Compare commits

..

6 Commits

Author SHA1 Message Date
Brennen Herbruck 872d0bf813 docs(rfc): make TypeScript a first-class plugin language in RFC 0002
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:10:47 -05:00
Brennen Herbruck 27844206b1 docs(rfc): add shared-across-Craft-apps section to RFC 0002
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:43:32 -05:00
Brennen Herbruck 8ac23ec94e docs(rfc): add agent-first to the RFC 0002 decisions list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:35:40 -05:00
Brennen Herbruck 118a37f5a0 docs(rfc): add agent-first section to RFC 0002
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:35:28 -05:00
Brennen Herbruck 34777cfb39 docs(rfc): note #4980 closed in RFC 0002
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:34:46 -05:00
Brennen Herbruck 44e885e295 docs(rfc): record plugin decisions in RFC 0002
Records the owner's decisions (one MCP command surface, WebAssembly as the
only runtime, no network at first, host-drawn UI through #15100), reserves
the net permission for signed official plugins, maps phases to PRs, and adds
a design-review gate on the phase 1 PR.

Refs #1221.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:34:37 -05:00
356 changed files with 5263 additions and 24172 deletions
-6
View File
@@ -9,9 +9,3 @@ self-hosted-runner:
- blacksmith-16vcpu-ubuntu-2404
- blacksmith-32vcpu-ubuntu-2404
- blacksmith-8vcpu-windows-2025
# Release builds (workflows/release.yml).
- blacksmith-16vcpu-ubuntu-2204
- blacksmith-16vcpu-ubuntu-2204-arm
- blacksmith-4vcpu-ubuntu-2404-arm
- blacksmith-6vcpu-macos-15
- blacksmith-12vcpu-macos-15
-3
View File
@@ -10,9 +10,6 @@ Changes a default: no
Changes a default: yes — <what>, old behaviour kept behind preference <name>
Docs-only and bot PRs are exempt. -->
<!-- A kernel change (solidcore/, crates/smlib/) that changes or deletes lines of an existing test or golden expectation needs a line saying why the old test was wrong (adding tests needs nothing):
Test change: <reason> -->
## Validation
What did you run, and what were the results? Include expected and measured values for geometry changes.
+2 -6
View File
@@ -84,8 +84,7 @@ jobs:
# Draft pull requests run no CI, as in ci.yml.
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: blacksmith-2vcpu-ubuntu-2404
# The checkout fetches history without file contents (below), so this takes well under a minute.
timeout-minutes: 10
timeout-minutes: 5
outputs:
# The shard matrix (JSON list of areas and the ladder; [] when nothing is selected).
shards: ${{ steps.plan.outputs.shards }}
@@ -94,11 +93,8 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# A pull request's diff needs its base's history: the commits and trees, not every file
# version. A full clone of every blob took over the 5-minute timeout on a 2-vCPU runner and
# cancelled the merge queue's rig selection (Select the rig areas) again and again.
# A pull request's diff needs its base's history.
fetch-depth: ${{ (github.event_name == 'pull_request' || github.event_name == 'merge_group') && '0' || '1' }}
filter: ${{ (github.event_name == 'pull_request' || github.event_name == 'merge_group') && 'blob:none' || '' }}
- name: Selection self-test
run: python3 -I scripts/ci/test_kernel_rig_plan.py
- id: plan
+3 -19
View File
@@ -1,7 +1,6 @@
# Four quick checks on every pull request (CONTRIBUTING.md, "Sending a change"): no second open PR
# for an issue a lower-numbered one already closes, the "Changes a default:" line is filled in, a PR
# adding to the showcase shows a picture, and a kernel fix that changes or deletes existing test or
# golden lines says why on a "Test change:" line. None is a required status check. The logic is in scripts/pr-checks/pr-checks.js (tested in ci.yml).
# Three quick checks on every pull request (CONTRIBUTING.md, "Sending a change"): no second open PR
# for an issue a lower-numbered one already closes, the "Changes a default:" line is filled in, and
# a PR adding to the showcase shows a picture. None is a required status check. The logic is in scripts/pr-checks/pr-checks.js (tested in ci.yml).
name: PR checks
on:
@@ -64,21 +63,6 @@ jobs:
const r = await checks.checkPicture({ github, context, core });
core.info(JSON.stringify(r));
test-change:
name: Test change check
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/pr-checks
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const checks = require('./scripts/pr-checks/pr-checks.js');
const r = await checks.checkTestChange({ github, context, core });
core.info(JSON.stringify(r));
# Labelling needs write access, which a fork PR's token never has, so it is its own job and best
# effort: a fork PR just doesn't get the `changes-default` label from here.
label:
+54 -27
View File
@@ -8,9 +8,6 @@
# The macOS app is signed and notarized when the APPLE_* secrets exist. The Windows binaries and
# MSI are Authenticode-signed through SignPath Foundation when the SIGNPATH_API_TOKEN secret exists
# (see the windows job). Nothing else is signed yet.
# Runners: Blacksmith (blacksmith-<N>vcpu-<os>), as CI uses, except the `version` and `windows`
# jobs, which stay GitHub-hosted: SignPath Foundation only signs a request when every job leading
# up to it ran on a GitHub-hosted runner.
#
# 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
@@ -117,18 +114,40 @@ jobs:
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
# The app icon in every size, drawn by solidcraft-cli (no image files are committed), for the
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
icons:
needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-icons
- run: cargo build --release --locked -p solidcraft-cli
- run: |
. packaging/env.sh
make_icons target/release/solidcraft-cli icons
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: icons
path: icons
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
if: needs.version.outputs.build == 'true'
# Ubuntu 22.04, the oldest image, so the binaries only need glibc >= 2.35.
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: blacksmith-16vcpu-ubuntu-2204, deb: amd64 }
- { arch: aarch64, runner: blacksmith-16vcpu-ubuntu-2204-arm, deb: arm64 }
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -184,8 +203,8 @@ jobs:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: blacksmith-4vcpu-ubuntu-2404 }
- { arch: aarch64, runner: blacksmith-4vcpu-ubuntu-2404-arm }
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -214,7 +233,7 @@ jobs:
needs: [version, linux]
if: needs.version.outputs.build == 'true'
# makepkg only repackages the tarball's prebuilt binaries, so both architectures build here.
runs-on: blacksmith-2vcpu-ubuntu-2404
runs-on: ubuntu-24.04
# A pre-release or nightly without an Arch package is still worth having; a stable release isn't.
continue-on-error: ${{ contains(needs.version.outputs.version, '-') }}
strategy:
@@ -284,8 +303,8 @@ jobs:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: blacksmith-4vcpu-ubuntu-2404 }
- { arch: aarch64, runner: blacksmith-4vcpu-ubuntu-2404-arm }
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -330,8 +349,8 @@ jobs:
name: FreeBSD x86_64
needs: version
if: needs.version.outputs.build == 'true'
# There are no FreeBSD runners: build in a FreeBSD 14.3 VM (Blacksmith's x64 runners have KVM).
runs-on: blacksmith-16vcpu-ubuntu-2404
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
runs-on: ubuntu-latest
timeout-minutes: 180
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
@@ -344,8 +363,8 @@ jobs:
with:
release: "14.3"
usesh: true
cpu: 14
mem: 40960
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_BUILD_DATE"
prepare: |
@@ -355,9 +374,9 @@ jobs:
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM. About 3 GB of
# VM memory per job: 4 jobs in 12 GB was the most that fit before.
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=12
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
# runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
tar -tzf dist/release/solidcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/io\.tangencylabs\.solidcraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
@@ -369,12 +388,11 @@ jobs:
# The two halves of the universal binary build in parallel on their own runners (each alone
# took about half of the old single job's 45 minutes); `macos` below joins and packages them.
# Blacksmith's macOS runners are Apple silicon: the x86_64 half is cross-compiled.
macos-build:
name: macOS build ${{ matrix.arch }}
needs: version
if: needs.version.outputs.build == 'true'
runs-on: blacksmith-12vcpu-macos-15
runs-on: macos-15
strategy:
fail-fast: false
matrix:
@@ -408,7 +426,7 @@ jobs:
name: macOS universal
needs: [version, macos-build]
if: needs.version.outputs.build == 'true'
runs-on: blacksmith-6vcpu-macos-15
runs-on: macos-15
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -448,11 +466,10 @@ jobs:
windows:
name: Windows ${{ matrix.arch }}
needs: version
needs: [version, icons]
if: needs.version.outputs.build == 'true'
# Must stay a GitHub-hosted runner, like the `version` job before it: SignPath's open-source
# policy only signs requests whose jobs leading up to them all ran on GitHub-hosted runners.
# The installer icon is the committed assets/icons/app/windows/exports/solidcraft.ico.
# Must stay a GitHub-hosted runner, like every job before it: SignPath's open-source policy
# only signs builds whose jobs all ran on GitHub-hosted runners.
runs-on: windows-latest
# actions: read lets the SignPath action read this job's details and fetch the uploaded
# artifact with GITHUB_TOKEN (the workflow default above grants contents: read only).
@@ -481,6 +498,10 @@ jobs:
SIGNPATH_PROJECT_SLUG: solidcraft
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: icons
path: icons
- uses: dtolnay/rust-toolchain@1.98.0
with:
targets: ${{ matrix.target }}
@@ -495,6 +516,8 @@ jobs:
- name: Build and package (msi, portable zip)
if: env.SIGN_WINDOWS != 'true'
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
# ---- signed path ---------------------------------------------------------------------------
@@ -508,6 +531,8 @@ jobs:
- name: Build and stage the binaries for signing
if: env.SIGN_WINDOWS == 'true'
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase stage
- name: Upload the unsigned binaries
id: unsigned-exes
@@ -551,6 +576,8 @@ jobs:
- name: Package the signed binaries (msi, portable zip)
if: env.SIGN_WINDOWS == 'true'
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase package
- name: Upload the unsigned MSI
id: unsigned-msi
@@ -600,7 +627,7 @@ jobs:
name: Web (wasm)
needs: version
if: needs.version.outputs.build == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubuntu-latest
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
TRUNK_VERSION: 0.21.14
@@ -629,7 +656,7 @@ jobs:
needs: [version, linux, flatpak, arch, snap, freebsd, macos, windows, web]
# Not for the release gate (packages_only): it builds and checks the packages, nothing more.
if: needs.version.outputs.build == 'true' && !inputs.packages_only
runs-on: blacksmith-2vcpu-ubuntu-2404
runs-on: ubuntu-latest
permissions:
contents: write
env:
Generated
+38 -139
View File
@@ -730,42 +730,18 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "calloop"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
dependencies = [
"bitflags 2.13.2",
"polling",
"rustix 1.1.6",
"tracing",
]
[[package]]
name = "calloop-wayland-source"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
dependencies = [
"calloop 0.13.0",
"calloop",
"rustix 0.38.44",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "calloop-wayland-source"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
dependencies = [
"calloop 0.14.5",
"rustix 1.1.6",
"wayland-backend",
"wayland-client",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -1306,7 +1282,6 @@ dependencies = [
"profiling",
"raw-window-handle",
"serde",
"smithay-clipboard",
"web-time",
"webbrowser",
"windows-sys 0.61.2",
@@ -4198,8 +4173,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
dependencies = [
"bitflags 2.13.2",
"calloop 0.13.0",
"calloop-wayland-source 0.3.0",
"calloop",
"calloop-wayland-source",
"cursor-icon",
"libc",
"log",
@@ -4216,44 +4191,6 @@ dependencies = [
"xkeysym",
]
[[package]]
name = "smithay-client-toolkit"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
dependencies = [
"bitflags 2.13.2",
"calloop 0.14.5",
"calloop-wayland-source 0.4.1",
"cursor-icon",
"libc",
"log",
"memmap2",
"rustix 1.1.6",
"thiserror 2.0.21",
"wayland-backend",
"wayland-client",
"wayland-csd-frame",
"wayland-cursor",
"wayland-protocols",
"wayland-protocols-experimental",
"wayland-protocols-misc",
"wayland-protocols-wlr",
"wayland-scanner",
"xkeysym",
]
[[package]]
name = "smithay-clipboard"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
dependencies = [
"libc",
"smithay-client-toolkit 0.20.0",
"wayland-backend",
]
[[package]]
name = "smol_str"
version = "0.2.2"
@@ -4322,7 +4259,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4365,11 +4302,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.40.1"
version = "0.40.0"
[[package]]
name = "solidcraft-cli"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"image",
"log",
@@ -4385,11 +4322,11 @@ dependencies = [
[[package]]
name = "solidcraft-color"
version = "0.40.1"
version = "0.40.0"
[[package]]
name = "solidcraft-control"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"getrandom 0.3.4",
"serde",
@@ -4398,7 +4335,7 @@ dependencies = [
[[package]]
name = "solidcraft-control-egui"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"egui",
"image",
@@ -4408,7 +4345,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"proc-macro2",
"quote",
@@ -4417,7 +4354,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"log",
"serde",
@@ -4427,7 +4364,6 @@ dependencies = [
"solidcore-tess",
"solidcraft-color",
"solidcraft-derive",
"solidcraft-format",
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-profile",
@@ -4440,7 +4376,7 @@ dependencies = [
[[package]]
name = "solidcraft-dynamics"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"serde",
"serde_json",
@@ -4451,7 +4387,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"image",
"log",
@@ -4475,18 +4411,9 @@ dependencies = [
"web-time",
]
[[package]]
name = "solidcraft-format"
version = "0.40.1"
dependencies = [
"serde_json",
"sha2 0.10.9",
"zip",
]
[[package]]
name = "solidcraft-geom"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"serde",
"serde_json",
@@ -4499,7 +4426,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"image",
"log",
@@ -4524,7 +4451,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4542,7 +4469,6 @@ dependencies = [
"solidcore-record",
"solidcraft-color",
"solidcraft-doc",
"solidcraft-format",
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-profile",
@@ -4553,7 +4479,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"log",
"proptest",
@@ -4574,7 +4500,7 @@ dependencies = [
[[package]]
name = "solidcraft-look"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"egui",
"log",
@@ -4586,7 +4512,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"image",
"serde_json",
@@ -4600,14 +4526,14 @@ dependencies = [
[[package]]
name = "solidcraft-mcp-server"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"serde_json",
]
[[package]]
name = "solidcraft-profile"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"serde_json",
"web-time",
@@ -4615,7 +4541,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"flate2",
"image",
@@ -4638,7 +4564,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"harfrust",
"serde",
@@ -4652,7 +4578,7 @@ dependencies = [
[[package]]
name = "solidcraft-smlib"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"flate2",
"log",
@@ -4666,18 +4592,18 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-testkit"
version = "0.40.1"
version = "0.40.0"
[[package]]
name = "solidcraft-theme"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"egui",
"serde",
@@ -4687,7 +4613,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"arboard",
"egui",
@@ -4729,7 +4655,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-kit"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"arboard",
"egui",
@@ -4752,14 +4678,14 @@ dependencies = [
[[package]]
name = "solidcraft-ui-registry"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"egui",
]
[[package]]
name = "solidcraft-ui-text"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"log",
"serde_json",
@@ -4769,7 +4695,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"image",
"serde",
@@ -4781,7 +4707,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-egui"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -4792,7 +4718,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-gpu"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"log",
"naga",
@@ -4808,7 +4734,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"eframe",
"egui",
@@ -5126,7 +5052,6 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -5527,32 +5452,6 @@ dependencies = [
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-experimental"
version = "20250721.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-misc"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-plasma"
version = "0.3.12"
@@ -6158,7 +6057,7 @@ dependencies = [
"bitflags 2.13.2",
"block2 0.5.1",
"bytemuck",
"calloop 0.13.0",
"calloop",
"cfg_aliases",
"concurrent-queue",
"core-foundation 0.9.4",
@@ -6179,7 +6078,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"smithay-client-toolkit 0.19.2",
"smithay-client-toolkit",
"smol_str",
"tracing",
"unicode-segmentation",
@@ -6321,7 +6220,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.40.1"
version = "0.40.0"
dependencies = [
"flate2",
"proc-macro2",
+1 -3
View File
@@ -6,7 +6,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["solidcore", "vendor"]
[workspace.package]
version = "0.40.1"
version = "0.40.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -25,8 +25,6 @@ solidcraft-render = { path = "crates/render" }
solidcraft-dynamics = { path = "crates/dynamics" }
solidcraft-gpu = { path = "crates/gpu" }
solidcraft-doc = { path = "crates/doc" }
# Versioned file formats: the format tag, the version registry and its upgrade, a newer file told
solidcraft-format = { path = "crates/format" }
solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" }
@@ -1750,31 +1750,3 @@ fn the_ribbed_lid_builds_the_same_body_every_time() {
assert_eq!(fingerprint(), first);
}
}
// #13295: a circle sketched on the plane at 45 degrees through a bar's step edge, 7.9e-6 off
// that edge (as the reporter's sketch had it), cut through all across a hole. The cut wall's
// seam edge ends on the bar's edge a few micrometres across the seam, inside its vertex's
// tolerance, and the check refused the result ("Face Near missing Seam").
#[test]
fn a_circle_cut_from_a_slanted_plane_through_a_step_edge_beside_a_hole() {
let mut s = Session::default();
let (y0, z1, yc, z2) = (16.423877938511588, 13.06859205815335, 12.527075811882755, 19.566787029154483);
let p = [[0.0, 0.0], [y0, 0.0], [y0, z1], [yc, z1], [yc, z2], [0.0, z2]];
s.execute("sketch.create", &json!({"plane": "YZ"})).unwrap();
for i in 0..6 {
s.execute("sketch.line", &json!({"points": [p[i], p[(i + 1) % 6]]})).unwrap();
}
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.extrude", &json!({"distance": 100, "operation": "new"})).unwrap();
let start = s.doc.features.len();
let hole = json!({"base": [20.421614627576027, y0 + 5.0, 6.534296029076675], "axis": [0, -1, 0], "radius": 3.5355339059327378, "height": y0 + 10.0, "operation": "cut", "targets": ["Body1"]});
s.execute("solid.cylinder", &hole).unwrap();
let r = std::f64::consts::FRAC_1_SQRT_2;
let plane = json!({"origin": [0, yc - yc * r, z2 + yc * r], "x_dir": [1, 0, 0], "y_dir": [0, r, -r]});
s.execute("sketch.create", &json!({"plane": plane, "name": "Slant"})).unwrap();
s.execute("sketch.circle.center", &json!({"center": [20.4216146277246, 12.527083680740917], "radius": 4.716990566008734})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.extrude", &json!({"sketch": "Slant", "through_all": true, "direction": "negative", "operation": "cut", "targets": ["Body1"]}))
.unwrap();
check(&s, start, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
}
+1 -3
View File
@@ -42,9 +42,7 @@ solidcraft-look = { workspace = true }
solidcraft-ui-text = { workspace = true }
solidcraft-theme = { workspace = true }
solidcraft-space-mouse = { workspace = true }
# "clipboard" is not a default here: eframe 0.37 builds egui-winit without it, and then every
# Copy and Paste on the desktop silently does nothing (see src/clipboard_tests.rs).
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "clipboard"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
-13
View File
@@ -1,13 +0,0 @@
//! The desktop build keeps the system clipboard.
//!
//! eframe 0.37 builds egui-winit without its clipboard unless asked, and the workspace turns
//! eframe's defaults off: every Copy (the error text, Copy diagnostic information, Ctrl+C in a
//! field) then reaches the platform as a command that nothing carries out. The UI tests see the
//! copied text in egui's output and still pass, so this is checked where the build is described.
#[test]
fn the_desktop_build_enables_eframes_clipboard() {
let manifest = include_str!("../Cargo.toml");
let eframe = manifest.lines().find(|line| line.trim_start().starts_with("eframe =")).expect("the eframe dependency");
assert!(eframe.contains("\"clipboard\""), "eframe without its clipboard feature drops every copy on the desktop: {eframe}");
}
-2
View File
@@ -20,8 +20,6 @@
#[cfg(not(target_os = "macos"))]
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
mod app_icon;
#[cfg(test)]
mod clipboard_tests;
mod come_forward;
mod control_server;
#[cfg(target_os = "linux")]
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "الملفات المستوردة",
"prefs.design_tools.note": "ملاحظة",
"prefs.design_tools.off": "إيقاف",
"prefs.design_tools.placement_group": "الميزات الموضوعة",
"prefs.design_tools.placement_group": "مسند، نتوء، تعشيق مرن",
"prefs.design_tools.rows": "صفوف",
"prefs.devices": "الفأرة ثلاثية الأبعاد",
"prefs.dialog_buttons": "أزرار مربعات الحوار",
@@ -6070,7 +6070,7 @@
"ui.pitch": "الخطوة",
"ui.placement": "الموضع",
"ui.placement_handles": "مقابض الموضع",
"ui.placement_handles_tip": "علامة لسحب الميزة عبر النموذج، وحلقة لتدويرها حيث يمكن تدوير الميزة.",
"ui.placement_handles_tip": "علامة لسحب الميزة عبر النموذج وحلقة لتدويرها.",
"ui.plane": "المستوى",
"ui.plane_at_angle": "مستوى بزاوية",
"ui.plugins": "الإضافات",
+1 -1
View File
@@ -3384,7 +3384,7 @@
"ui.pitch": "Pitch",
"ui.placement": "Placement",
"ui.placement_handles": "Placement handles",
"ui.placement_handles_tip": "Маркер за влачене на елемента по модела и, където елементът може да се завърта, пръстен за завъртането му.",
"ui.placement_handles_tip": "Handles on the preview of a Rest, Boss or Snap Fit: drag the marker to move it across the model, turn it by its ring",
"ui.plane": "Plane",
"ui.plane_at_angle": "Plane at Angle",
"ui.plugins": "Plugins",
+1 -1
View File
@@ -4907,7 +4907,7 @@
"ui.pitch": "Korak",
"ui.placement": "Postavljanje",
"ui.placement_handles": "Ručice za postavljanje",
"ui.placement_handles_tip": "Oznaka za povlačenje operacije po modelu i, gdje se operacija može okretati, prsten za njeno okretanje.",
"ui.placement_handles_tip": "Ručice na pregledu oslonca, ispusta ili škljocajućeg spoja: povucite oznaku da je pomjerite po modelu, okrenite je njenim prstenom",
"ui.plane": "Ravan",
"ui.plane_at_angle": "Ravan pod uglom",
"ui.plugins": "Proširenja",
+10 -192
View File
@@ -247,7 +247,6 @@
"Drag to move the hole; click to remove it": "Arrossegueu per moure el forat; feu clic per treure'l",
"Drag to move the rest across the model. It snaps to sketch points on the face.": "Arrossegueu per moure el recolzament pel model. S'ajusta als punts del bocet de la cara.",
"Drag to move the snap fit across the model. It snaps to sketch points on the face.": "Arrossegueu per moure l'encaix a pressió pel model. S'ajusta als punts del bocet de la cara.",
"Drag to place it on another face.": "Arrossegueu-lo per col·locar-lo en una altra cara.",
"Drag to point the catch in another direction.": "Arrossegueu per orientar el retén en una altra direcció.",
"Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "Arrossegueu per girar el recolzament de 5° en 5°. Manteniu Alt o Ctrl per girar-lo lliurement.",
"Drive Joint": "Accionar la unió",
@@ -514,7 +513,6 @@
"Offset Y": "Desplaçament Y",
"Offset Z": "Desplaçament Z",
"On your computer": "Al vostre equip",
"One more stretch of the picked edge, each with its own angle, height and turns": "Un tram més de l'aresta triada, cadascun amb el seu angle, altura i girs",
"Opacity": "Opacitat",
"Open GitHub": "Obrir GitHub",
"Open GitHub draft": "Obrir l'esborrany de GitHub",
@@ -636,7 +634,6 @@
"Remove the track": "Eliminar la pista",
"Remove this bend": "Treure aquest plegat",
"Remove this point": "Eliminar aquest punt",
"Remove this segment": "Eliminar aquest segment",
"Rename Feature": "Canviar el nom de l'operació",
"Rename Folder": "Canviar el nom de la carpeta",
"Render Stage": "Escenari de renderitzat",
@@ -726,7 +723,6 @@
"Section position": "Posició de la secció",
"Sections": "Seccions",
"Sections (one sketch each)": "Seccions (un bocet cadascuna)",
"Segments": "Segments",
"Select Features": "Seleccionar les operacions",
"Select Other": "Seleccionar-ne un altre",
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Seleccionar-ne un altre: manteniu el botó esquerre quiet sobre el model (o feu clic amb el botó dret › Seleccionar-ne un altre…) per llistar tot el que hi ha sota el punter; torneu a fer clic al mateix lloc per seleccionar el següent element. Feu doble clic amb el botó central per tornar a Inici (Preferències › Navegació).",
@@ -776,7 +772,6 @@
"Spacing must be a non-zero number": "L'espaiat ha de ser un nombre diferent de zero",
"Specialise": "Especialitzar",
"Speed": "Velocitat",
"Split Edge": "Dividir aresta",
"Splitting tool": "Eina de divisió",
"Square": "Quadrat",
"Stainless Steel": "Acer inoxidable",
@@ -937,20 +932,13 @@
"ai.http.confirm": "Permetre per a aquest servei",
"ai.http.warning": "La clau de l'API i la conversa s'enviaran sense xifrar. Qualsevol persona amb accés a la xarxa les pot llegir o canviar. Feu-ho servir només en una xarxa de confiança.",
"ai.prefs.allow_control": "Permetre que es connectin agents d'IA",
"ai.prefs.allow_control_note": "Un canal local (MCP). No surt res d'aquest ordinador.\n\nObre un canal de control només en aquest equip (127.0.0.1, al port de sota; 27878 pren el següent lliure quan està en ús). El programa que es connecti també ha de presentar un token secret que només pot llegir el vostre compte d'usuari, generat de nou a cada arrencada. S'aplica a l'instant. Desactivat: no s'escolta res tret que inicieu SolidCraft amb --control.",
"ai.prefs.allow_control_note": "Un canal local (MCP). No surt res d'aquest ordinador.\n\nObre un canal de control només en aquest equip (127.0.0.1, port 27878 o el següent lliure). El programa que es connecti també ha de presentar un token secret que només pot llegir el vostre compte d'usuari, generat de nou a cada arrencada. S'aplica la propera vegada que s'iniciï SolidCraft. Desactivat: no s'escolta res tret que inicieu SolidCraft amb --control.",
"ai.prefs.availability": "Disponibilitat",
"ai.prefs.background_commands": "Executar les ordres de l'agent en segon pla",
"ai.prefs.connect_apps": "Posar SolidCraft a disposició de les aplicacions d'IA d'aquest equip",
"ai.prefs.connect_apps_note": "Desactivat: treu les entrades que havia afegit.\n\nEn iniciar SolidCraft, l'afegeix com a servidor MCP a les aplicacions d'IA que trobi en aquest equip (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), perquè un agent el pugui fer servir sense configurar res. Només s'afegeix l'entrada solidcraft o se n'actualitza el camí; una entrada que hàgiu canviat es deixa intacta, i el fitxer es copia abans. Desactivat: s'eliminen les entrades que havia afegit. Ajuda › Connectar assistents d'IA… ho fa a mà.",
"ai.prefs.connect_at_start": "Connectar el darrer agent en iniciar",
"ai.prefs.connect_at_start_note": "Inicia en segon pla l'agent que vau connectar per última vegada (o el predeterminat) cada vegada que s'inicia SolidCraft, perquè estigui a punt per xatejar. No quan SolidCraft s'inicia amb --control. Si no es pot connectar, l'Assistent mostra Connexió fallida i un botó Reintentar.",
"ai.prefs.control_failed": "No s'ha pogut iniciar el canal de control: {error}",
"ai.prefs.control_from_launch": "Iniciat amb --control al port {port} per a aquesta execució. Els canvis d'aquí s'apliquen la propera vegada que SolidCraft s'iniciï sense --control.",
"ai.prefs.control_listening": "Escoltant a 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "No està escoltant",
"ai.prefs.control_port": "Port",
"ai.prefs.control_port_note": "El port al qual es connecten els agents d'IA en aquest equip (127.0.0.1). 27878 és el predeterminat: quan està en ús, es pren el següent lliure. Qualsevol altre port s'usa exactament. Apunteu el vostre client MCP al mateix port, per exemple solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_port_taken": "El port {port} està en ús per un altre programa (o una altra finestra de SolidCraft). Trieu un altre port.",
"ai.prefs.follow_camera": "Girar la càmera cap als bocets",
"ai.prefs.follow_camera_off": "El seguiment automàtic de la càmera està desactivat.",
"ai.prefs.follow_camera_on": "La càmera gira cap als bocets que crea l'agent.",
@@ -1011,13 +999,6 @@
"browser.sketches_to_parts_not_on_face": "no en una cara de peça",
"browser.sketches_to_parts_part_missing": "la seva peça ha desaparegut",
"browser.sketches_to_parts_result": "S'han mogut {moved} bocets a la peça on són; {stayed} s'han quedat: {why}",
"browser.straighten_angle": "Direcció de l'aresta",
"browser.straighten_choose": "Trieu Horitzontal, Vertical o Angle per a l'aresta i premeu Redreçar.",
"browser.straighten_click_a": "Feu clic a la imatge en un extrem d'una aresta que hagi de quedar recta, o escriviu-ne les coordenades.",
"browser.straighten_click_b": "Feu clic a l'altre extrem de l'aresta, o escriviu-ne les coordenades.",
"browser.straighten_horizontal": "Horitzontal",
"browser.straighten_title": "Redreçar: {name}",
"browser.straighten_vertical": "Vertical",
"browser.units": "Unitats: {units}",
"cam.flat": "Cara plana",
"cam.follower": "Seguidor",
@@ -1047,8 +1028,6 @@
"cmd.appearance_pick_up": "Agafar l'aparença",
"cmd.appearance_replace": "Substituir l'aparença",
"cmd.assembly_bom": "Llista de materials",
"cmd.assembly_constraint_create": "Restringir",
"cmd.assembly_constraint_edit": "Editar la restricció",
"cmd.assembly_mass_properties": "Propietats de massa de l'ensamblatge",
"cmd.body.print_face": "Cara d'impressió",
"cmd.body_rename": "Canviar el nom del cos",
@@ -1067,7 +1046,6 @@
"cmd.canvas_delete": "Eliminar el llenç",
"cmd.canvas_edit": "Editar el llenç",
"cmd.canvas_insert": "Llenç",
"cmd.canvas_straighten": "Redreçar el llenç",
"cmd.canvas_to_sketch": "Llenç a bocet",
"cmd.component_activate": "Activar el component",
"cmd.component_break_link": "Trencar el vincle",
@@ -1109,10 +1087,7 @@
"cmd.config_rule": "Regla de configuració",
"cmd.config_rules": "Regles de configuració",
"cmd.config_table": "Mostrar la taula de configuracions",
"cmd.construct.axis": "Eix",
"cmd.construct.plane": "Pla",
"cmd.construct.plane.axis_point": "Pla per eix i punt",
"cmd.construct.point": "Punt",
"cmd.construct_axis_edge": "Eix per una aresta",
"cmd.construct_axis_normal_to_face": "Eix perpendicular a una cara",
"cmd.construct_axis_through_cylinder": "Eix per un cilindre, con o tor",
@@ -1175,7 +1150,6 @@
"cmd.file_save_mesh": "Desar com a malla",
"cmd.file_send_to_slicer": "Enviar al laminador",
"cmd.file_slicers": "Laminadors instal·lats",
"cmd.frame_end_cap": "Tapa d'extrem",
"cmd.frame_member": "Element estructural",
"cmd.frame_profile_insert": "Inserir un perfil",
"cmd.frame_profile_list": "Biblioteca de perfils",
@@ -1196,7 +1170,6 @@
"cmd.inspect_isocurve": "Anàlisi d'isocorbes",
"cmd.inspect_minimum_radius": "Anàlisi de radi mínim",
"cmd.inspect_parameter_sweep": "Escombrat de paràmetres",
"cmd.inspect_parameter_variants": "Comparar variants de paràmetres",
"cmd.inspect_silhouette": "Silueta",
"cmd.inspect_zebra": "Anàlisi de franges de zebra",
"cmd.joint_as_built": "Unió segons la posició actual",
@@ -1250,10 +1223,6 @@
"cmd.motion_trace": "Traça del moviment",
"cmd.motion_trace_edit": "Editar la traça del moviment",
"cmd.motion_trace_keep": "Conservar la traça del moviment",
"cmd.note_add": "Afegir una nota",
"cmd.note_delete": "Eliminar la nota",
"cmd.note_list": "Llistar les notes",
"cmd.note_update": "Editar la nota",
"cmd.occurrence_align": "Alinear la instància",
"cmd.occurrence_copy": "Enganxar (instància)",
"cmd.occurrence_drag": "Arrossegar la instància",
@@ -1379,7 +1348,6 @@
"cmd.render_start": "Renderitzar la imatge",
"cmd.render_status": "Progrés del renderitzat",
"cmd.select_clear": "Esborrar la selecció",
"cmd.selection_get": "Obtenir la selecció",
"cmd.settings_profile_apply": "Aplicar el perfil d'ajustos",
"cmd.settings_profile_delete": "Eliminar el perfil d'ajustos",
"cmd.settings_profile_export": "Exportar el perfil d'ajustos",
@@ -1543,7 +1511,6 @@
"cmd.timeline_suppress": "Suprimir l'operació",
"cmd.ui.assistant": "Alternar l'Assistent",
"cmd.ui.palette": "Cercar ordres",
"cmd.ui.toggle_visibility": "Mostrar/amagar la selecció",
"cmd.view.navigator": "Navegador de vistes personalitzat",
"cmd.view.render_browser": "Explorador de render",
"cmd.view.render_dock": "Moure l'acoblament del render",
@@ -1601,7 +1568,6 @@
"color.step_down": "Baixar un pas",
"color.step_up": "Pujar un pas",
"color.value": "Valor, la brillantor (percentatge)",
"component.error.ambiguous_name": "Diversos components es diuen `{name}`. Feu servir un ID numèric de component: {ids}.",
"config.column_filter_empty": "No hi ha columnes coincidents",
"config.rule_parameter_columns": "Només es llisten els paràmetres amb una columna a la taula. Afegiu-ne una amb Afegir una columna (+) per fer-la servir aquí.",
"config.rule_suppression_columns": "Només es llisten les operacions amb una columna Suprimir a la taula. Afegiu-ne una amb Afegir una columna (+) per controlar-la aquí.",
@@ -1642,7 +1608,6 @@
"diagnostic.reselect_reference": "{feature}: la referència `{reference}` ja no existeix tal com es va triar; torneu-la a seleccionar",
"dialog.bodies_count": "{count} cossos",
"dialog.clearance_invalid": "la folgança ha de ser una longitud de 0 o més",
"dialog.component_origin": "{placement} · {item}",
"dialog.compute": "Calcular",
"dialog.compute_again": "Calcular de nou",
"dialog.computing": "Calculant… {seconds} s",
@@ -1655,12 +1620,10 @@
"dialog.dimension_step_expression": "Només s'avança amb un nombre; una expressió es queda tal com s'escriu",
"dialog.dimension_step_up": "Pujar {step}",
"dialog.edit_title": "EDITAR {title}",
"dialog.hole_held_off_its_line": "L'aresta de referència manté aquest forat sobre una línia que surt de la cara abans del punt triat. Trieu on la línia travessa la cara, o traieu l'aresta per moure el forat lliurement.",
"dialog.no_shown_body": "No hi ha cap cos mostrat per canviar: mostreu-ne un o trieu Cos nou",
"dialog.pattern_sketch_not_object": "Trieu cossos o operacions: un bocet no és cap de les dues coses. Repetiu les seves corbes amb el Patró o la Simetria del propi bocet, o extruïu-lo primer.",
"dialog.primitive_point_tip": "Un punt que omple X, Y i Z: feu clic aquí i després feu clic en un vèrtex, una aresta o una cara del model, un punt d'un bocet o l'origen.",
"dialog.reference_edge_tip": "Una aresta recta de la cara triada per mesurar-hi la distància de sota: feu clic aquí i després feu clic a l'aresta.",
"dialog.right_click_pick": "Seleccioneu {input} primer",
"dialog.select": "↖ Seleccionar",
"dialog.selected_count": "↖ {count} seleccionats",
"dialog.selection_constraint": "Restricció {id}",
@@ -1674,12 +1637,9 @@
"document.changed_on_disk": "{name} ha canviat al disc",
"document.changed_on_disk_detail": "Un altre programa o un altre equip ha modificat {name} des que el vau obrir o desar per última vegada. Sobreescriure substitueix aquests canvis pels vostres; Desar una còpia conserva tots dos.",
"document.copy_name": "Còpia de {name}",
"document.discard_row": "Descartar",
"document.finish_save_prompt": "Acabeu de respondre l'avís de desar abans d'obrir un altre disseny.",
"document.newer_file": "Desat amb una versió més recent de SolidCraft (format {format}; aquesta versió llegeix fins a {reads}). Si falta alguna cosa, actualitzeu.",
"document.newer_file_change": "Desat amb una versió més recent de SolidCraft (format {format} amb `{change}`, que aquesta versió no coneix). Si falta alguna cosa, actualitzeu.",
"document.newer_file_not_read": "No llegit: {what}. En desar es conserva.",
"document.not_saved_yet": "Encara sense desar",
"document.open_anyway": "Obrir de tota manera",
"document.open_elsewhere": "{name} ja està obert",
"document.open_elsewhere_detail": "{name} està obert a SolidCraft {app} a {who}, obert {when}. Si deseu tots dos, un de vosaltres perdrà la feina. Obriu-lo en només lectura per veure'l sense sobreescriure'l.",
@@ -1689,9 +1649,6 @@
"document.read_only_save": "Aquest disseny s'ha obert en només lectura: deseu-lo com a fitxer nou",
"document.save_changes": "Voleu desar els canvis de {name}?",
"document.save_copy": "Desar una còpia…",
"document.save_many": "Voleu desar els canvis de {n} dissenys?",
"document.save_row": "Desar",
"document.save_selected": "Desar els seleccionats",
"document.tab_unsaved_changes": "Canvis sense desar",
"document.unsaved_changes": "{name} té canvis sense desar. Si no els deseu, es perdran.",
"drive.rigid": "{joint} és una unió rígida: manté les seves peces quietes, així que no té res a accionar. Per deixar-les moure, canvieu-ne el tipus a Editar la unió: Revolució per girar, Lliscador per lliscar, Cilíndrica per totes dues.",
@@ -1711,8 +1668,6 @@
"dxf.tolerance": "Tolerància de corba",
"dxf.tolerance_help": "Només cossos visibles i bocets mostrats. Les corbes fan servir segments rectes. Una tolerància més petita dóna corbes més fines i consumeix més temps i memòria.",
"each rail's curves must be in one sketch": "les corbes de cada guia han de ser en un sol bocet",
"error.end_cap_faces": "Trieu almenys una cara d'extrem oberta d'un element estructural",
"error.end_cap_unresolved": "L'extrem {ends} d'aquesta tapa ja no és al model. Torneu a triar les cares d'extrem",
"feature computation ended without a result": "el càlcul de l'operació s'ha acabat sense resultat",
"file.open.drawing_filter": "Dissenys, fitxers CAD, malles i dibuixos",
"file.open.portal_fallback.no_portal_service": "El selector de fitxers que s'ha obert no és el propi de l'escriptori: el servei del portal de l'escriptori no està disponible.",
@@ -1724,7 +1679,6 @@
"file_load.inserting": "Inserint la geometria",
"file_load.link_item": "{name} ({done} de {total})",
"file_load.opened": "S'ha obert {name}",
"file_load.opened_behind": "{name} s'ha obert en una pestanya nova",
"file_load.opened_in_tab": "S'ha obert {name} en una pestanya nova",
"file_load.opening": "Obrint el disseny",
"file_load.preparing_thumbnail": "Preparant la miniatura…",
@@ -1772,13 +1726,11 @@
"help.config.table": "Mostra la taula de configuració: una fila per variant, una columna per paràmetre o operació, i si el disseny encara coincideix amb la fila activa. Feu-ho servir per revisar les variants d'un disseny.",
"help.connect_note": "Reinicieu cada assistent perquè carregui el servidor de SolidCraft. Cada fitxer s'ha desat com a .bak abans de modificar-lo.",
"help.connect_title": "Connectar assistents d'IA",
"help.construct.axis": "Crea un eix de construcció amb el mètode que demanin les vostres seleccions: al llarg d'una aresta o línia, per un forat o una cara rodona, on es troben dos plans, per dos punts, o perpendicular a una cara per un punt. Feu-lo servir quan conegueu les referències i no el mètode.",
"help.construct.axis.edge": "Crea un eix de construcció al llarg d'una aresta recta o una línia de bocet. Feu-lo servir com a eix per a una revolució, un patró circular o una unió.",
"help.construct.axis.normal_to_face": "Crea un eix de construcció per un punt, perpendicular a una cara. Feu-lo servir com a eix que surt recte d'una superfície.",
"help.construct.axis.through_cylinder": "Crea un eix de construcció pel centre d'una cara de cilindre, con o tor, o una aresta circular. Feu-lo servir per girar o fer un patró d'operacions al voltant d'una peça rodona.",
"help.construct.axis.two_planes": "Crea un eix de construcció on es troben dos plans o cares planes. Feu-lo servir quan l'eix que necessiteu és la línia entre dues cares.",
"help.construct.axis.two_points": "Crea un eix de construcció per dos punts, vèrtexs o punts de bocet. Feu-lo servir per a un eix que va entre dues posicions conegudes.",
"help.construct.plane": "Crea un pla de construcció amb el mètode que demanin les vostres seleccions: desplaçat respecte d'un pla, per un punt, entre dos plans, per línies o punts, tangent a una cara corba, o perpendicular a una trajectòria. Feu-lo servir quan conegueu les referències i no el mètode.",
"help.construct.plane.along_path": "Crea un pla de construcció perpendicular a una aresta o corba, en una posició al llarg seu. Feu-lo servir per bocetar un perfil per a un escombrat o una secció d'una trajectòria.",
"help.construct.plane.at_angle": "Crea un pla de construcció girat un angle des d'un pla o cara, al voltant d'un eix o aresta. Activeu «Fer servir només la direcció de l'eix» per projectar l'eix sobre la base i pivotar al voltant d'un punt d'aquest pla (l'origen de la base quan no se'n tria cap). Feu-lo servir per a bocets inclinats, com un suport angular.",
"help.construct.plane.axis_point": "Crea un pla de construcció que conté un eix i un punt fora d'aquest eix. Trieu un origen o eix de construcció, una aresta recta o línia de bocet, i després un vèrtex, punt de bocet o punt de construcció. Un punt a l'eix no pot definir un pla únic.",
@@ -1788,7 +1740,6 @@
"help.construct.plane.tangent": "Crea un pla de construcció que toca un cilindre, con, esfera o tor en un punt. Feu-lo servir per bocetar al costat d'una peça rodona.",
"help.construct.plane.three_points": "Crea un pla de construcció per tres punts. Feu-lo servir per a un pla definit per posicions del model, com tres cantonades.",
"help.construct.plane.two_edges": "Crea un pla de construcció per dues arestes rectes o línies que són en un mateix pla. Feu-lo servir per a un pla a través d'una peça, com per dues arestes oposades.",
"help.construct.point": "Crea un punt de construcció amb el mètode que demanin les vostres seleccions: en un vèrtex, al centre d'un cercle o una esfera, al llarg d'una aresta, on es troben dues línies o tres plans, o on una línia travessa un pla. Feu-lo servir quan conegueu les referències i no el mètode.",
"help.construct.point.along_path": "Crea un punt de construcció en una posició al llarg d'una aresta o corba. Feu-lo servir com a referència a mig camí d'una trajectòria.",
"help.construct.point.center": "Crea un punt de construcció al centre d'una aresta circular, cercle o arc de bocet, esfera o tor. Feu-lo servir com a referència per a forats, eixos i unions.",
"help.construct.point.edge_plane": "Crea un punt de construcció on una aresta o línia travessa un pla o cara plana. Feu-lo servir per marcar on una trajectòria creua un pla.",
@@ -1814,7 +1765,6 @@
"help.file.send_to_slicer": "Desa els cossos seleccionats com a fitxer 3MF i obre'l al laminador que trieu. Feu-ho servir per passar del model a la impressió en un sol pas.",
"help.font_details": "La interfície fa servir les fonts integrades d'egui (Ubuntu Light i Hack, sota la Ubuntu Font Licence i la llicència MIT/Bitstream Vera; Noto Emoji i la font d'icones emoji, sota l'OFL i MIT). El text dels bocets fa servir Liberation Sans (SIL OFL 1.1). El paquet opcional de fonts de The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), no s'inclou a les compilacions de SolidCraft.",
"help.fonts": "Fonts",
"help.frame.end_cap": "Tanca els extrems oberts dels elements estructurals amb plaques amb la forma del contorn del perfil. Feu-lo servir per segellar tubs, angulars i bigues, a sobre de l'extrem o encastat-hi.",
"help.frame.member": "Col·loca un perfil, com un tub o una extrusió d'alumini, al llarg de les línies d'un bocet d'estructura, un element per línia. Feu-ho servir per construir bastiments, prestatges i estructures soldades.",
"help.inspect.accessibility": "Coloreja les cares que una eina pot assolir des d'una direcció i les que no. Feu-ho servir per comprovar una peça per a mecanitzat o motlle des d'aquell costat.",
"help.inspect.center_of_mass": "Mostra el centre de masses dels cossos, cadascun per separat i tots junts, amb densitat uniforme. Feu-ho servir per comprovar l'equilibri i on bolcarà una peça.",
@@ -1941,13 +1891,13 @@
"help.sketch.construction_line": "Crea una línia de construcció infinita que passa per dos punts. Feu-la servir per estructurar un bocet sense partir perfils.",
"help.sketch.construction_line.angle": "Crea una línia de construcció infinita amb un angle respecte de l'eix X del bocet. Trieu el punt i escriviu l'angle, o trieu-ne la direcció.",
"help.sketch.construction_line.bisector": "Trieu dues línies rectes del bocet que es tallin i després una de les dues bisectrius de l'angle. El resultat segueix les seves línies d'origen.",
"help.sketch.construction_line.horizontal": "Crea una línia de construcció horitzontal infinita que passi per un punt, o escriviu-ne la distància a l'eix X del bocet. Una distància escrita es converteix en una cota controladora. La seva restricció horitzontal segueix les edicions posteriors.",
"help.sketch.construction_line.parallel": "Trieu una línia recta de bocet i després col·loqueu una línia de construcció paral·lela infinita o escriviu-ne el desplaçament. Un desplaçament escrit es converteix en una distància controladora. Feu clic amb el botó dret per triar una altra referència.",
"help.sketch.construction_line.horizontal": "Crea una línia de construcció horitzontal infinita que passa per un punt. La seva restricció horitzontal segueix les edicions posteriors.",
"help.sketch.construction_line.parallel": "Trieu una línia recta del bocet i col·loqueu una línia de construcció paral·lela infinita. Feu clic amb el botó dret per triar una altra referència.",
"help.sketch.construction_line.perpendicular": "Trieu una línia recta del bocet i col·loqueu una línia de construcció perpendicular infinita. Feu clic amb el botó dret per triar una altra referència.",
"help.sketch.construction_line.tangent_at": "Crea una tangent en un punt d'un cercle o arc circular, o en un extrem d'una spline o cònica oberta. Trieu la corba i després el punt de contacte.",
"help.sketch.construction_line.tangent_external": "Trieu un cercle o arc circular, després un punt exterior i una solució de tangent. Les previsualitzacions només mostren contactes a l'arc dibuixat.",
"help.sketch.construction_line.tangent_two": "Trieu dos cercles o arcs circulars i després una tangent comuna a la previsualització. Els contactes han de ser a les corbes dibuixades.",
"help.sketch.construction_line.vertical": "Crea una línia de construcció vertical infinita que passi per un punt, o escriviu-ne la distància a l'eix Y del bocet. Una distància escrita es converteix en una cota controladora. La seva restricció vertical segueix les edicions posteriors.",
"help.sketch.construction_line.vertical": "Crea una línia de construcció vertical infinita que passa per un punt. La seva restricció vertical segueix les edicions posteriors.",
"help.sketch.copy": "Copia les corbes i punts de bocet seleccionats, juntament amb les restriccions i cotes que hi ha entre ells. Enganxeu-los en aquest bocet o en un altre.",
"help.sketch.create": "Inicia un bocet 2D nou en un pla o en una cara plana del model. Cada extrusió, revolució i escombrat comença amb un bocet.",
"help.sketch.cut": "Copia la geometria de bocet seleccionada i l'esborra del bocet, en un sol pas de desfer. Enganxeu-la en un altre lloc d'aquest bocet o en un altre.",
@@ -2291,9 +2241,6 @@
"measure.at": "A",
"measure.item": "Element {number}",
"measure.line_to_centre": "Línia al centre",
"measure.perimeter": "Perímetre",
"measure.perimeter_inner": "Perímetre interior",
"measure.perimeter_outer": "Perímetre exterior",
"measure.quick_copied": "Mesura copiada",
"measure.quick_copy": "Feu clic per copiar la mesura",
"measure.quick_edges": "{count} arestes",
@@ -2335,8 +2282,6 @@
"nav.hfss.help": "Alt+arrossegar esquerre desplaça, Majúscules+Alt+arrossegar esquerre fa zoom, Ctrl+arrossegar esquerre en un espai buit orbita",
"nav.inventor.help": "Arrossegar amb el botó central desplaça, Majúscules+central orbita, Ctrl+central fa zoom i arrossegar amb el dret orbita",
"nav.maya.help": "Alt+arrossegar esquerre orbita, Alt+arrossegar central desplaça, Alt+arrossegar dret fa zoom; arrossegar central també desplaça",
"nav.maya_horizontal_zoom.help": "Alt+arrossegar amb el botó dret: cap a la dreta apropa, cap a l'esquerra allunya. Desactiveu-ho per tornar al zoom arrossegant en vertical.",
"nav.maya_horizontal_zoom.label": "Maya: zoom arrossegant en horitzontal",
"nav.nx.help": "Arrossegar amb el botó central orbita, Majúscules+central desplaça (central+dret de NX), Ctrl+central fa zoom (central+esquerre de NX); arrossegar amb el dret deixa la càmera quieta",
"nav.openscad.help": "Arrossegar amb el botó esquerre en un espai buit orbita, Majúscules+esquerre selecciona amb un rectangle i arrossegar geometria del bocet l'edita; arrossegar amb el dret o el central desplaça, i Majúscules+dret o Majúscules+central fa zoom",
"nav.orbit_cursor_wrap.help": "Un arrossegament d'òrbita que arriba a la vora de la vista mou el cursor a la vora oposada i continua girant. Desactivat: l'arrossegament s'atura a la vora de la pantalla. No té efecte a Wayland ni al navegador.",
@@ -2467,16 +2412,6 @@
"not a move": "no és un moviment",
"not a number": "no és un nombre",
"open a sketch first": "obriu primer un bocet",
"operations.cancel": "Cancel·lar {name}",
"operations.cancelled": "{name} cancel·lat",
"operations.cancelling": "Cancel·lant…",
"operations.cannot_cancel": "{name} no es pot cancel·lar",
"operations.feature": "Operació",
"operations.feature_edit": "Editar l'operació",
"operations.meshing": "Carregant les peces",
"operations.queued": "A la cua",
"operations.stopped": "Cancel·lat",
"operations.waiting": "A punt",
"parameters.apply_live": "Aplicar cada canvi en sortir del camp",
"parameters.apply_live_tip": "Desactivat: els canvis esperen fins que premeu Aplicar o D'acord, i llavors el disseny es reconstrueix una sola vegada per a tots. Cancel·lar o Escape els descarta.",
"parameters.held": "{count} canvis esperant Aplicar",
@@ -2565,7 +2500,6 @@
"prefs.appearance.viewport_colours": "Colors de la vista 3D",
"prefs.appearance.viewport_logo": "Visor i logotip",
"prefs.applies_on_restart": "S'aplica en reiniciar",
"prefs.assemblies": "Ensamblatges",
"prefs.assistant": "Assistent",
"prefs.assistant_connecting": "Connectant",
"prefs.assistant_follow_help": "Els bocets Mirar cap a mouen la vista amb el punter.",
@@ -2618,10 +2552,6 @@
"prefs.choose_font": "Triar un fitxer de tipus de lletra…",
"prefs.click": "Clic",
"prefs.command_dialog_values": "Diàlegs d'ordres",
"prefs.constraint_glyph_size": "Mida dels glifs de restricció",
"prefs.constraint_glyph_size_note": "Com de grans es dibuixen els glifs de restricció i les seves lletres o icones en un bocet. El 100 % és la mida habitual; els glifs conserven aquesta mida en pantalla amb qualsevol zoom.",
"prefs.construct_menu_groups": "Agrupar les eines de Construir per tipus d'entitat",
"prefs.construct_menu_groups_help": "Mostra els encapçalaments Plans, Eixos i Punts. Desactivat, es manté l'ordre original del menú.",
"prefs.contact_shading": "Ombrejat de contacte suau",
"prefs.contact_shading_note": "Ombrejat suau on les peces s'acosten.",
"prefs.context_menu": "Menú contextual",
@@ -2653,12 +2583,10 @@
"prefs.design_tools.imported_files": "Fitxers importats",
"prefs.design_tools.note": "Nota",
"prefs.design_tools.off": "Desactivat",
"prefs.design_tools.placement_group": "Operacions col·locades",
"prefs.design_tools.placement_group": "Repòs, Resalt, Encaix",
"prefs.design_tools.rows": "Files",
"prefs.devices": "Ratolí 3D",
"prefs.dialog_buttons": "Botons de diàleg",
"prefs.dialog_first_letter_buttons": "Els botons de diàleg responen a la seva primera lletra",
"prefs.dialog_first_letter_buttons_help": "Amb un diàleg obert, escriure la primera lletra de l'etiqueta d'un botó el prem: O per a OK, C per a Cancel·la. Una lletra que encaixa en dos botons no fa res, i un camp de text amb el focus conserva la pulsació. Desactivat: escriure no afecta els botons, com abans.",
"prefs.dialogs": "Diàlegs",
"prefs.dialogs_edge": "A la vora de la vista",
"prefs.dialogs_left_column": "A la columna esquerra",
@@ -2672,23 +2600,12 @@
"prefs.dimension_values_window": "En una finestra",
"prefs.display": "Pantalla",
"prefs.edges": "Arestes i ombrejat",
"prefs.export": "Exportar",
"prefs.export_name": "Nom del fitxer d'exportació",
"prefs.export_name.body": "Nom del cos exportat (el primer quan n'hi ha diversos)",
"prefs.export_name.design": "Nom del disseny",
"prefs.export_name_hint": "p. ex., {design}-{body}",
"prefs.export_name_sample": "En exportar aquest disseny s'ofereix: {name}",
"prefs.export_name_tip": "El nom de fitxer que ofereixen Fitxer › Exportar i Desar com a malla. Se substitueixen aquests marcadors:\n{placeholders}\nUna propietat del disseny que no estigui definida s'omet. Deixeu el camp buit per oferir el nom del disseny o, en una malla, el del cos.",
"prefs.extend": "Extensions",
"prefs.extra_navigation": "Ctrl + arrossegar amb el botó esquerre sobre un espai buit orbita; manteniu l'Espai per desplaçar.",
"prefs.extrude_arrow_face_stops": "La fletxa d'extrusió s'atura a les cares",
"prefs.extrude_arrow_face_stops_note": "En arrossegar la fletxa de distància d'Extruir, s'atura a les cares planes paral·leles al perfil, com el costat oposat d'un bloc que s'està tallant, i ressegueix la cara. Desactivat: la fletxa només es mou a passos rodons.",
"prefs.extrude_view_isometric": "Girar la vista a isomètrica en obrir Extruir",
"prefs.extrude_view_return": "Tornar a la vista anterior en tancar Extruir",
"prefs.file_operations_wait": "Esperar que s'obrin els fitxers",
"prefs.file_operations_wait_note": "Mentre s'obre o s'insereix un fitxer, o es tornen a llegir els vincles, el disseny espera darrere una targeta amb el progrés del fitxer i Cancel·la. Desactivat: el fitxer apareix al panell d'operacions i podeu seguir treballant; una inserció s'aplica quan acabeu el que esteu fent, i un fitxer obert mentrestant va a una pestanya nova.",
"prefs.fillet_legacy_radius": "Usar el radi predeterminat anterior d'Arrodoniment de sòlid",
"prefs.fillet_legacy_radius.help": "Desactivat de manera predeterminada. Activat, un nou Arrodoniment de sòlid s'obre amb el número 2 llegit en les unitats del disseny («2 in» en un disseny en polzades), com abans; desactivat, s'obre amb el radi que porta l'eina, 2 mm, mostrat en les unitats del disseny («0.07874 in» en un disseny en polzades).",
"prefs.follows_os": "Segueix el sistema operatiu",
"prefs.font": "Tipus de lletra",
"prefs.font_fallback": "Els glifs que no té el tipus de lletra triat es prenen del tipus integrat, així cap etiqueta no mostra un requadre buit.",
@@ -2712,17 +2629,6 @@
"prefs.gesture_pan": "Desplaçar",
"prefs.gesture_zoom": "Zoom",
"prefs.graphics": "Gràfics",
"prefs.graphics_api": "API gràfica",
"prefs.graphics_api_auto": "Automàtica",
"prefs.graphics_api_help": "Trieu l'API gràfica que s'usa per obrir SolidCraft. Si el proper inici no pot obrir la finestra, es restaura la tria anterior.",
"prefs.graphics_api_override": "Una opció d'inici controla l'API gràfica. La tria desada s'aplicarà quan SolidCraft s'obri normalment.",
"prefs.graphics_api_restart_note": "S'aplica després de reiniciar. Els dissenys sense desar es gestionen abans de tancar.",
"prefs.graphics_api_restart_unavailable": "Reinicieu SolidCraft per aplicar l'API gràfica desada.",
"prefs.graphics_api_running": "Actual: {current}. Triada per al proper inici: {selected}.",
"prefs.graphics_api_save_failed": "No s'ha pogut desar la tria d'API gràfica: {error}",
"prefs.graphics_api_start_failed": "No s'ha pogut preparar l'API gràfica triada: {error}",
"prefs.graphics_api_start_reverted": "L'inici anterior amb {requested} no va obrir la finestra. SolidCraft ha restaurat {previous}.",
"prefs.graphics_api_unavailable": "La selecció de l'API gràfica està disponible a l'aplicació d'escriptori.",
"prefs.graphics_card": "Targeta gràfica",
"prefs.graphics_card_auto": "Automàtica",
"prefs.graphics_card_missing": "La targeta gràfica desada no està disponible; s'usa Automàtica.",
@@ -2766,8 +2672,6 @@
"prefs.history_sketches_in_order": "En ordre",
"prefs.history_sketches_note": "Amb Bocets a Sota la seva operació, l'historial de l'esquerra llista cada bocet un nivell endins, sota la primera operació construïda a partir d'ell, sempre que hi hagi prou amplada per als noms. La línia de temps de baix manté sempre l'ordre de creació.",
"prefs.history_sketches_under_feature": "Sota la seva operació",
"prefs.history_steps_component": "Passos de l'historial per component",
"prefs.history_steps_component_note": "Amb un component actiu o seleccionat, els controls de l'Historial recorren les operacions d'aquest component, tal com les mostra l'Historial de baix. Desactivat: recorren totes les operacions del disseny. L'Historial de l'esquerra o sota el navegador sempre recorre totes les operacions.",
"prefs.hold_stop_snapping": "Mantenir premut per aturar l'ajust",
"prefs.home_fit_size": "Mida d'Inici i Ajustar",
"prefs.home_fit_size_note": "Quant de la vista omple el model quan Inici, Ajustar, un disseny obert o un canvi de vista del navegador l'enquadren, tant si l'ajust de sota està activat com si no. 100 %: omple la vista entre els panells, la brúixola i la barra de navegació.",
@@ -2783,8 +2687,6 @@
"prefs.invert_curved_roll_arrows_note": "Les dues fletxes corbes a la cantonada del cub giren la vista dins del pla de la pantalla. Activades: la fletxa horària gira en sentit antihorari i l'antihorària gira en sentit horari. Desactivades: cada fletxa gira la vista cap on es corba.",
"prefs.invert_navigator_arrows": "Invertir les fletxes de rotació del cub de vista",
"prefs.invert_navigator_arrows_note": "Les fletxes al voltant del cub orbiten la càmera cap on apunten, com a Fusion i Inventor: la fletxa de l'esquerra apunta a la dreta i gira la càmera a la dreta. Desactivades: una fletxa gira el cub cap al costat on és, així la fletxa de l'esquerra gira la càmera a l'esquerra. Les fletxes de gir corbes no en queden afectades.",
"prefs.joint_drag_trackball": "Girar les peces d'una unió de ròtula com un trackball en arrossegar-les",
"prefs.joint_drag_trackball_help": "Arrossegar una peça que subjecta una unió de ròtula la gira amb el gir més petit possible al voltant de la unió, com si rodés una bola de la mida de la peça. Desactivat: el punt que heu agafat segueix exactament el punter, i la peça també pot girar sobre ell.",
"prefs.keep_unsaved_also_asks": "Continuar preguntant quan es tanca la pestanya",
"prefs.keep_unsaved_also_asks_tip": "Amb això desactivat, la pestanya es tanca sense la finestra del navegador un cop la còpia de cada disseny sense desar està al dia.",
"prefs.keep_unsaved_in_browser": "Conservar la feina sense desar en aquest navegador",
@@ -2860,8 +2762,6 @@
"prefs.not_on_gpu": "no en aquesta GPU",
"prefs.on": "Activat",
"prefs.opaque": "Opac",
"prefs.operations_panel": "Mostrar les operacions a la vista",
"prefs.operations_panel_note": "Les operacions llargues (obrir un fitxer, calcular una operació, una ordre de l'agent) es mostren a la cantonada inferior dreta de la vista amb el seu progrés i un botó per cancel·lar-les. Desactivat: un avís de «Calculant», com abans.",
"prefs.orbit_snap": "Manteniu el Majúscules mentre orbiteu per ajustar a un eix.",
"prefs.orientation": "Orientació",
"prefs.origin_plane_names": "Noms dels plans d'origen",
@@ -2942,8 +2842,6 @@
"prefs.ribbon_tabs_only": "Mostrar només les pestanyes de la cinta",
"prefs.ribbon_tabs_only_note": "La cinta es plega fins a la seva fila de pestanyes. En fer clic en una pestanya es mostren els seus panells sobre la vista fins que comença una ordre o el punter en surt. «Només les pestanyes de la cinta», a la pestanya Vista o amb la seva drecera, ho commuta.",
"prefs.right_click": "Clic dret",
"prefs.save_prompt_one_at_a_time": "Preguntar per cada disseny sense desar per separat en tancar-ne diversos",
"prefs.save_prompt_one_at_a_time_help": "Mostrar un avís de desar per cada disseny sense desar en lloc d'una llista combinada.",
"prefs.saving": "Inici i desament",
"prefs.scale_note": "També Ctrl + / Ctrl − a la finestra. Els botons fan 28 px al 100 %.",
"prefs.scale_text": "Escala i text",
@@ -2980,8 +2878,6 @@
"prefs.sketch.center_arc": "L'arc pel centre segueix el cursor",
"prefs.sketch.click_selects": "El clic selecciona",
"prefs.sketch.conflict_colour": "Color de conflicte",
"prefs.sketch.cursor_coordinates": "Mostrar les coordenades del cursor en un bocet",
"prefs.sketch.cursor_coordinates_note": "Al costat del punter, la seva X i Y des de l'origen del bocet, també sense cap eina en marxa. Desactivat: només mentre una eina col·loca un punt.",
"prefs.sketch.drawing": "Dibuix",
"prefs.sketch.ellipse_axes": "Dibuixar les el·lipses amb els seus eixos",
"prefs.sketch.ellipse_axes_note": "Els eixos major i menor com a línies de construcció, per poder acotar els semieixos i els eixos. Desactivat: només l'el·lipse, i D a sobre seu dóna el radi menor allà on es tiri el valor, dibuixat i etiquetat R com abans.",
@@ -2993,20 +2889,14 @@
"prefs.sketch.faces_edges": "Cares i arestes",
"prefs.sketch.hide_glyphs": "Amagar els glifs en allunyar-se",
"prefs.sketch.marks": "Marques al bocet",
"prefs.sketch.max_points": "Bocet més gran",
"prefs.sketch.max_points_note": "El màxim de punts o corbes que admet un bocet: 20000 de manera predeterminada, com a molt 200000. Inserir SVG i Inserir DXF refusen un dibuix més gran. Un bocet gran es resol i es dibuixa més lentament.",
"prefs.sketch.max_points_unit": "punts",
"prefs.sketch.note": "Dibuix, ajust i selecció.",
"prefs.sketch.opening_closing": "Obertura i tancament",
"prefs.sketch.prefer_free_points": "Preferir punts mòbils quan se superposen",
"prefs.sketch.project_options": "Demanar primer les opcions de Projectar",
"prefs.sketch.recent_snap": "Ajustar on es creuen dues guies",
"prefs.sketch.select_3d_curve_points": "Seleccionar punts de corbes de bocet 3D",
"prefs.sketch.select_3d_curve_points_help": "Mostra i permet seleccionar punts de les corbes de bocet 3D dibuixades com a referències de construcció.",
"prefs.sketch.select_other_hidden": "Seleccionar-ne un altre arriba a la geometria oculta",
"prefs.sketch.shortcut_cycles_modes": "La drecera canvia de mode en tornar-la a prémer",
"prefs.sketch.shortcut_cycles_modes_tip": "Desactivat: torna a iniciar l'eina.",
"prefs.sketch.snap_to_visible_grid": "Ajustar als encreuaments visibles de la quadrícula",
"prefs.sketch.snapping_guides": "Ajust i guies",
"prefs.sketch.state_icons": "Icones d'estat",
"prefs.sketch.tracking_guides": "Guies de centre i punt mitjà",
@@ -3034,7 +2924,6 @@
"prefs.sketch_selection": "Bocet i selecció",
"prefs.sketch_selection_heading": "Bocet i selecció",
"prefs.sketch_selection_note": "Com s'ajusten els bocets i com seleccionen els clics.",
"prefs.sketch_snap_to_visible_grid_note": "Un punt d'un bocet s'ajusta als encreuaments de les línies que dibuixa la quadrícula quan el punter n'apunta un, i si no, a la retícula més fina entre ells. Desactivat: el punt s'ajusta només a la retícula més fina, com abans.",
"prefs.sketch_state_icons": "Icones dels bocets amb el color del seu estat",
"prefs.sketch_state_icons_note": "A l'historial i a l'explorador, la icona d'un bocet pren el color del seu estat (insuficientment restringit, completament restringit o en conflicte) i la seva informació en diu l'estat. Desactivat: tots els bocets tenen la mateixa icona.",
"prefs.sketch_tab_always": "Mantenir visible la pestanya Bocet",
@@ -3556,12 +3445,12 @@
"render.cpu_subsurface_denoise_reason": "La dispersió subsuperficial de CPU necessita la reducció de soroll desactivada. Es conserven els vostres ajustos.",
"render.cpu_subsurface_help": "La dispersió redirigeix la llum dins del vidre tancat. Les taxes positives necessiten il·luminació indirecta amb la reducció de soroll desactivada. La taxa és de col·lisions per mm; 0 no afegeix dispersió. Un biaix de fase positiu afavoreix les direccions cap endavant; un de negatiu, les cap enrere.",
"render.cpu_summary": "Ray tracing de CPU · reflexos · HDR · llums direccionals i puntuals",
"render.cpu_threads": "Workers de CPU",
"render.cpu_threads": "Processos de CPU",
"render.cpu_threads_auto": "Automàtic",
"render.cpu_threads_custom": "Personalitzat",
"render.cpu_threads_help": "Automàtic fa servir els workers de CPU disponibles. Un worker s'executa en sèrie. Més workers poden necessitar més memòria.",
"render.cpu_threads_help": "Automàtic fa servir els processos de CPU disponibles. Un procés s'executa en sèrie. Més processos poden necessitar més memòria.",
"render.cpu_threads_single": "Un",
"render.cpu_threads_web": "El renderitzat al navegador fa servir un worker cooperatiu. Es conserva la tria de workers desada per a l'escriptori.",
"render.cpu_threads_web": "El renderitzat al navegador fa servir un procés cooperatiu. Es conserva l'elecció de processos desada per a l'escriptori.",
"render.cpu_transparent_help": "El PNG transparent treu el fons i conserva la cobertura de les vores. L'opacitat del cos i de la cara ha de mantenir-se al 100 %.",
"render.cpu_volume_enable_path": "Activar la il·luminació indirecta",
"render.cpu_volume_enable_path_plan": "Ray tracing de CPU · profunditat {depth} · {spp} mostres/píxel · llavor {seed} · ombres traçades. Conserva l'atmosfera definida i el pressupost d'imatge.",
@@ -3736,7 +3625,6 @@
"render.error_pbr_opacity": "el PBR opac requereix opacitat 1",
"render.error_physical_glass": "la recepta triada no és vidre físic",
"render.error_preview_device": "la vista prèvia del producte requereix un dispositiu instal·lat amb búfer de profunditat",
"render.error_product_placement": "col·locació de producte no vàlida",
"render.error_product_triangle": "triangle de producte no vàlid",
"render.error_provisional_identity": "identitat provisional de renderitzat o nombre de mostres no vàlida",
"render.error_quality_bounds": "la projecció de qualitat supera els límits natius",
@@ -3820,10 +3708,6 @@
"render.gpu_path_retry": "Torna-ho a provar",
"render.gpu_seed": "Llavor aleatòria",
"render.gpu_unavailable": "El path tracing de GPU encara no està a punt en aquest dispositiu.",
"render.graphics_api_restart_vulkan": "Reiniciar amb Vulkan",
"render.graphics_api_restart_warning": "Canviar l'API gràfica reinicia SolidCraft i us demana de desar els dissenys sense desar. Si la nova API no pot obrir la finestra, es restaura la tria anterior al proper inici.",
"render.graphics_api_settings": "Configuració gràfica",
"render.graphics_api_vulkan_required": "El path tracing a la GPU necessita Vulkan en aquesta sessió. Reinicieu amb Vulkan o seguiu amb un mètode d'imatge per CPU.",
"render.ground_apply": "Ajustar i desar el terra",
"render.ground_gap": "Espai lliure explícit",
"render.ground_linear_rgb": "Reflectància difusa · RGB lineal",
@@ -4825,8 +4709,6 @@
"sketch.hint.spline3d": "Spline 3D: feu clic als punts d'ajust en 3D; clic dret o Esc per acabar",
"sketch.hint.trim": "Retallar: feu clic al tros de corba que voleu treure, o arrossegueu pels trossos que voleu treure",
"sketch.make_selection": "Convertir la selecció en {linetype}",
"sketch.move_released": "Mogut: s'han deixat anar {count} restriccions que el lligaven al que es queda ({names}).",
"sketch.move_released_one": "Mogut: s'ha deixat anar {count} restricció que el lligava al que es queda ({names}).",
"sketch.open_ends": "Oberts {count}",
"sketch.status_conflict": "restriccions en conflicte",
"sketch.status_fully_constrained": "totalment restringit",
@@ -5061,7 +4943,6 @@
"ui.agent_chat_allow_once": "Permetre una vegada",
"ui.agent_chat_allowed": "Permès",
"ui.agent_chat_answered": "Resposta: {option}",
"ui.agent_chat_answered_in": "Respost en {time}",
"ui.agent_chat_approval": "Permetre que l'agent faci això?",
"ui.agent_chat_attach_selection": "Adjuntar la selecció",
"ui.agent_chat_attach_selection_tip": "Envia amb el missatge el que estigui seleccionat al model (cares, arestes, operacions, cossos) perquè l'agent sàpiga a què us referiu",
@@ -5090,7 +4971,6 @@
"ui.agent_chat_ctx_bodies": "Cossos",
"ui.agent_chat_ctx_elements": "Elements",
"ui.agent_chat_ctx_features": "Operacions",
"ui.agent_chat_ctx_gone": "Ja no és al disseny: s'ha canviat o eliminat.",
"ui.agent_chat_ctx_more": "+{n} més",
"ui.agent_chat_ctx_other": "Altres",
"ui.agent_chat_custom_heading": "Model personalitzat",
@@ -5104,7 +4984,6 @@
"ui.agent_chat_dock_right": "Acoblar a la dreta",
"ui.agent_chat_drop_hint": "Deixeu anar per adjuntar-ho al proper missatge",
"ui.agent_chat_edit": "Editar",
"ui.agent_chat_ended_after": "Acabat després de {time}",
"ui.agent_chat_exited": "L'agent s'ha aturat.",
"ui.agent_chat_failed_start": "No s'ha pogut iniciar l'agent: {error}",
"ui.agent_chat_file_cad_not_read": "{name}: un fitxer CAD s'importa al disseny, no es llegeix",
@@ -5142,7 +5021,6 @@
"ui.agent_chat_model_no_tools": "{model} no pot usar les eines de SolidCraft, així que només pot xatejar. Trieu un model amb suport d'eines.",
"ui.agent_chat_model_rejected": "L'agent no ha acceptat {model}: {error}",
"ui.agent_chat_model_search": "Cercar models…",
"ui.agent_chat_model_search_or_id": "Cerqueu o escriviu qualsevol ID de model…",
"ui.agent_chat_model_tip": "El model que usa l'agent. La vostra tria es recorda per a aquest agent.",
"ui.agent_chat_models_failed": "No s'han pogut llistar els models: {error}",
"ui.agent_chat_models_heading": "Models disponibles",
@@ -5154,7 +5032,6 @@
"ui.agent_chat_next_picture": "Imatge següent",
"ui.agent_chat_no_cli": "No s'ha trobat solidcraft-cli al costat de l'aplicació, així que l'agent no pot accedir al disseny.",
"ui.agent_chat_no_control": "El punt de connexió del xat de l'agent no s'ha iniciat, així que l'agent no pot accedir al disseny.",
"ui.agent_chat_no_design_hint": "L'agent s'executa sense les eines de SolidCraft, així que no pot llegir ni canviar el disseny: no s'ha trobat solidcraft-cli al costat de l'aplicació. Instal·leu SolidCraft amb solidcraft-cli al costat (a Windows, el MSI o el zip portàtil) o definiu SOLIDCRAFT_CLI amb la seva ruta, i torneu a connectar.",
"ui.agent_chat_no_images": "Aquest agent no admet imatges",
"ui.agent_chat_no_picture": "No hi ha cap imatge al porta-retalls",
"ui.agent_chat_no_tool_call": "El model no ha fet servir les eines de SolidCraft, així que el disseny no ha canviat. Trieu un model que admeti crides a eines; vegeu Models locals a la guia del xat de l'agent.",
@@ -5169,7 +5046,7 @@
"ui.agent_chat_permission": "L'agent demana executar: {title}",
"ui.agent_chat_pick": "Agent",
"ui.agent_chat_pick_context": "Triar context",
"ui.agent_chat_pick_context_tip": "Trieu cares, arestes, operacions i cossos del model i premeu OK: s'afegeixen al missatge com una fitxa on és el cursor (escriure @ fa el mateix)",
"ui.agent_chat_pick_context_tip": "Trieu cares, arestes, operacions i cossos del model perquè l'agent els miri i premeu D'acord",
"ui.agent_chat_placeholder": "Demaneu a l'agent que modifiqui el disseny…",
"ui.agent_chat_placeholder_busy": "Posa un missatge a la cua per quan l'agent acabi (Ctrl+Retorn: enviar ara)",
"ui.agent_chat_pop_out": "Obrir en una finestra pròpia, que podeu moure a una altra pantalla",
@@ -5201,26 +5078,16 @@
"ui.agent_chat_setup_title": "Configurar un assistent",
"ui.agent_chat_side": "Costat del xat de l'agent",
"ui.agent_chat_signed_in": "sessió iniciada",
"ui.agent_chat_snap_none_found": "No s'ha trobat cap agent de codi. El paquet Snap està confinat, així que no pot veure ni iniciar els agents de codi instal·lats en aquest equip. Per usar-los, instal·leu SolidCraft des de l'AppImage o el paquet .deb o .rpm. Un agent que usa una API compatible amb OpenAI també funciona al paquet Snap: configureu-ne un amb Afegir agent.",
"ui.agent_chat_span_h": "{h} h {min} min",
"ui.agent_chat_span_min": "{n} min",
"ui.agent_chat_span_s": "{n} s",
"ui.agent_chat_state_auth": "Cal iniciar sessió",
"ui.agent_chat_state_busy": "Treballant…",
"ui.agent_chat_state_exited": "Agent aturat",
"ui.agent_chat_state_failed": "Connexió fallida",
"ui.agent_chat_state_no_design": "Sense accés al disseny",
"ui.agent_chat_state_off": "Sense connexió",
"ui.agent_chat_state_ready": "A punt",
"ui.agent_chat_state_starting": "Iniciant…",
"ui.agent_chat_stop": "Aturar",
"ui.agent_chat_stop_tip": "Aturar l'agent i tot el que hagi iniciat",
"ui.agent_chat_stop_turn_tip": "Atura l'agent (Esc). Els missatges a la cua es queden a la cua.",
"ui.agent_chat_thinking_for": "Pensant durant {time}",
"ui.agent_chat_tool_running_for": "en execució durant {time}",
"ui.agent_chat_tools_not_started": "L'agent s'executa sense les eines de SolidCraft, així que no pot llegir ni canviar el disseny: s'ha trobat solidcraft-cli, però les seves eines no s'han iniciat (l'error del xat explica per què). Torneu a connectar; si continua passant, reinstal·leu SolidCraft.",
"ui.agent_chat_transcript_assistant": "Assistent:",
"ui.agent_chat_transcript_user": "Usuari:",
"ui.agent_chat_turn_limit": "Canvis per missatge",
"ui.agent_chat_turn_unlimited": "Sense límit",
"ui.agent_chat_undo_changes": "Desfer aquests canvis",
@@ -5377,9 +5244,7 @@
"ui.browser_bodies_one": "{count} cos",
"ui.browser_bodies_other": "{count} cossos",
"ui.browser_collapse": "Contraure l'explorador",
"ui.browser_collapse_below": "Contraure a sota",
"ui.browser_expand": "Expandir l'explorador",
"ui.browser_expand_below": "Expandir a sota",
"ui.browser_title": "Explorador",
"ui.calibrate": "Calibrar",
"ui.cancel": "Cancel·la",
@@ -5409,7 +5274,6 @@
"ui.collapse": "Contraure",
"ui.collinear": "Colineal",
"ui.colour_vision": "Visió del color",
"ui.colour_vision_bends": "Plecs",
"ui.colour_vision_deutan": "Deuteranòpsia",
"ui.colour_vision_deutan_tip": "Per a una percepció del verd feble o absent",
"ui.colour_vision_failed": "Fallit",
@@ -5439,24 +5303,14 @@
"ui.conic_curve": "Corba cònica",
"ui.constant": "Constant",
"ui.constraints_conflict": "Conflicte de restriccions",
"ui.construct_auto.chosen": "Triat a partir de les vostres seleccions. Trieu una altra referència, o traieu-ne una, per canviar el mètode.",
"ui.construct_auto.no_fit": "Trieu altres referències: cap mètode es pot construir amb aquestes.",
"ui.construct_auto.pick": "Trieu amb què construir-lo: el mètode es dedueix de les vostres seleccions.",
"ui.construct_auto.refs": "Referències",
"ui.construct_auto.select": "Seleccioneu primer les referències amb què construir",
"ui.construct_axes": "Eixos",
"ui.construct_planes": "Plans",
"ui.construct_points": "Punts",
"ui.construction": "Construcció",
"ui.construction.angle_hint": "Línia de construcció: feu clic en un punt i escriviu un angle o trieu una direcció",
"ui.construction.axis_hint": "Línia de construcció: feu clic en un punt o escriviu la seva distància a l'eix del bocet",
"ui.construction.bisector_hint": "Bisectriu: trieu dues línies rectes que es tallin i després la solució volguda",
"ui.construction.common_hint": "Tangent comuna: trieu dos cercles o arcs i després la solució volguda",
"ui.construction.contact_hint": "Tangent: trieu un cercle, arc o spline o cònica oberta i després el punt de contacte (corbes lliures: extrems)",
"ui.construction.external_hint": "Tangent: trieu un cercle o arc, un punt exterior i després la solució volguda",
"ui.construction.free_hint": "Línia de construcció: feu clic en dos punts; feu clic amb el botó dret per reiniciar",
"ui.construction.normal_needs_length": "Una línia infinita és sempre de construcció: escriviu una longitud per dibuixar una línia normal",
"ui.construction.parallel_hint": "Línia de construcció: trieu una línia recta i després feu clic o escriviu el desplaçament; feu clic amb el botó dret per canviar la referència",
"ui.construction.point_hint": "Línia de construcció: feu clic en el punt per on ha de passar",
"ui.construction.reference_hint": "Línia de construcció: trieu una línia recta i després un punt; feu clic amb el botó dret per canviar la referència",
"ui.construction.ribbon_label": "Línies X",
@@ -5599,17 +5453,6 @@
"ui.ellipse": "El·lipse",
"ui.emboss_wrap_note": "Els perfils de línies ortogonals tangents a un cilindre compatible s'embolcallen automàticament. Trieu una paret per triar un destí.",
"ui.end": "Fi",
"ui.end_cap.chamfer": "Xamfrà de cantonada",
"ui.end_cap.direction": "Direcció",
"ui.end_cap.faces": "Cares d'extrem",
"ui.end_cap.inset": "Reducció",
"ui.end_cap.inset_kind": "Reduir per",
"ui.end_cap.inset_ratio": "Proporció de reducció",
"ui.end_cap.inward": "Cap endins",
"ui.end_cap.length": "Longitud",
"ui.end_cap.outward": "Cap enfora",
"ui.end_cap.ratio": "Proporció de gruix",
"ui.end_cap_title": "TAPA D'EXTREM",
"ui.end_radius": "Radi final",
"ui.end_scale": "Escala final",
"ui.ends": "Extrems",
@@ -5630,10 +5473,7 @@
"ui.explode_needs_two_components": "La vista explosionada separa components, així que el disseny en necessita dos o més (Component nou, Inserir un disseny o Crear components a partir de cossos)",
"ui.exportDxf": "Exportar DXF…",
"ui.export_ellipsis_keymap": "Exportar…",
"ui.export_glb": "Exportar GLB",
"ui.export_keymap_tip": "Les tecles, els favorits de la cerca S i la barra d'eines, com a fitxer",
"ui.export_selects_only_body": "Seleccionar l'únic cos en exportar una malla",
"ui.export_selects_only_body_tip": "Exportar STL, Exportar 3MF i Enviar al laminador comencen amb el cos del disseny seleccionat quan només n'hi ha un.",
"ui.extend": "Estendre",
"ui.extent": "Extensió",
"ui.extent_type": "Tipus d'extensió",
@@ -5693,8 +5533,6 @@
"ui.finest": "Molt fina",
"ui.finish_sketch": "Acabar el bocet",
"ui.first_dimension_scales": "La primera cota escala el dibuix",
"ui.first_dimension_scales_about_origin": "Escalar respecte de l'origen del bocet",
"ui.first_dimension_scales_about_origin_tip": "Activat: el dibuix sempre creix o s'encongeix respecte de l'origen del bocet, així que una forma dibuixada lluny de l'origen es mou. Desactivat: un dibuix que res no lliga a l'origen s'escala respecte del seu propi centre i es queda on es va dibuixar.",
"ui.first_dimension_scales_tip": "Activat: la primera cota de longitud d'un bocet escala el dibuix que mesura, de manera que la seva forma no canvia. Desactivat: la cota només acota allò que mesura.",
"ui.fit": "Ajustar",
"ui.fix_unfix": "Fixar/alliberar",
@@ -6070,7 +5908,7 @@
"ui.pitch": "Inclinació",
"ui.placement": "Col·locació",
"ui.placement_handles": "Tiradors de col·locació",
"ui.placement_handles_tip": "Un marcador per arrossegar l'operació pel model i, on l'operació es pot girar, un anell per girar-la.",
"ui.placement_handles_tip": "Un marcador per arrossegar l'operació pel model i un anell per girar-la.",
"ui.plane": "Pla",
"ui.plane_at_angle": "Pla amb angle",
"ui.plugins": "Complements",
@@ -6117,7 +5955,6 @@
"ui.project_references_construction_note": "Projectar, Intersecar, Incloure geometria 3D i les arestes de cara d'un bocet nou sobre una cara creen geometria de construcció: referències sobre les quals dibuixar, excloses dels perfils. Continuen vinculades al model.",
"ui.properties": "Propietats",
"ui.properties.copy_matrix": "Copiar la matriu",
"ui.properties.copy_value": "Feu clic per copiar",
"ui.properties.first_moments": "Primers moments",
"ui.properties.inertia": "Moment d'inèrcia",
"ui.properties.inertia_note": "Respecte al centre de masses, en eixos globals (g·{unit}²)",
@@ -6130,7 +5967,6 @@
"ui.properties.measuring_failed": "El mesurament s'ha aturat abans d'acabar.",
"ui.properties.principal_axis": "Eix principal {number}",
"ui.properties.principal_moments": "Moments principals",
"ui.properties.value_copied": "Valor copiat",
"ui.pull_direction": "Direcció d'estirada",
"ui.quality": "Qualitat",
"ui.quantity": "Quantitat",
@@ -6151,14 +5987,11 @@
"ui.relink_design_changed": "L'ensamblatge ha canviat mentre trieu un origen. Torneu a iniciar Revincular l'origen.",
"ui.relink_done": "Origen revinculat a totes les instàncies.",
"ui.relink_not_linked": "Aquest component no està vinculat a cap disseny d'origen.",
"ui.relink_one_done": "Origen revinculat en aquesta instància.",
"ui.relink_select_one": "Seleccioneu primer un component vinculat o una de les seves instàncies.",
"ui.relink_source_all": "Revincular l'origen (totes les instàncies)…",
"ui.relink_source_one": "Revincular l'origen…",
"ui.removeFromQuickAccess": "Treure de l'accés ràpid",
"ui.removeFromToolbar": "Treure de la barra d'eines",
"ui.removeSection": "Treure la secció",
"ui.renameSelectedBodies": "Canviar el nom dels cossos seleccionats",
"ui.render": "Renderitzar",
"ui.renderer": "Motor de renderitzat",
"ui.repeat": "Repetir",
@@ -6189,8 +6022,6 @@
"ui.ribbon_pin_footer": "Les ordres fixades es mostren a la cinta. Passeu el punter per una fila per fixar-la o deixar-la de fixar.",
"ui.ribbon_workspace": "Espai de treball",
"ui.right": "Dreta",
"ui.right_click_confirms": "El botó dret confirma les entrades dels diàlegs",
"ui.right_click_confirms_tip": "Amb un diàleg obert, un clic amb el botó dret a la vista passa a la seva propera entrada buida, i a l'última executa OK",
"ui.save": "Desar",
"ui.save_as": "Desar com a",
"ui.save_mesh_no_slicer_open": "No s'ha trobat cap laminador: trieu Personalitzat… i trieu el vostre programa de laminatge.",
@@ -6250,16 +6081,6 @@
"ui.sketch_intersect_selection_filter": "Filtre de selecció",
"ui.sketch_keys_only": "Mantenir les tecles del model fora dels bocets",
"ui.sketch_keys_only_tip": "Mentre editeu un bocet, només s'executen les tecles de bocet i les que funcionen a tot arreu; una tecla de model com E o F no fa res, així una tecla perduda no pot tancar un bocet pesat.",
"ui.sketch_links_added_curve": "{command} ha afegit 1 corba al bocet.",
"ui.sketch_links_added_curves": "{command} ha afegit {count} corbes al bocet.",
"ui.sketch_links_added_curves_and_points": "{command} ha afegit {curves} i {points} al bocet.",
"ui.sketch_links_added_point": "{command} ha afegit 1 punt al bocet.",
"ui.sketch_links_added_points": "{command} ha afegit {count} punts al bocet.",
"ui.sketch_links_curve_count": "{count} corbes",
"ui.sketch_links_curve_count_one": "1 corba",
"ui.sketch_links_hidden": "El resultat s'amaga perquè {option} està desactivat a la paleta del bocet.",
"ui.sketch_links_point_count": "{count} punts",
"ui.sketch_links_point_count_one": "1 punt",
"ui.sketch_modifiers": "Modificadors del bocet",
"ui.sketch_project_geometry": "Geometria",
"ui.sketch_report.all_constrained": "Tot està totalment restringit.",
@@ -6299,7 +6120,6 @@
"ui.step_values": "Incrementar valors amb les fletxes i la roda",
"ui.step_values_tip": "En un camp de valor on esteu escrivint, Amunt/Avall canvien un nombre simple d'1 en 1 (Majúscules: 10, Alt: 0,1), i la roda del ratolí sobre el camp fa el mateix. Les expressions es deixen tal com s'escriuen.",
"ui.stitch_tolerance_hint": "estricta",
"ui.straighten": "Redreçar",
"ui.stretch": "Estirar",
"ui.supersampling": "Supermostreig (SSAA)",
"ui.supersampling_adaptive": "Només quan la vista està quieta",
@@ -6373,8 +6193,6 @@
"ui.view_angles_yaw": "Rumb",
"ui.view_angles_zero": "Posa {axis} a 0°",
"ui.view_centre": "Centre de la vista",
"ui.view_compass_tip": "Vista des de {axis}. Torneu-hi a fer clic per invertir-la. Arrossegueu per orbitar.",
"ui.view_compass_tip_snap": "Vista des de {axis}. Torneu-hi a fer clic per invertir-la. Arrossegueu per orbitar; manteniu Majúscules per ajustar.",
"ui.viewport_status_clear": "Desactivar {name}",
"ui.viewport_status_clear_hint": "Desactivar {name} · Esc",
"ui.viewport_status_edges": "{count} arestes",
+2 -2
View File
@@ -2562,7 +2562,7 @@
"prefs.design_tools.imported_files": "Importované soubory",
"prefs.design_tools.note": "Poznámka",
"prefs.design_tools.off": "Vypnuto",
"prefs.design_tools.placement_group": "Umisťované prvky",
"prefs.design_tools.placement_group": "Opěra, Nálitek, Západka",
"prefs.design_tools.rows": "Řádky",
"prefs.devices": "3D myš",
"prefs.dialog_buttons": "Tlačítka dialogu",
@@ -5824,7 +5824,7 @@
"ui.pitch": "Stoupání",
"ui.placement": "Umístění",
"ui.placement_handles": "Úchyty umístění",
"ui.placement_handles_tip": "Značka pro přetažení prvku po modelu a tam, kde se prvek může otáčet, kroužek pro jeho otočení.",
"ui.placement_handles_tip": "Úchyty na náhledu opěry, nálitku nebo západky: tažením značky ji přesunete po modelu, prstencem ji otočíte",
"ui.plane": "Rovina",
"ui.plane_at_angle": "Rovina pod úhlem",
"ui.plugins": "Moduly plug-in",
+1 -1
View File
@@ -4916,7 +4916,7 @@
"ui.pitch": "Stigning",
"ui.placement": "Placering",
"ui.placement_handles": "Placeringshåndtag",
"ui.placement_handles_tip": "En markør til at trække featuren hen over modellen og, hvor featuren kan drejes, en ring til at dreje den.",
"ui.placement_handles_tip": "Håndtag på forhåndsvisningen af en Understøtning, Skruebøsning eller Snapforbindelse: Træk i markøren for at flytte den hen over modellen, og drej den ved dens ring",
"ui.plane": "Plan",
"ui.plane_at_angle": "Plan i vinkel",
"ui.plugins": "Plugins",
+2 -2
View File
@@ -2424,7 +2424,7 @@
"prefs.design_tools.imported_files": "Importierte Dateien",
"prefs.design_tools.note": "Hinweis",
"prefs.design_tools.off": "Aus",
"prefs.design_tools.placement_group": "Platzierte Funktionen",
"prefs.design_tools.placement_group": "Auflage, Zapfen, Schnappverbindung",
"prefs.design_tools.rows": "Zeilen",
"prefs.devices": "3D-Maus",
"prefs.dialog_buttons": "Dialogschaltflächen",
@@ -5758,7 +5758,7 @@
"ui.pitch": "Steigung",
"ui.placement": "Platzierung",
"ui.placement_handles": "Platzierungsgriffe",
"ui.placement_handles_tip": "Eine Markierung zum Verschieben des Features über das Modell und, wo sich das Feature drehen lässt, ein Ring zum Drehen.",
"ui.placement_handles_tip": "Eine Markierung zum Verschieben des Features über das Modell und ein Ring zum Drehen.",
"ui.plane": "Ebene",
"ui.plane_at_angle": "Ebene im Winkel",
"ui.plugins": "Plugins",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "Imported files",
"prefs.design_tools.note": "Note",
"prefs.design_tools.off": "Off",
"prefs.design_tools.placement_group": "Placed features",
"prefs.design_tools.placement_group": "Rest, Boss, Snap Fit",
"prefs.design_tools.rows": "Rows",
"prefs.devices": "3D mouse",
"prefs.dialog_buttons": "Dialog buttons",
@@ -6070,7 +6070,7 @@
"ui.pitch": "Pitch",
"ui.placement": "Placement",
"ui.placement_handles": "Placement handles",
"ui.placement_handles_tip": "A marker to drag the feature across the model and, where the feature can turn, a ring to turn it.",
"ui.placement_handles_tip": "A marker to drag the feature across the model and a ring to turn it.",
"ui.plane": "Plane",
"ui.plane_at_angle": "Plane at Angle",
"ui.plugins": "Plugins",
+32 -171
View File
@@ -247,7 +247,6 @@
"Drag to move the hole; click to remove it": "Arrastra para mover el agujero; haz clic para quitarlo",
"Drag to move the rest across the model. It snaps to sketch points on the face.": "Arrastra para mover el refuerzo por el modelo. Se ajusta a los puntos del boceto en la cara.",
"Drag to move the snap fit across the model. It snaps to sketch points on the face.": "Arrastra para mover el encaje a presión por el modelo. Se ajusta a los puntos del boceto en la cara.",
"Drag to place it on another face.": "Arrastra para colocarlo en otra cara.",
"Drag to point the catch in another direction.": "Arrastra para orientar el retén en otra dirección.",
"Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "Arrastra para girar el refuerzo en pasos de 5°. Mantén Alt o Ctrl para girarlo libremente.",
"Drive Joint": "Accionar unión",
@@ -261,8 +260,8 @@
"EMBOSS": "REALCE",
"EXPLODED VIEW": "VISTA EXPLOSIONADA",
"EXTEND": "EXTENDER",
"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 forma nueva también se acota desde el origen del boceto hasta que no quede nada libre. Una forma ya unida al origen o a geometría situada no recibe ninguna.",
"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 dibujas recibe todas sus cotas con los tamaños dibujados (los valores escritos tienen prioridad). Se omite un tamaño que el boceto ya fija.",
"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 forma nueva también se acota desde el origen del croquis hasta que no quede nada libre. Una forma ya unida al origen o a geometría situada no recibe ninguna.",
"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 dibujas recibe todas sus cotas con los tamaños dibujados (los valores escritos tienen prioridad). Se omite un tamaño que el croquis ya fija.",
"Edge": "Arista",
"Edges": "Aristas",
"Edit": "Editar",
@@ -514,7 +513,6 @@
"Offset Y": "Desfase Y",
"Offset Z": "Desfase Z",
"On your computer": "En tu equipo",
"One more stretch of the picked edge, each with its own angle, height and turns": "Otro tramo más de la arista seleccionada, cada uno con su propio ángulo, altura y giros",
"Opacity": "Opacidad",
"Open GitHub": "Abrir GitHub",
"Open GitHub draft": "Abrir borrador de GitHub",
@@ -636,7 +634,6 @@
"Remove the track": "Eliminar la pista",
"Remove this bend": "Quitar este pliegue",
"Remove this point": "Eliminar este punto",
"Remove this segment": "Quitar este segmento",
"Rename Feature": "Cambiar nombre de la operación",
"Rename Folder": "Cambiar nombre de la carpeta",
"Render Stage": "Escenario de renderizado",
@@ -726,7 +723,6 @@
"Section position": "Posición de la sección",
"Sections": "Secciones",
"Sections (one sketch each)": "Secciones (un boceto cada una)",
"Segments": "Segmentos",
"Select Features": "Seleccionar operaciones",
"Select Other": "Seleccionar otro",
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Seleccionar otro: mantén pulsado el botón izquierdo sobre el modelo (o haz clic derecho › Seleccionar otro…) para ver todo lo que hay bajo el cursor; vuelve a pulsar en el mismo lugar para seleccionar el siguiente elemento. Haz doble clic con el botón central para volver a Inicio (Preferencias › Navegación).",
@@ -776,7 +772,6 @@
"Spacing must be a non-zero number": "El espaciado debe ser un número distinto de cero",
"Specialise": "Especializar",
"Speed": "Velocidad",
"Split Edge": "Dividir arista",
"Splitting tool": "Herramienta de división",
"Square": "Cuadrado",
"Stainless Steel": "Acero inoxidable",
@@ -937,20 +932,13 @@
"ai.http.confirm": "Permitir para este servicio",
"ai.http.warning": "La clave de API y la conversación se enviarán sin cifrar. Cualquiera con acceso a la red podría leerlas o modificarlas. Úsalo solo en una red de confianza.",
"ai.prefs.allow_control": "Permitir que se conecten agentes de IA (canal de control / MCP)",
"ai.prefs.allow_control_note": "Un canal local (MCP). Nada sale de este equipo.\n\nAbre un canal de control solo en este equipo (127.0.0.1, en el puerto de abajo; 27878 toma el siguiente libre cuando está en uso). El programa que se conecte también debe presentar un token secreto que solo puede leer tu cuenta de usuario, generado de nuevo en cada arranque. Se aplica al momento. Desactivado: no se escucha nada salvo que inicies SolidCraft con --control.",
"ai.prefs.allow_control_note": "Abre un canal de control solo en este equipo (127.0.0.1, puerto 27878 o el siguiente libre). El programa que se conecte también debe presentar un token secreto que solo puede leer tu cuenta de usuario, generado de nuevo en cada arranque. Se aplica la próxima vez que se inicie SolidCraft. Desactivado: no se escucha nada salvo que inicies SolidCraft con --control.",
"ai.prefs.availability": "Controles del Asistente",
"ai.prefs.background_commands": "Ejecutar los comandos del agente en segundo plano",
"ai.prefs.connect_apps": "Poner SolidCraft a disposición de las aplicaciones de IA de este equipo",
"ai.prefs.connect_apps_note": "Al iniciar SolidCraft, lo añade como servidor MCP a las aplicaciones de IA que encuentre en este equipo (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), para que un agente pueda usarlo sin configurar nada. Solo se añade la entrada solidcraft o se actualiza su ruta; una entrada que hayas cambiado se deja intacta y el archivo se copia antes. Desactivado: se eliminan las entradas que añadió. Ayuda › Conectar asistentes de IA… lo hace a mano.",
"ai.prefs.connect_at_start": "Conectar el último agente usado al iniciar SolidCraft",
"ai.prefs.connect_at_start_note": "Inicia el agente que conectaste por última vez (o el predeterminado) en segundo plano cada vez que se abre SolidCraft",
"ai.prefs.control_failed": "No se ha podido iniciar el canal de control: {error}",
"ai.prefs.control_from_launch": "Iniciado con --control en el puerto {port} para esta ejecución. Los cambios de aquí se aplican la próxima vez que SolidCraft se inicie sin --control.",
"ai.prefs.control_listening": "Escuchando en 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "No está escuchando",
"ai.prefs.control_port": "Puerto",
"ai.prefs.control_port_note": "El puerto al que se conectan los agentes de IA en este equipo (127.0.0.1). 27878 es el predeterminado: cuando está en uso, se toma el siguiente libre. Cualquier otro puerto se usa exactamente. Apunta tu cliente MCP al mismo puerto, por ejemplo solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_port_taken": "El puerto {port} está en uso por otro programa (u otra ventana de SolidCraft). Elige otro puerto.",
"ai.prefs.follow_camera": "Girar la cámara hacia los bocetos",
"ai.prefs.follow_camera_off": "El seguimiento automático de la cámara está desactivado.",
"ai.prefs.follow_camera_on": "La cámara gira hacia los bocetos que crea el agente.",
@@ -1011,13 +999,6 @@
"browser.sketches_to_parts_not_on_face": "no en la cara de una pieza",
"browser.sketches_to_parts_part_missing": "falta su pieza",
"browser.sketches_to_parts_result": "Se movieron {moved} bocetos a la pieza en la que están; {stayed} se quedaron: {why}",
"browser.straighten_angle": "Dirección de la arista",
"browser.straighten_choose": "Elige Horizontal, Vertical o Ángulo para la arista y pulsa Enderezar.",
"browser.straighten_click_a": "Haz clic en la imagen en un extremo de una arista que deba quedar recta, o escribe sus coordenadas.",
"browser.straighten_click_b": "Haz clic en el otro extremo de la arista, o escribe sus coordenadas.",
"browser.straighten_horizontal": "Horizontal",
"browser.straighten_title": "Enderezar: {name}",
"browser.straighten_vertical": "Vertical",
"browser.units": "Unidades: {units}",
"cam.flat": "Cara plana",
"cam.follower": "Seguidor",
@@ -1047,8 +1028,6 @@
"cmd.appearance_pick_up": "Tomar apariencia",
"cmd.appearance_replace": "Reemplazar apariencia",
"cmd.assembly_bom": "Lista de materiales",
"cmd.assembly_constraint_create": "Restringir",
"cmd.assembly_constraint_edit": "Editar restricción",
"cmd.assembly_mass_properties": "Propiedades de masa",
"cmd.body.print_face": "Cara de impresión",
"cmd.body_rename": "Cambiar nombre del cuerpo",
@@ -1067,7 +1046,6 @@
"cmd.canvas_delete": "Eliminar lienzo",
"cmd.canvas_edit": "Editar lienzo",
"cmd.canvas_insert": "Lienzo",
"cmd.canvas_straighten": "Enderezar lienzo",
"cmd.canvas_to_sketch": "Lienzo a boceto",
"cmd.component_activate": "Activar componente",
"cmd.component_break_link": "Romper vínculo",
@@ -1109,10 +1087,7 @@
"cmd.config_rule": "Regla de configuración",
"cmd.config_rules": "Reglas de configuración",
"cmd.config_table": "Mostrar tabla de configuraciones",
"cmd.construct.axis": "Eje",
"cmd.construct.plane": "Plano",
"cmd.construct.plane.axis_point": "Plano por eje y punto",
"cmd.construct.point": "Punto",
"cmd.construct_axis_edge": "Eje por arista",
"cmd.construct_axis_normal_to_face": "Eje perpendicular a cara",
"cmd.construct_axis_through_cylinder": "Eje por cilindro/cono/toro",
@@ -1250,10 +1225,6 @@
"cmd.motion_trace": "Trayectoria de movimiento",
"cmd.motion_trace_edit": "Editar traza de movimiento",
"cmd.motion_trace_keep": "Conservar trayectoria de movimiento",
"cmd.note_add": "Añadir nota",
"cmd.note_delete": "Eliminar nota",
"cmd.note_list": "Listar notas",
"cmd.note_update": "Editar nota",
"cmd.occurrence_align": "Alinear instancia",
"cmd.occurrence_copy": "Pegar (instancia)",
"cmd.occurrence_drag": "Arrastrar instancia",
@@ -1379,7 +1350,6 @@
"cmd.render_start": "Renderizar imagen",
"cmd.render_status": "Progreso del renderizado",
"cmd.select_clear": "Borrar selección",
"cmd.selection_get": "Obtener selección",
"cmd.settings_profile_apply": "Aplicar perfil de ajustes",
"cmd.settings_profile_delete": "Eliminar perfil de ajustes",
"cmd.settings_profile_export": "Exportar perfil de ajustes",
@@ -1543,7 +1513,6 @@
"cmd.timeline_suppress": "Suprimir operación",
"cmd.ui.assistant": "Alternar Asistente",
"cmd.ui.palette": "Buscar comandos",
"cmd.ui.toggle_visibility": "Mostrar/ocultar selección",
"cmd.view.navigator": "Navegador de vistas personalizado",
"cmd.view.render_browser": "Explorador de render",
"cmd.view.render_dock": "Mover acople de render",
@@ -1601,7 +1570,6 @@
"color.step_down": "Bajar un paso",
"color.step_up": "Subir un paso",
"color.value": "Valor, el brillo (porcentaje)",
"component.error.ambiguous_name": "Varios componentes se llaman `{name}`. Usa un ID numérico de componente: {ids}.",
"config.column_filter_empty": "Sin columnas coincidentes",
"config.rule_parameter_columns": "Solo se listan los parámetros con una columna en la tabla. Añade una con Añadir columna (+) para usarla aquí.",
"config.rule_suppression_columns": "Solo se listan las operaciones con una columna Suprimir en la tabla. Añade una con Añadir columna (+) para controlarla aquí.",
@@ -1642,7 +1610,6 @@
"diagnostic.reselect_reference": "{feature}: la referencia `{reference}` ya no existe como se seleccionó; vuélvela a seleccionar",
"dialog.bodies_count": "{count} cuerpos",
"dialog.clearance_invalid": "la holgura debe ser una longitud de 0 o más",
"dialog.component_origin": "{placement} · {item}",
"dialog.compute": "Calcular",
"dialog.compute_again": "Calcular de nuevo",
"dialog.computing": "Calculando… {seconds} s",
@@ -1655,7 +1622,6 @@
"dialog.dimension_step_expression": "Solo se puede avanzar con un número; una expresión se queda como se escribe",
"dialog.dimension_step_up": "Subir {step}",
"dialog.edit_title": "EDITAR {title}",
"dialog.hole_held_off_its_line": "La arista de referencia mantiene este agujero sobre una línea que sale de la cara antes del punto seleccionado. Elige dónde cruza la línea la cara, o quita la arista para mover el agujero libremente.",
"dialog.no_shown_body": "No hay cuerpo mostrado para cambiar: muestra uno, o elige Nuevo cuerpo",
"dialog.pattern_sketch_not_object": "Selecciona cuerpos o operaciones: un boceto no es ninguno. Repite sus curvas con el Patrón o Espejo del propio boceto, o extrúdelas primero.",
"dialog.primitive_point_tip": "Un punto que rellena X, Y y Z: haz clic aquí y después en un vértice, una arista o una cara del modelo, en un punto de un boceto o en el origen.",
@@ -1674,12 +1640,9 @@
"document.changed_on_disk": "{name} ha cambiado en el disco",
"document.changed_on_disk_detail": "Otro programa u otro equipo ha modificado {name} desde que lo abriste o lo guardaste por última vez. Sobrescribir sustituye esos cambios por los tuyos; Guardar una copia conserva ambos.",
"document.copy_name": "Copia de {name}",
"document.discard_row": "Descartar",
"document.finish_save_prompt": "Termina de responder al aviso de guardado antes de abrir otro diseño.",
"document.newer_file": "Guardado con una versión más reciente de SolidCraft (formato {format}; esta versión lee hasta {reads}). Si falta algo, actualiza.",
"document.newer_file_change": "Guardado con una versión más reciente de SolidCraft (formato {format} con `{change}`, que esta versión no conoce). Si falta algo, actualiza.",
"document.newer_file_not_read": "No leído: {what}. Al guardar se conserva.",
"document.not_saved_yet": "Aún sin guardar",
"document.open_anyway": "Abrir de todos modos",
"document.open_elsewhere": "{name} ya está abierto",
"document.open_elsewhere_detail": "{name} está abierto en SolidCraft {app} en {who}, abierto {when}. Si guardáis los dos, uno de vosotros perderá su trabajo. Ábrelo en solo lectura para verlo sin sobrescribirlo.",
@@ -1689,9 +1652,6 @@
"document.read_only_save": "Este diseño se abrió en solo lectura: guárdalo como un archivo nuevo",
"document.save_changes": "¿Guardar los cambios de {name}?",
"document.save_copy": "Guardar una copia…",
"document.save_many": "¿Guardar los cambios de {n} diseños?",
"document.save_row": "Guardar",
"document.save_selected": "Guardar seleccionados",
"document.tab_unsaved_changes": "Cambios sin guardar",
"document.unsaved_changes": "{name} tiene cambios sin guardar. Si no los guardas, se perderán.",
"drive.rigid": "{joint} es una junta rígida: mantiene sus partes quietas, así que no tiene nada que impulsar.",
@@ -1724,7 +1684,6 @@
"file_load.inserting": "Insertando geometría",
"file_load.link_item": "{name} ({done} de {total})",
"file_load.opened": "Se ha abierto {name}",
"file_load.opened_behind": "{name} se ha abierto en una pestaña nueva",
"file_load.opened_in_tab": "Se ha abierto {name} en una pestaña nueva",
"file_load.opening": "Abriendo diseño",
"file_load.preparing_thumbnail": "Preparando la miniatura...",
@@ -1772,28 +1731,25 @@
"help.config.table": "Muestra la tabla de configuración: una fila por variante, una columna por parámetro u operación, y si el diseño sigue coincidiendo con la fila activa. Úsalo para revisar las variantes de un diseño.",
"help.connect_note": "Reinicia cada asistente para que cargue el servidor de SolidCraft. Cada archivo se guardó como .bak antes de modificarlo.",
"help.connect_title": "Conectar asistentes de IA",
"help.construct.axis": "Crea un eje de construcción con el método que pidan tus selecciones: a lo largo de una arista o línea, por un agujero o una cara redonda, donde se encuentran dos planos, por dos puntos, o perpendicular a una cara por un punto. Úsalo cuando conozcas las referencias y no el método.",
"help.construct.axis.edge": "Crea un eje de construcción a lo largo de una arista recta o de una línea de boceto. Úsalo como eje de una revolución, un patrón circular o una unión.",
"help.construct.axis.normal_to_face": "Crea un eje de construcción que pasa por un punto, perpendicular a una cara. Úsalo como eje que sale recto de una cara.",
"help.construct.axis.normal_to_face": "Crea un eje de construcción que pasa por un punto, perpendicular a una cara. Úsalo como un eje que sale recto de una superficie.",
"help.construct.axis.through_cylinder": "Crea un eje de construcción por el centro de una cara cilíndrica, cónica o toroidal, o de una arista circular. Úsalo para girar o repetir operaciones alrededor de una pieza redonda.",
"help.construct.axis.two_planes": "Crea un eje de construcción donde se cortan dos planos o caras planas. Úsalo cuando el eje que necesitas sea la línea entre dos caras.",
"help.construct.axis.two_points": "Crea un eje de construcción por dos puntos, vértices o puntos de boceto. Úsalo para un eje que va entre dos posiciones conocidas.",
"help.construct.plane": "Crea un plano de construcción con el método que pidan tus selecciones: con desfase respecto a un plano, por un punto, entre dos planos, por líneas o puntos, tangente a una cara curva, o perpendicular a una trayectoria. Úsalo cuando conozcas las referencias y no el método.",
"help.construct.plane.along_path": "Crea un plano de construcción perpendicular a una arista o curva, en una posición a lo largo de ella. Úsalo para dibujar el perfil de un barrido o una sección de una trayectoria.",
"help.construct.plane.at_angle": "Crea un plano de construcción girado un ángulo respecto a un plano o cara, alrededor de un eje o arista. Activa «Usar solo la dirección del eje» para proyectar el eje sobre la base y pivotar alrededor de un punto de ese plano (el origen de la base cuando no se elige ninguno). Úsalo para bocetos inclinados, como un soporte angular.",
"help.construct.plane.axis_point": "Crea un plano de construcción que contiene un eje y un punto fuera de ese eje. Elige un origen o un eje de construcción, una arista recta o una línea de boceto, y después un vértice, un punto de boceto o un punto de construcción. Un punto del eje no define un plano único.",
"help.construct.plane.midplane": "Crea un plano de construcción a medio camino entre dos planos o caras planas, o que los biseca donde se encuentran en ángulo. Úsalo como plano de simetría al reflejar.",
"help.construct.plane.midplane": "Crea un plano de construcción a medio camino entre dos planos o caras planas, o que los biseca donde se encuentran en ángulo. Úsalo como plano de simetría para un espejo.",
"help.construct.plane.offset": "Crea un plano de construcción paralelo a un plano o una cara plana, a una distancia o por un punto. Úsalo para dibujar a una altura fija sobre una cara.",
"help.construct.plane.perpendicular": "Crea un plano de construcción que contiene una línea o arista y es perpendicular a un plano o una cara. Úsalo para dibujar en vertical sobre un modelo a lo largo de una arista.",
"help.construct.plane.tangent": "Crea un plano de construcción que toca un cilindro, cono, esfera o toro en un punto. Úsalo para dibujar en el lado de una pieza redonda.",
"help.construct.plane.three_points": "Crea un plano de construcción por tres puntos. Úsalo para un plano definido por posiciones del modelo, como tres esquinas.",
"help.construct.plane.two_edges": "Crea un plano de construcción por dos aristas rectas o líneas que están en un mismo plano. Úsalo para un plano que cruza una pieza, por ejemplo por dos aristas opuestas.",
"help.construct.point": "Crea un punto de construcción con el método que pidan tus selecciones: en un vértice, en el centro de un círculo o una esfera, a lo largo de una arista, donde se encuentran dos líneas o tres planos, o donde una línea cruza un plano. Úsalo cuando conozcas las referencias y no el método.",
"help.construct.point.along_path": "Crea un punto de construcción en una posición a lo largo de una arista o curva. Úsalo como referencia a mitad de una trayectoria.",
"help.construct.point.center": "Crea un punto de construcción en el centro de una arista circular, un círculo o arco de boceto, una esfera o un toro. Úsalo como referencia para agujeros, ejes y uniones.",
"help.construct.point.edge_plane": "Crea un punto de construcción donde una arista o línea atraviesa un plano o una cara plana. Úsalo para marcar dónde una trayectoria cruza un plano.",
"help.construct.point.three_planes": "Crea un punto de construcción donde se encuentran tres planos o caras planas. Úsalo como referencia de esquina, incluso donde el modelo no tiene vértice.",
"help.construct.point.two_edges": "Crea un punto de construcción donde se cortan dos aristas o líneas, o donde más se acercan. Úsalo como referencia en una esquina virtual.",
"help.construct.point.two_edges": "Crea un punto de construcción donde se cortan dos aristas o líneas, o donde están más cerca. Úsalo como referencia en una esquina virtual.",
"help.construct.point.vertex": "Crea un punto de construcción sobre un vértice o un punto de boceto. Úsalo como referencia que permanece cuando se oculta el boceto o el cuerpo que lo rodea.",
"help.contact.check": "Lista los pares de piezas de los conjuntos de contacto que se atraviesan en su posición actual. Úsalo para encontrar colisiones antes de accionar uniones o ejecutar un estudio de movimiento.",
"help.contact.create": "Agrupa dos o más piezas en un conjunto de contacto, cuyos cuerpos no pueden atravesarse mientras las uniones se mueven. Úsalo para piezas que deben detenerse al tocarse, como una palanca contra su tope.",
@@ -1815,7 +1771,7 @@
"help.font_details": "La interfaz utiliza las fuentes integradas de egui (Ubuntu Light y Hack, bajo las licencias Ubuntu Font Licence y MIT/Bitstream Vera; Noto Emoji y la fuente de iconos emoji, bajo OFL y MIT). El texto de los bocetos utiliza Liberation Sans (SIL OFL 1.1). El paquete opcional de fuentes de The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), no se incluye en las compilaciones de SolidCraft.",
"help.fonts": "Fuentes",
"help.frame.end_cap": "Cierra los extremos abiertos de los miembros de una estructura con placas con la forma del contorno del perfil. Úsalo para tapar tubos, angulares y vigas, sobre el extremo o embutidas en él.",
"help.frame.member": "Coloca un perfil, como un tubo o una extrusión de aluminio, a lo largo de las líneas de un boceto de estructura, un miembro por línea. Úsalo para construir bastidores, estanterías y estructuras soldadas.",
"help.frame.member": "Coloca un perfil, como un tubo o un perfil de aluminio extruido, a lo largo de las líneas de un boceto de estructura, un miembro por línea. Úsalo para construir bastidores, estanterías y estructuras soldadas.",
"help.inspect.accessibility": "Colorea las caras a las que una herramienta puede llegar desde una dirección y las que no. Úsalo para comprobar una pieza para mecanizado o moldeo desde ese lado.",
"help.inspect.center_of_mass": "Muestra el centro de masa de los cuerpos, cada uno por separado y todos juntos, con densidad uniforme. Úsalo para comprobar el equilibrio y dónde volcará una pieza.",
"help.inspect.colored_components": "Da a cada componente su propio color temporal, sin cambiar los aspectos. Úsalo para distinguir de un vistazo las piezas de un ensamblaje.",
@@ -1886,11 +1842,11 @@
"help.parts.library": "Abre las piezas estándar: tornillos, pernos, tuercas, arandelas, tornillos sin cabeza, pasadores cilíndricos y rodamientos en tamaños estándar. Úsalo para añadir una pieza estándar en lugar de modelarla tú.",
"help.parts.resize": "Cambia el tamaño o la longitud de una pieza estándar insertada y la reconstruye; sus uniones la mantienen asentada. Úsalo cuando un tornillo resulta demasiado largo o un agujero necesita otro tamaño.",
"help.plastic.assign_rule": "Asigna una regla de plástico a cuerpos, de modo que sus operaciones de plástico toman su desmoldeo y holgura como valores por defecto. Úsalo para configurar una pieza moldeada una sola vez para todas sus operaciones.",
"help.plastic.boss": "Añade un saliente para tornillo en una cara: un poste con agujero opcional, desmoldeo, redondeo en la base y nervios de apoyo. Úsalo para los puntos de tornillo en carcasas moldeadas o impresas.",
"help.plastic.boss": "Añade una torreta para tornillo en una cara: un poste con agujero opcional, desmoldeo, redondeo en la base y nervios de apoyo. Úsalo para los puntos de tornillo en carcasas moldeadas o impresas.",
"help.plastic.lip": "Adds a lip or a matching groove along the rim of a shelled body. Use it so that two housing halves locate on each other and close tightly.",
"help.plastic.manage_rules": "Crea y edita reglas de plástico: material, espesor de pared, radios, holgura y desmoldeo. Úsalas para mantener coherentes las operaciones de una pieza moldeada.",
"help.plastic.rest": "Añade un apoyo en una cara: un taco redondo o rectangular, macizo o hueco, con desmoldeo. Úsalo como soporte o separador en una carcasa moldeada.",
"help.plastic.snap_fit": "Añade un gancho de encaje a presión en una cara, con su longitud, espesor y retención. Úsalo para unir piezas sin tornillos.",
"help.plastic.snap_fit": "Añade un gancho de encaje a presión en una cara, con su longitud, espesor y resalte. Úsalo para unir piezas sin tornillos.",
"help.pointcloud.insert": "Inserta un escaneo PLY, XYZ o PTS como nube de puntos junto al modelo. Úsala como",
"help.sheet.convert": "Convierte una placa o un cuerpo vaciado de espesor uniforme en un cuerpo de chapa metálica, con pliegues en las aristas que elijas. Úsalo para hacer desplegable una pieza existente.",
"help.sheet.export_bend_table": "Guarda la tabla de plegado de un cuerpo de chapa metálica como CSV o JSON: ángulos, radios y posiciones de los pliegues en el desarrollo. Úsalo para la plegadora o el taller.",
@@ -1901,22 +1857,22 @@
"help.sheet.hem": "Dobla el borde de una chapa sobre sí mismo. Úsalo para bordes seguros y rígidos, sin rebabas cortantes.",
"help.sheet.manage_rules": "Crea y edita reglas de chapa metálica: espesor, radio de plegado, factor K, alivios y holguras. Úsalas para ajustarte al material y a las herramientas de tu taller.",
"help.sheet.path_flange": "Añade una cadena de pestañas a lo largo del borde de una chapa, siguiendo una cadena abierta de líneas de boceto. Úsalo para perfiles como una U o una Z en un solo paso.",
"help.sheet.perforate": "Añade un campo de agujeros sobre toda la chapa plana, en patrón al tresbolillo o en cuadrícula. Úsalo para rejillas, ventilaciones y paneles más ligeros.",
"help.sheet.perforate": "Añade un campo de agujeros sobre toda la chapa plana, en patrón al tresbolillo o cuadrado. Úsalo para rejillas, ventilaciones y paneles más ligeros.",
"help.sheet.refold": "Vuelve a plegar un cuerpo de chapa metálica desplegado. Úsalo tras añadir operaciones a la chapa plana.",
"help.sheet.unfold": "Despliega todos los pliegues de un cuerpo de chapa metálica. Úsalo para añadir agujeros o cortes que deban cruzar un pliegue, y luego vuelve a plegar.",
"help.sketch.arc.center_point": "Dibuja un arco circular alrededor de un punto central, de un punto inicial a un punto final. Úsalo cuando se conoce el centro del arco, como un radio alrededor de un agujero o de un pivote.",
"help.sketch.arc.tangent": "Dibuja un arco que empieza en el extremo de una línea o arco y continúa tangente a él. Úsalo para seguir una cadena con una curva suave en lugar de una esquina.",
"help.sketch.arc.three_point": "Dibuja un arco de un punto inicial a un punto final pasando por un tercer punto. Úsalo cuando el arco deba pasar por puntos dados y no alrededor de un centro conocido.",
"help.sketch.arc3d": "Dibuja un arco circular exacto en el espacio por tres puntos, con el Boceto 3D activado. Úsalo para curvas en trayectorias y guías que salen del plano del boceto.",
"help.sketch.auto_constrain": "Añade las restricciones que el boceto ya casi cumple, como horizontal, paralela o coincidente, y luego lo acota hasta definirlo por completo. Úsalo para fijar en un solo paso un boceto dibujado a mano alzada.",
"help.sketch.auto_constrain_from_datum": "Funciona como Restringir automáticamente, pero mide las posiciones desde un punto de referencia que eliges en lugar del origen. Úsalo cuando la pieza se organiza a partir de un punto de referencia, como una esquina o un agujero.",
"help.sketch.auto_constrain": "Añade las restricciones que el boceto ya casi cumple, como horizontal, paralela o coincidente, y luego lo acota hasta definirlo por completo. Úsalo para fijar de un paso un boceto dibujado a mano alzada.",
"help.sketch.auto_constrain_from_datum": "Funciona como Restricción automática, pero mide las posiciones desde un punto de referencia que eliges en lugar del origen. Úsalo cuando la pieza se organiza a partir de un punto de referencia, como una esquina o un agujero.",
"help.sketch.blend_curve": "Une los extremos de dos curvas con una spline suave tangente a ambas, o también de curvatura continua si lo eliges. Úsalo para salvar un hueco entre curvas sin quiebros.",
"help.sketch.break": "Divide una curva en trozos donde otras curvas la cruzan, o en puntos que colocas sobre ella. Úsalo cuando una parte de una curva necesite su propia restricción o deba borrarse por separado.",
"help.sketch.cancel": "Sale del boceto y descarta todo lo cambiado desde que se creó o se abrió para editarlo. Deshacer recupera los cambios.",
"help.sketch.centerline": "Cambia las líneas entre líneas normales y líneas de eje, ejes que los perfiles dejan fuera. Usa una línea de eje como eje de una revolución, una simetría o una restricción de simetría.",
"help.sketch.chamfer.distance_angle": "Bisela la esquina entre dos líneas, definida por una distancia a lo largo de la primera línea y un ángulo. Úsalo cuando un plano da el chaflán como una longitud y un ángulo, como 2 mm × 30°.",
"help.sketch.centerline": "Cambia las líneas entre líneas normales y líneas de eje, ejes que los perfiles dejan fuera. Usa una línea de eje como eje de una revolución, un espejo o una restricción de simetría.",
"help.sketch.chamfer.distance_angle": "Bisela la esquina entre dos líneas, definido por una distancia a lo largo de la primera línea y un ángulo. Úsalo cuando un plano da el chaflán como una longitud y un ángulo, como 2 mm × 30°.",
"help.sketch.chamfer.equal_distance": "Bisela la esquina entre dos líneas la misma distancia hacia atrás en ambas. Úsalo para el chaflán habitual de 45° en un contorno.",
"help.sketch.chamfer.two_distance": "Bisela la esquina entre dos líneas, definida por una distancia distinta a lo largo de cada línea. Úsalo para chaflanes que no son de 45°, dados como dos longitudes.",
"help.sketch.chamfer.two_distance": "Bisela la esquina entre dos líneas, definido por una distancia distinta a lo largo de cada línea. Úsalo para chaflanes que no son de 45°, dados como dos longitudes.",
"help.sketch.circle.center": "Dibuja un círculo a partir de su centro y su diámetro. Úsalo para agujeros, salientes y otras formas redondas cuyo centro se conoce.",
"help.sketch.circle.three_point": "Dibuja un círculo por tres puntos. Úsalo cuando el círculo deba pasar por puntos dados, por ejemplo de geometría existente.",
"help.sketch.circle.three_tangent": "Dibuja un círculo que toca tres líneas, círculos o arcos. Úsalo para ajustar un círculo en el espacio entre tres curvas, como el círculo inscrito de un triángulo.",
@@ -1941,13 +1897,13 @@
"help.sketch.construction_line": "Crea una línea de construcción infinita a través de dos puntos. Úsala para organizar un boceto.",
"help.sketch.construction_line.angle": "Crea una línea de construcción infinita en un ángulo desde el eje X del boceto. Elige el punto a",
"help.sketch.construction_line.bisector": "Elige dos líneas rectas de boceto que se intersecten, luego elige una de las dos bisectrices del ángulo.",
"help.sketch.construction_line.horizontal": "Crea una línea de construcción horizontal infinita que pase por un punto, o escribe su distancia al eje X del boceto. Una distancia escrita se convierte en una cota controladora. Su restricción horizontal sigue las ediciones posteriores.",
"help.sketch.construction_line.parallel": "Elige una línea recta de boceto y luego coloca una línea de construcción paralela infinita o escribe su desfase. Un desfase escrito se convierte en una distancia controladora. Haz clic con el botón derecho para elegir otra referencia.",
"help.sketch.construction_line.horizontal": "Crea una línea de construcción horizontal infinita a través de un punto. Su dirección horizontal",
"help.sketch.construction_line.parallel": "Elige una línea recta de boceto, luego coloca una línea de construcción paralela infinita. R",
"help.sketch.construction_line.perpendicular": "Elige una línea recta de boceto, luego coloca una línea de construcción perpendicular infinita.",
"help.sketch.construction_line.tangent_at": "Crea una tangente en un punto de un círculo o arco circular, o en un extremo de una curva abierta.",
"help.sketch.construction_line.tangent_external": "Elige un círculo o arco circular, luego un punto externo y una solución de tangente. Th",
"help.sketch.construction_line.tangent_two": "Elige dos círculos o arcos circulares, luego elige una tangente común en la previsualización.",
"help.sketch.construction_line.vertical": "Crea una línea de construcción vertical infinita que pase por un punto, o escribe su distancia al eje Y del boceto. Una distancia escrita se convierte en una cota controladora. Su restricción vertical sigue las ediciones posteriores.",
"help.sketch.construction_line.vertical": "Crea una línea de construcción vertical infinita a través de un punto. Su dirección vertical",
"help.sketch.copy": "Copia las curvas y puntos de boceto seleccionados, junto con las restricciones y cotas entre ellos. Pégalos en este boceto o en otro.",
"help.sketch.create": "Empieza un nuevo boceto 2D en un plano o en una cara plana del modelo. Toda extrusión, revolución y barrido empieza con un boceto.",
"help.sketch.cut": "Copia la geometría de boceto seleccionada y la borra del boceto, en un solo paso de deshacer. Pégala en otro lugar de este boceto o en otro.",
@@ -1958,10 +1914,10 @@
"help.sketch.extend": "Alarga una línea o un arco hasta la curva más cercana que encuentre, o hace crecer dos curvas hasta que se encuentran. Úsalo para cerrar huecos de un perfil tras recortar o editar.",
"help.sketch.fillet": "Redondea la esquina donde se encuentran dos líneas o arcos con un arco de un radio dado. Varias esquinas redondeadas a la vez comparten un radio.",
"help.sketch.finish": "Sale del boceto y vuelve al modelo. Sus perfiles cerrados quedan listos para Extrusión, Revolución y otras operaciones.",
"help.sketch.finish_auto_constrain": "Ejecuta Restringir automáticamente en el boceto y luego lo finaliza. Úsalo para dejar un boceto totalmente definido en un solo paso.",
"help.sketch.finish_auto_constrain": "Ejecuta Restricción automática en el boceto y luego lo finaliza. Úsalo para dejar un boceto totalmente definido de un paso.",
"help.sketch.fit_curves_to_section": "Corta un cuerpo de malla o sólido con el plano del boceto y ajusta líneas y arcos a la sección. Úsalo para reconstruir como boceto editable una pieza escaneada o importada.",
"help.sketch.include_3d": "Trae aristas del modelo, vértices y curvas de otros bocetos a este boceto como referencias vinculadas, aplanando sobre él las que no están en su plano. Úsalo para construir sobre geometría que no está en el plano del boceto.",
"help.sketch.infinite": "Cambia las líneas entre líneas normales y líneas de construcción infinitas que cruzan todo el visor. Úsalas como referencias de disposición que nunca se quedan cortas.",
"help.sketch.infinite": "Cambia las líneas entre líneas normales y líneas de construcción infinitas que cruzan toda la vista. Úsalas como referencias de disposición que nunca se quedan cortas.",
"help.sketch.insert_dxf": "Lee las curvas de un dibujo DXF en un boceto, escaladas, giradas y colocadas donde indiques. Úsalo para construir sobre un dibujo 2D de otro programa, como un contorno para corte láser.",
"help.sketch.insert_svg": "Lee los trazados de un dibujo SVG en un boceto, escalados, girados y colocados donde indiques. Úsalo para convertir un logotipo o un contorno vectorial en curvas que puedas extruir.",
"help.sketch.inspect": "Abre un informe sobre el boceto abierto: cuántos grados de libertad quedan y qué",
@@ -2066,12 +2022,12 @@
"help.view.apply": "Gira la vista a una dirección estándar como frontal, superior o isométrica, o a una vista con nombre guardada. Úsalo para mirar el modelo desde un ángulo fijo.",
"help.view.delete": "Borra una vista con nombre guardada. El modelo no cambia.",
"help.view.fit": "Acerca y desplaza para que todo el modelo, o la selección, llene la vista. Úsalo cuando el modelo se ha salido de la vista.",
"help.view.grid": "Muestra u oculta la cuadrícula en el visor. Úsala como guía de tamaño y orientación al modelar.",
"help.view.grid": "Muestra u oculta la cuadrícula en la vista. Úsala como guía de tamaño y orientación al modelar.",
"help.view.ground_shadow": "Muestra u oculta la sombra del modelo en el suelo. Úsalo para juzgar con más facilidad la altura y la posición.",
"help.view.home": "Devuelve la vista a la dirección de inicio y encuadra el modelo. Úsalo para volver rápido a una vista conocida.",
"help.view.list": "Lista las vistas con nombre guardadas en el diseño. Úsalo para encontrar una vista que guardaste antes.",
"help.view.navigate": "Gira, desplaza o acerca la vista por pasos, como un cuarto de vuelta a la izquierda. Úsalo para mover la cámara con precisión en lugar de arrastrar.",
"help.view.navigation_bar": "Muestra u oculta la barra de navegación en la parte inferior del visor. Ocúltala para tener más espacio, muéstrala para llegar a las herramientas de vista con el ratón.",
"help.view.navigation_bar": "Muestra u oculta la barra de navegación en la parte inferior de la vista. Ocúltala para tener más espacio, muéstrala para llegar a las herramientas de vista con el ratón.",
"help.view.rename": "Da un nombre nuevo a una vista con nombre guardada. La vista en sí no cambia.",
"help.view.ribbon_tabs_only": "Pliega la cinta hasta su fila de pestañas para que la vista recupere el espacio de los paneles. Haz clic en una pestaña para mostrar sus paneles sobre la vista; vuelve a elegir esta opción para mantener toda la cinta visible.",
"help.view.save": "Guarda la posición actual de la cámara como una vista con nombre en el diseño. Úsalo para volver al mismo ángulo más tarde, por ejemplo para capturas de pantalla.",
@@ -2291,9 +2247,6 @@
"measure.at": "En",
"measure.item": "Elemento {number}",
"measure.line_to_centre": "Línea al centro",
"measure.perimeter": "Perímetro",
"measure.perimeter_inner": "Perímetro interior",
"measure.perimeter_outer": "Perímetro exterior",
"measure.quick_copied": "Medición copiada",
"measure.quick_copy": "Haz clic para copiar la medición",
"measure.quick_edges": "{count} aristas",
@@ -2335,8 +2288,6 @@
"nav.hfss.help": "Alt+arrastre izq. desplaza, Mayús+Alt+arrastre izq. hace zoom, Ctrl+arrastre izq. en espacio vacío orbita",
"nav.inventor.help": "Arrastrar con el botón central desplaza, Mayús+central orbita, Ctrl+central hace zoom y arrastrar con el derecho orbita",
"nav.maya.help": "Alt+arrastre izq. orbita, Alt+arrastre central desplaza, Alt+arrastre der. hace zoom; arrastre central también desplaza",
"nav.maya_horizontal_zoom.help": "Alt+arrastrar con el botón derecho: hacia la derecha acerca, hacia la izquierda aleja. Desactívalo para volver al zoom arrastrando en vertical.",
"nav.maya_horizontal_zoom.label": "Maya: zoom arrastrando en horizontal",
"nav.nx.help": "Arrastrar con botón central orbita, Shift+central desplaza (central + derecho de NX), Ctrl+central hace zoom (central + izquierdo de NX); arrastrar con derecho deja la cámara",
"nav.openscad.help": "Arrastrar con el botón izquierdo sobre un espacio vacío orbita, Mayús+izquierdo selecciona con un recuadro y arrastrar geometría del boceto la edita; arrastrar con el derecho o el central desplaza, y Mayús+derecho o Mayús+central hace zoom",
"nav.orbit_cursor_wrap.help": "Un arrastre de órbita que llega al borde de la vista mueve el cursor al borde opuesto y sigue girando. Desactivado: el arrastre se detiene en el borde de la pantalla. No tiene efecto en Wayland ni en el navegador.",
@@ -2467,16 +2418,6 @@
"not a move": "no es un movimiento",
"not a number": "no es un número",
"open a sketch first": "abre primero un boceto",
"operations.cancel": "Cancelar {name}",
"operations.cancelled": "{name} cancelado",
"operations.cancelling": "Cancelando…",
"operations.cannot_cancel": "{name} no se puede cancelar",
"operations.feature": "Operación",
"operations.feature_edit": "Editar operación",
"operations.meshing": "Cargando piezas",
"operations.queued": "En cola",
"operations.stopped": "Cancelado",
"operations.waiting": "Listo",
"parameters.apply_live": "Aplicar en vivo",
"parameters.apply_live_tip": "Aplica los cambios de parámetros mientras los editas",
"parameters.held": "Mantenidos ({count})",
@@ -2565,7 +2506,6 @@
"prefs.appearance.viewport_colours": "Colores de vista 3D",
"prefs.appearance.viewport_logo": "Visor y logo",
"prefs.applies_on_restart": "Se aplica al reiniciar",
"prefs.assemblies": "Ensamblajes",
"prefs.assistant": "Asistente",
"prefs.assistant_connecting": "Conectando",
"prefs.assistant_follow_help": "Los bocetos Mirar mueven la vista con el puntero.",
@@ -2618,10 +2558,6 @@
"prefs.choose_font": "Elegir archivo de fuente…",
"prefs.click": "Clic",
"prefs.command_dialog_values": "Diálogos de comando",
"prefs.constraint_glyph_size": "Tamaño de los glifos de restricción",
"prefs.constraint_glyph_size_note": "Cómo de grandes se dibujan los glifos de restricción y sus letras o iconos en un boceto. El 100 % es el tamaño habitual; los glifos conservan este tamaño en pantalla con cualquier zoom.",
"prefs.construct_menu_groups": "Agrupar las herramientas de Construir por tipo de entidad",
"prefs.construct_menu_groups_help": "Muestra los encabezados Planos, Ejes y Puntos. Desactivado, se mantiene el orden original del menú.",
"prefs.contact_shading": "Sombreado de contacto suave",
"prefs.contact_shading_note": "Sombreado suave donde las piezas se acercan.",
"prefs.context_menu": "Menú contextual",
@@ -2653,12 +2589,10 @@
"prefs.design_tools.imported_files": "Archivos importados",
"prefs.design_tools.note": "Nota",
"prefs.design_tools.off": "Desactivado",
"prefs.design_tools.placement_group": "Operaciones colocadas",
"prefs.design_tools.placement_group": "Apoyo, Resalte, Encaje",
"prefs.design_tools.rows": "Filas",
"prefs.devices": "Ratón 3D",
"prefs.dialog_buttons": "Botones de diálogo",
"prefs.dialog_first_letter_buttons": "Los botones de diálogo responden a su primera letra",
"prefs.dialog_first_letter_buttons_help": "Con un diálogo abierto, escribir la primera letra de la etiqueta de un botón lo pulsa: O para OK, C para Cancelar. Una letra que encaja en dos botones no hace nada, y un campo de texto con el foco conserva la pulsación. Desactivado: escribir no afecta a los botones, como antes.",
"prefs.dialogs": "Diálogos",
"prefs.dialogs_edge": "En el borde de la vista",
"prefs.dialogs_left_column": "En la columna izquierda",
@@ -2672,23 +2606,12 @@
"prefs.dimension_values_window": "En una ventana",
"prefs.display": "Pantalla",
"prefs.edges": "Aristas y sombreado",
"prefs.export": "Exportar",
"prefs.export_name": "Nombre del archivo de exportación",
"prefs.export_name.body": "Nombre del cuerpo exportado (el primero cuando hay varios)",
"prefs.export_name.design": "Nombre del diseño",
"prefs.export_name_hint": "p. ej., {design}-{body}",
"prefs.export_name_sample": "Al exportar este diseño se ofrece: {name}",
"prefs.export_name_tip": "El nombre de archivo que ofrecen Archivo › Exportar y Guardar como malla. Se sustituyen estos marcadores:\n{placeholders}\nUna propiedad del diseño que no esté definida se omite. Deja el campo vacío para ofrecer el nombre del diseño o, en una malla, el del cuerpo.",
"prefs.extend": "Extensiones",
"prefs.extra_navigation": "Ctrl + arrastrar con el botón izquierdo sobre un espacio vacío orbita; mantén Espacio para desplazar.",
"prefs.extrude_arrow_face_stops": "La flecha de Extruir se detiene en las caras",
"prefs.extrude_arrow_face_stops_note": "Al arrastrar la flecha de distancia de Extruir, se detiene en las caras planas paralelas al perfil, como el lado opuesto de un bloque que se corta, y resalta la cara. Desactivado: la flecha solo avanza en pasos redondos.",
"prefs.extrude_view_isometric": "Girar la vista a isométrica al abrir Extrusión",
"prefs.extrude_view_return": "Volver a la vista anterior al cerrar Extrusión",
"prefs.file_operations_wait": "Esperar a que se abran los archivos",
"prefs.file_operations_wait_note": "Mientras se abre o se inserta un archivo, o se vuelven a leer los vínculos, el diseño espera tras una tarjeta con el progreso del archivo y Cancelar. Desactivado: el archivo aparece en el panel de operaciones y puedes seguir trabajando; una inserción se aplica cuando termines lo que estás haciendo, y un archivo abierto mientras tanto va a una pestaña nueva.",
"prefs.fillet_legacy_radius": "Usar el radio predeterminado anterior de Redondeo de sólido",
"prefs.fillet_legacy_radius.help": "Desactivado de forma predeterminada. Activado, un nuevo Redondeo de sólido se abre con el número 2 leído en las unidades del diseño («2 in» en un diseño en pulgadas), como antes; desactivado, se abre con el radio que trae la herramienta, 2 mm, mostrado en las unidades del diseño («0.07874 in» en un diseño en pulgadas).",
"prefs.follows_os": "Sigue al sistema operativo",
"prefs.font": "Fuente",
"prefs.font_fallback": "Los glifos que faltan en la fuente elegida se toman de la fuente integrada, así que ninguna etiqueta muestra un recuadro vacío.",
@@ -2766,8 +2689,6 @@
"prefs.history_sketches_in_order": "En orden",
"prefs.history_sketches_note": "Con Bocetos en Debajo de su operación, el Historial de la izquierda lista cada boceto un nivel dentro, bajo la primera operación construida a partir de él, siempre que haya ancho para nombres. La línea de tiempo en la parte inferior mantiene siempre el orden de creación.",
"prefs.history_sketches_under_feature": "Debajo de su operación",
"prefs.history_steps_component": "Pasos del historial por componente",
"prefs.history_steps_component_note": "Con un componente activo o seleccionado, los controles del Historial recorren las operaciones de ese componente, tal como las muestra el Historial de abajo. Desactivado: recorren todas las operaciones del diseño. El Historial de la izquierda o bajo el navegador siempre recorre todas las operaciones.",
"prefs.hold_stop_snapping": "Mantén pulsado para desactivar el ajuste",
"prefs.home_fit_size": "Tamaño de Inicio y Ajustar",
"prefs.home_fit_size_note": "Cuánto de la vista ocupa el modelo al usar Inicio, Ajustar, un diseño abierto o un cambio de vista del navegador, independientemente de la configuración siguiente. 100 %: llena la vista entre los paneles, la brújula y la barra de navegación.",
@@ -2783,8 +2704,6 @@
"prefs.invert_curved_roll_arrows_note": "Las dos flechas curvas en la esquina del cubo giran la vista en el plano de la pantalla. Activado: la flecha horaria gira antihoraria y la antihoraria gira horaria. Desactivado: cada flecha gira la vista hacia donde se curva.",
"prefs.invert_navigator_arrows": "Invertir las flechas de rotación del cubo de vista",
"prefs.invert_navigator_arrows_note": "Las flechas alrededor del cubo orbitan la cámara hacia donde apuntan, como en Fusion e Inventor: la flecha de la izquierda apunta a la derecha y gira la cámara a la derecha. Desactivado: una flecha rota el cubo hacia el lado en que está, así la flecha izquierda gira la cámara a la izquierda. Las flechas de giro curvo no se ven afectadas.",
"prefs.joint_drag_trackball": "Girar las piezas de una unión de rótula como un trackball al arrastrarlas",
"prefs.joint_drag_trackball_help": "Arrastrar una pieza que sujeta una unión de rótula la gira con el menor giro posible alrededor de la unión, como si rodara una bola del tamaño de la pieza. Desactivado: el punto que agarraste sigue exactamente al puntero, y la pieza también puede girar sobre él.",
"prefs.keep_unsaved_also_asks": "Preguntar igualmente al cerrar la pestaña",
"prefs.keep_unsaved_also_asks_tip": "Con esto desactivado, la pestaña se cierra sin el cuadro del navegador en cuanto la copia de cada diseño sin guardar está al día.",
"prefs.keep_unsaved_in_browser": "Conservar el trabajo sin guardar en este navegador",
@@ -2860,8 +2779,6 @@
"prefs.not_on_gpu": "no en esta GPU",
"prefs.on": "Activado",
"prefs.opaque": "Opaco",
"prefs.operations_panel": "Mostrar las operaciones en la vista",
"prefs.operations_panel_note": "Las operaciones largas (abrir un archivo, calcular una operación, un comando del agente) se muestran en la esquina inferior derecha de la vista con su progreso y un botón para cancelarlas. Desactivado: un aviso de «Calculando», como antes.",
"prefs.orbit_snap": "Mantén Mayús mientras orbitas para ajustar a un eje.",
"prefs.orientation": "Orientación",
"prefs.origin_plane_names": "Nombres de los planos de origen",
@@ -2942,8 +2859,6 @@
"prefs.ribbon_tabs_only": "Mostrar solo las pestañas de la cinta",
"prefs.ribbon_tabs_only_note": "La cinta se pliega hasta su fila de pestañas. Al hacer clic en una pestaña se muestran sus paneles sobre la vista hasta que empieza un comando o el puntero sale de ellos. «Solo pestañas de la cinta», en la pestaña Vista o con su atajo, alterna esta opción.",
"prefs.right_click": "Clic derecho",
"prefs.save_prompt_one_at_a_time": "Preguntar por cada diseño sin guardar por separado al cerrar varios",
"prefs.save_prompt_one_at_a_time_help": "Mostrar un aviso de guardado por cada diseño sin guardar en lugar de una lista combinada.",
"prefs.saving": "Guardado automático y recuperación",
"prefs.scale_note": "También Ctrl + / Ctrl − en la ventana. Los botones miden 28 px al 100%.",
"prefs.scale_text": "Escala y texto",
@@ -2980,8 +2895,8 @@
"prefs.sketch.center_arc": "El arco por centro sigue al cursor",
"prefs.sketch.click_selects": "El clic selecciona",
"prefs.sketch.conflict_colour": "Color de conflicto",
"prefs.sketch.cursor_coordinates": "Mostrar las coordenadas del cursor en un boceto",
"prefs.sketch.cursor_coordinates_note": "Junto al puntero, su X e Y desde el origen del boceto, también sin ninguna herramienta activa. Desactivado: solo mientras una herramienta coloca un punto.",
"prefs.sketch.cursor_coordinates": "Mostrar las coordenadas del cursor en un croquis",
"prefs.sketch.cursor_coordinates_note": "Junto al puntero, su X e Y desde el origen del croquis, también sin ninguna herramienta activa. Desactivado: solo mientras una herramienta coloca un punto.",
"prefs.sketch.drawing": "Dibujo",
"prefs.sketch.ellipse_axes": "Dibujar elipses con sus ejes",
"prefs.sketch.ellipse_axes_note": "Los ejes mayor y menor como líneas de construcción, para poder acotar los semiejes y los ejes. Desactivado: solo la elipse, y al pulsar D sobre ella se da su radio menor allí donde se arrastre el valor, dibujado y etiquetado como R, como antes.",
@@ -2993,20 +2908,14 @@
"prefs.sketch.faces_edges": "Caras y aristas",
"prefs.sketch.hide_glyphs": "Ocultar glifos al alejar",
"prefs.sketch.marks": "Marcas en el boceto",
"prefs.sketch.max_points": "Boceto más grande",
"prefs.sketch.max_points_note": "El máximo de puntos o curvas que admite un boceto: 20000 de forma predeterminada, como mucho 200000. Insertar SVG e Insertar DXF rechazan un dibujo mayor. Un boceto grande se resuelve y se dibuja más despacio.",
"prefs.sketch.max_points_unit": "puntos",
"prefs.sketch.note": "Dibujo, ajuste y selección.",
"prefs.sketch.opening_closing": "Apertura y cierre",
"prefs.sketch.prefer_free_points": "Preferir puntos móviles donde se solapen",
"prefs.sketch.project_options": "Preguntar primero las opciones de Proyectar",
"prefs.sketch.recent_snap": "Ajustar a donde se cruzan dos guías",
"prefs.sketch.select_3d_curve_points": "Seleccionar puntos de curvas de boceto 3D",
"prefs.sketch.select_3d_curve_points_help": "Muestra y permite seleccionar puntos de las curvas de boceto 3D dibujadas como referencias de construcción.",
"prefs.sketch.select_other_hidden": "Seleccionar otro llega a la geometría oculta",
"prefs.sketch.shortcut_cycles_modes": "El atajo cambia de modo al pulsarlo otra vez",
"prefs.sketch.shortcut_cycles_modes_tip": "Desactivado: vuelve a iniciar la herramienta.",
"prefs.sketch.snap_to_visible_grid": "Ajustar a los cruces visibles de la cuadrícula",
"prefs.sketch.snapping_guides": "Ajuste y guías",
"prefs.sketch.state_icons": "Iconos de estado",
"prefs.sketch.tracking_guides": "Guías de centro y punto medio",
@@ -3034,7 +2943,6 @@
"prefs.sketch_selection": "Boceto y selección",
"prefs.sketch_selection_heading": "Boceto y selección",
"prefs.sketch_selection_note": "Cómo se ajustan los bocetos y cómo seleccionan los clics.",
"prefs.sketch_snap_to_visible_grid_note": "Un punto de un boceto se ajusta a los cruces de las líneas que dibuja la cuadrícula cuando el puntero apunta a uno, y si no, a la retícula más fina entre ellos. Desactivado: el punto se ajusta solo a la retícula más fina, como antes.",
"prefs.sketch_state_icons": "Iconos de boceto en el color de su estado",
"prefs.sketch_state_icons_note": "En Historial y el explorador, el icono de un boceto toma el color de su estado (bajo restricción, totalmente restringido o en conflicto) y su info muestra el estado. Desactivado: todos los bocetos tienen el mismo icono.",
"prefs.sketch_tab_always": "Mantener la pestaña Boceto visible",
@@ -3556,12 +3464,12 @@
"render.cpu_subsurface_denoise_reason": "La dispersión subsuperficial por CPU necesita la reducción de ruido desactivada. Se conservan tus ajustes.",
"render.cpu_subsurface_help": "La dispersión redirige la luz dentro del vidrio cerrado. Las tasas positivas necesitan iluminación indirecta con la reducción de ruido desactivada. La tasa es de colisiones por mm; 0 no añade dispersión. Un sesgo de fase positivo favorece las direcciones hacia delante; uno negativo, las hacia atrás.",
"render.cpu_summary": "Trazado CPU · reflejos · HDR · luces direccionales y puntuales",
"render.cpu_threads": "Workers de CPU",
"render.cpu_threads": "Procesos de CPU",
"render.cpu_threads_auto": "Automático",
"render.cpu_threads_custom": "Personalizado",
"render.cpu_threads_help": "Automático usa los workers de CPU disponibles. Un worker se ejecuta en serie. Más workers pueden necesitar más memoria.",
"render.cpu_threads_help": "Automático usa los procesos de CPU disponibles. Un proceso se ejecuta en serie. Más procesos pueden necesitar más memoria.",
"render.cpu_threads_single": "Uno",
"render.cpu_threads_web": "El renderizado en el navegador usa un worker cooperativo. Se conserva la elección de workers guardada para el escritorio.",
"render.cpu_threads_web": "El renderizado en el navegador usa un proceso cooperativo. Se conserva la elección de procesos guardada para el escritorio.",
"render.cpu_transparent_help": "El PNG transparente quita el fondo y conserva la cobertura de los bordes. La opacidad del cuerpo y de la cara debe mantenerse al 100 %.",
"render.cpu_volume_enable_path": "Activar iluminación indirecta",
"render.cpu_volume_enable_path_plan": "Ray tracing en CPU · profundidad {depth} · {spp} muestras/píxel · semilla {seed} · sombras trazadas. Conserva la atmósfera definida y el presupuesto de imagen.",
@@ -3736,7 +3644,6 @@
"render.error_pbr_opacity": "PBR opaco requiere opacidad 1",
"render.error_physical_glass": "la receta seleccionada no es vidrio físico",
"render.error_preview_device": "la previsualización de producto requiere un dispositivo instalado con búfer de profundidad",
"render.error_product_placement": "colocación de producto no válida",
"render.error_product_triangle": "triángulo de producto no válido",
"render.error_provisional_identity": "identidad provisional de render o cuenta de muestras no válida",
"render.error_quality_bounds": "la proyección de calidad supera los límites nativos",
@@ -5061,7 +4968,6 @@
"ui.agent_chat_allow_once": "Permitir una vez",
"ui.agent_chat_allowed": "Permitido",
"ui.agent_chat_answered": "Respuesta: {option}",
"ui.agent_chat_answered_in": "Respondido en {time}",
"ui.agent_chat_approval": "¿Permitir que el agente haga esto?",
"ui.agent_chat_attach_selection": "Adjuntar selección",
"ui.agent_chat_attach_selection_tip": "Envía con el mensaje lo que esté seleccionado en el modelo (caras, aristas, operaciones, cuerpos) para que el agente sepa a qué te refieres",
@@ -5090,7 +4996,6 @@
"ui.agent_chat_ctx_bodies": "Cuerpos",
"ui.agent_chat_ctx_elements": "Elementos",
"ui.agent_chat_ctx_features": "Operaciones",
"ui.agent_chat_ctx_gone": "Ya no está en el diseño: se ha cambiado o eliminado.",
"ui.agent_chat_ctx_more": "+{n} más",
"ui.agent_chat_ctx_other": "Otros",
"ui.agent_chat_custom_heading": "Modelo personalizado",
@@ -5104,7 +5009,6 @@
"ui.agent_chat_dock_right": "Acoplar a la derecha",
"ui.agent_chat_drop_hint": "Suelta para adjuntar a tu próximo mensaje",
"ui.agent_chat_edit": "Editar",
"ui.agent_chat_ended_after": "Finalizado tras {time}",
"ui.agent_chat_exited": "El agente se ha detenido.",
"ui.agent_chat_failed_start": "No se ha podido iniciar el agente: {error}",
"ui.agent_chat_file_cad_not_read": "{name}: un archivo CAD se importa al diseño, no se lee",
@@ -5142,7 +5046,6 @@
"ui.agent_chat_model_no_tools": "{model} no puede usar las herramientas de SolidCraft, así que solo puede chatear. Elige un modelo con soporte de herramientas.",
"ui.agent_chat_model_rejected": "El agente no aceptó {model}: {error}",
"ui.agent_chat_model_search": "Buscar modelos…",
"ui.agent_chat_model_search_or_id": "Busca o escribe cualquier ID de modelo…",
"ui.agent_chat_model_tip": "Modelo que utiliza el agente. Se recuerda tu elección para este agente.",
"ui.agent_chat_models_failed": "No se pudieron listar modelos: {error}",
"ui.agent_chat_models_heading": "Modelos disponibles",
@@ -5154,7 +5057,6 @@
"ui.agent_chat_next_picture": "Imagen siguiente",
"ui.agent_chat_no_cli": "No se ha encontrado solidcraft-cli junto a la aplicación, por lo que el agente no puede acceder al diseño.",
"ui.agent_chat_no_control": "No se ha iniciado el punto de conexión del chat del agente, por lo que no puede acceder al diseño.",
"ui.agent_chat_no_design_hint": "El agente se ejecuta sin las herramientas de SolidCraft, así que no puede leer ni cambiar el diseño: no se ha encontrado solidcraft-cli junto a la aplicación. Instala SolidCraft con solidcraft-cli al lado (en Windows, el MSI o el zip portátil) o define SOLIDCRAFT_CLI con su ruta, y vuelve a conectar.",
"ui.agent_chat_no_images": "Este agente no admite imágenes",
"ui.agent_chat_no_picture": "No hay ninguna imagen en el portapapeles",
"ui.agent_chat_no_tool_call": "El modelo no usó las herramientas de SolidCraft, así que el diseño no cambió. Elige un modelo que soporte llamada a herramientas; véase Modelos locales en la guía del chat del agente.",
@@ -5169,7 +5071,7 @@
"ui.agent_chat_permission": "El agente solicita ejecutar: {title}",
"ui.agent_chat_pick": "Agente",
"ui.agent_chat_pick_context": "Elegir contexto",
"ui.agent_chat_pick_context_tip": "Elige caras, aristas, operaciones y cuerpos del modelo y pulsa OK: se añaden al mensaje como una ficha donde está el cursor (escribir @ hace lo mismo)",
"ui.agent_chat_pick_context_tip": "Elige caras, aristas, operaciones y cuerpos del modelo para que el agente los examine y pulsa Aceptar",
"ui.agent_chat_placeholder": "Pide al agente que modifique el diseño…",
"ui.agent_chat_placeholder_busy": "Pon un mensaje en cola para cuando termine el agente (Ctrl+Intro: enviar ahora)",
"ui.agent_chat_pop_out": "Abrir en su propia ventana, que puedes mover a otra pantalla",
@@ -5202,25 +5104,16 @@
"ui.agent_chat_side": "Lado del chat del agente",
"ui.agent_chat_signed_in": "sesión iniciada",
"ui.agent_chat_snap_none_found": "No se ha encontrado ningún agente de programación. El paquete Snap está confinado, así que no puede ver ni iniciar los agentes de programación instalados en este equipo. Para usarlos, instala SolidCraft desde el paquete AppImage, .deb o .rpm. Un agente que use una API compatible con OpenAI también funciona en el paquete Snap: configúralo con Añadir agente.",
"ui.agent_chat_span_h": "{h} h {min} min",
"ui.agent_chat_span_min": "{n} min",
"ui.agent_chat_span_s": "{n} s",
"ui.agent_chat_state_auth": "Se requiere iniciar sesión",
"ui.agent_chat_state_busy": "Trabajando…",
"ui.agent_chat_state_exited": "Agente detenido",
"ui.agent_chat_state_failed": "Conexión fallida",
"ui.agent_chat_state_no_design": "Sin acceso al diseño",
"ui.agent_chat_state_off": "Sin conexión",
"ui.agent_chat_state_ready": "Listo",
"ui.agent_chat_state_starting": "Iniciando…",
"ui.agent_chat_stop": "Detener",
"ui.agent_chat_stop_tip": "Detener el agente y todo lo que haya iniciado",
"ui.agent_chat_stop_turn_tip": "Detener el agente (Esc). Los mensajes en cola permanecen en cola.",
"ui.agent_chat_thinking_for": "Pensando durante {time}",
"ui.agent_chat_tool_running_for": "en ejecución durante {time}",
"ui.agent_chat_tools_not_started": "El agente se ejecuta sin las herramientas de SolidCraft, así que no puede leer ni cambiar el diseño: se ha encontrado solidcraft-cli, pero sus herramientas no se han iniciado (el error del chat explica por qué). Vuelve a conectar; si sigue pasando, reinstala SolidCraft.",
"ui.agent_chat_transcript_assistant": "Asistente:",
"ui.agent_chat_transcript_user": "Usuario:",
"ui.agent_chat_turn_limit": "Cambios por mensaje",
"ui.agent_chat_turn_unlimited": "Sin límite",
"ui.agent_chat_undo_changes": "Deshacer estos cambios",
@@ -5409,7 +5302,6 @@
"ui.collapse": "Contraer",
"ui.collinear": "Colineal",
"ui.colour_vision": "Visión del color",
"ui.colour_vision_bends": "Pliegues",
"ui.colour_vision_deutan": "Deuteranopía",
"ui.colour_vision_deutan_tip": "Para personas con sensibilidad reducida o ausente al verde",
"ui.colour_vision_failed": "Fallido",
@@ -5439,24 +5331,14 @@
"ui.conic_curve": "Curva cónica",
"ui.constant": "Constante",
"ui.constraints_conflict": "Conflicto entre restricciones",
"ui.construct_auto.chosen": "Elegido a partir de tus selecciones. Elige otra referencia, o quita una, para cambiar el método.",
"ui.construct_auto.no_fit": "Elige otras referencias: ningún método se puede construir con estas.",
"ui.construct_auto.pick": "Elige con qué construirlo: el método se deduce de tus selecciones.",
"ui.construct_auto.refs": "Referencias",
"ui.construct_auto.select": "Selecciona primero las referencias con las que construir",
"ui.construct_axes": "Ejes",
"ui.construct_planes": "Planos",
"ui.construct_points": "Puntos",
"ui.construction": "Construcción",
"ui.construction.angle_hint": "Línea de construcción: haz clic en un punto, luego escribe un ángulo o elige una dirección",
"ui.construction.axis_hint": "Línea de construcción: haz clic en un punto o escribe su distancia al eje del boceto",
"ui.construction.bisector_hint": "Bisectriz: elige dos líneas rectas que se intersecten, luego la solución deseada",
"ui.construction.common_hint": "Tangente común: elige dos círculos/arcos, luego la solución deseada",
"ui.construction.contact_hint": "Tangente: elige un círculo/arco o spline/cónica abierta, luego el punto de contacto (libre)",
"ui.construction.external_hint": "Tangente: elige un círculo o arco, un punto externo, luego la solución deseada",
"ui.construction.free_hint": "Línea de construcción: haz clic en dos puntos; clic derecho para reiniciar",
"ui.construction.normal_needs_length": "Una línea infinita es siempre de construcción: escribe una longitud para dibujar una línea normal",
"ui.construction.parallel_hint": "Línea de construcción: elige una línea recta y luego haz clic o escribe el desfase; haz clic con el botón derecho para cambiar la referencia",
"ui.construction.point_hint": "Línea de construcción: haz clic en el punto por el que debe pasar",
"ui.construction.reference_hint": "Línea de construcción: elige una línea recta, luego un punto; clic derecho para cambiar la",
"ui.construction.ribbon_label": "X-Líneas",
@@ -5630,10 +5512,7 @@
"ui.explode_needs_two_components": "Vista explosada separa los componentes, así el diseño necesita dos o más (Nuevo componente, Insertar diseño o Crear componentes desde cuerpos)",
"ui.exportDxf": "Exportar DXF…",
"ui.export_ellipsis_keymap": "Exportar…",
"ui.export_glb": "Exportar GLB",
"ui.export_keymap_tip": "Exportar las teclas, los favoritos de la búsqueda S y la barra de herramientas como archivo",
"ui.export_selects_only_body": "Seleccionar el único cuerpo al exportar una malla",
"ui.export_selects_only_body_tip": "Exportar STL, Exportar 3MF y Enviar al laminador empiezan con el cuerpo del diseño seleccionado cuando solo hay uno.",
"ui.extend": "Extender",
"ui.extent": "Extensión",
"ui.extent_type": "Tipo de extensión",
@@ -5693,8 +5572,6 @@
"ui.finest": "Muy fina",
"ui.finish_sketch": "Finalizar boceto",
"ui.first_dimension_scales": "La primera cota escala el dibujo",
"ui.first_dimension_scales_about_origin": "Escalar respecto al origen del boceto",
"ui.first_dimension_scales_about_origin_tip": "Activado: el dibujo siempre crece o se encoge respecto al origen del boceto, así que una forma dibujada lejos del origen se mueve. Desactivado: un dibujo que nada ata al origen se escala respecto a su propio centro y se queda donde se dibujó.",
"ui.first_dimension_scales_tip": "Activado: la primera cota de longitud de un boceto escala el dibujo que mide, de modo que su forma no cambia. Desactivado: la cota solo dimensiona lo que mide.",
"ui.fit": "Ajustar",
"ui.fix_unfix": "Fijar/liberar",
@@ -5950,7 +5827,7 @@
"ui.origin": "Origen",
"ui.palette_construction_mode": "Modo de construcción (X)",
"ui.palette_more": "Más",
"ui.palette_sketch_options": "Opciones de boceto",
"ui.palette_sketch_options": "Opciones de croquis",
"ui.pan": "Desplazar",
"ui.pan_zoom_orbit": "Desplazar, zoom y orbitar",
"ui.panel_fixture.apply": "Aplicar",
@@ -6070,7 +5947,7 @@
"ui.pitch": "Inclinación",
"ui.placement": "Colocación",
"ui.placement_handles": "Tiradores de colocación",
"ui.placement_handles_tip": "Un marcador para arrastrar la operación por el modelo y, donde la operación puede girar, un anillo para girarla.",
"ui.placement_handles_tip": "Tiradores en la vista previa de un Refuerzo, Saliente o Encaje a presión: arrastra el marcador para moverlo por el modelo, gíralo por su anillo.",
"ui.plane": "Plano",
"ui.plane_at_angle": "Plano en ángulo",
"ui.plugins": "Complementos",
@@ -6107,7 +5984,7 @@
"ui.print_face_cleared": "{body}: cara de impresión limpiada",
"ui.print_face_input": "Cara de impresión",
"ui.print_face_pick": "Selecciona una cara plana de un cuerpo sólido para hacerla la cara de impresión",
"ui.print_face_set": "{body}: cara de impresión establecida; Enviar al laminador coloca el cuerpo sobre ella",
"ui.print_face_set": "{body}: cara de impresión establecida; Enviar a Laminador coloca el cuerpo sobre ella",
"ui.profile": "Perfil",
"ui.profile_plane": "Plano de perfil",
"ui.profiles": "Perfiles",
@@ -6151,14 +6028,11 @@
"ui.relink_design_changed": "El ensamblaje cambió mientras se elegía un origen. Inicia Revincular origen de nuevo.",
"ui.relink_done": "Origen revinculado en todas las instancias.",
"ui.relink_not_linked": "Este componente no está vinculado a un diseño de origen.",
"ui.relink_one_done": "Origen revinculado en esta instancia.",
"ui.relink_select_one": "Selecciona primero un componente vinculado o una de sus instancias.",
"ui.relink_source_all": "Revincular origen (todas las instancias)…",
"ui.relink_source_one": "Revincular origen…",
"ui.removeFromQuickAccess": "Quitar de Acceso rápido",
"ui.removeFromToolbar": "Quitar de la barra de herramientas",
"ui.removeSection": "Quitar sección",
"ui.renameSelectedBodies": "Cambiar nombre de los cuerpos seleccionados",
"ui.render": "Renderizar",
"ui.renderer": "Motor de renderizado",
"ui.repeat": "Repetir",
@@ -6250,16 +6124,6 @@
"ui.sketch_intersect_selection_filter": "Filtro de selección",
"ui.sketch_keys_only": "Mantener las teclas del modelo fuera de los bocetos",
"ui.sketch_keys_only_tip": "Mientras editas un boceto, solo se ejecutan las teclas de boceto y las que funcionan en todas partes; una tecla de modelo como E o F no hace nada, así que una tecla perdida no puede cerrar un boceto pesado.",
"ui.sketch_links_added_curve": "{command} ha añadido 1 curva al boceto.",
"ui.sketch_links_added_curves": "{command} ha añadido {count} curvas al boceto.",
"ui.sketch_links_added_curves_and_points": "{command} ha añadido {curves} y {points} al boceto.",
"ui.sketch_links_added_point": "{command} ha añadido 1 punto al boceto.",
"ui.sketch_links_added_points": "{command} ha añadido {count} puntos al boceto.",
"ui.sketch_links_curve_count": "{count} curvas",
"ui.sketch_links_curve_count_one": "1 curva",
"ui.sketch_links_hidden": "El resultado está oculto porque {option} está desactivado en la paleta del boceto.",
"ui.sketch_links_point_count": "{count} puntos",
"ui.sketch_links_point_count_one": "1 punto",
"ui.sketch_modifiers": "Modificadores del boceto",
"ui.sketch_project_geometry": "Geometría",
"ui.sketch_report.all_constrained": "Todo está totalmente restringido.",
@@ -6299,7 +6163,6 @@
"ui.step_values": "Incrementar valores con las flechas y la rueda",
"ui.step_values_tip": "En un campo de valor en el que estés escribiendo, Arriba/Abajo cambian un número simple de 1 en 1 (Mayús: 10, Alt: 0,1), y la rueda del ratón sobre el campo hace lo mismo. Las expresiones se dejan tal como se escriben.",
"ui.stitch_tolerance_hint": "estricta",
"ui.straighten": "Enderezar",
"ui.stretch": "Estirar",
"ui.supersampling": "Supermuestreo (SSAA)",
"ui.supersampling_adaptive": "Solo cuando la vista está inmóvil",
@@ -6373,8 +6236,6 @@
"ui.view_angles_yaw": "Rumbo",
"ui.view_angles_zero": "Poner {axis} a 0°",
"ui.view_centre": "Centro de la vista",
"ui.view_compass_tip": "Vista desde {axis}. Haz clic otra vez para invertir. Arrastra para orbitar.",
"ui.view_compass_tip_snap": "Vista desde {axis}. Haz clic otra vez para invertir. Arrastra para orbitar; mantén Mayús para ajustar.",
"ui.viewport_status_clear": "Desactivar {name}",
"ui.viewport_status_clear_hint": "Desactivar {name} · Esc",
"ui.viewport_status_edges": "{count} aristas",
+2 -2
View File
@@ -2581,7 +2581,7 @@
"prefs.design_tools.imported_files": "Tuodut tiedostot",
"prefs.design_tools.note": "Huomautus",
"prefs.design_tools.off": "Pois",
"prefs.design_tools.placement_group": "Sijoitettavat piirteet",
"prefs.design_tools.placement_group": "Tuki, Tappi, Napsautusliitos",
"prefs.design_tools.rows": "Rivit",
"prefs.devices": "3D-hiiri",
"prefs.dialog_buttons": "Valintaikkunan painikkeet",
@@ -5850,7 +5850,7 @@
"ui.pitch": "Nousu",
"ui.placement": "Sijoitus",
"ui.placement_handles": "Sijoituskahvat",
"ui.placement_handles_tip": "Merkki piirteen vetämiseen mallin yli ja, jos piirrettä voi kääntää, rengas sen kääntämiseen.",
"ui.placement_handles_tip": "Kahvat tuen, tapin tai napsautusliitoksen esikatselussa: siirrä merkkiä mallin pinnalla vetämällä ja käännä sitä renkaasta",
"ui.plane": "Taso",
"ui.plane_at_angle": "Taso kulmassa",
"ui.plugins": "Laajennukset",
+2 -2
View File
@@ -2343,7 +2343,7 @@
"prefs.design_tools.imported_files": "Fichiers importés",
"prefs.design_tools.note": "Remarque",
"prefs.design_tools.off": "Désactivé",
"prefs.design_tools.placement_group": "Fonctions placées",
"prefs.design_tools.placement_group": "Appui, Bossage, Clip",
"prefs.design_tools.rows": "Lignes",
"prefs.devices": "Souris 3D",
"prefs.dialog_buttons": "Boutons OK",
@@ -5287,7 +5287,7 @@
"ui.pitch": "Pas",
"ui.placement": "Placement",
"ui.placement_handles": "Poignées de placement",
"ui.placement_handles_tip": "Un repère pour faire glisser la fonction sur le modèle et, là où la fonction peut pivoter, un anneau pour la faire pivoter.",
"ui.placement_handles_tip": "Poignées sur l'aperçu d'un appui, d'un bossage ou d'un clip : faire glisser le repère pour le déplacer sur le modèle, le tourner par son anneau",
"ui.plane": "Plan",
"ui.plane_at_angle": "Plan incliné",
"ui.plugins": "Modules externes",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Paso",
"ui.placement": "Colocación",
"ui.placement_handles": "Asas de colocación",
"ui.placement_handles_tip": "Un marcador para arrastrar a operación polo modelo e, onde a operación pode xirar, un anel para xirala.",
"ui.placement_handles_tip": "Asas na vista previa dun Apoio, un Saínte ou un Encaixe a presión: arrastra o marcador para movelo polo modelo, xírao polo seu anel",
"ui.plane": "Plano",
"ui.plane_at_angle": "Plano en ángulo",
"ui.plugins": "Complementos",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "קבצים מיובאים",
"prefs.design_tools.note": "הערה",
"prefs.design_tools.off": "כבוי",
"prefs.design_tools.placement_group": "פיצ'רים ממוקמים",
"prefs.design_tools.placement_group": "משען, בליטה, חיבור נצמד",
"prefs.design_tools.rows": "שורות",
"prefs.devices": "עכבר תלת־ממדי",
"prefs.dialog_buttons": "לחצני תיבות דו־שיח",
@@ -6070,7 +6070,7 @@
"ui.pitch": "פסיעה",
"ui.placement": "מיקום",
"ui.placement_handles": "ידיות מיקום",
"ui.placement_handles_tip": "סמן לגרירת התכונה על המודל, וטבעת לסיבובה במקום שבו אפשר לסובב את התכונה.",
"ui.placement_handles_tip": "סמן לגרירת התכונה על המודל וטבעת לסיבובה.",
"ui.plane": "מישור",
"ui.plane_at_angle": "מישור בזווית",
"ui.plugins": "תוספים",
+1 -1
View File
@@ -4907,7 +4907,7 @@
"ui.pitch": "Korak",
"ui.placement": "Postavljanje",
"ui.placement_handles": "Ručice za postavljanje",
"ui.placement_handles_tip": "Oznaka za povlačenje značajke po modelu i, gdje se značajka može okretati, prsten za njezino okretanje.",
"ui.placement_handles_tip": "Ručice na pregledu oslonca, ispusta ili škljocajućeg spoja: povucite oznaku kako biste je pomaknuli po modelu, okrenite je njezinim prstenom",
"ui.plane": "Ravnina",
"ui.plane_at_angle": "Ravnina pod kutom",
"ui.plugins": "Proširenja",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Emelkedés",
"ui.placement": "Elhelyezés",
"ui.placement_handles": "Elhelyezési fogantyúk",
"ui.placement_handles_tip": "Egy jelölő az elem modellen való húzásához és, ahol az elem elforgatható, egy gyűrű az elforgatásához.",
"ui.placement_handles_tip": "Fogantyúk egy Támasz, Dudor vagy Pattintós illesztés előnézetén: húzza a jelölőt a modell fölött, a gyűrűjénél fogva forgassa el",
"ui.plane": "Sík",
"ui.plane_at_angle": "Szögben álló sík",
"ui.plugins": "Bővítmények",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Pitch",
"ui.placement": "Penempatan",
"ui.placement_handles": "Handle penempatan",
"ui.placement_handles_tip": "Penanda untuk menyeret fitur di atas model dan, jika fitur dapat diputar, cincin untuk memutarnya.",
"ui.placement_handles_tip": "Handle pada pratinjau Rest, Boss, atau Snap Fit: seret penanda untuk memindahkannya di model, putar lewat cincinnya",
"ui.plane": "Bidang",
"ui.plane_at_angle": "Bidang Bersudut",
"ui.plugins": "Plugin",
+2 -2
View File
@@ -2588,7 +2588,7 @@
"prefs.design_tools.imported_files": "File importati",
"prefs.design_tools.note": "Avvisa",
"prefs.design_tools.off": "Disattivato",
"prefs.design_tools.placement_group": "Lavorazioni posizionate",
"prefs.design_tools.placement_group": "Appoggio, Risalto, Aggancio a scatto",
"prefs.design_tools.rows": "Righe",
"prefs.devices": "Mouse 3D",
"prefs.dialog_buttons": "Pulsanti OK",
@@ -5873,7 +5873,7 @@
"ui.pitch": "Passo",
"ui.placement": "Posizionamento",
"ui.placement_handles": "Maniglie di posizionamento",
"ui.placement_handles_tip": "Un indicatore per trascinare la lavorazione sul modello e, dove la lavorazione può ruotare, un anello per ruotarla.",
"ui.placement_handles_tip": "Maniglie sull'anteprima di un Appoggio, Risalto o Aggancio a scatto: trascina il marcatore per spostarlo sul modello, ruotalo dal suo anello",
"ui.plane": "Piano",
"ui.plane_at_angle": "Piano inclinato",
"ui.plugins": "Plugin",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "インポートしたファイル",
"prefs.design_tools.note": "通知",
"prefs.design_tools.off": "オフ",
"prefs.design_tools.placement_group": "配置するフィーチャー",
"prefs.design_tools.placement_group": "レスト、ボス、スナップフィット",
"prefs.design_tools.rows": "行数",
"prefs.devices": "3D マウス",
"prefs.dialog_buttons": "ダイアログのボタン",
@@ -6070,7 +6070,7 @@
"ui.pitch": "ピッチ",
"ui.placement": "配置",
"ui.placement_handles": "配置ハンドル",
"ui.placement_handles_tip": "モデル上でフィーチャーをドラッグするマーカーと、フィーチャーを回転できる場合は回転させるリング。",
"ui.placement_handles_tip": "モデル上でフィーチャーをドラッグするマーカーと、回転させるリング。",
"ui.plane": "平面",
"ui.plane_at_angle": "傾斜平面",
"ui.plugins": "プラグイン",
+1 -1
View File
@@ -5073,7 +5073,7 @@
"ui.pitch": "ბიჯი",
"ui.placement": "განთავსება",
"ui.placement_handles": "განთავსების სახელურები",
"ui.placement_handles_tip": "მარკერი ოპერაციის მოდელზე გადასატანად და, სადაც ოპერაციის მობრუნება შეიძლება, რგოლი მის მოსაბრუნებლად.",
"ui.placement_handles_tip": "სახელურები საყრდენის, ბოსის ან ჩამკეტის გადახედვაზე: მარკერი გადაათრიეთ მოდელზე მის გადასაადგილებლად, ხოლო რგოლით შეაბრუნეთ",
"ui.plane": "სიბრტყე",
"ui.plane_at_angle": "სიბრტყე კუთხით",
"ui.plugins": "პლაგინები",
+2 -2
View File
@@ -1996,7 +1996,7 @@
"prefs.design_tools.imported_files": "가져온 파일",
"prefs.design_tools.note": "알림",
"prefs.design_tools.off": "끄기",
"prefs.design_tools.placement_group": "배치 피처",
"prefs.design_tools.placement_group": "레스트, 보스, 스냅 핏",
"prefs.design_tools.rows": "행",
"prefs.devices": "3D 마우스",
"prefs.dialog_buttons": "대화 상자 버튼",
@@ -5195,7 +5195,7 @@
"ui.pipe": "파이프",
"ui.pitch": "피치",
"ui.placement": "배치",
"ui.placement_handles_tip": "모델 위로 피처를 드래그하는 마커와, 피처를 회전할 수 있는 경우 회전하는 링입니다.",
"ui.placement_handles_tip": "모델 위로 피처를 드래그하는 마커와 회전하는 링입니다.",
"ui.plane": "평면",
"ui.plane_at_angle": "각도 평면",
"ui.plugins": "플러그인",
+2 -2
View File
@@ -2582,7 +2582,7 @@
"prefs.design_tools.imported_files": "Importētie faili",
"prefs.design_tools.note": "Piezīme",
"prefs.design_tools.off": "Izslēgts",
"prefs.design_tools.placement_group": "Izvietojamās operācijas",
"prefs.design_tools.placement_group": "Atbalsts, izcilnis, aizspiedsavienojums",
"prefs.design_tools.rows": "Rindas",
"prefs.devices": "3D pele",
"prefs.dialog_buttons": "Dialoglodziņu pogas",
@@ -5903,7 +5903,7 @@
"ui.pitch": "Solis",
"ui.placement": "Izvietojums",
"ui.placement_handles": "Izvietošanas turi",
"ui.placement_handles_tip": "Marķieris operācijas vilkšanai pa modeli un, kur operāciju var pagriezt, gredzens tās pagriešanai.",
"ui.placement_handles_tip": "Marķieris operācijas vilkšanai pa modeli un gredzens tās pagriešanai.",
"ui.plane": "Plakne",
"ui.plane_at_angle": "Plakne leņķī",
"ui.plugins": "Spraudņi",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Pic",
"ui.placement": "Penempatan",
"ui.placement_handles": "Pemegang penempatan",
"ui.placement_handles_tip": "Penanda untuk menyeret ciri merentasi model dan, jika ciri boleh dipusingkan, gelang untuk memusingkannya.",
"ui.placement_handles_tip": "Pemegang pada pratonton Penyokong, Bonggol atau Sambungan Sentap: seret penanda untuk menggerakkannya merentasi model, putarkannya melalui cincinnya",
"ui.plane": "Satah",
"ui.plane_at_angle": "Satah Bersudut",
"ui.plugins": "Pemalam",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Stigning",
"ui.placement": "Plassering",
"ui.placement_handles": "Plasseringshåndtak",
"ui.placement_handles_tip": "En markør for å dra funksjonen over modellen og, der funksjonen kan dreies, en ring for å dreie den.",
"ui.placement_handles_tip": "Håndtak på forhåndsvisningen av et anlegg, skruefeste eller snap-fit: dra markøren for å flytte den over modellen, drei den med ringen",
"ui.plane": "Plan",
"ui.plane_at_angle": "Plan i vinkel",
"ui.plugins": "Programtillegg",
+1 -1
View File
@@ -4929,7 +4929,7 @@
"ui.pitch": "Steek",
"ui.placement": "Plaatsing",
"ui.placement_handles": "Plaatsingsgrepen",
"ui.placement_handles_tip": "Een markering om het kenmerk over het model te slepen en, waar het kenmerk kan draaien, een ring om het te draaien.",
"ui.placement_handles_tip": "Grepen op het voorbeeld van een Rustvlak, Nok of Snapverbinding: sleep de markering om hem over het model te verplaatsen, draai hem aan de ring",
"ui.plane": "Vlak",
"ui.plane_at_angle": "Vlak onder hoek",
"ui.plugins": "Plug-ins",
@@ -1,42 +0,0 @@
{
"en": {
"prefs.keep_print_faces": "Keep print faces",
"prefs.keep_print_faces_tip": "On: the print face of each body is tinted in the view at all times and saved in the design. Off: it is tinted only while Send to Slicer is open, and designs are saved and opened without print faces."
},
"ar": {
"prefs.keep_print_faces": "الاحتفاظ بأوجه الطباعة",
"prefs.keep_print_faces_tip": "تشغيل: يُلوَّن وجه الطباعة لكل جسم في العرض دائمًا ويُحفظ في التصميم. إيقاف: يُلوَّن فقط أثناء فتح نافذة إرسال إلى برنامج التقطيع، وتُحفظ التصاميم وتُفتح بدون أوجه الطباعة."
},
"de": {
"prefs.keep_print_faces": "Auflageflächen behalten",
"prefs.keep_print_faces_tip": "Ein: Die Auflagefläche jedes Körpers wird in der Ansicht immer eingefärbt und in der Konstruktion gespeichert. Aus: Sie wird nur eingefärbt, solange An Slicer senden geöffnet ist, und Konstruktionen werden ohne Auflageflächen gespeichert und geöffnet."
},
"es": {
"prefs.keep_print_faces": "Conservar caras de impresión",
"prefs.keep_print_faces_tip": "Activado: la cara de impresión de cada cuerpo se tiñe siempre en la vista y se guarda en el diseño. Desactivado: solo se tiñe mientras Enviar al laminador está abierto, y los diseños se guardan y se abren sin caras de impresión."
},
"fr": {
"prefs.keep_print_faces": "Conserver les faces d’impression",
"prefs.keep_print_faces_tip": "Activé : la face d’impression de chaque corps est toujours teintée dans la vue et enregistrée dans la conception. Désactivé : elle n'est teintée que tant qu'Envoyer au slicer est ouvert, et les conceptions sont enregistrées et ouvertes sans faces d’impression."
},
"he": {
"prefs.keep_print_faces": "שמירת פאות הדפסה",
"prefs.keep_print_faces_tip": "מופעל: פאת ההדפסה של כל גוף נצבעת בתצוגה תמיד ונשמרת בתכנון. כבוי: היא נצבעת רק כל עוד חלון שליחה לסלייסר פתוח, ותכנונים נשמרים ונפתחים ללא פאות הדפסה."
},
"ja": {
"prefs.keep_print_faces": "造形面を保持",
"prefs.keep_print_faces_tip": "オン: 各ボディの造形面をビューで常に色付けし、デザインに保存します。オフ: 「スライサーに送る」が開いている間だけ色付けし、デザインは造形面なしで保存・オープンされます。"
},
"ko": {
"prefs.keep_print_faces": "출력 바닥면 유지",
"prefs.keep_print_faces_tip": "켜기: 각 바디의 출력 바닥면을 뷰에 항상 색으로 표시하고 디자인에 저장합니다. 끄기: 슬라이서로 보내기가 열려 있는 동안만 표시하며, 디자인은 출력 바닥면 없이 저장되고 열립니다."
},
"vi": {
"prefs.keep_print_faces": "Giữ mặt in",
"prefs.keep_print_faces_tip": "Bật: mặt in của mỗi khối luôn được tô màu trong khung nhìn và được lưu trong thiết kế. Tắt: mặt in chỉ được tô màu khi cửa sổ Gửi sang phần mềm cắt lớp (Slicer) đang mở, và thiết kế được lưu và mở mà không có mặt in."
},
"zh": {
"prefs.keep_print_faces": "保留打印底面",
"prefs.keep_print_faces_tip": "开启:每个实体的打印底面始终在视图中着色,并保存在设计中。关闭:仅在“发送到切片软件”打开时着色,设计保存和打开时不含打印底面。"
}
}
@@ -1,26 +0,0 @@
{
"en": {
"operations.failed": "Failed"
},
"de": {
"operations.failed": "Fehlgeschlagen"
},
"ja": {
"operations.failed": "失敗"
},
"ko": {
"operations.failed": "실패"
},
"zh": {
"operations.failed": "失败"
},
"ar": {
"operations.failed": "فشل"
},
"he": {
"operations.failed": "נכשל"
},
"vi": {
"operations.failed": "Thất bại"
}
}
+20 -320
View File
@@ -20,7 +20,7 @@
"ABS - White": "ABS – biały",
"ALIGN": "WYRÓWNAJ",
"ARRANGE": "UKŁADANIE",
"AS-BUILT JOINT": "ZŁĄCZE W BIEŻĄCYM POŁOŻENIU",
"AS-BUILT JOINT": "ZŁĄCZE W STANIE ZASTANYM",
"ASSIGN PLASTIC RULE": "PRZYPISZ REGUŁĘ TWORZYWA",
"AXIS PERPENDICULAR TO FACE": "OŚ PROSTOPADŁA DO ŚCIANY",
"AXIS THROUGH CYLINDER": "OŚ PRZEZ WALEC",
@@ -75,7 +75,7 @@
"Arc length": "Długość łuku",
"Area": "Pole powierzchni",
"As Picked": "Jak wybrano",
"As-Built": "W bieżącym położeniu",
"As-Built": "W stanie zastanym",
"Assign Rule": "Przypisz regułę",
"Attachment": "Zaczepienie",
"BLEND SURFACE": "POWIERZCHNIA PRZEJŚCIOWA",
@@ -266,7 +266,6 @@
"Edge": "Krawędź",
"Edges": "Krawędzie",
"Edit": "Edytuj",
"Edit Holes": "Edytuj otwory",
"Edit Profile Sketch": "Edytuj szkic profilu",
"Edit shortcuts": "Edytuj skróty",
"Enable rule": "Włącz regułę",
@@ -514,7 +513,6 @@
"Offset Y": "Odsunięcie Y",
"Offset Z": "Odsunięcie Z",
"On your computer": "Na tym komputerze",
"One more stretch of the picked edge, each with its own angle, height and turns": "Kolejny odcinek wybranej krawędzi, każdy z własnym kątem, wysokością i stroną gięcia",
"Opacity": "Nieprzezroczystość",
"Open GitHub": "Otwórz GitHub",
"Open GitHub draft": "Otwórz szkic zgłoszenia na GitHubie",
@@ -636,7 +634,6 @@
"Remove the track": "Usuń ścieżkę",
"Remove this bend": "Usuń to gięcie",
"Remove this point": "Usuń ten punkt",
"Remove this segment": "Usuń ten odcinek",
"Rename Feature": "Zmień nazwę operacji",
"Rename Folder": "Zmień nazwę folderu",
"Render Stage": "Scenografia renderowania",
@@ -695,7 +692,7 @@
"SNAP FIT": "ZATRZASK",
"SPLIT FACE": "PODZIEL ŚCIANĘ",
"STITCH": "ZSZYJ",
"STOCK": "PÓŁFABRYKAT",
"STOCK": "NADDATEK",
"SURFACE EXTRUDE": "WYCIĄGNIĘCIE POWIERZCHNI",
"SURFACE LOFT": "WYCIĄGNIĘCIE ZŁOŻONE POWIERZCHNI",
"SURFACE QUALITY": "JAKOŚĆ POWIERZCHNI",
@@ -726,7 +723,6 @@
"Section position": "Położenie przekroju",
"Sections": "Przekroje",
"Sections (one sketch each)": "Przekroje (każdy w osobnym szkicu)",
"Segments": "Odcinki",
"Select Features": "Zaznacz operacje",
"Select Other": "Wybierz inny",
"Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Wybierz inny: przytrzymaj nieruchomo lewy przycisk na modelu (lub prawy przycisk › Wybierz inny…), aby wyświetlić wszystko pod kursorem; kliknij ponownie w tym samym miejscu, aby zaznaczyć kolejny obiekt. Dwukrotne kliknięcie środkowym przyciskiem przechodzi do widoku głównego (Preferencje › Nawigacja).",
@@ -776,7 +772,6 @@
"Spacing must be a non-zero number": "Odstęp musi być liczbą różną od zera",
"Specialise": "Uszczegółów",
"Speed": "Prędkość",
"Split Edge": "Podziel krawędź",
"Splitting tool": "Narzędzie podziału",
"Square": "Kwadrat",
"Stainless Steel": "Stal nierdzewna",
@@ -944,13 +939,6 @@
"ai.prefs.connect_apps_note": "Przy uruchomieniu SolidCraft dodaje go jako serwer MCP do aplikacji AI znalezionych na tym komputerze (OpenCode, Claude Desktop, Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed, VS Code), aby agent mógł go używać bez konfiguracji. Dodawany jest tylko wpis solidcraft lub odświeżana jego ścieżka; zmieniony przez ciebie wpis pozostaje nietknięty, a plik jest najpierw kopiowany. Wyłączone: dodane wpisy są usuwane. Pomoc > Połącz asystentów AI… robi to ręcznie.",
"ai.prefs.connect_at_start": "Łącz ostatnio używanego agenta przy uruchomieniu SolidCraft",
"ai.prefs.connect_at_start_note": "Przy każdym uruchomieniu SolidCraft uruchamia w tle ostatnio połączonego agenta (lub domyślnego), aby był gotowy do rozmowy. Nie działa, gdy SolidCraft jest uruchomiony z --control. Jeśli nie uda się połączyć, Asystent pokaże komunikat „Połączenie nieudane” i przycisk Ponów.",
"ai.prefs.control_failed": "Nie udało się uruchomić kanału sterowania: {error}",
"ai.prefs.control_from_launch": "Uruchomiono z --control na porcie {port} na czas tej sesji. Zmiany tutaj zadziałają przy następnym uruchomieniu SolidCraft bez --control.",
"ai.prefs.control_listening": "Nasłuchuje na 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "Nie nasłuchuje",
"ai.prefs.control_port": "Port",
"ai.prefs.control_port_note": "Port, z którym łączą się agenty AI na tym komputerze (127.0.0.1). Domyślny to 27878: gdy jest zajęty, używany jest następny wolny port. Każdy inny port jest używany bez zmian. Ustaw ten sam port w kliencie MCP, np. solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_port_taken": "Port {port} jest zajęty przez inny program (lub inne okno SolidCraft). Wybierz inny port.",
"ai.prefs.follow_camera": "Obracaj kamerę do szkiców",
"ai.prefs.follow_camera_off": "Automatyczne podążanie kamery jest wyłączone.",
"ai.prefs.follow_camera_on": "Kamera obraca się przodem do szkiców tworzonych przez agenta.",
@@ -990,12 +978,11 @@
"browser.calibrate_click_a": "Kliknij obraz, aby wskazać punkt A, lub wpisz jego współrzędne.",
"browser.calibrate_click_b": "Kliknij obraz, aby wskazać punkt B, lub wpisz jego współrzędne.",
"browser.calibrate_title": "Kalibruj: {name}",
"browser.canvas_locked": "Płótno jest zablokowane. Najpierw je odblokuj.",
"browser.capture_many": "Przesunięto komponenty ({count}), ale ich położenia nie zostały zapamiętane.",
"browser.capture_note": "Zapamiętaj położenie zachowuje nowe położenie, a Przywróć położenie wraca do poprzedniego.",
"browser.capture_one": "Przesunięto komponent, ale jego położenie nie zostało zapamiętane.",
"browser.component_name": "Komponent{number}",
"browser.edit_canvas_title": "Edytuj płótno: {name}",
"browser.edit_canvas_title": "Edytuj kanwę: {name}",
"browser.edit_point_cloud_title": "Edytuj chmurę punktów: {name}",
"browser.move_title": "Przesuń: {name}",
"browser.needs_repair": "{name}: wymaga naprawy",
@@ -1011,13 +998,6 @@
"browser.sketches_to_parts_not_on_face": "nie na ścianie części",
"browser.sketches_to_parts_part_missing": "ich część już nie istnieje",
"browser.sketches_to_parts_result": "Przeniesione szkice do części, na których leżą: {moved}; pozostały: {stayed}: {why}",
"browser.straighten_angle": "Kierunek krawędzi",
"browser.straighten_choose": "Wybierz dla krawędzi opcję Pozioma, Pionowa lub Kąt, a potem naciśnij Wyprostuj.",
"browser.straighten_click_a": "Kliknij obraz na jednym końcu krawędzi, która powinna być prosta, lub wpisz współrzędne tego końca.",
"browser.straighten_click_b": "Kliknij drugi koniec krawędzi lub wpisz jego współrzędne.",
"browser.straighten_horizontal": "Pozioma",
"browser.straighten_title": "Wyprostuj: {name}",
"browser.straighten_vertical": "Pionowa",
"browser.units": "Jednostki: {units}",
"cam.flat": "Płaska ściana",
"cam.follower": "Popychacz",
@@ -1047,8 +1027,6 @@
"cmd.appearance_pick_up": "Pobierz wygląd",
"cmd.appearance_replace": "Zastąp wygląd",
"cmd.assembly_bom": "Zestawienie materiałowe",
"cmd.assembly_constraint_create": "Wiązanie",
"cmd.assembly_constraint_edit": "Edytuj wiązanie",
"cmd.assembly_mass_properties": "Właściwości masowe",
"cmd.body.print_face": "Ściana podstawy druku",
"cmd.body_rename": "Zmień nazwę bryły",
@@ -1067,7 +1045,6 @@
"cmd.canvas_delete": "Usuń płótno",
"cmd.canvas_edit": "Edytuj płótno",
"cmd.canvas_insert": "Płótno",
"cmd.canvas_straighten": "Wyprostuj płótno",
"cmd.canvas_to_sketch": "Szkic z płótna",
"cmd.component_activate": "Aktywuj komponent",
"cmd.component_break_link": "Zerwij łącze",
@@ -1109,10 +1086,7 @@
"cmd.config_rule": "Reguła konfiguracji",
"cmd.config_rules": "Reguły konfiguracji",
"cmd.config_table": "Pokaż tabelę konfiguracji",
"cmd.construct.axis": "Oś",
"cmd.construct.plane": "Płaszczyzna",
"cmd.construct.plane.axis_point": "Płaszczyzna przez oś i punkt",
"cmd.construct.point": "Punkt",
"cmd.construct_axis_edge": "Oś przez krawędź",
"cmd.construct_axis_normal_to_face": "Oś prostopadła do ściany",
"cmd.construct_axis_through_cylinder": "Oś przez walec/stożek/torus",
@@ -1175,7 +1149,6 @@
"cmd.file_save_mesh": "Zapisz jako siatkę",
"cmd.file_send_to_slicer": "Wyślij do slicera",
"cmd.file_slicers": "Zainstalowane slicery",
"cmd.frame_end_cap": "Zaślepka",
"cmd.frame_member": "Element ramy",
"cmd.frame_profile_insert": "Wstaw profil",
"cmd.frame_profile_list": "Biblioteka profili",
@@ -1196,7 +1169,6 @@
"cmd.inspect_isocurve": "Analiza izokrzywych",
"cmd.inspect_minimum_radius": "Analiza promienia minimalnego",
"cmd.inspect_parameter_sweep": "Przegląd wartości parametru",
"cmd.inspect_parameter_variants": "Porównaj warianty parametrów",
"cmd.inspect_silhouette": "Sylwetka",
"cmd.inspect_zebra": "Analiza zebry",
"cmd.joint_as_built": "Złącze w bieżącym położeniu",
@@ -1235,7 +1207,6 @@
"cmd.material_properties_directional": "Kierunkowy moduł sprężystości",
"cmd.material_properties_validate": "Sprawdź dane materiału fizycznego",
"cmd.mesh_convert": "Konwertuj siatkę",
"cmd.model_body_features": "Lista operacji bryły",
"cmd.model_edges": "Lista krawędzi",
"cmd.model_faces": "Lista ścian",
"cmd.model_threads": "Lista gwintów",
@@ -1250,10 +1221,6 @@
"cmd.motion_trace": "Ślad ruchu",
"cmd.motion_trace_edit": "Edytuj ślad ruchu",
"cmd.motion_trace_keep": "Zachowaj ślad ruchu",
"cmd.note_add": "Dodaj notatkę",
"cmd.note_delete": "Usuń notatkę",
"cmd.note_list": "Lista notatek",
"cmd.note_update": "Edytuj notatkę",
"cmd.occurrence_align": "Wyrównaj wystąpienie",
"cmd.occurrence_copy": "Wklej (wystąpienie)",
"cmd.occurrence_drag": "Przeciągnij wystąpienie",
@@ -1268,11 +1235,8 @@
"cmd.panel.dismiss": "Zamknij podgląd panelu",
"cmd.panel.float": "Odepnij panel",
"cmd.panel.focus": "Ustaw fokus na panelu",
"cmd.panel.focus.next": "Ustaw fokus na następnym panelu",
"cmd.panel.focus.previous": "Ustaw fokus na poprzednim panelu",
"cmd.panel.list": "Lista paneli",
"cmd.panel.move": "Przenieś panel",
"cmd.panel.move.next_zone": "Przenieś panel do następnej strefy",
"cmd.panel.native_close": "Zadokuj ponownie sekcję panelu",
"cmd.panel.open": "Otwórz panel",
"cmd.panel.peek": "Podejrzyj zwinięty panel",
@@ -1280,12 +1244,6 @@
"cmd.panel.reset_pair": "Resetuj podział paneli",
"cmd.panel.resize": "Zmień rozmiar sekcji panelu",
"cmd.panel.resize_pair": "Zmień podział paneli",
"cmd.panel.sash.down": "Przesuń podział paneli w dół",
"cmd.panel.sash.left": "Przesuń podział paneli w lewo",
"cmd.panel.sash.reset": "Resetuj podział panelu z fokusem",
"cmd.panel.sash.right": "Przesuń podział paneli w prawo",
"cmd.panel.sash.up": "Przesuń podział paneli w górę",
"cmd.panel.toggle": "Przełącz panel z fokusem",
"cmd.parameters_add": "Dodaj parametr użytkownika",
"cmd.parameters_change": "Zmień parametry",
"cmd.parameters_comment": "Komentarz parametru",
@@ -1379,7 +1337,6 @@
"cmd.render_start": "Renderuj obraz",
"cmd.render_status": "Postęp renderowania",
"cmd.select_clear": "Wyczyść zaznaczenie",
"cmd.selection_get": "Pobierz zaznaczenie",
"cmd.settings_profile_apply": "Zastosuj profil ustawień",
"cmd.settings_profile_delete": "Usuń profil ustawień",
"cmd.settings_profile_export": "Eksportuj profil ustawień",
@@ -1400,17 +1357,16 @@
"cmd.sheet_refold": "Zegnij ponownie",
"cmd.sheet_rules": "Lista reguł blach",
"cmd.sheet_unfold": "Rozegnij",
"cmd.sketch.construction_line": "Linia konstrukcyjna",
"cmd.sketch.construction_line.angle": "Linia konstrukcyjna pod kątem",
"cmd.sketch.construction_line": "Prosta konstrukcyjna",
"cmd.sketch.construction_line.angle": "Prosta konstrukcyjna pod kątem",
"cmd.sketch.construction_line.bisector": "Dwusieczna kąta",
"cmd.sketch.construction_line.horizontal": "Pozioma linia konstrukcyjna",
"cmd.sketch.construction_line.parallel": "Równoległa linia konstrukcyjna",
"cmd.sketch.construction_line.perpendicular": "Prostopadła linia konstrukcyjna",
"cmd.sketch.construction_line.horizontal": "Pozioma prosta konstrukcyjna",
"cmd.sketch.construction_line.parallel": "Równoległa prosta konstrukcyjna",
"cmd.sketch.construction_line.perpendicular": "Prostopadła prosta konstrukcyjna",
"cmd.sketch.construction_line.tangent_at": "Styczna w punkcie krzywej",
"cmd.sketch.construction_line.tangent_external": "Styczna przez punkt zewnętrzny",
"cmd.sketch.construction_line.tangent_two": "Wspólna styczna",
"cmd.sketch.construction_line.vertical": "Pionowa linia konstrukcyjna",
"cmd.sketch.replace": "Zastąp szkic",
"cmd.sketch.construction_line.vertical": "Pionowa prosta konstrukcyjna",
"cmd.sketch_arc3d": "Łuk 3D",
"cmd.sketch_arc_center_point": "Łuk ze środka",
"cmd.sketch_arc_tangent": "Łuk styczny",
@@ -1542,8 +1498,6 @@
"cmd.timeline_stop": "Zatrzymaj historię",
"cmd.timeline_suppress": "Wygaś operację",
"cmd.ui.assistant": "Przełącz Asystenta",
"cmd.ui.palette": "Szukaj poleceń",
"cmd.ui.toggle_visibility": "Pokaż/ukryj zaznaczenie",
"cmd.view.navigator": "Własny nawigator widoku",
"cmd.view.render_browser": "Przeglądarka renderowania",
"cmd.view.render_dock": "Przenieś dok renderowania",
@@ -1601,7 +1555,6 @@
"color.step_down": "Zmniejsz",
"color.step_up": "Zwiększ",
"color.value": "Jasność (procent)",
"component.error.ambiguous_name": "Kilka komponentów ma nazwę `{name}`. Użyj liczbowego identyfikatora komponentu: {ids}.",
"config.column_filter_empty": "Brak pasujących kolumn",
"config.rule_parameter_columns": "Wymienione są tylko parametry mające kolumnę w tabeli. Dodaj ją przyciskiem Dodaj kolumnę (+), aby użyć jej tutaj.",
"config.rule_suppression_columns": "Wymienione są tylko operacje mające w tabeli kolumnę Wygaś. Dodaj ją przyciskiem Dodaj kolumnę (+), aby nią tutaj sterować.",
@@ -1638,11 +1591,10 @@
"crash.report": "Zgłoś…",
"crash.unclean_explanation": "Wykryto przy późniejszym uruchomieniu; dokładny czas zamknięcia nie został zapisany. Nieprawidłowe zamknięcie może też wynikać z wyłączenia systemu lub wymuszonego zatrzymania.",
"delete.and_more": "… i jeszcze {count}",
"diagnostic.empty_boolean_component": "w komponencie '{component}' nie ma bryły do wycięcia lub przecięcia; narzędzie może nachodzić na '{body}' w komponencie '{other}' – aktywuj ten komponent lub wybierz tę bryłę jako cel",
"diagnostic.empty_boolean_component": "w komponencie '{component}' nie ma bryły do wycięcia lub przycięcia; narzędzie może nachodzić na '{body}' w komponencie '{other}' – aktywuj ten komponent lub wybierz tę bryłę jako cel",
"diagnostic.reselect_reference": "{feature}: odniesienie `{reference}` już nie istnieje w postaci, w jakiej je wybrano; wybierz je ponownie",
"dialog.bodies_count": "Bryły: {count}",
"dialog.clearance_invalid": "luz musi być długością równą 0 lub większą",
"dialog.component_origin": "{placement} · {item}",
"dialog.compute": "Oblicz",
"dialog.compute_again": "Oblicz ponownie",
"dialog.computing": "Obliczanie… {seconds} s",
@@ -1655,7 +1607,6 @@
"dialog.dimension_step_expression": "Zmieniać można tylko liczbę; wyrażenie pozostaje tak, jak je wpisano",
"dialog.dimension_step_up": "W górę o {step}",
"dialog.edit_title": "EDYCJA: {title}",
"dialog.hole_held_off_its_line": "Krawędź odniesienia trzyma ten otwór na linii, która opuszcza ścianę przed wskazanym punktem. Wskaż miejsce, w którym linia przecina ścianę, albo usuń krawędź, aby swobodnie przesuwać otwór.",
"dialog.no_shown_body": "Brak widocznej bryły do zmiany: pokaż którąś lub wybierz opcję Nowa bryła",
"dialog.pattern_sketch_not_object": "Wybierz bryły lub operacje: szkic nie jest ani jednym, ani drugim. Powiel jego krzywe własnym szykiem lub lustrem szkicu albo najpierw go wyciągnij.",
"dialog.primitive_point_tip": "Punkt, który wypełnia X, Y i Z: kliknij tutaj, a potem kliknij wierzchołek, krawędź lub ścianę modelu, punkt szkicu albo początek układu.",
@@ -1674,12 +1625,9 @@
"document.changed_on_disk": "{name} zmienił się na dysku",
"document.changed_on_disk_detail": "{name} został zmieniony przez inny program lub komputer od chwili otwarcia lub ostatniego zapisu. Nadpisz zastępuje te zmiany twoimi, a Zapisz kopię zachowuje obie wersje.",
"document.copy_name": "{name} — kopia",
"document.discard_row": "Odrzuć",
"document.finish_save_prompt": "Zanim otworzysz kolejny projekt, odpowiedz na pytanie o zapisanie.",
"document.newer_file": "Zapisano w nowszym SolidCraft (format {format}, ta wersja czyta do {reads}). Jeśli czegoś brakuje, zaktualizuj program.",
"document.newer_file_change": "Zapisano w nowszym SolidCraft (format {format} ze zmianą `{change}`, której ta wersja nie zna). Jeśli czegoś brakuje, zaktualizuj program.",
"document.newer_file_not_read": "Nie odczytano: {what}. Zapisanie to zachowa.",
"document.not_saved_yet": "Jeszcze nie zapisano",
"document.open_anyway": "Otwórz mimo to",
"document.open_elsewhere": "{name} jest już otwarty",
"document.open_elsewhere_detail": "{name} jest otwarty w SolidCraft {app} na {who}, otwarty {when}. Jeśli oboje zapiszecie, ktoś straci swoją pracę. Otwórz go tylko do odczytu, aby go obejrzeć bez nadpisywania.",
@@ -1689,9 +1637,6 @@
"document.read_only_save": "Ten projekt otwarto tylko do odczytu: zapisz go jako nowy plik",
"document.save_changes": "Zapisać zmiany w {name}?",
"document.save_copy": "Zapisz kopię…",
"document.save_many": "Zapisać zmiany w projektach ({n})?",
"document.save_row": "Zapisz",
"document.save_selected": "Zapisz zaznaczone",
"document.tab_unsaved_changes": "Niezapisane zmiany",
"document.unsaved_changes": "{name} ma niezapisane zmiany. Jeśli ich nie zapiszesz, zostaną utracone.",
"drive.rigid": "{joint} to złącze sztywne: utrzymuje swoje części nieruchomo, więc nie ma czym sterować. Aby mogły się poruszać, zmień jego typ w oknie Edytuj złącze: Obrotowe do obrotu, Przesuwne do przesuwu, Walcowe do obu.",
@@ -1711,8 +1656,6 @@
"dxf.tolerance": "Tolerancja krzywych",
"dxf.tolerance_help": "Tylko widoczne bryły i pokazane szkice. Krzywe są przybliżane odcinkami. Mniejsza tolerancja daje dokładniejsze krzywe, ale zajmuje więcej czasu i pamięci.",
"each rail's curves must be in one sketch": "krzywe każdej prowadnicy muszą leżeć w jednym szkicu",
"error.end_cap_faces": "Wybierz co najmniej jedną otwartą ścianę końcową elementu ramy",
"error.end_cap_unresolved": "Nie ma już w modelu końców tej zaślepki (nr {ends}). Wybierz ponownie ściany końcowe",
"feature computation ended without a result": "obliczanie operacji zakończyło się bez wyniku",
"file.open.drawing_filter": "Projekty, pliki CAD, siatki i rysunki",
"file.open.portal_fallback.no_portal_service": "Otwarte okno wyboru pliku nie pochodzi z pulpitu: usługa portalu pulpitu jest niedostępna.",
@@ -1724,7 +1667,6 @@
"file_load.inserting": "Wstawianie geometrii",
"file_load.link_item": "{name} ({done} z {total})",
"file_load.opened": "Otwarto {name}",
"file_load.opened_behind": "Otwarto {name} w nowej karcie",
"file_load.opened_in_tab": "Otwarto {name} w nowej karcie",
"file_load.opening": "Otwieranie projektu",
"file_load.preparing_thumbnail": "Przygotowywanie miniatury…",
@@ -1772,13 +1714,11 @@
"help.config.table": "Pokazuje tabelę konfiguracji: jeden wiersz na wariant, jedną kolumnę na parametr lub operację oraz to, czy projekt nadal odpowiada aktywnemu wierszowi. Służy do przeglądu wariantów projektu.",
"help.connect_note": "Uruchom ponownie każdego asystenta, aby wczytał serwer SolidCraft. Przed zmianą każdy plik zapisano jako kopię .bak.",
"help.connect_title": "Połącz asystentów AI",
"help.construct.axis": "Tworzy oś konstrukcyjną metodą wynikającą z wybranych elementów: wzdłuż krawędzi lub linii, przez otwór lub okrągłą ścianę, na przecięciu dwóch płaszczyzn, przez dwa punkty albo prostopadle do ściany przez punkt. Służy wtedy, gdy znasz elementy odniesienia, a nie metodę.",
"help.construct.axis.edge": "Tworzy oś konstrukcyjną wzdłuż prostej krawędzi lub linii szkicu. Służy jako oś obrotu, szyku kołowego lub złącza.",
"help.construct.axis.normal_to_face": "Tworzy oś konstrukcyjną przez punkt, prostopadłą do ściany. Daje oś wychodzącą prostopadle z powierzchni.",
"help.construct.axis.through_cylinder": "Tworzy oś konstrukcyjną przez środek ściany walcowej, stożkowej lub torusa albo okrągłej krawędzi. Służy do obracania lub powielania operacji wokół okrągłej części.",
"help.construct.axis.two_planes": "Tworzy oś konstrukcyjną w miejscu spotkania dwóch płaszczyzn lub płaskich ścian. Użyj, gdy potrzebna oś to linia między dwiema ścianami.",
"help.construct.axis.two_points": "Tworzy oś konstrukcyjną przez dwa punkty, wierzchołki lub punkty szkicu. Przydaje się, gdy oś ma biec między dwoma znanymi położeniami.",
"help.construct.plane": "Tworzy płaszczyznę konstrukcyjną metodą wynikającą z wybranych elementów: odsuniętą od płaszczyzny, przez punkt, między dwiema płaszczyznami, przez linie lub punkty, styczną do zakrzywionej ściany albo prostopadłą do ścieżki. Służy wtedy, gdy znasz elementy odniesienia, a nie metodę.",
"help.construct.plane.along_path": "Tworzy płaszczyznę konstrukcyjną prostopadłą do krawędzi lub krzywej, w wybranym miejscu wzdłuż niej. Służy do szkicowania profilu przeciągnięcia lub przekroju ścieżki.",
"help.construct.plane.at_angle": "Tworzy płaszczyznę konstrukcyjną obróconą o kąt względem płaszczyzny lub ściany, wokół osi lub krawędzi. Włącz opcję Użyj tylko kierunku osi, aby zrzutować oś na bazę i obracać wokół punktu na tej płaszczyźnie (początku bazy, gdy żaden nie jest wybrany). Służy do szkiców pochylonych, np. ukośnego wspornika.",
"help.construct.plane.axis_point": "Tworzy płaszczyznę konstrukcyjną zawierającą oś i punkt poza nią. Wybierz oś początku układu lub konstrukcyjną, prostą krawędź lub linię szkicu, a potem wierzchołek, punkt szkicu lub punkt konstrukcyjny. Punkt leżący na osi nie wyznacza jednoznacznie płaszczyzny.",
@@ -1788,7 +1728,6 @@
"help.construct.plane.tangent": "Tworzy płaszczyznę konstrukcyjną styczną do walca, stożka, sfery lub torusa w punkcie. Służy do szkicowania na boku okrągłej części.",
"help.construct.plane.three_points": "Tworzy płaszczyznę konstrukcyjną przez trzy punkty. Przydaje się, gdy płaszczyznę wyznaczają punkty modelu, np. trzy narożniki.",
"help.construct.plane.two_edges": "Tworzy płaszczyznę konstrukcyjną przez dwie proste krawędzie lub linie leżące w jednej płaszczyźnie. Pozwala poprowadzić płaszczyznę w poprzek części, np. przez dwie przeciwległe krawędzie.",
"help.construct.point": "Tworzy punkt konstrukcyjny metodą wynikającą z wybranych elementów: w wierzchołku, w środku okręgu lub sfery, wzdłuż krawędzi, na przecięciu dwóch linii lub trzech płaszczyzn albo tam, gdzie linia przecina płaszczyznę. Służy wtedy, gdy znasz elementy odniesienia, a nie metodę.",
"help.construct.point.along_path": "Tworzy punkt konstrukcyjny w wybranym miejscu wzdłuż krawędzi lub krzywej. Służy jako odniesienie w dowolnym miejscu ścieżki.",
"help.construct.point.center": "Tworzy punkt konstrukcyjny w środku okrągłej krawędzi, okręgu lub łuku szkicu, sfery albo torusa. Służy jako odniesienie dla otworów, osi i złączy.",
"help.construct.point.edge_plane": "Tworzy punkt konstrukcyjny tam, gdzie krawędź lub linia przechodzi przez płaszczyznę lub płaską ścianę. Służy do oznaczenia miejsca, w którym ścieżka przecina płaszczyznę.",
@@ -1814,7 +1753,6 @@
"help.file.send_to_slicer": "Zapisuje zaznaczone bryły jako plik 3MF i otwiera go w wybranym slicerze. Służy do przejścia od modelu do druku jednym krokiem.",
"help.font_details": "Interfejs używa wbudowanych czcionek egui (Ubuntu Light i Hack na licencjach Ubuntu Font Licence oraz MIT/Bitstream Vera; Noto Emoji i czcionka ikon emoji na licencjach OFL i MIT). Tekst szkicu używa Liberation Sans (SIL OFL 1.1). Opcjonalny pakiet czcionek The Crafting Apps, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic; SIL OFL 1.1), nie jest dołączany do kompilacji SolidCraft.",
"help.fonts": "Czcionki",
"help.frame.end_cap": "Zamyka otwarte końce elementów ramy płytkami w kształcie obrysu profilu, nałożonymi na koniec lub w niego wpuszczonymi. Służy do zaślepiania rur, kątowników i belek.",
"help.frame.member": "Umieszcza profil, np. rurę lub kształtownik aluminiowy, wzdłuż linii szkicu ramy, jeden element na linię. Służy do budowy ram, regałów i konstrukcji spawanych.",
"help.inspect.accessibility": "Koloruje ściany, do których narzędzie dociera z jednego kierunku, i te, do których nie. Służy do sprawdzenia części pod kątem obróbki lub formowania z tej strony.",
"help.inspect.center_of_mass": "Pokazuje środek masy brył, każdej osobno i wszystkich razem, przy jednorodnej gęstości. Służy do sprawdzenia wyważenia i miejsca, w którym część się przechyli.",
@@ -2080,7 +2018,6 @@
"help.view.visual_style": "Przełącza sposób rysowania modelu: cieniowany z krawędziami lub bez, krawędziowy albo z krawędziami niewidocznymi. Użyj modelu krawędziowego, aby zajrzeć do wnętrza, a cieniowanego dla czystego wyglądu.",
"history.sketch_under_constrained": "Niedostatecznie związany (stopnie swobody: {count})",
"history.sketch_under_constrained_one": "Niedostatecznie związany (stopnie swobody: {count})",
"hole_folder.applies_to": "Zmiany dotyczą otworów w folderze {folder} ({count}); każdy zachowuje swoje miejsce.",
"hole_presets.confirm_delete": "Potwierdź usunięcie",
"hole_presets.delete": "Usuń",
"hole_presets.delete_prompt": "Usunąć ten własny szablon? Istniejące otwory pozostaną bez zmian.",
@@ -2291,9 +2228,6 @@
"measure.at": "W punkcie",
"measure.item": "Element {number}",
"measure.line_to_centre": "Linia do środka",
"measure.perimeter": "Obwód",
"measure.perimeter_inner": "Obwód wewnętrzny",
"measure.perimeter_outer": "Obwód zewnętrzny",
"measure.quick_copied": "Skopiowano pomiar",
"measure.quick_copy": "Kliknij, aby skopiować pomiar",
"measure.quick_edges": "Krawędzie: {count}",
@@ -2335,8 +2269,6 @@
"nav.hfss.help": "Alt+przeciąganie lewym przesuwa, Shift+Alt+lewy powiększa, Ctrl+przeciąganie lewym na pustym miejscu obraca widok",
"nav.inventor.help": "Przeciąganie środkowym przesuwa, Shift+środkowy obraca widok, Ctrl+środkowy powiększa, przeciąganie prawym obraca widok",
"nav.maya.help": "Alt+przeciąganie lewym obraca widok, Alt+środkowy przesuwa, Alt+prawy powiększa; przeciąganie środkowym także przesuwa",
"nav.maya_horizontal_zoom.help": "Alt + przeciąganie prawym przyciskiem: w prawo przybliża, w lewo oddala. Wyłącz, aby przywrócić powiększanie przeciąganiem w pionie.",
"nav.maya_horizontal_zoom.label": "Maya: powiększanie przeciąganiem w poziomie",
"nav.nx.help": "Przeciąganie środkowym obraca widok, Shift+środkowy przesuwa (w NX środkowy + prawy), Ctrl+środkowy powiększa (w NX środkowy + lewy); przeciąganie prawym nie rusza kamery",
"nav.openscad.help": "Przeciąganie lewym na pustym miejscu obraca widok, Shift+przeciąganie lewym zaznacza prostokątem, przeciąganie geometrii szkicu ją edytuje; przeciąganie prawym lub środkowym przesuwa, Shift+prawy lub Shift+środkowy powiększa",
"nav.orbit_cursor_wrap.help": "Gdy obracanie przeciąganiem dojdzie do krawędzi widoku, kursor przeskakuje na przeciwną krawędź, a obrót trwa dalej. Wyłączone: przeciąganie zatrzymuje się na krawędzi ekranu. Nie działa w Wayland ani w przeglądarce.",
@@ -2467,16 +2399,6 @@
"not a move": "to nie jest przesunięcie",
"not a number": "to nie jest liczba",
"open a sketch first": "najpierw otwórz szkic",
"operations.cancel": "Anuluj: {name}",
"operations.cancelled": "Anulowano: {name}",
"operations.cancelling": "Anulowanie…",
"operations.cannot_cancel": "Nie można anulować: {name}",
"operations.feature": "Operacja",
"operations.feature_edit": "Edycja operacji",
"operations.meshing": "Wczytywanie części",
"operations.queued": "W kolejce",
"operations.stopped": "Anulowano",
"operations.waiting": "Gotowe",
"parameters.apply_live": "Stosuj każdą zmianę po opuszczeniu pola",
"parameters.apply_live_tip": "Wyłączone: zmiany czekają na kliknięcie Zastosuj lub OK, a potem projekt przebudowuje się raz dla wszystkich. Anuluj lub Escape je odrzuca.",
"parameters.held": "Zmiany do zastosowania: {count}",
@@ -2498,13 +2420,12 @@
"parts.dimension.width": "Szerokość",
"parts.family.bearing": "Łożysko kulkowe zwykłe (obrys)",
"parts.family.button_head_cap_screw": "Śruba z łbem półokrągłym z gniazdem sześciokątnym",
"parts.family.countersunk_socket_screw": "Śruba z łbem stożkowym z gniazdem sześciokątnym",
"parts.family.dowel_pin": "Kołek walcowy",
"parts.family.hex_bolt": "Śruba z łbem sześciokątnym",
"parts.family.hex_cap_screw_inch": "Śruba z łbem sześciokątnym (calowa)",
"parts.family.hex_nut": "Nakrętka sześciokątna",
"parts.family.hex_nut_inch": "Nakrętka sześciokątna (calowa)",
"parts.family.set_screw": "Wkręt dociskowy z gniazdem sześciokątnym z końcem płaskim",
"parts.family.set_screw": "Wkręt dociskowy z gniazdem sześciokątnym, płaski",
"parts.family.socket_head_cap_screw": "Śruba z łbem walcowym z gniazdem sześciokątnym",
"parts.family.washer": "Podkładka zwykła",
"parts.family.washer_sae": "Podkładka zwykła, SAE (wąska)",
@@ -2565,7 +2486,6 @@
"prefs.appearance.viewport_colours": "Kolory widoku 3D",
"prefs.appearance.viewport_logo": "Widok i logo",
"prefs.applies_on_restart": "Działa po ponownym uruchomieniu",
"prefs.assemblies": "Złożenia",
"prefs.assistant": "Asystent",
"prefs.assistant_connecting": "Łączenie",
"prefs.assistant_follow_help": "Patrz na szkice przesuwa widok razem ze wskaźnikiem.",
@@ -2598,8 +2518,6 @@
"prefs.bottom_centre": "Dół pośrodku",
"prefs.bottom_right": "Dół z prawej",
"prefs.browser": "Przeglądarka",
"prefs.browser_body_features": "Operacje pod bryłami",
"prefs.browser_body_features_note": "Każda bryła w przeglądarce rozwija się na operacje z Historii, które ją zbudowały, w kolejności Historii.",
"prefs.browser_components_open": "Komponenty startują rozwinięte",
"prefs.browser_components_open_note": "Każdy komponent w przeglądarce startuje rozwinięty, jak dotąd, zamiast zwinięty do czasu otwarcia.",
"prefs.browser_eye_left": "Oko obok strzałki zwijania",
@@ -2618,10 +2536,6 @@
"prefs.choose_font": "Wybierz plik czcionki…",
"prefs.click": "Kliknięcie",
"prefs.command_dialog_values": "Okna poleceń",
"prefs.constraint_glyph_size": "Rozmiar symboli wiązań",
"prefs.constraint_glyph_size_note": "Jak duże są rysowane w szkicu symbole wiązań oraz ich litery lub ikony. 100% to zwykły rozmiar; symbole zachowują ten rozmiar na ekranie przy każdym powiększeniu.",
"prefs.construct_menu_groups": "Grupuj narzędzia Konstrukcja według rodzaju elementu",
"prefs.construct_menu_groups_help": "Pokazuje nagłówki Płaszczyzny, Osie i Punkty. Wyłączone: menu zachowuje pierwotną kolejność.",
"prefs.contact_shading": "Miękkie cieniowanie kontaktowe",
"prefs.contact_shading_note": "Miękkie cieniowanie tam, gdzie części są blisko siebie.",
"prefs.context_menu": "Menu kontekstowe",
@@ -2644,21 +2558,15 @@
"prefs.design_tools.degrees": "Stopnie",
"prefs.design_tools.double_click_help": "Lub aby edytować połączony komponent w miejscu.",
"prefs.design_tools.ghost": "Pokazuj poprzednie miejsce przesuniętych części jako widmo",
"prefs.design_tools.hole_group": "Grupuj otwory utworzone razem",
"prefs.design_tools.hole_group_help": "Otwory utworzone za jednym razem trafiają do wspólnego folderu w Historii, na początku zwiniętego; rozwiń go, aby edytować pojedynczy otwór.",
"prefs.design_tools.hole_moves_help": "Ponowne wybranie ściany przesuwa otwór zamiast go usuwać.",
"prefs.design_tools.hole_several": "Kilka otworów na jednej ścianie",
"prefs.design_tools.hole_several_help": "Wybranie innego miejsca na ścianie dodaje kolejny otwór; wybranie otworu go usuwa.",
"prefs.design_tools.hole_snaps_help": "Środek ściany i środki jej okrągłych krawędzi.",
"prefs.design_tools.imported_files": "Zaimportowane pliki",
"prefs.design_tools.note": "Uwaga",
"prefs.design_tools.off": "Wyłączone",
"prefs.design_tools.placement_group": "Umieszczane operacje",
"prefs.design_tools.placement_group": "Podparcie, Występ, Zatrzask",
"prefs.design_tools.rows": "Wiersze",
"prefs.devices": "Mysz 3D",
"prefs.dialog_buttons": "Przyciski okien dialogowych",
"prefs.dialog_first_letter_buttons": "Przyciski okien dialogowych reagują na pierwszą literę",
"prefs.dialog_first_letter_buttons_help": "Gdy okno dialogowe jest otwarte, wpisanie pierwszej litery etykiety przycisku naciska go: O dla OK, A dla Anuluj. Litera pasująca do dwóch przycisków nic nie robi, a aktywne pole tekstowe zachowuje naciśnięty klawisz. Wyłączone: pisanie nie wpływa na przyciski, jak dotąd.",
"prefs.dialogs": "Okna dialogowe",
"prefs.dialogs_edge": "Przy krawędzi widoku",
"prefs.dialogs_left_column": "W lewej kolumnie",
@@ -2672,23 +2580,12 @@
"prefs.dimension_values_window": "W oknie",
"prefs.display": "Wyświetlanie",
"prefs.edges": "Krawędzie i cieniowanie",
"prefs.export": "Eksport",
"prefs.export_name": "Nazwa eksportowanego pliku",
"prefs.export_name.body": "Nazwa eksportowanej bryły (pierwszej, gdy jest ich kilka)",
"prefs.export_name.design": "Nazwa projektu",
"prefs.export_name_hint": "np. {design}-{body}",
"prefs.export_name_sample": "Eksport tego projektu zaproponuje: {name}",
"prefs.export_name_tip": "Nazwa pliku proponowana przez Plik › Eksportuj i Zapisz jako siatkę. Te symbole zastępcze są zamieniane:\n{placeholders}\nWłaściwość projektu, która nie jest ustawiona, jest pomijana. Zostaw pole puste, aby proponować nazwę projektu, a dla siatki – nazwę bryły.",
"prefs.extend": "Rozszerz",
"prefs.extra_navigation": "Ctrl+przeciąganie lewym na pustym miejscu obraca widok; przytrzymaj Spację, aby przesuwać.",
"prefs.extrude_arrow_face_stops": "Strzałka wyciągnięcia zatrzymuje się na ścianach",
"prefs.extrude_arrow_face_stops_note": "Przeciąganie strzałki odległości w wyciągnięciu zatrzymuje się na płaskich ścianach równoległych do profilu, np. na drugiej stronie wycinanego bloku, i obrysowuje ścianę. Wyłączone: strzałka przesuwa się tylko o okrągłe kroki.",
"prefs.extrude_view_isometric": "Obracaj widok do izometrycznego przy otwarciu wyciągnięcia",
"prefs.extrude_view_return": "Wracaj do poprzedniego widoku po zamknięciu wyciągnięcia",
"prefs.file_operations_wait": "Czekaj na otwarcie plików",
"prefs.file_operations_wait_note": "Gdy plik jest otwierany lub wstawiany albo łącza są ponownie odczytywane, projekt czeka pod nakładką z postępem pliku i przyciskiem Anuluj. Wyłączone: plik pojawia się w panelu zadań i możesz dalej pracować; wstawienie nastąpi, gdy skończysz bieżącą czynność, a plik otwarty w międzyczasie trafi do nowej karty.",
"prefs.fillet_legacy_radius": "Używaj poprzedniego domyślnego promienia zaokrąglenia bryły",
"prefs.fillet_legacy_radius.help": "Domyślnie wyłączone. Gdy włączone, nowe zaokrąglenie bryły otwiera się z liczbą 2 odczytaną w jednostkach projektu („2 in” w projekcie calowym), jak dotąd; gdy wyłączone, otwiera się z domyślnym promieniem narzędzia, 2 mm, pokazanym w jednostkach projektu („0.07874 in” w projekcie calowym).",
"prefs.follows_os": "Zgodnie z systemem",
"prefs.font": "Czcionka",
"prefs.font_fallback": "Znaki, których brakuje w wybranej czcionce, są brane z czcionki wbudowanej, więc żadna etykieta nie pokaże pustego prostokąta.",
@@ -2712,17 +2609,6 @@
"prefs.gesture_pan": "Przesuwanie",
"prefs.gesture_zoom": "Powiększanie",
"prefs.graphics": "Grafika",
"prefs.graphics_api": "API grafiki",
"prefs.graphics_api_auto": "Automatycznie",
"prefs.graphics_api_help": "Wybierz API grafiki, z którym uruchamia się SolidCraft. Jeśli przy następnym uruchomieniu okno się nie otworzy, przywracany jest poprzedni wybór.",
"prefs.graphics_api_override": "API grafiki jest wymuszone przy uruchomieniu. Zapisany wybór zadziała, gdy SolidCraft zostanie uruchomiony normalnie.",
"prefs.graphics_api_restart_note": "Działa po ponownym uruchomieniu. Przed zamknięciem pojawi się pytanie o niezapisane projekty.",
"prefs.graphics_api_restart_unavailable": "Uruchom ponownie SolidCraft, aby zastosować zapisane API grafiki.",
"prefs.graphics_api_running": "Bieżące: {current}. Wybrane na następne uruchomienie: {selected}.",
"prefs.graphics_api_save_failed": "Nie udało się zapisać wyboru API grafiki: {error}",
"prefs.graphics_api_start_failed": "Nie udało się przygotować wybranego API grafiki: {error}",
"prefs.graphics_api_start_reverted": "Poprzednie uruchomienie z {requested} nie otworzyło okna. SolidCraft przywrócił {previous}.",
"prefs.graphics_api_unavailable": "Wybór API grafiki jest dostępny w aplikacji desktopowej.",
"prefs.graphics_card": "Karta graficzna",
"prefs.graphics_card_auto": "Automatycznie",
"prefs.graphics_card_missing": "Zapisana karta graficzna jest niedostępna; używane jest ustawienie Automatycznie.",
@@ -2754,8 +2640,6 @@
"prefs.history": "Historia",
"prefs.history_bar_groups": "Zwijaj pasek Historii w grupy",
"prefs.history_bar_groups_note": "W długiej historii ciąg operacji jednego komponentu zwija się w jedną etykietę, a foldery operacji są na początku zwinięte. Wstawione złożenie STEP trafia do folderu Grupa. Wyłączone: widać każdą operację, foldery są na początku rozwinięte, a folder nie powstaje.",
"prefs.history_folder_kind_icon": "Ikona folderu według jego operacji",
"prefs.history_folder_kind_icon_note": "Folder Historii, którego wszystkie operacje są jednego rodzaju, pokazuje ich ikonę.",
"prefs.history_group_joints": "Grupuj złącza w Historii",
"prefs.history_group_joints_note": "Dwa lub więcej złączy są pokazywane jako jeden przycisk z ich liczbą, a kliknięcie pokazuje każde z nich. Wyłączone pokazuje każde złącze.",
"prefs.history_origin_bodies": "Pokazuj początek układu i bryły na górze",
@@ -2766,8 +2650,6 @@
"prefs.history_sketches_in_order": "W kolejności",
"prefs.history_sketches_note": "Gdy ustawienie Szkice ma wartość „Pod swoją operacją”, Historia po lewej pokazuje każdy szkic o poziom głębiej, pod pierwszą operacją z niego zbudowaną, o ile panel jest dość szeroki na nazwy. Oś czasu na dole zawsze zachowuje kolejność tworzenia.",
"prefs.history_sketches_under_feature": "Pod swoją operacją",
"prefs.history_steps_component": "Kroki Historii według komponentu",
"prefs.history_steps_component_note": "Gdy komponent jest aktywny lub wybrany, elementy sterujące Historii przechodzą przez operacje tego komponentu, tak jak pokazuje je Historia na dole. Wyłączone: przechodzą przez wszystkie operacje projektu. Historia po lewej lub pod przeglądarką zawsze przechodzi przez wszystkie operacje.",
"prefs.hold_stop_snapping": "Przytrzymaj, aby wyłączyć przyciąganie",
"prefs.home_fit_size": "Rozmiar przy poleceniach Widok główny i Dopasuj",
"prefs.home_fit_size_note": "Jaką część widoku wypełnia model, gdy kadrują go polecenia Widok główny i Dopasuj, otwarcie projektu lub zmiana widoku w nawigatorze, niezależnie od ustawienia poniżej. 100%: wypełnia widok między panelami, kompasem i paskiem nawigacji.",
@@ -2783,8 +2665,6 @@
"prefs.invert_curved_roll_arrows_note": "Dwie zakrzywione strzałki w rogu kostki przechylają widok w płaszczyźnie ekranu. Włączone: strzałka zgodna z ruchem wskazówek zegara przechyla przeciwnie, a przeciwna – zgodnie. Wyłączone: każda strzałka przechyla widok w stronę, w którą jest zakrzywiona.",
"prefs.invert_navigator_arrows": "Odwróć strzałki obrotu kostki widoku",
"prefs.invert_navigator_arrows_note": "Strzałki wokół kostki obracają kamerę w kierunku, w który wskazują, jak w Fusion i Inventor: strzałka po lewej wskazuje w prawo i obraca kamerę w prawo. Wyłączone: strzałka obraca kostkę w stronę, po której się znajduje, więc lewa strzałka obraca kamerę w lewo. Zakrzywione strzałki przechylenia nie są zmieniane.",
"prefs.joint_drag_trackball": "Obracaj części na złączu kulistym jak trackball przy przeciąganiu",
"prefs.joint_drag_trackball_help": "Przeciąganie części trzymanej przez złącze kuliste obraca ją o najmniejszy obrót wokół złącza, jak toczenie kuli wielkości części. Wyłączone: chwycony punkt dokładnie podąża za wskaźnikiem, a część może się też wokół niego obracać.",
"prefs.keep_unsaved_also_asks": "Mimo to pytaj przy zamykaniu karty",
"prefs.keep_unsaved_also_asks_tip": "Gdy wyłączone, karta zamyka się bez okna przeglądarki, jeśli kopia każdego niezapisanego projektu jest aktualna.",
"prefs.keep_unsaved_in_browser": "Przechowuj niezapisaną pracę w tej przeglądarce",
@@ -2860,14 +2740,11 @@
"prefs.not_on_gpu": "niedostępne na tym GPU",
"prefs.on": "Włączone",
"prefs.opaque": "Nieprzezroczysta",
"prefs.operations_panel": "Pokazuj trwające zadania w widoku",
"prefs.operations_panel_note": "Długie zadania (otwieranie pliku, obliczanie operacji, polecenie agenta) pojawiają się w prawym dolnym rogu widoku z postępem i przyciskiem, który je anuluje. Wyłączone: komunikat Obliczanie, jak dotąd.",
"prefs.orbit_snap": "Przytrzymaj Shift podczas obracania widoku, aby przyciągnąć do osi.",
"prefs.orientation": "Orientacja",
"prefs.origin_plane_names": "Nazwy płaszczyzn początku układu",
"prefs.origin_plane_names_note": "Nazwy pokazywane w przeglądarce, oknach dialogowych i listach. {top} to XY, {front} to XZ, a {right} to YZ, przy osi Z w górę. Polecenia, skrypty i zapisane projekty zachowują XY, XZ i YZ.",
"prefs.overall_speed": "Szybkość ogólna",
"prefs.panel_layout.recovery": "Nie udało się przywrócić zapisanych układów paneli. Zapisane dane są zachowane; układ Klasyczny pozostaje dostępny.",
"prefs.parameter_list_rows": "Wiersze listy parametrów",
"prefs.parameter_list_rows_tip": "Maksymalna liczba parametrów, które strzałka w dół wyświetla w pustym polu wyrażenia.",
"prefs.persistent_ribbon_pressed": "Wyróżniaj aktywne polecenia wstążki",
@@ -2929,8 +2806,6 @@
"prefs.remember_dialog_values.help": "Domyślnie wyłączone. Gdy włączone, po udanym poleceniu jego bezpieczne wartości i opcje są używane ponownie przy następnym otwarciu tego samego polecenia, aż do zamknięcia SolidCraft.",
"prefs.render_legacy_image_defaults": "Używaj poprzednich ustawień domyślnych obrazu renderowania",
"prefs.render_legacy_image_defaults_help": "Zachowuje dotychczasowy ręczny tryb pracy: nowe ustawienia używają rasteryzacji CPU, śledzenie promieni na CPU zachowuje stałe próbkowanie, a wyrenderowane wyniki otwierasz ręcznie. Dopasowuje widok przy wejściu do środowiska Renderowanie, przełącza ujęcia bez przywracania ich kamer i używa poprzedniego kadrowania i nawigacji. Wyłącza automatyczny podgląd obrazu na żywo. Zapisane ustawienia pozostają bez zmian.",
"prefs.render_legacy_memory_admission": "Używaj poprzednich kontroli pamięci renderowania",
"prefs.render_legacy_memory_admission_help": "Dopuszcza natywne renderowania w stałych limitach pamięci współdzielonej i hosta, nawet gdy ich oszacowanie przekracza dostępną pamięć RAM. Limity urządzenia i kontrole przydziału nadal obowiązują.",
"prefs.render_previous_geometry_budget": "Używaj poprzedniego budżetu geometrii podglądu renderowania",
"prefs.render_previous_geometry_budget_help": "Przywraca poprzedni stały limit geometrii (512 MiB na 64-bitowym komputerze stacjonarnym, 128 MiB w innych przypadkach) zamiast budżetu zależnego od urządzenia.",
"prefs.reports_saved": "Zapisane: {n}",
@@ -2942,8 +2817,6 @@
"prefs.ribbon_tabs_only": "Pokazuj tylko karty wstążki",
"prefs.ribbon_tabs_only_note": "Wstążka zwija się do wiersza kart. Kliknięcie karty pokazuje jej panele nad widokiem, dopóki nie uruchomisz polecenia lub wskaźnik ich nie opuści. Włącza się to i wyłącza poleceniem Tylko karty wstążki na karcie Widok lub jego skrótem.",
"prefs.right_click": "Kliknięcie prawym",
"prefs.save_prompt_one_at_a_time": "Pytaj osobno o każdy niezapisany projekt przy zamykaniu kilku",
"prefs.save_prompt_one_at_a_time_help": "Pokazuje osobne pytanie o zapisanie dla każdego niezapisanego projektu zamiast wspólnej listy.",
"prefs.saving": "Autozapis i odzyskiwanie",
"prefs.scale_note": "Także Ctrl + / Ctrl − w oknie. Przyciski mają 28 px przy 100%.",
"prefs.scale_text": "Skala i tekst",
@@ -2959,8 +2832,6 @@
"prefs.select_other_hidden": "Zaznaczaj ukrytą geometrię poleceniem Wybierz inny",
"prefs.select_other_hidden_help": "Włączone: uwzględnia ukryte bryły, szkice i zasłonięte krawędzie w pobliżu wskaźnika. Wyłączone: używa pierwotnej listy widocznej geometrii pod wskaźnikiem.",
"prefs.selecting": "Zaznaczanie",
"prefs.shared_panel_layout": "Używaj wspólnego układu paneli",
"prefs.shared_panel_layout_help": "Pokazuje istniejące panele Przeglądarka, Historia i Asystent jako przesuwalne panele. Zmiana działa po zastosowaniu preferencji.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift tylko dodaje; Ctrl / Cmd przełącza",
"prefs.shift_add_note": "Włączone: Shift dodaje, Ctrl / Cmd przełącza. Wyłączone (domyślnie): oba przełączają.",
@@ -2985,28 +2856,20 @@
"prefs.sketch.drawing": "Rysowanie",
"prefs.sketch.ellipse_axes": "Rysuj elipsy z osiami",
"prefs.sketch.ellipse_axes_note": "Oś wielka i mała jako linie konstrukcyjne, aby można było wymiarować półosie i osie. Wyłączone: sama elipsa, a D na niej daje promień małej półosi, gdziekolwiek przeciągnąć wartość, rysowany i oznaczony R jak dotąd.",
"prefs.sketch.escape_twice_finishes": "Podwójne Esc kończy szkic",
"prefs.sketch.escape_twice_finishes_note": "Dwa szybkie naciśnięcia Esc kończą szkic. Wyłączone: Esc tylko anuluje narzędzie lub zaznaczenie, a szkic kończy polecenie Zakończ szkic.",
"prefs.sketch.exit_keep": "Zachowaj",
"prefs.sketch.exit_previous": "Wróć do poprzedniego widoku",
"prefs.sketch.exit_unless_rotated": "Chyba że obrócono",
"prefs.sketch.faces_edges": "Ściany i krawędzie",
"prefs.sketch.hide_glyphs": "Ukrywaj symbole przy oddaleniu",
"prefs.sketch.marks": "Oznaczenia w szkicu",
"prefs.sketch.max_points": "Największy szkic",
"prefs.sketch.max_points_note": "Najwięcej punktów lub krzywych w jednym szkicu: domyślnie 20000, maksymalnie 200000. Wstaw SVG i Wstaw DXF odrzucają większy rysunek. Duży szkic wolniej się rozwiązuje i rysuje.",
"prefs.sketch.max_points_unit": "punktów",
"prefs.sketch.note": "Rysowanie, przyciąganie i zaznaczanie.",
"prefs.sketch.opening_closing": "Otwieranie i zamykanie",
"prefs.sketch.prefer_free_points": "Preferuj ruchome punkty tam, gdzie się nakładają",
"prefs.sketch.project_options": "Najpierw pytaj o opcje rzutowania",
"prefs.sketch.recent_snap": "Przyciągaj do przecięcia dwóch linii pomocniczych",
"prefs.sketch.select_3d_curve_points": "Zaznaczaj punkty na krzywych szkicu 3D",
"prefs.sketch.select_3d_curve_points_help": "Pokazuje punkty na narysowanych krzywych szkicu 3D i pozwala je zaznaczać jako odniesienia konstrukcyjne.",
"prefs.sketch.select_other_hidden": "Wybierz inny sięga do ukrytej geometrii",
"prefs.sketch.shortcut_cycles_modes": "Ponowny skrót przełącza tryb",
"prefs.sketch.shortcut_cycles_modes_tip": "Wyłączone: uruchamia narzędzie od nowa.",
"prefs.sketch.snap_to_visible_grid": "Przyciągaj do widocznych przecięć siatki",
"prefs.sketch.snapping_guides": "Przyciąganie i linie pomocnicze",
"prefs.sketch.state_icons": "Ikony stanu",
"prefs.sketch.tracking_guides": "Linie pomocnicze do środków i punktów środkowych",
@@ -3034,7 +2897,6 @@
"prefs.sketch_selection": "Szkic i zaznaczanie",
"prefs.sketch_selection_heading": "Szkic i zaznaczanie",
"prefs.sketch_selection_note": "Jak działa przyciąganie w szkicach i jak kliknięcia zaznaczają.",
"prefs.sketch_snap_to_visible_grid_note": "Punkt w szkicu przyciąga się do przecięć linii rysowanych przez siatkę, gdy wskaźnik jest blisko takiego przecięcia, a w przeciwnym razie do gęstszej siatki między nimi. Wyłączone: punkt przyciąga się tylko do gęstszej siatki, jak dotąd.",
"prefs.sketch_state_icons": "Ikony szkiców w kolorze ich stanu",
"prefs.sketch_state_icons_note": "W Historii i przeglądarce ikona szkicu przyjmuje kolor jego stanu (niedostatecznie związany, w pełni związany lub w konflikcie), a podpowiedź podaje ten stan. Wyłączone: każdy szkic ma tę samą ikonę.",
"prefs.sketch_tab_always": "Zawsze pokazuj kartę Szkic",
@@ -3179,7 +3041,7 @@
"refused.face_freeform": "Jedna z jej ścian o dowolnym kształcie nie nadaje się do modelowania.",
"refused.face_rounded": "Jedna z jej zaokrąglonych ścian nie nadaje się do modelowania.",
"refused.faces_apart": "Jej ściany nie stykają się dość dokładnie, by nadawała się do modelowania.",
"refused.intersect": "Nie udało się wyznaczyć przecięcia z {body}.",
"refused.intersect": "Nie udało się przyciąć z {body}.",
"refused.join": "Nie udało się połączyć z {body}.",
"refused.repair": "Napraw część",
"refused.show_face": "Pokaż ścianę",
@@ -3336,10 +3198,8 @@
"render.category_paint": "Farba",
"render.category_plastic": "Tworzywo",
"render.category_wood": "Drewno",
"render.caustics_area_lights": "Kaustyka od świateł powierzchniowych",
"render.caustics_depth": "Głębokość ścieżki kaustyki",
"render.caustics_enable": "Kaustyka szkła od świateł punktowych i kierunkowych",
"render.caustics_environment": "Kaustyka od otoczenia",
"render.caustics_help": "Liczba fotonów decyduje o szumie i nakładzie pracy. Promień zbierania zamienia ostre szczegóły na gładsze oszacowanie. CPU wymaga oświetlenia pośredniego.",
"render.caustics_path_required": "Kaustyka na CPU wymaga oświetlenia pośredniego.",
"render.caustics_photons": "Fotony",
@@ -3556,12 +3416,6 @@
"render.cpu_subsurface_denoise_reason": "Rozpraszanie podpowierzchniowe na CPU wymaga wyłączonego odszumiania. Twoje ustawienia zostają zachowane.",
"render.cpu_subsurface_help": "Rozpraszanie zmienia kierunek światła wewnątrz zamkniętego szkła. Wartości dodatnie wymagają oświetlenia pośredniego i wyłączonego odszumiania. Współczynnik to liczba zderzeń na mm; 0 nie dodaje rozpraszania. Dodatnie przesunięcie fazy faworyzuje kierunki do przodu, ujemne do tyłu.",
"render.cpu_summary": "Śledzenie promieni na CPU · odbicia · HDR · światła kierunkowe i punktowe",
"render.cpu_threads": "Wątki CPU",
"render.cpu_threads_auto": "Automatycznie",
"render.cpu_threads_custom": "Własne",
"render.cpu_threads_help": "Automatycznie używa dostępnych wątków CPU. Jeden wątek renderuje sekwencyjnie. Więcej wątków może wymagać więcej pamięci.",
"render.cpu_threads_single": "Jeden",
"render.cpu_threads_web": "Renderowanie w przeglądarce internetowej używa jednego wątku kooperacyjnego. Zapisany wybór wątków dla wersji desktopowej zostaje zachowany.",
"render.cpu_transparent_help": "Przezroczysty PNG usuwa tło i zachowuje pokrycie krawędzi. Nieprzezroczystość brył i ścian musi pozostać na 100%.",
"render.cpu_volume_enable_path": "Włącz oświetlenie pośrednie",
"render.cpu_volume_enable_path_plan": "Śledzenie promieni na CPU · głębokość ścieżki {depth} · próbki na piksel: {spp} · ziarno {seed} · śledzone cienie. Zachowuje przygotowaną atmosferę i budżet obrazu.",
@@ -3570,40 +3424,6 @@
"render.cpu_volume_method_incompatible": "Tych ustawień świateł, próbkowania lub atmosfery nie można przenieść do tej metody. Twoje ustawienia zostają zachowane.",
"render.cpu_volume_path_required": "Włącz oświetlenie pośrednie, aby użyć atmosfery CPU.",
"render.cpu_warm_cool": "Ciepłe i chłodne",
"render.denoise_class_background": "Pominięte: tło",
"render.denoise_class_coverage": "Pominięte: częściowe pokrycie",
"render.denoise_class_degenerate": "Pominięte: zdegenerowana ścieżka",
"render.denoise_class_depth": "Pominięte: limit głębokości ścieżki",
"render.denoise_class_emitter": "Pominięte: źródło światła",
"render.denoise_class_exit": "Pominięte: niezgodne wyjście",
"render.denoise_class_identity": "Pominięte: limit identyfikatorów bufora pomocniczego",
"render.denoise_class_lens": "Pominięte: głębia ostrości",
"render.denoise_class_material": "Pominięte: nieobsługiwany materiał",
"render.denoise_class_nested": "Pominięte: zagnieżdżony ośrodek",
"render.denoise_class_optical": "Kwalifikujące się: powierzchnia po załamaniu",
"render.denoise_class_reflection": "Pominięte: odbijający punkt końcowy",
"render.denoise_class_surface": "Kwalifikujące się: powierzchnia",
"render.denoise_class_tir": "Pominięte: całkowite wewnętrzne odbicie",
"render.denoise_class_transmission": "Pominięte: dalsze przepuszczanie",
"render.denoise_class_unavailable": "Pominięte: brak bufora pomocniczego",
"render.denoise_class_volume": "Pominięte: rozpraszanie objętościowe",
"render.denoise_diagnostics_counts": "Kwalifikujące się: {eligible} · Pominięte: {skipped}",
"render.denoise_diagnostics_enable": "Zbieraj diagnostykę odszumiania",
"render.denoise_diagnostics_grid": "Liczby pikseli przy rozmiarze wewnętrznym {width} × {height}.",
"render.denoise_diagnostics_help": "Zbiera liczby kwalifikujących się i pominiętych pikseli, nawet gdy odszumianie jest wyłączone. Liczby dotyczą wewnętrznego rozmiaru renderowania; maska używa rozmiaru wyjściowego.",
"render.denoise_diagnostics_title": "Diagnostyka odszumiania",
"render.denoise_export_mask": "Eksportuj maskę PNG",
"render.denoise_firefly": "Tłum pojedyncze jasne piksele",
"render.denoise_format_mask": "Maska PNG",
"render.denoise_mask_copy_help": "Wyeksportuj maskę odszumiania jako PNG albo wróć do wyrenderowanego obrazu, aby go skopiować.",
"render.denoise_mask_unavailable": "Włącz diagnostykę odszumiania i wyrenderuj obraz, aby obejrzeć jego maskę.",
"render.denoise_optical_filter": "Filtruj lustra i szkło",
"render.denoise_optical_filter_help": "Filtruje kwalifikujące się światło odbite i przepuszczone. Wyłącz, aby odszumiać tylko nieprzezroczyste powierzchnie.",
"render.denoise_passes": "Przebiegi filtra",
"render.denoise_settings_help": "Więcej przebiegów wygładza większy obszar. Siła określa, jak mocno filtruje każdy przebieg. Nieobsługiwane ścieżki optyczne pozostają niefiltrowane.",
"render.denoise_strength": "Siła odszumiania",
"render.denoise_view_image": "Wyrenderowany obraz",
"render.denoise_view_mask": "Maska odszumiania",
"render.diag_bodies": "Widoczne bryły",
"render.diag_job_details": "Uchwycone szczegóły zadania",
"render.diag_last_check": "Ostatnia kontrola wstępna",
@@ -3736,7 +3556,6 @@
"render.error_pbr_opacity": "nieprzezroczysty PBR wymaga nieprzezroczystości 1",
"render.error_physical_glass": "wybrany przepis nie jest fizycznym szkłem",
"render.error_preview_device": "podgląd produktu wymaga zainstalowanego urządzenia z buforem głębi",
"render.error_product_placement": "nieprawidłowe umiejscowienie produktu",
"render.error_product_triangle": "nieprawidłowy trójkąt produktu",
"render.error_provisional_identity": "nieprawidłowy tymczasowy identyfikator renderowania lub liczba próbek",
"render.error_quality_bounds": "odwzorowanie jakości przekracza natywne granice",
@@ -3810,7 +3629,6 @@
"render.glass_model_help": "Chropowatość szkła rozprasza przepuszczane promienie. Dyspersja i barwa warstwy używają trzech pasm długości fal. Chropowate arkusze wymagają wyłączenia prostego przepuszczania cieni.",
"render.glass_roughness": "Chropowatość szkła",
"render.gpu_depth": "Maksymalna głębokość ścieżki",
"render.gpu_job_shader_wait": "Przygotowanie shaderów może potrwać kilka minut",
"render.gpu_options_help": "Próbki, głębokość, ziarno i ekspozycja są zapisywane z tymi ustawieniami. Zapisywana jest tylko aktywna metoda obrazu.",
"render.gpu_path_crashed": "SolidCraft zamknął się podczas ostatniego przygotowywania na tej karcie graficznej, najpewniej w sterowniku grafiki, więc nie będzie tego robić ponownie sam. Wybierz zamiast tego śledzenie promieni na CPU albo spróbuj ponownie (aktualizacja sterownika też powoduje ponowną próbę).",
"render.gpu_path_failed": "Śledzenie ścieżek na GPU jest niedostępne",
@@ -3820,10 +3638,6 @@
"render.gpu_path_retry": "Spróbuj ponownie",
"render.gpu_seed": "Ziarno losowania",
"render.gpu_unavailable": "Śledzenie ścieżek na GPU nie jest gotowe na tym urządzeniu.",
"render.graphics_api_restart_vulkan": "Uruchom ponownie z Vulkanem",
"render.graphics_api_restart_warning": "Zmiana API grafiki uruchamia ponownie SolidCraft i prosi o zapisanie niezapisanych projektów. Jeśli nowe API nie otworzy okna, przy następnym uruchomieniu przywracany jest poprzedni wybór.",
"render.graphics_api_settings": "Ustawienia grafiki",
"render.graphics_api_vulkan_required": "Śledzenie ścieżek na GPU wymaga w tej sesji Vulkana. Uruchom ponownie z Vulkanem albo dalej używaj metody obrazu na CPU.",
"render.ground_apply": "Dopasuj i zapisz podłoże",
"render.ground_gap": "Jawny odstęp",
"render.ground_linear_rgb": "Rozproszony współczynnik odbicia · liniowe RGB",
@@ -4163,8 +3977,6 @@
"render.material_transient": "Tymczasowe nadpisania optyczne dla tej karty dokumentu. Nie są zapisywane w projekcie, ustawieniach renderowania ani bibliotece materiałów.",
"render.material_v_up": "Oś V tekstury skierowana w obrazie w górę",
"render.material_weight": "Waga lakieru (0–1)",
"render.metal_energy_compensation": "Odzyskuj odbicie chropowatego metalu",
"render.metal_energy_compensation_help": "Kompensuje światło tracone między mikrościankami chropowatego metalu. Renderowanie na CPU wymaga oświetlenia pośredniego.",
"render.method_cpu": "Śledzenie promieni na CPU",
"render.method_gpu": "Śledzenie ścieżek na GPU",
"render.method_limit_raster": "Rasteryzacja używa stałego, nieprzezroczystego podglądu studyjnego. Aby użyć obsługiwanych ustawień optycznych, wybierz jawną metodę śledzenia promieni.",
@@ -4293,8 +4105,6 @@
"render.quality_denoise_on": "Redukcja szumu: wł.",
"render.quality_draft": "Robocza",
"render.quality_high": "Wysoka",
"render.quality_legacy_samples": "Zapisana wartość: {samples} spp. Nowo wybierane wartości są ograniczone do {maximum} spp.",
"render.quality_preset_limit": "To ustawienie wstępne przekracza limit wyboru 16384 spp. Wybierz liczbę próbek do 16384.",
"render.quality_projection_help": "Ten przegląd zmienia tylko rozdzielczość, budżet próbek, głębokość ścieżki, ziarno i redukcję szumu. Pozostałe ustawienia zostają zachowane; budżet nie gwarantuje zbieżności.",
"render.quality_review": "Przejrzyj budżet próbkowania",
"render.quality_summary": "{width} × {height} · próbki: {samples} · głębokość {depth} · ziarno {seed} · {denoise}",
@@ -4442,7 +4252,6 @@
"render.show_shots_label": "Pokaż pasek ujęć",
"render.size_4k": "4K",
"render.size_4k_uhd": "4K UHD",
"render.size_8k_uhd": "8K UHD",
"render.size_custom": "Niestandardowy…",
"render.size_full_hd": "Full HD",
"render.size_hd": "HD",
@@ -4605,11 +4414,6 @@
"render.subsurface_enable": "Rozpraszanie wewnętrzne metodą błądzenia losowego",
"render.subsurface_help": "Tylko zamknięty dielektryk. Rozpraszanie i absorpcja RGB to osobne współczynniki na mm. Ścieżki rozpraszają się wewnątrz rzeczywistej geometrii, a potem załamują lub odbijają na granicy. Wyłącz proste przepuszczanie cieni, odszumianie powierzchni i objętość zewnętrzną; użyj jednej warstwy Fresnela. To nie jest shader dyfuzji, siatka gęstości ani zmierzony materiał. Gęste ośrodki mogą wymagać więcej próbek i większej głębokości ścieżki.",
"render.subsurface_rate": "Częstość zderzeń przy rozpraszaniu",
"render.supersampling": "Supersampling",
"render.supersampling_dimensions": "Renderuje {width} × {height} i zmniejsza do {output_width} × {output_height}. Używa czterokrotnie więcej pikseli.",
"render.supersampling_help": "Renderuje w dwukrotnej szerokości i wysokości, a potem zmniejsza w świetle liniowym. Rozmiar wyjściowy pozostaje bez zmian.",
"render.supersampling_off": "Wył.",
"render.supersampling_x2": "2×",
"render.surface_import.author": "Autor",
"render.surface_import.busy": "Importowanie obrazu…",
"render.surface_import.complete": "Wybierz obraz i wpisz jego autora, źródło i licencję.",
@@ -4623,8 +4427,6 @@
"render.surface_import.saved_cpu": "Zapisz mapy, aby używać ich przy renderowaniu na CPU.",
"render.surface_import.source": "Źródło",
"render.surface_import.title": "Importuj obraz",
"render.texture_filtering": "Filtruj szczegóły tekstur",
"render.texture_filtering_help": "Używa poziomów mipmap i filtrowania anizotropowego, aby zmniejszyć aliasing tekstur przy pomniejszeniu. Zużywa więcej pamięci; wyłączenie zachowuje poprzednie filtrowanie.",
"render.tone_aces_fitted": "ACES (dopasowane)",
"render.tone_reinhard": "Reinhard",
"render.transmission_scope": "Szkło GPU obsługuje certyfikowane rozłączne ośrodki. Nieograniczone zagnieżdżanie ośrodków i bezszumowy estymator przepuszczania nie są dostępne.",
@@ -4825,8 +4627,6 @@
"sketch.hint.spline3d": "Splajn 3D: klikaj punkty dopasowania w 3D; prawy przycisk lub Esc kończy",
"sketch.hint.trim": "Przytnij: kliknij fragment krzywej do usunięcia lub przeciągnij przez fragmenty do usunięcia",
"sketch.make_selection": "Zamień zaznaczenie na {linetype}",
"sketch.move_released": "Przesunięto. Zwolnione wiązania z geometrią, która została na miejscu ({count}): {names}.",
"sketch.move_released_one": "Przesunięto. Zwolniono {count} wiązanie z geometrią, która została na miejscu ({names}).",
"sketch.open_ends": "Otwarte: {count}",
"sketch.status_conflict": "wiązania w konflikcie",
"sketch.status_fully_constrained": "w pełni związany",
@@ -5061,7 +4861,6 @@
"ui.agent_chat_allow_once": "Zezwól raz",
"ui.agent_chat_allowed": "Zezwolono",
"ui.agent_chat_answered": "Odpowiedź: {option}",
"ui.agent_chat_answered_in": "Odpowiedź po {time}",
"ui.agent_chat_approval": "Zezwolić agentowi na to działanie?",
"ui.agent_chat_attach_selection": "Dołącz zaznaczenie",
"ui.agent_chat_attach_selection_tip": "Wyślij z wiadomością to, co zaznaczono w modelu (ściany, krawędzie, operacje, bryły), aby agent wiedział, o co chodzi",
@@ -5090,7 +4889,6 @@
"ui.agent_chat_ctx_bodies": "Bryły",
"ui.agent_chat_ctx_elements": "Elementy",
"ui.agent_chat_ctx_features": "Operacje",
"ui.agent_chat_ctx_gone": "Nie ma już w projekcie: zostało zmienione lub usunięte.",
"ui.agent_chat_ctx_more": "+{n} więcej",
"ui.agent_chat_ctx_other": "Inne",
"ui.agent_chat_custom_heading": "Własny model",
@@ -5104,7 +4902,6 @@
"ui.agent_chat_dock_right": "Zadokuj po prawej",
"ui.agent_chat_drop_hint": "Upuść, aby dołączyć do następnej wiadomości",
"ui.agent_chat_edit": "Edytuj",
"ui.agent_chat_ended_after": "Zakończono po {time}",
"ui.agent_chat_exited": "Agent się zatrzymał.",
"ui.agent_chat_failed_start": "Nie udało się uruchomić agenta: {error}",
"ui.agent_chat_file_cad_not_read": "{name}: plik CAD jest importowany do projektu, a nie odczytywany",
@@ -5142,7 +4939,6 @@
"ui.agent_chat_model_no_tools": "{model} nie może używać narzędzi SolidCraft, więc może tylko rozmawiać. Wybierz model obsługujący narzędzia.",
"ui.agent_chat_model_rejected": "Agent nie przyjął modelu {model}: {error}",
"ui.agent_chat_model_search": "Szukaj modeli…",
"ui.agent_chat_model_search_or_id": "Szukaj lub wpisz dowolny identyfikator modelu…",
"ui.agent_chat_model_tip": "Model używany przez agenta. Twój wybór jest zapamiętywany dla tego agenta.",
"ui.agent_chat_models_failed": "Nie udało się pobrać listy modeli: {error}",
"ui.agent_chat_models_heading": "Dostępne modele",
@@ -5154,7 +4950,6 @@
"ui.agent_chat_next_picture": "Następny obraz",
"ui.agent_chat_no_cli": "Nie znaleziono solidcraft-cli obok aplikacji, więc agent nie ma dostępu do projektu.",
"ui.agent_chat_no_control": "Punkt końcowy czatu z agentem się nie uruchomił, więc agent nie ma dostępu do projektu.",
"ui.agent_chat_no_design_hint": "Agent działa bez narzędzi SolidCraft, więc nie może odczytać ani zmienić projektu: nie znaleziono solidcraft-cli obok aplikacji. Zainstaluj SolidCraft razem z solidcraft-cli (w systemie Windows: instalator MSI lub przenośny zip) albo ustaw w SOLIDCRAFT_CLI jego ścieżkę, a potem połącz ponownie.",
"ui.agent_chat_no_images": "Ten agent nie przyjmuje obrazów",
"ui.agent_chat_no_picture": "W schowku nie ma obrazu",
"ui.agent_chat_no_tool_call": "Model nie użył narzędzi SolidCraft, więc projekt nie został zmieniony. Wybierz model obsługujący wywoływanie narzędzi; zobacz Modele lokalne w przewodniku czatu z agentem.",
@@ -5202,25 +4997,16 @@
"ui.agent_chat_side": "Strona czatu z agentem",
"ui.agent_chat_signed_in": "zalogowano",
"ui.agent_chat_snap_none_found": "Nie znaleziono agenta programistycznego. Pakiet Snap działa w izolacji, więc nie widzi agentów programistycznych zainstalowanych na tym komputerze i nie może ich uruchomić. Aby z nich korzystać, zainstaluj SolidCraft z pakietu AppImage, .deb lub .rpm. Agent korzystający z API zgodnego z OpenAI działa także w pakiecie Snap: skonfiguruj go przyciskiem Dodaj agenta.",
"ui.agent_chat_span_h": "{h} h {min} min",
"ui.agent_chat_span_min": "{n} min",
"ui.agent_chat_span_s": "{n} s",
"ui.agent_chat_state_auth": "Wymagane logowanie",
"ui.agent_chat_state_busy": "Pracuje…",
"ui.agent_chat_state_exited": "Agent zatrzymany",
"ui.agent_chat_state_failed": "Połączenie nieudane",
"ui.agent_chat_state_no_design": "Brak dostępu do projektu",
"ui.agent_chat_state_off": "Brak połączenia",
"ui.agent_chat_state_ready": "Gotowy",
"ui.agent_chat_state_starting": "Uruchamianie…",
"ui.agent_chat_stop": "Zatrzymaj",
"ui.agent_chat_stop_tip": "Zatrzymaj agenta i wszystko, co uruchomił",
"ui.agent_chat_stop_turn_tip": "Zatrzymaj agenta (Esc). Wiadomości w kolejce pozostają w kolejce.",
"ui.agent_chat_thinking_for": "Myśli od {time}",
"ui.agent_chat_tool_running_for": "działa od {time}",
"ui.agent_chat_tools_not_started": "Agent działa bez narzędzi SolidCraft, więc nie może odczytać ani zmienić projektu: znaleziono solidcraft-cli, ale jego narzędzia się nie uruchomiły (przyczynę podaje błąd w czacie). Połącz ponownie; jeśli to się powtarza, zainstaluj ponownie SolidCraft.",
"ui.agent_chat_transcript_assistant": "Asystent:",
"ui.agent_chat_transcript_user": "Użytkownik:",
"ui.agent_chat_turn_limit": "Zmiany na wiadomość",
"ui.agent_chat_turn_unlimited": "Bez limitu",
"ui.agent_chat_undo_changes": "Cofnij te zmiany",
@@ -5428,7 +5214,7 @@
"ui.comb_length_tip": "Długość zębów: wielokrotność długości automatycznej, przy której najdłuższy ząb ma jedną piątą rozmiaru pokazanych krzywych.",
"ui.comb_teeth": "Zęby",
"ui.comb_teeth_tip": "Liczba zębów każdego grzebienia, rozmieszczonych równo wzdłuż krzywej. Więcej zębów pokazuje dokładniej, jak zmienia się krzywizna.",
"ui.combine": "Operacja logiczna",
"ui.combine": "Kombinacja",
"ui.coming_later": "wkrótce",
"ui.command_done": "Wykonano: {command}",
"ui.command_palette_hint": "Wpisz polecenie, np. {example} – lub naciśnij S, aby wyszukać",
@@ -5439,24 +5225,14 @@
"ui.conic_curve": "Krzywa stożkowa",
"ui.constant": "Stały",
"ui.constraints_conflict": "Konflikt wiązań",
"ui.construct_auto.chosen": "Wybrano na podstawie wskazanych elementów. Wskaż inne odniesienie lub usuń jedno, aby zmienić metodę.",
"ui.construct_auto.no_fit": "Wskaż inne odniesienia: z tych nie da się zbudować elementu żadną metodą.",
"ui.construct_auto.pick": "Wskaż, z czego budować: metoda wynika z wybranych elementów.",
"ui.construct_auto.refs": "Odniesienia",
"ui.construct_auto.select": "Najpierw zaznacz odniesienia, z których budować",
"ui.construct_axes": "Osie",
"ui.construct_planes": "Płaszczyzny",
"ui.construct_points": "Punkty",
"ui.construction": "Konstrukcyjne",
"ui.construction.angle_hint": "Linia konstrukcyjna: kliknij punkt, a potem wpisz kąt lub wskaż kierunek",
"ui.construction.axis_hint": "Linia konstrukcyjna: kliknij punkt lub wpisz jego odległość od osi szkicu",
"ui.construction.bisector_hint": "Dwusieczna: wybierz dwie przecinające się proste linie, a potem żądane rozwiązanie",
"ui.construction.common_hint": "Wspólna styczna: wybierz dwa okręgi/łuki, a potem żądane rozwiązanie",
"ui.construction.contact_hint": "Styczna: wybierz okrąg/łuk lub otwarty splajn/krzywą stożkową, a potem punkt styczności (krzywe dowolne: punkty końcowe)",
"ui.construction.external_hint": "Styczna: wybierz okrąg lub łuk, punkt zewnętrzny, a potem żądane rozwiązanie",
"ui.construction.free_hint": "Linia konstrukcyjna: kliknij dwa punkty; prawy przycisk zaczyna od nowa",
"ui.construction.normal_needs_length": "Linia nieskończona jest zawsze konstrukcyjna: wpisz długość, aby narysować zwykłą linię",
"ui.construction.parallel_hint": "Linia konstrukcyjna: wybierz prostą linię, a potem kliknij lub wpisz odsunięcie; prawy przycisk zmienia odniesienie",
"ui.construction.point_hint": "Linia konstrukcyjna: kliknij punkt, przez który ma przechodzić",
"ui.construction.reference_hint": "Linia konstrukcyjna: wybierz prostą linię, a potem punkt; prawy przycisk zmienia odniesienie",
"ui.construction.ribbon_label": "Linie pom.",
@@ -5599,17 +5375,6 @@
"ui.ellipse": "Elipsa",
"ui.emboss_wrap_note": "Ortogonalne profile liniowe styczne do jednego obsługiwanego walca owijają się automatycznie. Wybierz ściankę, aby wskazać cel.",
"ui.end": "Koniec",
"ui.end_cap.chamfer": "Faza naroży",
"ui.end_cap.direction": "Kierunek",
"ui.end_cap.faces": "Ściany końcowe",
"ui.end_cap.inset": "Wpuszczenie",
"ui.end_cap.inset_kind": "Wpuszczenie według",
"ui.end_cap.inset_ratio": "Współczynnik wpuszczenia",
"ui.end_cap.inward": "Do wewnątrz",
"ui.end_cap.length": "Długość",
"ui.end_cap.outward": "Na zewnątrz",
"ui.end_cap.ratio": "Stosunek do grubości",
"ui.end_cap_title": "ZAŚLEPKA",
"ui.end_radius": "Promień końcowy",
"ui.end_scale": "Skala końcowa",
"ui.ends": "Końce",
@@ -5630,10 +5395,7 @@
"ui.explode_needs_two_components": "Widok rozstrzelony odsuwa od siebie komponenty, więc projekt potrzebuje co najmniej dwóch (Nowy komponent, Wstaw projekt lub Utwórz komponenty z brył)",
"ui.exportDxf": "Eksportuj DXF…",
"ui.export_ellipsis_keymap": "Eksportuj…",
"ui.export_glb": "Eksportuj GLB",
"ui.export_keymap_tip": "Klawisze, ulubione z okna S i pasek narzędzi, jako plik",
"ui.export_selects_only_body": "Zaznaczaj jedyną bryłę przy eksporcie siatki",
"ui.export_selects_only_body_tip": "Eksportuj STL, Eksportuj 3MF i Wyślij do slicera zaczynają z zaznaczoną bryłą projektu, gdy jest ona jedyna.",
"ui.extend": "Wydłuż",
"ui.extent": "Zakres",
"ui.extent_type": "Typ zakresu",
@@ -5693,8 +5455,6 @@
"ui.finest": "Najdokładniejsza",
"ui.finish_sketch": "Zakończ szkic",
"ui.first_dimension_scales": "Pierwszy wymiar skaluje rysunek",
"ui.first_dimension_scales_about_origin": "Skaluj względem początku układu szkicu",
"ui.first_dimension_scales_about_origin_tip": "Włączone: rysunek zawsze rośnie lub maleje względem początku układu szkicu, więc kształt narysowany z dala od niego się przesuwa. Wyłączone: rysunek, którego nic nie wiąże z początkiem układu, skaluje się względem własnego środka i zostaje tam, gdzie go narysowano.",
"ui.first_dimension_scales_tip": "Włączone: pierwszy wymiar długości w szkicu skaluje mierzony rysunek, więc jego kształt pozostaje ten sam. Wyłączone: wymiar zmienia tylko to, co mierzy.",
"ui.fit": "Dopasuj",
"ui.fix_unfix": "Ustal/zwolnij",
@@ -5725,7 +5485,7 @@
"ui.heading_chamfer": "FAZA",
"ui.heading_circular_pattern": "SZYK KOŁOWY",
"ui.heading_coil": "SPIRALA",
"ui.heading_combine": "OPERACJA LOGICZNA",
"ui.heading_combine": "KOMBINACJA",
"ui.heading_cone": "STOŻEK",
"ui.heading_configure": "KONFIGURACJA",
"ui.heading_constraints": "WIĄZANIA",
@@ -5749,7 +5509,7 @@
"ui.heading_home": "WIDOK GŁÓWNY",
"ui.heading_insert": "WSTAW",
"ui.heading_inspect": "SPRAWDŹ",
"ui.heading_interference": "KOLIZJA",
"ui.heading_interference": "INTERFERENCJA",
"ui.heading_loft": "WYCIĄGNIĘCIE ZŁOŻONE",
"ui.heading_make": "WYTWARZANIE",
"ui.heading_measure": "POMIAR",
@@ -5769,7 +5529,6 @@
"ui.heading_plastic": "TWORZYWA",
"ui.heading_position": "POŁOŻENIE",
"ui.heading_prepare": "PRZYGOTOWANIE",
"ui.heading_press_pull": "WCIŚNIJ/WYCIĄGNIJ",
"ui.heading_rectangular_pattern": "SZYK PROSTOKĄTNY",
"ui.heading_revolve": "OBRÓT",
"ui.heading_right": "PRAWY",
@@ -5792,7 +5551,7 @@
"ui.heading_torus": "TORUS",
"ui.heading_utilities": "NARZĘDZIA",
"ui.heading_utility": "NARZĘDZIE",
"ui.heading_weldments": "KONSTRUKCJE SPAWANE",
"ui.heading_weldments": "SPAWANIE",
"ui.height": "Wysokość",
"ui.help": "Pomoc",
"ui.hide": "Ukryj",
@@ -5846,7 +5605,7 @@
"ui.insert_design.unsaved": "Niezapisane zmiany",
"ui.inspect": "Sprawdź",
"ui.instances": "Wystąpienia",
"ui.intersect": "Przecięcie",
"ui.intersect": "Przytnij",
"ui.intersection_curve_second_pick": "Krzywa przecięcia: kliknij drugą ścianę lub krzywą (prawy przycisk zaczyna od nowa)",
"ui.iso_4753": "ISO 4753",
"ui.isolate": "Izoluj",
@@ -5906,8 +5665,6 @@
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "3MF ze znacznikami ścian",
"ui.mesh_format_stl": "STL",
"ui.mesh_import_origin": "Przenieś do początku układu",
"ui.mesh_import_origin_tip": "Wyśrodkowuje siatkę w X i Y, z podstawą na Z = 0. Gdy wyłączone, siatka zachowuje współrzędne zapisane w pliku.",
"ui.minor_radius": "Promień mniejszy",
"ui.mirror": "Lustro",
"ui.mirror_line": "Linia odbicia",
@@ -6012,42 +5769,6 @@
"ui.panel_host.surface": "Panel",
"ui.panel_host.tab": "Karta panelu",
"ui.panel_host.tabs": "Wszystkie karty",
"ui.panel_layout.applied": "Zastosowano {name} w środowisku {workspace}",
"ui.panel_layout.cancel": "Anuluj",
"ui.panel_layout.confirm_replace": "Zastąpić „{name}”?",
"ui.panel_layout.copy": "Kopiuj",
"ui.panel_layout.current": "Bieżący układ",
"ui.panel_layout.custom": "Własny",
"ui.panel_layout.custom_hint": "Panele przesunięto względem układu „{name}”.",
"ui.panel_layout.details": "Szczegóły",
"ui.panel_layout.layout_workspace": "Środowisko układu",
"ui.panel_layout.page_defaults_hint": "Resetuje ustawienia tej strony. Rozmieszczenie paneli i zapisane układy zostają zachowane.",
"ui.panel_layout.panel_layout": "Układ paneli",
"ui.panel_layout.panel_style": "Styl paneli",
"ui.panel_layout.replace": "Zastąp układ",
"ui.panel_layout.replace_current": "Zastąp bieżącym",
"ui.panel_layout.replace_short": "Zastąp",
"ui.panel_layout.reset": "Resetuj środowisko",
"ui.panel_layout.reset_hint": "Przywraca domyślne rozmieszczenie paneli w środowisku {workspace}. Zapisane układy zostają zachowane.",
"ui.panel_layout.reset_short": "Resetuj",
"ui.panel_layout.restore_failed": "Nie udało się przywrócić układu",
"ui.panel_layout.save": "Zapisz układ",
"ui.panel_layout.save_as": "Zapisz jako",
"ui.panel_layout.save_as_tile": "Zapisz jako…",
"ui.panel_layout.selected": "zaznaczony",
"ui.panel_layout.suggested_name": "Układ {n}",
"ui.panel_layout.try_again": "Spróbuj ponownie",
"ui.panel_layout.validation.builtin": "Wbudowanych układów nie można zmieniać",
"ui.panel_layout.validation.duplicate": "Ta nazwa jest już używana",
"ui.panel_layout.validation.empty": "Wpisz nazwę",
"ui.panel_layout.validation.full": "Zapisano 64 z 64. Wybierz jeden do zastąpienia.",
"ui.panel_layout.validation.invalid": "Użyj 1–64 znaków, bez znaków sterujących",
"ui.panel_layout.workspace.classic": "Klasyczny",
"ui.panel_layout.workspace.modelling": "Modelowanie",
"ui.panel_layout.workspace.render": "Renderowanie",
"ui.panel_layout.workspace.review": "Przegląd",
"ui.panel_layout.workspace.sketch": "Szkic",
"ui.panel_layout.your_layout": "twój układ",
"ui.parallel": "Równoległość",
"ui.parameters": "Parametry",
"ui.part_size_needs_a_part": "Zaznacz wstawioną część znormalizowaną (śrubę, nakrętkę, podkładkę lub kołek), aby zmienić jej rozmiar. Aby powiększyć lub pomniejszyć bryłę, użyj polecenia Skala.",
@@ -6070,7 +5791,7 @@
"ui.pitch": "Skok",
"ui.placement": "Umiejscowienie",
"ui.placement_handles": "Uchwyty umieszczania",
"ui.placement_handles_tip": "Znacznik do przeciągania operacji po modelu i, tam gdzie operację można obracać, pierścień do jej obracania.",
"ui.placement_handles_tip": "Uchwyty na podglądzie podparcia, występu lub zatrzasku: przeciągnij znacznik, aby przesunąć element po modelu, obróć go pierścieniem",
"ui.plane": "Płaszczyzna",
"ui.plane_at_angle": "Płaszczyzna pod kątem",
"ui.plugins": "Wtyczki",
@@ -6113,11 +5834,8 @@
"ui.profiles": "Profile",
"ui.project": "Rzutuj",
"ui.project_face_edges_sketch": "Rzutuj krawędzie ściany do nowych szkiców",
"ui.project_references_construction": "Rzutuj odniesienia jako geometrię konstrukcyjną",
"ui.project_references_construction_note": "Rzutuj, Przecięcie, Dołącz geometrię 3D oraz krawędzie ściany nowego szkicu na ścianie tworzą geometrię konstrukcyjną: odniesienia do rysowania, pomijane w profilach. Pozostają połączone z modelem.",
"ui.properties": "Właściwości",
"ui.properties.copy_matrix": "Kopiuj macierz",
"ui.properties.copy_value": "Kliknij, aby skopiować",
"ui.properties.first_moments": "Momenty statyczne",
"ui.properties.inertia": "Moment bezwładności",
"ui.properties.inertia_note": "Względem środka masy, w osiach globalnych (g·{unit}²)",
@@ -6130,7 +5848,6 @@
"ui.properties.measuring_failed": "Pomiar zatrzymał się przed zakończeniem.",
"ui.properties.principal_axis": "Oś główna {number}",
"ui.properties.principal_moments": "Główne momenty bezwładności",
"ui.properties.value_copied": "Skopiowano wartość",
"ui.pull_direction": "Kierunek wyjmowania",
"ui.quality": "Jakość",
"ui.quantity": "Liczba",
@@ -6151,10 +5868,8 @@
"ui.relink_design_changed": "Złożenie zmieniło się podczas wyboru źródła. Uruchom ponownie polecenie Połącz ponownie ze źródłem.",
"ui.relink_done": "Ponownie połączono źródło dla wszystkich wystąpień.",
"ui.relink_not_linked": "Ten komponent nie jest połączony z projektem źródłowym.",
"ui.relink_one_done": "Ponownie połączono źródło dla tego wystąpienia.",
"ui.relink_select_one": "Najpierw zaznacz jeden połączony komponent lub jedno z jego wystąpień.",
"ui.relink_source_all": "Połącz ponownie ze źródłem (wszystkie wystąpienia)…",
"ui.relink_source_one": "Połącz ponownie ze źródłem…",
"ui.removeFromQuickAccess": "Usuń z szybkiego dostępu",
"ui.removeFromToolbar": "Usuń z paska narzędzi",
"ui.removeSection": "Usuń przekrój",
@@ -6163,8 +5878,6 @@
"ui.renderer": "Renderer",
"ui.repeat": "Powtórz",
"ui.repeat_nothing": "Nie ma jeszcze czego powtórzyć: najpierw uruchom narzędzie lub okno.",
"ui.replace_sketch.from_file": "Z pliku projektu…",
"ui.replace_sketch.no_sketches": "Ten projekt nie ma szkicu do pobrania",
"ui.replace_with_link_done": "Kopie są teraz umiejscowieniami połączonego projektu.",
"ui.report_a_bug": "Zgłoś błąd",
"ui.report_characters_left_one": "Pozostało znaków: {count}",
@@ -6250,16 +5963,6 @@
"ui.sketch_intersect_selection_filter": "Filtr zaznaczania",
"ui.sketch_keys_only": "Nie używaj klawiszy modelu w szkicach",
"ui.sketch_keys_only_tip": "Podczas edycji szkicu działają tylko klawisze szkicu i klawisze działające wszędzie; klawisz modelu, np. E lub F, nic nie robi, więc przypadkowe naciśnięcie nie zamknie rozbudowanego szkicu.",
"ui.sketch_links_added_curve": "Polecenie {command} dodało do szkicu 1 krzywą.",
"ui.sketch_links_added_curves": "Polecenie {command} dodało krzywe do szkicu ({count}).",
"ui.sketch_links_added_curves_and_points": "Polecenie {command} dodało do szkicu: {curves} i {points}.",
"ui.sketch_links_added_point": "Polecenie {command} dodało do szkicu 1 punkt.",
"ui.sketch_links_added_points": "Polecenie {command} dodało punkty do szkicu ({count}).",
"ui.sketch_links_curve_count": "krzywe ({count})",
"ui.sketch_links_curve_count_one": "1 krzywa",
"ui.sketch_links_hidden": "Wynik jest ukryty, bo opcja {option} jest wyłączona w palecie szkicu.",
"ui.sketch_links_point_count": "punkty ({count})",
"ui.sketch_links_point_count_one": "1 punkt",
"ui.sketch_modifiers": "Modyfikatory szkicu",
"ui.sketch_project_geometry": "Geometria",
"ui.sketch_report.all_constrained": "Wszystko jest w pełni związane.",
@@ -6299,7 +6002,6 @@
"ui.step_values": "Zmieniaj wartości klawiszami strzałek i kółkiem",
"ui.step_values_tip": "W polu wartości, w którym piszesz, strzałki w górę i w dół zmieniają zwykłą liczbę o 1 (Shift: 10, Alt: 0,1), podobnie kółko myszy nad polem. Wyrażenia pozostają tak, jak je wpisano.",
"ui.stitch_tolerance_hint": "ciasna",
"ui.straighten": "Wyprostuj",
"ui.stretch": "Rozciągnij",
"ui.supersampling": "Supersampling (SSAA)",
"ui.supersampling_adaptive": "Tylko gdy widok jest nieruchomy",
@@ -6373,8 +6075,6 @@
"ui.view_angles_yaw": "Odchylenie",
"ui.view_angles_zero": "Ustaw {axis} na 0°",
"ui.view_centre": "Środek widoku",
"ui.view_compass_tip": "Widok od strony {axis}. Kliknij ponownie, aby odwrócić. Przeciągnij, aby obracać widok.",
"ui.view_compass_tip_snap": "Widok od strony {axis}. Kliknij ponownie, aby odwrócić. Przeciągnij, aby obracać widok; przytrzymaj Shift, aby przyciągać.",
"ui.viewport_status_clear": "Wyłącz {name}",
"ui.viewport_status_clear_hint": "Wyłącz {name} · Esc",
"ui.viewport_status_edges": "Krawędzie: {count}",
+2 -2
View File
@@ -2588,7 +2588,7 @@
"prefs.design_tools.imported_files": "Arquivos importados",
"prefs.design_tools.note": "Observação",
"prefs.design_tools.off": "Desativado",
"prefs.design_tools.placement_group": "Recursos posicionados",
"prefs.design_tools.placement_group": "Apoio, Ressalto, Encaixe por pressão",
"prefs.design_tools.rows": "Linhas",
"prefs.devices": "Mouse 3D",
"prefs.dialog_buttons": "Botões das caixas de diálogo",
@@ -5927,7 +5927,7 @@
"ui.pitch": "Passo",
"ui.placement": "Posicionamento",
"ui.placement_handles": "Alças de posicionamento",
"ui.placement_handles_tip": "Um marcador para arrastar o recurso pelo modelo e, onde o recurso pode girar, um anel para girá-lo.",
"ui.placement_handles_tip": "Um marcador para arrastar o recurso pelo modelo e um anel para girá-lo.",
"ui.plane": "Plano",
"ui.plane_at_angle": "Plano em ângulo",
"ui.plugins": "Plug-ins",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Pas",
"ui.placement": "Plasare",
"ui.placement_handles": "Mânere de plasare",
"ui.placement_handles_tip": "Un marcaj pentru a trage operația pe model și, acolo unde operația se poate roti, un inel pentru a o roti.",
"ui.placement_handles_tip": "Mânere pe previzualizarea unui Reazem, Bosaj sau a unei Prinderi cu clichet: trageți marcajul pentru a-l muta pe model, rotiți-l cu inelul său",
"ui.plane": "Plan",
"ui.plane_at_angle": "Plan în unghi",
"ui.plugins": "Pluginuri",
+1 -1
View File
@@ -4870,7 +4870,7 @@
"ui.pitch": "Шаг",
"ui.placement": "Размещение",
"ui.placement_handles": "Манипуляторы размещения",
"ui.placement_handles_tip": "Маркер для перетаскивания операции по модели и, где операцию можно поворачивать, кольцо для её поворота.",
"ui.placement_handles_tip": "Манипуляторы на предпросмотре «Опоры», «Бобышки» или «Защёлки»: перетащите маркер, чтобы переместить его по модели, поверните за кольцо",
"ui.plane": "Плоскость",
"ui.plane_at_angle": "Плоскость под углом",
"ui.plugins": "Плагины",
+1 -1
View File
@@ -4907,7 +4907,7 @@
"ui.pitch": "Корак",
"ui.placement": "Постављање",
"ui.placement_handles": "Ручице за постављање",
"ui.placement_handles_tip": "Ознака за превлачење операције по моделу и, где се операција може окретати, прстен за њено окретање.",
"ui.placement_handles_tip": "Ручице на прегледу ослонца, испуста или шкљоцајућег споја: превуците ознаку да је померите по моделу, окрените је њеним прстеном",
"ui.plane": "Раван",
"ui.plane_at_angle": "Раван под углом",
"ui.plugins": "Проширења",
+1 -1
View File
@@ -4916,7 +4916,7 @@
"ui.pitch": "Stigning",
"ui.placement": "Placering",
"ui.placement_handles": "Placeringshandtag",
"ui.placement_handles_tip": "En markör för att dra funktionen över modellen och, där funktionen kan vridas, en ring för att vrida den.",
"ui.placement_handles_tip": "Handtag på förhandsvisningen av ett stöd, nav eller snäppfäste: dra markören för att flytta det över modellen, vrid det med dess ring",
"ui.plane": "Plan",
"ui.plane_at_angle": "Plan i vinkel",
"ui.plugins": "Insticksmoduler",
+1 -1
View File
@@ -4916,7 +4916,7 @@
"ui.pitch": "Adım",
"ui.placement": "Yerleşim",
"ui.placement_handles": "Yerleşim tutamaçları",
"ui.placement_handles_tip": "Özelliği model üzerinde sürüklemek için bir işaretçi ve, özellik döndürülebiliyorsa, döndürmek için bir halka.",
"ui.placement_handles_tip": "Bir Dayama, Kabarıklık veya Kilit Geçme önizlemesindeki tutamaçlar: işaretçiyi model üzerinde hareket ettirmek için sürükleyin, halkasıyla döndürün",
"ui.plane": "Düzlem",
"ui.plane_at_angle": "Açılı düzlem",
"ui.plugins": "Eklentiler",
+2 -2
View File
@@ -2623,7 +2623,7 @@
"prefs.design_tools.imported_files": "Імпортовані файли",
"prefs.design_tools.note": "Примітка",
"prefs.design_tools.off": "Вимк.",
"prefs.design_tools.placement_group": "Розміщувані операції",
"prefs.design_tools.placement_group": "Упор, Бобишка, Защіпкове з'єднання",
"prefs.design_tools.rows": "Рядки",
"prefs.devices": "3D-миша",
"prefs.dialog_buttons": "Кнопки діалогу",
@@ -5989,7 +5989,7 @@
"ui.pitch": "Крок",
"ui.placement": "Розміщення",
"ui.placement_handles": "Ручки розміщення",
"ui.placement_handles_tip": "Маркер, щоб перетягнути елемент по моделі, і, де елемент можна повернути, кільце, щоб його повернути.",
"ui.placement_handles_tip": "Маркер, щоб перетягнути елемент по моделі, і кільце, щоб його повернути.",
"ui.plane": "Площина",
"ui.plane_at_angle": "Площина під кутом",
"ui.plugins": "Плагіни",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "Tệp đã nhập",
"prefs.design_tools.note": "Thông báo",
"prefs.design_tools.off": "Tắt",
"prefs.design_tools.placement_group": "Các tính năng đặt vị trí",
"prefs.design_tools.placement_group": "Gối đỡ, Trụ, Khớp gài",
"prefs.design_tools.rows": "Hàng",
"prefs.devices": "Chuột 3D",
"prefs.dialog_buttons": "Nút hộp thoại",
@@ -6070,7 +6070,7 @@
"ui.pitch": "Bước xoắn",
"ui.placement": "Vị trí đặt",
"ui.placement_handles": "Tay nắm định vị",
"ui.placement_handles_tip": "Dấu để kéo đặc trưng trên mô hình và, ở nơi đặc trưng có thể xoay, vòng để xoay.",
"ui.placement_handles_tip": "Dấu để kéo đặc trưng trên mô hình và vòng để xoay.",
"ui.plane": "Mặt phẳng",
"ui.plane_at_angle": "Mặt phẳng theo góc",
"ui.plugins": "Plugin",
+2 -2
View File
@@ -2588,7 +2588,7 @@
"prefs.design_tools.imported_files": "導入的檔案",
"prefs.design_tools.note": "提示",
"prefs.design_tools.off": "關閉",
"prefs.design_tools.placement_group": "放置的特徵",
"prefs.design_tools.placement_group": "支座、凸台、卡扣",
"prefs.design_tools.rows": "行數",
"prefs.devices": "3D 游標",
"prefs.dialog_buttons": "對話框按鈕",
@@ -5929,7 +5929,7 @@
"ui.pitch": "螺距",
"ui.placement": "放置",
"ui.placement_handles": "放置手柄",
"ui.placement_handles_tip": "用於在模型上拖動特徵的標記,以及在特徵可旋轉時用於旋轉的圓環。",
"ui.placement_handles_tip": "用於在模型上拖動特徵的標記和用於旋轉的圓環。",
"ui.plane": "平面",
"ui.plane_at_angle": "角度平面",
"ui.plugins": "插件",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "导入的文件",
"prefs.design_tools.note": "提示",
"prefs.design_tools.off": "关闭",
"prefs.design_tools.placement_group": "放置的特征",
"prefs.design_tools.placement_group": "支座、凸台、卡扣",
"prefs.design_tools.rows": "行数",
"prefs.devices": "3D 鼠标",
"prefs.dialog_buttons": "对话框按钮",
@@ -6070,7 +6070,7 @@
"ui.pitch": "螺距",
"ui.placement": "放置",
"ui.placement_handles": "放置手柄",
"ui.placement_handles_tip": "用于在模型上拖动特征的标记,以及在特征可旋转时用于旋转的圆环。",
"ui.placement_handles_tip": "用于在模型上拖动特征的标记和用于旋转的圆环。",
"ui.plane": "平面",
"ui.plane_at_angle": "角度平面",
"ui.plugins": "插件",
-1
View File
@@ -20,5 +20,4 @@ from an `App` value the app defines. No app types and no engine.
| `auth::tokens_match`, `auth::remove_token_file`, `auth::TokenFile`, `auth::pid_path`, `auth::MIN_TOKEN_LEN` | Constant-time compare and token file housekeeping |
| `Effect` | What a request still running does when it finishes: reads only, writes a file, or changes; `when_done(document)` says it |
| `server::Hub`, `server::Protocol`, `server::Summary`, `server::Waits`, `server::new_token` | The loopback JSON-lines server behind the token (desktop only); the app says what its requests mean |
| `client::Connection`, `client::Request`, `client::CallError`, `client::Waits` | The client (desktop only): the app's token on every request, one bounded round trip, a fresh connection after a lost one, the wait for a request still running (`control.pending`); "not running" told from "lost during the call" |
| `ControlRequest`, `ControlResponse`, `DocumentWatch`, `DOCUMENT_WATCH` | A request as the window gets it; the document id and revision subscribed connections wait on |
-616
View File
@@ -1,616 +0,0 @@
//! The control channel's client (docs/control-protocol.md): a connection to an app's loopback
//! control port, with the app's token ([`App::client_token`]) on every request, one bounded
//! round trip per request, a fresh connection after one is lost, and the wait for a request the
//! app says is still running (#1618), through `control.pending`. Its errors tell an app that is
//! not running from one that went away during a call ([`CallError`]). What a request means, and
//! anything an app adds to its parameters, is the caller's.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use crate::Effect;
use crate::auth::App;
/// Longest reply line accepted from the app.
pub const MAX_REPLY: u64 = 64 << 20;
/// How long a reply may take: kernel operations (booleans, fillets) on big parts can take a while.
pub const READ_TIMEOUT: Duration = Duration::from_secs(300);
/// How long the app may take to answer a request sent [`Request::once`]: a little longer than its
/// control server waits for a long request (3600 s), so its "still running" reply arrives first.
pub const ONCE_TIMEOUT: Duration = Duration::from_secs(3720);
/// How long connecting to the app may take.
const CONNECT_TIMEOUT: Duration = Duration::from_millis(800);
/// How often a long wait is reported ([`Connection::with_progress`]).
const PROGRESS_EVERY: Duration = Duration::from_secs(30);
/// How an error starts when a request is still running in the app after the wait: the request
/// was not cancelled and its reply waits on the connection; [`Connection::pending`] gets it.
pub const STILL_RUNNING: &str = "still running:";
/// How long to wait for a request the app says is still running.
#[derive(Clone, Copy, Debug)]
pub struct Waits {
/// How long a call goes on waiting, asking `control.pending`, after the app said its request
/// is still running (the app says so after 60 s), before it says so itself.
pub pending: Duration,
/// How long a later call, or [`Connection::pending`], waits for a request still running from
/// an earlier call.
pub settle: Duration,
/// How often `control.pending` is asked while waiting.
pub poll: Duration,
}
impl Default for Waits {
fn default() -> Self {
Waits { pending: Duration::from_secs(240), settle: Duration::from_secs(60), poll: Duration::from_millis(500) }
}
}
/// Why a call has no result. Each holds the message for the caller (an agent reads it).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CallError {
/// Nothing listens at the address: the app is not running, or it quit before the call was
/// sent. The message says how to start it.
NotRunning(String),
/// The app went away or did not answer during the call, after running it or not.
Lost(String),
/// The request is still running in the app after the wait (it starts with [`STILL_RUNNING`]),
/// or an earlier one is and this one was not sent.
StillRunning(String),
/// The app answered with an error (a refused token says which token file it came from).
Refused(String),
/// The reply was not a control reply.
BadReply(String),
}
impl CallError {
/// The message.
pub fn message(&self) -> &str {
match self {
CallError::NotRunning(m) | CallError::Lost(m) | CallError::StillRunning(m) | CallError::Refused(m) | CallError::BadReply(m) => m,
}
}
}
impl std::fmt::Display for CallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message())
}
}
impl std::error::Error for CallError {}
impl From<CallError> for String {
fn from(e: CallError) -> String {
e.message().to_string()
}
}
/// A request: its method and parameters, how messages name it and what it does if it is still
/// running when its call returns.
#[derive(Clone, Debug)]
pub struct Request {
pub method: String,
pub params: Value,
/// The request in messages ("`engine.execute` (solid.fillet)").
pub what: String,
/// What it still does when it finishes after its wait.
pub effect: Effect,
/// Never sent twice: on a fresh connection, waiting up to [`ONCE_TIMEOUT`], and once it is
/// being written any error is final, as the app may have run it. Otherwise a request that
/// finds its connection lost is sent once more on a fresh one (the app may have restarted).
pub once: bool,
}
impl Request {
/// `method` with `params`, named "`method`" in messages, taken to change the document, sent
/// again once on a fresh connection when its connection was lost.
pub fn new(method: &str, params: Value) -> Self {
Request { method: method.to_string(), params, what: format!("`{method}`"), effect: Effect::Changes, once: false }
}
}
/// A control reply as a call's result: its `result`, or its `error`.
pub fn outcome(v: &Value) -> Result<Value, String> {
if v.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
}
}
/// A request the app said is still running. Its reply will come on this connection, through
/// `control.pending`, so the connection is kept for it.
struct Outstanding {
what: String,
/// What it does when it finishes; `None`: not known (a request this client did not send).
effect: Option<Effect>,
since: Instant,
}
impl Outstanding {
fn secs(&self) -> u64 {
self.since.elapsed().as_secs()
}
}
/// What became of a request still running when its call returned, once a later call waited it
/// out: kept for [`Connection::pending`].
enum Settled {
Finished { what: String, reply: Value },
Lost { what: String },
}
/// The end of a wait for an outstanding request.
enum Waited {
/// It finished: its own reply.
Done(Value),
/// The app holds nothing (any more) for this connection.
Nothing,
/// Still running when the wait ran out.
Running,
}
/// A connection to the app listening at an address. It connects when a call needs it, so a
/// client started before the app reaches it once it runs.
pub struct Connection {
app: App,
/// What the app calls its document in messages ("design").
document: &'static str,
addr: String,
port: u16,
conn: Option<(BufReader<TcpStream>, TcpStream)>,
/// Counts the connections made, so a caller can tell a fresh one from the one it asked.
serial: u64,
next_id: u64,
/// The token given, when one was. Otherwise each request looks the token up again, since
/// every launch of the app makes a new one.
explicit_token: Option<String>,
/// A request still running in the app; its reply waits on `conn`.
outstanding: Option<Outstanding>,
/// An outstanding request's end, once a later call waited for it (for `pending`).
settled: Option<Settled>,
waits: Waits,
/// Where a long wait is reported.
progress: fn(&str),
}
impl Connection {
/// A connection to `app` at `addr` (`host:port`), with `token` when given, else the app's
/// token found the usual way ([`App::client_token`]); not connected yet.
pub fn new(app: App, addr: &str, token: Option<&str>) -> Self {
let port = addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(0);
Self {
app,
document: "design",
addr: addr.to_string(),
port,
conn: None,
serial: 0,
next_id: 1,
explicit_token: token.map(str::to_string),
outstanding: None,
settled: None,
waits: Waits::default(),
progress: |_| {},
}
}
/// What the app calls its document in messages ("design", the default).
#[must_use]
pub fn with_document(mut self, document: &'static str) -> Self {
self.document = document;
self
}
/// Report a long wait for a request still running to `progress`, every 30 s.
#[must_use]
pub fn with_progress(mut self, progress: fn(&str)) -> Self {
self.progress = progress;
self
}
/// How long to wait for a request still running.
pub fn set_waits(&mut self, waits: Waits) {
self.waits = waits;
}
/// The address it connects to.
pub fn addr(&self) -> &str {
&self.addr
}
/// The port of the address (0 when it names none).
pub fn port(&self) -> u16 {
self.port
}
/// The token given, if one was.
pub fn explicit_token(&self) -> Option<&str> {
self.explicit_token.as_deref()
}
/// Whether a connection is open now.
pub fn is_connected(&self) -> bool {
self.conn.is_some()
}
/// The number of connections made so far: the same number while the same connection stays
/// open.
pub fn serial(&self) -> u64 {
self.serial
}
/// Whether a request of this connection is still running in the app.
pub fn has_outstanding(&self) -> bool {
self.outstanding.is_some()
}
/// Close the connection. A request still running loses its reply with it.
pub fn disconnect(&mut self) {
self.conn = None;
if let Some(o) = self.outstanding.take() {
self.settled = Some(Settled::Lost { what: o.what });
}
}
/// Connect afresh. Failing to reach the app at all is `NotConnected`, so [`Connection::explain`]
/// can tell it from a connection lost during a call.
///
/// # Errors
/// `NotConnected` when nothing listens at the address (or it does not resolve).
pub fn connect(&mut self) -> std::io::Result<()> {
self.disconnect();
let not_connected = |e: std::io::Error| std::io::Error::new(std::io::ErrorKind::NotConnected, e);
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
for sa in self.addr.to_socket_addrs().map_err(not_connected)? {
match TcpStream::connect_timeout(&sa, CONNECT_TIMEOUT) {
Ok(s) => {
s.set_nodelay(true).ok();
s.set_read_timeout(Some(READ_TIMEOUT)).ok();
let read = s.try_clone()?;
self.conn = Some((BufReader::new(read), s));
self.serial += 1;
return Ok(());
}
Err(e) => last = e,
}
}
Err(not_connected(last))
}
/// See whether the app is there now; the error says why not. The connection doesn't stay
/// open: the app closes one that shows no token within a few seconds, and the first call may
/// be long away. That call connects again.
///
/// # Errors
/// As [`Connection::connect`].
pub fn try_connect(&mut self) -> std::io::Result<()> {
self.connect()?;
self.disconnect();
Ok(())
}
/// The error for a failed exchange: whether the app is not running (and how to start it) or
/// went away during the call.
pub fn explain(&self, e: &std::io::Error) -> CallError {
let (addr, port, name, document) = (&self.addr, self.port, self.app.name, self.document);
let start = format!("{} --control {port}", name.to_lowercase());
if e.kind() == std::io::ErrorKind::NotConnected {
CallError::NotRunning(format!(
"no {name} app is listening at {addr} ({e}): it is not running, or it quit. Start it with `{start}` (or ask the user to); the \
next call connects to it"
))
} else {
CallError::Lost(format!(
"the {name} app at {addr} did not answer ({e}): it may have quit or crashed during this call, after running it or not. Inspect \
the {document} before sending a change again; if the app quit, start it with `{start}`"
))
}
}
/// The request line for `method`: a fresh id and the app's token (looked up again for every
/// request: an app restarted mid-session has a new one).
pub fn request_line(&mut self, method: &str, params: Value) -> String {
let id = self.next_id;
self.next_id += 1;
let mut msg = json!({"id": id, "method": method, "params": params});
if let Some(t) = self.app.client_token(self.port, self.explicit_token.as_deref()) {
msg["token"] = json!(t.token);
}
msg.to_string()
}
/// One request line out and one reply line back, at most [`MAX_REPLY`] long, connecting
/// first when not connected. Any failure closes the connection (a timed-out read leaves a
/// late reply on the stream).
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
if self.conn.is_none() {
self.connect()?;
}
let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::other("not connected")) };
// One write: a request that loses its newline still reaches the app as a request.
let sent = writer.write_all(format!("{line}\n").as_bytes()).and_then(|()| writer.flush());
if let Err(e) = sent {
self.disconnect();
return Err(e);
}
let mut reply = String::new();
let n = match reader.by_ref().take(MAX_REPLY).read_line(&mut reply) {
Ok(n) => n,
Err(e) => {
self.disconnect();
return Err(e);
}
};
if n == 0 || !reply.ends_with('\n') {
self.disconnect();
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "connection closed"));
}
Ok(reply)
}
fn set_read_timeout(&self, t: Duration) {
if let Some((_, writer)) = &self.conn {
writer.set_read_timeout(Some(t)).ok();
}
}
/// A reply line as JSON.
fn parse(reply: &str) -> Result<Value, CallError> {
serde_json::from_str(reply).map_err(|e| CallError::BadReply(format!("bad reply from the app: {e}")))
}
/// A control reply as a call's result; a refused token says which token file it came from
/// (and whether it is stale).
///
/// # Errors
/// [`CallError::Refused`] with the app's error.
pub fn outcome(&self, v: &Value) -> Result<Value, CallError> {
outcome(v).map_err(|error| {
CallError::Refused(
self.app.client_token(self.port, self.explicit_token.as_deref()).map_or_else(|| error.clone(), |t| t.explain_error(&error)),
)
})
}
/// `method` with `params`, at once and once: not behind a request still running on this
/// connection, no second try. The app's whole reply (`ok`, `result` or `error`, and anything
/// it adds), for a request the app answers at once on any connection
/// (`control.capabilities`, `control.running`).
///
/// # Errors
/// As [`Connection::explain`]; [`CallError::BadReply`].
pub fn ask(&mut self, method: &str, params: Value) -> Result<Value, CallError> {
let line = self.request_line(method, params);
let reply = self.roundtrip(&line).map_err(|e| self.explain(&e))?;
Self::parse(&reply)
}
/// `method` with `params`, waiting at most `timeout` for the reply and never behind a request
/// still running: for a request the app answers within a frame, asked again later when the
/// app is busy. One try.
///
/// # Errors
/// [`CallError::StillRunning`] when a request of this connection is still running; as
/// [`Connection::ask`] and [`Connection::outcome`].
pub fn quick(&mut self, method: &str, params: Value, timeout: Duration) -> Result<Value, CallError> {
if self.outstanding.is_some() {
return Err(CallError::StillRunning("a request is still running in the app".into()));
}
if self.conn.is_none() {
self.connect().map_err(|e| self.explain(&e))?;
}
let line = self.request_line(method, params);
self.set_read_timeout(timeout);
let reply = self.roundtrip(&line);
self.set_read_timeout(READ_TIMEOUT);
let reply = reply.map_err(|e| self.explain(&e))?;
let v = Self::parse(&reply)?;
self.outcome(&v)
}
/// Ask the app, on this connection only, about the request it still runs for it.
fn ask_pending(&mut self) -> Result<Value, CallError> {
if self.conn.is_none() {
self.disconnect();
return Err(CallError::Lost("the connection to the app was lost".into()));
}
let v = self.ask("control.pending", json!({}))?;
outcome(&v).map_err(CallError::Refused).inspect_err(|_| self.disconnect())
}
/// `control.cancel` for the request still running on this connection. Sent at once, without
/// waiting for that request first, and never on a fresh connection, which has nothing to
/// cancel. Once the app no longer holds it (removed, stopped or finished), later calls don't
/// wait for it.
///
/// # Errors
/// As [`Connection::ask`]; [`CallError::Refused`].
pub fn cancel(&mut self, params: Value) -> Result<Value, CallError> {
if self.conn.is_none() {
return Ok(json!({"cancelled": null, "note": "nothing to cancel: no request of this client is running in the app"}));
}
let v = self.ask("control.cancel", params)?;
let r = outcome(&v).map_err(CallError::Refused);
if r.is_ok() {
self.outstanding = None;
}
r
}
/// Wait up to `limit` for the outstanding request, asking `control.pending` now and then and
/// reporting how long it takes. An error: the connection, and so the reply, was lost.
fn wait_outstanding(&mut self, limit: Duration) -> Result<Waited, CallError> {
let start = Instant::now();
let mut told = start;
loop {
let Some(o) = &self.outstanding else { return Ok(Waited::Nothing) };
let what = o.what.clone();
let r = self.ask_pending()?;
if r.get("pending").is_none_or(Value::is_null) {
self.outstanding = None;
return Ok(Waited::Nothing);
}
if r.get("done").and_then(Value::as_bool) == Some(true) {
self.outstanding = None;
return Ok(Waited::Done(r.get("reply").cloned().unwrap_or(Value::Null)));
}
let waited = start.elapsed();
if waited >= limit {
return Ok(Waited::Running);
}
if told.elapsed() >= PROGRESS_EVERY {
let secs = self.outstanding.as_ref().map_or(0, Outstanding::secs);
(self.progress)(&format!("{what} is still running in the app ({secs} s); waiting for it"));
told = Instant::now();
}
std::thread::sleep(self.waits.poll.min(limit.saturating_sub(waited)));
}
}
/// The error for the outstanding request still running after its wait.
fn still_running(&self, earlier: bool) -> CallError {
let Some(o) = &self.outstanding else { return CallError::StillRunning(format!("{STILL_RUNNING} a request is still running in the app")) };
let document = self.document;
let effect =
o.effect.map_or_else(|| format!("it was not cancelled and may still change the {document} when it finishes"), |e| e.when_done(document));
let settle = self.waits.settle.as_secs();
CallError::StillRunning(if earlier {
format!(
"{STILL_RUNNING} an earlier request, {}, is still running in the app ({} s; {effect}), so this call was not sent. Call the \
`pending` tool to wait for its result, then send this call again",
o.what,
o.secs()
)
} else {
format!(
"{STILL_RUNNING} {} is still running in the app after {} s ({effect}); don't send it again. Call the `pending` tool to \
wait for its result (up to {settle} s per call); other calls wait for it first",
o.what,
o.secs()
)
})
}
/// Before a new request: wait for one still running from an earlier call, keeping its end for
/// `pending`. Still running after the wait: the new request is not sent.
fn settle_earlier(&mut self) -> Result<(), CallError> {
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return Ok(()) };
match self.wait_outstanding(self.waits.settle) {
Ok(Waited::Done(reply)) => self.settled = Some(Settled::Finished { what, reply }),
Ok(Waited::Nothing) => {}
Ok(Waited::Running) => return Err(self.still_running(true)),
// The connection was lost, and the earlier reply with it (`pending` says so): the new
// request goes on a fresh connection.
Err(_) => {}
}
Ok(())
}
/// `control.pending` for the client: the end of the request still running from an earlier
/// call, waiting for it up to the settle wait: `{pending: what | null, done?, reply?, lost?,
/// running_s?}`. Never sent on a fresh connection, which has nothing pending.
pub fn pending(&mut self) -> Value {
if let Some(s) = self.settled.take() {
return match s {
Settled::Finished { what, reply } => json!({"pending": what, "done": true, "reply": reply}),
Settled::Lost { what } => json!({"pending": what, "lost": true}),
};
}
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return json!({"pending": null}) };
match self.wait_outstanding(self.waits.settle) {
Ok(Waited::Done(reply)) => json!({"pending": what, "done": true, "reply": reply}),
Ok(Waited::Nothing) => json!({"pending": null}),
Ok(Waited::Running) => json!({"pending": what, "done": false, "running_s": self.outstanding.as_ref().map_or(0, Outstanding::secs)}),
Err(_) => {
self.settled = None;
json!({"pending": what, "lost": true})
}
}
}
/// Send `request` and wait for its reply: the app's whole reply (`ok`, `result` or `error`,
/// and anything it adds). First a request still running from an earlier call is waited for
/// (except for `control.running`, which the app answers at once); a request the app says is
/// still running is waited for through `control.pending`; a request refused unrun as busy
/// behind one this client did not know of is sent once more after that one, when
/// `retry_busy`.
///
/// # Errors
/// As [`Connection::explain`]: a request whose connection is lost is sent once more on a fresh
/// one (unless [`Request::once`]), and when nothing listens any more the app went away, maybe
/// while running it ([`CallError::Lost`]); [`CallError::StillRunning`]; [`CallError::BadReply`].
pub fn exchange(&mut self, request: Request, retry_busy: bool) -> Result<Value, CallError> {
let Request { method, params, what, effect, once } = request;
if method != "control.running" {
self.settle_earlier()?;
}
let again = retry_busy.then(|| params.clone());
let sent = Instant::now();
let line = self.request_line(&method, params);
let reply = if once {
// A fresh connection (an idle one may have been closed by an app restart; connecting
// again sends nothing).
self.connect().or_else(|_| self.connect()).and_then(|()| {
self.set_read_timeout(ONCE_TIMEOUT);
let r = self.roundtrip(&line);
self.set_read_timeout(READ_TIMEOUT);
r
})
} else {
self.roundtrip(&line).or_else(|lost| {
self.roundtrip(&line).map_err(|e| if e.kind() == std::io::ErrorKind::NotConnected && lost.kind() != e.kind() { lost } else { e })
})
}
.map_err(|e| self.explain(&e))?;
let v = Self::parse(&reply)?;
// Timed out in the app, still running: wait for its result on this connection.
if v.get("running").and_then(Value::as_bool) == Some(true) {
self.outstanding = Some(Outstanding { what: what.clone(), effect: Some(effect), since: sent });
(self.progress)(&format!(
"{what} is still running in the app after {} s; waiting up to {} s more for it",
sent.elapsed().as_secs(),
self.waits.pending.as_secs()
));
let document = self.document;
return match self.wait_outstanding(self.waits.pending)? {
Waited::Done(reply) => Ok(reply),
Waited::Nothing => Err(CallError::Lost(format!(
"{what} timed out in the app, which kept no result for it: inspect the {document} before sending it again"
))),
Waited::Running => Err(self.still_running(false)),
};
}
// Refused unrun behind a request this client did not know of: wait for that one, then
// send this one once more.
if v.get("busy").and_then(Value::as_bool) == Some(true)
&& let Some(params) = again
{
self.outstanding = Some(Outstanding { what: "an earlier request".into(), effect: None, since: Instant::now() });
self.settle_earlier()?;
return self.exchange(Request { method, params, what, effect, once }, false);
}
Ok(v)
}
/// [`Connection::exchange`] (retrying a busy refusal once) and the reply's result.
///
/// # Errors
/// As [`Connection::exchange`] and [`Connection::outcome`].
pub fn call(&mut self, request: Request) -> Result<Value, CallError> {
let v = self.exchange(request, true)?;
self.outcome(&v)
}
}
#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;
-190
View File
@@ -1,190 +0,0 @@
//! The client against the shared server ([`crate::server::Hub`]) on an ephemeral loopback port.
use super::*;
use crate::ControlRequest;
use crate::server::{Hub, Protocol, Stop, Summary, Waits as ServerWaits};
use std::ffi::OsString;
use std::net::TcpListener;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::mpsc::channel;
const TOKEN: &str = "0123456789abcdef0123456789abcdef";
fn no_dir() -> Option<PathBuf> {
None
}
fn no_env(_: &str) -> Option<OsString> {
None
}
fn utc(secs: u64) -> String {
secs.to_string()
}
/// An app with no token files and no environment: a client sends only the token it is given.
const APP: App = App { name: "Probe", data_dir: no_dir, env: no_env, utc, default_port: 0 };
/// An app whose requests all may change its document.
struct Probe;
impl Protocol for Probe {
fn summary(&self, method: &str, _: &Value) -> Summary {
Summary { what: format!("`{method}`"), effect: Effect::Changes, command: None, steps: None, long: false }
}
fn document(&self) -> &'static str {
"design"
}
fn change_event(&self) -> &'static str {
"design.changed"
}
fn revision_method(&self) -> &'static str {
"document.revision"
}
fn docs(&self) -> &'static str {
"docs/control-protocol.md"
}
fn capabilities(&self) -> Value {
json!({})
}
}
/// The app on `port` (0: an ephemeral one) behind [`TOKEN`]: its window answers each request after
/// `delay` with the request's method and parameters, and the server says a request is still
/// running after `wait`.
fn serve(port: u16, delay: Duration, wait: Duration) -> Stop {
// A port just closed may take a moment to be free again on a loaded machine.
let started = Instant::now();
let mut bound = TcpListener::bind(("127.0.0.1", port));
while bound.is_err() && started.elapsed() < Duration::from_secs(2) {
std::thread::sleep(Duration::from_millis(10));
bound = TcpListener::bind(("127.0.0.1", port));
}
let listener = bound.unwrap();
let (tx, rx) = channel::<ControlRequest>();
std::thread::spawn(move || {
for req in rx {
std::thread::sleep(delay);
let _ = req.reply.send(json!({"ok": true, "result": {"method": req.method, "params": req.params}}));
}
});
let waits = ServerWaits { short: wait, long: wait, token: Duration::from_secs(5) };
let hub = Arc::new(Hub::new(Probe, || {}, 1 << 20).with_waits(waits));
hub.listen_stoppable(listener, TOKEN.into(), tx, false).unwrap()
}
fn addr(port: u16) -> String {
format!("127.0.0.1:{port}")
}
/// A port nothing listens on.
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}
#[test]
fn a_call_is_one_round_trip_with_the_token() {
let app = serve(0, Duration::ZERO, Duration::from_secs(10));
let mut c = Connection::new(APP, &addr(app.port()), Some(TOKEN));
assert!(!c.is_connected());
let r = c.call(Request::new("document.inspect", json!({"a": 1}))).unwrap();
assert_eq!(r, json!({"method": "document.inspect", "params": {"a": 1}}));
assert!(c.is_connected());
let serial = c.serial();
// the next call goes on the same connection
c.call(Request::new("document.inspect", json!({}))).unwrap();
assert_eq!(c.serial(), serial);
// `try_connect` sees the app there and leaves no connection open
let mut fresh = Connection::new(APP, &addr(app.port()), Some(TOKEN));
fresh.try_connect().unwrap();
assert!(!fresh.is_connected());
}
#[test]
fn a_wrong_token_or_none_is_refused() {
let app = serve(0, Duration::ZERO, Duration::from_secs(10));
for token in [Some("fedcba9876543210fedcba9876543210"), None] {
let mut c = Connection::new(APP, &addr(app.port()), token);
match c.call(Request::new("document.inspect", json!({}))) {
Err(CallError::Refused(e)) => assert!(e.starts_with("unauthorized"), "{token:?}: {e}"),
other => panic!("{token:?}: {other:?}"),
}
}
}
#[test]
fn an_app_not_running_says_how_to_start_it() {
let port = free_port();
let mut c = Connection::new(APP, &addr(port), Some(TOKEN));
let e = c.call(Request::new("document.inspect", json!({}))).unwrap_err();
let CallError::NotRunning(m) = &e else { panic!("{e:?}") };
assert!(m.starts_with(&format!("no Probe app is listening at 127.0.0.1:{port}")), "{m}");
assert!(m.contains(&format!("`probe --control {port}`")), "{m}");
assert_eq!(c.try_connect().unwrap_err().kind(), std::io::ErrorKind::NotConnected);
}
/// An app that goes away during a call is lost, not "not running"; the next call says it is not
/// running, and once it runs again the next call reaches it.
#[test]
fn an_app_gone_during_a_call_is_lost_and_reached_again_once_back() {
let app = serve(0, Duration::ZERO, Duration::from_secs(10));
let port = app.port();
let mut c = Connection::new(APP, &addr(port), Some(TOKEN));
c.call(Request::new("document.inspect", json!({}))).unwrap();
app.stop();
let e = c.call(Request::new("document.inspect", json!({}))).unwrap_err();
let CallError::Lost(m) = &e else { panic!("{e:?}") };
assert!(m.contains("may have quit") && m.contains("Inspect the design") && m.contains(&format!("`probe --control {port}`")), "{m}");
assert!(matches!(c.call(Request::new("document.inspect", json!({}))), Err(CallError::NotRunning(_))));
let _back = serve(port, Duration::ZERO, Duration::from_secs(10));
assert_eq!(c.call(Request::new("ui.view", json!({}))).unwrap()["method"], json!("ui.view"));
}
/// A server that reads a request and closes the connection without a reply: the call is lost
/// (after one more try on a fresh connection), and the reply is never taken for "not running".
#[test]
fn a_connection_closed_without_a_reply_is_lost() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let mut line = String::new();
let _ = BufReader::new(stream).read_line(&mut line);
}
});
let mut c = Connection::new(APP, &addr(port), Some(TOKEN));
let e = c.call(Request::new("document.inspect", json!({}))).unwrap_err();
assert!(matches!(e, CallError::Lost(_)), "{e:?}");
assert!(!c.is_connected());
}
/// A request the app says is still running is waited for through `control.pending`, on the same
/// connection, and its own reply is the call's result.
#[test]
fn a_request_still_running_is_waited_for_through_pending() {
let app = serve(0, Duration::from_millis(400), Duration::from_millis(50));
let mut c = Connection::new(APP, &addr(app.port()), Some(TOKEN));
c.set_waits(Waits { pending: Duration::from_secs(10), settle: Duration::from_secs(10), poll: Duration::from_millis(5) });
let r = c.call(Request::new("solid.slow", json!({"n": 2}))).unwrap();
assert_eq!(r, json!({"method": "solid.slow", "params": {"n": 2}}));
assert!(!c.has_outstanding());
assert_eq!(c.pending(), json!({"pending": null}));
}
/// Past the wait, the call says the request is still running, and `pending` later has its end.
#[test]
fn a_request_still_running_past_the_wait_is_reported_and_its_end_kept() {
let app = serve(0, Duration::from_millis(400), Duration::from_millis(20));
let mut c = Connection::new(APP, &addr(app.port()), Some(TOKEN));
c.set_waits(Waits { pending: Duration::from_millis(30), settle: Duration::from_secs(10), poll: Duration::from_millis(5) });
let mut slow = Request::new("solid.slow", json!({}));
slow.what = "`solid.slow` (now)".into();
let e = c.call(slow).unwrap_err();
let CallError::StillRunning(m) = &e else { panic!("{e:?}") };
assert!(m.starts_with(STILL_RUNNING) && m.contains("`solid.slow` (now)") && m.contains("will still apply"), "{m}");
assert!(c.has_outstanding());
let end = c.pending();
assert_eq!(end["done"], json!(true), "{end}");
assert_eq!(end["reply"]["result"]["method"], json!("solid.slow"), "{end}");
}
+4 -7
View File
@@ -1,17 +1,14 @@
//! The control channel any app may share (docs/control-protocol.md): the per-launch token and its
//! files ([`auth`]), the loopback server that admits a connection by the token and hands each
//! request to the window ([`server`]), the client that talks to it ([`client`]), the request the
//! window answers ([`ControlRequest`]), the document watch subscribed connections wait on
//! ([`DocumentWatch`]), and what a request still running does when it finishes ([`Effect`]). It
//! needs no UI toolkit; the window's own requests over egui (`ui.screenshot`, `ui.resize`) are
//! `solidcraft-control-egui`.
//! request to the window ([`server`]), the request the window answers ([`ControlRequest`]), the
//! document watch subscribed connections wait on ([`DocumentWatch`]), and what a request still running
//! does when it finishes ([`Effect`]). It needs no UI toolkit; the window's own requests over egui
//! (`ui.screenshot`, `ui.resize`) are `solidcraft-control-egui`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod auth;
mod cancel;
#[cfg(not(target_arch = "wasm32"))]
pub mod client;
mod effect;
mod request;
#[cfg(not(target_arch = "wasm32"))]
-5
View File
@@ -19,14 +19,9 @@ solidcraft-kernel = { workspace = true }
solidcraft-profile = { workspace = true }
solidcraft-render = { workspace = true }
solidcraft-derive = { workspace = true }
solidcraft-format = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true, features = ["raw_value"] }
thiserror = { workspace = true }
log = { workspace = true }
sha2 = { workspace = true }
web-time = { workspace = true }
# Lists the format change files (build.rs).
[build-dependencies]
solidcraft-format = { workspace = true }
+72 -8
View File
@@ -1,12 +1,18 @@
//! Lists the format changes of each family of files versioned by `src/format.rs`: designs in
//! `src/format/`, drawings in `src/drawing/format/` (#3890). One file per change, in `v<N>/` once a
//! release fixed its number, else in `pending/` (see `src/format/pending/README.md`). The lists are
//! generated (`solidcraft_format::build`) so that a change adds a file and never edits a line
//! another change also edits.
//! generated so that a change adds a file and never edits a line another change also edits.
use std::path::PathBuf;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use solidcraft_format::build::Family;
/// A family's change files: their folder, the last format before them, and the prefix of the
/// family's lists in `format.rs`.
struct Family {
folder: &'static str,
base: u32,
list_prefix: &'static str,
}
/// Every family, each read by `scripts/fold-format-pending.sh` too. A design's upgrade steps are
/// written out in `format.rs` up to 22 (`JSON_STEPS`); a drawing's first format is 1.
@@ -15,10 +21,68 @@ const FAMILIES: [Family; 2] =
fn main() {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let lists = solidcraft_format::build::lists(&manifest, &FAMILIES).unwrap_or_else(|e| panic!("{e}"));
for folder in &lists.watch {
println!("cargo:rerun-if-changed={}", folder.display());
let mut out = String::new();
let mut mods = String::new();
for family in &FAMILIES {
lists(family, &manifest, &mut out, &mut mods);
}
out.push_str(&mods);
let dest = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("format_parts.rs");
std::fs::write(dest, lists.code).unwrap();
std::fs::write(dest, out).unwrap();
}
/// A family's `FOLDED` and `PENDING` lists; its folded formats follow on from its base.
fn lists(family: &Family, manifest: &Path, out: &mut String, mods: &mut String) {
let Family { folder, base, list_prefix } = family;
let root = manifest.join(folder);
println!("cargo:rerun-if-changed={}", root.display());
let mut folded: Vec<(u32, PathBuf)> = std::fs::read_dir(&root)
.unwrap_or_else(|e| panic!("{}: {e}", root.display()))
.filter_map(|entry| {
let path = entry.ok()?.path();
let version = path.file_name()?.to_str()?.strip_prefix('v')?.parse().ok()?;
path.is_dir().then_some((version, path))
})
.collect();
folded.sort();
for (i, (version, _)) in folded.iter().enumerate() {
let expected = base + 1 + i as u32;
assert_eq!(*version, expected, "{folder}/: expected v{expected}/ next, found v{version}/ (versions are contiguous)");
}
// Module names start with the family's (`drawing_pending_…`), so two families' changes of one
// name never clash.
let module = list_prefix.to_ascii_lowercase();
let _ = writeln!(
out,
"/// The changes of each format after the base, oldest first (`{folder}/v<N>/`).\nconst {list_prefix}FOLDED: &[(u32, &[Part])] = &["
);
for (version, dir) in &folded {
let _ = writeln!(out, " ({version}, &[");
parts(dir, &format!("{module}v{version}"), out, mods);
out.push_str(" ]),\n");
}
let _ = writeln!(out, "];\n\n/// The changes no release has a number for yet (`{folder}/pending/`).\nconst {list_prefix}PENDING: &[Part] = &[");
parts(&root.join("pending"), &format!("{module}pending"), out, mods);
out.push_str("];\n\n");
}
/// The `Part`s of the change files in `dir`, in file-name order, and a module for each.
fn parts(dir: &Path, prefix: &str, out: &mut String, mods: &mut String) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut files: Vec<PathBuf> = entries.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x == "rs")).collect();
files.sort();
for path in files {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_string();
assert!(
!name.is_empty() && name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'),
"{}: name a format change file with lowercase letters, digits and dashes",
path.display()
);
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.trim_start().starts_with("//!"), "{}: start a format change with a `//!` note", path.display());
let module = format!("{prefix}_{}", name.replace('-', "_"));
let step = if text.contains("fn step(") { format!("Some({module}::step)") } else { "None".into() };
let _ = writeln!(out, " Part {{ name: {name:?}, step: {step} }},");
let _ = writeln!(mods, "#[path = {:?}]\nmod {module};", path.display().to_string());
}
}
-1
View File
@@ -721,7 +721,6 @@ mod tests {
width: "5 mm".into(),
height: "10 mm".into(),
operation: crate::Operation::NewBody,
centred: false,
},
None,
)
-9
View File
@@ -866,7 +866,6 @@ pub enum FeatureKind {
tangent_chain: bool,
},
Box {
/// The box's lowest corner, or its centre when `centred` is set.
#[serde(with = "point_expr")]
#[walk(placement)]
corner: PointExpr,
@@ -878,10 +877,6 @@ pub enum FeatureKind {
height: Expr,
#[serde(default)]
operation: Operation,
/// Placed by its centre: `corner` is the centre, so a new length, width or height grows the box
/// on both sides and it stays centred (#14487).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
centred: bool,
},
Cylinder {
#[serde(with = "point_expr")]
@@ -1197,10 +1192,6 @@ pub enum FeatureKind {
tool: Option<FaceAt>,
#[serde(default, skip_serializing_if = "Option::is_none")]
sketch: Option<SketchId>,
/// With `sketch`: its curves joined end to end first, so a closed loop cuts along itself
/// only. Off (each curve on its own, an open one extended past its ends) in older designs.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
loops: bool,
},
/// Scale bodies about a point (uniform, or per axis).
Scale {
+1 -1
View File
@@ -37,7 +37,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile};
use crate::format::{CheckNumbers as _, DRAWING};
use crate::format::DRAWING;
use crate::ids::{self, IdSource};
use crate::{DocError, Metadata, Result, expr};
+12 -35
View File
@@ -6081,12 +6081,6 @@ fn hole_references(f: &Feature) -> &[crate::HoleReference] {
/// Where a cylinder stands: its base, or with reference edges (#6380) at their distances in the
/// plane of the flat face at its base, square to its axis.
/// A box's lowest corner from its stored point and sides: the point itself, or the box's centre
/// when it was placed by its centre (#14487).
fn box_low_corner(at: Vec3, size: Vec3, centred: bool) -> Vec3 {
if centred { at - size * 0.5 } else { at }
}
fn cylinder_base(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature, base: &crate::PointExpr, axis: Vec3) -> Result<Vec3> {
let at = point(vals, base)?;
with_references(vals, st, f, at, -axis, true)
@@ -7442,9 +7436,9 @@ fn feature_tools_with_constructor(
let plane = Plane::from_normal(start, t).ok_or_else(|| DocError::Invalid("pipe profile plane".into()))?;
Ok(unnamed(vec![kernel::sweep(&plane, &pipe_region(vals, r, wall)?, &segs)?]))
}
FeatureKind::Box { corner, length, width, height, centred, .. } => {
FeatureKind::Box { corner, length, width, height, .. } => {
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
let corner = box_low_corner(point(vals, corner)?, s, *centred);
let corner = point(vals, corner)?;
Ok(vec![crate::lineage::box_tool(kernel::box_solid(corner, corner + s)?).with_recipe(tool_recipe(doc, f, vals))])
}
FeatureKind::Cylinder { base, axis, radius, height, .. } => Ok(vec![
@@ -9715,7 +9709,7 @@ fn eval_feature(
}
apply_traced(doc, st, f, made, *operation, &[])
}
FeatureKind::SplitFace { faces, body, plane, tool, sketch, loops } => {
FeatureKind::SplitFace { faces, body, plane, tool, sketch } => {
let i = body_at(st, body, &crate::plain(faces))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let mut picked: Vec<usize> = Vec::new();
@@ -9735,18 +9729,12 @@ fn eval_feature(
}
(None, Some(sid)) => {
let ss = st.sketch(sid.0).ok_or_else(|| DocError::Unknown(format!("sketch {sid}")))?;
// Each curve's exact segments (lines and arcs split by their own plane and cylinder),
// or with `loops` the curves joined end to end: a closed chain is not extended.
let curves: Vec<Vec<solidcore_record::Seg2>> = if *loops {
let ids: Vec<String> = ss.sketch.curves.iter().filter(|c| !c.construction).map(|c| c.id.clone()).collect();
surface::curve_chains(ss, &ids)?
} else {
(0..ss.sketch.curves.len())
.filter(|ci| ss.sketch.curves.get(*ci).is_some_and(|c| !c.construction))
.map(|ci| ss.sketch.segs(ci))
.filter(|segs| !segs.is_empty())
.collect()
};
// Each curve's exact segments (lines and arcs split by their own plane and cylinder).
let curves: Vec<Vec<solidcore_record::Seg2>> = (0..ss.sketch.curves.len())
.filter(|ci| ss.sketch.curves.get(*ci).is_some_and(|c| !c.construction))
.map(|ci| ss.sketch.segs(ci))
.filter(|segs| !segs.is_empty())
.collect();
if curves.is_empty() {
return Err(DocError::Invalid(format!("sketch `{}` has no curves to split with", ss.name)));
}
@@ -9999,9 +9987,9 @@ fn eval_feature(
}
Ok(())
}
FeatureKind::Box { corner, length, width, height, operation, centred } => {
FeatureKind::Box { corner, length, width, height, operation } => {
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
let corner = box_low_corner(point(vals, corner)?, s, *centred);
let corner = point(vals, corner)?;
let b = kernel::box_solid(corner, corner + s)?;
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b };
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation, false, warning)?;
@@ -11201,7 +11189,6 @@ mod world_placement_cache_tests {
width: "3 mm".into(),
height: "4 mm".into(),
operation: crate::Operation::NewBody,
centred: false,
},
Some("AuthoredBox"),
)
@@ -11238,14 +11225,7 @@ mod world_placement_cache_tests {
let mut boxes = |comp: u64, operation: Operation, corner: Vec3| {
let id = doc
.add_feature(
FeatureKind::Box {
corner: crate::point_expr(corner),
length: "10".into(),
width: "10".into(),
height: "2".into(),
operation,
centred: false,
},
FeatureKind::Box { corner: crate::point_expr(corner), length: "10".into(), width: "10".into(), height: "2".into(), operation },
None,
)
.unwrap();
@@ -11395,7 +11375,6 @@ mod chain_tests {
width: "30".into(),
height: "20".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -11442,7 +11421,6 @@ mod chain_tests {
width: w.into(),
height: h.into(),
operation,
centred: false,
};
doc.add_feature(block(Vec3::new(0.0, 0.0, 20.0), ["40", "20", "20"], Operation::NewBody), None).unwrap();
doc.add_feature(block(Vec3::new(20.0, 0.0, 0.0), ["20", "20", "20"], Operation::Join), None).unwrap();
@@ -11489,7 +11467,6 @@ mod chain_tests {
width: "20".into(),
height: "20".into(),
operation: Operation::NewBody,
centred: false,
};
doc.add_feature(solid, None).unwrap();
doc.add_feature(fillet(Vec3::new(20.0, 10.0, 20.0), "2", true), None).unwrap();
+4 -13
View File
@@ -574,7 +574,6 @@ fn plate_with_holes(doc: &mut Document, comp: u64, tools: u64, holes: usize) ->
width: "10".into(),
height: "4".into(),
operation: Operation::NewBody,
centred: false,
};
let plate = doc.add_feature(kind, None).unwrap();
doc.feature_mut(plate).unwrap().component = comp;
@@ -583,7 +582,7 @@ fn plate_with_holes(doc: &mut Document, comp: u64, tools: u64, holes: usize) ->
let body = model.state().bodies.iter().find(|b| b.feature == plate).unwrap().name.clone();
for k in 0..holes {
let corner = point_expr(Vec3::new(10.0 * k as f64 + 4.0, 4.0, -1.0));
let kind = FeatureKind::Box { corner, length: "2".into(), width: "2".into(), height: "6".into(), operation: Operation::Cut, centred: false };
let kind = FeatureKind::Box { corner, length: "2".into(), width: "2".into(), height: "6".into(), operation: Operation::Cut };
let id = doc.add_feature(kind, None).unwrap();
let f = doc.feature_mut(id).unwrap();
f.component = tools;
@@ -843,7 +842,6 @@ fn shared_evaluation_requires_its_actual_model_and_unmodified_geometry_inputs()
width: "3".into(),
height: "4".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -989,8 +987,7 @@ fn saved_states_read_back_their_sketch_keys() {
doc.add_feature(line_sketch(PlaneRef::Origin { name: "XY".into() }, Vec2::new(f64::from(i) + 1.0, 1.0)), None).unwrap();
for j in 0..3 {
let corner = point_expr(Vec3::new(f64::from(i) * 20.0, f64::from(j) * 20.0, 0.0));
let kind =
FeatureKind::Box { corner, length: "5".into(), width: "5".into(), height: "5".into(), operation: Operation::NewBody, centred: false };
let kind = FeatureKind::Box { corner, length: "5".into(), width: "5".into(), height: "5".into(), operation: Operation::NewBody };
doc.add_feature(kind, None).unwrap();
}
}
@@ -1076,14 +1073,13 @@ fn blocks_with_cuts(doc: &mut Document, comp: u64, blocks: usize, cuts: usize) {
let y = 100.0 * comp as f64;
for k in 0..blocks {
let corner = point_expr(Vec3::new(20.0 * k as f64, y, 0.0));
let kind =
FeatureKind::Box { corner, length: "10".into(), width: "10".into(), height: "4".into(), operation: Operation::NewBody, centred: false };
let kind = FeatureKind::Box { corner, length: "10".into(), width: "10".into(), height: "4".into(), operation: Operation::NewBody };
let id = doc.add_feature(kind, None).unwrap();
doc.feature_mut(id).unwrap().component = comp;
}
for k in 0..cuts {
let corner = point_expr(Vec3::new(20.0 * k as f64 + 4.0, y + 4.0, -1.0));
let kind = FeatureKind::Box { corner, length: "2".into(), width: "2".into(), height: "6".into(), operation: Operation::Cut, centred: false };
let kind = FeatureKind::Box { corner, length: "2".into(), width: "2".into(), height: "6".into(), operation: Operation::Cut };
let id = doc.add_feature(kind, None).unwrap();
doc.feature_mut(id).unwrap().component = comp;
}
@@ -1285,7 +1281,6 @@ fn block(doc: &mut Document, name: &str, corner: Vec3, size: [f64; 3]) {
width: size[1].to_string().into(),
height: size[2].to_string().into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -1349,7 +1344,6 @@ fn a_cut_still_runs_where_its_tool_touches_grazes_or_meets_a_curved_face() {
width: "40".into(),
height: "10".into(),
operation: Operation::Cut,
centred: false,
};
assert_eq!(cut_work(&mut doc, along).cut, 1);
// A dome: a sphere's cap rising 10 mm above the circle it stands on. A thin tool drilled into
@@ -1363,7 +1357,6 @@ fn a_cut_still_runs_where_its_tool_touches_grazes_or_meets_a_curved_face() {
width: "60".into(),
height: "40".into(),
operation: Operation::Cut,
centred: false,
};
doc.add_feature(below, None).unwrap();
let mut model = Model::new();
@@ -2033,7 +2026,6 @@ fn an_ambiguous_target_is_refused_before_a_sheet_is_machined() {
width: "10".into(),
height: "2".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -2048,7 +2040,6 @@ fn an_ambiguous_target_is_refused_before_a_sheet_is_machined() {
width: "5".into(),
height: "20".into(),
operation: Operation::Cut,
centred: false,
},
None,
)
+1 -1
View File
@@ -824,7 +824,7 @@ const BLEND_STATIONS: usize = 41;
/// The sketch curves `ids` (free-form ones exactly where they can be) grouped into chains of segments that meet end to end, each oriented
/// to run one way. Curves are joined greedily at their free ends: where three meet, one of them
/// starts a chain of its own.
pub(super) fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
if ids.len() > MAX_CHAIN_CURVES {
return Err(DocError::Invalid(format!("at most {MAX_CHAIN_CURVES} curves")));
}
+212 -78
View File
@@ -243,30 +243,6 @@ use serde_json::Value;
use crate::{DocError, Document, Result};
pub use solidcraft_format::{Alias, Newer};
/// A kind of file whose format is versioned here: designs, and drawings (#3890). Each family has
/// its own format tag, its own numbers and its own folder of change files (`build.rs` lists them);
/// reading a file's format, telling a file a newer build saved and the upgrade are the same code
/// for every family, the shared `solidcraft_format`.
pub type Family<'a> = solidcraft_format::Family<'a, DocError>;
/// A family's upgrade path: the steps written out by hand (designs' up to [`HAND_WRITTEN`]), the
/// folded formats after them (each a list of changes) and the pending changes, which make the
/// next format.
pub(crate) type Formats<'a> = solidcraft_format::Formats<'a, DocError>;
/// One change to a family's file format: a file in its `v<N>/` or `pending/` folder.
pub(crate) type Part = solidcraft_format::Part<DocError>;
impl From<solidcraft_format::FormatError> for DocError {
fn from(e: solidcraft_format::FormatError) -> Self {
match e {
solidcraft_format::FormatError::Invalid(why) => DocError::Invalid(why),
}
}
}
/// The format this build writes: the last folded one, or the next while changes are pending.
pub const FORMAT: u32 = FORMATS.current();
@@ -274,61 +250,223 @@ pub const FORMAT: u32 = FORMATS.current();
/// of [`Part`]s (`src/format/`, listed by `build.rs`).
const HAND_WRITTEN: u32 = 22;
/// One change to a family's file format: a file in its `v<N>/` or `pending/` folder.
pub(crate) struct Part {
/// The file's name, which a file written while the change was pending lists.
pub name: &'static str,
/// How a file from before the change is rewritten as it opens, if it is.
pub step: Option<JsonStep>,
}
include!(concat!(env!("OUT_DIR"), "/format_parts.rs"));
/// Every format this build knows and how to upgrade a design through them.
const FORMATS: Formats<'static> = Formats { hand: &JSON_STEPS, folded: FOLDED, pending: PENDING };
/// Another spelling a design of format 29 may carry: the personal format-29 renderer's name for
/// the engineering material cards. Its card schema is unchanged; recognising the alias must never
/// grant support to another reader, so it counts only while this build has the change.
const DESIGN_ALIASES: [Alias; 1] = [Alias { format: 29, name: "physical-material-cards", known_as: "material-engineering-cards" }];
/// A kind of file whose format is versioned here: designs, and drawings (#3890). Each family has
/// its own format tag, its own numbers and its own folder of change files (`build.rs` lists them);
/// reading a file's format, telling a file a newer build saved and the upgrade are the same code
/// for every family.
pub struct Family<'a> {
/// What a file of the family is called in messages.
noun: &'static str,
/// The tag before the number in a file's `format`.
prefix: &'static str,
/// The tag files saved before the rename carry: read, never written (docs/compatibility.md).
legacy_prefix: Option<&'static str>,
/// The format of a file that names none: designs saved before format strings are format 1; a
/// drawing always names its own.
untagged: Option<u32>,
formats: Formats<'a>,
}
/// Designs (`solidcraft/N`): the upgrade steps written out up to format 22, then the change files
/// in `src/format/`.
pub const DESIGN: Family<'static> = Family {
app: "SolidCraft",
noun: "design",
prefix: "solidcraft/",
legacy_prefix: Some(LEGACY_FORMAT_PREFIX),
untagged: Some(1),
aliases: &DESIGN_ALIASES,
formats: FORMATS,
};
pub const DESIGN: Family<'static> =
Family { noun: "design", prefix: "solidcraft/", legacy_prefix: Some(LEGACY_FORMAT_PREFIX), untagged: Some(1), formats: FORMATS };
/// Drawings (`solidcraft-drawing/N`, #3890): format 1 is the first drawing record, and every later
/// change is a file in `src/drawing/format/`.
pub const DRAWING: Family<'static> = Family {
app: "SolidCraft",
noun: "drawing",
prefix: "solidcraft-drawing/",
legacy_prefix: None,
untagged: None,
aliases: &[],
formats: Formats { hand: &[], folded: DRAWING_FOLDED, pending: DRAWING_PENDING },
};
/// Every number a file of a family holds is in range ([`check_numbers`]), but for its top-level
/// `kept` field: other tools' data (a design's `metadata`, a drawing's `properties`), kept as it is
/// and never read as a number (#1015); parsing already bounds its nesting.
pub(crate) trait CheckNumbers {
fn check_numbers(&self, v: &Value, kept: &str) -> Result<()>;
}
impl Family<'_> {
/// The format this build writes: the last folded one, or the next while changes are pending.
pub const fn current(&self) -> u32 {
self.formats.current()
}
impl CheckNumbers for Family<'_> {
fn check_numbers(&self, v: &Value, kept: &str) -> Result<()> {
/// The names of the pending changes this build writes into a file (`format_pending`).
pub fn pending_names(&self) -> Vec<String> {
self.formats.pending.iter().map(|p| p.name.to_string()).collect()
}
/// The format tag of format `n`.
pub fn format_string(&self, n: u32) -> String {
format!("{}{n}", self.prefix)
}
/// The version in one of this family's format tags; `None` for any other tag, another
/// family's included.
pub fn version_of(&self, s: &str) -> Option<u32> {
s.strip_prefix(self.prefix).or_else(|| self.legacy_prefix.and_then(|p| s.strip_prefix(p)))?.parse().ok()
}
/// Read a file's format and take its `format_pending` out of its JSON, before anything else
/// in it is read: a file that isn't this family's is refused here. One a newer build saved is
/// not: [`Family::newer`] says so.
pub fn take_version(&self, v: &mut Value) -> Result<(u32, Option<Vec<String>>)> {
let noun = self.noun;
let not_ours = |what: String| DocError::Invalid(format!("not a SolidCraft {noun} ({what})"));
let Some(obj) = v.as_object() else { return Err(not_ours("expected a JSON object".into())) };
let version = match obj.get("format") {
None => self.untagged.ok_or_else(|| not_ours("no format".into()))?,
Some(Value::String(f)) => self.version_of(f).ok_or_else(|| not_ours(format!("format `{f}`")))?,
Some(_) => return Err(not_ours("format is not a string".into())),
};
let applied = take_names(v, "format_pending", noun)?;
if version == 0 {
return Err(not_ours("format 0".into()));
}
Ok((version, applied))
}
/// What says a file of format `version` with the changes `applied` came from a newer build:
/// a number past this build's, or a change this build doesn't have anywhere in its history.
/// `None` for a file this build fully knows.
pub fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
self.formats.newer(version, applied)
}
/// Bring a file of format `version` up to this build's; `applied` is its `format_pending`
/// (both from [`Family::take_version`]).
pub fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
self.formats.upgrade(self.noun, v, version, applied)
}
/// Every number a file of the family holds is in range ([`check_numbers`]), but for its
/// top-level `kept` field: other tools' data (a design's `metadata`, a drawing's `properties`),
/// kept as it is and never read as a number (#1015); parsing already bounds its nesting.
pub(crate) fn check_numbers(&self, v: &Value, kept: &str) -> Result<()> {
match v {
Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != kept).try_for_each(|(_, x)| check_numbers(x, self.noun(), 1)),
v => check_numbers(v, self.noun(), 0),
Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != kept).try_for_each(|(_, x)| check_numbers(x, self.noun, 1)),
v => check_numbers(v, self.noun, 0),
}
}
}
/// How a file shows a newer build saved it ([`Family::newer`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Newer {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
}
/// The names of the pending changes this build writes into a design (`format_pending`).
pub fn pending_names() -> Vec<String> {
DESIGN.pending_names()
}
/// A family's upgrade path: the steps written out by hand (designs' up to [`HAND_WRITTEN`]), the
/// folded formats after them (each a list of changes) and the pending changes, which make the
/// next format.
struct Formats<'a> {
hand: &'a [JsonStep],
folded: &'a [(u32, &'a [Part])],
pending: &'a [Part],
}
impl Formats<'_> {
const fn current(&self) -> u32 {
self.hand.len() as u32 + 1 + self.folded.len() as u32 + if self.pending.is_empty() { 0 } else { 1 }
}
/// The changes format `version` is made of, if it is made of changes.
fn parts(&self, version: u32) -> Option<&[Part]> {
let after = version.checked_sub(self.hand.len() as u32 + 2)? as usize;
match self.folded.get(after) {
Some((_, parts)) => Some(parts),
None if after == self.folded.len() && !self.pending.is_empty() => Some(self.pending),
None => None,
}
}
/// A concept is implemented only when this reader registers its descriptor.
fn knows_part_name(&self, name: &str) -> bool {
self.folded.iter().flat_map(|(_, parts)| *parts).chain(self.pending).any(|part| part.name == name)
}
/// Read-only spelling from the personal format-29 renderer. Its card schema is
/// unchanged; recognizing the alias must never grant support to another reader.
fn canonical_applied_name<'name>(&self, version: u32, name: &'name str) -> &'name str {
if version == 29 && name == "physical-material-cards" && self.knows_part_name("material-engineering-cards") {
"material-engineering-cards"
} else {
name
}
}
/// [`Family::newer`]. Changes are recognized across the entire history: a branch's pending
/// change may have landed at a later number. A list beside a format that isn't made of changes
/// means nothing (a tool or a test set an older number on a newer file).
fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
let current = self.current();
let listed = applied.filter(|_| version > current || self.parts(version).is_some()).unwrap_or_default();
let unknown_changes: Vec<String> =
listed.iter().filter(|name| !self.knows_part_name(self.canonical_applied_name(version, name))).cloned().collect();
(version > current || !unknown_changes.is_empty()).then_some(Newer { format: version, reads_up_to: current, unknown_changes })
}
/// Bring a `noun` of format `version` up to [`Formats::current`]. `applied` is the file's
/// `format_pending`: the changes of its format it already has, when it was written while they
/// were pending; the others' steps run first, and already-applied changes never run again at
/// their later number. A file past this build's number has no steps to run.
fn upgrade(&self, noun: &str, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
if version == 0 {
return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)")));
}
// A list beside a format that isn't made of changes is ignored, like any field an upgrade
// drops.
let applied = applied.filter(|_| self.parts(version).is_some());
let already_applied =
|part: &Part| applied.is_some_and(|names| names.iter().any(|name| self.canonical_applied_name(version, name) == part.name));
if applied.is_some() {
for part in self.parts(version).unwrap_or_default().iter().filter(|part| !already_applied(part)) {
part.step.map_or(Ok(()), |step| step(v))?;
}
}
for from in version..self.current() {
match self.hand.get(from as usize - 1) {
Some(step) => step(v)?,
None => {
for part in self.parts(from + 1).unwrap_or_default().iter().filter(|part| !already_applied(part)) {
part.step.map_or(Ok(()), |step| step(v))?;
}
}
}
}
Ok(())
}
}
/// Take a short list of names out of a file's JSON: its `format_pending` (the pending changes it
/// has).
fn take_names(v: &mut Value, key: &str, noun: &str) -> Result<Option<Vec<String>>> {
let Some(list) = v.as_object_mut().and_then(|o| o.remove(key)) else { return Ok(None) };
let invalid = || DocError::Invalid(format!("damaged {noun}: `{key}` is not a short list of names"));
let list = list.as_array().filter(|l| l.len() <= 1000).ok_or_else(invalid)?;
list.iter().map(|n| n.as_str().filter(|n| n.len() <= 128).map(str::to_string).ok_or_else(invalid)).collect::<Result<_>>().map(Some)
}
pub fn format_string(n: u32) -> String {
DESIGN.format_string(n)
}
@@ -344,7 +482,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades up to [`HAND_WRITTEN`]: entry `i` turns format `i + 1` into `i + 2`.
/// Never add to it: a new format is a file in `src/format/pending/`.
type JsonStep = solidcraft_format::Step<DocError>;
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; HAND_WRITTEN as usize - 1] = [
|_| Ok(()),
v2_to_v3,
@@ -1224,30 +1362,30 @@ mod tests {
#[test]
fn pending_changes_apply_in_order_after_older_formats() {
let pending = [traced("a-first"), traced("b-second")];
let formats = Family { formats: Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending }, ..DESIGN };
let formats = Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending };
let next = HAND_WRITTEN + FOLDED.len() as u32 + 1;
assert_eq!(formats.current(), next);
for version in [1, HAND_WRITTEN, next - 1] {
let mut v = serde_json::json!({"features": [], "assembly": {"joints": [{"flip": false}]}});
formats.upgrade(&mut v, version, None).unwrap();
formats.upgrade("design", &mut v, version, None).unwrap();
assert_eq!(trace_of(&v), ["a", "b"], "from format {version}");
// The hand-written steps ran too: format 3's joints were flipped on the way up.
assert_eq!(v["assembly"]["joints"][0]["flip"], Value::Bool(version < 4), "from format {version}");
}
// A design of the pending number written before `b-second` existed gets just that step.
let mut v = serde_json::json!({});
formats.upgrade(&mut v, next, Some(&["a-first".to_string()])).unwrap();
formats.upgrade("design", &mut v, next, Some(&["a-first".to_string()])).unwrap();
assert_eq!(trace_of(&v), ["b"]);
// One that has both, or that this build wrote, needs nothing.
let mut v = serde_json::json!({});
formats.upgrade(&mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap();
formats.upgrade("design", &mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap();
assert_eq!(trace_of(&v), Vec::<&str>::new());
// A change this build doesn't know is a newer build's: said, and kept.
let said = formats.newer(next, Some(&["c-later".into()])).unwrap();
assert_eq!(said, Newer { format: next, reads_up_to: next, unknown_changes: vec!["c-later".into()] });
// A list on a format that isn't made of changes means nothing.
let mut v = serde_json::json!({});
formats.upgrade(&mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
formats.upgrade("design", &mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
assert_eq!(trace_of(&v), ["a", "b"]);
}
@@ -1259,16 +1397,16 @@ mod tests {
let mut folded: Vec<(u32, &[Part])> = FOLDED.to_vec();
let next = HAND_WRITTEN + folded.len() as u32 + 1;
folded.push((next, &parts));
let formats = Family { formats: Formats { hand: &JSON_STEPS, folded: &folded, pending: &[] }, ..DESIGN };
let formats = Formats { hand: &JSON_STEPS, folded: &folded, pending: &[] };
assert_eq!(formats.current(), next);
let mut old = serde_json::json!({});
formats.upgrade(&mut old, HAND_WRITTEN, None).unwrap();
formats.upgrade("design", &mut old, HAND_WRITTEN, None).unwrap();
assert_eq!(trace_of(&old), ["a", "b"]);
let mut nightly = serde_json::json!({});
formats.upgrade(&mut nightly, next, Some(&["a-first".into()])).unwrap();
formats.upgrade("design", &mut nightly, next, Some(&["a-first".into()])).unwrap();
assert_eq!(trace_of(&nightly), ["b"]);
let mut released = serde_json::json!({});
formats.upgrade(&mut released, next, None).unwrap();
formats.upgrade("design", &mut released, next, None).unwrap();
assert_eq!(trace_of(&released), Vec::<&str>::new());
}
@@ -1277,11 +1415,11 @@ mod tests {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let cards = [traced("material-engineering-cards")];
let pending = Family { formats: Formats { hand: &hand, folded: &[(29, &first)], pending: &cards }, ..DESIGN };
let folded = Family { formats: Formats { hand: &hand, folded: &[(29, &first), (30, &cards)], pending: &[] }, ..DESIGN };
let pending = Formats { hand: &hand, folded: &[(29, &first)], pending: &cards };
let folded = Formats { hand: &hand, folded: &[(29, &first), (30, &cards)], pending: &[] };
for formats in [pending, folded] {
let mut file = serde_json::json!({"trace": ["b"]});
formats.upgrade(&mut file, 29, Some(&["physical-material-cards".into()])).unwrap();
formats.upgrade("design", &mut file, 29, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(trace_of(&file), ["b", "a"], "never replay existing card semantics");
}
}
@@ -1290,7 +1428,7 @@ mod tests {
fn personal_card_alias_without_support_is_a_newer_builds() {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let formats = Family { formats: Formats { hand: &hand, folded: &[(29, &first)], pending: &[] }, ..DESIGN };
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &[] };
let said = formats.newer(29, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
}
@@ -1301,7 +1439,7 @@ mod tests {
let first = [traced("a-first")];
let second = [traced("b-second")];
let cards = [traced("material-engineering-cards")];
let formats = Family { formats: Formats { hand: &hand, folded: &[(28, &first), (29, &second), (30, &cards)], pending: &[] }, ..DESIGN };
let formats = Formats { hand: &hand, folded: &[(28, &first), (29, &second), (30, &cards)], pending: &[] };
for version in [28, 30] {
let said = formats.newer(version, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
@@ -1313,7 +1451,7 @@ mod tests {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let cards = [traced("material-engineering-cards")];
let formats = Family { formats: Formats { hand: &hand, folded: &[(29, &first)], pending: &cards }, ..DESIGN };
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &cards };
for names in
[["physical-material-cards".into(), "unknown-card-codec".into()], ["unknown-card-codec".into(), "physical-material-cards".into()]]
{
@@ -1325,14 +1463,14 @@ mod tests {
fn alias_and_canonical_markers_never_reapply_the_card_migration() {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first"), traced("material-engineering-cards")];
let formats = Family { formats: Formats { hand: &hand, folded: &[(29, &first)], pending: &[] }, ..DESIGN };
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &[] };
for names in [
vec!["physical-material-cards".into()],
vec!["physical-material-cards".into(), "material-engineering-cards".into()],
vec!["material-engineering-cards".into(), "physical-material-cards".into()],
] {
let mut file = serde_json::json!({"trace": ["b"]});
formats.upgrade(&mut file, 29, Some(&names)).unwrap();
formats.upgrade("design", &mut file, 29, Some(&names)).unwrap();
assert_eq!(trace_of(&file), ["b", "a"]);
}
}
@@ -1342,10 +1480,10 @@ mod tests {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let cards = [traced("material-engineering-cards")];
let formats = Family { formats: Formats { hand: &hand, folded: &[(29, &first)], pending: &cards }, ..DESIGN };
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &cards };
let mut file = serde_json::json!({"trace": ["a"], "metadata": {"format_pending": ["physical-material-cards"]}});
let metadata = file["metadata"].clone();
formats.upgrade(&mut file, 29, None).unwrap();
formats.upgrade("design", &mut file, 29, None).unwrap();
assert_eq!(trace_of(&file), ["a", "b"]);
assert_eq!(file["metadata"], metadata);
}
@@ -1357,7 +1495,7 @@ mod tests {
let parts = [traced("a-first")];
let later = [traced("b-second")];
for pending in [&[][..], &later[..]] {
let formats = Family { formats: Formats { hand: &[], folded: &[(2, &parts)], pending }, ..DESIGN };
let formats = Formats { hand: &[], folded: &[(2, &parts)], pending };
assert_eq!(formats.newer(2, Some(&["experimental-card".into()])).unwrap().unknown_changes, ["experimental-card"]);
}
}
@@ -1368,14 +1506,14 @@ mod tests {
fn pending_concepts_moved_to_later_formats_are_not_reapplied() {
let first = [traced("a-first")];
let later = [traced("b-second")];
let pending = Family { formats: Formats { hand: &[], folded: &[(2, &first)], pending: &later }, ..DESIGN };
let folded = Family { formats: Formats { hand: &[], folded: &[(2, &first), (3, &later)], pending: &[] }, ..DESIGN };
let pending = Formats { hand: &[], folded: &[(2, &first)], pending: &later };
let folded = Formats { hand: &[], folded: &[(2, &first), (3, &later)], pending: &[] };
for formats in [pending, folded] {
let mut experimental = serde_json::json!({"trace": ["b"]});
formats.upgrade(&mut experimental, 2, Some(&["b-second".into()])).unwrap();
formats.upgrade("design", &mut experimental, 2, Some(&["b-second".into()])).unwrap();
assert_eq!(trace_of(&experimental), ["b", "a"]);
let mut released = serde_json::json!({"trace": ["a"]});
formats.upgrade(&mut released, 2, None).unwrap();
formats.upgrade("design", &mut released, 2, None).unwrap();
assert_eq!(trace_of(&released), ["a", "b"], "ordinary released files still get later migrations");
}
}
@@ -1805,7 +1943,6 @@ mod tests {
width: "4".into(),
height: "6".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -2071,7 +2208,6 @@ mod tests {
width: "20".into(),
height: "5".into(),
operation: crate::Operation::NewBody,
centred: false,
},
None,
)
@@ -2197,7 +2333,6 @@ mod tests {
width: "10".into(),
height: "10".into(),
operation: crate::Operation::NewBody,
centred: false,
},
Some(&format!("Box{i}")),
)
@@ -2228,7 +2363,6 @@ mod tests {
width: "10".into(),
height: "10".into(),
operation: crate::Operation::NewBody,
centred: false,
},
None,
)
@@ -1,5 +0,0 @@
//! A Split Face by sketch curves may save `loops: true`: the sketch's curves are joined end to end
//! before the split, so a closed loop of lines and arcs cuts the face along the loop only. An older
//! design has none and splits by each curve on its own, an open one extended past its ends, as
//! before. The last release warns about the newer format number and opens the design, splitting
//! that feature curve by curve (its sides extended across the face).
-5
View File
@@ -1,5 +0,0 @@
//! Designs save `centred: true` on a box placed by its centre (`solid.box` with `center`); its
//! `corner` is then that centre, so a later length, width or height keeps the box centred. Older
//! designs have no such flag: their boxes keep their stored corner and need no migration. The last
//! release opens such a design with a warning about the newer format, reads the centre as the
//! corner and keeps what it can't read (`crates/doc/src/newer_file.rs`).
+326 -21
View File
@@ -11,9 +11,7 @@
//! with what was read.
//!
//! All of these are named in [`NewerFile::not_understood`] and go back into the file when it is
//! saved ([`restore`]), as they came. What keeps them is the shared `solidcraft_format::newer`; what
//! is a design's (its features kept as placeholders, its parameters left out, the sections a design
//! can't open without) is here. Saving keeps the file's own format number and change list:
//! saved ([`restore`]), as they came. Saving keeps the file's own format number and change list:
//! the file still holds what the newer build wrote, and a lower number would make that build
//! upgrade its own data as if it were old. What can't be put back because the design changed around
//! it ([`not_kept`]) is said in the reply to the save.
@@ -23,19 +21,16 @@ use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use solidcraft_format::newer::{leave_out_unreadable, looks_default, read_around, short};
use crate::format::{self, DESIGN, Newer};
use crate::{DocError, Document, Result};
pub use solidcraft_format::newer::NewerFile;
/// The app, as a newer file's notice names it.
const APP: &str = "SolidCraft";
/// How a [`RawFeature`] is written while it is inside this build; [`restore`] unwraps it.
const FOREIGN_TAG: &str = "newer_file_feature";
/// Sections a design can't open without: filled in when the file's can't be read.
const CORE: [&str; 3] = ["name", "params", "features"];
/// The most sections left out one at a time to find the one a design can't be read with.
const MAX_SECTION_TRIES: usize = 64;
/// Fields a design container adds beside what a design holds; not unknown to this build.
const CONTAINER_ONLY: [&str; 6] = ["step_blob", "mesh_blob", "image_blob", "points_blob", "hdr_blob", "texture_blob"];
@@ -56,16 +51,132 @@ impl RawFeature {
}
}
/// What a design `newer` says a newer build saved holds that this build doesn't read: nothing
/// left out of it yet (a design container's reader adds what it leaves out).
pub fn newer_file(newer: &Newer) -> NewerFile {
NewerFile::new(APP, newer)
/// What the file says and holds that this build doesn't read.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NewerFile {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
/// What this build couldn't read, by name.
pub not_understood: Vec<String>,
/// Fields known records lost in typed reading.
kept: Option<Kept>,
/// Top-level sections left out whole.
sections: Vec<Section>,
/// Elements left out of a top-level list: its key and the element.
elements: Vec<(String, Value)>,
/// The parts of a design container this build has no use for: name and bytes. A blob only one
/// of the kept pieces refers to is one of them.
pub parts: Vec<(String, Arc<[u8]>)>,
/// The file's `container.json`, when this build doesn't know what it says.
pub container: Option<Value>,
}
#[derive(Clone, Debug, PartialEq)]
struct Section {
key: String,
value: Value,
/// This build's record of the section, without the file's: while it is unchanged, the file's
/// goes back.
empty: Option<Value>,
}
/// The fields of a record that typed reading dropped.
#[derive(Clone, Debug, PartialEq)]
enum Kept {
Field(Value),
Fields(BTreeMap<String, Kept>),
Items(Vec<(Anchor, Kept)>),
}
/// Which element of a list some kept fields belong to.
#[derive(Clone, Debug, PartialEq)]
enum Anchor {
Id(u64),
Name(String),
/// Its place, good only while the list keeps its length.
Index {
at: usize,
of: usize,
},
}
impl NewerFile {
/// What to tell the user, whatever else is said.
pub fn notice(&self) -> String {
let reads = self.reads_up_to;
let format = self.format;
match self.unknown_changes.first() {
Some(change) if format <= reads => {
format!(
"Saved by a newer SolidCraft (format {format} with `{change}`, which this version doesn't know). If something's missing, update."
)
}
_ => format!("Saved by a newer SolidCraft (format {format}, this version reads up to {reads}). If something's missing, update."),
}
}
/// Every line to report: the notice, then what couldn't be read, by name.
pub fn warnings(&self) -> Vec<String> {
let unread = self.not_understood.iter().map(|what| format!("Not read: {what}. Saving keeps it."));
std::iter::once(self.notice()).chain(unread).collect()
}
/// What a reply says about it.
pub fn report(&self) -> Value {
json!({
"format": self.format,
"reads_up_to": self.reads_up_to,
"unknown_changes": self.unknown_changes,
"not_understood": self.not_understood,
"notice": self.notice(),
})
}
/// Whether the next save needs a container to keep what the file holds: plain JSON has no room
/// for parts beside the design.
pub fn needs_container(&self) -> bool {
!self.parts.is_empty()
}
/// Whether anything kept back names this text (a blob's name in a container).
pub fn mentions(&self, text: &str) -> bool {
fn value(v: &Value, text: &str) -> bool {
match v {
Value::String(s) => s == text,
Value::Array(a) => a.iter().any(|x| value(x, text)),
Value::Object(o) => o.values().any(|x| value(x, text)),
_ => false,
}
}
fn kept(k: &Kept, text: &str) -> bool {
match k {
Kept::Field(v) => value(v, text),
Kept::Fields(m) => m.values().any(|x| kept(x, text)),
Kept::Items(l) => l.iter().any(|(_, x)| kept(x, text)),
}
}
self.kept.as_ref().is_some_and(|k| kept(k, text))
|| self.sections.iter().any(|s| value(&s.value, text))
|| self.elements.iter().any(|(_, v)| value(v, text))
}
/// Say that a section the container reader couldn't finish reading is left out.
/// `empty` is what the design writes for the section while it holds nothing, if anything.
pub fn keep_section(&mut self, key: &str, value: Value, empty: Option<Value>, why: &str) {
self.not_understood.push(format!("section `{key}` ({why})"));
self.sections.push(Section { key: key.into(), value, empty });
}
}
/// Read a design a newer build saved, or say why it truly can't be read.
pub(crate) fn read(mut raw: Value, version: u32, applied: Option<Vec<String>>, newer: Newer) -> Result<Document> {
let applied_names = applied.as_deref();
let mut file = newer_file(&newer);
let mut file =
NewerFile { format: newer.format, reads_up_to: newer.reads_up_to, unknown_changes: newer.unknown_changes.clone(), ..NewerFile::default() };
let (mut doc, read_as) = match format::decode(raw.clone(), version, applied_names) {
Ok(doc) => (doc, raw),
Err(_) => {
@@ -84,7 +195,12 @@ pub(crate) fn read(mut raw: Value, version: u32, applied: Option<Vec<String>>, n
// Compared with what was read, the file's own shape after the same upgrade.
DESIGN.upgrade(&mut upgraded, version, applied_names)?;
let known = serde_json::to_value(&doc).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
file.compare(&upgraded, &known, FOREIGN_TAG, &CONTAINER_ONLY);
let mut paths = Vec::new();
file.kept = vanished(&upgraded, &known, "", &mut paths);
file.not_understood.extend(paths);
for section in &mut file.sections {
section.empty = known.get(&section.key).cloned();
}
if newer.format > newer.reads_up_to {
// The file's own number and list: it still holds what that build wrote.
doc.format = DESIGN.format_string(newer.format);
@@ -126,27 +242,72 @@ fn salvage(
Ok(()) => true,
Err(why) => {
let name = param.get("name").and_then(Value::as_str).unwrap_or("unnamed");
let note = format!("parameter `{name}` ({})", short(&why.to_string(), "design"));
file.keep_element("params", param.clone(), note);
file.not_understood.push(format!("parameter `{name}` ({})", short(&why.to_string())));
file.elements.push(("params".into(), param.clone()));
false
}
});
}
// Any other section, by itself.
leave_out_unreadable(top, file, "design", piece);
let keys: Vec<String> = top.keys().cloned().collect();
for key in &keys {
let value = top.get(key).cloned().unwrap_or_default();
if let Err(why) = piece(key, &value) {
move_out(top, key, &why.to_string(), file);
}
}
if !top.get("name").is_some_and(Value::is_string) {
top.insert("name".into(), json!("Untitled"));
}
for key in ["params", "features"] {
top.entry(key).or_insert_with(|| json!([]));
}
read_around(top, file, &CORE, "design", |top: &Map<String, Value>| format::decode(Value::Object(top.clone()), version, applied))
let attempt = |top: &Map<String, Value>| format::decode(Value::Object(top.clone()), version, applied);
let first = match attempt(top) {
Ok(doc) => return Ok(doc),
Err(why) => why,
};
// Sections that are fine by themselves but not together with the rest.
let keys: Vec<String> = top.keys().filter(|k| !CORE.contains(&k.as_str())).cloned().collect();
// Each try reads the whole design: a file of many sections goes straight to the last resort.
for key in keys.iter().filter(|_| keys.len() <= MAX_SECTION_TRIES) {
let mut without = top.clone();
without.remove(key);
if let Ok(doc) = attempt(&without) {
move_out(top, key, &first.to_string(), file);
return Ok(doc);
}
}
let mut bare = top.clone();
bare.retain(|key, _| CORE.contains(&key.as_str()));
match attempt(&bare) {
Ok(doc) => {
for key in &keys {
move_out(top, key, &first.to_string(), file);
}
Ok(doc)
}
Err(_) => Err(first.to_string()),
}
}
/// Leave a section out and keep it. A section the design needs is left empty.
fn move_out(top: &mut Map<String, Value>, key: &str, why: &str, file: &mut NewerFile) {
let Some(value) = top.remove(key) else { return };
file.not_understood.push(format!("section `{key}` ({})", short(why)));
file.sections.push(Section { key: key.into(), value, empty: None });
}
/// The first line of a reader's error, without the list of every kind it knows.
fn short(why: &str) -> &str {
let why = why.split(", expected").next().unwrap_or(why);
why.strip_prefix("design file: ").unwrap_or(why)
}
fn feature_note(feature: &Value, why: &str) -> String {
let name = feature.get("name").and_then(Value::as_str).unwrap_or("unnamed");
let kind = feature.get("type").and_then(Value::as_str).unwrap_or("unnamed");
let why = short(why, "design");
let why = short(why);
if why.starts_with("unknown variant") {
format!("feature `{name}`, a `{kind}` feature this version doesn't know")
} else {
@@ -201,16 +362,160 @@ fn unwrapped(feature: &mut Value) {
*feature = Value::Object(raw);
}
/// A value a writer leaves out without losing anything: not worth telling anyone about.
fn looks_default(v: &Value) -> bool {
match v {
Value::Null => true,
Value::Bool(b) => !b,
Value::Number(n) => n.as_f64() == Some(0.0),
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
}
}
/// What `raw` has that `known`, what was read of it, doesn't. `paths` gets those fields' names.
fn vanished(raw: &Value, known: &Value, path: &str, paths: &mut Vec<String>) -> Option<Kept> {
match (raw, known) {
(Value::Object(raw), Value::Object(known)) => {
if known.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG) {
return None;
}
let mut kept = BTreeMap::new();
for (key, value) in raw {
if CONTAINER_ONLY.contains(&key.as_str()) || (path.is_empty() && matches!(key.as_str(), "format" | "format_pending")) {
continue;
}
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match known.get(key) {
None => {
if !looks_default(value) {
paths.push(format!("field `{inner}`"));
}
kept.insert(key.clone(), Kept::Field(value.clone()));
}
Some(known) => {
if let Some(k) = vanished(value, known, &inner, paths) {
kept.insert(key.clone(), k);
}
}
}
}
(!kept.is_empty()).then_some(Kept::Fields(kept))
}
(Value::Array(raw), Value::Array(known)) => {
let mut items = Vec::new();
for (at, element) in raw.iter().enumerate() {
let anchor = anchor_of(element).unwrap_or(Anchor::Index { at, of: raw.len() });
let Some(partner) = find(known, &anchor) else { continue };
let label = match (element.get("name").and_then(Value::as_str), &anchor) {
(Some(name), _) => format!("{path}[{name}]"),
(None, Anchor::Id(id)) => format!("{path}[{id}]"),
_ => format!("{path}[{at}]"),
};
if let Some(k) = vanished(element, partner, &label, paths) {
items.push((anchor, k));
}
}
(!items.is_empty()).then_some(Kept::Items(items))
}
_ => None,
}
}
fn anchor_of(element: &Value) -> Option<Anchor> {
if let Some(id) = element.get("id").and_then(Value::as_u64) {
return Some(Anchor::Id(id));
}
element.get("name").and_then(Value::as_str).map(|name| Anchor::Name(name.to_string()))
}
fn find<'a>(list: &'a [Value], anchor: &Anchor) -> Option<&'a Value> {
match anchor {
Anchor::Id(id) => list.iter().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get(*at)).flatten(),
}
}
fn find_mut<'a>(list: &'a mut [Value], anchor: &Anchor) -> Option<&'a mut Value> {
match anchor {
Anchor::Id(id) => list.iter_mut().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter_mut().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get_mut(*at)).flatten(),
}
}
/// Put the kept fields back. An element that is gone was deleted here, its fields with it; one
/// whose place can't be told any more, and a field whose record is gone, go into `lost`.
fn apply(kept: &Kept, value: &mut Value, path: &str, lost: &mut Vec<String>) {
match kept {
Kept::Field(_) => {}
Kept::Fields(fields) => {
let Some(object) = value.as_object_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (key, sub) in fields {
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match (sub, object.get_mut(key)) {
(Kept::Field(v), None) => {
object.insert(key.clone(), v.clone());
}
(Kept::Field(_), Some(_)) => {}
(sub, Some(inner_value)) => apply(sub, inner_value, &inner, lost),
// The list it was in is empty and not written: its elements were deleted.
(Kept::Items(_), None) => {}
(_, None) => lost.push(format!("field `{inner}`")),
}
}
}
Kept::Items(items) => {
let Some(list) = value.as_array_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (anchor, sub) in items {
match (find_mut(list, anchor), anchor) {
(Some(element), _) => apply(sub, element, path, lost),
(None, Anchor::Index { at, .. }) => lost.push(format!("element {at} of `{path}`")),
(None, _) => {}
}
}
}
}
}
/// Write back what the design holds of the file a newer build saved, into `value`, the design's
/// JSON. Returns what couldn't go back ([`not_kept`]).
pub fn restore(doc: &Document, value: &mut Value) -> Vec<String> {
let Some(file) = &doc.newer_file else { return Vec::new() };
let mut lost = Vec::new();
let Some(file) = &doc.newer_file else { return lost };
if let Some(Value::Array(features)) = value.get_mut("features") {
for feature in features.iter_mut().filter(|f| f.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG)) {
unwrapped(feature);
}
}
file.restore_into(value)
if let Some(kept) = &file.kept {
apply(kept, value, "", &mut lost);
}
if let Some(top) = value.as_object_mut() {
for section in &file.sections {
if top.get(&section.key) == section.empty.as_ref() {
top.insert(section.key.clone(), section.value.clone());
} else {
lost.push(format!("section `{}`, replaced by what was edited here", section.key));
}
}
for (key, element) in &file.elements {
let name = element.get("name");
match top.entry(key.clone()).or_insert_with(|| json!([])) {
Value::Array(list) if name.is_none() || !list.iter().any(|x| x.get("name") == name) => list.push(element.clone()),
_ => lost.push(format!("`{key}` element {}, replaced by what was made here", name.and_then(Value::as_str).unwrap_or("unnamed"))),
}
}
}
lost
}
/// What of the file a newer build saved the next save can't keep, because the design was changed
+1 -1
View File
@@ -91,7 +91,7 @@ pub(super) fn local_geometry(f: &Feature, components: &std::collections::HashMap
match &f.kind {
// A cylinder placed by reference edges follows the body it stands on.
FeatureKind::Cylinder { references, .. } if !references.is_empty() => false,
FeatureKind::Box { corner: _, length: _, width: _, height: _, operation: Operation::NewBody, centred: _ }
FeatureKind::Box { corner: _, length: _, width: _, height: _, operation: Operation::NewBody }
| FeatureKind::Cylinder { base: _, axis: _, radius: _, height: _, operation: Operation::NewBody, references: _ }
| FeatureKind::Sphere { center: _, radius: _, operation: Operation::NewBody }
| FeatureKind::Torus { center: _, major: _, minor: _, operation: Operation::NewBody }
-1
View File
@@ -333,7 +333,6 @@ mod tests {
width: "3 mm".into(),
height: "4 mm".into(),
operation: crate::Operation::NewBody,
centred: false,
};
doc.add_feature(block, Some("Block")).unwrap();
let mut model = crate::Model::default();
+1 -6
View File
@@ -285,7 +285,6 @@ fn set_back_restores_everything_when_states_share_bodies() {
width: "10".into(),
height: height.into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -394,7 +393,6 @@ fn rolling_a_large_unchanged_timeline_forward_reuses_its_geometry() {
width: "10".into(),
height: "10".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -946,7 +944,6 @@ fn errors_are_reported_not_fatal() {
width: "1".into(),
height: "1".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -1029,7 +1026,6 @@ fn primitives_and_combine() {
width: "10".into(),
height: "10".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -1445,7 +1441,6 @@ fn empty_boolean_targets_without_overlap_keep_plain_message() {
width: "1".into(),
height: "1".into(),
operation,
centred: false,
},
None,
)
@@ -1460,7 +1455,7 @@ fn empty_boolean_targets_without_overlap_keep_plain_message() {
/// A box of `size` at `corner`, made with `operation`.
fn cube(doc: &mut Document, corner: Vec3, size: [f64; 3], operation: Operation) -> u64 {
let [length, width, height] = size.map(|x| Expr::new(x.to_string()));
doc.add_feature(FeatureKind::Box { corner: point_expr(corner), length, width, height, operation, centred: false }, None).unwrap()
doc.add_feature(FeatureKind::Box { corner: point_expr(corner), length, width, height, operation }, None).unwrap()
}
/// The bodies of `doc` evaluated, by name with their volumes and pieces, and how feature
@@ -321,27 +321,6 @@ fn shallow_loft_cut_from_the_top_face() {
assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}");
}
/// A carved top lofted through six closed spline sections from its slab's top face and joined
/// onto the slab. The loft lies on and above that face and shares only it with the slab, so the
/// joined top is exactly the slab plus the loft built as its own body.
#[test]
fn carved_top_lofted_onto_its_slab_joins() {
let script = include_str!("../../../examples/regressions/loft_join_carved_top_on_slab.json");
let joined = replay(script);
assert_eq!(joined.model.state().bodies.len(), 1);
let mut apart: Value = serde_json::from_str(script).unwrap();
let loft = apart["commands"].as_array_mut().unwrap().last_mut().unwrap();
loft["params"]["operation"] = json!("new_body");
loft["params"]["body_name"] = json!("Arch");
loft["params"].as_object_mut().unwrap().remove("targets");
let mut s = Session::default();
s.run_script(&apart).unwrap();
let (slab, arch) = (volume(&s, "Top"), volume(&s, "Arch"));
assert!(slab > 0.0 && arch > 0.0, "{slab} {arch}");
let got = volume(&joined, "Top");
assert!((got - (slab + arch)).abs() < 1e-7 * got, "{got} vs {slab} + {arch}");
}
#[test]
fn oblique_cut_through_a_small_arc_matches_a_split() {
let scripts = [
+22 -12
View File
@@ -71,7 +71,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("box")
.contract(&BOX_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin; a box placed by its center stays centred there when its size changes); operation?, touching?, targets?: [body], name?, body_name?"),
.params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, touching?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.cylinder", "Cylinder", crate::checked!("solid.cylinder", prim_cylinder))
.at("SOLID", "CREATE")
.icon("cylinder")
@@ -191,7 +191,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&SPLIT_FACE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal; each curve on its own, an open one extended past its ends) and loops?: bool (default false: true joins the curves end to end first, so a closed loop cuts along itself only)",
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
),
CommandSpec::new("solid.split_body", "Split Body", crate::checked!("solid.split_body", split_body))
.at("SOLID", "MODIFY")
@@ -1534,14 +1534,27 @@ fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
for (e, n) in [(&l, "length"), (&w, "width"), (&h, "height")] {
check_expr(s, e, Kind::Length, cmd, n)?;
}
// A box placed by its centre keeps that centre, so a later length, width or height grows it on
// both sides (#14487).
let (corner, centred) = match (point_param(s, p, "corner", cmd)?, point_param(s, p, "center", cmd)?) {
(Some(c), _) => (c, false),
(None, Some(c)) => (c, true),
_ => (point_expr(Vec3::ZERO), false),
let corner = match (point_param(s, p, "corner", cmd)?, point_param(s, p, "center", cmd)?) {
(Some(c), _) => c,
// The corner half a size back from the centre (numbers stay numbers, in mm). The centre's
// bare numbers are millimetres, the sizes' the design's units.
(None, Some([cx, cy, cz])) => {
let unit = typed_length_unit(s);
let mm_per_unit = solidcraft_doc::expr::unit_info(&unit).map_or(1.0, |(_, k)| k);
let back = |c: Expr, size: &Expr| -> Expr {
match (c.parse::<f64>(), size.parse::<f64>()) {
(Ok(a), Ok(b)) => format!("{}", a - b * mm_per_unit / 2.0).into(),
_ => {
let [c, size] = length_operands(s, [(&c, "mm"), (size, &unit)]);
format!("({c}) - ({size}) / 2").into()
}
}
};
[back(cx, &l), back(cy, &w), back(cz, &h)]
}
_ => point_expr(Vec3::ZERO),
};
add_feature(s, p, FeatureKind::Box { corner, length: l, width: w, height: h, operation: operation(p, cmd)?, centred })
add_feature(s, p, FeatureKind::Box { corner, length: l, width: w, height: h, operation: operation(p, cmd)? })
}
fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
@@ -2946,7 +2959,6 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
plane: xy(),
tool: Some(solidcraft_doc::FaceAt { body: BodyName(tb), point: Position(point) }),
sketch: None,
loops: false,
}
} else if p.get("sketch").is_some_and(|v| !v.is_null()) {
let sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?;
@@ -2956,7 +2968,6 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
plane: xy(),
tool: None,
sketch: Some(SketchId(sketch)),
loops: bool_(p, "loops").unwrap_or(false),
}
} else {
let plane = plane_param(s, p.get("plane"), cmd)?;
@@ -2966,7 +2977,6 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
plane,
tool: None,
sketch: None,
loops: false,
}
};
add_face_feature(&picked, s, p, kind)
@@ -2603,64 +2603,6 @@ fn an_imported_assembly_with_a_repeated_part_moves_as_one() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #13382: a design saved by a version whose STEP reader numbered the file's bodies otherwise
/// opens with each body where it was and with its own shape. Here the older reader left the
/// file's first body out, so every stored name and tree index after it is one lower. The names
/// went by index: each body took the next one's name, and that name put it in the next part's
/// component.
#[test]
fn an_import_saved_by_an_older_step_reader_opens_with_its_bodies_in_place() {
use solidcraft_doc::FeatureKind;
use solidcraft_doc::kernel::StepNode;
let dir = std::env::temp_dir().join(format!("solidcraft-13382-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("parts.step").to_string_lossy().to_string();
let mut src = Session::default();
// Three parts of different sizes, each in its own component and placed apart.
for (k, (name, size)) in [("Small", [10, 10, 10]), ("Long", [40, 6, 6]), ("Tall", [8, 8, 30])].into_iter().enumerate() {
let made = run(&mut src, "component.create", json!({"name": name}));
run(&mut src, "solid.box", json!({"length": size[0], "width": size[1], "height": size[2], "body_name": name}));
run(&mut src, "component.activate", json!({"component": 0}));
// The first part is grounded where it is made.
if k > 0 {
run(&mut src, "occurrence.move", json!({"occurrence": made["occurrence"], "translate": [100 * k, 0, 0], "space": "world"}));
}
}
run(&mut src, "file.export", json!({"path": path}));
let mut s = Session::default();
run(&mut s, "file.insert_step", json!({"path": path}));
let names: Vec<String> = s.world_state().unwrap().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names.len(), 3, "{names:?}");
let before: Vec<_> = names.iter().map(|n| placed_bounds(&s, n)).collect();
// The design as the older reader saved it: no body 0, the others numbered from 0.
let mut doc = (*s.doc).clone();
let f = doc.features.iter_mut().find(|f| matches!(f.kind, FeatureKind::Import { .. })).unwrap();
let first = f.body_names.remove(0);
fn renumber(nodes: &mut [StepNode]) {
for n in nodes {
n.bodies = n.bodies.iter().filter(|b| **b != 0).map(|b| b - 1).collect();
renumber(&mut n.children);
}
}
if let FeatureKind::Import { components, skip, .. } = &mut f.kind {
renumber(components);
assert!(skip.is_empty(), "no part is placed twice: {skip:?}");
}
let opened = Session::opened(doc, None);
let now: Vec<String> = opened.world_state().unwrap().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(now.len(), 3, "the body the older reader left out is built too: {now:?}");
for (n, b) in names.iter().zip(&before) {
if *n == first {
continue;
}
let got = placed_bounds(&opened, n);
assert!(shifted(*b, got, [0.0; 3]), "{n} opened at {got:?}, was at {b:?}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A 20 × 30 × 8 plate made in its own component and stood up by its placement (turned
/// `angle` degrees about X), the way an imported part set upright is: in the world it lies
/// flat at `x0`..`x0 + 20`, y 0..30, z 0..8. The design is active again afterwards.
+21 -96
View File
@@ -31,6 +31,7 @@ use crate::schema::insert_mesh::{InsertMeshParams, MeshInserted};
use crate::schema::insert_step::{InsertStepParams, StepInserted};
use crate::schema::new_design::{NewDesignParams, NewDesignResult};
use crate::schema::save::{SaveAsParams, SaveParams, SaveResult};
#[cfg(not(target_arch = "wasm32"))]
use crate::schema::save_mesh::{SaveMeshResult, SlicerDelivery};
use crate::schema::slicers::{SlicerInfo, SlicersParams, SlicersResult};
use crate::schema::{TypedContract, output_schema_for};
@@ -101,7 +102,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.contract(&EXPORT_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::FileExportResult>)
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|glb|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; obj: the colours of the bodies go in a material library (`.mtl`) written next to the mesh file; glb: binary glTF in metres with Y up, each body placed and named, its colours, metallic and roughness as materials; a transparent body or face, or a body a render setup gives an image texture or optics, is refused; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10); quality?: standard|fine|finest (STL, OBJ, 3MF and GLB: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts); lay_on_print_faces?: bool (STL and 3MF only, default false: each body with a print face (body.print_face) is written laid on it, as a slicer export lays it → print_faces: the bodies laid, warnings: each body that has a print face but was written as it lies)"),
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|glb|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; obj: the colours of the bodies go in a material library (`.mtl`) written next to the mesh file; glb: binary glTF in metres with Y up, each body placed and named, its colours, metallic and roughness as materials; a transparent body or face, or a body a render setup gives an image texture or optics, is refused; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10); quality?: standard|fine|finest (STL, OBJ, 3MF and GLB: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts)"),
// One command per format, so each File › Export item can have a key of its own; started
// from a key or command search, the app opens the save picker (the mesh dialog for STL and
// 3MF). Each takes `file.export`'s parameters for its format, without `format`.
@@ -116,13 +117,13 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.contract(&EXPORT_STL_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::ExportResult>)
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard); lay_on_print_faces?: bool (default false: as file.export) → {path, bytes, format, print_faces?, warnings?}: binary STL, whatever the path's extension"),
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard) → {path, bytes, format}: binary STL, whatever the path's extension"),
CommandSpec::new("file.export_3mf", "Export 3MF", crate::checked!("file.export_3mf", export_3mf))
.icon("mesh_export")
.noundo()
.contract(&EXPORT_3MF_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::ExportResult>)
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard); objects?: body|component (as file.export); lay_on_print_faces?: bool (default false: as file.export) → {path, bytes, format, print_faces?, warnings?}: 3MF, whatever the path's extension"),
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard); objects?: body|component (as file.export) → {path, bytes, format}: 3MF, whatever the path's extension"),
CommandSpec::new("file.export_obj", "Export OBJ", crate::checked!("file.export_obj", export_obj_cmd))
.icon("export")
.noundo()
@@ -141,7 +142,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.contract(&SAVE_MESH_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::save_mesh::SaveMeshResult>)
.params("path; format?: stl|3mf|3mf-tagged (default from the extension, else stl; 3mf-tagged: docs/formats/3mf-face-tags.md); bodies?: [names] (default: all); ascii?: bool (STL); slicer?: id or name from file.slicers, the program path it lists, or a program the user added under Custom… (send the saved file there, through the same steps as file.send_to_slicer: a slicer that has to be started first is waited for; may be deprecated later in favour of file.send_to_slicer or a Send to App workflow, kept for scripts); single_instance?: bool (default true; see file.send_to_slicer); quality?: standard|fine|finest (STL, OBJ and 3MF: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts); lay_on_print_faces?: bool (default false: each body with a print face (body.print_face) is saved laid on it; a file sent to `slicer` is laid on them whatever this says)"),
.params("path; format?: stl|3mf|3mf-tagged (default from the extension, else stl; 3mf-tagged: docs/formats/3mf-face-tags.md); bodies?: [names] (default: all); ascii?: bool (STL); slicer?: id or name from file.slicers, the program path it lists, or a program the user added under Custom… (send the saved file there, through the same steps as file.send_to_slicer: a slicer that has to be started first is waited for; may be deprecated later in favour of file.send_to_slicer or a Send to App workflow, kept for scripts); single_instance?: bool (default true; see file.send_to_slicer); quality?: standard|fine|finest (STL, OBJ and 3MF: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts)"),
CommandSpec::new("file.send_to_slicer", "Send to Slicer", send_to_slicer)
.enabled(starts_programs)
.at("INSERT", "FILES")
@@ -178,7 +179,7 @@ const EXPORT_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportP
const SAVE_CONTRACT: TypedContract<SaveParams> = TypedContract::new();
const SAVE_AS_CONTRACT: TypedContract<SaveAsParams> = TypedContract::new();
const EXPORT_STEP_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportStepParams> = crate::schema::TypedContract::new();
const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportStlParams> = crate::schema::TypedContract::new();
const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
const EXPORT_3MF_CONTRACT: crate::schema::TypedContract<crate::schema::file::Export3mfParams> = crate::schema::TypedContract::new();
const EXPORT_OBJ_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
const SLICERS_CONTRACT: TypedContract<SlicersParams> = TypedContract::new();
@@ -653,13 +654,6 @@ fn opened_design(
s.replace_design(opened);
s.disk = disk;
s.read_only = read_only;
// A design saved with print faces (by an older version, or with them kept) opens without
// them when they are not kept (#11512). The rest of it opens as saved, and dropping them is
// neither an unsaved change nor a step to undo.
if !s.keep_print_faces && !s.doc.print_faces.is_empty() {
s.doc_mut().print_faces.clear();
s.mark_saved();
}
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
let now = solidcraft_io::design_lock::now_secs();
let open_elsewhere = solidcraft_io::design_lock::held_elsewhere(std::path::Path::new(path), now).map(|lock| OpenElsewhere {
@@ -980,10 +974,6 @@ impl Session {
if !self.visibility.is_empty() {
design.to_mut().visibility = self.visibility.clone();
}
// Print faces not kept stay with this session: the file is saved without them (#11512).
if !self.keep_print_faces && !design.print_faces.is_empty() {
design.to_mut().print_faces.clear();
}
// A sketch edited with the timeline rolled to it: the file holds the position the edit
// returns to, not the temporary rollback (#14010).
if let Some(marker) = self.sketch_restore_marker() {
@@ -1222,7 +1212,8 @@ fn export_to(
)?
};
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let reply = ExportResult { path: path.to_string(), bytes: bytes.len(), format: format!("{format:?}"), ..ExportResult::default() };
let reply =
ExportResult { path: path.to_string(), bytes: bytes.len(), format: format!("{format:?}"), files: Vec::new(), param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
@@ -1231,60 +1222,8 @@ fn export_thumbnail(s: &Session, format: Format, bodies: &[String]) -> Option<Ve
matches!(format, Format::ThreeMf | Format::ThreeMfTagged).then(|| thumbnail::exported(s, bodies)).flatten()
}
/// `lay_on_print_faces` as the mesh exports read it: true writes each body with a print face laid
/// on it, as a slicer export does. Only STL and 3MF take it; `false` is accepted for any format.
fn lay_on_print_faces(p: &Value, cmd: &str, format: Format) -> Result<bool> {
match p.get("lay_on_print_faces") {
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(false),
Some(Value::Bool(true)) if matches!(format, Format::StlBinary | Format::StlAscii | Format::ThreeMf | Format::ThreeMfTagged) => Ok(true),
Some(Value::Bool(true)) => Err(bad(cmd, "`lay_on_print_faces` is for STL and 3MF: other formats are not printed")),
Some(_) => Err(bad(cmd, "`lay_on_print_faces` must be true or false")),
}
}
/// Write the bodies as a mesh file (STL or 3MF), each laid on its print face: the file's size, and
/// which bodies were laid and which were written as they lie.
fn write_laid(
s: &Session,
path: &str,
format: Format,
bodies: &[String],
parts: &[(String, Vec<String>)],
quality: solidcraft_io::MeshQuality,
) -> Result<(usize, print_face::Laid)> {
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
let mut state = s.doc.painted(&*s.world_state()?);
let laid = print_face::lay_on_print_faces(s, &mut state, bodies, parts);
let thumbnail = export_thumbnail(s, format, bodies);
let bytes = solidcraft_io::export_with_parts(&state, bodies, parts, format, &name, &s.doc.metadata, quality, thumbnail.as_deref())?;
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok((bytes.len(), laid))
}
/// [`export_to`] for `file.export`, `file.export_stl` and `file.export_3mf` with
/// `lay_on_print_faces`: each body laid on its print face.
fn export_laid(
s: &Session,
path: &str,
format: Format,
bodies: &[String],
parts: &[(String, Vec<String>)],
quality: solidcraft_io::MeshQuality,
) -> Result<Value> {
let (bytes, laid) = write_laid(s, path, format, bodies, parts, quality)?;
let reply = ExportResult {
path: path.to_string(),
bytes,
format: format!("{format:?}"),
print_faces: laid.bodies,
warnings: laid.warnings,
..ExportResult::default()
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
/// [`export_to`] for a mesh file a slicer is sent, or `file.save_mesh` with `lay_on_print_faces`
/// (STL or 3MF): each body laid on its print face.
/// [`export_to`] for a mesh file a slicer is sent (STL or 3MF): each body laid on its print face.
#[cfg(not(target_arch = "wasm32"))]
fn export_laid_to(
s: &Session,
path: &str,
@@ -1293,10 +1232,15 @@ fn export_laid_to(
parts: &[(String, Vec<String>)],
quality: solidcraft_io::MeshQuality,
) -> Result<Value> {
let (bytes, laid) = write_laid(s, path, format, bodies, parts, quality)?;
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
let mut state = s.doc.painted(&*s.world_state()?);
let laid = print_face::lay_on_print_faces(s, &mut state, bodies, parts);
let thumbnail = export_thumbnail(s, format, bodies);
let bytes = solidcraft_io::export_with_parts(&state, bodies, parts, format, &name, &s.doc.metadata, quality, thumbnail.as_deref())?;
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let reply = SaveMeshResult {
path: path.to_string(),
bytes,
bytes: bytes.len(),
format: format!("{format:?}"),
print_faces: laid.bodies,
warnings: laid.warnings,
@@ -1318,11 +1262,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
let quality = mesh_quality(p, cmd)?;
let parts = mesh_parts(s, p, cmd, format)?;
let bodies = string_list(cmd, p, "bodies")?;
if lay_on_print_faces(p, cmd, format)? {
return export_laid(s, path, format, &bodies, &parts, quality);
}
export_to(s, cmd, path, format, &bodies, &parts, quality)
export_to(s, cmd, path, format, &string_list(cmd, p, "bodies")?, &parts, quality)
}
fn export_step(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1334,22 +1274,14 @@ fn export_step(s: &mut Session, p: &Value) -> Result<Value> {
fn export_stl(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.export_stl";
let path = path_arg(p, cmd)?;
let (bodies, quality) = (string_list(cmd, p, "bodies")?, mesh_quality(p, cmd)?);
if lay_on_print_faces(p, cmd, Format::StlBinary)? {
return export_laid(s, path, Format::StlBinary, &bodies, &[], quality);
}
export_to(s, cmd, path, Format::StlBinary, &bodies, &[], quality)
export_to(s, cmd, path, Format::StlBinary, &string_list(cmd, p, "bodies")?, &[], mesh_quality(p, cmd)?)
}
fn export_3mf(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.export_3mf";
let path = path_arg(p, cmd)?;
let parts = mesh_parts(s, p, cmd, Format::ThreeMf)?;
let (bodies, quality) = (string_list(cmd, p, "bodies")?, mesh_quality(p, cmd)?);
if lay_on_print_faces(p, cmd, Format::ThreeMf)? {
return export_laid(s, path, Format::ThreeMf, &bodies, &parts, quality);
}
export_to(s, cmd, path, Format::ThreeMf, &bodies, &parts, quality)
export_to(s, cmd, path, Format::ThreeMf, &string_list(cmd, p, "bodies")?, &parts, mesh_quality(p, cmd)?)
}
fn export_obj_cmd(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1490,9 +1422,6 @@ fn save_mesh(s: &mut Session, p: &Value) -> Result<Value> {
// a possibility, not a plan.
let quality = mesh_quality(p, cmd)?;
let parts = mesh_parts(s, p, cmd, format)?;
// Read before `slicer`, so a wrong value is refused there too; a file sent to a slicer is laid
// on its print faces whatever this says.
let lay = lay_on_print_faces(p, cmd, format)?;
if let Some(spec) = str_(p, "slicer") {
#[cfg(target_arch = "wasm32")]
{
@@ -1516,11 +1445,7 @@ fn save_mesh(s: &mut Session, p: &Value) -> Result<Value> {
},
);
}
let bodies = string_list(cmd, p, "bodies")?;
if lay {
return export_laid_to(s, path, format, &bodies, &parts, quality);
}
export_to(s, cmd, path, format, &bodies, &parts, quality)
export_to(s, cmd, path, format, &string_list(cmd, p, "bodies")?, &parts, quality)
}
/// The browser build can't start a program.
-101
View File
@@ -186,107 +186,6 @@ fn node_key(imp: &StepImport, node: &StepNode, depth: usize) -> u64 {
h.finish()
}
/// For each body `now` gives, the index `stored` gave the same body, or `None` for a body
/// `stored` doesn't have. A body is the same one when it has the same place in both trees: the
/// same product at the same position among its siblings, and the same position among that
/// product's bodies. `None` when the trees disagree about their products (a name or a number of
/// children), or a body index is out of range or used twice: then nothing can be matched.
fn matched_bodies(stored: &[StepNode], now: &[StepNode], count: usize) -> Option<Vec<Option<usize>>> {
let mut matched = vec![None; count];
let mut taken = std::collections::HashSet::new();
let mut pending: Vec<(&[StepNode], &[StepNode])> = vec![(stored, now)];
while let Some((was, is)) = pending.pop() {
if was.len() != is.len() {
return None;
}
for (a, b) in was.iter().zip(is) {
if a.name != b.name {
return None;
}
for (old, new) in a.bodies.iter().zip(&b.bodies) {
let slot = matched.get_mut(*new)?;
if slot.is_some() || !taken.insert(*old) {
return None;
}
*slot = Some(*old);
}
pending.push((&a.children, &b.children));
}
}
Some(matched)
}
/// An Import feature written against `imp`'s numbering of the file's bodies (#13382). The
/// feature keeps its body names, skipped bodies and linked face names by body index, as the
/// STEP reader that inserted the file numbered them. A later reader can number the same file's
/// bodies differently, for example when it reads a body an earlier one left out. Each name
/// then went to the wrong body, which its name put in another component: bodies opened moved
/// and with another body's shape. The stored tree says which body each index was, so each
/// stored value moves to the index `imp` gives that body. A body only `imp` has gets the
/// reader's name, and is skipped when a new insert would skip it. Returns whether anything
/// changed; the feature is left as it is when the trees can't be matched.
pub(crate) fn realign_import(f: &mut solidcraft_doc::Feature, imp: &StepImport) -> bool {
let FeatureKind::Import { components, skip, .. } = &f.kind else { return false };
if components.is_empty() || *components == imp.tree {
return false;
}
let count = imp.bodies.len();
let Some(matched) = matched_bodies(components, &imp.tree, count) else { return false };
let stored_count = {
let mut all = Vec::new();
for n in components {
collect_bodies(n, &mut all, 0);
}
all.len()
};
if stored_count == count && matched.iter().enumerate().all(|(k, m)| *m == Some(k)) {
// The same numbering: only placements or names that don't move bodies differ.
return false;
}
let old_skip: std::collections::HashSet<u64> = skip.iter().copied().collect();
let new_skip: std::collections::HashSet<u64> = Plan::of(imp).skip.into_iter().collect();
let skip: Vec<u64> = (0..count as u64)
.filter(|k| match matched.get(*k as usize).copied().flatten() {
Some(old) => old_skip.contains(&(old as u64)),
None => new_skip.contains(k),
})
.collect();
f.body_names = by_new_index(&matched, &f.body_names);
if !f.link_faces.is_empty() {
f.link_faces = by_new_index(&matched, &f.link_faces);
}
if let FeatureKind::Import { components, skip: stored, .. } = &mut f.kind {
components.clone_from(&imp.tree);
*stored = skip;
}
true
}
/// `stored`, kept by the old body indices, as a list by the new ones (empty for a new body).
fn by_new_index<T: Clone + Default>(matched: &[Option<usize>], stored: &[T]) -> Vec<T> {
matched.iter().map(|m| m.and_then(|old| stored.get(old)).cloned().unwrap_or_default()).collect()
}
/// [`realign_import`] for every Import feature of `doc` that is not suppressed, read with the
/// reader that will build it. The reads are returned so the caller can hold them while it
/// evaluates `doc`: the evaluation then finds each file read already instead of reading it again.
pub(crate) fn realign_imports(doc: &mut solidcraft_doc::Document) -> Vec<std::sync::Arc<StepImport>> {
let mut reads = Vec::new();
for f in doc.features.iter_mut().filter(|f| !f.suppressed) {
let FeatureKind::Import { step, components, .. } = &f.kind else { continue };
if step.is_empty() || components.is_empty() {
continue;
}
// A file that doesn't read fails the feature when it is evaluated, which reports it.
let Ok(imp) = solidcraft_doc::kernel::step_import_shared(step) else { continue };
if realign_import(f, &imp) {
log::info!("{}: the stored body names follow this version's numbering of the file's bodies", f.name);
}
reads.push(imp);
}
reads
}
/// The import's component, as the command reports it. The reply types in `schema/insert_mesh.rs`
/// and `schema/insert_step.rs` are what the commands answer with these fields.
pub(crate) struct Placed {
-1
View File
@@ -51,7 +51,6 @@ pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_obj
pub use features::{auto_operation, extrude_reach, shown_scope};
pub(crate) use features::{rail_fit_form, sweep_orientation};
pub(crate) use features_more::{MAX_SKETCH_ID, SidePoint, SketchFrameId, axes_mask, side_point, sketch_frame_id};
pub(crate) use file::import::realign_imports;
pub(crate) use file::mesh_crease_angle;
pub use file::open_steps::OpenSteps;
pub(crate) use file::{NAMES_MESSAGE, UP_MESSAGE, names_form, up_form, usable_path};
-32
View File
@@ -423,38 +423,6 @@ mod tests {
assert!(g.execute("plastic.lip", &json!({"face": [40, 25, 0], "width": 1, "height": 2})).is_err(), "a face without an inner edge");
}
#[test]
fn groove_on_bottom_rim_of_rounded_shell() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": solidcore_record::Plane::XY, "name": "Outline"}));
run(
&mut s,
"sketch.rectangle.rounded",
json!({"p0": [-25.263865881072945, -28.541660374585607], "p1": [25.263865881072945, 28.541660374585607], "radius": 3}),
);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "distance": 10}));
run(&mut s, "solid.shell", json!({"faces": [[0, 0, 0]], "thickness": 2}));
let state = s.model.state();
let shell = &state.body("Body1").unwrap().body;
assert!(shell.validity().is_empty(), "shell: {:?}", shell.validity());
let (plane, band) = solidcraft_kernel::rim_band(shell, Vec3::new(0.0, -27.541660374585607, 0.0), 1.5, 0.0, false).unwrap();
let cutter = solidcraft_kernel::extrude(&plane, &[band], -2.0, 0.0).unwrap();
for body in &cutter {
assert!(body.validity().is_empty(), "cutter: {:?}", body.validity());
}
let before = volume(&mut s);
let result = s.execute("plastic.lip", &json!({"face": [0, -27.541660374585607, 0], "width": 1.5, "height": 2, "type": "groove"}));
assert!(result.is_ok(), "{result:?}");
let area = |w: f64, h: f64, r: f64| w * h - (4.0 - PI) * r * r;
let w = 50.52773176214589;
let h = 57.083320749171214;
let groove_area = area(w - 1.0, h - 1.0, 2.5) - area(w - 4.0, h - 4.0, 1.0);
let removed = before - volume(&mut s);
assert!((removed - groove_area * 2.0).abs() < 0.05, "removed {removed}, expected {}", groove_area * 2.0);
assert!(s.model.state().body("Body1").unwrap().body.validity().is_empty());
}
/// A lip follows its rim face when a parameter moves it (#11209): it was kept by a point
/// only, so a taller box left the point below the rim and the lip failed.
#[test]
+2 -190
View File
@@ -1,5 +1,4 @@
//! Laying bodies on their print face for a slicer (#8429), or for an STL or 3MF export with
//! `lay_on_print_faces`. The bodies being sent are copied (the
//! Laying bodies on their print face for a slicer (#8429). The bodies being sent are copied (the
//! state the export writes is already a copy), and each copy whose body has a print face is
//! turned so the face points down, its centre of mass moved onto the world origin in X and Y,
//! and the face moved onto Z = 0: one rigid transform, applied once. The design and its timeline
@@ -21,7 +20,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("body.print_face", "Pri
.key("Ctrl+B")
.contract(&PRINT_FACE_CONTRACT)
.output(crate::schema::output_schema_for::<PrintFaceResult>)
.params("body: its name; point?: [x, y, z] on a flat face of it, in mm (omit to clear) → {body, face?}: marks the face a slicer export (file.send_to_slicer, file.save_mesh with slicer, file.open_in_easyprint), or an STL or 3MF export with lay_on_print_faces (file.export, file.export_stl, file.export_3mf, file.save_mesh), lays the body on: turned so the face points down at Z = 0, the centre of mass over the origin. One per body, replacing the last; flat faces of solid bodies only. A print face that is gone or no longer flat is reported in the export's warnings, and the body exported as it lies")];
.params("body: its name; point?: [x, y, z] on a flat face of it, in mm (omit to clear) → {body, face?}: marks the face a slicer export (file.send_to_slicer, file.save_mesh with slicer, file.open_in_easyprint) lays the body on: turned so the face points down at Z = 0, the centre of mass over the origin. One per body, replacing the last; flat faces of solid bodies only. A print face that is gone or no longer flat is reported in the export's warnings, and the body exported as it lies")];
const PRINT_FACE_CONTRACT: crate::schema::TypedContract<crate::schema::print_face::PrintFaceParams> = crate::schema::TypedContract::new();
@@ -297,193 +296,6 @@ mod tests {
assert!(opened.print_faces.is_empty());
}
/// A file in a fresh folder of the system's temporary folder, for `case`.
fn temp_file(case: &str) -> String {
let dir = std::env::temp_dir().join(format!("solidcraft-print-faces-{case}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temporary folder");
dir.join("block.solidcraft").to_string_lossy().into_owned()
}
#[test]
fn without_keep_print_faces_a_save_leaves_them_out_and_the_session_keeps_them() {
let mut s = block();
s.keep_print_faces = false;
run(&mut s, "body.print_face", json!({"body": "Block", "point": [10, 5, 5]}));
let path = temp_file("save");
run(&mut s, "file.save", json!({"path": path}));
// The design on screen still has it, and is not left with unsaved changes.
assert!(s.doc.print_faces.contains_key("Block"));
assert!(!s.is_dirty());
let mut opened = Session::default();
run(&mut opened, "doc.open", json!({"path": path}));
assert!(opened.doc.print_faces.is_empty(), "the file has no print face");
assert_eq!(opened.doc.features.len(), s.doc.features.len());
assert_eq!(opened.doc.params, s.doc.params);
// With keep_print_faces on (the engine's default), the file keeps it, as before.
s.keep_print_faces = true;
run(&mut s, "file.save", json!({"path": path}));
let mut opened = Session::default();
run(&mut opened, "doc.open", json!({"path": path}));
assert_eq!(opened.doc.print_faces, s.doc.print_faces);
}
#[test]
fn a_design_saved_with_print_faces_opens_without_them_unless_they_are_kept() {
let mut s = block();
run(&mut s, "material.assign", json!({"bodies": ["Block"], "material": "Brass"}));
run(&mut s, "body.print_face", json!({"body": "Block", "point": [10, 5, 5]}));
// Saved as an older version saved it: with the print face in the file.
let path = temp_file("older");
run(&mut s, "file.save", json!({"path": path}));
let mut opened = Session { keep_print_faces: false, ..Session::default() };
run(&mut opened, "doc.open", json!({"path": path}));
assert!(opened.doc.print_faces.is_empty(), "the stored print face is dropped as the design opens");
assert!(!opened.is_dirty(), "dropping it is not an unsaved change");
assert!(opened.undo.is_empty(), "nor a step to undo");
// Everything else the file holds is there.
assert_eq!(opened.doc.features.len(), s.doc.features.len());
assert_eq!(opened.doc.params, s.doc.params);
assert_eq!(opened.doc.materials, s.doc.materials);
assert_eq!(opened.model.state().bodies.len(), 1);
// The setting is the user's: it survives the open.
assert!(!opened.keep_print_faces);
// Kept, the design opens with its print face.
let mut kept = Session::default();
run(&mut kept, "doc.open", json!({"path": path}));
assert_eq!(kept.doc.print_faces, s.doc.print_faces);
}
/// `name` in a fresh folder of the system's temporary folder, for `case`.
fn temp_path(case: &str, name: &str) -> String {
let dir = std::env::temp_dir().join(format!("solidcraft-lay-on-print-faces-{case}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temporary folder");
dir.join(name).to_string_lossy().into_owned()
}
/// The box around every triangle of the STL file at `path`, in mm.
fn stl_box(path: &str) -> ([f64; 3], [f64; 3]) {
let triangles = solidcraft_io::read_stl(&std::fs::read(path).expect("the STL file")).expect("an STL file");
assert!(!triangles.is_empty(), "{path} has no triangles");
let (mut min, mut max) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for p in triangles.iter().flatten() {
for k in 0..3 {
min[k] = min[k].min(f64::from(p[k]));
max[k] = max[k].max(f64::from(p[k]));
}
}
(min, max)
}
/// Near enough for a coordinate an STL file holds as an f32.
fn near_f32(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-4
}
#[test]
fn an_stl_export_lays_the_body_on_its_print_face_only_when_asked() {
// The 10 x 5 end at X = 20 is the print face, so laid the 20 mm length stands up at Z = 0,
// centred on the origin in X and Y.
let mut s = block();
run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]}));
let undo = s.undo.len();
let laid_box = |(min, max): ([f64; 3], [f64; 3])| {
near_f32(min[2], 0.)
&& near_f32(max[2], 20.)
&& near_f32(min[0] + max[0], 0.)
&& near_f32(min[1] + max[1], 0.)
&& near_f32((max[0] - min[0]) * (max[1] - min[1]), 50.)
};
for (id, name, extra) in [
("file.export_stl", "a.stl", json!({})),
("file.export", "b.stl", json!({})),
("file.export", "c.stl", json!({"format": "stla"})),
("file.save_mesh", "d.stl", json!({})),
("file.save_mesh", "e.stl", json!({"format": "stl", "ascii": true})),
] {
let path = temp_path("stl", name);
let mut p = json!({"path": path, "lay_on_print_faces": true});
p.as_object_mut().expect("object").extend(extra.as_object().expect("object").clone());
let reply = run(&mut s, id, p);
assert_eq!(reply["print_faces"], json!(["Block"]), "{id}: {reply}");
assert!(reply.get("warnings").is_none(), "{id}: {reply}");
let bbox = stl_box(&path);
assert!(laid_box(bbox), "{id} {name}: {bbox:?}");
// Without the key, or with it false, the body is written as it lies in the design.
for lay in [json!(null), json!(false)] {
let path = temp_path("stl-as-it-lies", name);
let mut p = json!({"path": path, "lay_on_print_faces": lay});
p.as_object_mut().expect("object").extend(extra.as_object().expect("object").clone());
let reply = run(&mut s, id, p);
assert!(reply.get("print_faces").is_none(), "{id}: {reply}");
let (min, max) = stl_box(&path);
assert!(near_f32(min[0], 0.) && near_f32(max[0], 20.) && near_f32(min[2], 0.) && near_f32(max[2], 5.), "{id}: {min:?} {max:?}");
}
}
// The design itself has not moved, and nothing was added to undo.
assert_eq!(s.undo.len(), undo);
let m = solidcraft_kernel::measure_mass(&s.model.state().bodies[0].body).expect("measure");
assert!(near(m.bbox.min.x, 0.) && near(m.bbox.max.x, 20.) && near(m.bbox.max.z, 5.), "{:?}", m.bbox);
}
#[test]
fn a_3mf_export_reports_the_bodies_it_laid_on_their_print_face() {
let mut s = block();
run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]}));
for (id, name) in [("file.export_3mf", "a.3mf"), ("file.export", "b.3mf"), ("file.save_mesh", "c.3mf")] {
let path = temp_path("3mf", name);
let reply = run(&mut s, id, json!({"path": path, "lay_on_print_faces": true}));
assert_eq!(reply["print_faces"], json!(["Block"]), "{id}: {reply}");
assert!(std::fs::metadata(&path).is_ok_and(|m| m.len() > 0), "{id}: {path}");
}
}
#[test]
fn an_export_with_lay_on_print_faces_writes_a_body_without_one_as_it_lies() {
// A second body with no print face lies where it is; a print face cut away is reported.
let mut s = block();
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [50, 0, 0], "body_name": "Cube", "operation": "new"}));
let path = temp_path("none", "cube.stl");
let reply = run(&mut s, "file.export_stl", json!({"path": path, "bodies": ["Cube"], "lay_on_print_faces": true}));
assert!(reply.get("print_faces").is_none() && reply.get("warnings").is_none(), "{reply}");
let (min, max) = stl_box(&path);
assert!(near_f32(min[0], 50.) && near_f32(max[0], 54.) && near_f32(max[2], 4.), "{min:?} {max:?}");
run(&mut s, "body.print_face", json!({"body": "Block", "point": [10, 5, 5]}));
run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 5, "corner": [-5, -5, 3], "operation": "cut"}));
let path = temp_path("cut", "block.stl");
let reply = run(&mut s, "file.export_stl", json!({"path": path, "bodies": ["Block"], "lay_on_print_faces": true}));
assert!(reply.get("print_faces").is_none(), "{reply}");
assert!(reply["warnings"].as_array().is_some_and(|w| w.len() == 1 && w[0].as_str().is_some_and(|w| w.contains("Block"))), "{reply}");
let (min, max) = stl_box(&path);
assert!(near_f32(min[0], 0.) && near_f32(max[0], 20.) && near_f32(max[2], 3.), "{min:?} {max:?}");
}
#[test]
fn lay_on_print_faces_is_refused_for_a_format_that_is_not_printed_or_a_value_that_is_not_true_or_false() {
let mut s = block();
run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]}));
for (id, name, lay) in [
("file.export", "a.step", json!(true)),
("file.export", "a.obj", json!(true)),
("file.export", "a.glb", json!(true)),
("file.export", "a.json", json!(true)),
("file.export", "b.stl", json!("yes")),
("file.export_stl", "c.stl", json!(1)),
("file.export_3mf", "d.3mf", json!([true])),
("file.save_mesh", "e.stl", json!({})),
] {
let path = temp_path("refused", name);
let e = s.execute(id, &json!({"path": path, "lay_on_print_faces": lay})).expect_err(id).to_string();
assert!(e.contains("lay_on_print_faces"), "{id} {name}: {e}");
assert!(std::fs::metadata(&path).is_err(), "{id} {name}: a refused export writes nothing");
}
// False is accepted for any format: the file is written as the design has it.
let path = temp_path("false", "a.step");
run(&mut s, "file.export", json!({"path": path, "lay_on_print_faces": false}));
assert!(std::fs::metadata(&path).is_ok());
}
#[test]
fn renaming_the_body_keeps_its_print_face() {
let mut s = block();
+1 -23
View File
@@ -175,27 +175,6 @@ fn a_box_placed_by_its_centre_reads_its_sizes_in_the_design_units() {
keeps_shape_through(&mut s, &["in", "mm"]);
}
/// A box placed by its centre grows on both sides when its size changes later, also after a save and
/// reopen; a box placed by its corner keeps growing from that corner (#14487).
#[test]
fn a_box_placed_by_its_centre_stays_centred_when_its_size_changes() {
let near = |a: [f64; 6], b: [f64; 6]| a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6);
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 10, "center": [0, 0, 0], "name": "Box", "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 10, "corner": [50, 0, 0], "name": "Corner", "body_name": "B"}));
assert!(near(shape(&mut s, "A").bbox, [-10.0, -5.0, -5.0, 10.0, 5.0, 5.0]), "{:?}", shape(&mut s, "A"));
run(&mut s, "timeline.edit", json!({"feature": "Box", "set": {"length": "30"}}));
let a = shape(&mut s, "A");
assert!(near(a.bbox, [-15.0, -5.0, -5.0, 15.0, 5.0, 5.0]), "{a:?}");
assert!(rel(a.volume, 3000.0) < 1e-9, "{a:?}");
run(&mut s, "timeline.edit", json!({"feature": "Corner", "set": {"length": "30"}}));
assert!(near(shape(&mut s, "B").bbox, [50.0, 0.0, 0.0, 80.0, 10.0, 10.0]), "{:?}", shape(&mut s, "B"));
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
run(&mut reopened, "timeline.edit", json!({"feature": "Box", "set": {"width": "20", "height": "4"}}));
assert!(near(shape(&mut reopened, "A").bbox, [-15.0, -10.0, -2.0, 15.0, 10.0, 2.0]), "{:?}", shape(&mut reopened, "A"));
keeps_shape_through(&mut reopened, &["in", "mm"]);
}
#[test]
fn press_pull_keeps_its_distance_when_the_units_change() {
// A face pushed in: the cut's depth is the distance's size.
@@ -245,8 +224,7 @@ fn bare_numbers_beside_a_unitless_expression_stay_bare() {
assert_eq!(inputs(&s, 0)[2], "(16) / (2 * 4)", "the revolutions");
assert!(s.model.result(s.doc.features[0].id).is_some_and(|r| r.error.is_none()));
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 10, "center": ["2 * 5", 0, 0], "body_name": "B"}));
// The centre is kept as typed; the box stays centred on it (#14487).
assert_eq!(s.doc.features[1].kind.point_expr().map(|p| p[0].to_string()), Some("2 * 5".to_string()));
assert_eq!(s.doc.features[1].kind.point_expr().map(|p| p[0].to_string()), Some("(2 * 5) - (20) / 2".to_string()));
let b = shape(&mut s, "B");
assert!(b.bbox[0].abs() < 1e-6 && (b.bbox[3] - 20.0).abs() < 1e-6, "{b:?}");
}
+1 -1
View File
@@ -794,7 +794,7 @@ fn primitive_placement(kind: &solidcraft_doc::FeatureKind, cmd: &str) -> Vec<(&'
use solidcraft_doc::FeatureKind as Feature;
let point = placement_point;
match (kind, cmd) {
(Feature::Box { corner, centred, .. }, "solid.box") => vec![(if *centred { "center" } else { "corner" }, point(corner))],
(Feature::Box { corner, .. }, "solid.box") => vec![("corner", point(corner))],
(Feature::Sphere { center, .. }, "solid.sphere") | (Feature::Torus { center, .. }, "solid.torus") => vec![("center", point(center))],
(Feature::Cylinder { base, axis, .. }, "solid.cylinder") | (Feature::Coil { base, axis, .. }, "solid.coil") => {
vec![("base", point(base)), ("axis", pt(axis.0))]
-20
View File
@@ -494,26 +494,6 @@ fn a_hole_wider_than_the_part_says_it_removes_all_of_it() {
assert_eq!(said(&s, "H1"), "");
}
/// A hole across the part's width that touches none of its sides keeps the rest, in one piece and
/// silently, and says so once a parameter widens it past the whole part. The same hole touching a
/// side along a line is the case above.
#[test]
fn a_hole_across_the_part_clear_of_its_sides_keeps_the_rest_silently() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "hd", "expression": "40 mm"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "name": "Plate"}));
run(&mut s, "solid.hole", json!({"position": [10, 15, 10], "diameter": "hd", "name": "H1"}));
assert_eq!(said(&s, "H1"), "");
assert_eq!(s.model.state().bodies.len(), 1);
// The plate less the part of the 40 mm circle inside it, 10 mm deep.
assert!((volume(&mut s) - 3623.4382).abs() < 1e-3, "{}", volume(&mut s));
change(&mut s, "hd", "100 mm");
assert!(s.model.state().bodies.is_empty());
assert!(said(&s, "H1").contains("the hole removes all of Body1"), "{}", said(&s, "H1"));
change(&mut s, "hd", "5 mm");
assert_eq!(said(&s, "H1"), "");
}
/// The volume of one body.
fn body_volume(s: &mut Session, name: &str) -> f64 {
let m = run(s, "inspect.measure", json!({}));
+1 -8
View File
@@ -29,14 +29,7 @@ fn generated_assembly(parts: usize) -> Document {
let mut made = Vec::new();
for i in 0..parts {
let o = Vec3::new(30.0 * (i % 20) as f64, 30.0 * (i / 20) as f64, 0.0);
let block = FeatureKind::Box {
corner: point_expr(o),
length: mm(20.0),
width: mm(12.0),
height: mm(6.0),
operation: Operation::NewBody,
centred: false,
};
let block = FeatureKind::Box { corner: point_expr(o), length: mm(20.0), width: mm(12.0), height: mm(6.0), operation: Operation::NewBody };
made.push((i, doc.add_feature(block, Some(&format!("Plate {i}"))).unwrap()));
for (k, x) in [2.0, 9.0, 16.0].into_iter().enumerate() {
let pin = FeatureKind::Cylinder {
+7 -24
View File
@@ -538,12 +538,6 @@ pub struct Session {
/// How the host uploads a model to EasyPrint (`file.open_in_easyprint`); `None`: it can't. It
/// belongs to the session like `param_check`, so it survives opening and starting designs.
pub easyprint_upload: Option<EasyPrintUpload>,
/// Whether print faces (`body.print_face`) are saved with the design and kept when one is
/// opened (#11512). On by default, so scripts and the command line keep them; the app sets it
/// from its "Keep print faces" preference, which is off unless the user turns it on. Off: a
/// save leaves them out of the file and an opened design starts without them. The user's,
/// like `param_check`, so it survives opening and starting designs.
pub keep_print_faces: bool,
/// After a live frame: its sketch, the document it left and the revision. Its links were
/// brought up to date by that frame, so the gesture's next frame needn't project them again
/// while nothing else has run (#3222).
@@ -801,21 +795,14 @@ impl Session {
solidcraft_io::vfs::install_checks();
// The session keeps what is hidden (an undo step of its own); the design file carries it.
let visibility = std::mem::take(&mut doc.visibility);
let mut doc = Arc::new(doc);
let doc = Arc::new(doc);
// A model saved under another check mode holds that mode's results: evaluated again.
let model = match Self::saved_model(&doc, saved).filter(|model| model.validation() == validation) {
Some(model) => model,
None => {
// Stored body names follow the numbering of the STEP reader that inserted each
// file; this version's reader may number the bodies otherwise (#13382). The
// reads are held so the evaluation doesn't read the files again.
let _reads = cmd::realign_imports(Arc::make_mut(&mut doc));
let mut model = Model::new();
model.set_validation(validation);
model.evaluate_shared(&doc);
model
}
};
let model = Self::saved_model(&doc, saved).filter(|model| model.validation() == validation).unwrap_or_else(|| {
let mut model = Model::new();
model.set_validation(validation);
model.evaluate_shared(&doc);
model
});
Self::with_model(doc, visibility, model)
}
@@ -856,7 +843,6 @@ impl Session {
project_construction: false,
param_check: schema::ParamCheck::Off,
easyprint_upload: None,
keep_print_faces: true,
live_links: None,
active_component: 0,
active_occurrence_path: None,
@@ -923,7 +909,6 @@ impl Session {
let single_surface_analysis = self.single_surface_analysis;
let param_check = self.param_check;
let easyprint_upload = self.easyprint_upload.take();
let keep_print_faces = self.keep_print_faces;
let render_viewport = self.render_viewport.clone();
let render_jobs = self.render_jobs.scratch();
let checkpoints = std::mem::take(&mut self.checkpoints);
@@ -944,7 +929,6 @@ impl Session {
self.set_single_surface_analysis(single_surface_analysis);
self.param_check = param_check;
self.easyprint_upload = easyprint_upload;
self.keep_print_faces = keep_print_faces;
self.render_viewport = render_viewport;
self.render_jobs = render_jobs;
self.checkpoints = checkpoints;
@@ -2010,7 +1994,6 @@ impl Session {
project_construction: self.project_construction,
param_check: self.param_check,
easyprint_upload: self.easyprint_upload.clone(),
keep_print_faces: self.keep_print_faces,
live_links: None,
active_component: self.active_component,
active_occurrence_path: self.active_occurrence_path.clone(),
+1 -2
View File
@@ -37,8 +37,7 @@ pub struct BoxParams {
#[serde(default, deserialize_with = "present")]
#[schemars(with = "PointExpr3")]
pub corner: Option<PointExpr3>,
/// The box's centre, instead of `corner`. The box stays centred there when its length, width
/// or height changes later.
/// The box's centre, instead of `corner`.
#[serde(default, deserialize_with = "present")]
#[schemars(with = "PointExpr3")]
pub center: Option<PointExpr3>,
+6 -57
View File
@@ -52,8 +52,9 @@ pub struct ExportStepParams {
pub bodies: Option<StringList>,
}
/// Export bodies as an OBJ mesh file (`file.export_obj`), the colours of the bodies in a material
/// library, `.mtl`, written next to it. The file is OBJ whatever the path's extension.
/// Export bodies as a mesh file: binary STL for `file.export_stl`, OBJ for `file.export_obj`
/// (the colours of the bodies go in a material library, `.mtl`, written next to it). The file
/// has that format whatever the path's extension.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ExportMeshParams {
@@ -67,26 +68,6 @@ pub struct ExportMeshParams {
pub quality: Option<MeshQualityParam>,
}
/// Export bodies as a binary STL file (`file.export_stl`), whatever the path's extension.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ExportStlParams {
/// The file to write.
pub path: String,
/// The bodies to export, by name. Default: every body.
#[serde(default)]
pub bodies: Option<StringList>,
/// How closely the mesh follows curved faces. Default: standard.
#[serde(default)]
pub quality: Option<MeshQualityParam>,
/// True writes each body with a print face (`body.print_face`) laid on it, as a slicer export
/// lays it: turned so the face points down at Z = 0, its centre of mass over the origin. Each
/// body is laid on its own, so several land on the origin together. Default: false, every body
/// as it lies in the design.
#[serde(default)]
pub lay_on_print_faces: Option<bool>,
}
/// Export bodies as a 3MF file, whatever the path's extension.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
@@ -102,11 +83,6 @@ pub struct Export3mfParams {
/// What each object in the file holds. Default: body.
#[serde(default)]
pub objects: Option<ObjectsParam>,
/// As `file.export_stl`'s: true writes each body with a print face laid on it. A body that is
/// one of the parts of an object (`objects: component`) stays as it lies, so the object keeps
/// its shape. Default: false.
#[serde(default)]
pub lay_on_print_faces: Option<bool>,
}
/// Export the whole assembly as a robot description (URDF), with an STL mesh for each link
@@ -164,10 +140,6 @@ pub struct ExportParams {
/// URDF only: each joint's velocity limit, a positive number. Default: 10.
#[serde(default)]
pub velocity: Option<f64>,
/// STL and 3MF only: true writes each body with a print face laid on it, as
/// `file.export_stl` has it; refused for the other formats. Default: false. Not read for URDF.
#[serde(default)]
pub lay_on_print_faces: Option<bool>,
}
fn export_required(schema: &mut Schema) {
@@ -207,17 +179,8 @@ fn names<E: serde::de::Error>(m: &Map<String, Value>) -> Result<Option<StringLis
impl<'de> Deserialize<'de> for ExportParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let m = Map::<String, Value>::deserialize(d)?;
let mut out = ExportParams {
path: None,
format: None,
bodies: None,
quality: None,
objects: None,
mesh_prefix: None,
effort: None,
velocity: None,
lay_on_print_faces: None,
};
let mut out =
ExportParams { path: None, format: None, bodies: None, quality: None, objects: None, mesh_prefix: None, effort: None, velocity: None };
// The handler reads `path` first and refuses anything but a name it can write to; the
// other keys are read only once it has one, and the format is known.
out.path = match m.get("path") {
@@ -267,19 +230,12 @@ impl<'de> Deserialize<'de> for ExportParams {
_ => return Err(refuse(format!("`objects` must be body or component, not {v}"))),
},
};
out.lay_on_print_faces = match m.get("lay_on_print_faces") {
None | Some(Value::Null) => None,
Some(Value::Bool(false)) => Some(false),
Some(Value::Bool(true)) if matches!(format, Format::StlBinary | Format::StlAscii | Format::ThreeMf | Format::ThreeMfTagged) => Some(true),
Some(Value::Bool(true)) => return Err(refuse("`lay_on_print_faces` is for STL and 3MF: other formats are not printed")),
Some(_) => return Err(refuse("`lay_on_print_faces` must be true or false")),
};
Ok(out)
}
}
/// What `file.export_step`, `file.export_stl`, `file.export_3mf` and `file.export_obj` return.
#[derive(Debug, Default, Serialize, JsonSchema)]
#[derive(Debug, Serialize, JsonSchema)]
pub struct ExportResult {
/// The file written.
pub path: String,
@@ -291,13 +247,6 @@ pub struct ExportResult {
/// OBJ only: every file written, the material library first and the mesh file last.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<String>,
/// With `lay_on_print_faces` only: the bodies that were written laid on their print face.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub print_faces: Vec<String>,
/// With `lay_on_print_faces` only: a warning for each body that has a print face but was
/// written as it lies.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
+5 -46
View File
@@ -3,9 +3,9 @@ use std::time::Duration;
use serde_json::{Value, json};
use super::{
Export3mfParams, ExportMeshParams, ExportParams, ExportStepParams, ExportStlParams, ExportUrdfParams, FileExportResult, NewerFileReport,
OpenElsewhere, OpenParams, OpenResult, OpenedDesign, RecoverParams, RecoverResult, RecoveryDiscardParams, RecoveryDiscardResult,
RecoveryListParams, RecoveryListResult,
Export3mfParams, ExportMeshParams, ExportParams, ExportStepParams, ExportUrdfParams, FileExportResult, NewerFileReport, OpenElsewhere,
OpenParams, OpenResult, OpenedDesign, RecoverParams, RecoverResult, RecoveryDiscardParams, RecoveryDiscardResult, RecoveryListParams,
RecoveryListResult,
};
use crate::Session;
use crate::recovery::Autosaver;
@@ -16,13 +16,9 @@ use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<ExportStepParams>(), &["bodies", "path"]);
assert_keys(input_schema_for::<ExportMeshParams>(), &["bodies", "path", "quality"]);
assert_keys(input_schema_for::<ExportStlParams>(), &["bodies", "lay_on_print_faces", "path", "quality"]);
assert_keys(input_schema_for::<Export3mfParams>(), &["bodies", "lay_on_print_faces", "objects", "path", "quality"]);
assert_keys(input_schema_for::<Export3mfParams>(), &["bodies", "objects", "path", "quality"]);
assert_keys(input_schema_for::<ExportUrdfParams>(), &["effort", "mesh_prefix", "path", "velocity"]);
assert_keys(
input_schema_for::<ExportParams>(),
&["bodies", "effort", "format", "lay_on_print_faces", "mesh_prefix", "objects", "path", "quality", "velocity"],
);
assert_keys(input_schema_for::<ExportParams>(), &["bodies", "effort", "format", "mesh_prefix", "objects", "path", "quality", "velocity"]);
assert_keys(input_schema_for::<OpenParams>(), &["folder", "max_points", "names", "origin", "path", "read_only", "units", "up"]);
assert_keys(input_schema_for::<RecoveryListParams>(), &["dir"]);
assert_keys(input_schema_for::<RecoverParams>(), &["dir", "discard", "id"]);
@@ -248,17 +244,6 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
/// Mark a print face on the first body of `s`, a box: its side at the lowest X.
fn mark_a_print_face(s: &mut Session) {
let (name, bbox) = {
let world = s.world_state().unwrap_or_else(|e| panic!("{e}"));
let body = world.bodies.first().expect("a body");
(body.name.clone(), solidcraft_kernel::measure_mass(&body.body).expect("measure").bbox)
};
let point = [bbox.min.x, (bbox.min.y + bbox.max.y) / 2.0, (bbox.min.z + bbox.max.z) / 2.0];
run(s, "body.print_face", json!({"body": name, "point": point}));
}
/// 10 mm cubes in components A (grounded) and B, so a URDF and a 3MF with parts can be written.
fn two_cubes() -> Session {
let mut s = Session::default();
@@ -299,12 +284,6 @@ fn strict_keeps_todays_errors() {
("file.export", json!({"path": at("a.urdf"), "effort": 0})),
("file.export", json!({"path": at("a.urdf"), "velocity": "fast"})),
("file.export", json!({"path": at("a"), "format": "urdf", "velocity": 1e9})),
("file.export", json!({"path": at("a.step"), "lay_on_print_faces": true})),
("file.export", json!({"path": at("a.obj"), "lay_on_print_faces": true})),
("file.export", json!({"path": at("a.stl"), "lay_on_print_faces": "yes"})),
("file.export", json!({"path": at("a.3mf"), "lay_on_print_faces": 1})),
("file.export_stl", json!({"path": at("a.stl"), "lay_on_print_faces": "yes"})),
("file.export_3mf", json!({"path": at("a.3mf"), "lay_on_print_faces": [true]})),
("doc.open", json!({"path": ""})),
("doc.open", json!({"path": " "})),
("doc.open", json!({"path": 5})),
@@ -342,19 +321,6 @@ fn the_export_type_reads_each_key_only_for_its_format() {
assert!(ok(json!({"path": "a.xyz", "quality": "rough", "effort": -1})).quality.is_none());
assert!(ok(json!({"quality": "rough"})).path.is_none());
assert!(ok(json!({"path": "a.stl", "bodies": null, "format": null, "quality": null, "objects": null})).bodies.is_none());
// `lay_on_print_faces`: true for STL and 3MF only, false for any format, not read for URDF.
for path in ["a.stl", "a.3mf"] {
assert_eq!(ok(json!({"path": path, "lay_on_print_faces": true})).lay_on_print_faces, Some(true), "{path}");
}
assert_eq!(ok(json!({"path": "a", "format": "stla", "lay_on_print_faces": true})).lay_on_print_faces, Some(true));
assert_eq!(ok(json!({"path": "a.step", "lay_on_print_faces": false})).lay_on_print_faces, Some(false));
assert!(ok(json!({"path": "a.urdf", "lay_on_print_faces": "yes"})).lay_on_print_faces.is_none());
for path in ["a.step", "a.obj", "a.glb", "a.json"] {
let e = read(json!({"path": path, "lay_on_print_faces": true})).expect_err(path);
assert!(e.contains("STL and 3MF"), "{path}: {e}");
}
let e = read(json!({"path": "a.stl", "lay_on_print_faces": "yes"})).expect_err("a string");
assert!(e.contains("must be true or false"), "{e}");
}
/// (`conforms` does not read `oneOf`, so `quality` and `objects` values are checked by the type,
@@ -368,9 +334,7 @@ fn the_export_schema_takes_what_the_handler_reads() {
(json!({"path": "a.3mf", "format": "3mf-tagged", "bodies": ["A", "B"], "quality": "fine", "objects": "component"}), true),
(json!({"path": "robot.urdf", "mesh_prefix": "package://robot/meshes/", "effort": 50, "velocity": 2.5}), true),
(json!({"path": "a.step", "bodies": "Body1"}), true),
(json!({"path": "a.stl", "lay_on_print_faces": true}), true),
(json!({}), false),
(json!({"path": "a.stl", "lay_on_print_faces": "yes"}), false),
(json!({"path": "a.stl", "bodies": [1]}), false),
(json!({"path": "a.stl", "colour": true}), false),
] {
@@ -395,15 +359,10 @@ fn export_replies_match_the_output_schema() {
json!({"path": at("f.bin"), "format": "3mf-tagged"}),
json!({"path": at("g.json")}),
json!({"path": at("h.urdf"), "mesh_prefix": "package://robot/meshes/", "effort": 50, "velocity": 2}),
// With a print face marked: `print_faces` in the first, and in the second a warning (the
// body is a part of a component's object, so it stays as it lies).
json!({"path": at("i.stl"), "lay_on_print_faces": true}),
json!({"path": at("j.3mf"), "objects": "component", "lay_on_print_faces": true}),
];
for level in [ParamCheck::Warn, ParamCheck::Strict] {
let mut s = two_cubes();
s.param_check = level;
mark_a_print_face(&mut s);
for p in &calls {
let reply = run(&mut s, "file.export", p.clone());
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
+3 -5
View File
@@ -6,11 +6,9 @@ use serde::{Deserialize, Serialize};
use super::types::Point3;
/// Mark the flat face of a body at a point as the body's print face, or clear it. A slicer
/// export (`file.send_to_slicer`, `file.save_mesh` with `slicer`, `file.open_in_easyprint`), and an
/// STL or 3MF export with `lay_on_print_faces` (`file.export`, `file.export_stl`, `file.export_3mf`,
/// `file.save_mesh`), lays the body on its print face: turned so the face points down and lies at
/// Z = 0, the centre of mass over the origin. A body has one print face at most; marking another
/// replaces it.
/// export (`file.send_to_slicer`, `file.save_mesh` with `slicer`, `file.open_in_easyprint`) lays
/// the body on its print face: turned so the face points down and lies at Z = 0, the centre of
/// mass over the origin. A body has one print face at most; marking another replaces it.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PrintFaceParams {
+2 -10
View File
@@ -72,13 +72,6 @@ pub struct SaveMeshKeys {
/// other formats. Default: body.
#[serde(default)]
pub objects: Option<MeshObjects>,
/// True saves each body with a print face (`body.print_face`) laid on it, as a slicer export
/// lays it: turned so the face points down at Z = 0, its centre of mass over the origin. Each
/// body is laid on its own, so several land on the origin together; a part of a 3MF object
/// (`objects: component`) stays as it lies. A file sent to `slicer` is laid on them whatever
/// this says. Default: false.
#[serde(default)]
pub lay_on_print_faces: Option<bool>,
}
/// Leaves out the keys the handler never reads for this call: `ascii` unless `format` is `stl`,
@@ -352,11 +345,10 @@ pub struct SaveMeshResult {
pub bytes: usize,
/// The format written: StlBinary, StlAscii, ThreeMf or ThreeMfTagged.
pub format: String,
/// With `slicer` or `lay_on_print_faces` only: the bodies that were saved laid on their print face.
/// With `slicer` only: the bodies that were laid on their print face for the slicer.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub print_faces: Vec<String>,
/// With `slicer` or `lay_on_print_faces` only: a warning for each body that has a print face but
/// was saved as it lies.
/// With `slicer` only: a warning for each body that has a print face but was saved as it lies.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
/// With `slicer` only: what happened when the file was handed over.
+1 -17
View File
@@ -23,10 +23,7 @@ fn with_box(level: ParamCheck) -> Session {
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(
input_schema_for::<SaveMeshParams>(),
&["ascii", "bodies", "format", "lay_on_print_faces", "objects", "path", "quality", "single_instance", "slicer"],
);
assert_keys(input_schema_for::<SaveMeshParams>(), &["ascii", "bodies", "format", "objects", "path", "quality", "single_instance", "slicer"]);
// `path` is the key the handler refuses when it is left out: required in the schema, optional in the type.
let schema = input_schema_for::<SaveMeshParams>();
assert_eq!(schema.as_value()["required"], json!(["path"]));
@@ -77,9 +74,6 @@ fn strict_keeps_todays_errors() {
json!({"bodies": {"a": 1}}),
json!({"bodies": true}),
json!({"format": "stl", "bodies": ["a", null]}),
json!({"lay_on_print_faces": "yes"}),
json!({"lay_on_print_faces": 1}),
json!({"format": "3mf", "lay_on_print_faces": {}}),
] {
cases.push(("file.save_mesh", p(extra)));
}
@@ -230,16 +224,6 @@ fn replies_match_the_output_schema() {
}
assert!(std::fs::read_to_string(at("e.stl")).unwrap().starts_with("solid"));
assert!(std::fs::read(at("a.stl")).unwrap().len() > 84);
// Laid on their print faces: the bodies laid are named, and the reply still matches.
s.execute("body.print_face", &json!({"body": "Block", "point": [10, 5, 5]})).unwrap();
for p in [json!({"path": at("r.stl"), "lay_on_print_faces": true}), json!({"path": at("s.3mf"), "lay_on_print_faces": true})] {
let r = s.execute("file.save_mesh", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
matches(&r);
assert_eq!(keys(&r), ["bytes", "format", "path", "print_faces"].map(String::from).into(), "{p}: {r}");
assert_eq!(r["print_faces"], json!(["Block"]), "{p}");
}
s.execute("body.print_face", &json!({"body": "Block"})).unwrap();
// A misspelt key runs in warn and says so; the reply still matches. Strict refuses it.
let p = json!({"path": at("p.stl"), "qualty": "fine"});
-5
View File
@@ -64,11 +64,6 @@ pub struct SplitFaceParams {
/// `tool` is given.
#[serde(default)]
pub sketch: Option<SketchRef>,
/// With `sketch`: true joins its curves end to end first, so a closed loop of lines and arcs
/// splits the faces along the loop only. Default: false, each curve on its own, an open one
/// extended past its ends.
#[serde(default)]
pub loops: Option<bool>,
/// The plane to split along. Read when neither `tool` nor `sketch` is given; then it is
/// required.
#[serde(default)]
+1 -4
View File
@@ -16,7 +16,7 @@ fn schemas_and_the_key_check_agree() {
);
assert_keys(
input_schema_for::<SplitFaceParams>(),
&["body", "body_name", "body_names", "faces", "loops", "name", "participants", "plane", "sketch", "tool"],
&["body", "body_name", "body_names", "faces", "name", "participants", "plane", "sketch", "tool"],
);
}
@@ -155,8 +155,6 @@ fn the_check_takes_what_the_schema_allows() {
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "tool": {"body": "T", "point": [1, 2, 3]}}), true),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": "Cuts"}), true),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": 7, "name": "Split", "participants": ["*"]}), true),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": "Cuts", "loops": true}), true),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": "Cuts", "loops": null}), true),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "tool": null, "sketch": null, "plane": null, "body": null}), true),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]]}), true),
("solid.split_face", &face, json!({"plane": "XY"}), false),
@@ -164,7 +162,6 @@ fn the_check_takes_what_the_schema_allows() {
("solid.split_face", &face, json!({"faces": [[1, 2]], "plane": "XY"}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "body": 5, "plane": "XY"}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": true}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "sketch": "Cuts", "loops": "yes"}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "tool": {"point": [1, 2, 3]}}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "plane": "XY", "line": "cut"}), false),
] {
-27
View File
@@ -55,33 +55,6 @@ fn split_a_face_by_sketch_curves() {
assert!((v - 24000.0).abs() < 1e-6 * 24000.0, "{v}");
}
/// A triangle of three lines on the top: by default each side is extended across the face; with
/// `loops` the sides are joined first and the closed loop cuts out the triangle only. Open curves
/// are extended either way.
#[test]
fn split_a_face_by_a_closed_loop_of_lines() {
let split = |loops: bool| {
let mut s = boxed();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Loop"}));
run(&mut s, "sketch.line", json!({"points": [[10, 5], [30, 5], [20, 25]], "closed": true}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.split_face", json!({"faces": [[20, 12, 20]], "body": "B", "sketch": "Loop", "loops": loops}));
faces_volume(&mut s, "B")
};
let (f, v) = split(true);
assert_eq!(f, 7, "the triangle and the rest of the top");
assert!((v - 24000.0).abs() < 1e-6 * 24000.0, "{v}");
assert!(split(false).0 > 7, "each side extended across the top");
let mut s = boxed();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cuts"}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [10, 20]]}));
run(&mut s, "sketch.line", json!({"points": [[30, 10], [30, 20]]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.split_face", json!({"faces": [[20, 15, 20]], "body": "B", "sketch": "Cuts", "loops": true}));
assert_eq!(faces_volume(&mut s, "B").0, 8, "two open lines still cut across the top");
}
#[test]
fn a_tool_that_misses_is_refused() {
let mut s = boxed();
@@ -143,7 +143,6 @@ fn placement_dependent_cross_component_cuts_still_evaluate_and_undo() {
width: "10".into(),
height: "4".into(),
operation: Operation::NewBody,
centred: false,
},
None,
)
@@ -160,7 +159,6 @@ fn placement_dependent_cross_component_cuts_still_evaluate_and_undo() {
width: "2".into(),
height: "6".into(),
operation: Operation::Cut,
centred: false,
},
None,
)
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "solidcraft-format"
description = "Versioned JSON file formats: the format tag, the registry of versions (hand-written steps, then one folder of changes per version, then the pending changes), the upgrade of an older file and the telling of a newer one"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
serde_json = { workspace = true }
# The container: content-addressed blobs (SHA-256) in a ZIP package.
sha2 = { workspace = true }
zip = { workspace = true }
-66
View File
@@ -1,66 +0,0 @@
# solidcraft-format
Versioned file formats: what an app needs to read every file it ever wrote, to open a file a
newer build of it wrote without losing what it can't read, and to package a file with its binary
payloads. It holds no app types; each app registers its own families of files and their versions,
and keeps its own payload encodings.
A file starts with `"format": "<prefix>N"`. A family's versions are, in order: the steps written
out by hand for its first formats, then one folder per format (`v<N>/`, one file per change, each
with a JSON step when older files are rewritten as they open), then the changes in flight
(`pending/`), which make the next number until a release folds them into `v<N>/`. App code only
knows the current format: all translation lives in the steps.
## API
| Item | What it does |
|---|---|
| `Family { app, noun, prefix, legacy_prefix, untagged, aliases, formats }` | One kind of file: its tag, its messages, its versions |
| `Formats { hand, folded, pending }` | A family's versions: hand-written steps, folded formats and their changes, pending changes |
| `Part { name, step }` | One change: its file's name, and its JSON step if it has one |
| `Step<E>` | `fn(&mut Value) -> Result<(), E>`: a JSON-level step, returning the app's error |
| `Alias { format, name, known_as }` | Another spelling of a change name that files of one format may carry |
| `Family::current()` | The format this build writes |
| `Family::format_string(n)`, `Family::version_of(tag)` | A format's tag, a tag's format (the legacy prefix read too) |
| `Family::pending_names()` | The changes in flight, written into a file's `format_pending` |
| `Family::take_version(&mut json)` | A file's format and its `format_pending`, taken out before anything else is read; a file not of the family is refused |
| `Family::newer(version, applied)` | `Some(Newer)` when a newer build wrote the file: a number past this build's, or a change it doesn't know |
| `Family::upgrade(&mut json, version, applied)` | Run the steps that bring the file to this build's format, each once |
| `Newer { format, reads_up_to, unknown_changes }` | How a file shows a newer build wrote it |
| `FormatError` | This crate's refusals; the app's error takes it with `From` |
| `build::lists(manifest, families)` | For a build script: the generated `FOLDED` and `PENDING` lists and a module per change file |
| `newer::NewerFile` | What a file a newer build saved holds that this build doesn't read: `new(app, &newer)`, `notice`, `warnings`, `report`, `keep_section`, `keep_element`, `compare` (the file against what was read of it), `restore_into` (put it all back on saving), `mentions`, `needs_container`, and the container's unknown `parts` and `container` |
| `newer::leave_out_unreadable`, `newer::read_around` | Leave out, and keep, the top-level sections a file can't be read with |
| `newer::short`, `newer::looks_default` | A reader's error's first line; a value a writer leaves out |
| `container::Writer` | A ZIP container being written: `part`, `blob` (content-addressed, `blobs/<sha256>.<ext>`), `room`/`space` for optional caches, `finish` |
| `container::Reader` | One being read over a seekable source, its directory checked on opening: `document`, `limited_part`, `blob`, `limited_blob`, `has_part`, `part_names`, `hydrated` |
| `container::Limits` | The app's budgets: entries, expanded size, per-part limits, Deflate level, file size |
| `container::Compression`, `container::Thumbnail`, `container::digest`, `container::is_container` | Fast or compact payloads; a PNG picture keyed to its file; the SHA-256 name of bytes; the ZIP signature |
## Registering a family
The app's build script lists the folders:
```rust
use solidcraft_format::build::Family;
// format 1 is the first record, so its folded formats start at 2 (`base` is the last format before them)
const FAMILIES: [Family; 1] = [Family { folder: "src/format", base: 1, list_prefix: "" }];
let lists = solidcraft_format::build::lists(&manifest, &FAMILIES)?;
// write lists.code to OUT_DIR, rerun on each of lists.watch
```
and its format module includes them, with its error type:
```rust
type Part = solidcraft_format::Part<AppError>;
include!(concat!(env!("OUT_DIR"), "/format_parts.rs"));
pub const DESIGN: solidcraft_format::Family<'static, AppError> = solidcraft_format::Family {
app: "App", noun: "design", prefix: "app/", legacy_prefix: None, untagged: None, aliases: &[],
formats: solidcraft_format::Formats { hand: &[], folded: FOLDED, pending: PENDING },
};
```
A change file is `v<N>/<name>.rs` or `pending/<name>.rs`, named with lowercase letters, digits and
dashes, starting with a `//!` note (what is saved, what an older file gets, what the last release
does with a file that has it), and with `pub fn step(v: &mut Value) -> Result<(), AppError>` when
older files are rewritten as they open.
-100
View File
@@ -1,100 +0,0 @@
//! For a build script: the generated lists of a family's changes. Each change is one `.rs` file in
//! the family's folder, in `v<N>/` once a release fixed its number, else in `pending/`; the lists
//! are generated so that a change adds a file and never edits a line another change also edits.
//!
//! The generated code, for the app to `include!`, is per family a `FOLDED` list (each folded
//! format and its parts) and a `PENDING` list, both prefixed by the family's `list_prefix`, then a
//! module per change file. It names the app's `Part` type, which the including module has in scope
//! (`type Part = solidcraft_format::Part<AppError>;`), and a change file's step as `step` (a
//! `pub fn step(v: &mut Value) -> Result<(), AppError>`, when the file has one).
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
/// A family's change files: their folder (relative to the crate's manifest), the last format
/// before them, and the prefix of the family's lists.
pub struct Family {
pub folder: &'static str,
pub base: u32,
pub list_prefix: &'static str,
}
/// What [`lists`] made: the code to write, and the folders the build script reruns on.
pub struct Lists {
pub code: String,
pub watch: Vec<PathBuf>,
}
/// The `FOLDED` and `PENDING` lists of every family, and a module for each change file. Refuses
/// versions that are not contiguous from a family's base, a change file not named with lowercase
/// letters, digits and dashes, and one that doesn't start with a `//!` note.
pub fn lists(manifest: &Path, families: &[Family]) -> Result<Lists, String> {
let mut code = String::new();
let mut mods = String::new();
let mut watch = Vec::new();
for family in families {
let root = manifest.join(family.folder);
family_lists(family, &root, &mut code, &mut mods)?;
watch.push(root);
}
code.push_str(&mods);
Ok(Lists { code, watch })
}
/// A family's `FOLDED` and `PENDING` lists; its folded formats follow on from its base.
fn family_lists(family: &Family, root: &Path, out: &mut String, mods: &mut String) -> Result<(), String> {
let Family { folder, base, list_prefix } = family;
let mut folded: Vec<(u32, PathBuf)> = std::fs::read_dir(root)
.map_err(|e| format!("{}: {e}", root.display()))?
.filter_map(|entry| {
let path = entry.ok()?.path();
let version = path.file_name()?.to_str()?.strip_prefix('v')?.parse().ok()?;
path.is_dir().then_some((version, path))
})
.collect();
folded.sort();
for (i, (version, _)) in folded.iter().enumerate() {
let expected = base + 1 + i as u32;
if *version != expected {
return Err(format!("{folder}/: expected v{expected}/ next, found v{version}/ (versions are contiguous)"));
}
}
// Module names start with the family's (`drawing_pending_…`), so two families' changes of one
// name never clash.
let module = list_prefix.to_ascii_lowercase();
let _ = writeln!(
out,
"/// The changes of each format after the base, oldest first (`{folder}/v<N>/`).\nconst {list_prefix}FOLDED: &[(u32, &[Part])] = &["
);
for (version, dir) in &folded {
let _ = writeln!(out, " ({version}, &[");
parts(dir, &format!("{module}v{version}"), out, mods)?;
out.push_str(" ]),\n");
}
let _ = writeln!(out, "];\n\n/// The changes no release has a number for yet (`{folder}/pending/`).\nconst {list_prefix}PENDING: &[Part] = &[");
parts(&root.join("pending"), &format!("{module}pending"), out, mods)?;
out.push_str("];\n\n");
Ok(())
}
/// The `Part`s of the change files in `dir`, in file-name order, and a module for each.
fn parts(dir: &Path, prefix: &str, out: &mut String, mods: &mut String) -> Result<(), String> {
let Ok(entries) = std::fs::read_dir(dir) else { return Ok(()) };
let mut files: Vec<PathBuf> = entries.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x == "rs")).collect();
files.sort();
for path in files {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_string();
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') {
return Err(format!("{}: name a format change file with lowercase letters, digits and dashes", path.display()));
}
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
if !text.trim_start().starts_with("//!") {
return Err(format!("{}: start a format change with a `//!` note", path.display()));
}
let module = format!("{prefix}_{}", name.replace('-', "_"));
let step = if text.contains("fn step(") { format!("Some({module}::step)") } else { "None".into() };
let _ = writeln!(out, " Part {{ name: {name:?}, step: {step} }},");
let _ = writeln!(mods, "#[path = {:?}]\nmod {module};", path.display().to_string());
}
Ok(())
}
-339
View File
@@ -1,339 +0,0 @@
//! The file container: a ZIP package of named parts (the file's JSON, binary payloads, optional
//! caches), never extracted to filesystem paths. Payloads are content-addressed blobs,
//! `blobs/<sha256>.<extension>`, stored once however often they are referred to. Every size is
//! bounded by the app's [`Limits`]: the file, each part, all parts expanded, the payloads resolved,
//! and the number of entries; a part is read no further than its declared size (a decompression
//! bomb stops there). What a part holds, and how a payload is encoded, is the app's.
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use std::sync::Arc;
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::FormatError;
/// How hard the payloads are compressed: Fast favours interactive file operations; Compact trades
/// CPU time for space.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Compression {
#[default]
Fast,
Compact,
}
/// An app's budgets for its containers. The functions are read each time they are needed, so a
/// limit the app lets the user raise takes effect at once.
#[derive(Clone, Copy)]
pub struct Limits {
/// The most entries a container holds.
pub max_entries: usize,
/// The most bytes all parts may expand to, and all payloads may resolve to.
pub max_expanded: fn() -> usize,
/// The most bytes a part of this name may expand to.
pub part_limit: fn(&str) -> usize,
/// The Deflate level of a compressed part of this name and size.
pub level: fn(&str, usize) -> i64,
/// Whether a container of this many bytes may be read, or why not.
pub check_size: fn(u64) -> Result<(), String>,
}
fn invalid(message: impl std::fmt::Display) -> FormatError {
FormatError::Invalid(message.to_string())
}
/// Whether the bytes start as a container does, whatever the file is called.
pub fn is_container(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK\x03\x04")
}
/// The SHA-256 of the bytes, in lowercase hexadecimal: a blob's name, and how a cache is keyed to
/// the file it was written for.
pub fn digest(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
/// A container being written.
pub struct Writer {
zip: zip::ZipWriter<Cursor<Vec<u8>>>,
names: BTreeSet<String>,
expanded: usize,
resolved: usize,
limits: Limits,
}
impl Writer {
pub fn new(limits: Limits) -> Writer {
Writer { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0, limits }
}
/// The container's bytes.
pub fn finish(self) -> Result<Vec<u8>, FormatError> {
Ok(self.zip.finish().map_err(invalid)?.into_inner())
}
/// Add a part, compressed or stored. A name already written is written once.
pub fn part(&mut self, name: &str, bytes: &[u8], compressed: bool) -> Result<(), FormatError> {
if bytes.len() > (self.limits.part_limit)(name) {
return Err(invalid("part too large"));
}
if !self.names.insert(name.to_owned()) {
return Ok(());
}
self.expanded = self.expanded.checked_add(bytes.len()).ok_or_else(|| invalid("expanded size overflow"))?;
if self.expanded > (self.limits.max_expanded)() || self.names.len() > self.limits.max_entries {
return Err(invalid("package too large"));
}
let method = if compressed { zip::CompressionMethod::Deflated } else { zip::CompressionMethod::Stored };
let level = (self.limits.level)(name, bytes.len());
// A part of 4 GiB or more (only with a raised file limit) needs a ZIP64 entry.
let options = zip::write::SimpleFileOptions::default()
.compression_method(method)
.compression_level(compressed.then_some(level))
.large_file(bytes.len() as u64 >= u64::from(u32::MAX));
self.zip.start_file(name, options).map_err(invalid)?;
self.zip.write_all(bytes).map_err(invalid)
}
/// Would `entries` more optional parts of `extra` bytes, stored uncompressed, still keep the
/// file within `budget` and its expanded size within the reader's budget?
pub fn room(&self, entries: usize, extra: usize, budget: usize) -> bool {
self.names.len().saturating_add(entries) <= self.limits.max_entries && extra <= self.space(entries, budget)
}
/// The bytes `entries` more optional parts may hold together ([`Writer::room`]).
pub fn space(&self, entries: usize, budget: usize) -> usize {
if self.names.len().saturating_add(entries) > self.limits.max_entries {
return 0;
}
// The bytes written so far, plus what the open compressed part may still hold back.
let written = self.zip.get_ref().map_or(usize::MAX, |c| c.get_ref().len()).saturating_add(1 << 20);
let directory = self.names.len().saturating_add(entries).saturating_mul(256).saturating_add(65_536);
budget.saturating_sub(written.saturating_add(directory)).min((self.limits.max_expanded)().saturating_sub(self.expanded))
}
/// Add a payload as a blob, `blobs/<sha256>.<extension>`, and say its name, for the JSON that
/// refers to it.
pub fn blob(&mut self, bytes: &[u8], extension: &str, compressed: bool) -> Result<Value, FormatError> {
self.resolved = self
.resolved
.checked_add(bytes.len())
.filter(|&n| n <= (self.limits.max_expanded)())
.ok_or_else(|| invalid("resolved payloads too large"))?;
let name = format!("blobs/{}.{extension}", digest(bytes));
self.part(&name, bytes, compressed)?;
Ok(Value::String(name))
}
}
/// A container being read, over a seekable source. Opening checks the directory (the entry count,
/// unique and plain names, every part's declared size), not the parts.
pub struct Reader<R: Read + Seek> {
zip: zip::ZipArchive<R>,
blobs: BTreeMap<String, Vec<u8>>,
/// The bytes of payloads resolved so far, repeats counted: bounded by the expanded budget.
pub hydrated: usize,
document: Option<Arc<[u8]>>,
limits: Limits,
}
impl<R: Read + Seek> Reader<R> {
pub fn new(mut source: R, limits: Limits) -> Result<Self, FormatError> {
let length = source.seek(SeekFrom::End(0)).map_err(invalid)?;
(limits.check_size)(length).map_err(invalid)?;
let tail_length = length.min(65_557) as usize;
source.seek(SeekFrom::Start(length - tail_length as u64)).map_err(invalid)?;
let mut tail = vec![0; tail_length];
source.read_exact(&mut tail).map_err(invalid)?;
// zip's name index can coalesce duplicate central-directory entries. The bounded format
// does not need ZIP64/multiple disks: verify the declared entry count as well so
// duplicate names cannot silently select a payload.
let eocd = tail.windows(4).rposition(|v| v == b"PK\x05\x06").ok_or_else(|| invalid("missing ZIP directory"))?;
let end = tail.get(eocd..).filter(|v| v.len() >= 22).ok_or_else(|| invalid("truncated ZIP directory"))?;
let u16_at = |offset| -> Result<u16, FormatError> {
let array: [u8; 2] = end.get(offset..offset + 2).ok_or_else(|| invalid("truncated ZIP directory"))?.try_into().map_err(invalid)?;
Ok(u16::from_le_bytes(array))
};
let declared = u16_at(10)? as usize;
if declared > limits.max_entries
|| u16_at(4)? != 0
|| u16_at(6)? != 0
|| u16_at(8)? as usize != declared
|| end.len() != 22 + u16_at(20)? as usize
{
return Err(invalid("too many entries or unsupported ZIP directory"));
}
// A container is a ZIP with nothing in front of it. Saying so skips zip's search for a
// prefix, which reads back through the stored parts and would load a large payload
// before a preview can be shown.
let config = zip::read::Config { archive_offset: zip::read::ArchiveOffset::Known(0) };
let mut zip = zip::ZipArchive::with_config(config, source).map_err(invalid)?;
if u16_at(4)? != 0
|| u16_at(6)? != 0
|| u16_at(8)? as usize != zip.len()
|| u16_at(10)? as usize != zip.len()
|| end.len() != 22 + u16_at(20)? as usize
{
return Err(invalid("duplicate entries or unsupported ZIP directory"));
}
let mut names = BTreeSet::new();
let mut total = 0u64;
for i in 0..zip.len() {
let part = zip.by_index_raw(i).map_err(invalid)?;
let name = part.name().map_err(invalid)?.into_owned();
if name.is_empty()
|| name.contains('\\')
|| name.split('/').any(|c| c.is_empty() || c == "." || c == "..")
|| !names.insert(name.to_owned())
{
return Err(invalid("duplicate or invalid archive entry"));
}
total = total.checked_add(part.size()).ok_or_else(|| invalid("expanded size overflow"))?;
let limit = (limits.part_limit)(&name);
if part.size() > limit as u64 || total > (limits.max_expanded)() as u64 {
return Err(invalid("expanded package too large"));
}
}
Ok(Self { zip, blobs: BTreeMap::new(), hydrated: 0, document: None, limits })
}
/// The file's own part, `document.json`, read once and kept: provisional caches and the
/// authoritative reading are bound to the same bytes, even if another process overwrites the
/// open file in place. Blobs remain hash-checked.
pub fn document(&mut self, limit: usize) -> Result<Arc<[u8]>, FormatError> {
if self.document.is_none() {
self.document = Some(self.limited_part("document.json", limit)?.into());
}
self.document.as_ref().filter(|d| d.len() <= limit).cloned().ok_or_else(|| invalid("metadata budget"))
}
/// Whether the container has a part of this name.
pub fn has_part(&self, name: &str) -> bool {
self.zip.index_for_name(name).is_some()
}
/// The names of every part, in the container's order.
pub fn part_names(&self) -> Vec<String> {
self.zip.file_names().filter_map(|name| name.ok().map(|name| name.into_owned())).collect()
}
/// The part of this name, at most `limit` bytes.
pub fn limited_part(&mut self, name: &str, limit: usize) -> Result<Vec<u8>, FormatError> {
let part = self.zip.by_name(name).map_err(|e| invalid(format!("required part `{name}`: {e}")))?;
let expected = part.size();
if expected > limit as u64 {
return Err(invalid("part too large"));
}
let mut bytes = Vec::new();
bytes.try_reserve_exact(expected as usize).map_err(|_| invalid("could not reserve required part"))?;
// A compressed part may expand beyond its declared size (a decompression bomb): stop
// one byte past it instead of at the limit.
part.take(expected + 1).read_to_end(&mut bytes).map_err(invalid)?;
if bytes.len() > limit || bytes.len() as u64 != expected {
return Err(invalid("part too large or length mismatch"));
}
Ok(bytes)
}
/// The blob `value`'s `field` names, of this extension, within its part limit.
pub fn blob(&mut self, value: &Value, field: &str, extension: &str) -> Result<&[u8], FormatError> {
let limit = (self.limits.part_limit)(&format!("blob.{extension}"));
self.limited_blob(value, field, extension, limit)
}
/// [`Reader::blob`] within `limit` bytes. Its hash is checked when it is first read, and every
/// reference counts against the resolved budget.
pub fn limited_blob(&mut self, value: &Value, field: &str, extension: &str, limit: usize) -> Result<&[u8], FormatError> {
let name = value.get(field).and_then(Value::as_str).ok_or_else(|| invalid(format!("missing `{field}` reference")))?;
let hash = name
.strip_prefix("blobs/")
.and_then(|n| n.strip_suffix(&format!(".{extension}")))
.filter(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
.ok_or_else(|| invalid("invalid blob reference"))?;
if !self.blobs.contains_key(name) {
let bytes = self.limited_part(name, limit)?;
if digest(&bytes) != hash {
return Err(invalid(format!("blob hash mismatch: {name}")));
}
self.blobs.insert(name.to_owned(), bytes);
}
let bytes = self.blobs.get(name).ok_or_else(|| invalid("missing blob"))?;
if bytes.len() > limit {
return Err(invalid("blob exceeds its resource budget"));
}
// Count repeated references too: a tiny archive must not expand into unbounded owned
// strings or arrays when the same blob is referenced repeatedly.
self.hydrated = self
.hydrated
.checked_add(bytes.len())
.filter(|&n| n <= (self.limits.max_expanded)())
.ok_or_else(|| invalid("resolved payloads too large"))?;
Ok(bytes)
}
}
/// A small PNG picture of the file, kept in the container as a disposable cache keyed to the
/// exact file it shows: `cache/thumbnail.json` (its key) and `cache/thumbnail.png`. Reading it
/// never resolves a payload; a picture that isn't one, or is stale, is a miss.
#[derive(Clone, Copy, Debug)]
pub struct Thumbnail {
/// The key's format tag.
pub format: &'static str,
/// The build that wrote it: only the same build reads it back.
pub app: &'static str,
/// Its width and height in pixels.
pub size: [usize; 2],
/// The most bytes it may have.
pub max_bytes: usize,
}
impl Thumbnail {
/// Whether the bytes start as a PNG of this size within the byte budget (not a decode: a
/// consumer decodes with its own dimension and allocation limits).
pub fn valid(&self, bytes: &[u8]) -> bool {
bytes.len() <= self.max_bytes
&& bytes.get(..8) == Some(b"\x89PNG\r\n\x1a\n")
&& bytes.get(8..16) == Some(b"\0\0\0\rIHDR")
&& bytes.get(16..20) == Some((self.size[0] as u32).to_be_bytes().as_slice())
&& bytes.get(20..24) == Some((self.size[1] as u32).to_be_bytes().as_slice())
}
/// Add the picture, keyed to `document` (the file's own part), when it is one and there is
/// room for it within `budget`; otherwise leave it out, never failing the save.
pub fn write(&self, writer: &mut Writer, png: &[u8], document: &[u8], budget: usize) -> Result<(), FormatError> {
if !self.valid(png) {
return Ok(());
}
let key =
serde_json::to_vec(&serde_json::json!({"format": self.format, "app": self.app, "document": digest(document), "payload": digest(png)}))
.map_err(invalid)?;
if !writer.room(2, key.len().saturating_add(png.len()), budget) {
return Ok(());
}
writer.part("cache/thumbnail.json", &key, false)?;
writer.part("cache/thumbnail.png", png, false)
}
/// The picture, when this build wrote it for exactly the file's own part (`document.json`,
/// read within `document_limit`).
pub fn read<R: Read + Seek>(&self, reader: &mut Reader<R>, document_limit: usize) -> Result<Vec<u8>, FormatError> {
let key: Value = serde_json::from_slice(&reader.limited_part("cache/thumbnail.json", 4096)?).map_err(invalid)?;
let document = reader.document(document_limit)?;
if key.get("format").and_then(Value::as_str) != Some(self.format)
|| key.get("app").and_then(Value::as_str) != Some(self.app)
|| key.get("document").and_then(Value::as_str) != Some(digest(&document).as_str())
{
return Err(invalid("thumbnail key"));
}
let png = reader.limited_part("cache/thumbnail.png", self.max_bytes)?;
if !self.valid(&png) || key.get("payload").and_then(Value::as_str) != Some(digest(&png).as_str()) {
return Err(invalid("thumbnail payload"));
}
Ok(png)
}
}
#[cfg(test)]
#[path = "container_tests.rs"]
mod tests;
-66
View File
@@ -1,66 +0,0 @@
use super::*;
const LIMITS: Limits = Limits {
max_entries: 8,
max_expanded: || 1 << 20,
part_limit: |name| if name.ends_with(".big") { 1 << 20 } else { 1024 },
level: |_, _| 1,
check_size: |length| if length <= 1 << 20 { Ok(()) } else { Err("too large".into()) },
};
/// Parts and blobs written are read back as they were; a blob is stored once, named by its hash,
/// and checked against it.
#[test]
fn parts_and_blobs_read_back() {
let mut writer = Writer::new(LIMITS);
writer.part("document.json", br#"{"a":1}"#, true).unwrap();
let first = writer.blob(b"payload", "bin", false).unwrap();
let again = writer.blob(b"payload", "bin", true).unwrap();
assert_eq!(first, again, "the same payload is one blob");
assert_eq!(first, Value::String(format!("blobs/{}.bin", digest(b"payload"))));
let bytes = writer.finish().unwrap();
assert!(is_container(&bytes));
let mut reader = Reader::new(Cursor::new(bytes), LIMITS).unwrap();
assert_eq!(reader.part_names().len(), 2);
assert!(reader.has_part("document.json") && !reader.has_part("other.json"));
assert_eq!(&*reader.document(64).unwrap(), br#"{"a":1}"#);
let reference = serde_json::json!({"blob": first});
assert_eq!(reader.blob(&reference, "blob", "bin").unwrap(), b"payload");
assert!(reader.blob(&reference, "blob", "png").is_err(), "another extension");
assert_eq!(reader.hydrated, 7);
reader.hydrated = (LIMITS.max_expanded)();
assert!(reader.blob(&reference, "blob", "bin").unwrap_err().to_string().contains("resolved payloads too large"));
}
/// A part past its limit, or more entries than the limit, are refused as they are written.
#[test]
fn budgets_are_kept_when_writing() {
let mut writer = Writer::new(LIMITS);
assert_eq!(writer.part("small.json", &[0; 1025], false), Err(FormatError::Invalid("part too large".into())));
assert!(writer.part("large.big", &[0; 1025], true).is_ok());
for i in 0..7 {
writer.part(&format!("{i}.json"), b"x", false).unwrap();
}
assert_eq!(writer.part("one-more.json", b"x", false), Err(FormatError::Invalid("package too large".into())));
assert!(!writer.room(1, 0, 1 << 20), "no room for an entry past the limit");
}
/// A thumbnail is kept only for the file it shows.
#[test]
fn a_thumbnail_is_keyed_to_its_file() {
let picture = Thumbnail { format: "app-thumbnail/1", app: "1.0", size: [2, 1], max_bytes: 1024 };
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
png.extend_from_slice(&2u32.to_be_bytes());
png.extend_from_slice(&1u32.to_be_bytes());
assert!(picture.valid(&png));
let mut writer = Writer::new(LIMITS);
writer.part("document.json", b"{}", false).unwrap();
picture.write(&mut writer, &png, b"{}", 8 << 20).unwrap();
let mut reader = Reader::new(Cursor::new(writer.finish().unwrap()), LIMITS).unwrap();
assert_eq!(picture.read(&mut reader, 64).unwrap(), png);
let mut writer = Writer::new(LIMITS);
writer.part("document.json", b"{\"changed\":1}", false).unwrap();
picture.write(&mut writer, &png, b"{}", 8 << 20).unwrap();
let mut reader = Reader::new(Cursor::new(writer.finish().unwrap()), LIMITS).unwrap();
assert!(picture.read(&mut reader, 64).is_err(), "stale");
}
-249
View File
@@ -1,249 +0,0 @@
//! Versioned file formats: what an app needs to read every file it ever wrote, and to open a file
//! a newer build of it wrote.
//!
//! A file starts with `"format": "<prefix>N"`. A [`Family`] is one kind of file (an app's designs,
//! its drawings): its tag, its own numbers, and how a file of an older number is brought up to
//! this build's, step by step (N → N+1 → … → current). The app's code only knows the current
//! format; all translation lives in the steps:
//! - the steps written out by hand for the first formats, one per format ([`Formats::hand`]);
//! - then one folder per format, `v<N>/`, one file per change in it ([`Part`]), each with a step
//! when files from before the change are rewritten as they open ([`Formats::folded`]);
//! - then the changes no release has a number for yet, in `pending/` ([`Formats::pending`]):
//! while there are any, the build writes the next number, with their names in the file's
//! `format_pending`, so a later build runs just the steps a file is missing. A release moves
//! them into the next `v<N>/`. [`build`] lists the folders for a build script.
//!
//! A file a newer build wrote says so by a number past this build's or a change name this build
//! doesn't have ([`Family::newer`]); the app then reads what it can and keeps the rest
//! ([`newer`]). A file is saved as JSON, or in a [`container`] with its binary payloads.
//!
//! Steps are JSON-level (the file's shape) and return the app's own error type; every error this
//! crate makes is a [`FormatError`], which the app's error type takes `From`.
use serde_json::Value;
pub mod build;
pub mod container;
pub mod newer;
/// Why a file is refused: its text says what is wrong, as the user reads it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FormatError {
/// The file isn't one of the family's, or it is damaged.
Invalid(String),
}
impl std::fmt::Display for FormatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FormatError::Invalid(why) => f.write_str(why),
}
}
}
impl std::error::Error for FormatError {}
/// One JSON-level upgrade step: rewrites a file in place, or says why it can't.
pub type Step<E> = fn(&mut Value) -> Result<(), E>;
/// One change to a family's file format: a file in its `v<N>/` or `pending/` folder.
pub struct Part<E> {
/// The file's name, which a file written while the change was pending lists.
pub name: &'static str,
/// How a file from before the change is rewritten as it opens, if it is.
pub step: Option<Step<E>>,
}
/// Another spelling of a change name that a file of one format may carry (a build that named the
/// change otherwise): read as the change it stands for, never written.
#[derive(Clone, Copy, Debug)]
pub struct Alias {
/// The format whose files may say it.
pub format: u32,
/// The spelling in the file.
pub name: &'static str,
/// The change it stands for.
pub known_as: &'static str,
}
/// A family's upgrade path: the steps written out by hand, the folded formats after them (each a
/// list of changes) and the pending changes, which make the next format.
pub struct Formats<'a, E> {
/// Entry `i` turns format `i + 1` into `i + 2`.
pub hand: &'a [Step<E>],
/// Each format after the hand-written ones, oldest first, and its changes.
pub folded: &'a [(u32, &'a [Part<E>])],
/// The changes no release has a number for yet.
pub pending: &'a [Part<E>],
}
/// A kind of file whose format is versioned: its own format tag, its own numbers and its own
/// changes. Reading a file's format, telling a file a newer build saved and the upgrade are the
/// same for every family.
pub struct Family<'a, E> {
/// The app, as messages name it.
pub app: &'static str,
/// What a file of the family is called in messages.
pub noun: &'static str,
/// The tag before the number in a file's `format`.
pub prefix: &'static str,
/// The tag files saved under an older name carry: read, never written.
pub legacy_prefix: Option<&'static str>,
/// The format of a file that names none, if such files exist.
pub untagged: Option<u32>,
/// Other spellings of change names that files may carry.
pub aliases: &'a [Alias],
/// The upgrade path.
pub formats: Formats<'a, E>,
}
/// How a file shows a newer build saved it ([`Family::newer`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Newer {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
}
impl<E> Formats<'_, E> {
/// The format this build writes: the last folded one, or the next while changes are pending.
pub const fn current(&self) -> u32 {
self.hand.len() as u32 + 1 + self.folded.len() as u32 + if self.pending.is_empty() { 0 } else { 1 }
}
/// The changes format `version` is made of, if it is made of changes.
fn parts(&self, version: u32) -> Option<&[Part<E>]> {
let after = version.checked_sub(self.hand.len() as u32 + 2)? as usize;
match self.folded.get(after) {
Some((_, parts)) => Some(parts),
None if after == self.folded.len() && !self.pending.is_empty() => Some(self.pending),
None => None,
}
}
/// Whether a change of this name is anywhere in this build's history.
fn knows_part_name(&self, name: &str) -> bool {
self.folded.iter().flat_map(|(_, parts)| *parts).chain(self.pending).any(|part| part.name == name)
}
}
impl<E> Family<'_, E> {
/// The format this build writes: the last folded one, or the next while changes are pending.
pub const fn current(&self) -> u32 {
self.formats.current()
}
/// What a file of the family is called in messages.
pub const fn noun(&self) -> &'static str {
self.noun
}
/// The names of the pending changes this build writes into a file (`format_pending`).
pub fn pending_names(&self) -> Vec<String> {
self.formats.pending.iter().map(|p| p.name.to_string()).collect()
}
/// The format tag of format `n`.
pub fn format_string(&self, n: u32) -> String {
format!("{}{n}", self.prefix)
}
/// The version in one of this family's format tags; `None` for any other tag, another
/// family's included.
pub fn version_of(&self, s: &str) -> Option<u32> {
s.strip_prefix(self.prefix).or_else(|| self.legacy_prefix.and_then(|p| s.strip_prefix(p)))?.parse().ok()
}
/// The change a name a file of format `version` lists stands for: its alias's, or itself.
/// An alias counts only while this build has the change it stands for, so recognising it
/// never grants support to another reader.
fn canonical_applied_name<'name>(&self, version: u32, name: &'name str) -> &'name str {
match self.aliases.iter().find(|a| a.format == version && a.name == name) {
Some(alias) if self.formats.knows_part_name(alias.known_as) => alias.known_as,
_ => name,
}
}
/// What says a file of format `version` with the changes `applied` came from a newer build:
/// a number past this build's, or a change this build doesn't have anywhere in its history
/// (a branch's pending change may have landed at a later number). A list beside a format that
/// isn't made of changes means nothing (a tool or a test set an older number on a newer file).
/// `None` for a file this build fully knows.
pub fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
let current = self.current();
let listed = applied.filter(|_| version > current || self.formats.parts(version).is_some()).unwrap_or_default();
let unknown_changes: Vec<String> =
listed.iter().filter(|name| !self.formats.knows_part_name(self.canonical_applied_name(version, name))).cloned().collect();
(version > current || !unknown_changes.is_empty()).then_some(Newer { format: version, reads_up_to: current, unknown_changes })
}
}
impl<E: From<FormatError>> Family<'_, E> {
/// Read a file's format and take its `format_pending` out of its JSON, before anything else
/// in it is read: a file that isn't this family's is refused here. One a newer build saved is
/// not: [`Family::newer`] says so.
pub fn take_version(&self, v: &mut Value) -> Result<(u32, Option<Vec<String>>), E> {
let (app, noun) = (self.app, self.noun);
let not_ours = |what: String| E::from(FormatError::Invalid(format!("not a {app} {noun} ({what})")));
let Some(obj) = v.as_object() else { return Err(not_ours("expected a JSON object".into())) };
let version = match obj.get("format") {
None => self.untagged.ok_or_else(|| not_ours("no format".into()))?,
Some(Value::String(f)) => self.version_of(f).ok_or_else(|| not_ours(format!("format `{f}`")))?,
Some(_) => return Err(not_ours("format is not a string".into())),
};
let applied = take_names(v, "format_pending", noun).map_err(E::from)?;
if version == 0 {
return Err(not_ours("format 0".into()));
}
Ok((version, applied))
}
/// Bring a file of format `version` up to this build's; `applied` is its `format_pending`
/// (both from [`Family::take_version`]): the changes of its format it already has, when it
/// was written while they were pending. The others' steps run first, and already-applied
/// changes never run again at their later number. A file past this build's number has no
/// steps to run.
pub fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<(), E> {
let formats = &self.formats;
if version == 0 {
return Err(E::from(FormatError::Invalid(format!("not a {} {} (format 0)", self.app, self.noun))));
}
// A list beside a format that isn't made of changes is ignored, like any field an upgrade
// drops.
let applied = applied.filter(|_| formats.parts(version).is_some());
let already_applied =
|part: &Part<E>| applied.is_some_and(|names| names.iter().any(|name| self.canonical_applied_name(version, name) == part.name));
if applied.is_some() {
for part in formats.parts(version).unwrap_or_default().iter().filter(|part| !already_applied(part)) {
part.step.map_or(Ok(()), |step| step(v))?;
}
}
for from in version..self.current() {
match formats.hand.get(from as usize - 1) {
Some(step) => step(v)?,
None => {
for part in formats.parts(from + 1).unwrap_or_default().iter().filter(|part| !already_applied(part)) {
part.step.map_or(Ok(()), |step| step(v))?;
}
}
}
}
Ok(())
}
}
/// Take a short list of names out of a file's JSON: its `format_pending` (the pending changes it
/// has).
fn take_names(v: &mut Value, key: &str, noun: &str) -> Result<Option<Vec<String>>, FormatError> {
let Some(list) = v.as_object_mut().and_then(|o| o.remove(key)) else { return Ok(None) };
let invalid = || FormatError::Invalid(format!("damaged {noun}: `{key}` is not a short list of names"));
let list = list.as_array().filter(|l| l.len() <= 1000).ok_or_else(invalid)?;
list.iter().map(|n| n.as_str().filter(|n| n.len() <= 128).map(str::to_string).ok_or_else(invalid)).collect::<Result<_, _>>().map(Some)
}
#[cfg(test)]
#[path = "lib_tests.rs"]
mod tests;
-134
View File
@@ -1,134 +0,0 @@
use super::*;
use serde_json::json;
/// The app's error, as an app has one.
#[derive(Debug, PartialEq)]
struct AppError(String);
impl From<FormatError> for AppError {
fn from(e: FormatError) -> Self {
AppError(e.to_string())
}
}
/// Each step appends its name to the file's `log`, so the order steps ran in can be read back.
fn log(v: &mut Value, step: &str) {
if let Some(list) = v.get_mut("log").and_then(Value::as_array_mut) {
list.push(json!(step));
}
}
const HAND: [Step<AppError>; 2] = [
|v| {
log(v, "1to2");
Ok(())
},
|v| {
log(v, "2to3");
Ok(())
},
];
const V4: [Part<AppError>; 2] = [
Part {
name: "a",
step: Some(|v| {
log(v, "a");
Ok(())
}),
},
Part { name: "b", step: None },
];
const V5: [Part<AppError>; 1] = [Part {
name: "c",
step: Some(|v| {
log(v, "c");
Ok(())
}),
}];
const FOLDED: [(u32, &[Part<AppError>]); 2] = [(4, &V4), (5, &V5)];
const PENDING: [Part<AppError>; 2] = [
Part {
name: "d",
step: Some(|v| {
log(v, "d");
Ok(())
}),
},
Part {
name: "e",
step: Some(|v| {
log(v, "e");
Ok(())
}),
},
];
const ALIASES: [Alias; 1] = [Alias { format: 5, name: "old-c", known_as: "c" }];
/// Format 1 untagged; hand steps to 3; folded 4 and 5; pending changes make 6.
const FAMILY: Family<'static, AppError> = Family {
app: "App",
noun: "design",
prefix: "app/",
legacy_prefix: Some("old/"),
untagged: Some(1),
aliases: &ALIASES,
formats: Formats { hand: &HAND, folded: &FOLDED, pending: &PENDING },
};
fn steps_run(version: u32, applied: Option<&[String]>) -> Vec<String> {
let mut v = json!({"log": []});
FAMILY.upgrade(&mut v, version, applied).unwrap();
v["log"].as_array().unwrap().iter().map(|s| s.as_str().unwrap().to_string()).collect()
}
#[test]
fn tags_and_numbers() {
assert_eq!(FAMILY.current(), 6);
assert_eq!(FAMILY.format_string(6), "app/6");
assert_eq!(FAMILY.version_of("app/4"), Some(4));
assert_eq!(FAMILY.version_of("old/3"), Some(3), "the legacy tag is read");
assert_eq!(FAMILY.version_of("other/3"), None);
assert_eq!(FAMILY.pending_names(), vec!["d".to_string(), "e".to_string()]);
}
#[test]
fn a_file_is_taken_apart_or_refused_with_the_apps_words() {
let mut v = json!({"format": "app/6", "format_pending": ["d"], "x": 1});
assert_eq!(FAMILY.take_version(&mut v), Ok((6, Some(vec!["d".to_string()]))));
assert!(v.get("format_pending").is_none(), "taken out");
assert_eq!(FAMILY.take_version(&mut json!({})), Ok((1, None)), "untagged");
let refused = |mut v: Value| FAMILY.take_version(&mut v).unwrap_err().0;
assert_eq!(refused(json!([])), "not a App design (expected a JSON object)");
assert_eq!(refused(json!({"format": "other/2"})), "not a App design (format `other/2`)");
assert_eq!(refused(json!({"format": 2})), "not a App design (format is not a string)");
assert_eq!(refused(json!({"format": "app/0"})), "not a App design (format 0)");
assert_eq!(refused(json!({"format": "app/2", "format_pending": [1]})), "damaged design: `format_pending` is not a short list of names");
}
#[test]
fn every_step_runs_once_in_order() {
assert_eq!(steps_run(1, None), ["1to2", "2to3", "a", "c", "d", "e"]);
assert_eq!(steps_run(4, None), ["c", "d", "e"]);
assert_eq!(steps_run(6, None), Vec::<String>::new());
// A file written while `d` was pending has it: only `e` runs.
assert_eq!(steps_run(6, Some(&["d".to_string()])), ["e"]);
// A file of format 5 written while `c` was still pending, without it: `c` runs first.
assert_eq!(steps_run(5, Some(&[])), ["c", "d", "e"]);
// Under its other spelling `c` counts as had.
assert_eq!(steps_run(5, Some(&["old-c".to_string()])), ["d", "e"]);
// A list beside a hand-written format means nothing.
assert_eq!(steps_run(2, Some(&["d".to_string()])), ["2to3", "a", "c", "d", "e"]);
let mut v = json!({});
assert_eq!(FAMILY.upgrade(&mut v, 0, None), Err(AppError("not a App design (format 0)".into())));
}
#[test]
fn a_newer_file_is_told_by_its_number_or_an_unknown_change() {
assert_eq!(FAMILY.newer(6, None), None);
assert_eq!(FAMILY.newer(6, Some(&["d".to_string()])), None);
assert_eq!(FAMILY.newer(7, None), Some(Newer { format: 7, reads_up_to: 6, unknown_changes: Vec::new() }));
assert_eq!(FAMILY.newer(6, Some(&["z".to_string()])), Some(Newer { format: 6, reads_up_to: 6, unknown_changes: vec!["z".to_string()] }));
assert_eq!(FAMILY.newer(5, Some(&["old-c".to_string()])), None, "an alias of a known change");
assert_eq!(FAMILY.newer(4, Some(&["old-c".to_string()])), Some(Newer { format: 4, reads_up_to: 6, unknown_changes: vec!["old-c".to_string()] }));
assert_eq!(FAMILY.newer(2, Some(&["z".to_string()])), None, "a list beside a hand-written format means nothing");
}
-401
View File
@@ -1,401 +0,0 @@
//! A file a newer build saved: it opens, with a warning, and saving it loses nothing.
//!
//! A newer format number, or a change the file names that this build doesn't have, says a newer
//! build saved the file ([`crate::Family::newer`]). It may use nothing this build lacks, so the app
//! reads it as usual. When part of it can't be read, that part stays out of the way and the rest
//! opens: the app reads the elements of its lists it can ([`NewerFile::keep_element`]), then any
//! other top-level section it can't read is left out whole ([`leave_out_unreadable`],
//! [`read_around`]), and a field it doesn't know, which typed reading would drop, is noticed by
//! comparing the file with what was read ([`NewerFile::compare`]).
//!
//! All of these are named in [`NewerFile::not_understood`] and go back into the file when it is
//! saved ([`NewerFile::restore_into`]), as they came. What can't be put back because the file was
//! changed around it is what `restore_into` returns.
use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::{Map, Value, json};
use crate::Newer;
/// The most sections left out one at a time to find the one a file can't be read with.
const MAX_SECTION_TRIES: usize = 64;
/// What the file says and holds that this build doesn't read.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NewerFile {
/// The app, as the notice names it.
pub app: &'static str,
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
/// What this build couldn't read, by name.
pub not_understood: Vec<String>,
/// Fields known records lost in typed reading.
kept: Option<Kept>,
/// Top-level sections left out whole.
sections: Vec<Section>,
/// Elements left out of a top-level list: its key and the element.
elements: Vec<(String, Value)>,
/// The parts of a container this build has no use for: name and bytes. A blob only one of the
/// kept pieces refers to is one of them.
pub parts: Vec<(String, Arc<[u8]>)>,
/// The file's `container.json`, when this build doesn't know what it says.
pub container: Option<Value>,
}
#[derive(Clone, Debug, PartialEq)]
struct Section {
key: String,
value: Value,
/// This build's record of the section, without the file's: while it is unchanged, the file's
/// goes back.
empty: Option<Value>,
}
/// The fields of a record that typed reading dropped.
#[derive(Clone, Debug, PartialEq)]
enum Kept {
Field(Value),
Fields(BTreeMap<String, Kept>),
Items(Vec<(Anchor, Kept)>),
}
/// Which element of a list some kept fields belong to.
#[derive(Clone, Debug, PartialEq)]
enum Anchor {
Id(u64),
Name(String),
/// Its place, good only while the list keeps its length.
Index {
at: usize,
of: usize,
},
}
impl NewerFile {
/// A file of `app` that `newer` says a newer build saved, nothing left out of it yet.
pub fn new(app: &'static str, newer: &Newer) -> NewerFile {
NewerFile {
app,
format: newer.format,
reads_up_to: newer.reads_up_to,
unknown_changes: newer.unknown_changes.clone(),
..NewerFile::default()
}
}
/// What to tell the user, whatever else is said.
pub fn notice(&self) -> String {
let (app, reads, format) = (self.app, self.reads_up_to, self.format);
match self.unknown_changes.first() {
Some(change) if format <= reads => {
format!("Saved by a newer {app} (format {format} with `{change}`, which this version doesn't know). If something's missing, update.")
}
_ => format!("Saved by a newer {app} (format {format}, this version reads up to {reads}). If something's missing, update."),
}
}
/// Every line to report: the notice, then what couldn't be read, by name.
pub fn warnings(&self) -> Vec<String> {
let unread = self.not_understood.iter().map(|what| format!("Not read: {what}. Saving keeps it."));
std::iter::once(self.notice()).chain(unread).collect()
}
/// What a reply says about it.
pub fn report(&self) -> Value {
json!({
"format": self.format,
"reads_up_to": self.reads_up_to,
"unknown_changes": self.unknown_changes,
"not_understood": self.not_understood,
"notice": self.notice(),
})
}
/// Whether the next save needs a container to keep what the file holds: plain JSON has no room
/// for parts beside the file's own.
pub fn needs_container(&self) -> bool {
!self.parts.is_empty()
}
/// Whether anything kept back names this text (a blob's name in a container).
pub fn mentions(&self, text: &str) -> bool {
fn kept(k: &Kept, text: &str) -> bool {
match k {
Kept::Field(v) => value_mentions(v, text),
Kept::Fields(m) => m.values().any(|x| kept(x, text)),
Kept::Items(l) => l.iter().any(|(_, x)| kept(x, text)),
}
}
self.kept.as_ref().is_some_and(|k| kept(k, text))
|| self.sections.iter().any(|s| value_mentions(&s.value, text))
|| self.elements.iter().any(|(_, v)| value_mentions(v, text))
}
/// Say that a section the reader couldn't finish reading is left out. `empty` is what the
/// app writes for the section while it holds nothing, if anything.
pub fn keep_section(&mut self, key: &str, value: Value, empty: Option<Value>, why: &str) {
self.not_understood.push(format!("section `{key}` ({why})"));
self.sections.push(Section { key: key.into(), value, empty });
}
/// Say that an element of the top-level list `key` is left out, as `note` names it; it goes
/// back at the end of the list when the file is saved.
pub fn keep_element(&mut self, key: &str, element: Value, note: String) {
self.not_understood.push(note);
self.elements.push((key.into(), element));
}
/// Compare the file as it reads (`file`, after the same upgrade as what was read) with what was
/// read of it (`known`, its typed record written out): what `known` lacks is kept and named.
/// An object whose `type` is `placeholder_tag` (an element the app kept whole) and the fields
/// in `container_only` (what a container adds beside the file's own) are not compared; nor
/// are the top level's `format` and `format_pending`.
pub fn compare(&mut self, file: &Value, known: &Value, placeholder_tag: &str, container_only: &[&str]) {
let mut paths = Vec::new();
self.kept = vanished(file, known, "", &mut paths, placeholder_tag, container_only);
self.not_understood.extend(paths);
for section in &mut self.sections {
section.empty = known.get(&section.key).cloned();
}
}
/// Write back what was kept of the file into `value`, the file as this build writes it.
/// Returns what couldn't go back, because the file was changed around it.
pub fn restore_into(&self, value: &mut Value) -> Vec<String> {
let mut lost = Vec::new();
if let Some(kept) = &self.kept {
apply(kept, value, "", &mut lost);
}
if let Some(top) = value.as_object_mut() {
for section in &self.sections {
if top.get(&section.key) == section.empty.as_ref() {
top.insert(section.key.clone(), section.value.clone());
} else {
lost.push(format!("section `{}`, replaced by what was edited here", section.key));
}
}
for (key, element) in &self.elements {
let name = element.get("name");
match top.entry(key.clone()).or_insert_with(|| json!([])) {
Value::Array(list) if name.is_none() || !list.iter().any(|x| x.get("name") == name) => list.push(element.clone()),
_ => lost.push(format!("`{key}` element {}, replaced by what was made here", name.and_then(Value::as_str).unwrap_or("unnamed"))),
}
}
}
lost
}
}
fn value_mentions(v: &Value, text: &str) -> bool {
match v {
Value::String(s) => s == text,
Value::Array(a) => a.iter().any(|x| value_mentions(x, text)),
Value::Object(o) => o.values().any(|x| value_mentions(x, text)),
_ => false,
}
}
/// The first line of a reader's error, without the list of every kind it knows, and without the
/// `"<noun> file: "` its reader starts it with.
pub fn short<'a>(why: &'a str, noun: &str) -> &'a str {
let why = why.split(", expected").next().unwrap_or(why);
why.strip_prefix(&format!("{noun} file: ")).unwrap_or(why)
}
/// Leave a section out and keep it. A section the file needs is left empty.
fn move_out(top: &mut Map<String, Value>, key: &str, why: &str, noun: &str, file: &mut NewerFile) {
let Some(value) = top.remove(key) else { return };
file.not_understood.push(format!("section `{key}` ({})", short(why, noun)));
file.sections.push(Section { key: key.into(), value, empty: None });
}
/// Every top-level section of `top` that doesn't read by itself (`piece`, the app's reading of a
/// file holding just that section) is left out and kept in `file`. `noun` is what a file of the
/// app's is called, which its reader's errors start with.
pub fn leave_out_unreadable<E: std::fmt::Display>(
top: &mut Map<String, Value>,
file: &mut NewerFile,
noun: &str,
piece: impl Fn(&str, &Value) -> Result<(), E>,
) {
let keys: Vec<String> = top.keys().cloned().collect();
for key in &keys {
let value = top.get(key).cloned().unwrap_or_default();
if let Err(why) = piece(key, &value) {
move_out(top, key, &why.to_string(), noun, file);
}
}
}
/// Read `top` with `attempt`, leaving out the sections it can't be read with: if it doesn't read,
/// each section but the `core` ones (which the file can't be read without) is tried left out alone,
/// and as a last resort every one of them is. What is left out is kept in `file`. The error is the
/// first attempt's, when even the core alone doesn't read.
pub fn read_around<D, E: std::fmt::Display>(
top: &mut Map<String, Value>,
file: &mut NewerFile,
core: &[&str],
noun: &str,
attempt: impl Fn(&Map<String, Value>) -> Result<D, E>,
) -> Result<D, String> {
let first = match attempt(top) {
Ok(doc) => return Ok(doc),
Err(why) => why.to_string(),
};
// Sections that are fine by themselves but not together with the rest.
let keys: Vec<String> = top.keys().filter(|k| !core.contains(&k.as_str())).cloned().collect();
// Each try reads the whole file: a file of many sections goes straight to the last resort.
for key in keys.iter().filter(|_| keys.len() <= MAX_SECTION_TRIES) {
let mut without = top.clone();
without.remove(key);
if let Ok(doc) = attempt(&without) {
move_out(top, key, &first, noun, file);
return Ok(doc);
}
}
let mut bare = top.clone();
bare.retain(|key, _| core.contains(&key.as_str()));
match attempt(&bare) {
Ok(doc) => {
for key in &keys {
move_out(top, key, &first, noun, file);
}
Ok(doc)
}
Err(_) => Err(first),
}
}
/// A value a writer leaves out without losing anything: not worth telling anyone about.
pub fn looks_default(v: &Value) -> bool {
match v {
Value::Null => true,
Value::Bool(b) => !b,
Value::Number(n) => n.as_f64() == Some(0.0),
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
}
}
/// What `raw` has that `known`, what was read of it, doesn't. `paths` gets those fields' names.
fn vanished(raw: &Value, known: &Value, path: &str, paths: &mut Vec<String>, placeholder_tag: &str, container_only: &[&str]) -> Option<Kept> {
match (raw, known) {
(Value::Object(raw), Value::Object(known)) => {
if known.get("type").and_then(Value::as_str) == Some(placeholder_tag) {
return None;
}
let mut kept = BTreeMap::new();
for (key, value) in raw {
if container_only.contains(&key.as_str()) || (path.is_empty() && matches!(key.as_str(), "format" | "format_pending")) {
continue;
}
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match known.get(key) {
None => {
if !looks_default(value) {
paths.push(format!("field `{inner}`"));
}
kept.insert(key.clone(), Kept::Field(value.clone()));
}
Some(known) => {
if let Some(k) = vanished(value, known, &inner, paths, placeholder_tag, container_only) {
kept.insert(key.clone(), k);
}
}
}
}
(!kept.is_empty()).then_some(Kept::Fields(kept))
}
(Value::Array(raw), Value::Array(known)) => {
let mut items = Vec::new();
for (at, element) in raw.iter().enumerate() {
let anchor = anchor_of(element).unwrap_or(Anchor::Index { at, of: raw.len() });
let Some(partner) = find(known, &anchor) else { continue };
let label = match (element.get("name").and_then(Value::as_str), &anchor) {
(Some(name), _) => format!("{path}[{name}]"),
(None, Anchor::Id(id)) => format!("{path}[{id}]"),
_ => format!("{path}[{at}]"),
};
if let Some(k) = vanished(element, partner, &label, paths, placeholder_tag, container_only) {
items.push((anchor, k));
}
}
(!items.is_empty()).then_some(Kept::Items(items))
}
_ => None,
}
}
fn anchor_of(element: &Value) -> Option<Anchor> {
if let Some(id) = element.get("id").and_then(Value::as_u64) {
return Some(Anchor::Id(id));
}
element.get("name").and_then(Value::as_str).map(|name| Anchor::Name(name.to_string()))
}
fn find<'a>(list: &'a [Value], anchor: &Anchor) -> Option<&'a Value> {
match anchor {
Anchor::Id(id) => list.iter().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get(*at)).flatten(),
}
}
fn find_mut<'a>(list: &'a mut [Value], anchor: &Anchor) -> Option<&'a mut Value> {
match anchor {
Anchor::Id(id) => list.iter_mut().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter_mut().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get_mut(*at)).flatten(),
}
}
/// Put the kept fields back. An element that is gone was deleted here, its fields with it; one
/// whose place can't be told any more, and a field whose record is gone, go into `lost`.
fn apply(kept: &Kept, value: &mut Value, path: &str, lost: &mut Vec<String>) {
match kept {
Kept::Field(_) => {}
Kept::Fields(fields) => {
let Some(object) = value.as_object_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (key, sub) in fields {
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match (sub, object.get_mut(key)) {
(Kept::Field(v), None) => {
object.insert(key.clone(), v.clone());
}
(Kept::Field(_), Some(_)) => {}
(sub, Some(inner_value)) => apply(sub, inner_value, &inner, lost),
// The list it was in is empty and not written: its elements were deleted.
(Kept::Items(_), None) => {}
(_, None) => lost.push(format!("field `{inner}`")),
}
}
}
Kept::Items(items) => {
let Some(list) = value.as_array_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (anchor, sub) in items {
match (find_mut(list, anchor), anchor) {
(Some(element), _) => apply(sub, element, path, lost),
(None, Anchor::Index { at, .. }) => lost.push(format!("element {at} of `{path}`")),
(None, _) => {}
}
}
}
}
}
#[cfg(test)]
#[path = "newer_tests.rs"]
mod tests;
-48
View File
@@ -1,48 +0,0 @@
use super::*;
fn newer() -> Newer {
Newer { format: 9, reads_up_to: 8, unknown_changes: Vec::new() }
}
/// What typed reading dropped is named and goes back on saving, where it was.
#[test]
fn dropped_fields_are_named_and_put_back() {
let file = json!({"name": "A", "format": "app/9", "items": [{"id": 1, "size": 2, "colour": "red"}], "extra": {"x": 1}});
let known = json!({"name": "A", "items": [{"id": 1, "size": 2}]});
let mut said = NewerFile::new("App", &newer());
said.compare(&file, &known, "placeholder", &[]);
assert_eq!(said.not_understood, ["field `extra`", "field `items[1].colour`"]);
let mut saved = json!({"name": "A", "items": [{"id": 1, "size": 3}]});
assert!(said.restore_into(&mut saved).is_empty());
assert_eq!(saved, json!({"name": "A", "items": [{"id": 1, "size": 3, "colour": "red"}], "extra": {"x": 1}}));
assert_eq!(said.notice(), "Saved by a newer App (format 9, this version reads up to 8). If something's missing, update.");
}
/// A section that can't be read is left out, kept, and goes back while it is unchanged here.
#[test]
fn unreadable_sections_are_left_out_and_kept() {
let mut top = match json!({"name": "A", "good": 1, "bad": 2}) {
Value::Object(m) => m,
_ => unreachable!(),
};
let mut said = NewerFile::new("App", &newer());
leave_out_unreadable(&mut top, &mut said, "design", |key: &str, _: &Value| if key == "bad" { Err("design file: no") } else { Ok(()) });
assert!(!top.contains_key("bad"));
assert_eq!(said.not_understood, ["section `bad` (no)"]);
let read =
read_around(
&mut top,
&mut said,
&["name"],
"design",
|top: &Map<String, Value>| {
if top.contains_key("good") { Err("design file: clash") } else { Ok(top.len()) }
},
);
assert_eq!(read, Ok(1));
assert_eq!(said.not_understood, ["section `bad` (no)", "section `good` (clash)"]);
let mut saved = json!({"name": "A"});
said.compare(&saved.clone(), &saved.clone(), "placeholder", &[]);
assert!(said.restore_into(&mut saved).is_empty());
assert_eq!(saved, json!({"name": "A", "good": 1, "bad": 2}));
}
+7 -20
View File
@@ -1,13 +1,13 @@
//! Optional output filtering; never writes the Monte Carlo accumulation buffer.
use super::{Context, Result, SHADER, TIMEOUT, Task, checked, one_compile_at_a_time, placeholder_bind, scopes, warm_up};
use solidcraft_render::job::{CancelToken, DenoiseDiagnostics, DenoiseGuideClass, resolve_denoise_class};
use super::{Context, Result, SHADER, TIMEOUT, Task, checked, placeholder_bind, scopes, warm_up};
use solidcraft_render::job::{DenoiseDiagnostics, DenoiseGuideClass, resolve_denoise_class};
use std::sync::atomic::{AtomicBool, Ordering};
use wgpu::util::DeviceExt;
pub(super) const GUIDE_BYTES_PER_PIXEL: u64 = 96; // two complete surface guides
pub(super) const BYTES_PER_PIXEL: u64 = GUIDE_BYTES_PER_PIXEL + 32; // two colour vec4s
const FILTER: &str = include_str!("denoise.wgsl");
pub(super) const GUIDES: &str = include_str!("denoise_guides.wgsl");
const GUIDES: &str = include_str!("denoise_guides.wgsl");
#[cfg(test)]
#[path = "denoise_tests.rs"]
mod tests;
@@ -85,21 +85,10 @@ impl Pipelines {
Ok(Self { guide, filter, classify, filter_layout })
}
// The guide program inlines the path tracer's BVH walks, so it compiles under the
// process-wide lock of the path programs (#6706), never beside one of them. As for a scene
// program, the lock is held until the pipelines are published: a job that waited for another
// job's compile then uses its pipelines instead of compiling them again, and a job cancelled
// while it waited returns before compiling. A compile that ran is kept for the next job.
fn shared<'a>(context: &'a Context, cancel: &CancelToken) -> Result<&'a Self> {
fn shared(context: &Context) -> Result<&Self> {
if let Some(pipelines) = context.denoise.get() {
return Ok(pipelines);
}
cancel.check()?;
let _compiling = one_compile_at_a_time();
if let Some(pipelines) = context.denoise.get() {
return Ok(pipelines);
}
cancel.check()?;
let built = Self::new(context)?;
Ok(context.denoise.get_or_init(|| built))
}
@@ -139,7 +128,7 @@ impl Resources {
size: u64,
adaptive_preview: bool,
) -> Result<Self> {
Self::build(context, path_uniform, scene, accumulation, size, adaptive_preview, None, &CancelToken::default())
Self::build(context, path_uniform, scene, accumulation, size, adaptive_preview, None)
}
pub(super) fn new_for_layout(
@@ -149,9 +138,8 @@ impl Resources {
accumulation: &wgpu::Buffer,
adaptive_preview: bool,
layout: super::image_pages::Layout,
cancel: &CancelToken,
) -> Result<Self> {
Self::build(context, path_uniform, scene, accumulation, u64::from(layout.max_window_pixels()) * 16, adaptive_preview, Some(layout), cancel)
Self::build(context, path_uniform, scene, accumulation, u64::from(layout.max_window_pixels()) * 16, adaptive_preview, Some(layout))
}
fn build(
@@ -162,9 +150,8 @@ impl Resources {
size: u64,
adaptive_preview: bool,
layout: Option<super::image_pages::Layout>,
cancel: &CancelToken,
) -> Result<Self> {
let pipelines = Pipelines::shared(context, cancel)?;
let pipelines = Pipelines::shared(context)?;
let device = &context.device;
let error_scopes = scopes(device);
let buffer = |label, bytes| {

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