Compare commits

..

1 Commits

Author SHA1 Message Date
bherbruck 18a734d029 Add a trademark policy for the SolidCraft name and logo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:19:36 -04:00
1026 changed files with 8665 additions and 229298 deletions
+6 -113
View File
@@ -15,9 +15,7 @@
# nightly. The reference tests and the doc tests run in the merge queue, on main and nightly, not
# on pull requests (the fast lane, in Select checks); a break there is fixed forward on main. The
# Windows jobs (D3D12 shaders and app startup) run nightly, and in the merge queue only for a group
# that changes what they exercise. The wasm compile check runs in the merge queue for a group that
# touches the crates below the apps, the web app or the manifests. Dependency advisories run nightly
# and on any pull request or merge group that changes Cargo.lock or a Cargo.toml. Only main saves the build caches; pull requests restore them.
# that changes what they exercise. Only main saves the build caches; pull requests restore them.
# sccache is on a Blacksmith sticky disk that only main commits.
# A push to main runs only the repository checks when a passing merge-queue run tested that very
# commit, or when nothing a build reads changed since the last push to main whose run passed.
@@ -80,13 +78,8 @@ env:
jobs:
dependency-advisories:
# Nightly and by hand: advisories, bans and sources for everything. A pull request or merge
# group that changes Cargo.lock or a Cargo.toml (Select checks' `deps`) checks the advisories
# only, so a dependency with a known vulnerability is caught before it lands, not the next
# morning; other pull requests skip it. `ci` requires it whenever `deps` is true.
name: Dependency advisories
needs: changes
if: ${{ !cancelled() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || needs.changes.outputs.deps == 'true') }}
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 20
steps:
@@ -95,12 +88,7 @@ jobs:
- name: Install advisory scanner
run: cargo install cargo-deny --version 0.20.2 --locked
- name: Check locked dependencies
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
run: cargo deny --locked check --warn unmaintained advisories bans sources
# deny.toml denies unmaintained crates; like the nightly, they are reported, not fatal.
- name: Check advisories for the changed dependencies
if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch'
run: cargo deny --locked check --warn unmaintained advisories
changes:
name: Select checks
@@ -127,10 +115,6 @@ jobs:
packages: ${{ steps.paths.outputs.packages }}
oracle: ${{ steps.paths.outputs.oracle }}
doctests: ${{ steps.paths.outputs.doctests }}
# Cargo.lock or a Cargo.toml changed (pull requests and merge groups): Dependency advisories.
deps: ${{ steps.paths.outputs.deps }}
# A merge group reaches the wasm build: Compile WebAssembly.
wasm: ${{ steps.paths.outputs.wasm }}
# The passing run a merge of main carries forward (its URL), when this push is only that.
carried: ${{ steps.paths.outputs.carried }}
steps:
@@ -159,8 +143,6 @@ jobs:
goldens=false
windows=false
gpu_paths=false
deps=false
wasm=false
full=true
packages=
# Whether the NUL-separated paths in $1 reach any code: scripts/ci/affected.py selects
@@ -201,21 +183,6 @@ jobs:
esac
done < "$1"
}
# Whether the NUL-separated paths in $1 change the dependencies (Cargo.lock or any
# Cargo.toml: deps), and whether they reach what `cargo xtask wasm` compiles (wasm): the
# crates below the apps, the web app, the vendored crates, the manifests and lockfile, the
# toolchain and cargo config, and this workflow. #8227 put a native-only call in
# crates/ui-egui and the merge queue passed it, because nothing built for wasm there.
deps_wasm_paths() {
while IFS= read -r -d '' path; do
case "$path" in
Cargo.lock|Cargo.toml|*/Cargo.toml) deps=true ;;
esac
case "$path" in
crates/*|apps/solidcraft-web/*|vendor/*|Cargo.lock|Cargo.toml|rust-toolchain*|.cargo/*|.github/workflows/ci.yml) wasm=true ;;
esac
done < "$1"
}
# Opt-in for a big render change: a pull request labelled render-tests, or a merge group
# holding one (its pull requests are the (#N) of its squash commits), runs them whatever
# the paths say.
@@ -261,17 +228,14 @@ jobs:
if git diff --no-renames --name-only -z "$GROUP_BASE_SHA...$GROUP_HEAD_SHA" > "$RUNNER_TEMP/ci-paths"; then
windows_paths "$RUNNER_TEMP/ci-paths" "$GROUP_BASE_SHA...$GROUP_HEAD_SHA"
gpu_paths "$RUNNER_TEMP/ci-paths"
deps_wasm_paths "$RUNNER_TEMP/ci-paths"
if [ "$gpu_paths" = false ] && group_has_render_label "$GROUP_BASE_SHA..$GROUP_HEAD_SHA"; then
gpu_paths=true
fi
else
windows=true
gpu_paths=true
deps=true
wasm=true
fi
echo "windows=$windows gpu_paths=$gpu_paths deps=$deps wasm=$wasm for $GROUP_BASE_SHA...$GROUP_HEAD_SHA"
echo "windows=$windows gpu_paths=$gpu_paths for $GROUP_BASE_SHA...$GROUP_HEAD_SHA"
elif [ "$EVENT_NAME" != pull_request ]; then
# The nightly run and Run workflow: everything, Windows included.
native=true
@@ -292,10 +256,6 @@ jobs:
done < "$RUNNER_TEMP/ci-paths"
# Computed as for a merge group, then turned off below (the fast lane).
windows_paths "$RUNNER_TEMP/ci-paths" "$BASE_SHA...$HEAD_SHA"
# The advisories run on the pull request itself (it builds nothing of ours: about two minutes, most of it installing cargo-deny);
# the wasm compile waits for the merge queue.
deps_wasm_paths "$RUNNER_TEMP/ci-paths"
wasm=false
# Pull requests build and test the touched packages and their dependents; nothing
# selected means no code changed.
select_packages "$RUNNER_TEMP/ci-paths"
@@ -367,8 +327,6 @@ jobs:
echo "windows=$windows"
echo "oracle=$oracle"
echo "doctests=$doctests"
echo "deps=$deps"
echo "wasm=$wasm"
echo "carried=$carried"
echo "full=$full"
echo "quick=$quick"
@@ -406,9 +364,6 @@ jobs:
cargo test -p xtask ui_containers --locked
- name: Sorted lists
run: cargo xtask lists
# New UI text must reach every complete catalog; report it here, before review (#8542).
- name: Translations in the complete catalogs
run: cargo xtask i18n --check
- name: Agent router and skills cite real files
run: |
cargo xtask skills
@@ -988,7 +943,7 @@ jobs:
# commit's real run instead: the merge queue reads `ci` from the newest run of a commit, so a
# run without a passing `ci` would block a pull request that passed (#1850, #3182, #3252).
name: ci
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup, dependency-advisories, wasm]
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
@@ -1042,10 +997,6 @@ jobs:
ORACLE: ${{ needs.oracle.result }}
WINDOWS_FXC: ${{ needs.windows-fxc.result }}
WINDOWS_STARTUP: ${{ needs.windows-startup.result }}
DEPS_RUN: ${{ needs.changes.outputs.deps }}
ADVISORIES: ${{ needs.dependency-advisories.result }}
WASM_RUN: ${{ needs.changes.outputs.wasm }}
WASM: ${{ needs.wasm.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
@@ -1075,14 +1026,6 @@ jobs:
test "$WINDOWS_FXC" = success
test "$WINDOWS_STARTUP" = success
fi
# Cargo.lock or a Cargo.toml changed on a pull request or in a merge group.
if [ "$DEPS_RUN" = true ]; then
test "$ADVISORIES" = success
fi
# A merge group that reaches the wasm build.
if [ "$WASM_RUN" = true ]; then
test "$WASM" = success
fi
- name: Note a carried result
if: needs.changes.outputs.carried != ''
env:
@@ -1090,13 +1033,9 @@ jobs:
run: |
echo "This push only merges main into a change that already passed; the heavy checks were not repeated. Passing run: $CARRIED" >> "$GITHUB_STEP_SUMMARY"
# Every crate below the apps and the web app must build for the browser (`cargo xtask wasm`, a
# cargo check, no tests). In the merge queue for a group that reaches the wasm build (Select
# checks' `wasm`), where `ci` requires it; by hand with cross_compile.
wasm:
name: Compile WebAssembly
needs: changes
if: ${{ !cancelled() && ((github.event_name == 'workflow_dispatch' && inputs.cross_compile) || needs.changes.outputs.wasm == 'true') }}
name: Compile WebAssembly (manual)
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
@@ -1157,49 +1096,3 @@ jobs:
. "$HOME/.cargo/env"
export CRAFT_FONTS_DIR="$PWD/craft-fonts"
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
# Nobody watches the nightly run, and it failed every day from 2026-10-16 to 2026-10-28 before
# anyone noticed. When a nightly fails, open one issue, "Nightly CI is failing" (label `ci`), or
# comment on it if it is already open; when a nightly passes, close it. A nightly that neither
# failed nor fully passed (cancelled) leaves the issue as it is. The only job that may write issues.
nightly-alert:
name: Nightly alert
needs: [dependency-advisories, changes, checks, ci, build-tests, action-matrix, action-matrix-determinism, property-tests]
if: always() && github.event_name == 'schedule'
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 5
permissions:
issues: write
steps:
- name: Open, update or close the nightly issue
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RESULTS: ${{ toJSON(needs) }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
SHA: ${{ github.sha }}
run: |
title="Nightly CI is failing"
failed=$(jq -r 'to_entries[] | select(.value.result == "failure") | .key' <<<"$RESULTS")
unfinished=$(jq -r 'to_entries[] | select(.value.result != "success" and .value.result != "failure") | "\(.key) (\(.value.result))"' <<<"$RESULTS")
issue=$(gh issue list --state open --label ci --limit 100 --json number,title \
--jq "[.[] | select(.title == \"$title\")] | first | .number // empty")
if [ -n "$failed" ]; then
body="The nightly run on ${SHA:0:9} failed: $RUN_URL
Failed jobs: $(paste -sd , - <<<"$failed" | sed 's/,/, /g')."
if [ -n "$issue" ]; then
gh issue comment "$issue" --body "$body"
else
gh issue create --title "$title" --label ci --body "$body
This issue is opened and updated by the nightly run (the nightly-alert job in .github/workflows/ci.yml) and closed by the first nightly that passes."
fi
elif [ -z "$unfinished" ]; then
if [ -n "$issue" ]; then
gh issue close "$issue" --comment "The nightly run on ${SHA:0:9} passed: $RUN_URL"
fi
echo "The nightly passed."
else
echo "Nothing failed, but not every job finished: $unfinished. The issue is left as it is."
fi
-1
View File
@@ -52,7 +52,6 @@ jobs:
# effort: a fork PR just doesn't get the `changes-default` label from here.
label:
name: Label changes-default
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
timeout-minutes: 5
continue-on-error: true
+2 -42
View File
@@ -10,7 +10,6 @@
# (see the windows job). Nothing else is signed yet.
#
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
# Arch Linux x86_64, aarch64 .pkg.tar.zst, repackaged from the .tar.gz the arch job
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
# macOS universal .dmg, and the CLI as a .zip (both archs built in parallel) packaging/macos
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
@@ -207,45 +206,6 @@ jobs:
path: dist/release/*.flatpak
if-no-files-found: error
arch:
name: Arch Linux ${{ matrix.arch }}
needs: [version, linux]
if: needs.version.outputs.build == 'true'
# makepkg only repackages the tarball's prebuilt binaries, so both architectures build here.
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
arch: [x86_64, aarch64]
env:
VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
# The Craft apps' shared Arch packaging (storytold/craft-common-actions, MIT OR Apache-2.0):
# makepkg in an archlinux:base-devel container. The package is named after the tarball's
# .desktop file (io.tangencylabs.solidcraft) and written as
# dist/release/solidcraft-<version>-linux-<arch>.pkg.tar.zst.
- uses: storytold/craft-common-actions/actions/arch-package@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 # main
with:
tarball: dist/linux/solidcraft-${{ env.VERSION }}-linux-${{ matrix.arch }}.tar.gz
arch: ${{ matrix.arch }}
- name: Check the package
env:
PKG: dist/release/solidcraft-${{ env.VERSION }}-linux-${{ matrix.arch }}.pkg.tar.zst
run: |
tar --zstd -tf "$PKG" | grep -E '^usr/bin/solidcraft$|^usr/bin/solidcraft-cli$|^usr/share/applications/io\.tangencylabs\.solidcraft\.desktop$'
tar --zstd -xOf "$PKG" .PKGINFO | grep -E '^(pkgname|pkgver|arch) = '
tar --zstd -xOf "$PKG" .PKGINFO | grep -qx 'pkgname = io.tangencylabs.solidcraft'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-arch-${{ matrix.arch }}
path: dist/release/*.pkg.tar.zst
if-no-files-found: error
snap:
@@ -611,7 +571,7 @@ jobs:
release:
name: GitHub Release or dry-run artifacts
needs: [version, linux, flatpak, arch, snap, freebsd, macos, windows, web]
needs: [version, linux, flatpak, snap, freebsd, macos, windows, web]
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest
permissions:
@@ -637,7 +597,7 @@ jobs:
shopt -s nullglob
for f in "solidcraft-$VERSION-"*; do
case "$f" in
*.msi | *-portable.zip | *.dmg | *.AppImage | *.deb | *.rpm | *.flatpak | *.snap | *.pkg.tar.zst)
*.msi | *-portable.zip | *.dmg | *.AppImage | *.deb | *.rpm | *.flatpak | *.snap)
cp -- "$f" "solidcraft-${f#"solidcraft-$VERSION-"}" ;;
esac
done
-38
View File
@@ -84,8 +84,6 @@ Editable drawing code, native projects and shipped exports are collected under `
| docs/book/src/generated/tool-preview.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/tool-preview.json`; gitignored) | MIT OR Apache-2.0 |
| assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
| assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in assets/fonts/biz-udp-gothic/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
| assets/fonts/craft-ui-sc/CraftUI-SC.otf | Adobe / Google Inc. (Noto Sans SC); SolidCraft subset | https://github.com/notofonts/noto-cjk Sans/SubsetOTF/SC Regular, glyph-subset for assets/locales/zh.json and renamed Craft UI SC (OFL reserved-name); licence text in assets/fonts/craft-ui-sc/OFL.txt; web UI Simplified Chinese | SIL OFL 1.1 |
| assets/fonts/craft-ui-tc/CraftUI-TC.otf | Adobe / Google Inc. (Noto Sans TC); SolidCraft subset | https://github.com/notofonts/noto-cjk Sans/SubsetOTF/TC Regular, glyph-subset for assets/locales/zh-TW.json and renamed Craft UI TC (OFL reserved-name); licence text in assets/fonts/craft-ui-tc/OFL.txt; web UI Traditional Chinese | SIL OFL 1.1 |
| tests/step-conformance/cone_point.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cone_point_vertex.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
@@ -127,8 +125,6 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/appearances/opaque_finishes.rs | SolidCraft contributors | original illustrative opaque finish values; no external dataset, textures or measured engineering data | MIT OR Apache-2.0 |
| assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/craft-ui-sc/OFL.txt | Adobe / Google Inc. (Noto CJK); SolidCraft header | Accompanying OFL for CraftUI-SC.otf | SIL OFL 1.1 |
| assets/fonts/craft-ui-tc/OFL.txt | Adobe / Google Inc. (Noto CJK); SolidCraft header | Accompanying OFL for CraftUI-TC.otf | SIL OFL 1.1 |
| assets/fonts/ibm-plex-mono/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/ibm-plex-sans/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
@@ -601,39 +597,6 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
| assets/styles/ui/themes/1-bit-dark.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
| `assets/locales/pending/navbar-customisation.json` | SolidCraft contributors | Original navigation bar editor translations | MIT OR Apache-2.0 |
| `assets/locales/pending/navbar-slots.json` | SolidCraft contributors | Original navigation slot translations | MIT OR Apache-2.0 |
| assets/locales/bs.json | Tarik Sijercic (tsijercic1) | Original Bosnian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/hr.json | Tarik Sijercic (tsijercic1) | Original Croatian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/sr.json | Tarik Sijercic (tsijercic1) | Original Serbian (ekavian, Cyrillic) translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/finish_edit.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| `assets/locales/pending/sketch-tracking-guides.json` | SolidCraft contributors | Original alignment-guide preference labels and help text | MIT OR Apache-2.0 |
| assets/locales/pending/point-clouds.json | SolidCraft contributors | Original point-cloud UI strings and translations | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/feature_group.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/mesh_body.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/timeline_play.svg | SolidCraft contributors | Original history playback icon; canonical grey_cube base and original action cue | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/timeline_stop.svg | SolidCraft contributors | Original history playback icon; canonical grey_cube base and original action cue | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/status_pin.svg | SolidCraft contributors | Original analytic pin icon for persistent viewport results | MIT OR Apache-2.0 |
| assets/locales/pending/render-wear.json | SolidCraft contributors | Original UI labels and translations | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/equation_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/locales/pending/sketch-recent-snap.json | SolidCraft contributors | Original preference messages for the recent centre and midpoint snap | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_horizontal.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_vertical.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_angle.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_parallel.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_perpendicular.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_tangent_external.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_tangent_at.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_tangent_two.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_bisector.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-circle.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-circle.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-flower.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-flower.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-involute.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-involute.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-line.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-line.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-parabola.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-parabola.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-spiral.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-spiral.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-wave.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-wave.json`; gitignored) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/print_face.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/locales/fi.json | SolidCraft contributors | Original Finnish translation of SolidCraft's English catalog, AI-assisted (Claude Code) clean-room from the English and not yet reviewed line by line by a native speaker; no commercial CAD text used | MIT OR Apache-2.0 |
## Code adapted from sibling projects
@@ -693,7 +656,6 @@ copied into SolidCraft.
| Directory | Source | Licence | Changes |
|---|---|---|---|
| vendor/accesskit_macos | accesskit_macos 0.26.3, https://github.com/AccessKit/accesskit (The AccessKit contributors) | MIT OR Apache-2.0 | the subclassing adapter and its tests from 0.27.2: dropping the adapter keeps AppKit's key-value observers (vendor/README.md) |
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on; reuse of an operand's meshes (vendor/README.md) |
+3 -13
View File
@@ -117,8 +117,7 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
not.) Permissive
dependencies are fine. Note which one you're adding and why in the PR. THIRD-PARTY-LICENSES.txt
lists it once the change lands: the Generated files workflow rewrites it on `main`.
Nightly/manual CI also scans advisories, bans and sources with cargo-deny, and a PR that
changes Cargo.lock or a Cargo.toml checks the advisories; see
Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see
[the local command and coverage limits](docs/book/src/dev/testing.md#dependency-advisories).
3. **Every asset is original or openly licensed and has a row in
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
@@ -269,9 +268,7 @@ a conflict. `crates/doc/src/format/pending/README.md` has an example.
**At release time** the maintainer runs `scripts/fold-format-pending.sh`, which moves the pending
files into `crates/doc/src/format/v<N>/` and fixes that number for good (see
[docs/releasing.md](docs/releasing.md)). Never add a file to a `v<N>/` folder afterwards. Drawings
follow the same rules with their own numbers, from 1, in `crates/doc/src/drawing/format/` (see its
`pending/README.md`), and the same script folds them.
[docs/releasing.md](docs/releasing.md)). Never add a file to a `v<N>/` folder afterwards.
## Lists many changes add to
@@ -279,11 +276,6 @@ Some files gain an item in many pull requests at once. They are kept so that two
each add an item only meet when their items are neighbours, which spares everyone a conflict
and a fresh CI run:
- **Growing Rust lists**: use slices (`const ITEMS: &[Item] = &[...];`) without a written
length. `cargo xtask lists` rejects non-test const/static arrays with more than ten lines
of entries, except fixed standard or algorithm tables listed with reasons in `xtask/src/lists.rs`.
Built-in themes need only a JSON file in `assets/styles/ui/themes/`, with a unique nonnegative
integer `order` (the picker order) and its own `provenance` line; keep existing order values.
- **Message catalogs** (`assets/locales/*.json`): one message per line, sorted by id. Add your
message anywhere and run `cargo xtask lists --fix`; `cargo xtask lists` (in `cargo xtask ci`
and the Lists workflow) fails on an unsorted catalog or an id that appears twice.
@@ -306,9 +298,7 @@ and a fresh CI run:
- **Design format**: one file per change, never an edit to a shared number (above).
- **New UI strings and attribution rows**: one new file per change, `assets/locales/pending/<slug>.json`
and `attribution/pending/<slug>.md`, instead of editing the catalogs or the ATTRIBUTION.md table;
`cargo xtask fold-pending` folds them on `main` (see the README in each folder). A new English
message also needs every catalog delivered complete (`COMPLETE` in `crates/ui-egui/src/i18n.rs`:
today ar, he, ja, vi, and zh) in the same pending file; `cargo xtask i18n --check` reports any gap.
`cargo xtask fold-pending` folds them on `main` (see the README in each folder).
## Sending a change
Generated
+30 -35
View File
@@ -60,6 +60,8 @@ dependencies = [
[[package]]
name = "accesskit_macos"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
dependencies = [
"accesskit",
"accesskit_consumer 0.38.0",
@@ -1690,11 +1692,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@@ -3771,9 +3771,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.16"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
@@ -4208,7 +4208,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4233,23 +4233,20 @@ dependencies = [
"serde_json",
"solidcraft-3dx-client",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-space-mouse",
"solidcraft-ui-egui",
"ureq",
"windows-sys 0.61.2",
"winit",
"x11rb",
"zbus 5.19.0",
]
[[package]]
name = "solidcraft-3dx-client"
version = "0.28.0"
version = "0.25.1"
[[package]]
name = "solidcraft-cli"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"image",
"log",
@@ -4261,7 +4258,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"proc-macro2",
"quote",
@@ -4270,7 +4267,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"log",
"serde",
@@ -4287,7 +4284,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"image",
"log",
@@ -4308,7 +4305,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"serde",
"serde_json",
@@ -4317,7 +4314,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"image",
"log",
@@ -4337,7 +4334,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4361,7 +4358,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"log",
"proptest",
@@ -4381,7 +4378,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4390,7 +4387,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"image",
"moxcms",
@@ -4404,27 +4401,25 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"harfrust",
"serde",
"serde_json",
"solidcraft-geom",
"thiserror 2.0.21",
"ttf-parser",
"unicode-bidi",
]
[[package]]
name = "solidcraft-space-mouse"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"arboard",
"egui",
@@ -4454,7 +4449,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"eframe",
"egui",
@@ -4565,7 +4560,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
@@ -4923,9 +4918,9 @@ dependencies = [
[[package]]
name = "typed-path"
version = "0.13.0"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typeid"
@@ -5362,9 +5357,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
version = "1.0.10"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
@@ -5845,6 +5840,8 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
dependencies = [
"ahash",
"android-activity",
@@ -5997,7 +5994,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.28.0"
version = "0.25.1"
dependencies = [
"proc-macro2",
"serde",
@@ -6295,18 +6292,16 @@ dependencies = [
[[package]]
name = "zip"
version = "9.1.3"
version = "9.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
dependencies = [
"crc32fast",
"flate2",
"getrandom 0.4.3",
"indexmap",
"memchr",
"nix 0.31.3",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
+2 -9
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.28.0"
version = "0.25.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -80,7 +80,7 @@ cadmpeg-ir = "=0.6.3"
cadmpeg-registry = { version = "=0.6.3", default-features = false, features = ["fcstd", "rhino", "step"] }
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
ttf-parser = { version = "0.25", default-features = false, features = ["std", "variable-fonts"] }
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
# Ends a process group with the system call (MIT; already in the tree through zip).
nix = { version = "0.31", default-features = false, features = ["signal"] }
@@ -161,13 +161,6 @@ matext4cgmath = { path = "vendor/matext4cgmath" }
# Keep OpenGL pipelines, resolve textures and CAD callbacks at the same safe sample count.
egui-wgpu = { path = "vendor/egui-wgpu" }
# Deliver trackpad pinch and rotation gestures on Wayland (zwp_pointer_gestures_v1), which
# winit 0.30 never binds and eframe 0.36 still requires winit 0.30.
winit = { path = "vendor/winit" }
# macOS: dropping the accessibility adapter leaves AppKit's key-value observers intact (#7535).
accesskit_macos = { path = "vendor/accesskit_macos" }
# Mutation testing (cargo-mutants, docs/mutation-testing.md): the ci profile with incremental
# builds, since every mutant rebuilds the crates above the one it changes.
[profile.mutants]
+8 -8
View File
@@ -42,8 +42,8 @@
</p>
<p align="center">
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily" alt="tangency-labs%2Fsolidcraft | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily?language=Rust" alt="tangency-labs%2Fsolidcraft | Trendshift (Rust)" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily" alt="tangency-labs%2Fsolidcraft | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily?language=Rust" alt="tangency-labs%2Fsolidcraft | Trendshift (Rust)" width="250" height="55"/></a>
</p>
![SolidCraft: model a Raspberry Pi 4 case from an empty design: a sketch dimensioned with parameters, extrude, fillets and shell, a rim rounded in one pick, standoffs patterned on the board's mounting holes, port cut-outs, vent slots, "Pi 4" engraved in a font, a PETG material with its mass in Properties, and a height change in Parameters](docs/screenshots/demo.gif)
@@ -58,9 +58,9 @@
- **Web:** [try it in your browser][web-app], no install.
ARM64 and 32-bit builds, portable zip, .deb, .rpm, Flatpak and the other packages are on the
[downloads page](docs/downloads.md). The macOS app is signed with a Developer ID and notarized by
Apple. The Windows downloads aren't signed yet: choose **More info**, then **Run anyway** in
SmartScreen. On SolidCraft 0.11 to 0.14, download once by hand; see
[downloads page](docs/downloads.md). The downloads aren't signed yet: on Windows choose
**More info**, then **Run anyway** in SmartScreen; on macOS right-click SolidCraft and choose **Open**
the first time. On SolidCraft 0.11 to 0.14, download once by hand; see
[updating](https://docs.solidcraft.io/guide/updates.html#updating-from-011-to-014).
## What works
@@ -98,7 +98,7 @@ More commands, the Windows cross-build and the browser build are in [docs/buildi
runs its own design session, or with `--connect` it drives the running app so you can watch.
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. If a terminal doesn't find
`solidcraft-cli` (older Windows installers don't put it on `PATH`), give its full path (by default
`"C:\\Program Files\\SolidCraft\\solidcraft-cli.exe"`). Tools, access rules and an example session:
`"C:\Program Files\SolidCraft\solidcraft-cli.exe"`). Tools, access rules and an example session:
[docs/mcp.md](docs/mcp.md).
## Scripts
@@ -131,7 +131,8 @@ them: read [DISCLAIMER.md](DISCLAIMER.md).
MIT OR Apache-2.0, at your option. Developed under the
[clean-room contribution rules](AGENTS.md); third-party code and assets retain their
documented licences and attribution.
documented licences and attribution. The licence covers the code, not the name and logo: see
[TRADEMARKS.md](TRADEMARKS.md) for how to name a fork or a product built on SolidCraft.
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
conventions and user feedback. Reference tests use analytic values and open-source tools.
@@ -150,4 +151,3 @@ Tinkercad are trademarks of their respective owners, named only to describe comp
[linux-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
[snap-store]: https://snapcraft.io/solidcraft
[web-app]: https://solidcraft.io/app
-694
View File
@@ -80,697 +80,3 @@ permanently.
- Discord: <https://discord.gg/FtU4NQNVDH>
- Releases: <https://github.com/tangency-labs/solidcraft/releases>
- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
Status as of **2026-10-14**. SolidCraft supports parametric modelling (sketch →
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
## At a glance
| Question | Answer |
|---|---|
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; FreeCAD/Rhino import; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
| Reference tests | [Current case count and results](docs/oracle.md): public cases in `tests/oracle/` with analytic or FreeCAD-computed expected values, checked by the reference-test job in [CI](.github/workflows/ci.yml). |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
Optional GPU viewport ambient occlusion is available in View settings and Graphics preferences,
with persistent settings, Performance/Quality graphics presets (Quality combines AO, Fine tessellation and adaptive 2× supersampling; manual combinations show Custom), and the
`view.ambient_occlusion` and `view.graphics_preset` commands. See [display settings](docs/ambient-occlusion.md).
## Scope (owner decision, 2026-10-07)
The issue batch adds preservation of blind pockets in through cuts, associative projected
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
acceptance scenarios now include board resizes; STEP reopening is automated. See [WIP reconciliation](docs/wip-reconciliation.md).
Face-based Extrude Join (including outward Press Pull) now defaults to the picked body,
so edge-touching assembly neighbours stay separate regardless of body order (#226).
In scope, in priority order:
1. SOLID and SKETCH (now).
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
cycles reported with their path, rename follows references, deletes list their users):
user parameters with units and expressions (+-*/, ^,
parentheses, sin/cos/sqrt/min/max/floor/ceil/round, unit conversion), model parameters
d1, d2… for every dimension and feature input, references between parameters with a
dependency graph and cycle detection, re-evaluation on change, comments, favourites,
CSV/JSON import and export.
3. Components and assemblies with joints (M9).
4. SHEET METAL: flange, contour flange, bend, unfold/refold, flat pattern with DXF export, sheet
metal rules (thickness, K-factor, bend radius) (M10).
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need (scope proposed in #772), PCB / 3D
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Milestones
| # | Milestone | Status | Hours left |
|---|---|---|---|
| M0 | Vertical slice | done | — |
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, Shift-picked line midpoints in Horizontal/Vertical using persisted points and ordinary constraints (#2087), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces, at distances from reference edges, and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, Shift-picked line midpoints in Horizontal/Vertical using persisted points and ordinary constraints (#2087), drawing snaps to where two lines, circles or arcs cross (#4967), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks; tiny rim edges remain selected instead of nearby wall seams when filleting (#2498) | in progress | 30 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop, browser and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import, PLY/XYZ point clouds as a modelling reference and PLY meshes (done — [docs/point-clouds.md](docs/point-clouds.md), #5151) | in progress | 6 |
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; responsive feature history with width-driven icons/names/title, snapping width resize, bottom/left/browser-stack docking, shared-column collapse, rollback bar, multi-selection and persistent folders), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator, extrude taper), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; Browser Expand All / Collapse All (`browser.expand_all`, `browser.collapse_all`) on the design and component menus; preferences | in progress | 6 |
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane, a face or sketch curves swept onto the surface, keeping a side or the picked regions; mutual trim of two surfaces, extended to meet and stitched, #767), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764: edge chains along one or several faces, closed rims paired seam to seam or from picked seams, a dialog), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown, and guide rails on solid and surface lofts (one or two, scaling or stretching the sections between; #201). Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), and every remaining #757–#772 item | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements, and exported as an animation coming apart and back together); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
| M15 | Frames and weldments (#4003): structural members along 2D and 3D sketch lines, butt and mitre corners, trim to faces and bodies, end caps, gussets, cosmetic welds and a cut list. Done: the profile library (`frame.profile.list`, `frame.profile.properties`, `frame.profile.insert`): bar, round bar, rectangular and round tube, angle, channel and I-beam, each SolidCraft's own parametric definition with exact fillet arcs and exact section properties (area, centroid, second moments), tested against hand-computed values; members (`frame.member`): one body per straight line of a 2D or 3D frame sketch, following parameter edits, with a built-in profile (its dimensions parameter expressions) or a profile sketch (its drawn material by alternating nested loops), anchor, angle and mirror; each member's name kept with its line. Left: corners, trims, end caps, gussets, welds, cut list, FRAME tab and dialogs | in progress | 60 |
M6 navigation preferences include **Touchpad**: Shift + pointer movement pans, Alt/Option +
pointer movement orbits without a mouse button, and vertical scrolling zooms. Other presets
retain their saved two-finger scroll preference; camera motion is available through
`view.navigate` and modifier-aware `ui.move` automation.
With the **Fusion** preset and two-finger pan enabled, two fingers pan and Shift + two fingers
orbit freely, with pinch zoom and mouse bindings unchanged. The shared navigation handler
applies the same gestures to model and Render views and blocks orbit on flat pages.
UI redesign (#2016): the File/start view (#2027) uses shared light/dark navigation,
action tiles, recent rows and sample cards, with recovery review and Save As/Export
entry points. Document loading, sample caches and recovery safety retain their
existing command paths.
M11 precursor: File → Export Current View as DXF exports shown placed bodies and sketches from the current
camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits
partly hidden edges and includes curved silhouettes. Repeated part meshes and triangle
hierarchies are shared; bounded desktop preparation and occlusion use up to eight workers.
Curves are approximated; drawing sheets,
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
Drawing foundations: [RFC #860](https://github.com/tangency-labs/solidcraft/issues/860)
and the [foundation ledger](docs/drawing-foundations.md) track the bounded implementation
packages. The first geometry increment defines validated section records in a plane-local
frame; the ledger separates that contract from section producers and the Drawing workspace.
Desktop view DXF export runs in the file-operation worker with five-stage progress and
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
completed drawings publish atomically. The browser still exports synchronously.
## Interactive modelling
- Modelled threads preview their cut before OK using the background worker on desktop.
Browser users apply the cut with OK to avoid blocking on every edit.
- Thread resolves a suggested metric size when a cylindrical face is picked. Diameter and
pitch selectors expose the existing metric designation parameter; custom designations stay
editable, and Automatic size returns to the face's suggestion. Cosmetic/modelled and
full/partial-length options remain available. This does not add tolerance classes or
left-hand threads.
- New Body extrusion and face-offset previews show completed intermediate geometry while a
drag continues, then catch up to the exact final value. Changes to the selected geometry,
operation or feature still discard obsolete results. Distance arrows and their value boxes
follow the current frame's input.
- Large fine modelled threads use pitch-based helix resolution and tighter faceted
reconstruction fallbacks, preserving thin crests and closed shells on large shafts.
## Physical material data
- Physical material card validation (#923/#922) provides typed SI mechanical and
thermal data, per-property sources/conditions and isotropic/orthotropic stability
checks through read-only `material.properties.validate`. It does not assign or
persist properties, provide a solver or enable rendering; see
[the contract and staged follow-ups](docs/physical-material-cards.md).
- Directional elasticity (#994) queries the validated card in material-local axes
through read-only `material.properties.directional`, with source/condition review.
It does not assign body frames, predict structural behavior or enable a solver.
## Library foundations (#785, #786, #787)
- Local scalar libraries (#846) inspect/export bounded portable catalogs and copy exact
selected appearance/material revisions into existing design definitions in one undo step.
Appearance offers local loading/search/export; physical materials use existing commands.
This slice is independent of the render stack and does not change the document format.
See [the contract and limits](docs/book/src/dev/local-libraries.md).
- Proposed phases cover offline bundled/project/user packs, physical materials and
appearances, editable wood mapping, and parameterized component/assembly families.
Stable identity/revision, copied design definitions, provenance and resource limits
apply from the first implementation. Existing material, appearance and standard-part
commands remain the application paths. See [the proposal](docs/book/src/dev/libraries.md)
for source/license review and acceptance gates. This is planning only: pack loading,
texture rendering and expanded component families are not implemented by this entry.
## Render foundation
- Original starter appearances (#1722): 34 opaque recipes through existing commands, with six explicitly unavailable glass recipes and preserved legacy looks; see [the contract](docs/render-starter-appearances.md).
- Bounded linear film accumulation (#1696) supplies caller-admitted mean/variance storage and complete-pass views; actual progressive consumers remain separate. See [the film contract](docs/render-film.md).
- Definition-local visual mapping (#1214) supplies a bounded numerical frame, physical repeat coordinates and a deterministic one-time grain-axis suggestion. It adds no saved record or texture consumer; see [the mapping contract](docs/render-visual-mapping.md).
- Explicit texture normalization (#1218) produces completed float RGBA with
role-aware linear color/data semantics, bounded matrix/TRC ICC conversion and
opt-in EXIF orientation. It retains high-precision raw samples and records lossy
gamut clipping only when explicitly requested. It is not a renderer/resource
consumer; see [the supported policy and limits](docs/render-texture-normalization.md).
- Opt-in opaque PBR (#1046) connects persisted surface parameters to the integrated
product viewport and CPU PNG, using a fixed direct-light studio. Geometry and mass
stay unchanged. HDRI, transmission and textures remain separate gates.
See [the consumer and limitations](docs/render-opaque-pbr.md).
- Combined integration is tracked in #842, with independent scalar libraries in
#863 and richer PBR/material-card transport in #1086. See [phase boundaries and validation gates](docs/render-integration.md).
Render/library consumers use the merged asynchronous picker (#1127). Material-card
UI (#1094), texture mapping and HDRI lighting remain explicit follow-ups.
- R4a adds bounded CPU product-image jobs, cancellation and atomic PNG export through
engine commands, the Render panel, CLI and persistent MCP. Immutable captures and
results stay outside persistence/undo; native workers and cooperative browser tasks
share the same work protocol. See [jobs, limits and export](docs/render-jobs.md).
- R3 adds an overlay-free product scene and the integrated Render workspace, with
runtime GPU host capabilities, per-document drafts and named camera/background
controls. Design keeps its camera and drawing policy. Raster transparency remains
approximate; PBR and environments are later slices. See
[workspace and scene contracts](docs/render-workspace.md).
- R2 adds bounded named render setups in document format 21 and the read/save
`render.settings.get/set` commands. Proven render-only changes preserve geometry
and prepared world data through undo/redo and checkpoint restore. This slice does
not add a Render workspace, PBR, environments or image jobs; see
[settings and refresh policy](docs/render-settings.md).
- Bounded PNG/JPEG texture admission (#900) validates in-memory sources and returns
completed RGBA8 pixels with explicit color/data roles. It is an input utility only:
ICC/16-bit normalization, local wood mapping, resource persistence and the renderer
consumer remain separate work. See [limits and licensed-file probes](docs/render-texture-input.md).
- The initial opaque surface numerical contract for #1004 provides bounded linear
color/metallic/roughness/IOR admission and an original tested direct-light BRDF.
The numerical module alone advertises no capabilities; the installed consumer above
provides rendering. See
[the model, evidence and remaining consumer gates](docs/render-surface.md).
- A bounded cooperative Radiance RGBE input utility is available in L1 render;
it does not yet enable HDRI/PBR rendering or a document import command. See
[the input contract](docs/render-hdr-input.md) and #813 for integration work.
- R1 adds renderer-independent backend/profile metadata, strict request resolution and
bounded image/scratch admission checks, exposed by the read-only `render.backends`
command. It describes the current CPU raster snapshot path without evaluating the model.
The legacy Design snapshot route remains unchanged. Later slices above provide the
workspace/jobs and opaque PBR; HDRI and optional engines remain future work. See [the contract](docs/render-backends.md)
and [the phased proposal (#620)](https://github.com/bherbruck/solidcraft/issues/620).
## Dimensioning and error reporting
- Operation length fields share design-unit defaults and input evaluation, including secondary
distances, thin walls, blend controls, sheet/plastic rules, joints and motion keys. Canvas
handles and sketch previews use the same lengths as committed geometry. Existing stored
millimetre values retain their size when edited in a design using another unit (#3271).
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
`batch`: body changes and created sketch IDs stay, while repeated lists are omitted.
Both tools retain full replies by default.
- Sketch Create groups drawing variants under Line, Rectangle, Circle, Arc, Slot,
Polygon and Spline. Each open part remembers its last interactive choice per family
across sketch sessions and document-tab switches; closing the part resets these UI choices.
- Inscribed, circumscribed and edge polygon tools preview their actual outline before
placement, including orientation and the selected side count.
- Construction (X) preserves the active sketch drawing tool, picked anchors and inline
dimension values, so line type can change while a shape is in progress.
- Planar sketch construction previews and hover feedback keep the same dashed pattern as
committed geometry; centerlines retain their dash-dot pattern.
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
arguments reject such a name when an older design's construction plane already has it. Use
the other alias, the construction feature's numeric ID or an explicit plane; existing
stored references keep their saved meaning. Section Analysis uses the same check.
- Sketch text can be edited from a letter interior or outline's context menu or the Edit Text command.
Interior selection requires every profile boundary to belong to the same text source;
the editor highlights the whole source without replacing profile selections.
Content, baseline X/Y, height and rotation update the original text source in one undo step;
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
use the document units captured when editing starts; generated outlines remain associated
with their text source, and untouched inputs preserve their saved internal values.
Dragging a text outline moves the entire source in one undo step; Escape restores the
gesture. Move/Copy on a selected text outline provides unit-aware X/Y distances and rotation
about its baseline; copies keep an independent editable text source.
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
waits for a distinct point without a degenerate-shape error or history entry. Typed input
and programmatic commands retain their validation.
- Circular-pattern and angled-plane axes reject explicitly supplied malformed or expression
origins instead of silently rotating about zero. Numeric origins remain in millimetres;
associative axis expressions and construction-axis references remain unsupported.
- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints.
Type a value and press Enter to commit one undo step; rejected values remain editable and
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
and navigation controls, including enlarged displays and nested pending placements.
- Sketch Dimension takes a line and a circle or arc (#1803): it measures from the line to the
circle's edge (the gap, 0 when tangent, or how far the edge reaches past a line that cuts
it), drives it like any other dimension and keeps the circle on its side of the line.
- Sketch Dimension takes a point and a circle or arc, or two circles or arcs (#4064): it
measures to the circle's edge (from the point, or between the two edges: the gap, 0 when
they touch, how far they overlap, or the narrowest gap between nested circles).
- Sketch annotations, live drawing lengths and Measure use document units. Inches show
three decimals and retain a meaningful fourth digit. Bare length dimension entry captures
the current design unit; changing units leaves existing geometry and expressions intact.
Component/canvas distance controls, sketch cursor coordinates, flat-pattern dimensions
and pocket depths, interference volumes and parts-library lengths also use design units.
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
consumes complete point/curve selections consistently; mixed or ambiguous selections
retain the picking workflow. Rejected constraints preserve selection and undo history.
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
useful reproduction context. The draft is editable, with optional capture of the actual
viewport and copy/save controls. Opening GitHub does not submit the issue. Help also exposes
reporting for unexpected results that did not produce an error.
- The header exposes **Report a bug**, including an accessible compact control on
narrow windows. It opens the same editable report and screenshot review without
changing the active model or a pending dimension.
- Crash reports: every panic (in a command, in the kernel, anywhere else) and every unclean
exit (abort, stack overflow, kill: found at the next start through a locked per-run command
journal) leaves a local report with the backtrace, version and commit, the command and the
design that reproduce it (`solidcraft-cli crashes replay <id>`). Repeats are counted, not
rewritten; at most 20 reports, 50 MB and 90 days. The app shows them after a crash (Help >
Crash Reports…) with a reviewed issue draft; agents read them through
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
anywhere. See [crash reports](docs/crash-reports.md).
- Agent chat (off by default, desktop): a side panel that drives a coding agent the user
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, Grok Build, …) over the
Agent Client Protocol, under the user's own login; SolidCraft stores no keys. The agent works
through SolidCraft's MCP tools on a gated endpoint of its own: file actions wait for Allow or
Deny, crash reports are off limits, 24 changes per message, one undo step per message, and
the agent's whole process group ends with the panel or the app. Docks left or right,
minimises to a strip. Grok Build discovery includes `~/.grok/bin` and reuses the user's
Grok login. See [agent chat](docs/agent-chat.md).
- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`)
writes a design's history as a script that `eval` and `run` replay into the same design —
user parameters, units, sheet metal and plastic rules and materials as their own commands,
then each feature through `timeline.add` with its ids, names and dimension parameters kept,
so every feature kind replays exactly and a failing feature fails the same way. Trimming the
script after a step gives the design at that step. Appearances, folders, views and
configurations are listed as left out; components and joints are not supported yet.
## 0.2.0 release batch
- Includes the new axis navigation, adaptive sketch drawing/picking, origin-corner repairs,
durable supported profile/face references, endpoint dimensioning and contextual reporting.
- Smooth ruled-wall display joins are hidden only within the supported geometric certificate;
authored splits and actual creases remain protected. Wireframe retains physical edges.
- Paste New uses a bounded current-geometry comparison for supported convex line/circle
prisms with separated circular holes; unsupported shapes retain the existing strict check.
- Common-bore Union retries check additional boundary curves before recutting the bore,
preventing the reproduced thin-web loss. Unknown additional trims refuse; inherited
common-cylinder trims retain their existing policy, so this is not a general Boolean proof.
- Exact full-path assembly joint editing remains separate work and is not part of this batch.
- Stable 0.2.0 is published: all release payload checksums, native cold reopening, deployed
web app and both Flatpak architectures were verified. The replacement README demo uses
the original electronics enclosure story and animated pointer in the actual English
0.2.0 UI, at 4× playback. Publication follows [the release workflow](docs/releasing.md).
## 0.2.1 critical fixes
- Separated multi-profile Join/Cut extrusions into one solid target batch their tools using
conservative construction bounds, avoiding a Boolean rebuild for every profile. Live
extrusion previews build only their tools; confirmation validates the final Boolean.
- Coplanar selected body faces share one linked sketch and one extrusion. Automatic Join/Cut
keeps the evaluator's participant scope, including parts of an empty imported component. Desktop OK/Enter
computes on a scratch session in the background, with atomic acceptance and cancellation.
Planar section stitching projects lines/arcs analytically and removes duplicate endpoints.
- Imported rotated face groups can check all full start-face seams in one guarded
construction. Separated tools may batch a Join when each meets the same single body;
nearby uninvolved bodies retain the original contact decisions. Regression comparisons
cover all four extrusion operations.
- Inward multi-face cuts retain every eligible target and subtract separated tools together.
Safe interior classification handles roundoff at shared imported line/arc endpoints.
Straight extrusion feedback caches existing face/profile triangles and updates the display
directly, including a newly opened STEP's single identity component placement; other
extents, moved or repeated components and curved sketch profiles keep the Engine preview.
- The extrusion fixes retain tracked Boolean history and the current `solidcraft` packages.
Confirmation and tool previews use main's shared background calculation. Main's collapsed
blend-wall refusal and its tests are retained, including smaller valid radii and mirrored parts.
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
the command palette open, at normal and enlarged display scales. Fit still accounts for
dialog coverage; covered controls do not receive clicks through dialogs.
- Eligible planar faces survive fillet, chamfer and shell operations with exact source
certificates. Sketches placed directly on the enclosure wall and inner floor follow
width and wall changes, including after the USB cut and cold reopening.
- Remove artificial straight-wall and rounded-corner subdivisions left by shell and
extrusion cuts. Identical generated wall segments span adjacent slabs as one physical
face, with exact full-height supports and boundary stitching. Preserve authored face
splits and face appearances. Permanent regressions cover material, STEP, reopening and
unaffected bodies across the extrusion end plane.
- Feature dialogs use the available screen height for their fields, with OK and Cancel
outside the scrolling region, including enlarged display scales.
- Design format 13 records shell-face attachments. Older designs remain readable;
newly saved designs require 0.2.1 or later. Ambiguous or unsupported faces still refuse
attachment rather than guessing a plane.
- Saved projections from a removed strip retain their geometry and report the lost
reference. Explicitly reselect the wall to repair that projection.
## Workspace layouts (#2029)
- General preferences save Floating or Compact using the universal UI containers.
Compact joins the workspace selector and ribbon, docks the content-hugging Browser
flush beneath them with a square join, and opens tool sheets below the compass.
Sheets remain draggable, rounding all corners away from their right dock; footer
backgrounds follow the same corners. A separate preference hides ribbon icon labels
for a shorter ribbon in either layout.
Assistant keeps the outermost edge and History keeps its chosen dock. Switching
retains the active tool, camera, selection and design; system windows and menus
keep their existing behavior.
## View navigation
- Navigation has an opt-in Free orbit preference for mouse and 3D-mouse rotation through
both poles with roll. It defaults off, preserving existing preset snapping and pole limits.
The SolidWorks face menu offers Normal to Selection (`view.normal_to`), shared with Look At
and Ctrl+8, including repeat-to-flip, preserving scale and projection.
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
its layout stable during a drag and applies the selected scale on release.
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
top/bottom heading are preserved. Diagonals include 45-degree edge and isometric corner views.
## Known limitations
- Scheduled and manual CI scan locked dependency advisories, bans and sources with
cargo-deny. Vulnerabilities fail the scan; unmaintained crates warn. Local vendored
patches require separate upstream review because advisory databases cannot version-match
their path dependencies. Pull requests retain the existing licence checks.
- Associative shell-face sketches require a unique certified planar source. Nested shell
attachments and ambiguous coincident sources remain unsupported.
- G2 fillets: exact quintic blends of straight edges between planar faces, with one planar
end face at each end, including sloped ends (#2708). The UI and `solid.fillet` expose
`curvature` (alias `g2`); the size is an equivalent circular setback. Curved faces/edges,
multi-face ends and corner junctions remain unsupported.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); perpendicular corner fillets after a closed ring of earlier
rounds preserve the ring's radii and the enclosed planar face (#1557);
mitres between separate rounds of the same radius keep their endpoint tangent, so a later
fillet can select an upright and its adjoining bottom mitre as one chain (#2422);
straight edges (convex or concave) between planar faces with perpendicular
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
connected equal-distance chamfers also trim each other across unequal sloped rim walls;
curved chamfer chains with straight planar ends can terminate across two existing planar
bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303);
whole smooth loops of a planar face, picked whole or by one edge (tangent
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
plate outlines), loops with sharp corners between straight or mixed arc/line edges (straight
blends meet in mitres; perpendicular curved walls use offset intersections, with D-rim removal
checked within 0.1% of analytic offset slices), runs of
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
with the edge between them too, the corner becomes a sphere octant),
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
convex planar body or of an extruded part; fillets inside patterns; closed chains of curved
edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the
blend a rational B-spline through exact arcs), closed or ending at planar faces square to the
edges, where straight or curved cap edges are trimmed to the contacts (the straight edge
between two overlapping extruded arcs); knife edges, where the two faces have opposite
normals (a wall that runs out tangent into a face: the ball that fits there); an edge that
runs out along an end face tangent to one side (the arc top edge of that wall), cut where the
blend meets it; rounds that end in an earlier round on curved faces, in any order (the rounds
of two edges at a vertex meet in a crease, as on a box), picked together or one after the
other, except a round after a round of the edge between the wall and a flat face that it
would run over; the three edges at a vertex of any smooth faces, of one radius, end in a
piece of sphere. A limiting blend that collapses a side wall into coincident boundary
curves is refused as too large rather than returning an unmeshable face (#1113).
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve
annular holes with overrun cutters, including inward Press Pull after a joined extrusion;
curved wall qualification rejects false transverse axes instead of filling holes.
Bodies touching along one whole face
(a part and its mirror image) are stitched; coincident planar faces are pushed apart when
their neighbours are planes or cylinders. Other coincident curved configurations can still
fail. Bounded containment classification preserves fully internal cavities and nested
material islands independently of shell order, with STEP round-trip and material-membership
regressions. Near-contact curved shells use the general boolean path rather than a
containment shortcut. Spheres are built from six pole-free patches.
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
- STEP import (our own reader, crates/kernel/src/step_in) reads solids (B-rep with analytic and B-spline geometry), units,
product names, colours and assemblies; analytic offsets on planes, cylinders, cones, spheres
and regular tori are read exactly (signed/nested distances, units and face sense). Freeform
offset surfaces and pcurve-only edges are not read yet,
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
- Persistent naming follows the kernel's exact face history (docs/book/src/dev/naming.md):
references resolve by name first, follow splits and merges, and warn when the entity split,
merged or vanished, or was found by geometry only (operations that report no history yet).
New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face
source certificates with checked target and author occurrence paths. A missing or changed
source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while
retaining current geometry and dimension identities. Unsupported modified faces need an
explicit fixed plane. Legacy attachments retain their original resolver behavior.
- Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude
and Revolve To Object on a curved face cut the tool back to the target's surface.
- Explicit sketch breaks preserve exact ellipses, fit/control splines (including high degrees)
and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
touch (no clean crossing) enter that profile as polylines.
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its
path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled
walls through denser stations where the path has corners or the profile has holes.
Guide rails (one or two, #771) do the same: each station meets every rail once, and the rail
turns the profile and scales it (or stretches it across to the rail) against the start; two
rails carry it between them. Rails along a path with sharp corners are refused for now.
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
certificate and refine within bounded node limits while retaining existing IDs.
At the node limit, geometric certificate subdivisions accommodate unequal spline parameter
speeds without changing stored nodes or relaxing the distance tolerance (#971).
Associated offset fit nodes stay hidden from display, picking and snapping; dragging the
editable source updates the derived boundary live. Break Association restores point editing.
Singular, self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth
closed natural-spline seams remain limitations. New explicitly closed fit splines and closed
offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older
natural splines are preserved. Clicking the starting fit point deliberately closes a new loop.
Newly captured adaptive profile references with complete source coverage retain their outer
and hole identities through span-count changes, including parameter edits after extrusion.
Partial, intersected or ambiguous boundaries do not qualify for these whole-source references.
Legacy curve-span multisets remain exact and unchanged; already-lost selections may need
reselection. Format 12 stores the new bounded whole-boundary references.
Projected analytic boundaries follow nested loop identities through resizing, including
stationary holes and rounded corners. Supported convex, tangent line-and-arc boundaries
can lose or regain corner arcs during an inset while preserving reserved curve identities
and linked profile selections. Format 12 saves those associations; compatible older closed
offsets upgrade structurally without changing their curve IDs. Breaking an association
freezes its current boundary and materializes downstream profile references. Child offsets
of a boundary with retired arcs still require breaking its association first; mixed or
nonconvex junctions and retained references to disappearing individual arcs remain limited.
- Extrude and Revolve editors retain original direction, extent expressions, explicit targets,
axes, From/To inputs and All-profile intent. No-op edits preserve downstream dimension names
and selected occurrence frames; cancel and successful edits preserve one-step undo history.
Placed feature edits preview through the edited timeline entry, and cylindrical Emboss guides
use the sketch's actual target, author or uniquely placed third-component frame.
Revolve rotators follow the actual signed axis and support symmetric and two-sided extents;
dragging a To Object extent converts its derived value to an explicit angle. Legacy reversed-axis
symmetric geometry is preserved through format 11's explicit legacy convention. New revolves
use the sketch-normal direction for each selected profile, including To Object and curved
target trimming, independently of the selected axis sign.
- Cylindrical emboss/deboss wraps bounded orthogonal line profiles, including holes, concave
outlines and nested islands, onto complete exterior cylinders or coaxial sleeves with exact
radial walls. New features infer a unique eligible cylinder when the face field is empty;
ambiguous targets require an explicit face. Saved target
and author occurrence paths retain the chosen placements; missing/split faces refuse rather
than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces
remain unsupported. STEP round-trip and independent radial/volume checks cover this subset.
- Shell and offset faces work on bodies of planes, cylinders, cones, spheres and tori (convex or not:
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
floor falling to a drain, as long as the corner's edges are all convex or all concave); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
sections ruled. Circular-meridian barrels retain curved inner walls through either axial
opening (#2421). Solid lofts preserve holes through sections with matching hole counts
(#2435), including inner walls and caps with openings; a hole cannot appear or disappear
between sections, and sections with holes cannot collapse to a point.
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
the origin; clicked UI regressions verify geometry, closed profiles and undo.
- Sketch display: drawing, picking, highlighting, tool previews and profile shading share
adaptive projected curve boundaries. Fit, zoom, display scale and placed sketch frames
invalidate the display cache without changing CAD geometry or timeline history. Work
budgets and clipped paths prevent unbounded sampling or invented edges across the eye plane.
The Sketch Palette scrolls its options on short windows and enlarged displays while
keeping constraint status and Finish Sketch visible.
- Face projection: closed boundaries have a deterministic outer-first order. Refresh matches
existing geometry before assigning positions, retaining saved curve IDs and constraints
across enumeration changes and resize, including concentric circles. Ambiguous identities
retain the last geometry and report a lost reference; permutation matching is bounded.
- Project accepts complete supported preselection in one undo step. Visible external sketch
curves and points retain their source sketch identity; model geometry, axes and planes use
the existing linked projection commands. Rejection preserves the document and selection;
mixed selections retain ordinary tool-first picking.
- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
selecting visited values for replacement. Expression completion retains forward-Tab
priority; real keyboard regressions cover rectangles, lines and single-value circles.
Typed lengths, radii, line angles, signed arc sweeps and slot lengths persist as constraints
and expressions through save/reopen. Shape creation and its dimensions form one undo step;
rejected values keep the editable field focused.
- Repeated components: Browser selection, visibility, properties, copy and Move/Copy use
checked occurrence paths. Active nested occurrences author planar sketches and solids in
their selected frame. Pending moves compose through parents; dialog cancellation restores
its selected move. Source materials and body/face appearances follow transformed instances.
Shared nested definitions remain shared. Deep Paste New copies reachable definition graphs,
independent feature/input histories, styles and internal motion records, with bounded geometry
checks and explicit external-link notices. Paste New selects the copy while preserving the active
authoring context. Imported periodic STEP seams survive placement and reexport with shared
topology and appearances intact. Recorded body transfers preserve historical Join/Cut membership,
material, placements and machining data through edits, copying, undo and reopen. New 3D wires
record their coordinate frame; legacy wires migrate from known provenance without changing
coordinates. Broken legacy links with missing provenance can require explicit refresh or relink.
See [occurrence workflows](docs/occurrence-workflows.md).
- Toolbar drops on panel labels, gaps and margins append commands with insertion feedback;
button drops keep insert-before ordering.
- Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel.
Parameters opens the user/model variable editor; Configurations opens the configuration table.
Configure remains available in the panel dropdown.
- Parameter creation supports Enter with validation and continued keyboard entry. Shared
expression autocomplete supports Up/Down selection, Enter/Tab acceptance and Escape dismissal;
accepting a suggestion does not submit a parameter or trigger viewport shortcuts.
- Movable window title bars follow the app's dark/light header palette. Radial right-click
menus are opt-in in Preferences > Navigation; ordinary context menus remain the default.
- Desktop distribution: running Windows windows and executable resources use the app icon;
packaging validates icon resources. Nix installs launchers, MIME data and generated icons,
with explicit launch apps and Vulkan-loader/driver discovery. Flatpak feed instructions
register the required runtime source; release checks exercise desktop modelling on X11/Wayland.
- OpenGL rendering clamps effective anti-aliasing to 1x across UI pipelines, textures and CAD
callbacks to prevent black frames. Other backends retain the requested sample count, and
stored preferences remain unchanged.
- Configuration rules: ordered typed conditions can drive parameters and suppressions or
restrict values. Preview explains effective values without modifying the document; applying
rules validates rows atomically. Length expressions retain their authoring units across
document-unit changes. The UI supports structured All/Any/Not conditions and rule ordering.
Component styles and certified semantic face styles are available; unsupported lineage refuses
atomically, and stale certified face paint cannot jump to another surface. See [configurations](docs/configurations.md).
- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained
islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material
pieces survive unfold/refold as one logical sheet group, including split flanged sheets.
Cut-outs made in the flat pattern cross bends at any angle or curved: their edges fold to
helices and curves on the bend, their walls square to the sheet. Free-form bases use bounded
sampling.
Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and
unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves
exact holes and retained islands, including the union of multiple selected profiles, and
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
tapered or bend-crossing machining refuses atomically instead of restoring material later.
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
A flange on another flange's far edge (a return) follows that flange's closed end and keeps
clear of the flange beside it, and the returns of one feature are mitred toward each other
with the rule's gap where their flanges meet at a corner; returns of separate features, and
flanges on returns, are not closed toward each other.
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
check. Under-resolved fits return an error.
- Contact driving checks the trajectory between clear endpoints, including linked joints,
playback and sparse motion exports. Checks use bounded tessellation samples, not exact
continuous collision detection; local thin walls and small details remain limitations.
See [contact-set limits and recovery](docs/contact-sets.md).
## Assembly dragging
The design's first part is grounded when it is made, and a left-button drag repositions a free part (`occurrence.drag`: one undo step, exact occurrence paths including repeated jointed definitions, any floating-chain member carries the chain, grounded parts and joints hold where present, Escape cancels).
## 3D sketch bridges
3D sketch drawing snaps to points on other visible sketch planes without flattening them onto
the active plane. Bridge endpoints capture their source positions; associative endpoint links,
construction-plane selection of 3D wires and loft guide rails remain separate work.
Feature dialog header chevrons collapse and restore the form without discarding selections
or edited values, including the Appearance panel.
## Construction on 3D paths
Plane Along Path has a viewport path selector, fractional position, live preview and timeline
editing. Construction planes and points resolve drawn 3D wires without applying their sketch
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
and reference-object placement remain separate work, as do loft rails and centerlines.
## Design properties (#1015)
- Designs carry project, part number, revision, author, organization, description and licence
in an optional `metadata` object (File → Design Properties…, `document.metadata.*`,
`inspect_design`). STEP and IGES headers and 3MF model metadata take the fields their format
has a place for. Other tools keep their own data under a reverse-DNS key, kept as it is
through open, edit and save and never exported. See [design properties](docs/book/src/guide/files.md#design-properties).
Per-component properties (a part number per component) can follow with the same type.
## Document-opening performance
- Desktop STEP import prepares consecutive independent solids in bounded batches with up
to eight workers, retaining serial traversal, result order and persistent source identities.
The web path remains serial. Original multi-solid fixtures show 64–81% lower complete
import times; single-solid assembly parts are not scheduled across nodes. See
[measurements and limits](docs/perf.md#parallel-step-body-preparation-2026-10-12).
- Design containers (#303, phase one) separate and deduplicate STEP, raw images and
lossless binary mesh arrays while keeping plain JSON as a supported Save As/export
format. Generated-mesh storage measurements reduce serialization and decoding work;
a seekable reader defers original blobs until after provisional display.
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
- Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology
and geometry records, with a separate truck adapter and bounded binary reader. Exact
face-source certificates and operation history survive codec round trips. A provisional
picture can be generated before original blobs are read; editing and measurement wait
for authoritative reconstruction. Cached metadata binds preview and reconstruction to
the same document despite concurrent file writes. Default saves omit this cache and never force cold
evaluation or meshing; explicit `preview: true` remains a separate warm-mesh option.
Keys, resource budgets and document/revision guards reject incompatible, hostile or
stale results. A second modelling kernel still requires its own tested adapter.
- Warm saves with explicit `cache: true` can also carry a bounded PNG thumbnail from already cached meshes.
Home reads it without rebuilding the design when its local picture is missing;
mismatched or malformed thumbnails fall back to normal generation. The image is
kernel-independent and saving never forces cold meshing to produce it.
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
benchmark runs reduce median rebuild time by 26% on the public sensor enclosure and 22%
on a local case assembly; shape measurements and topology match the original build.
See [performance measurements](docs/perf.md). Further cold Boolean and import costs remain.
- Prepared assembly geometry survives selection and view changes while real geometry and
placement changes invalidate it. A Pi assembly edge-selection probe drops from 9.24 s to
0.57 s; initial opening is unchanged. Actual Windows desktop profiling measures 15.87 s
median opening, with 82% spent rebuilding geometry; halving full opening remains open.
## Kernel
- Successful Boolean retries report their strategy and numerical adjustments as
feature warnings and structured inspection data. Diagnostics survive cached
evaluation, undo/redo and cold reopening; the robustness runner counts retry
events by strategy and reports any bounded-log truncation. Existing geometry
acceptance checks remain in force.
- Cylinders of one radius whose axes cross (a second bore across a bore of the same size, a pipe
tee of equal pipes) touch at two points, where the mesh intersection cannot trace the curves
and a boolean cannot tell the four arms of the crossing apart. When the ordinary boolean fails
on such a pair, both faces are split along the line of their cylinder through the touching
points (the solids are unchanged), the boolean is taken of those, and its history is composed
back over the operands as given (#788). The result keeps the split lines as edges of the bore
walls. A crossing the ordinary route fails on and the split faces do not help (a radius of 17.5
in a 45 mm block with a tool of 80 mm) is still refused.
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
polylines without panicking. Direct constructor regressions retain valid open endpoints
and closed spherical intersections; the original interactive trigger remains unidentified.
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
support and shared topology, preserving real steps and intentional boundaries. The local
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
cover stepped stock, STEP round trips and saved projections from removed pieces.
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
+9 -17
View File
@@ -353,7 +353,7 @@ rustls-native-certs 0.8.5 (Apache-2.0 OR ISC OR MIT)
rustls-pki-types 1.15.1 (MIT OR Apache-2.0)
rustls-platform-verifier 0.7.1 (MIT OR Apache-2.0)
rustls-platform-verifier-android 0.2.0 (MIT OR Apache-2.0)
rustls-webpki 0.103.16 (ISC)
rustls-webpki 0.103.15 (ISC)
rustversion 1.0.23 (MIT OR Apache-2.0)
ruzstd 0.9.1 (MIT)
schannel 0.1.29 (MIT)
@@ -435,7 +435,7 @@ truck-topology 0.6.0 (Apache-2.0)
ttf-parser 0.25.1 (MIT OR Apache-2.0)
twox-hash 2.1.5 (MIT)
type-map 0.5.1 (MIT/Apache-2.0)
typed-path 0.13.0 (MIT OR Apache-2.0)
typed-path 0.12.3 (MIT OR Apache-2.0)
typeid 1.0.3 (MIT OR Apache-2.0)
typenum 1.20.1 (MIT OR Apache-2.0)
uds_windows 1.2.1 (MIT)
@@ -474,7 +474,7 @@ wayland-sys 0.31.11 (MIT)
web-sys 0.3.106 (MIT OR Apache-2.0)
web-time 1.1.0 (MIT OR Apache-2.0)
webbrowser 1.2.4 (MIT OR Apache-2.0)
webpki-root-certs 1.0.10 (CDLA-Permissive-2.0)
webpki-root-certs 1.0.9 (CDLA-Permissive-2.0)
weezl 0.1.12 (MIT OR Apache-2.0)
wgpu 30.0.1 (MIT OR Apache-2.0)
wgpu-core 30.0.1 (MIT OR Apache-2.0)
@@ -552,7 +552,7 @@ zeroize_derive 1.5.0 (Apache-2.0 OR MIT)
zerotrie 0.2.5 (Unicode-3.0)
zerovec 0.11.8 (Unicode-3.0)
zerovec-derive 0.11.6 (Unicode-3.0)
zip 9.1.3 (MIT)
zip 9.0.4 (MIT)
zlib-rs 0.6.8 (Zlib)
zmij 1.0.23 (MIT)
zopfli 0.8.3 (Apache-2.0)
@@ -596,7 +596,7 @@ pin-project 1.1.13, pin-project-internal 1.1.13, pin-project-lite 0.2.17, piper
rustix 0.38.44, rustix 1.1.5, rustversion 1.0.23, semver 1.0.28, serde 1.0.229, serde_core 1.0.229,
serde_derive 1.0.229, serde_derive_internals 0.30.0, serde_json 1.0.151, serde_path_to_error 0.1.20,
serde_repr 0.1.21, simd_cesu8 1.2.0, smol_str 0.2.2, syn 1.0.109, syn 2.0.119, syn 3.0.6, thiserror
1.0.69, thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.13.0, typeid
1.0.69, thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.12.3, typeid
1.0.3, unicode-ident 1.0.26, utf8-zero 0.8.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2
1.0.4+wasi-0.2.12, wit-bindgen 0.57.1, x11-dl 2.21.0, xdg-home 1.3.0, zbus-lockstep 0.5.2,
zbus-lockstep-macros 0.5.2, zbus_names 3.0.0, zmij 1.0.23, zvariant_utils 2.1.0, zvariant_utils
@@ -1339,7 +1339,7 @@ libc 0.2.190, litrs 1.0.0, num_enum 0.7.6, num_enum_derive 0.7.6, proc-macro2 1.
ref-cast 1.0.27, ref-cast-impl 1.0.27, rustc-hash 2.1.3, rustversion 1.0.23, semver 1.0.28, serde
1.0.229, serde_core 1.0.229, serde_derive 1.0.229, serde_derive_internals 0.30.0, serde_json
1.0.151, serde_path_to_error 0.1.20, serde_repr 0.1.21, syn 2.0.119, syn 3.0.6, thiserror 1.0.69,
thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.13.0, typeid 1.0.3,
thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.12.3, typeid 1.0.3,
unicode-ident 1.0.26, utf8-zero 0.8.1
================================================================================
@@ -12431,7 +12431,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
rustls-webpki 0.103.16
rustls-webpki 0.103.15
================================================================================
Except as otherwise noted, this project is licensed under the following
@@ -15274,7 +15274,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
webpki-root-certs 1.0.10
webpki-root-certs 1.0.9
================================================================================
# Community Data License Agreement - Permissive - Version 2.0
@@ -16401,7 +16401,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
zip 9.1.3
zip 9.0.4
================================================================================
The MIT License (MIT)
@@ -16799,14 +16799,6 @@ Bundled fonts (SIL Open Font License, Version 1.1)
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
Source: https://github.com/googlefonts/morisawa-biz-ud-gothic
- CraftUI-SC.otf (assets/fonts/craft-ui-sc/CraftUI-SC.otf)
Copyright 2014-2021 Adobe / Google Inc. (Noto Sans SC subset for SolidCraft web zh)
Source: https://github.com/notofonts/noto-cjk (Sans SubsetOTF/SC)
- CraftUI-TC.otf (assets/fonts/craft-ui-tc/CraftUI-TC.otf)
Copyright 2014-2021 Adobe / Google Inc. (Noto Sans TC subset for SolidCraft web zh-TW)
Source: https://github.com/notofonts/noto-cjk (Sans SubsetOTF/TC)
- IBMPlexSans-Variable.ttf (assets/fonts/ibm-plex-sans/IBMPlexSans-Variable.ttf)
Copyright 2017 IBM Corp. with Reserved Font Name "Plex"
Source: https://github.com/IBM/plex
+36
View File
@@ -0,0 +1,36 @@
# SolidCraft name and logo
The code is yours to use under MIT OR Apache-2.0 (see [NOTICE](NOTICE)). The name is not part of
that licence: "SolidCraft" and the SolidCraft logo identify this project and the builds its
maintainers publish, and Tangency Labs, which maintains SolidCraft, uses them as its trademarks.
Neither licence grants rights to them.
This page says how you can use the name. It is meant to be easy to follow: most uses are fine.
## Fine without asking
- Saying what your work is built on: "based on SolidCraft", "a fork of SolidCraft", "works with
SolidCraft", "SolidCraft plugin for …".
- Writing, teaching, reviewing or making videos about SolidCraft, including in titles.
- Redistributing unmodified official builds under the SolidCraft name, for example in a Linux
distribution or package manager.
- Using the logo to link to the project or to show that something supports SolidCraft.
- User groups, meetups and community spaces about SolidCraft, as long as they don't look official.
## Use your own name
- A fork, a modified build or a product built on SolidCraft needs its own name and logo. "SolidCraft"
may appear in its description ("based on SolidCraft"), not as its name or the main part of it.
- Don't use names or logos that could be mistaken for SolidCraft, such as "SolidCraft Pro",
"SolidKraft" or a lightly changed version of the logo.
- Don't imply that SolidCraft's maintainers made, endorse or support your product or service unless
they do.
- Don't use the name in domain names, app store listings or social media accounts in a way that
looks official.
## Questions
If you're not sure whether a use is fine, open an issue or ask on [Discord](https://discord.gg/FtU4NQNVDH).
For anything this page doesn't allow, ask first; we're generally happy to say yes.
Other companies' trademarks named in this repository are covered in [NOTICE](NOTICE).
@@ -66,7 +66,7 @@ fn run() -> Result<(), String> {
let mut session = Session::default();
eprintln!("Generating original CAD scene from {recipe_path}");
let generation_start = Instant::now();
let (commands, command_timings) = session.run_script_timed(&recipe, false, &mut |_, _| {}).map_err(|e| e.to_string())?;
let (commands, command_timings) = session.run_script_timed(&recipe, false).map_err(|e| e.to_string())?;
let generation_seconds = generation_start.elapsed().as_secs_f64();
let design_path = output.with_extension(solidcraft_engine::io::DESIGN_EXT);
session.execute("file.save_as", &json!({"path":design_path})).map_err(|e| e.to_string())?;
@@ -87,7 +87,7 @@ fn run() -> Result<(), String> {
let gpu_host_start = Instant::now();
let policy = solidcraft_gpu::select::Policy::from_env().map_err(|e| e.to_string())?;
let (adapter, report) =
solidcraft_gpu::select::pick_path(policy, |_| true).ok_or("no Vulkan or Metal adapter satisfies the requested GPU policy")?;
solidcraft_gpu::select::pick_vulkan(policy, |_| true).ok_or("no Vulkan adapter satisfies the requested GPU policy")?;
session.register_render_job_host(solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
(Some(report), Some(gpu_host_start.elapsed().as_secs_f64()))
} else {
+44 -93
View File
@@ -2,7 +2,6 @@
//!
//! ```text
//! solidcraft-cli run <script.json|design.sc|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
//! [--param-check off|warn|strict]
//! solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect) measurements as JSON
//! solidcraft-cli script <design.sc> [--out script.json] the design's history as a command script
//! solidcraft-cli bench-open <design.sc | part.step> [--parse-only] [--save OUT.sc] [--display LEVEL [--edit NAME EXPR]] [--ops script.json] loading timings as JSON lines
@@ -24,7 +23,6 @@
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
//!
//! --debug (with any command) SolidCraft's debug log on stderr (or RUST_LOG=LEVEL)
//! --max-file-size SIZE (with any command) read STEP and design files up to SIZE (2GB, 1500MB; at most 16GB)
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -58,7 +56,7 @@ struct Command {
}
/// `run`'s lines in the usage.
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n [--param-check off|warn|strict]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--param-check checks each step against its command's contract: warn reports on stderr, strict stops the run)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n";
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n";
/// `eval`'s lines in the usage.
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect)\n solidcraft-cli eval --in <script.json|design.sc|part.step> [--json-out FILE]\n (with both inputs, runs the positional script on --in before measuring; does not automatically save the modified design)\n (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,\n as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)\n";
/// `script`'s lines in the usage.
@@ -101,7 +99,7 @@ const USAGE_BENCH_EDIT: &str = " solidcraft-cli bench-edit <script.json|recipe.
/// `save-stress`'s lines in the usage: the file saved over and over.
const USAGE_SAVE_STRESS: &str = " solidcraft-cli save-stress <path> <rounds>\n";
/// The flags every command takes, at the end of the top-level usage.
const USAGE_DEBUG: &str = " --debug, with any command: what SolidCraft logs (why a body has no display plan, why a save left its\n model cache out), on stderr; RUST_LOG=error|warn|info|debug|trace sets one level for every crate\n --max-file-size SIZE, with any command: read STEP and import files and design files up to SIZE for this run\n (for example 2GB or 1500MB; at most 16GB; the defaults are 512 MB and 256 MB, and a smaller SIZE keeps them)\n";
const USAGE_DEBUG: &str = " --debug, with any command: what SolidCraft logs (why a body has no display plan, why a save left its\n model cache out), on stderr; RUST_LOG=error|warn|info|debug|trace sets one level for every crate\n";
/// The commands the CLI takes, in the order the top-level usage lists them.
const COMMANDS: &[Command] = &[
@@ -133,10 +131,6 @@ const COMMANDS: &[Command] = &[
fn main() -> ExitCode {
let mut args: Vec<String> = std::env::args().skip(1).collect();
logger::init(&mut args);
if let Err(e) = max_file_size(&mut args) {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
let r = match args.first().map(String::as_str) {
// Asked-for help is not an error: the command's usage on stdout, exit 0. It is read
// before the command runs, so `--help` never starts a run (a fuzz run, a server).
@@ -211,28 +205,6 @@ fn main() -> ExitCode {
}
}
/// Take `--max-file-size SIZE` (or `--max-file-size=SIZE`), which any command takes, out of the
/// arguments and raise the file size limits for this run (#4086). A bad size is an error.
fn max_file_size(args: &mut Vec<String>) -> Result<(), String> {
let mut size = None;
let mut i = 0;
while let Some(arg) = args.get(i) {
if arg == "--max-file-size" {
let value = args.get(i + 1).cloned().ok_or("--max-file-size needs a size, for example --max-file-size 2GB")?;
args.drain(i..i + 2);
size = Some(value);
} else if let Some(value) = arg.strip_prefix("--max-file-size=") {
size = Some(value.to_string());
args.remove(i);
} else {
i += 1;
}
}
let Some(size) = size else { return Ok(()) };
let bytes = solidcraft_engine::io::limits::parse_flag(&size).map_err(|e| format!("--max-file-size: {e}"))?;
solidcraft_engine::io::limits::set_override(Some(bytes))
}
fn pretty(v: &Value) -> String {
serde_json::to_string_pretty(v).unwrap_or_default()
}
@@ -269,12 +241,11 @@ fn wants_help(args: &[String]) -> bool {
pub fn read_json(path: &str) -> Result<Value, String> {
// Through the engine's file access, which refuses anything that is not a regular file and
// reads no more than the cap: a character device or a FIFO would otherwise never end.
let s = solidcraft_engine::io::vfs::read_to_string(path, solidcraft_engine::io::limits::max_design_bytes())
.map_err(|e| format!("{path}: {}", solidcraft_engine::io::limits::explain(solidcraft_engine::io::limits::Kind::Design, e)))?;
let s = solidcraft_engine::io::vfs::read_to_string(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
serde_json::from_str(&s).map_err(|e| format!("{path}: {e}"))
}
/// A session from a design file, a STEP/3MF/STL file, a point cloud, a command script, or an oracle recipe.
/// A session from a design file, a STEP/3MF/STL file, a command script, or an oracle recipe.
pub fn load(path: &str) -> Result<Session, String> {
load_checked(path, solidcraft_engine::schema::ParamCheck::Off)
}
@@ -284,11 +255,11 @@ pub fn load(path: &str) -> Result<Session, String> {
fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Result<Session, String> {
let mut s = Session::default();
s.param_check = check;
if solidcraft_engine::io::is_step_path(path) || solidcraft_engine::io::is_mesh_path(path) || solidcraft_engine::io::is_point_cloud_path(path) {
if solidcraft_engine::io::is_step_path(path) || solidcraft_engine::io::is_mesh_path(path) {
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
return Ok(s);
}
let bytes = solidcraft_engine::io::limits::read(path, solidcraft_engine::io::limits::Kind::Design).map_err(|e| format!("{path}: {e}"))?;
let bytes = solidcraft_engine::io::vfs::read(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
if solidcraft_engine::io::is_design_container(&bytes) {
let doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| format!("{path}: {e}"))?;
let mut session = Session::opened_at(doc, None, Some(path));
@@ -300,36 +271,18 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
} else {
let script = if v.get("features").is_some() && v.get("commands").is_none() { recipe::to_script(&v)? } else { v };
run_script_warning(&mut s, path, &script)?;
// Warn level: each step's `param_warnings` as it completes, even when a later step fails
// (stderr, so stdout stays the caller's).
let mut warn = |step: usize, r: &Value| {
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
}
};
s.run_script_each(&script, &mut warn).map_err(|e| e.to_string())?;
}
Ok(s)
}
/// Run `script`, read from `path`, on `s`. At the warn level each step's `param_warnings` go to
/// stderr as the step completes, even when a later step fails (stdout stays the caller's).
fn run_script_warning(s: &mut Session, path: &str, script: &Value) -> Result<Vec<Value>, String> {
s.run_script_each(script, &mut |step, r| print_param_warnings(path, step, r)).map_err(|e| e.to_string())
}
/// Step `step`'s `param_warnings` on stderr, one per line, named after the script at `path`.
fn print_param_warnings(path: &str, step: usize, r: &Value) {
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
}
}
/// `--param-check off|warn|strict`: how calls that break a command's parameter contract are
/// treated (docs/mcp.md); off when the option isn't given.
fn parse_param_check(cmd: &str, args: &[String]) -> Result<solidcraft_engine::schema::ParamCheck, String> {
match flag(args, "--param-check") {
None if args.iter().any(|a| a == "--param-check") => Err(format!("{cmd}: --param-check wants off, warn or strict")),
None => Ok(solidcraft_engine::schema::ParamCheck::Off),
Some(v) => {
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("{cmd}: --param-check wants off, warn or strict, not `{v}`"))
}
}
}
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
}
@@ -386,15 +339,7 @@ fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
fn cmd_run(args: &[String]) -> Result<(), String> {
install_renderer();
let json_out = json_out(args)?;
// Before the option check, so a bare `--param-check` gets the words `mcp` uses.
let check = parse_param_check("run", args)?;
known_args(
"run",
args,
&["--in", "--out", "--save", "--json-out", "--param-check"],
&["--quiet", "--results", "--timings", "--profile", "--allow-errors"],
true,
)?;
known_args("run", args, &["--in", "--out", "--save", "--json-out"], &["--quiet", "--results", "--timings", "--profile", "--allow-errors"], true)?;
let base = flag(args, "--in");
let path = positional(args);
if path.is_none() && base.is_none() {
@@ -407,25 +352,23 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
let (mut s, results) = if want_results || timings {
// Collect every command's result or time (scripts only), then process requested files too.
let mut s = match base {
Some(b) => load_checked(b, check)?,
Some(b) => load(b)?,
None => Session::default(),
};
s.param_check = check;
let needs = if want_results { "--results needs a script" } else { "--timings needs a script" };
let script = path.ok_or(needs)?;
let v = read_json(script)?;
let v = read_json(path.ok_or(needs)?)?;
let out = if timings {
let (r, mut t) = s.run_script_timed(&v, profile, &mut |step, r| print_param_warnings(script, step, r)).map_err(|e| e.to_string())?;
let (r, mut t) = s.run_script_timed(&v, profile).map_err(|e| e.to_string())?;
if want_results && let Some(o) = t.as_object_mut() {
o.insert("results".into(), Value::Array(r));
}
t
} else {
Value::Array(run_script_warning(&mut s, script, &v)?)
Value::Array(s.run_script(&v).map_err(|e| e.to_string())?)
};
(s, Some(out))
} else {
(open(base, path, check)?, None)
(open(base, path)?, None)
};
// A feature that fails leaves the bodies as they were before it (a parameter change can
// break a feature without failing the command that made it): writing them would hand out
@@ -450,16 +393,16 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
}
/// The session `run`, `eval` and `snapshot` start from: `--in` (a design or STEP file) with the
/// positional script run on top of it, or either one alone, with scripts run at `check`.
fn open(base: Option<&str>, path: Option<&String>, check: solidcraft_engine::schema::ParamCheck) -> Result<Session, String> {
/// positional script run on top of it, or either one alone.
fn open(base: Option<&str>, path: Option<&String>) -> Result<Session, String> {
match (base, path) {
(Some(b), Some(p)) => {
let mut s = load_checked(b, check)?;
run_script_warning(&mut s, p, &read_json(p)?)?;
let mut s = load(b)?;
s.run_script(&read_json(p)?).map_err(|e| e.to_string())?;
Ok(s)
}
(Some(b), None) => load_checked(b, check),
(None, Some(p)) => load_checked(p, check),
(Some(b), None) => load(b),
(None, Some(p)) => load(p),
(None, None) => Err(usage()),
}
}
@@ -502,7 +445,7 @@ fn timeline_ok(s: &Session) -> Result<(), String> {
fn cmd_eval(args: &[String]) -> Result<(), String> {
let json_out = json_out(args)?;
known_args("eval", args, &["--in", "--json-out"], &[], true)?;
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
let mut s = open(flag(args, "--in"), positional(args))?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
let out = json!({
@@ -596,7 +539,7 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
};
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
let mut s = open(flag(args, "--in"), positional(args))?;
// How the bodies are drawn and where the model is cut, through the commands the app's
// Appearance dialog and Section Analysis run (`preview`).
preview::apply(&mut s, &flags(args, "--body"), &flags(args, "--tool"), flag(args, "--section"))?;
@@ -710,7 +653,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
if let Some(s) = &scope {
let roots: Vec<String> = s.roots().iter().map(|r| r.display().to_string()).collect();
eprintln!(
"solidcraft mcp: files limited to {} and the folders of designs opened or saved here (--allow-path DIR adds one, --allow-any-path lifts the limit)",
"solidcraft mcp: files limited to {} and the open design's folder (--allow-path DIR adds one, --allow-any-path lifts the limit)",
roots.join(", ")
);
let design_dir = flag(args, "--in")
@@ -720,8 +663,17 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
}
}
// How calls that break a command's parameter contract are treated, headless or bridged.
let param_check = parse_param_check("mcp", args)?;
// How a headless session treats calls that break a command's parameter contract (docs/mcp.md).
let param_check = match flag(args, "--param-check") {
None if args.iter().any(|a| a == "--param-check") => return Err("mcp: --param-check wants off, warn or strict".into()),
None => solidcraft_engine::schema::ParamCheck::Off,
Some(v) => {
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("mcp: --param-check wants off, warn or strict, not `{v}`"))?
}
};
if param_check != solidcraft_engine::schema::ParamCheck::Off && flag(args, "--connect").is_some() {
eprintln!("solidcraft mcp: --param-check applies to headless sessions; calls to the app are not checked yet");
}
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
(Some(addr), None) => {
@@ -729,7 +681,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
return Err(format!("mcp: --connect wants HOST:PORT, such as 127.0.0.1:7878, not `{addr}`"));
};
// Serve without the app too: the tools connect once it runs, and say it isn't until then.
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token")).with_param_check(param_check);
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token"));
if let Err(e) = remote.try_connect() {
eprintln!(
"solidcraft mcp: no SolidCraft app at {addr} yet ({e}); start it with `solidcraft --control {port}` \
@@ -749,8 +701,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
let auto = !args.iter().any(|a| a == "--headless");
if auto && let Some(port) = discover_app() {
eprintln!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
let remote = solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token")).with_param_check(param_check);
return serve_mcp(Box::new(remote), scope);
return serve_mcp(Box::new(solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token"))), scope);
}
let mut headless = solidcraft_mcp::Headless::default();
headless.session.param_check = param_check;
@@ -869,8 +820,8 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.sc | part.step> [--parse-only]")?;
let t = std::time::Instant::now();
let step = solidcraft_engine::io::is_step_path(path);
let kind = if step { solidcraft_engine::io::limits::Kind::Step } else { solidcraft_engine::io::limits::Kind::Design };
let bytes = solidcraft_engine::io::limits::read(path, kind).map_err(|e| format!("{path}: {e}"))?;
let limit = if step { solidcraft_engine::io::MAX_STEP_BYTES } else { solidcraft_engine::io::MAX_DESIGN_BYTES };
let bytes = solidcraft_engine::io::vfs::read(path, limit).map_err(|e| format!("{path}: {e}"))?;
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()}));
let t = std::time::Instant::now();
-31
View File
@@ -157,34 +157,3 @@ fn render_help_keeps_the_registered_gpu_request_controls() {
}
}
}
/// `--max-file-size` (#4086), with any command: the usage names it, a bad size stops the run
/// with the reason, and a design over the default limit opens once the flag raises it.
#[test]
fn max_file_size_raises_the_limit_and_refuses_bad_sizes() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("--help").output().unwrap();
assert!(String::from_utf8_lossy(&output.stdout).contains("--max-file-size"));
for size in ["17GB", "2", "lots"] {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["--max-file-size", size, "exec", "file.new"]).output().unwrap();
assert!(!output.status.success(), "{size} was accepted");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.starts_with("error: --max-file-size"), "{size}: {stderr}");
}
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["exec", "file.new", "--max-file-size=2GB"]).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
// A sparse design file just over the default 256 MB: refused unread, then read with the flag
// (and refused for what it holds, which is no design).
let dir = std::env::temp_dir().join(format!("solidcraft-cli-4086-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("big.solidcraft");
std::fs::File::create(&path).unwrap().set_len((256 << 20) + 1).unwrap();
let path = path.to_string_lossy().to_string();
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["eval", &path]).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success() && stderr.contains("too large") && stderr.contains("--max-file-size"), "{stderr}");
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["--max-file-size", "1GB", "eval", &path]).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success() && !stderr.contains("too large"), "{stderr}");
let _ = std::fs::remove_dir_all(&dir);
}
-44
View File
@@ -288,47 +288,3 @@ fn undoing_an_edit_after_opening_reuses_the_saved_results() {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
}
/// A design saved before body names were pinned (#6403) pins them as it opens, as its first
/// command would, and opening it is no change. That command pinned them as part of itself, so
/// undoing it went back to the unpinned design, which no evaluated result matched: the first
/// undo after opening from the command line evaluated every feature again (82-93 s in a
/// 5,484-feature design); `doc.open` pinned them after opening, which marked the design changed.
#[test]
fn a_design_saved_before_names_were_pinned_opens_pinned_and_unchanged() {
let plate = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Sample Plate").unwrap();
let mut built = Session::default();
for c in (plate.script)()["commands"].as_array().unwrap() {
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
}
let mut doc = built.design().into_owned();
for f in &mut doc.features {
f.body_names.clear();
}
doc.reserved_body_names.clear();
let unpinned = Session::new(doc);
let design = unpinned.design();
let saved = unpinned.model.saved(&design).unwrap();
let bytes = solidcraft_engine::io::write_design_container_with_cache(&design, solidcraft_engine::io::DesignCompression::Fast, None, Some(&saved))
.unwrap();
let path = temp("unpinned");
std::fs::write(&path, &bytes).unwrap();
let file = path.to_string_lossy().to_string();
// As the command line opens a design (`load`), and as the app does (`doc.open`).
let (doc, saved) = solidcraft_engine::io::read_design_file(&file, None::<fn(solidcraft_engine::io::DesignPreview)>).unwrap();
for (how, mut s) in [("opened_at", Session::opened_at(doc, saved.as_ref(), Some(&file))), ("doc.open", open(&path))] {
assert_eq!(s.model.last_recomputed, 0, "{how}: taken from the file");
assert!(s.doc.features.iter().any(|f| !f.body_names.is_empty()), "{how}: the names are pinned");
assert!(!s.is_dirty(), "{how}: opening is no change");
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
s.execute("solid.box", &json!({"length": 3, "width": 3, "height": 3, "operation": "new"})).unwrap();
// As in a large design, whose evaluation cache keeps only a few results: undo finds the
// results in the model itself or evaluates them again.
*s.model.cache.lock().unwrap() = Default::default();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.model.last_recomputed, 0, "{how}: the first undo evaluates nothing");
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "{how}: the states read from the file");
assert!(!s.is_dirty(), "{how}: undone to the design as opened");
}
let _ = std::fs::remove_file(&path);
}
-72
View File
@@ -496,78 +496,6 @@ fn timings_and_profile_report_each_command() {
assert!(String::from_utf8_lossy(&output.stderr).contains("--timings needs a script"));
}
/// #6921: `--param-check` checks a script's steps against their contracts on every path `run`
/// takes (a script alone, on an `--in` design or script, with `--results`, `--timings` or
/// `--profile`), and a warning reaches stderr even when a later step fails.
#[test]
fn param_check_reaches_every_script_run_takes() {
let dir = Scratch::new();
let typo = dir.0.join("typo.json");
let step = json!({"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4, "operaton": "new"}});
std::fs::write(&typo, json!({"commands": [step.clone()]}).to_string()).unwrap();
let base = dir.0.join("base.solidcraft");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
let saved = dir.0.join("saved.solidcraft");
let start = dir.script();
let (typo, base, saved_arg, start) = (typo.to_str().unwrap(), base.to_str().unwrap(), saved.to_str().unwrap(), start.to_str().unwrap());
let paths = [&[][..], &["--in", base], &["--in", start], &["--results"], &["--timings"], &["--timings", "--profile"]];
let run = |level: Option<&str>, extra: &[&str]| {
std::fs::remove_file(&saved).ok();
let check = level.map(|l| ["--param-check", l]).into_iter().flatten();
cli().args(["run", typo, "--save", saved_arg]).args(check).args(extra).output().unwrap()
};
// Off, the default: the step runs as it always has, without a word.
let off = run(None, &[]);
assert!(off.status.success() && !String::from_utf8_lossy(&off.stderr).contains("operaton"), "{off:?}");
assert_eq!(body_count(&saved), 1);
// Warn: each path runs the step, saves, and reports it on stderr; `--results` keeps it too.
for extra in paths {
let output = run(Some("warn"), extra);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "{extra:?}: {stderr}");
assert!(stderr.contains("step 1: unknown key `operaton`; did you mean `operation`?"), "{extra:?}: {stderr}");
assert!(saved.exists(), "{extra:?}");
if extra == ["--results"] {
let results: Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(results[0]["param_warnings"][0].as_str().is_some_and(|w| w.contains("operaton")), "{results}");
}
}
// Strict: on each path the run stops at the step, and nothing is written.
for extra in paths {
let output = run(Some("strict"), extra);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
assert!(stderr.contains("unknown key `operaton`"), "{extra:?}: {stderr}");
assert!(!saved.exists(), "{extra:?}");
}
// Warn, with a later step that fails: the run fails, and the first step's warning is still
// reported, timed or not.
let failing = dir.0.join("failing.json");
let fail = json!({"command": "solid.extrude", "params": {"sketch": "Nope"}});
std::fs::write(&failing, json!({"commands": [step, fail]}).to_string()).unwrap();
for extra in [&[][..], &["--timings"]] {
let output = cli().arg("run").arg(&failing).args(["--param-check", "warn"]).args(extra).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
assert!(stderr.contains("step 1: unknown key `operaton`") && stderr.contains("no sketch `Nope`"), "{extra:?}: {stderr}");
}
// A level it doesn't know and a missing value fail.
for (bad, says) in [
(&["--param-check", "loud"][..], "--param-check wants off, warn or strict, not `loud`"),
(&["--param-check"], "run: --param-check wants off, warn or strict"),
] {
let output = cli().args(["run", typo]).args(bad).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{bad:?}: {stderr}");
assert!(stderr.contains(says), "{bad:?}: {stderr}");
}
}
fn render_setup_script(dir: &Scratch, job: Value) -> PathBuf {
let path = dir.0.join("render.json");
std::fs::write(
+1 -1
View File
@@ -34,7 +34,7 @@ serde_json = { workspace = true }
web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", "StorageEvent",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
"Headers", "ReadableStream", "ReadableStreamDefaultReader",
"BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "FormData", "RequestInit", "MediaQueryList", "Gamepad", "Performance",
-4
View File
@@ -22,10 +22,6 @@
<link data-trunk rel="rust" data-bin="solidcraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/OFL.txt" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-sc/CraftUI-SC.otf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-sc/OFL.txt" data-target-path="fonts/craft-ui-sc" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-tc/CraftUI-TC.otf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-tc/OFL.txt" data-target-path="fonts/craft-ui-tc" />
<link data-trunk rel="css" href="../../assets/styles/web/loading.css" />
<link data-trunk rel="copy-file" href="../../assets/styles/web/loading-logo.svg" />
<!-- Installable as an app (manifest.webmanifest); the icons are the Linux exports of the app mark. -->
+2 -6
View File
@@ -40,8 +40,6 @@ pub enum Intent {
Open,
/// Add its bodies to the open design (STEP, 3MF, STL).
Insert,
/// Import it as a theme: it was dropped on Preferences' Import tile (#8226).
Theme,
}
/// A stored design, as the file panel lists it.
@@ -617,15 +615,13 @@ fn choose_file(accept: &str) -> Result<js_sys::Promise, String> {
}
/// Read files dropped on the page.
/// `theme`: the pointer is over Preferences' Import tile, so the files are themes.
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput, theme: bool) {
let intent = if theme { Intent::Theme } else { Intent::Insert };
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput) {
for f in std::mem::take(&mut raw.dropped_files) {
let files = files.clone();
spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(b) => files.arrive(name, b, intent),
Ok(b) => files.arrive(name, b, Intent::Insert),
Err(e) => files.say(format!("{name}: {e}"), true),
}
});
+82 -305
View File
@@ -112,50 +112,6 @@ fn check_download_size(current: usize, incoming: u64) -> Result<(), String> {
Ok(())
}
/// Ctrl+W (⌘W, without Shift or Alt) while a design is shown closes it in the app, so the
/// browser must not close the tab (#5366). On the start page the browser closes the tab.
#[cfg(any(target_arch = "wasm32", test))]
fn closes_design(key: &str, command: bool, shift: bool, alt: bool, design_shown: bool) -> bool {
design_shown && command && !shift && !alt && key.eq_ignore_ascii_case("w")
}
/// The settings a tab last wrote to the browser's storage, or loaded from another tab (#8644).
/// Every open tab of the app shares that storage, and eframe saves each tab's whole copy every
/// 30 s and when the page loses focus, so a tab still holding old settings put them back over a
/// change made in another. A change is now written in the frame it is made, and the other tabs
/// load it ([`settings_written`]).
#[cfg(any(target_arch = "wasm32", test))]
#[derive(Default, Debug)]
struct PrefsWriter {
written: Option<String>,
}
#[cfg(any(target_arch = "wasm32", test))]
impl PrefsWriter {
/// `prefs` if they are not the settings written or loaded last (they count as written from
/// now on).
fn take(&mut self, prefs: String) -> Option<String> {
if self.written.as_deref() == Some(prefs.as_str()) {
return None;
}
self.written = Some(prefs.clone());
Some(prefs)
}
/// The settings the tab holds after loading another tab's: the tab doesn't write them back.
fn loaded(&mut self, prefs: String) {
self.written = Some(prefs);
}
}
/// The settings another tab wrote, from a `storage` event (the browser sends one to every other
/// tab of the page when one tab writes). Other keys, and settings taken away (clearing the site's
/// data), leave this tab's settings as they are.
#[cfg(any(target_arch = "wasm32", test))]
fn settings_written(key: Option<&str>, value: Option<String>) -> Option<String> {
value.filter(|_| key == Some(solidcraft_ui_egui::prefs::STORAGE_KEY))
}
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
@@ -207,23 +163,6 @@ mod web {
thread_local! {
/// An open design has unsaved changes: leaving the page asks first.
static UNSAVED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// A design is shown (not the start page): Ctrl+W closes it, not the browser tab.
static DESIGN_SHOWN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Ctrl+W (⌘W) closes the design shown, as on the desktop; on the start page it is left to
/// the browser, which closes the tab (#5366). Browsers that keep Ctrl+W to themselves still
/// close the tab, after asking when a design is unsaved ([`ask_before_unload`]).
fn keep_ctrl_w_for_the_design(window: &web_sys::Window) {
let on_key = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::KeyboardEvent)>::new(|e: web_sys::KeyboardEvent| {
let command = e.ctrl_key() || e.meta_key();
if crate::closes_design(&e.key(), command, e.shift_key(), e.alt_key(), DESIGN_SHOWN.with(std::cell::Cell::get)) {
e.prevent_default();
}
});
let _ = window.add_event_listener_with_callback_and_bool("keydown", on_key.as_ref().unchecked_ref(), true);
// The listener lives as long as the page.
on_key.forget();
}
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
@@ -254,29 +193,9 @@ mod web {
on_key.forget();
}
/// Settings another tab wrote, waiting for this tab's next frame (#8644).
type PrefsInbox = Rc<std::cell::Cell<Option<String>>>;
/// Settings another tab of the app writes go to `inbox`, and this tab draws a frame, which
/// loads them.
fn hear_settings_from_other_tabs(window: &web_sys::Window, ctx: &egui::Context, inbox: PrefsInbox) {
let ctx = ctx.clone();
let on_storage = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::StorageEvent)>::new(move |e: web_sys::StorageEvent| {
if let Some(prefs) = crate::settings_written(e.key().as_deref(), e.new_value()) {
inbox.set(Some(prefs));
ctx.request_repaint();
}
});
let _ = window.add_event_listener_with_callback("storage", on_storage.as_ref().unchecked_ref());
// The listener lives as long as the page.
on_storage.forget();
}
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
const CHINESE_SC_FONT_PATH: &str = "./fonts/CraftUI-SC.otf";
const CHINESE_TC_FONT_PATH: &str = "./fonts/CraftUI-TC.otf";
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<(String, Vec<u8>), String>>>>;
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
struct Shell {
app: SolveApp,
@@ -294,63 +213,18 @@ mod web {
rename_focus: bool,
/// The start page's recent designs were filled from the stored ones.
recents_synced: bool,
/// On-demand script fonts: fetched only when a CJK language is selected.
/// On-demand CJK font: fetched only when a CJK language is selected.
font: solidcraft_ui_egui::fonts::CjkFontLoader,
font_result: FontLoadResult,
/// The fetched Japanese font's bytes: kept so a language change can re-order the fallbacks.
/// The fetched CJK font's bytes: kept so a language change can re-order the fallbacks.
cjk_bytes: Option<Vec<u8>>,
/// The fetched Chinese font's bytes (Simplified or Traditional for this session).
chinese_bytes: Option<Vec<u8>>,
/// Which Chinese face `chinese_bytes` is: `"zh"` or `"zh-TW"`.
chinese_kind: Option<&'static str>,
/// Kind requested by the in-flight fetch (`"ja"`, `"zh"`, or `"zh-TW"`).
font_fetch_kind: Option<&'static str>,
/// The language the fonts were last set up for (a CJK UI leads with the CJK face).
font_language: String,
space_mouse: SpaceMouse,
wheel_gestures: solidcraft_ui_egui::WebWheelGestures,
/// The settings this tab wrote or loaded last, and those another tab wrote since (#8644).
prefs_writer: crate::PrefsWriter,
other_tab_prefs: PrefsInbox,
}
impl Shell {
/// The settings as they are kept between visits: with the user's own language, not a
/// session-only English fallback.
fn persisted_prefs(&mut self) -> String {
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
let live = std::mem::replace(&mut self.app.preferences.language, saved);
let prefs = self.app.prefs();
self.app.preferences.language = live;
prefs
}
/// A settings change goes to the browser's storage in the frame it is made, and from
/// there to the other open tabs (#8644). Input is what changes settings, so a frame
/// without any is skipped; eframe's own save still catches anything else.
fn store_prefs(&mut self, ctx: &egui::Context) {
if !self.launch.persists() || ctx.input(|i| i.events.is_empty()) {
return;
}
let prefs = self.persisted_prefs();
let Some(prefs) = self.prefs_writer.take(prefs) else { return };
let stored =
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).map(|s| s.set_item(solidcraft_ui_egui::prefs::STORAGE_KEY, &prefs));
if !matches!(stored, Some(Ok(()))) {
log::warn!("settings: the browser's storage did not take them; they are saved again later");
self.prefs_writer = crate::PrefsWriter::default();
}
}
/// Settings another tab wrote replace this tab's, so this tab's own saves keep them
/// instead of putting its older copy back (#8644).
fn load_other_tabs_prefs(&mut self) {
let Some(prefs) = self.other_tab_prefs.take() else { return };
self.app.load_prefs(&prefs);
let held = self.persisted_prefs();
self.prefs_writer.loaded(held);
}
/// Files from the computer, writes to store, messages and the autosave.
fn files_tick(&mut self, ctx: &egui::Context) {
if !self.launch.persists() {
@@ -397,10 +271,6 @@ mod web {
!companion
});
for (name, bytes, intent) in incoming {
if intent == Intent::Theme {
solidcraft_ui_egui::prefs::import_theme_bytes(&mut self.app, bytes);
continue;
}
let design = solidcraft_engine::io::is_design_path(&name);
let path = if design && intent == Intent::Open {
// A design from the computer is kept in the browser (a copy), so Save works.
@@ -417,7 +287,7 @@ mod web {
};
match intent {
Intent::Open => self.app.open_path(&path),
Intent::Insert | Intent::Theme => self.app.drop_path(&path),
Intent::Insert => self.app.drop_path(&path),
}
self.files.panel.set(false);
}
@@ -483,8 +353,7 @@ mod web {
}
let mut act: Option<(&'static str, String)> = None;
let closed = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::new("sc_browser_files"), "folder", "Files in this browser")
// Room for long names; the kit still caps the window at the browser's width.
.cards(4.0)
.cards(3.0)
.show_body(ctx, |ui| {
ui.horizontal(|ui| {
if ui_kit::button(ui, "Open from computer…", ui_kit::Button::Normal).clicked() {
@@ -512,62 +381,61 @@ mod web {
return;
}
let entries = self.files.entries.borrow().clone();
if entries.is_empty() {
ui.label("Designs you save appear here. They stay in this browser.");
}
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
// The name takes the width the size and the actions leave (#8097).
let empty = "Designs you save appear here. They stay in this browser.";
ui_kit::name_list(ui, "opfs_files", &["Name", "Size", "Actions"], empty, |list| {
egui::Grid::new("opfs_files").num_columns(3).spacing([12.0, 6.0]).show(ui, |ui| {
for e in &entries {
list.row(&e.name, |row| {
match &mut self.renaming {
Some((from, to)) if *from == e.name => row.cell(|ui| {
let r =
ui_kit::cell_editor(ui, ui.make_persistent_id(("stored_file_name", from.as_str())), to, "", false);
if std::mem::take(&mut self.rename_focus) {
r.request_focus();
}
if r.lost_focus() {
act = Some(if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
("rename", to.clone())
} else {
("rename_cancel", String::new())
});
}
}),
_ => {
row.name(&e.name);
match &mut self.renaming {
Some((from, to)) if *from == e.name => {
let r = ui_kit::cell_editor(ui, ui.make_persistent_id(("stored_file_name", from.as_str())), to, "", false);
if std::mem::take(&mut self.rename_focus) {
r.request_focus();
}
if r.lost_focus() {
act = Some(if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
("rename", to.clone())
} else {
("rename_cancel", String::new())
});
}
}
row.cell(|ui| ui.label(format!("{:.1} KB", e.size as f64 / 1024.0)));
row.cell(|ui| {
if ui_kit::button(ui, "Open", ui_kit::Button::Small).clicked() {
act = Some(("open", e.name.clone()));
}
let design = solidcraft_engine::io::is_design_path(&e.name);
if !design
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
.clicked()
{
act = Some(("insert", e.name.clone()));
}
if ui_kit::button(ui, "Rename", ui_kit::Button::Small).clicked() {
self.renaming = Some((e.name.clone(), e.name.clone()));
self.rename_focus = true;
}
if ui_kit::button(ui, "Save to computer…", ui_kit::Button::Small)
.on_hover_text("A copy in a file you choose; the stored design stays here")
_ => {
ui.label(&e.name);
}
}
ui.label(format!("{:.1} KB", e.size as f64 / 1024.0));
ui.horizontal(|ui| {
if ui_kit::button(ui, "Open", ui_kit::Button::Small).clicked() {
act = Some(("open", e.name.clone()));
}
let design = solidcraft_engine::io::is_design_path(&e.name);
if !design
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
.clicked()
{
act = Some(("save_copy", e.name.clone()));
}
if ui_kit::button(ui, "Download", ui_kit::Button::Small).clicked() {
act = Some(("download", e.name.clone()));
}
if ui_kit::button(ui, "Delete", ui_kit::Button::Small).clicked() {
act = Some(("delete", e.name.clone()));
}
});
{
act = Some(("insert", e.name.clone()));
}
if ui_kit::button(ui, "Rename", ui_kit::Button::Small).clicked() {
self.renaming = Some((e.name.clone(), e.name.clone()));
self.rename_focus = true;
}
if ui_kit::button(ui, "Save to computer…", ui_kit::Button::Small)
.on_hover_text("A copy in a file you choose; the stored design stays here")
.clicked()
{
act = Some(("save_copy", e.name.clone()));
}
if ui_kit::button(ui, "Download", ui_kit::Button::Small).clicked() {
act = Some(("download", e.name.clone()));
}
if ui_kit::button(ui, "Delete", ui_kit::Button::Small).clicked() {
act = Some(("delete", e.name.clone()));
}
});
ui.end_row();
}
});
});
@@ -682,60 +550,32 @@ mod web {
files::autosave(&self.files, None);
}
/// Whether the shell already has the on-demand face the live language needs.
fn script_font_ready(&self) -> bool {
match solidcraft_ui_egui::fonts::web_script_font_kind(&self.app.preferences.language) {
Some("zh") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh"),
Some("zh-TW") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh-TW"),
Some("ja") => self.cjk_bytes.is_some(),
_ => true,
}
}
/// Fetch and install a script font when a CJK language is selected. If that fails, this
/// Fetch and install the CJK font when a CJK language is selected. If that fails, this
/// session falls back to English (no tofu); the saved language preference is kept.
fn tick_font(&mut self, ctx: &egui::Context) {
let done = self.font_result.borrow_mut().take();
if let Some(res) = done {
let kind = self.font_fetch_kind.take();
let installed = match res {
Ok((path, bytes)) => {
match kind {
Some("zh") => {
self.chinese_bytes = Some(bytes);
self.chinese_kind = Some("zh");
}
Some("zh-TW") => {
self.chinese_bytes = Some(bytes);
self.chinese_kind = Some("zh-TW");
}
_ => {
self.cjk_bytes = Some(bytes);
}
}
log::info!("loaded web script font from {path}");
Ok(bytes) => {
self.cjk_bytes = Some(bytes);
true
}
Err(err) => {
let path = match kind {
Some("zh") => CHINESE_SC_FONT_PATH,
Some("zh-TW") => CHINESE_TC_FONT_PATH,
_ => JAPANESE_FONT_PATH,
};
log::warn!("failed to fetch script font from {path}: {err}");
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
false
}
};
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(&self.app.preferences.language) {
log::warn!("script font unavailable; showing English for this session");
if installed {
log::info!("loaded web CJK font from {JAPANESE_FONT_PATH}");
} else if solidcraft_ui_egui::fonts::needs_cjk_font(&self.app.preferences.language) {
log::warn!("CJK font unavailable; showing English for this session");
}
self.font.finished(installed, &mut self.app.preferences.language);
self.apply_fonts(ctx);
ctx.request_repaint();
}
let ready = self.script_font_ready();
if self.font.tick(&mut self.app.preferences.language, ready) {
self.fetch_font(ctx, solidcraft_ui_egui::fonts::web_script_font_kind(&self.app.preferences.language).unwrap_or("ja"));
if self.font.tick(&mut self.app.preferences.language) {
self.fetch_font(ctx);
}
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
// to Ukrainian (or English) must put the Latin face back in front.
@@ -744,42 +584,31 @@ mod web {
}
}
/// Set up the UI fonts for the live language, keeping fetched script fonts as fallbacks
/// (Chinese leads a Chinese UI; see [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
/// Set up the UI fonts for the live language, keeping the fetched CJK font as a fallback
/// (it leads only for a CJK UI, see [`solidcraft_ui_egui::fonts::install_cjk_font_for`]).
fn apply_fonts(&mut self, ctx: &egui::Context) {
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() {
solidcraft_ui_egui::fonts::setup_fonts(ctx);
} else {
solidcraft_ui_egui::fonts::install_web_script_fonts(
ctx,
self.cjk_bytes.clone(),
self.chinese_bytes.clone(),
&self.app.preferences.language,
);
match self.cjk_bytes.clone() {
Some(bytes) => {
solidcraft_ui_egui::fonts::install_cjk_font_for(ctx, bytes, &self.app.preferences.language);
}
None => solidcraft_ui_egui::fonts::setup_fonts(ctx),
}
self.font_language.clone_from(&self.app.preferences.language);
}
/// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name):
/// fetch the Japanese font, so the text does not stay tofu (#720).
/// CJK text on screen with no CJK font yet (an English UI showing a Japanese name):
/// fetch the font, so the text does not stay tofu (#720).
fn watch_text_for_font(&mut self, ctx: &egui::Context) {
if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() {
self.fetch_font(ctx, "ja");
self.fetch_font(ctx);
}
}
fn fetch_font(&mut self, ctx: &egui::Context, kind: &'static str) {
let path = match kind {
"zh" => CHINESE_SC_FONT_PATH,
"zh-TW" => CHINESE_TC_FONT_PATH,
_ => JAPANESE_FONT_PATH,
};
self.font_fetch_kind = Some(kind);
fn fetch_font(&self, ctx: &egui::Context) {
let slot = self.font_result.clone();
let ctx = ctx.clone();
let path = path.to_owned();
wasm_bindgen_futures::spawn_local(async move {
let res = fetch_font_bytes(&path).await.map(|bytes| (path, bytes));
let res = fetch_font_bytes(JAPANESE_FONT_PATH).await;
*slot.borrow_mut() = Some(res);
ctx.request_repaint();
});
@@ -788,15 +617,12 @@ mod web {
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// First: a language another tab chose fetches its font this frame.
self.load_other_tabs_prefs();
self.tick_font(ctx);
self.app.logic(ctx);
self.api.poll();
self.space_mouse.logic(&mut self.app, ctx);
self.files_tick(ctx);
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
DESIGN_SHOWN.with(|d| d.set(solidcraft_ui_egui::toolbar::design_shown(&self.app)));
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
let now = raw.time.unwrap_or_else(|| ctx.input(|i| i.time));
@@ -804,8 +630,7 @@ mod web {
ctx.request_repaint_after(delay);
}
if self.launch.persists() {
let theme = solidcraft_ui_egui::prefs::theme_drop_target(&self.app, ctx);
files::take_drops(&self.files, raw, theme);
files::take_drops(&self.files, raw);
} else {
raw.dropped_files.clear();
}
@@ -832,7 +657,6 @@ mod web {
self.recovery_prompt(&ctx);
}
self.watch_text_for_font(&ctx);
self.store_prefs(&ctx);
}
fn persist_egui_memory(&self) -> bool {
self.launch.persists()
@@ -841,8 +665,11 @@ mod web {
if !self.launch.persists() {
return;
}
let prefs = self.persisted_prefs();
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs);
// Persist the user's own language, not a session-only English fallback.
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
let live = std::mem::replace(&mut self.app.preferences.language, saved);
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.app.prefs());
self.app.preferences.language = live;
}
}
@@ -895,7 +722,6 @@ mod web {
ask_before_unload(&window);
}
leave_tab_switching_to_browser(&window);
keep_ctrl_w_for_the_design(&window);
let Some(document) = window.document() else { return };
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
@@ -963,12 +789,6 @@ mod web {
let (sender, receiver) = std::sync::mpsc::channel();
let mut app = SolveApp::new(Session::default(), services).with_control(receiver);
app.embedded = launch.embed;
// Listening before reading: settings another tab writes from now on are
// newer than those read here.
let other_tab_prefs = PrefsInbox::default();
if launch.persists() {
hear_settings_from_other_tabs(&window, &cc.egui_ctx, other_tab_prefs.clone());
}
if launch.persists() && let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
app.load_prefs(&p);
}
@@ -1009,14 +829,9 @@ mod web {
font: Default::default(),
font_result: Rc::new(std::cell::RefCell::new(None)),
cjk_bytes: None,
chinese_bytes: None,
chinese_kind: None,
font_fetch_kind: None,
font_language,
space_mouse: SpaceMouse::default(),
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
prefs_writer: crate::PrefsWriter::default(),
other_tab_prefs,
}))
}),
)
@@ -1139,42 +954,4 @@ mod tests {
assert!(!browser_switches_tabs("Tab", false));
assert!(!browser_switches_tabs("s", true));
}
#[test]
fn ctrl_w_closes_the_design_not_the_tab() {
// Ctrl+W and ⌘W (either case of the key) close the design shown.
assert!(closes_design("w", true, false, false, true));
assert!(closes_design("W", true, false, false, true));
// On the start page the browser closes the tab.
assert!(!closes_design("w", true, false, false, false));
// Ctrl+Shift+W (close the window), plain W and other keys stay the browser's or the app's.
assert!(!closes_design("W", true, true, false, true));
assert!(!closes_design("w", true, false, true, true));
assert!(!closes_design("w", false, false, false, true));
assert!(!closes_design("s", true, false, false, true));
}
/// A tab writes its settings when they change, and not again while they stay the same; the
/// settings it loaded from another tab aren't written back (#8644).
#[test]
fn changed_settings_are_written_once_and_loaded_ones_not_at_all() {
let mut writer = PrefsWriter::default();
assert_eq!(writer.take(r#"{"a":1}"#.into()).as_deref(), Some(r#"{"a":1}"#), "the first settings are written");
assert_eq!(writer.take(r#"{"a":1}"#.into()), None, "the same settings are not written again");
assert_eq!(writer.take(r#"{"a":2}"#.into()).as_deref(), Some(r#"{"a":2}"#), "a change is written at once");
writer.loaded(r#"{"a":3}"#.into());
assert_eq!(writer.take(r#"{"a":3}"#.into()), None, "settings from another tab are not written back");
assert_eq!(writer.take(r#"{"a":4}"#.into()).as_deref(), Some(r#"{"a":4}"#), "this tab's next change is written");
}
/// Only the settings another tab wrote are loaded: not other keys, and not settings taken
/// away by clearing the site's data (#8644).
#[test]
fn only_settings_another_tab_wrote_are_loaded() {
let key = solidcraft_ui_egui::prefs::STORAGE_KEY;
assert_eq!(settings_written(Some(key), Some("{}".into())).as_deref(), Some("{}"));
assert_eq!(settings_written(Some("solidcraft.webgl"), Some("1".into())), None);
assert_eq!(settings_written(Some(key), None), None, "removed settings leave this tab's");
assert_eq!(settings_written(None, None), None, "clearing all storage leaves this tab's settings");
}
}
-12
View File
@@ -28,8 +28,6 @@ neg_cmp_op_on_partial_ord = "allow"
[dependencies]
solidcraft-engine = { workspace = true }
# The D3D12 shader compiler the window starts with (#6756).
solidcraft-gpu = { workspace = true }
solidcraft-ui-egui = { workspace = true }
solidcraft-space-mouse = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
@@ -98,16 +96,6 @@ libc = "0.2"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
# Bringing the window forward on X11 when a close asks about unsaved designs (src/come_forward.rs):
# the window's id, and the window manager request winit doesn't send (MIT OR Apache-2.0; both
# already in the tree through eframe and winit).
raw-window-handle = "0.6"
x11rb = { version = "0.13", default-features = false }
[target.'cfg(target_os = "linux")'.dependencies]
# The desktop's dark or light preference through the XDG Desktop Portal (src/desktop_theme.rs,
# #7944); already in the tree through eframe's accessibility support (accesskit_unix).
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
[build-dependencies]
embed-resource = "3.0.11"
+1 -16
View File
@@ -4,7 +4,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let icon = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/icons/app/windows/exports/solidcraft-executable.ico");
println!("cargo:rerun-if-changed={}", icon.display());
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION");
if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" {
return Ok(());
}
@@ -12,21 +11,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let copy = out.join("solidcraft.ico");
std::fs::copy(icon, &copy)?;
let rc = out.join("solidcraft.rc");
let icon_line = format!("1 ICON \"{}\"\n", copy.display().to_string().replace('\\', "/"));
std::fs::write(&rc, format!("{icon_line}{}", version_resource(&std::env::var("CARGO_PKG_VERSION")?)))?;
std::fs::write(&rc, format!("1 ICON \"{}\"\n", copy.display().to_string().replace('\\', "/")))?;
embed_resource::compile_for(&rc, ["solidcraft"], embed_resource::NONE).manifest_required()?;
Ok(())
}
/// The Windows version resource (what Explorer's Details tab shows and what a launcher reads).
/// `FileVersion` and `ProductVersion` are the package version verbatim (`0.25.1`, `0.26.0-rc.1`):
/// ArtCraft's launcher parses the `ProductVersion` string as semver, so a fourth `.0` would not
/// read. The binary `FILEVERSION` fields need four numbers and take the leading `major.minor.patch`.
fn version_resource(version: &str) -> String {
let mut numbers = version.split(['-', '+']).next().unwrap_or("").split('.').map(|p| p.parse::<u16>().unwrap_or(0));
let mut next = || numbers.next().unwrap_or(0);
let (major, minor, patch) = (next(), next(), next());
format!(
"1 VERSIONINFO\nFILEVERSION {major},{minor},{patch},0\nPRODUCTVERSION {major},{minor},{patch},0\nFILEOS 0x40004\nFILETYPE 0x1\n{{\nBLOCK \"StringFileInfo\"\n{{\nBLOCK \"040904B0\"\n{{\nVALUE \"CompanyName\", \"SolidCraft contributors\"\nVALUE \"FileDescription\", \"SolidCraft, parametric 3D CAD\"\nVALUE \"FileVersion\", \"{version}\"\nVALUE \"InternalName\", \"solidcraft\"\nVALUE \"OriginalFilename\", \"solidcraft.exe\"\nVALUE \"ProductName\", \"SolidCraft\"\nVALUE \"ProductVersion\", \"{version}\"\n}}\n}}\nBLOCK \"VarFileInfo\"\n{{\nVALUE \"Translation\", 0x0409, 1200\n}}\n}}\n"
)
}
-234
View File
@@ -1,234 +0,0 @@
//! Bringing the window forward when a close stops at "save changes?".
//!
//! A close from the taskbar or a window switcher reaches the window while another app has the
//! focus. The prompt it opens is only seen once the window comes forward, so the window asks for
//! the focus then. A window the user is already in is left alone.
//!
//! winit's Focus asks as an application, which a window manager with focus-stealing prevention
//! (KWin's default) refuses for a window used before the switch away: it only marks the taskbar
//! entry. On X11 the request is sent again with the source EWMH gives "direct user actions"
//! (`_NET_ACTIVE_WINDOW`, source 2), which is what this is: the prompt is the rest of the user's
//! close. Native Wayland has no way for a window to raise itself, so there the attention request
//! (xdg-activation) lets the compositor raise it or mark its taskbar entry.
//!
//! When: not at once. KWin raises the window, then the taskbar's closing context menu hands the
//! focus back to the app that had it, a few milliseconds later, and the window would flicker
//! forward and back. So the window asks once the menu is gone, `FIRST_AFTER` the close. It asks
//! again only if the focus is taken back right after it came (a slow menu) or never came; a later
//! switch is the user's own, and the window stays where the user put it.
use std::time::{Duration, Instant};
/// From the close to the first request: past the taskbar menu's focus hand-back (2 to 11 ms on
/// KDE Plasma 6), too short to notice.
const FIRST_AFTER: Duration = Duration::from_millis(120);
/// After the first request, how long the window asks again when it loses the focus: short of
/// anything the user does next.
const KEEP_ASKING: Duration = Duration::from_millis(400);
/// The least time between two requests.
const AGAIN_AFTER: Duration = Duration::from_millis(100);
/// Focus lost this soon after a request was taken back by the taskbar menu; later, by the user.
const TAKEN_BACK_WITHIN: Duration = Duration::from_millis(50);
/// The window coming forward after a close that asks about unsaved designs.
#[derive(Default)]
pub struct ComeForward {
/// The first request and the end of asking; `None`: not asking.
window: Option<(Instant, Instant)>,
/// The last request.
last: Option<Instant>,
/// The window has had the focus since the last request.
held: bool,
}
/// What a frame does while the window is coming forward.
#[derive(Debug, PartialEq)]
enum Step {
/// Ask for the focus now.
Ask,
/// Look again after this long.
Wait(Duration),
/// Done asking.
Done,
}
impl ComeForward {
/// A close stopped at the prompt: ask for the focus shortly.
pub fn start(&mut self, now: Instant) {
let first = now + FIRST_AFTER;
self.window = Some((first, first + KEEP_ASKING));
self.last = None;
self.held = false;
}
/// Every frame: ask once the taskbar menu is gone, and again while the focus is taken back.
pub fn tick(&mut self, ctx: &egui::Context, frame: &eframe::Frame) {
if self.window.is_none() {
return;
}
let (focused, minimized) = ctx.input(|i| (i.viewport().focused, i.viewport().minimized));
// The app may be idle: a frame to look again.
match self.step(Instant::now(), focused) {
Step::Ask => {
bring(ctx, frame, minimized);
ctx.request_repaint_after(AGAIN_AFTER);
}
Step::Wait(after) => ctx.request_repaint_after(after),
Step::Done => {}
}
}
fn step(&mut self, now: Instant, focused: Option<bool>) -> Step {
let Some((first, until)) = self.window else { return Step::Done };
if now >= until {
self.window = None;
return Step::Done;
}
if now < first {
return Step::Wait(first - now);
}
let Some(last) = self.last else {
if focused == Some(true) {
// The user went back to the window already.
self.window = None;
return Step::Done;
}
self.last = Some(now);
return Step::Ask;
};
if focused == Some(true) {
self.held = true;
return Step::Wait(until - now);
}
if self.held {
if now.duration_since(last) >= TAKEN_BACK_WITHIN {
// The user switched away: leave the window there.
self.window = None;
return Step::Done;
}
self.held = false;
}
let next = last + AGAIN_AFTER;
if now < next {
return Step::Wait(next - now);
}
self.last = Some(now);
Step::Ask
}
}
/// Ask for the focus once.
fn bring(ctx: &egui::Context, frame: &eframe::Frame, minimized: Option<bool>) {
#[cfg(all(unix, not(target_os = "macos")))]
let raised = match x11_activate(frame) {
Ok(sent) => sent,
Err(e) => {
log::warn!("bringing the window forward through X11: {e}");
false
}
};
#[cfg(not(all(unix, not(target_os = "macos"))))]
let raised = {
let _ = frame;
false
};
for c in commands(minimized, raised) {
ctx.send_viewport_cmd(c);
}
}
/// The viewport commands for a window in the background; `raised`: the X11 request went out,
/// which also restores a minimized window.
fn commands(minimized: Option<bool>, raised: bool) -> Vec<egui::ViewportCommand> {
if raised {
return vec![egui::ViewportCommand::Focus];
}
let mut commands = Vec::new();
if minimized == Some(true) {
commands.push(egui::ViewportCommand::Minimized(false));
}
commands.push(egui::ViewportCommand::Focus);
commands.push(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational));
commands
}
/// Ask the X11 window manager to activate the window as a user action. `Ok(false)`: the window
/// isn't an X11 window (native Wayland).
#[cfg(all(unix, not(target_os = "macos")))]
fn x11_activate(frame: &eframe::Frame) -> Result<bool, String> {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{ClientMessageEvent, ConnectionExt, EventMask};
let handle = frame.window_handle().map_err(|e| e.to_string())?;
let window = match handle.as_raw() {
RawWindowHandle::Xlib(h) => u32::try_from(h.window).map_err(|e| e.to_string())?,
RawWindowHandle::Xcb(h) => h.window.get(),
_ => return Ok(false),
};
// A connection of its own: winit's isn't reachable, and a close that asks is rare.
let (conn, screen) = x11rb::connect(None).map_err(|e| e.to_string())?;
let root = conn.setup().roots.get(screen).ok_or("no X11 screen")?.root;
let atom = conn.intern_atom(false, b"_NET_ACTIVE_WINDOW").map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?.atom;
// Source 2: a direct user action; the time is the server's current time.
let event = ClientMessageEvent::new(32, window, atom, [2, x11rb::CURRENT_TIME, 0, 0, 0]);
conn.send_event(false, root, EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, event).map_err(|e| e.to_string())?;
conn.flush().map_err(|e| e.to_string())?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::{AGAIN_AFTER, ComeForward, FIRST_AFTER, KEEP_ASKING, Step};
use eframe::egui::{UserAttentionType, ViewportCommand as C};
use std::time::{Duration, Instant};
/// The first request waits until the taskbar menu has handed the focus back. The window asks
/// again when the focus is taken back right after it came, or never came, no more often than
/// every `AGAIN_AFTER` and only for `KEEP_ASKING`; a later switch away is the user's.
#[test]
fn the_window_asks_once_the_taskbar_menu_is_gone() {
let t0 = Instant::now();
let ms = |n: u64| Duration::from_millis(n);
let at = |n: u64| t0 + ms(n);
let (first, again, keep) = (FIRST_AFTER.as_millis() as u64, AGAIN_AFTER.as_millis() as u64, KEEP_ASKING.as_millis() as u64);
let mut f = ComeForward::default();
assert_eq!(f.step(t0, Some(false)), Step::Done, "not started");
f.start(t0);
assert_eq!(f.step(t0, Some(false)), Step::Wait(FIRST_AFTER), "the menu is still closing");
assert_eq!(f.step(at(5), Some(false)), Step::Wait(ms(first - 5)));
assert_eq!(f.step(at(first), Some(false)), Step::Ask);
assert_eq!(f.step(at(first + 3), Some(true)), Step::Wait(ms(keep - 3)), "raised");
// A slow menu takes the focus back 10 ms later: ask again, not before AGAIN_AFTER.
assert_eq!(f.step(at(first + 10), Some(false)), Step::Wait(ms(again - 10)));
assert_eq!(f.step(at(first + again), Some(false)), Step::Ask);
assert_eq!(f.step(at(first + again + 3), Some(true)), Step::Wait(ms(keep - again - 3)));
// The user clicks another app 200 ms later: the window stays behind.
assert_eq!(f.step(at(first + again + 200), Some(false)), Step::Done);
assert_eq!(f.step(at(first + again + 300), Some(false)), Step::Done);
// Never raised (the request was refused): ask again every AGAIN_AFTER, for KEEP_ASKING.
f.start(t0);
assert_eq!(f.step(at(first), None), Step::Ask, "unknown focus counts as out of focus");
assert_eq!(f.step(at(first + again), Some(false)), Step::Ask);
assert_eq!(f.step(at(first + keep), Some(false)), Step::Done);
// Back in the window before the first request: nothing to do.
f.start(t0);
assert_eq!(f.step(at(first), Some(true)), Step::Done);
}
/// Without the X11 request: focus, and the attention mark where focus is refused; a minimized
/// window is restored first.
#[test]
fn a_window_closed_from_the_background_comes_forward() {
let forward = [C::Focus, C::RequestUserAttention(UserAttentionType::Informational)];
assert_eq!(super::commands(Some(false), false), forward);
assert_eq!(super::commands(None, false), forward, "unknown counts as not minimized");
let minimized = super::commands(Some(true), false);
assert_eq!(minimized.first(), Some(&C::Minimized(false)));
assert_eq!(minimized.get(1..), Some(&forward[..]));
// The window manager took the X11 request: nothing left to mark.
assert_eq!(super::commands(Some(true), true), [C::Focus]);
}
}
+24 -130
View File
@@ -24,9 +24,9 @@ const MAX_BAD_LINES: usize = 8;
const EVENT_GAP: Duration = Duration::from_millis(100);
static OPEN: AtomicUsize = AtomicUsize::new(0);
/// The token files this launch wrote (its control port, the agent chat's endpoint) and the
/// token in each, removed when the app ends unless another launch has written the file since.
static TOKEN_FILES: std::sync::Mutex<Vec<(std::path::PathBuf, String)>> = std::sync::Mutex::new(Vec::new());
/// The token files this launch wrote (its control port, the agent chat's endpoint), removed
/// when the app ends.
static TOKEN_FILES: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
/// A new random token: 32 bytes from the operating system, as hex.
fn new_token() -> Option<String> {
@@ -35,21 +35,20 @@ fn new_token() -> Option<String> {
Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
}
/// Remove this launch's token files and discovery file (at exit), leaving any that another
/// launch has written since (#8794).
/// Remove this launch's token file (at exit).
pub fn remove_token_file() {
if let Ok(files) = TOKEN_FILES.lock() {
for (path, token) in files.iter() {
control_auth::remove_token_file(path, token);
for p in files.iter() {
control_auth::remove_token_file(p);
}
}
if let Some((port, token)) = APP_PORT.lock().ok().and_then(|p| p.clone()) {
control_auth::remove_current_port(port, &token);
if let Some(port) = APP_PORT.lock().ok().and_then(|p| *p) {
control_auth::remove_current_port(port);
}
}
/// The port the app's own channel listens on and its token, for removing the discovery file at exit.
static APP_PORT: std::sync::Mutex<Option<(u16, String)>> = std::sync::Mutex::new(None);
/// The port the app's own channel listens on, for removing the discovery file at exit.
static APP_PORT: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
@@ -62,7 +61,7 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
};
match listener {
Ok(l) => {
if let Some(port) = listen_on_listener(l, token.clone(), tx, ctx, false) {
if let Some(port) = listen_on_listener(l, token, tx, ctx, false) {
if matches!(listen_on, control_auth::Listen::Default) {
eprintln!("solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)");
}
@@ -70,7 +69,7 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
match control_auth::write_current_port(port) {
Ok(_) => {
if let Ok(mut p) = APP_PORT.lock() {
*p = Some((port, token));
*p = Some(port);
}
}
Err(e) => eprintln!("solidcraft: could not write the control discovery file ({e})"),
@@ -113,7 +112,7 @@ fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRe
Ok(()) => {
eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
if let Ok(mut files) = TOKEN_FILES.lock() {
files.push((path, token.clone()));
files.push(path);
}
}
Err(e) => eprintln!(
@@ -413,37 +412,11 @@ impl Lines {
}
}
/// A subscribed connection's seat on the watch: it leaves exactly once, whichever thread sees
/// the connection's end first — the connection's own thread at the end of the stream, or the
/// events thread at the first write a closed client refuses. The seat used to wait for the
/// events thread alone, and that thread only leaves its wait when the design moves, so the
/// count could outlive the connection by a second or more (#8315).
struct Seat(Mutex<Option<Subscriber>>);
impl Seat {
fn new(counted: Subscriber) -> Self {
Seat(Mutex::new(Some(counted)))
}
/// Leave the watch now: the seat goes once, whoever gets here first.
fn release(&self) {
let counted = self.0.lock().unwrap_or_else(PoisonError::into_inner).take();
drop(counted);
}
}
impl Drop for Seat {
fn drop(&mut self) {
self.release();
}
}
/// A connection's `control.subscribe` (#6985): its `design.changed` lines, sent from a thread of
/// their own until the connection ends.
struct Subscription {
lines: Arc<Lines>,
stop: Arc<AtomicBool>,
seat: Arc<Seat>,
}
impl Subscription {
@@ -459,17 +432,15 @@ impl Subscription {
) -> Option<(Self, std::io::Result<()>)> {
let lines = Arc::new(Lines { socket: out.try_clone().ok()?, turn: Mutex::new(()) });
let stop = Arc::new(AtomicBool::new(false));
let seat = Arc::new(Seat::new(counted));
let first = lines.turn.lock().unwrap_or_else(PoisonError::into_inner);
let (l, s, watch_seat) = (lines.clone(), stop.clone(), Arc::clone(&seat));
let (l, s) = (lines.clone(), stop.clone());
// The connection's seat on the watch rides the events thread: it leaves when the thread
// does, so the next failed write to a closed client retires it even while the connection's
// own thread is slow to see the end of the stream (#7226). The connection's own thread
// leaves it too, so a closed connection stops counting at once (#8315).
std::thread::Builder::new().stack_size(64 * 1024).spawn(move || send_events(design, &l, &s, seen, watch_seat)).ok()?;
// own thread is slow to see the end of the stream (#7226).
std::thread::Builder::new().stack_size(64 * 1024).spawn(move || send_events(design, &l, &s, seen, counted)).ok()?;
let sent = send(&mut &lines.socket, reply);
drop(first);
Some((Subscription { lines, stop, seat }, sent))
Some((Subscription { lines, stop }, sent))
}
}
@@ -482,9 +453,6 @@ fn design_of(reply: &Value) -> Option<(String, u64)> {
impl Drop for Subscription {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
// The seat on the watch goes now: the connection is over, and nothing counts it any more
// (#8315).
self.seat.release();
// The socket closes now, not when the thread next looks; a line blocked on a client that
// stopped reading ends too.
let _ = self.lines.socket.shutdown(std::net::Shutdown::Both);
@@ -492,10 +460,10 @@ impl Drop for Subscription {
}
/// The `design.changed` lines of a subscribed connection, each time the design differs from the
/// last one it was told of (`seen`), until `stop`. Holds the connection's seat on the watch
/// (`seat`), which it leaves when the thread ends: on `stop`, or on the first write a closed
/// client refuses (#7226).
fn send_events(design: &DesignWatch, lines: &Lines, stop: &AtomicBool, mut seen: (String, u64), seat: Arc<Seat>) {
/// last one it was told of (`seen`), until `stop`. Owns the connection's seat on the watch
/// (`counted`), so the seat leaves exactly when this thread does: on `stop`, or on the first
/// write a closed client refuses (#7226).
fn send_events(design: &DesignWatch, lines: &Lines, stop: &AtomicBool, mut seen: (String, u64), _counted: Subscriber) {
while !stop.load(Ordering::Relaxed) {
let Some(now) = design.wait(&seen, stop, Duration::from_secs(1)).filter(|now| *now != seen) else { continue };
if lines.send(&json!({"event": "design.changed", "params": {"document_id": now.0, "revision": now.1}})).is_err() {
@@ -504,17 +472,6 @@ fn send_events(design: &DesignWatch, lines: &Lines, stop: &AtomicBool, mut seen:
seen = now;
std::thread::sleep(EVENT_GAP);
}
// The client is gone: it is not counted on the watch any more, whatever the connection's own
// thread still has to notice (#8315).
seat.release();
}
/// What this app's control channel does beyond the methods every version has, for a client to
/// find out before it relies on it (an older app answers `control.capabilities` with "unknown
/// method"). `param_check`: the levels `engine.execute`, `engine.batch` and `engine.script` take
/// as a request's own parameter check (#9184).
fn capabilities() -> Value {
json!({"param_check": solidcraft_engine::schema::ParamCheck::ALL.map(|l| l.name())})
}
/// Write `reply` on the connection: through its subscription once it has one.
@@ -577,20 +534,15 @@ fn serve_with(
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
// A request that timed out may have finished since (#1618). `control.running` and
// `control.capabilities` only look, so they leave that reply where it is.
let late = pending.as_ref().filter(|_| !matches!(method.as_str(), "control.running" | "control.capabilities")).and_then(|p| match p
.reply
.try_recv()
{
// A request that timed out may have finished since (#1618). `control.running` only
// looks, so it leaves that reply where it is.
let late = pending.as_ref().filter(|_| method != "control.running").and_then(|p| match p.reply.try_recv() {
Ok(reply) => Some(reply),
Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app ended the request without a reply"})),
Err(TryRecvError::Empty) => None,
});
let mut r = if method == "control.running" {
json!({"ok": true, "result": running.result(connection)})
} else if method == "control.capabilities" {
json!({"ok": true, "result": capabilities()})
} else if method == "control.pending" {
match (pending.take(), late) {
(None, _) => json!({"ok": true, "result": {"pending": null}}),
@@ -797,9 +749,6 @@ mod tests {
let mut watcher = open();
let (r, rows) = running(&mut watcher, &|_| true);
assert!(r["ok"] == true && rows.is_empty(), "idle: {r}");
// What the app's control channel does beyond the methods every version has (#9184).
let r = watcher(json!({"id": 0, "method": "control.capabilities", "token": token}));
assert_eq!((&r["ok"], &r["result"]["param_check"]), (&json!(true), &json!(["off", "warn", "strict"])), "{r}");
let mut export = open();
let path = "/private/part.step";
@@ -831,9 +780,6 @@ mod tests {
let own: Vec<&Value> = r["result"]["running"].as_array().unwrap().iter().filter(|row| row["self"] == true).collect();
assert_eq!(own.len(), 1, "{r}");
assert_eq!(own[0]["command"], "file.export", "{r}");
// So may it ask what the app can do: answered at once, not refused as busy.
let r = export(json!({"id": 8, "method": "control.capabilities"}));
assert_eq!(r["result"]["param_check"], json!(["off", "warn", "strict"]), "{r}");
gate_tx.send(()).unwrap();
assert_eq!(queued.join().unwrap()["result"], "engine.batch");
@@ -939,58 +885,6 @@ mod tests {
assert!(!WATCH.subscribed());
}
/// #8315: a closed connection leaves the watch at once. The seat of a subscribed connection
/// goes when the connection's own thread sees the end of the stream, not only when the
/// events thread notices: that thread waits for the design to move, so a connection that
/// closed while the design stood still kept the watch counting it for a second or more.
#[test]
fn a_closed_connection_leaves_the_watch_at_once() {
static RUNNING: Running = Running::new();
static WATCH: DesignWatch = DesignWatch::new();
let token = "0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
// The window names the design once and then leaves it alone: nothing moves it, so the
// events thread has nothing to write and only the end of the connection can retire it.
std::thread::spawn(move || {
for req in rx {
if req.method == "control.subscribe" {
WATCH.publish("d-1", 3);
let _ = req.reply.send(json!({"ok": true, "result": {"document_id": "d-1", "revision": 3}}));
} else {
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
}
});
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, WAITS, &RUNNING, &WATCH));
}
});
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let mut out = s.try_clone().unwrap();
let mut lines = BufReader::new(s);
writeln!(out, "{}", json!({"id": 1, "method": "control.subscribe", "token": token})).unwrap();
let mut l = String::new();
lines.read_line(&mut l).unwrap();
let reply: Value = serde_json::from_str(&l).unwrap();
assert!(reply["ok"] == true, "{reply}");
assert!(WATCH.subscribed(), "the connection is subscribed");
// Let the events thread settle into its wait: the design stands still, so it has nothing
// to write, and only the end of the connection can retire it.
std::thread::sleep(Duration::from_millis(200));
// The client goes away without asking anything else.
drop((out, lines));
let deadline = Instant::now() + Duration::from_millis(400);
while WATCH.subscribed() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(!WATCH.subscribed(), "a closed connection is not counted on the watch");
}
/// A timed-out request that only reads is not said to "still apply", nor is one that only
/// writes a file (an export, #1989); a change is, by name.
#[test]
-195
View File
@@ -1,195 +0,0 @@
//! Linux: the desktop's dark or light preference, for Theme: System (#7944).
//!
//! The window toolkit does not pass it on everywhere: on GNOME (Wayland, or X11 through XWayland)
//! the window never learns it, nor when it changes, so the app kept its colours. This reads the
//! freedesktop setting through the XDG Desktop Portal's Settings interface on the session bus
//! (`org.freedesktop.appearance color-scheme`, then GNOME's `color-scheme` and `gtk-theme`), the
//! same setting other applications follow, and inside a Flatpak too. It runs on a thread of its
//! own: the first reading, then the portal's `SettingChanged` signal; where the signal can't be
//! had, a reading every few seconds. Without a portal it asks `gsettings` instead, on the same
//! thread. The window reads the last answer with an atomic load
//! (`solidcraft_ui_egui::Services::desktop_dark`), and the decision from the raw values is
//! `solidcraft_ui_egui::prefs::desktop_prefers_dark`.
use std::io::Read;
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use std::time::Duration;
use zbus::zvariant::{OwnedValue, Value};
const UNKNOWN: u8 = 0;
const DARK: u8 = 1;
const LIGHT: u8 = 2;
/// How often to read again where the portal's change signal is not available.
const POLL: Duration = Duration::from_secs(5);
/// How long one `gsettings` reading may take before it is given up.
const GSETTINGS_TIMEOUT: Duration = Duration::from_secs(2);
const PORTAL: &str = "org.freedesktop.portal.Desktop";
const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
const SETTINGS: &str = "org.freedesktop.portal.Settings";
const APPEARANCE: &str = "org.freedesktop.appearance";
const GNOME: &str = "org.gnome.desktop.interface";
/// Start following the desktop's preference; the closure answers the latest reading (`None`
/// until there is one, or when the desktop says nothing).
pub fn start(ctx: &egui::Context) -> Box<dyn Fn() -> Option<bool>> {
let state = Arc::new(AtomicU8::new(UNKNOWN));
let (thread_state, ctx) = (state.clone(), ctx.clone());
if let Err(e) = std::thread::Builder::new().name("desktop-theme".into()).spawn(move || follow(&thread_state, &ctx)) {
log::warn!("desktop theme: no thread to follow it: {e}");
}
Box::new(move || match state.load(Ordering::Relaxed) {
DARK => Some(true),
LIGHT => Some(false),
_ => None,
})
}
/// Keep `state` on the desktop's preference, asking for a frame whenever it changes.
fn follow(state: &AtomicU8, ctx: &egui::Context) {
let publish = |dark: Option<bool>| {
let code = match dark {
Some(true) => DARK,
Some(false) => LIGHT,
None => UNKNOWN,
};
if state.swap(code, Ordering::Relaxed) != code {
ctx.request_repaint();
}
};
let portal = match portal() {
Ok(p) => Some(p),
Err(e) => {
log::info!("desktop theme: no settings portal ({e}); asking gsettings");
None
}
};
// The portal first; gsettings where there is none or it says nothing (its backend missing).
let read = |portal: Option<&zbus::blocking::Proxy<'static>>| portal.and_then(read_portal).or_else(read_gsettings);
let from_portal = portal.as_ref().and_then(read_portal);
let first = from_portal.or_else(read_gsettings);
publish(first);
// Wait for the portal's signal only when the portal answers: else the reading is gsettings',
// which only a new reading notices.
if let Some(p) = portal.as_ref().filter(|_| from_portal.is_some()) {
match p.receive_signal("SettingChanged") {
Ok(changes) => {
for message in changes {
if relevant(&message) {
publish(read(Some(p)));
}
}
log::info!("desktop theme: the portal's change signal ended; reading every few seconds");
}
Err(e) => log::info!("desktop theme: no change signal ({e}); reading every few seconds"),
}
} else if portal.is_none() && first.is_none() && !gsettings_available() {
// Neither the portal nor gsettings: nothing to follow; the window toolkit's theme stays.
return;
}
loop {
std::thread::sleep(POLL);
publish(read(portal.as_ref()));
}
}
/// The portal's Settings interface on the session bus.
fn portal() -> zbus::Result<zbus::blocking::Proxy<'static>> {
let connection = zbus::blocking::Connection::session()?;
zbus::blocking::Proxy::new(&connection, PORTAL, PORTAL_PATH, SETTINGS)
}
/// One reading through the portal.
fn read_portal(portal: &zbus::blocking::Proxy<'static>) -> Option<bool> {
let appearance = read_setting(portal, APPEARANCE, "color-scheme").and_then(|v| as_u32(&v));
// The GNOME settings only matter when the freedesktop one has no preference.
if matches!(appearance, Some(1 | 2)) {
return solidcraft_ui_egui::prefs::desktop_prefers_dark(appearance, None, None);
}
let gnome = read_setting(portal, GNOME, "color-scheme").and_then(|v| as_string(&v));
let gtk_theme = read_setting(portal, GNOME, "gtk-theme").and_then(|v| as_string(&v));
solidcraft_ui_egui::prefs::desktop_prefers_dark(appearance, gnome.as_deref(), gtk_theme.as_deref())
}
/// A setting through `ReadOne` (portal version 2), else the older `Read`.
fn read_setting(portal: &zbus::blocking::Proxy<'static>, namespace: &str, key: &str) -> Option<OwnedValue> {
let one: zbus::Result<OwnedValue> = portal.call("ReadOne", &(namespace, key));
one.or_else(|_| -> zbus::Result<OwnedValue> { portal.call("Read", &(namespace, key)) }).ok()
}
/// Whether a `SettingChanged` signal is about a setting the preference is read from.
fn relevant(message: &zbus::Message) -> bool {
let body = message.body();
let Ok((namespace, key, _)) = body.deserialize::<(String, String, OwnedValue)>() else { return false };
matches!((namespace.as_str(), key.as_str()), (APPEARANCE, "color-scheme") | (GNOME, "color-scheme" | "gtk-theme"))
}
/// The value inside the variants `Read` wraps it in (a portal answers one or two deep).
fn unwrap_variants<'v, 'a>(value: &'v Value<'a>) -> &'v Value<'a> {
let mut value = value;
for _ in 0..4 {
match value {
Value::Value(inner) => value = &**inner,
_ => break,
}
}
value
}
fn as_u32(value: &Value<'_>) -> Option<u32> {
match unwrap_variants(value) {
Value::U32(n) => Some(*n),
Value::I32(n) => u32::try_from(*n).ok(),
Value::U8(n) => Some(u32::from(*n)),
_ => None,
}
}
fn as_string(value: &Value<'_>) -> Option<String> {
match unwrap_variants(value) {
Value::Str(s) => Some(s.as_str().to_string()),
_ => None,
}
}
/// Without a portal: GNOME's settings through `gsettings` (`None` when it is not there).
fn read_gsettings() -> Option<bool> {
let gnome = gsettings("color-scheme");
let gtk_theme = gsettings("gtk-theme");
solidcraft_ui_egui::prefs::desktop_prefers_dark(None, gnome.as_deref(), gtk_theme.as_deref())
}
fn gsettings_available() -> bool {
gsettings("gtk-theme").is_some()
}
/// `gsettings get org.gnome.desktop.interface <key>`, its quoted answer, or `None` when the
/// program is missing, fails or does not answer in time.
fn gsettings(key: &str) -> Option<String> {
let mut child = std::process::Command::new("gsettings")
.args(["get", GNOME, key])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
let started = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => break,
Ok(None) if started.elapsed() < GSETTINGS_TIMEOUT => std::thread::sleep(Duration::from_millis(20)),
_ => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
let mut out = String::new();
child.stdout.take()?.take(4096).read_to_string(&mut out).ok()?;
let value = out.trim().trim_matches('\'').to_string();
(!value.is_empty()).then_some(value)
}
+10 -93
View File
@@ -5,25 +5,12 @@
//! for recovery as a forced quit does, and starts again ([`restart`]), and the new start says what
//! happened ([`note`]). A device lost again soon after such a restart starts the next one on the
//! software renderer, which doesn't depend on the graphics driver.
//!
//! The old process ends without tearing down its GPU objects ([`end`]): on a lost NVIDIA device
//! destroying the window's swap chain busy-waits in the driver and never returns (#9044).
use crate::startup;
/// Set on the start after a lost device: when the device was lost (seconds since 1970).
pub const LOST_ENV: &str = "SOLIDCRAFT_DEVICE_LOST";
/// Set on the start after a lost device: why it was lost, as wgpu said it. The old process's
/// session journal, which recorded it, is gone by then.
pub const REASON_ENV: &str = "SOLIDCRAFT_DEVICE_LOST_REASON";
/// The longest reason passed on ([`REASON_ENV`]) and shown, in characters.
const REASON_CHARS: usize = 300;
/// How long the process may take to end ([`end`]) before it is aborted.
const END_GRACE: std::time::Duration = std::time::Duration::from_secs(3);
/// A loss this recent (seconds) counts for the start after it: its note, and a second loss
/// within it starts on the software renderer. Inherited by later starts, an older stamp is
/// ignored.
@@ -45,54 +32,27 @@ pub fn restart_on_software(after_recent_loss: bool, cpu_renderer: bool) -> bool
after_recent_loss || cpu_renderer
}
/// `reason` on one line, at most [`REASON_CHARS`] long; None when there is nothing to say.
pub fn reason(reason: Option<&str>) -> Option<String> {
let line = reason?.split_whitespace().collect::<Vec<_>>().join(" ");
(!line.is_empty()).then(|| line.chars().take(REASON_CHARS).collect())
}
/// What the start after a lost device tells the user, `software` when it runs on the software
/// renderer, with what the driver reported ([`REASON_ENV`]).
pub fn note(software: bool, reason: Option<&str>) -> String {
/// renderer.
pub fn note(software: bool) -> String {
let mut note = String::from(
"The graphics device was reset (the graphics driver stopped responding), so SolidCraft closed and started again. Unsaved changes were kept for recovery.",
);
if software {
note.push_str(" This start uses software rendering, which doesn't depend on the graphics driver.");
}
if let Some(reason) = self::reason(reason) {
note.push_str(&format!(" The graphics driver reported: {reason}"));
}
note
}
/// The command that starts `exe` again with `args`, the loss stamped at `lost_at` and its
/// `reason`, on the software renderer when `software`.
fn command(
exe: std::path::PathBuf,
args: impl IntoIterator<Item = std::ffi::OsString>,
software: bool,
lost_at: u64,
reason: &str,
) -> std::process::Command {
/// Start SolidCraft again with the same arguments, the loss stamped at `lost_at`, on the software
/// renderer when `software`.
pub fn restart(software: bool, lost_at: u64) {
let Ok(exe) = std::env::current_exe() else { return };
let mut command = std::process::Command::new(exe);
command.args(args).env(LOST_ENV, lost_at.to_string());
match self::reason(Some(reason)) {
Some(reason) => command.env(REASON_ENV, reason),
// Not one inherited from an earlier loss.
None => command.env_remove(REASON_ENV),
};
command.args(std::env::args_os().skip(1)).env(LOST_ENV, lost_at.to_string());
if software {
command.env(startup::RENDERER_ENV, "cpu");
}
command
}
/// Start SolidCraft again with the same arguments, the loss stamped at `lost_at` and its `reason`,
/// on the software renderer when `software`.
pub fn restart(software: bool, lost_at: u64, reason: &str) {
let Ok(exe) = std::env::current_exe() else { return };
let mut command = command(exe, std::env::args_os().skip(1), software, lost_at, reason);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
@@ -104,23 +64,6 @@ pub fn restart(software: bool, lost_at: u64, reason: &str) {
}
}
/// End the process now, without tearing down what is left on the lost device: the window's swap
/// chain, the viewport's buffers and pipelines. On NVIDIA's Vulkan driver destroying the swap
/// chain of a lost device busy-waits forever, which left the old window frozen at 100% of a core
/// beside the new one (#9044). Should the exit itself get stuck in a library's exit handler, the
/// process is aborted after [`END_GRACE`].
pub fn end() -> ! {
let watchdog = std::thread::Builder::new().name("solidcraft-exit".into()).spawn(|| {
std::thread::sleep(END_GRACE);
eprintln!("SolidCraft graphics: still closing after the device was lost; aborting");
std::process::abort();
});
if let Err(e) = watchdog {
log::warn!("graphics: no exit watchdog: {e}");
}
std::process::exit(0)
}
#[cfg(test)]
mod tests {
/// Only a recent, well-formed stamp counts: a later start that inherited it says nothing.
@@ -141,34 +84,8 @@ mod tests {
assert!(!super::restart_on_software(false, false));
assert!(super::restart_on_software(true, false));
assert!(super::restart_on_software(false, true));
assert!(super::note(true, None).contains("software rendering"));
assert!(!super::note(false, None).contains("software rendering"));
assert!(super::note(false, None).contains("kept for recovery"));
}
/// The start after a loss is told why (#9044): the old process's journal held the reason, and
/// it is deleted as that process closes.
#[test]
fn the_next_start_is_told_why_the_device_was_lost() {
let exe = std::path::PathBuf::from("solidcraft");
let args = ["--control".into(), "7979".into()];
let command = super::command(exe.clone(), args, true, 1_000, "Lost: Device lost\n (vkQueueSubmit)");
let env = |command: &std::process::Command, name: &str| {
command.get_envs().find(|(k, _)| *k == name).map(|(_, v)| v.map(|v| v.to_string_lossy().into_owned()))
};
assert_eq!(env(&command, super::LOST_ENV), Some(Some("1000".into())));
assert_eq!(env(&command, super::REASON_ENV), Some(Some("Lost: Device lost (vkQueueSubmit)".into())), "on one line");
assert_eq!(env(&command, crate::startup::RENDERER_ENV), Some(Some("cpu".into())));
assert_eq!(command.get_args().collect::<Vec<_>>(), ["--control", "7979"]);
// No reason: none inherited from an earlier loss either.
let command = super::command(exe, Vec::new(), false, 1_000, " ");
assert_eq!(env(&command, super::REASON_ENV), Some(None));
assert_eq!(env(&command, crate::startup::RENDERER_ENV), None);
let note = super::note(false, Some("Lost: Device lost"));
assert!(note.ends_with("The graphics driver reported: Lost: Device lost"), "{note}");
assert!(!super::note(false, Some(" \n ")).contains("reported"));
let long = "x".repeat(1_000);
assert_eq!(super::reason(Some(&long)).map(|r| r.chars().count()), Some(300));
assert!(super::note(true).contains("software rendering"));
assert!(!super::note(false).contains("software rendering"));
assert!(super::note(false).contains("kept for recovery"));
}
}
+8 -78
View File
@@ -20,10 +20,7 @@
#[cfg(not(target_os = "macos"))]
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
mod app_icon;
mod come_forward;
mod control_server;
#[cfg(target_os = "linux")]
mod desktop_theme;
mod device_lost;
#[cfg(target_os = "windows")]
mod native_crash;
@@ -87,8 +84,6 @@ struct App {
frames: u32,
/// Started over the control channel: the window doesn't ask for the foreground.
take_focus_unwanted: bool,
/// Coming forward after a close that asks about unsaved designs.
come_forward: come_forward::ComeForward,
/// The graphics device: lost, it can't draw again (`App::watch_device`, #4172).
gpu: Option<LostWatch>,
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
@@ -144,13 +139,7 @@ impl eframe::App for App {
if ctx.input(|i| i.viewport().close_requested()) && !self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
solidcraft_ui_egui::documents::request_quit(&mut self.solve);
// Closed from the taskbar or a window switcher while another app had the focus: the
// "save changes?" prompt is only seen once the window comes forward.
if !self.solve.quit_requested {
self.come_forward.start(std::time::Instant::now());
}
}
self.come_forward.tick(ctx, _frame);
#[cfg(target_os = "macos")]
for f in if self.solve.file_picker_busy() { Vec::new() } else { open_documents::take() } {
self.solve.open_path_async(&f);
@@ -204,29 +193,13 @@ impl eframe::App for App {
}
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
solidcraft_ui_egui::open_elsewhere::close(&mut self.solve);
// Before a restart (`Drop for App`) starts the next instance, which would otherwise get
// nothing from the 3D mouse driver (`solidcraft_3dx_client::stop`).
// Before a restart below starts the next instance, which would otherwise get nothing from
// the 3D mouse driver (`solidcraft_3dx_client::stop`).
#[cfg(target_os = "macos")]
solidcraft_3dx_client::stop();
solidcraft_engine::crash::end_session();
// The next start (after a lost device, or into an update) begins once the app is dropped
// (`Drop for App`), when the settings are on disk.
}
}
impl Drop for App {
/// eframe drops its settings storage first, which waits for `app.ron` to be written, then the
/// app, then the window and its GPU surface. So the next start begins here: started from
/// `on_exit`, it read `app.ron` while the old process was still writing it, found it
/// unreadable and moved it aside, resetting every setting (#9044).
fn drop(&mut self) {
if let Some(gpu) = &self.gpu
&& let Some(lost_at) = gpu.lost_at
{
device_lost::restart(gpu.restart_on_software, lost_at, &gpu.reason);
// The window's GPU surface is still to be dropped, and on a lost device that may
// never return (#9044).
device_lost::end();
if let Some(lost_at) = self.gpu.as_ref().and_then(|g| g.lost_at) {
device_lost::restart(self.gpu.as_ref().is_some_and(|g| g.restart_on_software), lost_at);
}
// An update asked to restart into the new version or to run its installer.
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve) {
@@ -254,8 +227,6 @@ struct LostWatch {
restart_on_software: bool,
/// When the loss was seen: the app is closing to start again.
lost_at: Option<u64>,
/// Why, as wgpu said it: passed to the next start ([`device_lost::REASON_ENV`]).
reason: String,
}
impl App {
@@ -269,7 +240,6 @@ impl App {
eprintln!("SolidCraft graphics: the device was lost ({reason}); starting again");
solidcraft_engine::crash::phase(&format!("graphics device lost: {reason}"));
gpu.lost_at = Some(device_lost::now_secs());
gpu.reason = reason;
self.solve.quit_requested = true;
}
}
@@ -443,12 +413,6 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services
graphics_adapters: Vec::new(),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
window_system: window_system_now(),
// Linux: the desktop's dark or light setting, which the window is not told on every
// desktop (#7944). Windows and macOS: the window's own theme.
#[cfg(target_os = "linux")]
desktop_dark: Some(desktop_theme::start(&cc.egui_ctx)),
#[cfg(not(target_os = "linux"))]
desktop_dark: None,
http_get: Some(std::sync::Arc::new(http_get)),
http_download: Some(std::sync::Arc::new(http_download)),
// Already an installed app: only the browser build offers to install.
@@ -777,16 +741,6 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
mark("main");
// On OCLP-patched Macs, wgpu picks a Metal Shading Language standard that the system's
// compiler refuses (`invalid value 'metal3.2'`), because the patched OS reports a newer
// macOS version than the GPU's Metal compiler supports. Re-launching with
// SYSTEM_VERSION_COMPAT=1 makes the OS report the real (older) version so wgpu selects
// a standard the compiler accepts. The variable must be set before the process starts, so
// we detect OCLP here and re-exec immediately, before any macOS framework call (#3080, #4605).
#[cfg(target_os = "macos")]
if let Err(e) = startup::relaunch_with_version_compat() {
log::warn!("startup: OCLP check failed (continuing normally): {e}");
}
// Validate before legacy adoption, crash markers or any user preference read.
let settings = settings_dir().map_err(|error| eframe::Error::AppCreation(error.into()))?;
if std::env::var_os("SOLIDCRAFT_SETTINGS_DIR").is_none() {
@@ -798,15 +752,12 @@ fn main() -> eframe::Result {
let mut sample = false;
// `--theme-file PATH`: follow that theme file for this run without saving it (#7573).
let mut theme_file: Option<String> = None;
let mut max_file_size = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--theme-file" => theme_file = args.next(),
"--max-file-size" => max_file_size = Some(args.next()),
s if s.starts_with("--max-file-size=") => max_file_size = Some(s.strip_prefix("--max-file-size=").map(str::to_string)),
"--safe-mode" => safe_mode = true,
"--version" | "-V" => {
println!("solidcraft {}", env!("CARGO_PKG_VERSION"));
@@ -827,21 +778,13 @@ fn main() -> eframe::Result {
}
"--help" | "-h" => {
println!(
"usage: solidcraft [--control PORT] [--sample] [--theme-file THEME.json] [--crash-log] [--safe-mode] [--max-file-size SIZE] [design.sc | part.step]"
"usage: solidcraft [--control PORT] [--sample] [--theme-file THEME.json] [--crash-log] [--safe-mode] [design.sc | part.step]"
);
return Ok(());
}
_ => files.push(a),
}
}
// `--max-file-size 2GB`: the largest files this run reads, over Preferences, never saved (#4086).
if let Some(value) = max_file_size {
let applied = startup::max_file_size(value.as_deref()).and_then(|bytes| solidcraft_engine::io::limits::set_override(Some(bytes)));
if let Err(error) = applied {
eprintln!("SolidCraft: {error}");
return Err(eframe::Error::AppCreation(std::io::Error::other(error).into()));
}
}
// Settings that don't read are moved aside; launches that never got a window are counted,
// and after a few in a row this one starts in safe mode (docs/book/src/guide/troubleshooting.md).
let mut notes: Vec<String> = Vec::new();
@@ -858,11 +801,7 @@ fn main() -> eframe::Result {
// The launch before this one closed because its graphics device was lost (`App::watch_device`).
let after_loss = device_lost::recent(std::env::var(device_lost::LOST_ENV).ok().as_deref(), device_lost::now_secs());
if after_loss {
let reason = std::env::var(device_lost::REASON_ENV).ok();
if let Some(reason) = device_lost::reason(reason.as_deref()) {
log::warn!("graphics: the last launch lost its device ({reason})");
}
notes.push(device_lost::note(cpu_renderer, reason.as_deref()));
notes.push(device_lost::note(cpu_renderer));
}
if safe_mode {
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
@@ -1002,7 +941,6 @@ fn main() -> eframe::Result {
lost: rs.device_lost.clone(),
restart_on_software: device_lost::restart_on_software(after_loss, cpu_renderer),
lost_at: None,
reason: String::new(),
});
if !safe_mode {
app.preferences.graphics_adapter =
@@ -1058,10 +996,6 @@ fn main() -> eframe::Result {
app.session.doc_mut().name = "Sample Plate".into();
app.session.mark_saved();
app.session.undo.clear();
// Built straight into the session, so nothing asked for a fit: frame it at the
// home view as any opened design is (#9195). The fit waits for the first layout.
app.cam.set_view(solidcraft_engine::render::StandardView::Iso);
app.fit_view();
}
// Lock files next to open designs: a design open on another computer says so (#5467),
// the files given at launch included.
@@ -1126,7 +1060,6 @@ fn main() -> eframe::Result {
starting,
frames: 0,
take_focus_unwanted: control_port.is_some(),
come_forward: come_forward::ComeForward::default(),
gpu,
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
space_mouse,
@@ -1145,13 +1078,12 @@ fn main() -> eframe::Result {
}
/// egui-wgpu is about to start a step of getting the graphics device and the window's surface up:
/// the crash journal keeps it until the app runs (a window opened later goes through the same
/// steps), and the adapter once it is picked.
/// the crash journal keeps it, and the adapter once it is picked.
fn startup_step(step: &'static str, detail: &str) {
if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN {
solidcraft_engine::crash::set_environment(&format!("Renderer: {detail}"));
}
solidcraft_engine::crash::startup_step(step);
solidcraft_engine::crash::phase(step);
}
/// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe). The
@@ -1171,8 +1103,6 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
let intel_mac = cfg!(all(target_os = "macos", target_arch = "x86_64"));
let indirect = std::env::var(startup::INDIRECT_VALIDATION_ENV).ok();
setup.instance_descriptor.flags = startup::window_instance_flags(setup.instance_descriptor.flags, intel_mac, indirect.as_deref());
// Never a `dxcompiler.dll` another program put on PATH (#6756).
setup.instance_descriptor.backend_options.dx12.shader_compiler = solidcraft_gpu::dx12_compiler();
let environment = std::env::var("WGPU_ADAPTER_NAME").ok();
let adapter = graphics().and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str).map(str::to_string));
startup::configure_adapter(setup, cpu, environment, adapter);
-1
View File
@@ -101,7 +101,6 @@ impl NativeMenu {
])?;
for (action, label) in [
("docs", "Documentation"),
("updates", app.tr("update.check_for_updates")),
("shortcuts", "Keyboard Shortcuts"),
("messages", "Messages\u{2026}"),
("report", "Report an Issue\u{2026}"),
+2 -107
View File
@@ -255,7 +255,7 @@ pub const FLOAT_DEPTH_STENCIL: eframe::wgpu::Features = eframe::wgpu::Features::
/// asks for a float one only when whichever it picks has it.
pub fn float_depth_everywhere(options: &eframe::egui_wgpu::WgpuConfiguration) -> bool {
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &options.wgpu_setup else { return false };
let mut desc = solidcraft_gpu::instance_descriptor_from_env();
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle_from_env();
desc.backends = setup.instance_descriptor.backends;
let instance = eframe::wgpu::Instance::new(desc);
let adapters = pollster::block_on(instance.enumerate_adapters(setup.instance_descriptor.backends));
@@ -272,7 +272,6 @@ pub fn has_hardware_adapter(backends: eframe::wgpu::Backends) -> bool {
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = backends;
desc.flags = eframe::wgpu::InstanceFlags::from_build_config().with_env();
desc.backend_options.dx12.shader_compiler = solidcraft_gpu::dx12_compiler();
let instance = eframe::wgpu::Instance::new(desc);
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
let found: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().device_type)).collect();
@@ -292,71 +291,6 @@ pub fn window_instance_flags(flags: eframe::wgpu::InstanceFlags, intel_mac: bool
if intel_mac && env.map(str::trim).is_none_or(str::is_empty) { flags - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL } else { flags }
}
/// The environment variable that makes the macOS compatibility shim report an older OS
/// version to wgpu's Metal backend. On OCLP-patched machines the Metal compiler accepts
/// only up to the MSL standard of the original GPU, not the one the patched OS reports,
/// so wgpu picks a standard that the compiler refuses (`invalid value 'metal3.2'`).
/// Setting this variable before the process starts causes the OS to report the older
/// version and wgpu selects a supported MSL standard (#3080, #4605).
#[cfg(target_os = "macos")]
pub const SYSTEM_VERSION_COMPAT_ENV: &str = "SYSTEM_VERSION_COMPAT";
/// The NVRAM variable that OpenCore Legacy Patcher writes on every OCLP-booted system.
/// Its presence is the primary signal that `SYSTEM_VERSION_COMPAT=1` is needed.
#[cfg(target_os = "macos")]
pub const OCLP_NVRAM_KEY: &str = "4D1FDA02-38C7-4A6A-9CC6-4BCCA8B30102:OCLP-Version";
/// `true` if the OCLP NVRAM key is present on this system.
///
/// Runs `nvram <key>` and treats exit 0 as present. Any error (missing binary,
/// permission denied, key absent) returns `false` — this is a best-effort probe.
#[cfg(target_os = "macos")]
pub fn oclp_nvram_present() -> bool {
std::process::Command::new("nvram").arg(OCLP_NVRAM_KEY).output().map(|o| o.status.success()).unwrap_or(false)
}
/// Whether to re-launch with `SYSTEM_VERSION_COMPAT=1`: it is not set yet and OCLP shows through
/// the NVRAM key. The probe runs only when it is needed, so a re-launched process (variable
/// set) never starts `nvram`.
#[cfg(target_os = "macos")]
fn compat_wanted(compat_set: bool, nvram: impl FnOnce() -> bool) -> bool {
!compat_set && nvram()
}
/// If this process is running under OCLP and `SYSTEM_VERSION_COMPAT` has not been set,
/// re-launch the current binary with `SYSTEM_VERSION_COMPAT=1` added to the environment,
/// then exit with the child's status code.
///
/// `SYSTEM_VERSION_COMPAT=1` must be present **before** the process starts: setting it
/// with `std::env::set_var` inside `main` is too late because the macOS frameworks have
/// already queried the OS version by then. Re-launching is the only safe approach that
/// works without `unsafe` (a true `execve` replacement would require it).
///
/// Returns `Ok(false)` immediately when:
/// - `SYSTEM_VERSION_COMPAT` is already set (re-launched process, no loop), or
/// - the OCLP NVRAM key is absent.
///
/// Returns `Err` only when `std::env::current_exe()` fails or the child process cannot
/// be spawned; the caller should log the error and continue normally.
///
/// Never returns `Ok(true)`: a successful re-launch exits the current process.
#[cfg(target_os = "macos")]
pub fn relaunch_with_version_compat() -> Result<bool, String> {
// A set variable means this is the re-launched process: no loop.
if !compat_wanted(std::env::var_os(SYSTEM_VERSION_COMPAT_ENV).is_some(), oclp_nvram_present) {
return Ok(false);
}
log::info!("startup: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
eprintln!("SolidCraft: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
let exe = std::env::current_exe().map_err(|e| format!("could not find own executable: {e}"))?;
let status = std::process::Command::new(&exe)
.args(std::env::args_os().skip(1))
.env(SYSTEM_VERSION_COMPAT_ENV, "1")
.status()
.map_err(|e| format!("could not re-launch with {}: {e}", SYSTEM_VERSION_COMPAT_ENV))?;
std::process::exit(status.code().unwrap_or(1));
}
/// Windows with OpenGL: the window keeps the system title bar (#2063). The window without OS
/// decorations keeps its caption style while having no frame, and some OpenGL drivers (older
/// Intel HD/UHD) then show the picture a caption's height lower than the window, so it no longer
@@ -368,14 +302,6 @@ pub fn system_title_bar(windows: bool, backends: eframe::wgpu::Backends) -> bool
windows && backends.contains(Backends::GL) && !backends.intersects(Backends::DX12 | Backends::VULKAN)
}
/// The `--max-file-size` value (#4086): a size such as `2GB` or `1500MB`, at most 16 GB. It raises
/// the largest STEP/import and design file this run reads, over the saved preference, without
/// saving anything. A missing or bad value stops the start with this error.
pub fn max_file_size(value: Option<&str>) -> Result<u64, String> {
let value = value.ok_or("--max-file-size needs a size, for example --max-file-size 2GB")?;
solidcraft_engine::io::limits::parse_flag(value).map_err(|e| format!("--max-file-size: {e}"))
}
/// The message shown when no window could be opened at all.
pub fn no_window_message(error: &str, settings: Option<&Path>) -> String {
let folder = settings.map(|d| d.display().to_string()).unwrap_or_else(|| "the SolidCraft settings folder".into());
@@ -398,16 +324,6 @@ mod tests {
["Integrated", "Discrete small", "Discrete large"].into_iter().map(|name| AdapterChoice { name: name.into(), compatible: true }).collect()
}
#[test]
fn max_file_size_parses_sizes_and_refuses_bad_ones() {
assert_eq!(max_file_size(Some("2GB")), Ok(2 << 30));
assert_eq!(max_file_size(Some("1500MB")), Ok(1500 << 20));
for bad in [None, Some(""), Some("2"), Some("17GB"), Some("lots"), Some("-1GB")] {
let error = max_file_size(bad).unwrap_err();
assert!(error.contains("--max-file-size"), "{bad:?}: {error}");
}
}
#[test]
fn adapter_names_environment_override_and_missing_card_fallback() {
let cards = cards();
@@ -479,27 +395,6 @@ mod tests {
assert_eq!(window_instance_flags(on, false, None), on);
}
#[cfg(target_os = "macos")]
#[test]
fn oclp_relaunch_needs_the_signal_and_no_compat_variable() {
use super::compat_wanted;
let probed = std::cell::Cell::new(false);
let probe = |found: bool| {
let probed = &probed;
move || {
probed.set(true);
found
}
};
// The re-launched process has the variable: no probe, no second re-launch.
assert!(!compat_wanted(true, probe(true)));
assert!(!probed.get());
// The OCLP NVRAM key is the signal.
assert!(compat_wanted(false, probe(true)));
// No signal: an ordinary Mac starts as it is.
assert!(!compat_wanted(false, probe(false)));
}
#[test]
fn switches_read_from_the_environment() {
assert!(env_on(Some("1")) && env_on(Some("yes")) && !env_on(Some("0")) && !env_on(Some(" ")) && !env_on(None) && !env_on(Some("false")));
@@ -678,7 +573,7 @@ mod tests {
if cfg!(target_os = "windows") && std::env::var_os(WGPU_BACKEND_ENV).is_none() {
assert_eq!(backends, eframe::wgpu::Backends::DX12);
}
let mut desc = solidcraft_gpu::instance_descriptor_from_env();
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle_from_env();
desc.backends = backends;
let instance = eframe::wgpu::Instance::new(desc);
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
+1 -1
View File
@@ -1,5 +1,5 @@
//! Built-in model appearances: names, sRGB colours and metalness.
pub const LIBRARY: &[(&str, [u8; 3], f64)] = &[
pub const LIBRARY: [(&str, [u8; 3], f64); 20] = [
("Aluminum - Satin", [204, 208, 214], 1.0),
("Aluminum - Anodized Black", [44, 46, 50], 1.0),
("Aluminum - Anodized Blue", [52, 92, 160], 1.0),
+1 -1
View File
@@ -1,7 +1,7 @@
//! Original illustrative opaque finishes; not measured optical or engineering data.
//! (name, sRGB color, metal fraction, perceptual roughness). IOR is an illustrative 1.5.
//! Separate names preserve the scalar library's appearance and saved assignments.
pub const OPAQUE_FINISHES: &[(&str, [u8; 3], f64, f64)] = &[
pub const OPAQUE_FINISHES: [(&str, [u8; 3], f64, f64); 6] = [
("Steel - Satin PBR", [158, 164, 172], 1.0, 0.4),
("Aluminum - Satin PBR", [204, 208, 214], 1.0, 0.3),
("Copper - Clean PBR", [204, 122, 80], 1.0, 0.25),
+1 -1
View File
@@ -16,7 +16,7 @@ const fn glass(transmission_linear_rgb: [f64; 3], ior: f64, reference_path_mm: f
GlassRecipe { transmission_linear_rgb, dielectric_ior: ior, reference_path_mm, boundary }
}
pub const STARTER_PACK: &[VisualPreset] = &[
pub const STARTER_PACK: [VisualPreset; 40] = [
VisualPreset::opaque(
"starter.wood.oak-honey",
"Wood - Honey Oak Uniform Preview",
Binary file not shown.
-96
View File
@@ -1,96 +0,0 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'.
Copyright 2014-2021 Google Inc. with Reserved Font Name 'Noto'.
Subset for SolidCraft web UI (glyphs used by the zh locale); renamed Craft UI SC (no Reserved Font Name).
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
-96
View File
@@ -1,96 +0,0 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'.
Copyright 2014-2021 Google Inc. with Reserved Font Name 'Noto'.
Subset for SolidCraft web UI (glyphs used by the zh-TW locale); renamed Craft UI TC (no Reserved Font Name).
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+2 -8
View File
@@ -188,7 +188,7 @@ surface = [
sketch = [
"arc3", "arc_center", "arc_slot", "arc_slot_center", "arc_tangent",
"blend", "circle", "circle2", "circle3", "circle_tt",
"circle_ttt", "conic", "constrainer", "dimension", "ellipse", "equation_curve",
"circle_ttt", "conic", "constrainer", "dimension", "ellipse",
"extend", "finish", "fit_section", "include",
"intersect", "intersection_curve", "iso_curve", "line", "line_mid",
"offset", "offset_two_sides", "offset_symmetric", "point", "polygon", "polygon_circumscribed", "polygon_edge",
@@ -204,11 +204,5 @@ constraint = [
"c_polygon", "c_smooth", "c_symmetry", "c_tangent",
]
construction = [
"centerline", "construction", "construction_line", "construction_line_angle",
"construction_line_bisector", "construction_line_horizontal", "construction_line_parallel",
"construction_line_perpendicular", "construction_line_tangent_at", "construction_line_tangent_external",
"construction_line_tangent_two", "construction_line_vertical", "infinite",
]
mesh = [
"mesh_body",
"centerline", "construction", "infinite",
]
@@ -70,16 +70,6 @@ configuration_table false
conic false
constrainer false
construction false
construction_line false
construction_line_angle false
construction_line_bisector false
construction_line_horizontal false
construction_line_parallel false
construction_line_perpendicular false
construction_line_tangent_at false
construction_line_tangent_external false
construction_line_tangent_two false
construction_line_vertical false
contact_all false
contact_new false
contact_off false
@@ -105,7 +95,6 @@ edit false
ellipse false
emboss false
environment_map false
equation_curve false
error false
explode false
export false
@@ -114,10 +103,8 @@ extend false
extrude false
eye false
fastener false
feature_group false
fillet false
finish false
finish_edit false
fit false
fit_section false
flange false
@@ -155,7 +142,6 @@ loft false
lookat false
material false
measure false
mesh_body false
mesh_export false
min_radius false
mirror false
@@ -201,7 +187,6 @@ polygon false
polygon_circumscribed false
polygon_edge false
presspull false
print_face false
project false
project_surface false
rack_pinion false
@@ -236,7 +221,6 @@ settings_monitor false
settings_mouse false
settings_palette false
settings_plug false
sheet false
sheet_rules false
shell false
shield false
@@ -272,7 +256,6 @@ split_face false
spun false
star false
status_error false
status_pin false
status_recovery false
style false
style_hidden_edges false
@@ -304,8 +287,6 @@ svg false
sweep false
text false
thread false
timeline_play false
timeline_stop false
torus false
trace false
trim false
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Pinned result</title>
<path d="M32 57C25 48 14 35 14 24A18 18 0 0 1 50 24C50 35 39 48 32 57Z" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M25 24A7 7 0 1 0 39 24A7 7 0 1 0 25 24Z" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 429 B

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Construction line through two points</title>
<path d="M8 52L56 12" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="10 5"/>
<circle cx="20" cy="42" r="5.8" fill="#ffffff"/>
<circle cx="20" cy="42" r="3.4" fill="#236ead"/>
<circle cx="44" cy="22" r="5.8" fill="#ffffff"/>
<circle cx="44" cy="22" r="3.4" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 472 B

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Construction line at an angle</title>
<path d="M10.26 48.66L58.26 48.66" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M28.65 33.23A24 24 0 0 1 34.26 48.66" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M10.26 48.66L54.26 11.66" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="10.26" cy="48.66" r="5.8" fill="#ffffff"/>
<circle cx="10.26" cy="48.66" r="3.4" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 646 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Angle bisector</title>
<path d="M13.92 12.18L9.92 52.17L54.92 44.17" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M12.11 30.29A22 22 0 0 1 31.58 48.33" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M9.92 52.17L47.3 11.83" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 526 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Horizontal construction line</title>
<path d="M8 32L56 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="10 5"/>
<circle cx="32" cy="32" r="5.8" fill="#ffffff"/>
<circle cx="32" cy="32" r="3.4" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 362 B

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Parallel construction line</title>
<path d="M9 37L41 9" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M23 55L55 27" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="10 5"/>
</svg>

Before

Width:  |  Height:  |  Size: 380 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Perpendicular construction line</title>
<path d="M8 44L56 44" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 8L32 56" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 34L42 34L42 44" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 490 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Tangent at a curve point</title>
<circle cx="32" cy="34.26" r="17" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M8 17.26L56 17.26" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="32" cy="17.26" r="5.8" fill="#ffffff"/>
<circle cx="32" cy="17.26" r="3.4" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 480 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Tangent through an external point</title>
<circle cx="21.74" cy="38.77" r="14" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M53.6 8.23L28.55 55.9" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="51.74" cy="11.77" r="5.8" fill="#ffffff"/>
<circle cx="51.74" cy="11.77" r="3.4" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 502 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Common tangent to two curves</title>
<circle cx="20" cy="33.59" r="12" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="46" cy="33.59" r="8" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M6 19.27L58 27.36" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 509 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Vertical construction line</title>
<path d="M32 8L32 56" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="10 5"/>
<circle cx="32" cy="32" r="5.8" fill="#ffffff"/>
<circle cx="32" cy="32" r="3.4" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 360 B

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Equation curve</title>
<path d="M12 8L12 56M6 32L58 32" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M12 32C15.42 24.83 18.72 18 22.5 18C26.28 18 29.58 24.83 33 32C36.42 39.17 39.72 46 43.5 46C47.28 46 50.58 39.17 54 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="12" cy="32" r="5.8" fill="#ffffff"/>
<circle cx="12" cy="32" r="3.4" fill="#236ead"/>
<circle cx="54" cy="32" r="5.8" fill="#ffffff"/>
<circle cx="54" cy="32" r="3.4" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 666 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Feature group</title>
<path d="M28.5 11L50.5 11A4.5 4.5 0 0 1 55 15.5L55 28.5A4.5 4.5 0 0 1 50.5 33L28.5 33A4.5 4.5 0 0 1 24 28.5L24 15.5A4.5 4.5 0 0 1 28.5 11Z" fill="#8e9faf" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M21 20L43 20A4.5 4.5 0 0 1 47.5 24.5L47.5 37.5A4.5 4.5 0 0 1 43 42L21 42A4.5 4.5 0 0 1 16.5 37.5L16.5 24.5A4.5 4.5 0 0 1 21 20Z" fill="#b8c3cf" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M13.5 29L35.5 29A4.5 4.5 0 0 1 40 33.5L40 46.5A4.5 4.5 0 0 1 35.5 51L13.5 51A4.5 4.5 0 0 1 9 46.5L9 33.5A4.5 4.5 0 0 1 13.5 29Z" fill="#b9ddf8" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M16 40L33 40" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 947 B

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Finish Edit</title>
<path d="M50 32L14 32M25 21L14 32L25 43" fill="none" stroke="#236ead" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 237 B

@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Mesh body</title>
<path d="M8.62 18.5L32 32L32 59L8.62 45.5Z" fill="#328bd3"/>
<path d="M55.38 18.5L32 32L32 59L55.38 45.5Z" fill="#236ead"/>
<path d="M32 5L55.38 18.5L32 32L8.62 18.5Z" fill="#b9ddf8"/>
<path d="M8.62 18.5L55.38 18.5" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M8.62 18.5L32 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M55.38 18.5L32 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 5L55.38 18.5L55.38 45.5L32 59L8.62 45.5L8.62 18.5Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M8.62 18.5L32 32L55.38 18.5" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 32L32 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Print Face</title>
<path d="M9.72 26.17L4.5 20.95L4.5 40.85L9.72 46.07Z" fill="#b8c3cf"/>
<path d="M9.72 26.17L4.5 20.95L4.5 40.85L9.72 46.07Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M18.77 23.15L9.72 26.17L9.72 46.07L18.77 43.05Z" fill="#328bd3"/>
<path d="M18.77 23.15L9.72 26.17L9.72 46.07L18.77 43.05Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M13.55 17.93L18.77 23.15L9.72 26.17L4.5 20.95Z" fill="#e2e8ef"/>
<path d="M13.55 17.93L18.77 23.15L9.72 26.17L4.5 20.95Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M50.45 37.54L38.27 25.35L38.27 33.88L50.45 46.07Z" fill="#b8c3cf"/>
<path d="M50.45 37.54L38.27 25.35L38.27 33.88L50.45 46.07Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M59.5 34.53L50.45 37.54L50.45 46.07L59.5 43.05Z" fill="#8e9faf"/>
<path d="M59.5 34.53L50.45 37.54L50.45 46.07L59.5 43.05Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M47.31 22.34L59.5 34.53L50.45 37.54L38.27 25.35Z" fill="#e2e8ef"/>
<path d="M47.31 22.34L59.5 34.53L50.45 37.54L38.27 25.35Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M50.45 46.07L38.27 33.88" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M59.5 43.05L50.45 46.07" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M24.17 34.2L28.31 34.2" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M35.31 34.2L27.31 38.7L27.31 29.7Z" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><title>Play History</title><path d="M11.22 20L32 32L32 56L11.22 44Z" fill="#b8c3cf"/><path d="M52.78 20L32 32L32 56L52.78 44Z" fill="#8e9faf"/><path d="M32 8L52.78 20L32 32L11.22 20Z" fill="#e2e8ef"/><path d="M32 8L52.78 20L52.78 44L32 56L11.22 44L11.22 20Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M11.22 20L32 32L52.78 20" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M32 32L32 56" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M35 34L56 46L35 58Z" fill="#ffffff" stroke="#ffffff" stroke-width="4.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M35 34L56 46L35 58Z" fill="#236ead" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 935 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><title>Stop History</title><path d="M11.22 20L32 32L32 56L11.22 44Z" fill="#b8c3cf"/><path d="M52.78 20L32 32L32 56L52.78 44Z" fill="#8e9faf"/><path d="M32 8L52.78 20L32 32L11.22 20Z" fill="#e2e8ef"/><path d="M32 8L52.78 20L52.78 44L32 56L11.22 44L11.22 20Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M11.22 20L32 32L52.78 20" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M32 32L32 56" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M36 35L53 35A2 2 0 0 1 55 37L55 54A2 2 0 0 1 53 56L36 56A2 2 0 0 1 34 54L34 37A2 2 0 0 1 36 35Z" fill="#ffffff" stroke="#ffffff" stroke-width="4.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M36 35L53 35A2 2 0 0 1 55 37L55 54A2 2 0 0 1 53 56L36 56A2 2 0 0 1 34 54L34 37A2 2 0 0 1 36 35Z" fill="#236ead" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-11
View File
@@ -15,17 +15,6 @@ pub(crate) fn transport_glyph(p: &egui::Painter, c: egui::Pos2, what: &str, col:
}
"back" => tri(-1.0, 0.0),
"forward" => tri(1.0, 0.0),
// Play History: larger than the step arrows so it doesn't read as Forward, and two bars
// while it plays.
"play" => {
let pts = vec![pos2(c.x - 4.5, c.y - 7.0), pos2(c.x + 6.5, c.y), pos2(c.x - 4.5, c.y + 7.0)];
p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));
}
"pause" => {
for dx in [-3.0, 3.0] {
p.rect_filled(Rect::from_center_size(pos2(c.x + dx, c.y), vec2(3.0, 12.0)), 0.5, col);
}
}
_ => {
tri(1.0, -1.0);
bar(5.0);
+19 -1006
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -2350
View File
File diff suppressed because it is too large Load Diff
+3 -1458
View File
File diff suppressed because it is too large Load Diff
-137
View File
@@ -1,100 +1,28 @@
{
"A Single hole snaps to the middle of its face and to the centres of its round edges": "A Single hole snaps to the middle of its face and to the centres of its round edges",
"Base Center": "Base Centre",
"Center X": "Centre X",
"Center Y": "Centre Y",
"Center of mass": "Centre of mass",
"Centre to centre": "Centre to centre",
"Circle center": "Circle centre",
"Colour": "Colour",
"Copy all licences": "Copy all licences",
"Customize Toolbar": "Customise Toolbar",
"Customize…": "Customise…",
"Face center": "Face centre",
"Favorites": "Favourites",
"Favourite": "Favourite",
"Mass Center": "Mass Centre",
"Modeled": "Modelled",
"Select the centre the pattern turns around first": "Select the centre the pattern turns around first",
"Snap to face and edge centres": "Snap to face and edge centres",
"Specialise": "Specialise",
"cmd.appearance_favorite": "Favourite Look",
"cmd.component_cycle_color": "Cycle Component Colour",
"cmd.construct_point_center": "Point At Centre Of Circle/Sphere/Torus",
"cmd.inspect_center_of_mass": "Centre of Mass",
"cmd.inspect_colored_components": "Enable Component Colours",
"cmd.inspect_colored_components_disable": "Disable Component Colours",
"cmd.parameters_favorite": "Favourite Parameter",
"cmd.render_aging_describe": "Layered Surface Aging",
"cmd.render_effects_describe": "Lens and Creative Effects",
"cmd.sketch.construction_line": "Construction Line",
"cmd.sketch.construction_line.angle": "Construction Line at Angle",
"cmd.sketch.construction_line.bisector": "Angle Bisector",
"cmd.sketch.construction_line.horizontal": "Horizontal Construction Line",
"cmd.sketch.construction_line.parallel": "Parallel Construction Line",
"cmd.sketch.construction_line.perpendicular": "Perpendicular Construction Line",
"cmd.sketch.construction_line.tangent_at": "Tangent at Curve Point",
"cmd.sketch.construction_line.tangent_external": "Tangent through External Point",
"cmd.sketch.construction_line.tangent_two": "Common Tangent",
"cmd.sketch.construction_line.vertical": "Vertical Construction Line",
"cmd.sketch_arc_center_point": "Centre Point Arc",
"cmd.sketch_circle_center": "Centre Diameter Circle",
"cmd.sketch_rectangle_center": "Centre Rectangle",
"cmd.sketch_slot_arc_center": "Centre Point Arc Slot",
"cmd.sketch_slot_center_point": "Centre Point Slot",
"cmd.sketch_slot_center_to_center": "Centre to Centre Slot",
"color.copy": "Click to copy colour values",
"color.edit": "Click to edit colour",
"color.gamma": "Showing colour values in 0-255 gamma space",
"color.linear": "Showing colour values in 0-1 linear space",
"color.selected": "Selected colour",
"dxf.cancelled": "DXF export cancelled",
"dxf.cancelling": "Cancelling DXF export",
"help.apache": "Apache licence 2.0",
"help.appearance.assign": "Gives the selected bodies, faces or components a colour or a material look, without changing their weight. Use it to tell parts apart or to preview a finish.",
"help.face.delete": "Removes the selected faces and lets the neighbouring faces close the gap, or leaves the gap open as a surface. Use it to take off a rounded edge, a hole or a boss that is in the way.",
"help.font_details": "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and the MIT/Bitstream Vera licence; Noto Emoji and the emoji icon font, under the OFL and MIT). Sketch text uses Liberation Sans (SIL OFL 1.1). The Crafting Apps' optional font pack, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), is not bundled in SolidCraft builds.",
"help.licences": "Licences",
"help.material.assign": "Sets what the selected bodies are made of, such as steel, aluminium or ABS. Use it so that the mass and weight match the real part.",
"help.mit": "MIT licence",
"help.solid.draft": "Tilts the selected faces by the angle you set, measured from a neutral plane. Use it on moulded or cast parts so that they slide out of the mould.",
"help.solid.revolve": "Spins a sketch profile around an axis to make a round solid. Use it for shafts, knobs, bowls and any part that is round about a centre line.",
"help.solid.split_body": "Cuts a body into separate bodies with a plane, the face of another body or a sketch line. Use it to make parts that are printed, machined or coloured on their own.",
"help.solid.split_face": "Divides the selected faces along a plane, the face of another body or sketch curves, without cutting the body. Use it to give part of a face its own draft, colour or offset.",
"help.third_party": "Third-party licences",
"home.new_mm_hint": "Millimetres · opens in Design",
"navbar.custom.customise": "Customise navigation bar…",
"navbar.custom.position_centre": "Centre",
"navbar.description.separator": "A thin line that groups neighbouring slots.",
"pattern.alignment_hint": "Counts include the original. Centre balances copies; Flip chooses the extra side for even totals.",
"prefs.bottom_centre": "Bottom centre",
"prefs.logo_theme": "Theme's colours",
"prefs.max_file_size": "Largest file to open",
"prefs.max_file_size_design": "Design files",
"prefs.max_file_size_flag": "This run started with --max-file-size {size}, which applies until SolidCraft closes. These settings apply when it starts without it.",
"prefs.max_file_size_note": "Defaults: 512 MB and 256 MB; at most 16 GB. Opening a file takes several times its size in memory. A design that keeps a large import may need a higher design file limit to save and reopen.",
"prefs.max_file_size_step": "STEP and import files",
"prefs.max_file_size_web": "The web app opens STEP and import files up to 512 MB and designs up to 256 MB: a browser tab can't hold larger files. The desktop app can raise these limits.",
"prefs.megabytes": "MB",
"prefs.sketch_conflict_colour": "Conflicting geometry in the conflict colour",
"prefs.sketch_conflict_colour_note": "While a sketch's constraints conflict, the curves and points a failing constraint holds are drawn in the conflict colour too, not only its glyph or dimension. Off: they keep their usual colours.",
"prefs.sketch_construction_lines": "Extended construction line tools",
"prefs.sketch_recent_snap": "Auto snap to recent centres and midpoints",
"prefs.sketch_recent_snap_note": "While drawing, snap the cursor where the guides of the two most recent circle centres or straight-edge midpoints cross. Off: those guides stay visual and the cursor does not move.",
"prefs.sketch_state_icons": "Sketch icons in the colour of their state",
"prefs.sketch_state_icons_note": "In History and the browser, a sketch's icon takes the colour of its state (under constrained, fully constrained or conflicting) and its tooltip names the state. Off: every sketch has the same icon.",
"prefs.sketch_tracking_guides": "Centre and midpoint alignment guides",
"prefs.sketch_tracking_guides_note": "Hover over a circle centre or straight-edge midpoint, then move horizontally or vertically to show a temporary dotted guide.",
"prefs.status_bar_note": "A thin bar along the window's bottom edge shows what is being edited, the open sketch's state in its colour and the design's units; a click on the units changes them. The sketch's state then no longer shows in the view's corner.",
"prefs.theme_follow": "Follow a theme file",
"prefs.theme_follow_error": "Not applied: {error}. The last good theme stays on.",
"prefs.theme_follow_launch": "Followed for this run only (--theme-file); reloaded whenever another tool rewrites it.",
"prefs.theme_follow_note": "Reloaded whenever another tool rewrites it. Choosing a theme stops following it.",
"prefs.theme_follow_off": "Off",
"prefs.theme_follow_stop": "Stop following",
"prefs.trackpad_zoom_modifier.help": "Hold only this key to zoom at the pointer with vertical scroll. Off keeps the existing scroll behaviour.",
"prefs.view": "View behaviour",
"prefs.viewport_background_colour": "Colour",
"render.aging_add": "Add aging layer",
"render.aging_airborne": "Airborne dust / participating medium",
"render.aging_airborne_help": "Airborne dust is a separate finite homogeneous scattering volume. It changes transport and raw EXR, not the surface-aging stack. Existing volume/glass/denoising restrictions remain explicit; no atmosphere is added automatically.",
@@ -122,9 +50,6 @@
"render.aging_target_help": "A named target must still match its captured index and name. Global masks are world-anchored; they do not follow moving parts. This is neither cavity detection nor true edge wear. Filter the list to reach additional bodies.",
"render.aging_target_search": "Filter body names",
"render.aging_upward": "Upward-facing deposition",
"render.appearance_editor.base_colour": "Base colour",
"render.backdrop_first": "Start / centre colour",
"render.backdrop_second": "End / edge colour",
"render.backdrop_x": "Centre X",
"render.backdrop_y": "Centre Y",
"render.background_camera_section": "Camera background · PNG presentation",
@@ -132,38 +57,20 @@
"render.background_hdr_separate": "The current HDR resources illuminate and reflect on surfaces; they are not automatically displayed as a panorama behind the subject. Changing a gradient does not rotate or recolour the lighting.",
"render.background_lighting_section": "HDR illumination · separate from camera background",
"render.background_no_alpha_override": "A camera background is not added to transparent output silently. Raw-master regrading uses captured coverage and does not reapply denoising; the source EXR remains unchanged.",
"render.background_not_light": "Background colour is presentation, not an additional lighting source.",
"render.background_regrade": "Apply to retained raw master",
"render.background_stage_occlusion": "This shot includes physical stage geometry. A large floor or wall can fill the frame and hide a camera-only background. Clear or resize the stage deliberately, or change its physical reflectance. Regrading cannot remove captured geometry.",
"render.background_workflow": "Choose the background you intend: a camera-only colour/gradient, a physical stage that receives light, or HDR illumination. They are independent. Live Preview is a modelling view; render a new image or regrade a retained master to see camera finishing.",
"render.base_color": "Base colour · sRGB",
"render.bloom_help": "Creative output-only glow from positive linear highlights, spread with three normalised box passes. Radius is in output pixels. Added in linear display light before sRGB; not a lens, volumetric effect or convergence improvement. The raw EXR is unchanged.",
"render.chromatic_offset": "Chromatic edge offset",
"render.cpu_area_editor_help": "Full side vectors in millimeters; U cross V points towards the emitting side. This differs from GPU half-edge vectors.",
"render.cpu_light_help": "Up to eight directional lights. An empty custom list turns them off; selecting a preset restores its lights. Colours are linear RGB and intensity is relative, not watts.",
"render.cpu_linear_color": "Linear colour (R, G, B)",
"render.effects_gpu_only": "These effects require GPU path tracing. Changing the page never switches the renderer automatically.",
"render.effects_help": "Combine ordered colour looks and image-space lens effects. Rendering and raw-master regrading use the same saved settings. Raw EXR stays unchanged. These are original artistic controls, not calibrated lens or branded-film emulations.",
"render.effects_reset_lens": "Reset lens effects",
"render.effects_seed": "Pattern seed",
"render.env_colour": "Colour",
"render.environment.radial_only": "Centre and radius apply to Radial mode.",
"render.error_finish_gesture": "finish the modelling gesture before previewing",
"render.error_finish_operation": "finish the current modelling operation before entering Render",
"render.film_grain": "Photographic grain",
"render.flare_ghosts": "Ghost rings per source",
"render.flare_halo": "Flare halo",
"render.flare_streak": "Anamorphic-style streak",
"render.flare_strength": "Highlight flare strength",
"render.flare_threshold": "Scene-linear flare threshold",
"render.frame_centre_mark": "Centre mark",
"render.hdr_primaries_declared": "The source declares its colour primaries.",
"render.hdr_primaries_srgb": "Colour primaries: sRGB/D65",
"render.hdr_primaries_undeclared": "The source does not declare primaries; review the renderer colour requirements.",
"render.image_cancelled": "Render cancelled",
"render.image_cancelling": "Cancelling",
"render.image_scene_stale": "Finish the modelling gesture or refresh the scene before checking or starting an image.",
"render.inspector_colour": "Colour",
"render.layer_down": "Move down",
"render.layer_enabled": "Layer enabled",
"render.layer_remove": "Remove layer",
@@ -171,18 +78,6 @@
"render.layer_up": "Move up",
"render.lens_effects_help": "Flare uses up to four separated bright sources in the actual linear image. Ghosts, halo, streaks, chromatic offsets and radial distortion are artistic image-space approximations. Spatial effects need opaque output; outside samples extend the image edge. Grain is deterministic. No physics, EXR or geometry is altered.",
"render.lens_effects_title": "Lens-inspired & photographic",
"render.library_center": "Rig centre",
"render.library_favorite": "Add favourite",
"render.library_favorites": "Favourites",
"render.library_inspect_only": "Select to inspect; nothing is applied automatically. Favourites and recent history belong to this open document session.",
"render.library_rig_help": "Fit once at an explicit centre and physical extent. Complete rigs include passive cards; calibration blockers still refuse. Conservative emitter/card clearance is checked before saving.",
"render.library_source_preview_help": "Authored base-colour source map. This is not a rendered or measured material preview.",
"render.library_unfavorite": "Remove favourite",
"render.light_behaviour": "BEHAVIOUR",
"render.light_preview_help": "Live view is unshadowed; Area lights use a centre-sample approximation. Render uses the complete light model.",
"render.light_product_centre": "Product centre",
"render.light_temperature": "Colour temperature",
"render.lighting_colour": "Colour",
"render.look_add": "Add colour look",
"render.look_bleach": "Bleach-bypass-inspired",
"render.look_cool": "Cool",
@@ -194,11 +89,6 @@
"render.look_teal_orange": "Teal / orange",
"render.look_title": "Creative look stack",
"render.look_warm": "Warm",
"render.material_color_map": "Base colour image (sRGB)",
"render.material_color_replace": "Base map replaces body colour",
"render.material_map_required": "Choose a colour image or a normal image before using maps.",
"render.material_maps": "Planar colour and normal maps",
"render.material_maps_help": "PNG/JPEG up to 1024×1024; 16 MiB encoded total. U and V are orthogonal world-mm repeat vectors (maximum 128 repeats). Colour is sRGB; normal data uses OpenGL +Y. Files are captured at Check/Start.",
"render.opaque_preview_help": "Opaque PBR and simple colours; blended transparency. Refraction, HDRI and final rendering are not available yet.",
"render.page_aging": "Aging & contamination",
"render.page_background": "Backgrounds",
@@ -206,45 +96,18 @@
"render.radial_distortion": "Radial distortion",
"render.ribbon_aging": "Aging",
"render.ribbon_effects": "Effects",
"render.ribbon_studios_help": "Browse implemented studio compositions and review their replacements before applying. Concept-only scenes remain labelled.",
"render.sharpening": "Detail sharpening",
"render.soft_focus": "Soft focus",
"render.stage_panels.centre": "Centre",
"render.stage_panels.source_note": "Authored base-colour source map. Not a measured material.",
"render.studio_area_help": "Up to four one-sided rectangles. Orthogonal half-edges U and V define size; U × V faces the lit side. Coordinates are world-space millimetres.",
"render.studio_center": "Centre X, Y, Z (mm)",
"render.studio_hdr_help": "Radiance .hdr, 2:1 panorama, up to 2048 × 1024. EXR is unavailable. The colour assumption applies only when primaries are unspecified.",
"sketch_text.align_center": "Centre",
"ui.3d_mouse_kind_cap": "Return to Centre",
"ui.3d_mouse_looks_like_cap": "The reading stays put while you hold it, the way a Return to Centre device does.",
"ui.3d_mouse_orbit_tip": "Traced: the 3D mouse turns the view around the part of the model in the middle of the view when the turn begins, marked by a faint ball. Classic: around the view's centre",
"ui.3d_mouse_step_pull": "Pull the cap towards you, hold it, then let go.",
"ui.3d_mouse_switch_cap": "Calibrate as Return to Centre",
"ui.agent_chat_minimize": "Minimise to a strip",
"ui.ai_mode_edits_note": "Modelling commands run without asking; saving, opening, exporting and other file or destructive actions still ask.",
"ui.center": "Centre",
"ui.center_arc_follows_cursor": "Centre point arc follows the cursor's direction",
"ui.center_arc_follows_cursor_tip": "A centre point arc runs clockwise or counter-clockwise the way the cursor goes round its centre. Off: always counter-clockwise from the start.",
"ui.centerline": "Centreline",
"ui.colour_vision": "Colour vision",
"ui.colour_vision_note": "Changes the colours that carry a meaning: sketch states, axes, bends, analyses and icons. If you use Windows colour filters, keep this Off.",
"ui.colour_vision_off_tip": "The theme's own colours",
"ui.construction.ribbon_label": "X-Lines",
"ui.customizeToolbar": "Customise Toolbar…",
"ui.default_modelling_orientation": "Default modelling orientation",
"ui.design_properties.license": "Licence",
"ui.design_properties.organization": "Organisation",
"ui.export_keymap_tip": "Keys, S box favourites and the toolbar, as a file",
"ui.layout_compact": "Compact",
"ui.layout_floating": "Floating",
"ui.material_review.claims": "Sources and licences are unverified claims. Missing values remain unknown.",
"ui.point_cloud_color_by": "Colour By",
"ui.point_cloud_file_colors": "File Colours",
"ui.point_cloud_no_file_colors": "The file has no colours",
"ui.point_cloud_single_color": "Single Colour",
"ui.properties.inertia_note": "About the centre of mass, in world axes (g·{unit}²)",
"ui.ribbon_labels": "Show text under ribbon icons",
"ui.view_centre": "View centre",
"ui.workspace_layout": "Workspace layout",
"ui.zoom_to_cursor_tip": "The wheel zooms towards the point under the cursor (off: towards the view's centre)",
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software centre. SolidCraft doesn't check."
+54 -1041
View File
File diff suppressed because it is too large Load Diff
+41 -1928
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1350
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+29 -1016
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1824
View File
File diff suppressed because it is too large Load Diff
+1 -1203
View File
File diff suppressed because it is too large Load Diff
-43
View File
@@ -30,7 +30,6 @@
"All open designs": "Todos os projetos abertos",
"Allowed expressions, separated by semicolons": "Expressões permitidas, separadas por ponto e vírgula",
"Also deletes": "Também exclui",
"Also place them from the origin (fully constrained)": "Posicioná-las também a partir da origem (totalmente restritas)",
"Angle 2": "Ângulo 2",
"Animate": "Animar",
"App/system information and command settings are included. Review private details before sharing.": "Informações do aplicativo/sistema e configurações estão incluídas. Revise detalhes privados antes de compartilhar.",
@@ -146,7 +145,6 @@
"Designs you open or save appear here.": "Os projetos que você abrir ou salvar aparecem aqui.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por Até o objeto. Escolha Ângulo para editar a extensão angular.",
"Diameter": "Diâmetro",
"Dimension new shapes": "Cotar novas formas",
"Direction": "Direção",
"Discard": "Descartar",
"Distance": "Distância",
@@ -157,8 +155,6 @@
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Clique duplo com o botão do meio na vista: Início (a vista inicial, enquadrando o que é mostrado), Ajustar (enquadra o que é mostrado, mantendo a orientação) ou nada",
"Drive Joint": "Acionar junta",
"Duration": "Duração",
"Each new shape is also dimensioned from the sketch origin until nothing of it is left free. A shape already attached to the origin or to placed geometry gets none.": "Cada nova forma também recebe cotas a partir da origem do esboço até nada ficar livre. Uma forma já ligada à origem ou a uma geometria posicionada não recebe nenhuma.",
"Each shape you draw gets all its dimensions, at the sizes you drew (typed values win). A size the sketch already sets is left out.": "Cada forma que você desenha recebe todas as suas cotas nos tamanhos desenhados (valores digitados têm prioridade). Um tamanho que o esboço já define é omitido.",
"Edges": "Arestas",
"Edit": "Editar",
"Edit Profile Sketch": "Editar esboço do perfil",
@@ -582,7 +578,6 @@
"appearance.pbr_opaque": "PBR opaco",
"appearance.roughness": "Rugosidade",
"appearance.surface_response": "Resposta da superfície",
"at the face": "na face",
"browser.body_name": "Corpo{number}",
"browser.calibrate_title": "Calibrar: {name}",
"browser.capture_many": "{count} componentes foram movidos, mas suas posições não foram capturadas.",
@@ -606,7 +601,6 @@
"cmd.appearance_library": "Biblioteca de aparências",
"cmd.appearance_list": "Listar aparências",
"cmd.assembly_bom": "Lista de materiais",
"cmd.body.print_face": "Face de impressão",
"cmd.body_rename": "Renomear corpo",
"cmd.browser_group": "Novo grupo",
"cmd.browser_move": "Mover itens do navegador",
@@ -816,16 +810,6 @@
"cmd.sheet_refold": "Redobrar",
"cmd.sheet_rules": "Listar regras de chapa metálica",
"cmd.sheet_unfold": "Desdobrar",
"cmd.sketch.construction_line": "Reta de construção",
"cmd.sketch.construction_line.angle": "Reta de construção em ângulo",
"cmd.sketch.construction_line.bisector": "Bissetriz",
"cmd.sketch.construction_line.horizontal": "Reta de construção horizontal",
"cmd.sketch.construction_line.parallel": "Reta de construção paralela",
"cmd.sketch.construction_line.perpendicular": "Reta de construção perpendicular",
"cmd.sketch.construction_line.tangent_at": "Tangente em um ponto da curva",
"cmd.sketch.construction_line.tangent_external": "Tangente por um ponto externo",
"cmd.sketch.construction_line.tangent_two": "Tangente comum",
"cmd.sketch.construction_line.vertical": "Reta de construção vertical",
"cmd.sketch_arc_center_point": "Arco por ponto central",
"cmd.sketch_arc_tangent": "Arco tangente",
"cmd.sketch_arc_three_point": "Arco por 3 pontos",
@@ -897,7 +881,6 @@
"cmd.sketch_spline_control_point": "Spline por pontos de controle",
"cmd.sketch_spline_control_point_5": "Spline por pontos de controle (grau 5)",
"cmd.sketch_spline_fit_point": "Spline por pontos de ajuste",
"cmd.sketch_spline_reverse": "Inverter direção do spline",
"cmd.sketch_spun_profile": "Perfil de revolução",
"cmd.sketch_toggle_driven": "Alternar cota conduzida",
"cmd.sketch_trim_path": "Aparar ao longo do caminho",
@@ -1272,13 +1255,6 @@
"prefs.left_click": "Clique esquerdo",
"prefs.live_input": "Entrada ao vivo",
"prefs.live_preview": "Visualização ao vivo",
"prefs.max_file_size": "Tamanho máximo de arquivo a abrir",
"prefs.max_file_size_design": "Arquivos de projeto",
"prefs.max_file_size_flag": "Esta execução foi iniciada com --max-file-size {size}, que vale até fechar o SolidCraft. Estas configurações se aplicam ao iniciar sem essa opção.",
"prefs.max_file_size_note": "Padrões: 512 MB e 256 MB; máximo de 16 GB. Abrir um arquivo exige várias vezes seu tamanho em memória. Um projeto que mantém uma importação grande pode precisar de um limite maior para ser salvo e reaberto.",
"prefs.max_file_size_step": "Arquivos STEP e de importação",
"prefs.max_file_size_web": "O aplicativo web abre arquivos STEP e de importação de até 512 MB e projetos de até 256 MB: uma aba do navegador não comporta arquivos maiores. O aplicativo para desktop permite aumentar esses limites.",
"prefs.megabytes": "MB",
"prefs.minutes": "minutos",
"prefs.minutes_suffix": " min",
"prefs.mouse": "Mouse e trackpad",
@@ -1293,8 +1269,6 @@
"prefs.orientation": "Orientação",
"prefs.overall_speed": "Velocidade geral",
"prefs.preview_choices": "Prévia das suas escolhas",
"prefs.print_face_highlight": "Destaque da face de impressão",
"prefs.print_face_highlight_tip": "Intensidade da cor e do contorno da face de impressão de um corpo na vista. 0 % não mostra destaque.",
"prefs.quality_default": "padrão",
"prefs.quality_fastest": "mais rápido",
"prefs.quality_slowest": "mais lento",
@@ -1574,8 +1548,6 @@
"ui.3d_mouse_step_twist_counterclockwise": "Gire a tampa no sentido anti-horário, segure e solte.",
"ui.3d_mouse_switch_cap": "Calibrar como retorno ao centro",
"ui.3d_mouse_switch_trackball": "Calibrar como trackball",
"ui.3d_mouse_use_direct": "Usar driver direto",
"ui.3d_mouse_use_direct_tip": "Ler o mouse 3D diretamente (hidraw) em vez de pelo spacenavd.",
"ui.3d_mouse_view_axis": "Eixo da vista",
"ui.3d_mouse_view_off": "Desligado",
"ui.3d_mouse_view_off_tip": "O perfil do driver do mouse 3D desliga este eixo, então o movimento dele não move a vista. Escolher aqui o movimento de outro eixo troca os dois, e esse movimento deixa de mover a vista.",
@@ -1845,7 +1817,6 @@
"ui.circle": "Círculo",
"ui.circular_pattern": "Padrão circular",
"ui.clear": "Limpar",
"ui.clear_print_face": "Limpar face de impressão",
"ui.clear_recent": "Limpar recentes",
"ui.clear_the_selection": "Limpar a seleção",
"ui.click_selects_whole_bodies": "O clique seleciona corpos inteiros",
@@ -1880,7 +1851,6 @@
"ui.constant": "Constante",
"ui.constraints_conflict": "Restrições em conflito",
"ui.construction": "Construção",
"ui.construction.ribbon_label": "Linhas aux.",
"ui.context_menu": "Menu de contexto",
"ui.continuity_g0": "G0 vinco",
"ui.continuity_g1": "G1 tangente",
@@ -2178,7 +2148,6 @@
"ui.pan_zoom_orbit": "Deslocar, zoom, orbitar",
"ui.parallel": "Paralelo",
"ui.parameters": "Parâmetros",
"ui.part_size_needs_a_part": "Selecione uma peça padrão inserida (um parafuso, uma porca, uma arruela ou um pino) para alterar o tamanho. Para aumentar ou diminuir um corpo, use Escala.",
"ui.part_size_shared": "Reconstrói a peça, substituindo quaisquer recursos adicionados. Todas as ocorrências deste componente são atualizadas.",
"ui.paste": "Colar",
"ui.path": "Caminho",
@@ -2204,11 +2173,6 @@
"ui.polygon": "Polígono",
"ui.preferences": "Preferências",
"ui.press_a_key": "pressione uma tecla…",
"ui.print_face_cleared": "{body}: face de impressão removida",
"ui.print_face_input": "Face de impressão",
"ui.print_face_one_body": "Uma face de impressão pertence a um único corpo: envie esse corpo sozinho para alterá-la",
"ui.print_face_pick": "Selecione uma face plana de um corpo sólido para torná-la a face de impressão",
"ui.print_face_set": "{body}: face de impressão definida; Enviar ao fatiador apoia o corpo sobre ela",
"ui.profile": "Perfil",
"ui.profile_plane": "Plano do perfil",
"ui.profiles": "Perfis",
@@ -2266,7 +2230,6 @@
"ui.select": "Selecionar",
"ui.selectOther": "Selecionar outro…",
"ui.selectTangentChain": "Selecionar cadeia tangente",
"ui.select_all": "Selecionar tudo",
"ui.selection": "Seleção",
"ui.selection_modifiers": "Modificadores de seleção",
"ui.selection_shift_add_only": "Shift+clique adiciona sem remover",
@@ -2328,10 +2291,6 @@
"ui.symmetric": "Simétrico",
"ui.symmetry": "Simetria",
"ui.system": "Sistema",
"ui.tab_assemble": "Montar",
"ui.tab_manage": "Gerenciar",
"ui.tab_sheet_metal": "Chapa Metálica",
"ui.tab_view": "Vista",
"ui.tangent": "Tangente",
"ui.taper_angle": "Ângulo de inclinação",
"ui.tapped": "Roscado",
@@ -2399,9 +2358,7 @@
"update.button": "Atualizar",
"update.button_version": "Atualizar para {version}",
"update.check_automatically": "Verificar atualizações automaticamente",
"update.check_for_updates": "Verificar atualizações",
"update.check_now": "Verificar agora",
"update.check_unavailable": "A verificação de atualizações não está disponível para esta instalação.",
"update.checking": "Verificando…",
"update.copy": "Copiar",
"update.dmg_opened": "A imagem de disco está aberta e verificada: arraste o SolidCraft para Aplicativos, substituindo a cópia antiga, e reinicie o SolidCraft.",
+1 -2768
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+921 -2372
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -7,7 +7,7 @@ pub const DEFAULT_MATERIAL: &str = "Default";
pub const DEFAULT_DENSITY: f64 = 1.29;
/// Physical materials: name and density (g/cm³).
pub const MATERIALS: &[(&str, f64)] = &[
pub const MATERIALS: [(&str, f64); 14] = [
(DEFAULT_MATERIAL, DEFAULT_DENSITY),
("Steel", 7.85),
("Stainless Steel", 8.0),
+4 -182
View File
@@ -10,7 +10,7 @@ struct U {
acc_u: vec4<f32>, // accessibility map axes (xyz, offset)
acc_v: vec4<f32>,
eye: vec4<f32>, // eye position, w = 1 for perspective (0: orthographic, looking along -back)
ao: vec4<f32>, // x: radius in world units (zero: disabled), y: samples per pixel
ao: vec4<f32>, // radius in world units (zero: disabled)
// analysis colours (`theme::AnalysisColors`): draft positive, between, negative; reachable,
// blocked; curvature flat, middle, tight
ana_c: array<vec4<f32>, 8>,
@@ -18,8 +18,6 @@ struct U {
depth: vec4<f32>, // x = 1: reversed depth (1 near, 0 far); y, z: the faces' push (`FACE_BIAS`)
tint: vec4<f32>, // xyz added to a whole hovered body's colour before lighting
sel: vec4<f32>, // xyz a whole selected body's colour before lighting
product_lights_meta: vec4<f32>, // explicit preview flag and bounded count
product_lights: array<vec4<f32>,84>, // position/kind, radiance, axis/cone, attenuation
};
fn reversed() -> bool {
@@ -243,44 +241,6 @@ fn pushed(z: f32) -> f32 {
return clamp(z - select(-push, push, reversed()), 0.0, 1.0);
}
/// A far wall seen through the cut where another body's face lying on it was drawn in front of
/// it (a shaft in its bore, #9407): its cap colour again, at its depth pulled toward the eye by
/// `touch_depth`, so it wins over that face, which is faceted another way.
@fragment
fn fs_solid_touch(i: TOut, @builtin(front_facing) front: bool) -> CutOut {
var o: CutOut;
// Derivatives (in `touch_depth`) first, before `solid` may discard.
o.depth = touch_depth(i.wp, i.pos.z);
o.c = solid(i, front);
return o;
}
@fragment
fn fs_solid_touch_flat(i: FOut, @builtin(front_facing) front: bool) -> CutOut {
let t = flat_fragment(i, front);
var o: CutOut;
o.depth = touch_depth(t.wp, t.pos.z);
o.c = solid(t, front);
return o;
}
/// The depth of `wp` (depth `z`) pulled toward the eye by what may part two faceted copies of
/// one surface: 3 pixels' width along the view ray (display refinement keeps a facet's sag about
/// a pixel) and 2 pixels of the face's own depth slope (more where it turns from the eye).
fn touch_depth(wp: vec3<f32>, z: f32) -> f32 {
let px = min(length(dpdx(wp)), length(dpdy(wp)));
let slope = max(abs(dpdx(z)), abs(dpdy(z)));
var dir = -u.back.xyz;
if (u.eye.w > 0.5) { dir = wp - u.eye.xyz; }
let len = length(dir);
if (len < 1e-12) { return z; }
let c = u.vp * vec4<f32>(wp - dir * (3.0 * px / len), 1.0);
if (c.w <= 0.0) { return z; }
let pulled = c.z / c.w;
if (reversed()) { return clamp(max(pulled, z) + 2.0 * slope, 0.0, 1.0); }
return clamp(min(pulled, z) - 2.0 * slope, 0.0, 1.0);
}
/// Depth of the section plane on the view ray through `wp` (a far wall seen through the cut),
/// never behind the wall itself (`z`).
fn cap_depth(wp: vec3<f32>, z: f32) -> f32 {
@@ -373,12 +333,9 @@ fn occlusion(i: TOut) -> f32 {
// the buffer runs reversed). The map is cleared to its far plane either way, so a texel
// with nothing in it reads as 1 and the `d < 1.0` test below skips it.
let flip = reversed();
// Fewer samples while the view moves (#7165), spread over the same hemisphere. 32 is the
// still picture's count, `AO_SAMPLES` in view.rs.
let count = u32(clamp(u.ao.y, 1.0, 32.0));
var blocked = 0.0;
for (var j = 0u; j < count; j = j + 1u) {
let f = (f32(j) + 0.5) / f32(count);
for (var j = 0u; j < 32u; j = j + 1u) {
let f = (f32(j) + 0.5) / 32.0;
let angle = f32(j) * 2.39996323;
let direction = t * (cos(angle) * sqrt(f)) + b * (sin(angle) * sqrt(f)) + n * sqrt(1.0 - f);
let reach = radius * (0.2 + 0.8 * fract(f32(j) * 0.61803399 + 0.5));
@@ -399,7 +356,7 @@ fn occlusion(i: TOut) -> f32 {
blocked = blocked + smoothstep(0.005, 0.15, delta) * (1.0 - smoothstep(0.5, 1.0, delta));
}
}
return 1.0 - 0.85 * blocked / f32(count);
return 1.0 - 0.85 * blocked / 32.0;
}
/// How fast the normal turns across the pixel (per mm).
@@ -511,36 +468,6 @@ fn shade(i: TOut) -> vec4<f32> {
return lit(i, 1.0);
}
fn product_light_unit(v: vec3<f32>) -> vec3<f32> {
let scale=max(max(abs(v.x),abs(v.y)),abs(v.z));
if scale == 0.0 { return vec3<f32>(0.0,0.0,1.0); }
return normalize(v/scale);
}
// Direction and incident radiance for an unshadowed analytic preview sample.
fn product_light_sample(k: u32, p: vec3<f32>) -> mat2x3<f32> {
let source=u.product_lights[k*4u];
let colour=u.product_lights[k*4u+1u];
let axis=u.product_lights[k*4u+2u];
let falloff=u.product_lights[k*4u+3u];
var direction=product_light_unit(source.xyz);
var weight=1.0;
if source.w > 0.5 {
let delta=source.xyz-p;
let distance=max(length(delta),falloff.y);
direction=product_light_unit(delta);
weight=falloff.x/max(distance*distance,0.000001);
if source.w == 2.0 {
let cosine=dot(product_light_unit(axis.xyz),-direction);
var cone=select(0.0,1.0,cosine>=colour.w);
if axis.w-colour.w>0.000001 {cone=smoothstep(colour.w,axis.w,cosine);}
weight *= cone;
}
if source.w == 3.0 {weight *= max(dot(product_light_unit(axis.xyz),-direction),0.0);}
}
return mat2x3<f32>(direction,colour.xyz*weight);
}
fn lit(i: TOut, visibility: f32) -> vec4<f32> {
return lit_color(i, visibility, i.c);
}
@@ -555,17 +482,6 @@ fn lit_color(i: TOut, visibility: f32, base: vec4<f32>) -> vec4<f32> {
let v = normalize(u.back.xyz);
var n = normalize(i.n);
if (dot(n, v) < 0.0) { n = -n; }
if u.product_lights_meta.x>0.0 {
var radiance=vec3<f32>(0.0);
for (var k=0u;k<min(u32(u.product_lights_meta.y),21u);k+=1u) {
if u.product_lights[k*4u].w == 4.0 {radiance+=to_linear(base.rgb)*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
let sample=product_light_sample(k,i.wp);
radiance+=to_linear(base.rgb)*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
}
let mapped=radiance/(vec3<f32>(1.0)+radiance);
let encoded=select(mapped*12.92,1.055*pow(mapped,vec3<f32>(1.0/2.4))-vec3<f32>(0.055),mapped>vec3<f32>(0.0031308));
return out_color(vec4<f32>(encoded,base.a));
}
let key = normalize(v * 0.8 + vec3<f32>(-0.3, 0.2, 0.6));
let diff = max(dot(n, key), 0.0);
let fill = max(dot(n, v), 0.0);
@@ -734,97 +650,3 @@ fn fs_img(i: IOut) -> @location(0) vec4<f32> {
fn fs_line_all(i: LOut) -> @location(0) vec4<f32> {
return out_color(i.c);
}
// Point clouds (`view/clouds.rs`): each point is a round dot a few pixels across, facing the
// screen, depth-tested and written like the model's faces, so the model hides the points behind
// it and they hide it. The cloud's style colours them by their own colour, a ramp or a palette,
// and shows only those whose filtered values lie in its ranges or sets.
struct Cloud {
style: vec4<f32>, // x: colour (0 own, 1 ramp, 2 palette), y: dot size (px), z, w: ramp range
filters: array<vec4<f32>, 4>, // x: kind (0 off, 1 range y…z, 2 whole values y + i for the bits i of w)
};
@group(1) @binding(3) var<uniform> cloud: Cloud;
struct CIn {
@location(0) p: vec3<f32>,
@location(1) v: f32,
@location(2) f: vec4<f32>,
@location(3) rgb: vec4<f32>,
};
struct COut {
@builtin(position) pos: vec4<f32>,
@location(0) wp: vec3<f32>,
@location(1) v: f32,
@location(2) f: vec4<f32>,
@location(3) rgb: vec3<f32>,
@location(4) at: vec2<f32>, // the place on its disc
@location(5) @interpolate(flat) has: u32,
};
@vertex
fn vs_cloud_dot(@builtin(vertex_index) vi: u32, i: CIn) -> COut {
var xs = array<f32, 6>(-1.0, 1.0, 1.0, -1.0, 1.0, -1.0);
var ys = array<f32, 6>(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0);
let at = vec2<f32>(xs[vi], ys[vi]);
let size = max(cloud.style.y, 1.0);
let centre = u.vp * vec4<f32>(i.p, 1.0);
var o: COut;
o.pos = vec4<f32>(centre.xy + at * size / u.screen.xy * centre.w, centre.z, centre.w);
// Behind the eye: nothing.
if (near_z(centre) < 0.0) { o.pos = vec4<f32>(0.0, 0.0, 0.0, -1.0); }
o.wp = i.p;
o.v = i.v;
o.f = i.f;
o.rgb = i.rgb.rgb;
o.has = u32(round(i.rgb.a * 255.0));
// Dots under two pixels are squares: a disc that small loses its centre pixel.
o.at = at * select(1.0, 0.0, size < 2.0);
return o;
}
/// Whether filter `f` keeps the value `v`.
fn cloud_keeps(f: vec4<f32>, v: f32) -> bool {
if (f.x < 0.5) { return true; }
if (f.x < 1.5) { return v >= f.y && v <= f.z; }
let k = i32(round(v)) - i32(round(f.y));
return k >= 0 && k < 32 && ((bitcast<u32>(f.w) >> u32(k)) & 1u) != 0u;
}
fn cloud_ramp(t: f32) -> vec3<f32> {
var stops = array<vec3<f32>, 5>(
vec3<f32>(40.0, 70.0, 200.0), vec3<f32>(30.0, 170.0, 230.0), vec3<f32>(60.0, 200.0, 90.0),
vec3<f32>(240.0, 220.0, 60.0), vec3<f32>(230.0, 70.0, 50.0));
let k = clamp(t, 0.0, 1.0) * 4.0;
let j = min(u32(k), 3u);
return mix(stops[j], stops[j + 1u], k - f32(j)) / 255.0;
}
fn cloud_palette(v: f32) -> vec3<f32> {
var colours = array<vec3<f32>, 10>(
vec3<f32>(240.0, 140.0, 40.0), vec3<f32>(245.0, 205.0, 65.0), vec3<f32>(230.0, 65.0, 65.0),
vec3<f32>(165.0, 75.0, 190.0), vec3<f32>(65.0, 190.0, 115.0), vec3<f32>(75.0, 150.0, 220.0),
vec3<f32>(240.0, 120.0, 180.0), vec3<f32>(140.0, 105.0, 75.0), vec3<f32>(120.0, 215.0, 215.0),
vec3<f32>(190.0, 190.0, 80.0));
if (v < -0.5) { return vec3<f32>(150.0, 160.0, 176.0) / 255.0; }
return colours[u32(round(v)) % 10u] / 255.0;
}
@fragment
fn fs_cloud(o: COut) -> @location(0) vec4<f32> {
var out = clipped(o.wp) || dot(o.at, o.at) > 1.0;
for (var k = 0u; k < 4u; k++) {
out = out || (((o.has >> k) & 1u) != 0u && !cloud_keeps(cloud.filters[k], o.f[k]));
}
if (out) { discard; }
var c = o.rgb;
let mode = cloud.style.x;
if ((o.has & 16u) != 0u && mode > 1.5) {
c = cloud_palette(o.v);
} else if ((o.has & 16u) != 0u && mode > 0.5) {
c = cloud_ramp((o.v - cloud.style.z) / max(cloud.style.w - cloud.style.z, 1e-12));
}
return out_color(vec4<f32>(c, 1.0));
}
+4 -19
View File
@@ -175,8 +175,6 @@ impl Tokens {
body: Color32::from_rgb(176, 186, 198),
body_edge: Color32::from_rgb(40, 44, 52),
manipulator: Color32::from_rgb(40, 110, 220),
joint_manipulator: Color32::from_rgb(235, 157, 0),
joint_axis: Color32::from_rgb(0, 95, 110),
preview_add: Color32::from_rgb(112, 160, 226),
preview_new: Color32::from_rgb(240, 225, 105),
preview_sketch: Color32::from_rgb(32, 190, 96),
@@ -195,12 +193,8 @@ impl Tokens {
sketch_halo: Color32::from_rgb(240, 243, 247),
sketch_point: Color32::BLACK,
sketch_locked: Color32::from_rgb(0, 140, 60),
// The error red (`error`).
sketch_conflict: Color32::from_rgb(214, 54, 54),
// Sketch dimensions in a plum of their own, apart from the blue and black curves for
// every colour vision mode (#7861).
dimension: Color32::from_rgb(130, 42, 110),
dimension_driven: Color32::from_rgb(160, 90, 120),
dimension: Color32::from_rgb(36, 40, 48),
dimension_driven: Color32::from_rgb(120, 126, 138),
sketch_point_free: Color32::from_rgb(30, 90, 200),
section_cap: Color32::from_rgb(214, 128, 72),
dialog_bg: Color32::from_rgb(250, 251, 252),
@@ -347,14 +341,12 @@ impl Tokens {
t.selected_bg = Color32::from_rgb(42, 70, 110);
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[1]);
t.error = Color32::from_rgb(240, 100, 96);
t.sketch_conflict = t.error;
t.warning = Color32::from_rgb(236, 170, 70);
t.icon = Color32::from_rgb(208, 214, 226);
t.timeline = Color32::from_rgb(33, 37, 45);
t.hover = Color32::from_rgb(54, 61, 74);
t.hover_edge_halo = Color32::from_rgb(203, 203, 203);
t.origin_point = Color32::from_rgb(214, 215, 218);
t.joint_axis = Color32::from_rgb(110, 240, 250);
t.viewport_top = Color32::from_rgb(46, 52, 64);
t.viewport_bottom = Color32::from_rgb(46, 52, 64);
t.grid = Color32::from_rgb(59, 65, 76);
@@ -366,9 +358,8 @@ impl Tokens {
t.sketch_halo = Color32::from_rgb(28, 32, 40);
t.sketch_point = Color32::from_rgb(232, 234, 238);
t.sketch_locked = Color32::from_rgb(96, 204, 116);
// Sketch dimensions: Light's plum, lightened for the dark view (#7861).
t.dimension = Color32::from_rgb(224, 176, 224);
t.dimension_driven = Color32::from_rgb(176, 104, 144);
t.dimension = Color32::from_rgb(224, 228, 234);
t.dimension_driven = Color32::from_rgb(142, 150, 164);
t.sketch_point_free = Color32::from_rgb(92, 150, 255);
t.profile_fill = Color32::from_rgba_unmultiplied_const(120, 150, 200, 40);
t.dialog_bg = Color32::from_rgb(39, 43, 52);
@@ -419,9 +410,6 @@ impl Tokens {
}
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_RED_GREEN[dark as usize]);
t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_RED_GREEN[dark as usize]);
// A deeper red keeps conflicting sketch geometry (#7244) apart from the orange
// construction and centerline lines and the crimson lost links for a red-green loss.
t.sketch_conflict = Color32::from_rgb(195, 35, 30);
t.surface_accent = if dark { Color32::from_rgb(200, 135, 55) } else { Color32::from_rgb(164, 80, 0) };
t.mesh_accent = if dark { Color32::from_rgb(190, 175, 70) } else { Color32::from_rgb(130, 103, 0) };
t.form_accent = if dark { Color32::from_rgb(165, 135, 220) } else { Color32::from_rgb(115, 73, 205) };
@@ -452,9 +440,6 @@ impl Tokens {
t.preview_sketch = Color32::from_rgb(125, 180, 105);
t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_BLUE_YELLOW[dark as usize]);
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_BLUE_YELLOW[dark as usize]);
// Conflicting sketch geometry (#7244) apart from the lost links and the construction and
// centerline lines for a blue-yellow loss: darker on the light view, lighter on the dark.
t.sketch_conflict = if dark { Color32::from_rgb(255, 170, 160) } else { Color32::from_rgb(165, 15, 30) };
if dark {
t.sketch_locked = Color32::from_rgb(140, 225, 110);
t.box_crossing = Color32::from_rgb(240, 110, 170);
-5
View File
@@ -1,6 +1,4 @@
{
"order": 76,
"provenance": "Original black-and-white palette",
"base": "dark",
"colors": {
"accent": "#ffffff",
@@ -93,8 +91,6 @@
"icons.highlight": "#000000",
"icons.marker_halo": "#000000",
"interference": "#ffffff",
"joint_axis": "#ffffff",
"joint_manipulator": "#ffffff",
"logo": [
"#ffffff",
"#ffffff",
@@ -146,7 +142,6 @@
"sketch": "#ffffff",
"sketch_accent": "#ffffff",
"sketch_centerline": "#ffffff",
"sketch_conflict": "#ffffff",
"sketch_construction": "#ffffff",
"sketch_fixed": "#ffffff",
"sketch_halo": "#000000",
-5
View File
@@ -1,6 +1,4 @@
{
"order": 75,
"provenance": "Original black-and-white palette",
"base": "light",
"colors": {
"accent": "#000000",
@@ -93,8 +91,6 @@
"icons.highlight": "#ffffff",
"icons.marker_halo": "#ffffff",
"interference": "#000000",
"joint_axis": "#000000",
"joint_manipulator": "#000000",
"logo": [
"#000000",
"#000000",
@@ -146,7 +142,6 @@
"sketch": "#000000",
"sketch_accent": "#000000",
"sketch_centerline": "#000000",
"sketch_conflict": "#000000",
"sketch_construction": "#000000",
"sketch_fixed": "#000000",
"sketch_halo": "#ffffff",
+2 -4
View File
@@ -1,6 +1,4 @@
{
"order": 220,
"provenance": "Maps the Okibi design system",
"base": "light",
"colors": {
"accent": "#c63a00",
@@ -52,8 +50,8 @@
"dialog_footer": "#efece8",
"dialog_label": "#403d38",
"dialog_rule": "#d9d5d0",
"dimension": "#006a70",
"dimension_driven": "#4d8a8c",
"dimension": "#403d38",
"dimension_driven": "#6c6863",
"error": "#c71529",
"field": "#fffdfa",
"file_rail": "#e1ddd8",
+2 -4
View File
@@ -1,6 +1,4 @@
{
"order": 110,
"provenance": "Original SolidCraft theme",
"base": "dark",
"colors": {
"accent": "#ffbc48",
@@ -52,8 +50,8 @@
"dialog_footer": "#100b04",
"dialog_label": "#f3ca87",
"dialog_rule": "#3b280d",
"dimension": "#f5e050",
"dimension_driven": "#a8962e",
"dimension": "#ffe2a5",
"dimension_driven": "#9b6922",
"error": "#fff0ca",
"field": "#100b04",
"form_accent": "#ca892b",
+2 -4
View File
@@ -1,6 +1,4 @@
{
"order": 120,
"provenance": "Original SolidCraft theme",
"base": "dark",
"colors": {
"accent": "#66ddff",
@@ -52,8 +50,8 @@
"dialog_footer": "#0c223c",
"dialog_label": "#e3f0fa",
"dialog_rule": "#254764",
"dimension": "#ffd970",
"dimension_driven": "#c9a95a",
"dimension": "#e3f0fa",
"dimension_driven": "#94b2cb",
"error": "#ff968d",
"field": "#071d35",
"form_accent": "#baade8",
@@ -1,6 +1,4 @@
{
"order": 200,
"provenance": "Original SolidCraft mapping of Catppuccin (MIT palette)",
"base": "dark",
"colors": {
"accent": "#cba6f7",
@@ -52,8 +50,8 @@
"dialog_footer": "#1e1e2e",
"dialog_label": "#cdd6f4",
"dialog_rule": "#45475a",
"dimension": "#94e2d5",
"dimension_driven": "#6c9d95",
"dimension": "#cdd6f4",
"dimension_driven": "#a6adc8",
"error": "#f38ba8",
"field": "#181825",
"form_accent": "#a9b2f2",
+2 -7
View File
@@ -1,6 +1,4 @@
{
"order": 250,
"provenance": "Original mapping of the IBM Color Graphics Adapter palette",
"base": "dark",
"colors": {
"accent": "#55ffff",
@@ -47,8 +45,8 @@
"dialog_footer": "#000000",
"dialog_label": "#55ffff",
"dialog_rule": "#00aaaa",
"dimension": "#ffff55",
"dimension_driven": "#aa5500",
"dimension": "#ffffff",
"dimension_driven": "#aaaaaa",
"error": "#ff5555",
"field": "#000000",
"file_background": "#000000",
@@ -93,8 +91,6 @@
"icons.highlight": "#ffffff",
"icons.marker_halo": "#000000cc",
"interference": "#ff5555",
"joint_axis": "#aaaaaa",
"joint_manipulator": "#ffff55",
"logo": [
"#ffffff",
"#55ffff",
@@ -146,7 +142,6 @@
"sketch": "#55ffff",
"sketch_accent": "#ff55ff",
"sketch_centerline": "#ffff55",
"sketch_conflict": "#ff5555",
"sketch_construction": "#aaaaaa",
"sketch_fixed": "#ffffff",
"sketch_halo": "#000000",
+2 -4
View File
@@ -1,6 +1,4 @@
{
"order": 240,
"provenance": "Original warm paper and charcoal palette",
"base": "dark",
"colors": {
"accent": "#e8845e",
@@ -52,8 +50,8 @@
"dialog_footer": "#29241f",
"dialog_label": "#c3bdb5",
"dialog_rule": "#403930",
"dimension": "#5fc4c0",
"dimension_driven": "#4f9895",
"dimension": "#c3bdb5",
"dimension_driven": "#9d9285",
"error": "#f85052",
"field": "#3c352d",
"form_accent": "#a6a2fc",

Some files were not shown because too many files have changed in this diff Show More