Compare commits

..

1 Commits

380 changed files with 9349 additions and 36481 deletions
+5 -17
View File
@@ -1,27 +1,15 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
profile: chill
poem: false
sequence_diagrams: false
related_issues: false
related_prs: false
suggested_labels: false
suggested_reviewers: false
in_progress_fortune: false
auto_review:
# Review when a PR is opened or marked ready, or on `@coderabbitai review`; not on every push.
drafts: false
auto_incremental_review: false
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
path_instructions:
- path: "**"
instructions: |
Project rule: don't change anyone's existing UX defaults unless the change is purely additive and belongs in the application.
A changed default is when something people already rely on in the UI changes for everyone who didn't opt in: an existing control, interaction or setting value. Examples the owner declined or reverted: right click becoming select while left click orbits (make it opt-in instead); a PR that removed all camera animation when only orbit lag was to be fixed. Flag any change to something a user already relies on: a default setting or preference value, what a command or tool does when used the usual way, the UI's default layout or behaviour, a keyboard or mouse shortcut.
A changed kernel or geometry result (different faces or volumes, a case that now builds or is now refused), including anything under solidcore/, is not a changed default; don't flag it as one, and expect `Changes a default: no` for it.
Not defaults: bug fixes and more correct results, kernel or geometry result changes, new options that are off by default, and CI or tooling config. Don't flag purely additive changes: a new command, a new setting whose default keeps today's behaviour, or a new option that appears only when someone chooses it. Don't flag bug fixes that only turn a crash or an error into the result the feature always promised.
Project rule: don't change anyone's existing defaults unless the change is purely additive and belongs in the application.
Flag any change to something a user already relies on: a default setting or preference value, what a command or tool does when used the usual way, the UI's default layout or behaviour, a keyboard or mouse shortcut, or the result an existing saved design rebuilds to.
Don't flag purely additive changes: a new command, a new setting whose default keeps today's behaviour, or a new option that appears only when someone chooses it. Don't flag bug fixes that only turn a crash or an error into the result the feature always promised.
When you flag one, start the comment with "Changes a default:", say what a user sees before and after, whether a setting keeps the old behaviour, and whether the PR description's `Changes a default:` line says so.
# Where code goes, SolidCraft or SolidCore: solidcore/docs/layers.md.
@@ -189,5 +177,5 @@ reviews:
- name: "Existing defaults unchanged"
mode: "warning"
instructions: |
Pass if the pull request changes no UX default a user already relies on (default setting or preference values, what a command or tool does when used the usual way, the UI's default layout or behaviour, shortcuts), or if its changes are purely additive. UX defaults only: a changed kernel or geometry result (different faces or volumes, a case that now builds or is now refused), including anything under solidcore/, is not a changed default and passes.
Fail otherwise, naming each changed default and what a user sees before and after. Also fail if the description says `Changes a default: no` while the code changes a UX default.
Pass if the pull request changes no default a user already relies on (default setting or preference values, what a command or tool does when used the usual way, the UI's default layout or behaviour, shortcuts, or the result an existing saved design rebuilds to), or if its changes are purely additive.
Fail otherwise, naming each changed default and what a user sees before and after. Also fail if the description says `Changes a default: no` while the code changes one.
+1 -2
View File
@@ -4,5 +4,4 @@
# The vendored SolidCore kernel, owned as in SolidCore's own CODEOWNERS
# (solidcore/.github/CODEOWNERS, which GitHub reads only at a repository's root).
# The team (jyviko and bherbruck) assigns one of them per pull request, so either one approves.
/solidcore/ @tangency-labs/solidcore-owners
/solidcore/ @jyviko @bherbruck
+1 -1
View File
@@ -6,7 +6,7 @@ Start with what this is, in plain words someone new to the project would underst
Changes a default: no
<!-- Required line, replace "no" if this changes what a user gets out of the box (a default value, key binding, setting, tool behaviour; a UX default only, a kernel result change is 'no'):
<!-- Required line, replace "no" if this changes what a user gets out of the box (a default value, key binding, setting, tool behaviour):
Changes a default: yes — <what>, old behaviour kept behind preference <name>
Docs-only and bot PRs are exempt. -->
+3 -34
View File
@@ -448,8 +448,8 @@ jobs:
run: python3 -I scripts/ci/test_affected.py
- name: Merge-of-main detection self-test
run: python3 -I scripts/ci/test_main_merge.py
- name: Claim, triage and PR-check bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/pr-checks/*.test.js
- name: Claim, triage and kernel-paused bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js scripts/pr-checks/*.test.js
# Fast feedback for a pull request that has not been approved for the full CI yet.
quick-compile:
@@ -1040,7 +1040,7 @@ jobs:
# commit's real run instead: the merge queue reads `ci` from the newest run of a commit, so a
# run without a passing `ci` would block a pull request that passed (#1850, #3182, #3252).
name: ci
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup, dependency-advisories, wasm, solidcore, solidcore-standalone]
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup, dependency-advisories, wasm, solidcore]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
@@ -1100,7 +1100,6 @@ jobs:
WASM: ${{ needs.wasm.result }}
SOLIDCORE_RUN: ${{ needs.changes.outputs.solidcore }}
SOLIDCORE: ${{ needs.solidcore.result }}
SOLIDCORE_STANDALONE: ${{ needs.solidcore-standalone.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
@@ -1141,7 +1140,6 @@ jobs:
# solidcore/** changed on an approved pull request, in a merge group or on main.
if [ "$SOLIDCORE_RUN" = true ]; then
test "$SOLIDCORE" = success
test "$SOLIDCORE_STANDALONE" = success
fi
- name: Note a carried result
if: needs.changes.outputs.carried != ''
@@ -1177,35 +1175,6 @@ jobs:
# solidcore/** (Select checks' `solidcore`), where `ci` requires it; skipped otherwise. Its build,
# kernel-rig and goldens jobs are one entry here, so the rig and goldens binaries are built once
# without an artifact between jobs.
# solidcore/ exported alone (as the mirror to tangency-labs/solidcore carries it) must still check: the
# `layers` rule reads the manifests, this builds them without the rest of the repository around.
solidcore-standalone:
name: SolidCore builds on its own
needs: changes
if: needs.changes.outputs.solidcore == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
env:
CARGO_INCREMENTAL: 0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Export solidcore/ alone
run: |
mkdir -p "$RUNNER_TEMP/sc"
git archive HEAD solidcore | tar -x -C "$RUNNER_TEMP/sc"
- name: Toolchain (solidcore/rust-toolchain.toml)
working-directory: ${{ runner.temp }}/sc/solidcore
run: rustup show active-toolchain || rustup toolchain install
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: "${{ runner.temp }}/sc/solidcore -> bin"
key: solidcore-standalone
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
- name: Check the exported tree
working-directory: ${{ runner.temp }}/sc/solidcore
run: cargo check --workspace --all-targets --locked
solidcore:
name: SolidCore checks (${{ matrix.check }})
needs: changes
+43
View File
@@ -0,0 +1,43 @@
# While a new geometry kernel is evaluated, new issues and discussions about kernel work, and new
# pull requests that change the kernel directories, are labelled `kernel-paused` for a maintainer to
# answer (CONTRIBUTING.md, "Kernel work is paused"). The logic is in scripts/kernel-paused/ (tested
# in ci.yml).
name: Kernel paused
on:
issues:
types: [opened]
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
# scripts/kernel-paused and only lists the PR's changed paths; the PR's code is never checked out.
pull_request_target:
types: [opened]
discussion:
types: [created]
permissions:
contents: read # to check out scripts/kernel-paused
issues: write
pull-requests: write # to list a PR's changed paths and add its label
discussions: write
concurrency:
group: kernel-paused-${{ github.event.issue.number || github.event.pull_request.number || github.event.discussion.number }}
cancel-in-progress: false
jobs:
paused:
name: Kernel paused bot
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/kernel-paused
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const paused = require('./scripts/kernel-paused/kernel-paused.js');
const done = context.eventName === 'discussion'
? await paused.handleDiscussion({ github, context })
: await paused.handleOpened({ github, context });
core.info(done);
+1 -1
View File
@@ -20,7 +20,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
- A design-format change is ONE new file in `crates/doc/src/format/pending/`: `scripts/test-format-pending.sh`.
- A command has input and output schemas (will become required; `schema/unconverted.txt` only shrinks): `skills/solidcraft-commands/SKILL.md`.
- New UI strings go in `assets/locales/pending/<slug>.json`, ATTRIBUTION rows in `attribution/pending/<slug>.md`: `cargo xtask lists`.
- A changed default (existing UI, not kernel results) keeps the old behaviour behind a preference; fill the PR's `Changes a default:` line.
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line: `pr-checks.yml`.
- A regression test goes in the area's existing test file, never a per-bug file (#3226).
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR: `skills/solidcraft-contributing/SKILL.md`.
- A PR's title and first sentence make sense to someone who never saw your chat or the issue: `skills/solidcraft-contributing/SKILL.md`.
+9 -28
View File
@@ -17,30 +17,15 @@ Before choosing a task, check the [command inventory](docs/features.md) and
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
## Kernel work goes in solidcore/
## Kernel work is paused
Kernel and geometry work is welcome: booleans, fillets, chamfers, shell, offsets, sweeps and lofts,
threads and holes, STEP/IGES import and export, tessellation. It goes in `solidcore/`, the vendored
solidcore kernel, following [`solidcore/docs/layers.md`](solidcore/docs/layers.md) and
`skills/solidcraft-layers/SKILL.md`. SolidCraft's own `crates/kernel`, `crates/smlib` and
`crates/geom` only shrink toward the seam (ops, tess, record); please don't grow them.
A kernel pull request targets `main` like any other, with a code owner (jyviko or me) approving the
`solidcore/` part; a change that touches the kernel and the app is one pull request. The `kernel` branch
is only for multi-step kernel work that isn't safe on `main` until every step has landed: send those
steps to `kernel`, and it merges into `main` when the work is done.
Crashes and data loss are still taken first.
### Updating a PR that was moved from solidcore
solidcore's open PRs were re-opened here on `moved/solidcore-N` branches in tangency-labs, so
contributors without write access can't push to them. To update one:
1. `git fetch https://github.com/tangency-labs/solidcraft moved/solidcore-N:moved/solidcore-N`, then
`git switch moved/solidcore-N`.
2. Commit your changes on it and push it to your fork: `git push <your-fork> moved/solidcore-N`.
3. Open a PR against `kernel` that says "Replaces #M" (M is the moved PR's number). The moved one
gets closed.
We're moving to a new geometry kernel. Until that's done, please don't open pull requests that
change `crates/kernel/`, `crates/geom/` or the vendored kernel in `solidcore/`, or issues and discussions about kernel
work: booleans, fillets, chamfers, shell, offsets, sweeps and lofts, threads and holes, STEP/IGES
import and export, or tessellation. The pinned issue on the tracker explains it. New ones get the
`kernel-paused` label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes
and data loss are still taken as issues. Pull requests outside those directories carry on as normal,
including the dialogs and tools for these features.
## Get it running (about 10 minutes on a fast machine)
@@ -444,13 +429,9 @@ owning one area, with a human setting direction and reporting bugs. Their workin
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
long as the change meets the rules above.
If you run an agent, keep one long-running session rather than restarting it, don't `--resume` one
whose prompt cache has gone cold (it re-reads the whole history at full price), and act on CodeRabbit
comments only when they're about crashes, wrong results, data loss or security.
Two quick checks run on every pull request (`.github/workflows/pr-checks.yml`, not required): it
fails when a lower-numbered open PR by someone else already closes the same issue (the label
`not-a-duplicate` skips it), or when the description lacks the `Changes a default: no` / `Changes a default: yes — <what>, old behaviour kept behind preference <name>` line (docs-only and bot PRs are exempt). A default here is a UX default only: a control, setting, preference or tool behaviour a user sees by default; a changed kernel or geometry result is `Changes a default: no`.
`not-a-duplicate` skips it), or when the description lacks the `Changes a default: no` / `Changes a default: yes — <what>, old behaviour kept behind preference <name>` line (docs-only and bot PRs are exempt).
## Labels and triage
Generated
+12 -235
View File
@@ -125,21 +125,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher 0.4.4",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "aes"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
dependencies = [
"cipher 0.5.2",
"cpubits",
"cpufeatures 0.3.1",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -154,21 +143,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -511,15 +485,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -551,16 +516,6 @@ dependencies = [
"piper",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -748,16 +703,7 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "cbc"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
dependencies = [
"cipher 0.5.2",
"cipher",
]
[[package]]
@@ -822,17 +768,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout 0.1.4",
]
[[package]]
name = "cipher"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.2",
"inout 0.2.6",
"inout",
]
[[package]]
@@ -951,12 +887,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1066,9 +996,9 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"aes 0.8.4",
"block-padding 0.3.3",
"cbc 0.1.2",
"aes",
"block-padding",
"cbc",
"dbus",
"fastrand",
"hkdf",
@@ -1165,15 +1095,6 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecb"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac"
dependencies = [
"cipher 0.5.2",
]
[[package]]
name = "ecolor"
version = "0.37.0"
@@ -1704,7 +1625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl 0.1.12",
"weezl",
]
[[package]]
@@ -2147,20 +2068,10 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding 0.3.3",
"block-padding",
"generic-array",
]
[[package]]
name = "inout"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a580c13f0ffc335ef42dd3f6b1a41b726051c5e1656d9a3aa6fddb3c5dfbbf9"
dependencies = [
"block-padding 0.4.2",
"hybrid-array",
]
[[package]]
name = "itertools"
version = "0.15.0"
@@ -2405,43 +2316,6 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lopdf"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfffda0fe1ab0157e1a13c14bebd3f28671f2fccb7922f0722ec53926e6922d3"
dependencies = [
"aes 0.9.4",
"bitflags 2.13.2",
"brotli-decompressor",
"cbc 0.2.1",
"ecb",
"encoding_rs",
"flate2",
"getrandom 0.4.3",
"indexmap",
"itoa",
"log",
"md-5",
"nom",
"rand 0.10.3",
"rangemap",
"sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
"weezl 0.2.1",
]
[[package]]
name = "md-5"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [
"cfg-if",
"digest 0.11.3",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -2634,15 +2508,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]]
name = "num"
version = "0.4.3"
@@ -2908,16 +2773,6 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
dependencies = [
"libc",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
@@ -3456,7 +3311,6 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
@@ -3501,12 +3355,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
[[package]]
name = "rangemap"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3882,8 +3730,8 @@ version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
dependencies = [
"aes 0.8.4",
"cbc 0.1.2",
"aes",
"cbc",
"futures-util",
"generic-array",
"hkdf",
@@ -4477,7 +4325,6 @@ dependencies = [
"image",
"moxcms",
"png",
"rayon",
"robust",
"serde",
"serde_json",
@@ -4485,9 +4332,8 @@ dependencies = [
"solidcore-record",
"solidcraft-color",
"solidcraft-geom",
"sysinfo",
"web-time",
"weezl 0.1.12",
"weezl",
]
[[package]]
@@ -4556,7 +4402,6 @@ dependencies = [
"egui-wgpu",
"image",
"log",
"lopdf",
"minisign-verify",
"muda",
"nix 0.31.3",
@@ -4661,17 +4506,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4723,20 +4557,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "sysinfo"
version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8a3edbe0394ce115f3917a00349dd242be09eb5a091ed509eaf67b55cfb8a8"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -4809,7 +4629,7 @@ dependencies = [
"flate2",
"half",
"quick-error",
"weezl 0.1.12",
"weezl",
"zune-jpeg",
]
@@ -4848,12 +4668,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.2"
@@ -5031,15 +4845,6 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.5"
@@ -5421,12 +5226,6 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "weezl"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2"
[[package]]
name = "wgpu"
version = "30.0.1"
@@ -5610,22 +5409,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -5635,12 +5418,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.62.2"
-2
View File
@@ -69,8 +69,6 @@ semver = "1"
# constrained Delaunay + Ruppert refinement for trimmed faces (MIT OR Apache-2.0).
spade = { version = "2.15", default-features = false, features = ["std"] }
rayon = "1.12"
# Native render-memory admission; RAM-only queries through a safe MIT-licensed API.
sysinfo = { version = "0.40.1", default-features = false, features = ["system"] }
# packages (3MF)
zip = { version = "9", default-features = false, features = ["deflate"] }
+111 -570
View File
@@ -1,6 +1,6 @@
Third-party software in SolidCraft
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 559 crates below.
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 540 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
@@ -14,10 +14,7 @@ accesskit_windows 0.35.2 (MIT OR Apache-2.0)
accesskit_winit 0.34.2 (Apache-2.0)
adler2 2.0.1 (0BSD OR MIT OR Apache-2.0)
aes 0.8.4 (MIT OR Apache-2.0)
aes 0.9.4 (MIT OR Apache-2.0)
ahash 0.8.12 (MIT OR Apache-2.0)
alloc-no-stdlib 2.0.4 (BSD-3-Clause)
alloc-stdlib 0.2.4 (BSD-3-Clause)
allocator-api2 0.2.21 (MIT OR Apache-2.0)
android-activity 0.6.1 (MIT OR Apache-2.0)
android-properties 0.2.2 (MIT)
@@ -50,11 +47,9 @@ bitflags 2.13.2 (MIT OR Apache-2.0)
block-buffer 0.10.4 (MIT OR Apache-2.0)
block-buffer 0.12.1 (MIT OR Apache-2.0)
block-padding 0.3.3 (MIT OR Apache-2.0)
block-padding 0.4.2 (MIT OR Apache-2.0)
block2 0.5.1 (MIT)
block2 0.6.2 (MIT)
blocking 1.7.0 (Apache-2.0 OR MIT)
brotli-decompressor 5.0.3 (BSD-3-Clause/MIT)
bumpalo 3.20.3 (MIT OR Apache-2.0)
bytemuck 1.25.2 (Zlib OR Apache-2.0 OR MIT)
bytemuck_derive 1.12.1 (Zlib OR Apache-2.0 OR MIT)
@@ -71,13 +66,10 @@ cadmpeg-registry 0.6.7 (Apache-2.0)
calloop 0.13.0 (MIT)
calloop-wayland-source 0.3.0 (MIT)
cbc 0.1.2 (MIT OR Apache-2.0)
cbc 0.2.1 (MIT OR Apache-2.0)
cfg-if 1.0.5 (MIT OR Apache-2.0)
cgl 0.3.2 (MIT / Apache-2.0)
chacha20 0.10.2 (MIT OR Apache-2.0)
chardetng 0.1.17 (Apache-2.0 OR MIT)
cipher 0.4.4 (MIT OR Apache-2.0)
cipher 0.5.2 (MIT OR Apache-2.0)
clipboard-win 5.4.1 (BSL-1.0)
codespan-reporting 0.13.1 (Apache-2.0)
color 0.3.3 (Apache-2.0 OR MIT)
@@ -91,7 +83,6 @@ core-foundation-sys 0.8.7 (MIT OR Apache-2.0)
core-graphics 0.23.2 (MIT OR Apache-2.0)
core-graphics-types 0.1.3 (MIT OR Apache-2.0)
core_detect 1.0.0 (MIT/Apache-2.0)
cpubits 0.1.1 (MIT OR Apache-2.0)
cpufeatures 0.2.17 (MIT OR Apache-2.0)
cpufeatures 0.3.1 (MIT OR Apache-2.0)
crc32fast 1.5.2 (MIT OR Apache-2.0)
@@ -115,7 +106,6 @@ document-features 0.2.12 (MIT OR Apache-2.0)
downcast-rs 1.2.1 (MIT/Apache-2.0)
dpi 0.1.2 (Apache-2.0 AND MIT)
dyn-clone 1.0.20 (MIT OR Apache-2.0)
ecb 0.2.1 (MIT OR Apache-2.0)
ecolor 0.37.0 (MIT OR Apache-2.0)
eframe 0.37.0 (MIT OR Apache-2.0)
egui 0.37.0 (MIT OR Apache-2.0)
@@ -202,7 +192,6 @@ image-webp 0.2.4 (MIT OR Apache-2.0)
indexmap 2.14.2 (Apache-2.0 OR MIT)
inflections 1.1.1 (MIT)
inout 0.1.4 (MIT OR Apache-2.0)
inout 0.2.6 (MIT OR Apache-2.0)
itertools 0.15.0 (MIT OR Apache-2.0)
itoa 1.0.18 (MIT OR Apache-2.0)
jni 0.22.4 (MIT OR Apache-2.0)
@@ -228,8 +217,6 @@ litemap 0.8.3 (Unicode-3.0)
litrs 1.0.0 (MIT OR Apache-2.0)
lock_api 0.4.14 (MIT OR Apache-2.0)
log 0.4.34 (MIT OR Apache-2.0)
lopdf 0.45.0 (MIT)
md-5 0.11.0 (MIT OR Apache-2.0)
memchr 2.8.3 (Unlicense OR MIT)
memmap2 0.9.11 (MIT OR Apache-2.0)
memoffset 0.9.1 (MIT)
@@ -324,13 +311,11 @@ quick-xml 0.41.0 (MIT)
quote 1.0.47 (MIT OR Apache-2.0)
r-efi 5.3.0 (MIT OR Apache-2.0 OR LGPL-2.1-or-later)
r-efi 6.0.0 (MIT OR Apache-2.0 OR LGPL-2.1-or-later)
rand 0.10.3 (MIT OR Apache-2.0)
rand 0.8.8 (MIT OR Apache-2.0)
rand_chacha 0.3.1 (MIT OR Apache-2.0)
rand_core 0.10.1 (MIT OR Apache-2.0)
rand_core 0.6.4 (MIT OR Apache-2.0)
range-alloc 0.1.5 (MIT OR Apache-2.0)
rangemap 1.8.0 (MIT/Apache-2.0)
raw-window-handle 0.6.2 (MIT OR Apache-2.0 OR Zlib)
raw-window-metal 1.1.0 (MIT OR Apache-2.0)
rayon 1.12.0 (MIT OR Apache-2.0)
@@ -402,7 +387,6 @@ spirv 0.4.0+sdk-1.4.341.0 (Apache-2.0)
stable_deref_trait 1.2.1 (MIT OR Apache-2.0)
static_assertions 1.1.0 (MIT OR Apache-2.0)
strict-num 0.1.1 (MIT)
stringprep 0.1.5 (MIT/Apache-2.0)
subtle 2.6.1 (BSD-3-Clause)
syn 2.0.119 (MIT OR Apache-2.0)
syn 3.0.6 (MIT OR Apache-2.0)
@@ -418,7 +402,6 @@ tiff 0.11.3 (MIT)
tiny-skia 0.12.0 (BSD-3-Clause)
tiny-skia-path 0.12.0 (BSD-3-Clause)
tinystr 0.8.4 (Unicode-3.0)
tinyvec 1.13.3 (Zlib OR Apache-2.0 OR MIT)
tokio 1.53.2 (MIT)
toml 1.1.8+spec-1.1.0 (MIT OR Apache-2.0)
toml_datetime 1.1.2+spec-1.1.0 (MIT OR Apache-2.0)
@@ -439,7 +422,6 @@ uds_windows 1.2.1 (MIT)
unicode-bidi 0.3.18 (MIT OR Apache-2.0)
unicode-general-category 1.1.0 (Apache-2.0)
unicode-ident 1.0.26 ((MIT OR Apache-2.0) AND Unicode-3.0)
unicode-normalization 0.1.26 ((MIT OR Apache-2.0) AND Unicode-3.0)
unicode-properties 0.1.5 ((MIT OR Apache-2.0) AND Unicode-3.0)
unicode-segmentation 1.13.3 (MIT OR Apache-2.0)
unicode-width 0.2.2 (MIT OR Apache-2.0)
@@ -473,7 +455,6 @@ web-time 1.1.0 (MIT OR Apache-2.0)
webbrowser 1.2.4 (MIT OR Apache-2.0)
webpki-root-certs 1.0.10 (CDLA-Permissive-2.0)
weezl 0.1.12 (MIT OR Apache-2.0)
weezl 0.2.1 (MIT OR Apache-2.0)
wgpu 30.0.1 (MIT OR Apache-2.0)
wgpu-core 30.0.1 (MIT OR Apache-2.0)
wgpu-core-deps-apple 30.0.1 (MIT OR Apache-2.0)
@@ -863,11 +844,10 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
aes 0.8.4, aes 0.9.4, block-buffer 0.10.4, block-buffer 0.12.1, block-padding 0.3.3, block-padding
0.4.2, cbc 0.1.2, cbc 0.2.1, chacha20 0.10.2, cipher 0.4.4, cipher 0.5.2, const-oid 0.10.2, cpubits
0.1.1, cpufeatures 0.2.17, cpufeatures 0.3.1, crypto-common 0.1.7, crypto-common 0.2.2, digest
0.10.7, digest 0.11.3, ecb 0.2.1, hkdf 0.12.4, hmac 0.12.1, hybrid-array 0.4.15, inout 0.1.4, inout
0.2.6, md-5 0.11.0, sha1 0.10.7, sha1 0.11.0, sha2 0.10.9, sha2 0.11.1
aes 0.8.4, block-buffer 0.10.4, block-buffer 0.12.1, block-padding 0.3.3, cbc 0.1.2, cipher 0.4.4,
const-oid 0.10.2, cpufeatures 0.2.17, cpufeatures 0.3.1, crypto-common 0.1.7, crypto-common 0.2.2,
digest 0.10.7, digest 0.11.3, hkdf 0.12.4, hmac 0.12.1, hybrid-array 0.4.15, inout 0.1.4, sha1
0.10.7, sha1 0.11.0, sha2 0.10.9, sha2 0.11.1
================================================================================
Apache License
@@ -1072,37 +1052,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================================================
aes 0.9.4
================================================================================
Copyright (c) 2018-2024 The RustCrypto Project Developers
Copyright (c) 2018 Artyom Pavlov
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
ahash 0.8.12
================================================================================
@@ -1156,11 +1105,11 @@ plain 0.2.3, png 0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1
1.2.0, secret-service 4.0.0, security-framework 2.11.1, security-framework 3.7.0,
security-framework-sys 2.17.0, send_wrapper 0.6.0, signal-hook-registry 1.4.8, simd_cesu8 1.2.0,
smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait 1.2.1, tempfile 3.27.0, ttf-parser 0.25.1,
unicode-bidi 0.3.18, unicode-normalization 0.1.26, unicode-properties 0.1.5, unicode-segmentation
1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2
1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129,
wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12,
weezl 0.2.1, wit-bindgen 0.57.1, wl-clipboard-rs 0.9.5
unicode-bidi 0.3.18, unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width 0.2.2, url
2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen
0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support
0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12, wit-bindgen 0.57.1,
wl-clipboard-rs 0.9.5
================================================================================
Apache License
@@ -1365,23 +1314,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================================================
alloc-no-stdlib 2.0.4, brotli-decompressor 5.0.3
================================================================================
Copyright (c) 2016 Dropbox, Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
allocator-api2 0.2.21, async-trait 0.1.92, dyn-clone 1.0.20, enumn 0.1.14, glifo 0.3.0, itoa 1.0.18,
libc 0.2.190, litrs 1.0.0, num_enum 0.7.6, num_enum_derive 0.7.6, proc-macro2 1.0.107, quote 1.0.47,
@@ -2001,8 +1933,8 @@ as-raw-xcb-connection 1.0.1, atspi 0.29.0, atspi-common 0.13.0, atspi-proxies 0.
0.1.17, codespan-reporting 0.13.1, encoding_rs 0.8.42, kurbo 0.13.1, multiversion_no_op 1.0.0,
nohash-hasher 0.2.0, peniko 0.6.1, polycool 0.4.0, raw-window-metal 1.1.0, rustls-platform-verifier
0.7.1, self_cell 1.3.0, siphasher 1.0.4, spade 2.15.1, static_assertions 1.1.0, sys-locale 0.3.2,
tinyvec 1.13.3, ureq 3.4.2, ureq-proto 0.6.4, utf8_iter 1.0.4, x11rb 0.13.2, x11rb-protocol 0.13.2,
zeroize 1.9.1, zeroize_derive 1.5.0, zune-core 0.5.3, zune-jpeg 0.5.15
ureq 3.4.2, ureq-proto 0.6.4, utf8_iter 1.0.4, x11rb 0.13.2, x11rb-protocol 0.13.2, zeroize 1.9.1,
zeroize_derive 1.5.0, zune-core 0.5.3, zune-jpeg 0.5.15
================================================================================
Apache License
@@ -2958,7 +2890,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
block-buffer 0.12.1, block-padding 0.4.2
block-buffer 0.12.1
================================================================================
Copyright (c) 2018-2025 The RustCrypto Project Developers
@@ -3087,20 +3019,6 @@ limitations under the License.
bytemuck 1.25.2, bytemuck_derive 1.12.1
================================================================================
MIT License
Copyright (c) 2019 Daniel "Lokathor" Gee.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
bytemuck 1.25.2, bytemuck_derive 1.12.1, tinyvec 1.13.3
================================================================================
Copyright (c) 2019 Daniel "Lokathor" Gee.
This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software.
@@ -3113,6 +3031,20 @@ Permission is granted to anyone to use this software for any purpose, including
3. This notice may not be removed or altered from any source distribution.
================================================================================
bytemuck 1.25.2, bytemuck_derive 1.12.1
================================================================================
MIT License
Copyright (c) 2019 Daniel "Lokathor" Gee.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
byteorder 1.5.0, byteorder-lite 0.1.0, memchr 2.8.3, termcolor 1.4.1
================================================================================
@@ -3255,7 +3187,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
cbc 0.1.2, cbc 0.2.1
cbc 0.1.2
================================================================================
Copyright (c) 2018-2022 RustCrypto Developers
@@ -3358,36 +3290,6 @@ http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
option. All files in the project carrying such notice may not be
copied, modified, or distributed except according to those terms.
================================================================================
chacha20 0.10.2, zeroize_derive 1.5.0
================================================================================
Copyright (c) 2019-2026 The RustCrypto Project Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
chardetng 0.1.17
================================================================================
@@ -3462,36 +3364,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
cipher 0.5.2
================================================================================
Copyright (c) 2016-2025 RustCrypto Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
color_quant 1.1.0
================================================================================
@@ -3604,36 +3476,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
cpubits 0.1.1
================================================================================
Copyright (c) 2023-2026 The RustCrypto Project Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
cpufeatures 0.2.17
================================================================================
@@ -3692,8 +3534,8 @@ SOFTWARE.
================================================================================
crc32fast 1.5.2, dpi 0.1.2, foreign-types 0.5.0, foreign-types-macros 0.2.4, foreign-types-shared
0.3.1, hex 0.4.3, jni-sys 0.3.1, jni-sys 0.4.1, quick-error 2.0.1, stringprep 0.1.5, toml_edit
0.25.15+spec-1.1.0, unicode-general-category 1.1.0, winit 0.30.13
0.3.1, hex 0.4.3, jni-sys 0.3.1, jni-sys 0.4.1, quick-error 2.0.1, toml_edit 0.25.15+spec-1.1.0,
unicode-general-category 1.1.0, winit 0.30.13
================================================================================
Apache License
@@ -5393,36 +5235,6 @@ and labelled as such in comments in the individual source files. All
have been licensed under extremely permissive terms.
------------------------------------------------------------------------------
================================================================================
ecb 0.2.1
================================================================================
Copyright (c) 2026 RustCrypto Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
egui-wgpu 0.37.0
================================================================================
@@ -7957,37 +7769,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
inout 0.2.6
================================================================================
Copyright (c) 2022-2025 The RustCrypto Project Developers
Copyright (c) 2022 Artyom Pavlov
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
jni-sys 0.3.1, jni-sys 0.4.1
================================================================================
@@ -8926,65 +8707,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
lopdf 0.45.0
================================================================================
MIT License
Copyright (c) 2016 Junfeng Liu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
md-5 0.11.0, sha1 0.11.0, sha2 0.11.1
================================================================================
Copyright (c) 2016-2026 The RustCrypto Project Developers
Copyright (c) 2016 Artyom Pavlov
Copyright (c) 2009-2013 Mozilla Foundation
Copyright (c) 2006-2009 Graydon Hoare
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
memmap2 0.9.11
================================================================================
@@ -10357,7 +10079,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
rand 0.10.3, rand 0.8.8
rand 0.8.8
================================================================================
Apache License
@@ -10538,7 +10260,7 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
END OF TERMS AND CONDITIONS
================================================================================
rand 0.10.3, rand 0.8.8, rand_chacha 0.3.1, rand_core 0.6.4
rand 0.8.8, rand_chacha 0.3.1, rand_core 0.6.4
================================================================================
Copyright 2018 Developers of the Rand project
@@ -10569,7 +10291,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
rand 0.10.3, rand 0.8.8, rand_chacha 0.3.1, rand_core 0.6.4
rand 0.8.8, rand_chacha 0.3.1, rand_core 0.6.4
================================================================================
Copyrights in the Rand project are retained by their contributors. No
@@ -10847,213 +10569,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
rangemap 1.8.0
================================================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2019-2022 Jeff Parsons, and [contributors](https://github.com/jeffparsons/rangemap/contributors)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================================================
rangemap 1.8.0
================================================================================
Copyright 2019 Jeffrey Parsons
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
raw-window-handle 0.6.2
================================================================================
@@ -12761,6 +12276,39 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
sha1 0.11.0, sha2 0.11.1
================================================================================
Copyright (c) 2016-2026 The RustCrypto Project Developers
Copyright (c) 2016 Artyom Pavlov
Copyright (c) 2009-2013 Mozilla Foundation
Copyright (c) 2006-2009 Graydon Hoare
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
signal-hook-registry 1.4.8
================================================================================
@@ -13266,30 +12814,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
stringprep 0.1.5
================================================================================
Copyright (c) 2017 The rust-stringprep Developers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
subtle 2.6.1
================================================================================
@@ -13453,16 +12977,6 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
tinyvec 1.13.3
================================================================================
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
tokio 1.53.2
================================================================================
@@ -14367,8 +13881,7 @@ carrying such notice may not be copied, modified, or distributed except
according to those terms.
================================================================================
unicode-bidi 0.3.18, unicode-normalization 0.1.26, unicode-properties 0.1.5, unicode-segmentation
1.13.3, unicode-width 0.2.2
unicode-bidi 0.3.18, unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width 0.2.2
================================================================================
Copyright (c) 2015 The Rust Project Developers
@@ -14398,7 +13911,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
unicode-ident 1.0.26, unicode-normalization 0.1.26
unicode-ident 1.0.26
================================================================================
UNICODE LICENSE V3
@@ -14441,19 +13954,6 @@ not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
================================================================================
unicode-normalization 0.1.26, unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width
0.2.2
================================================================================
Licensed under the Apache License, Version 2.0
<LICENSE-APACHE or
http://www.apache.org/licenses/LICENSE-2.0> or the MIT
license <LICENSE-MIT or http://opensource.org/licenses/MIT>,
at your option. All files in the project carrying such
notice may not be copied, modified, or distributed except
according to those terms.
================================================================================
unicode-properties 0.1.5
================================================================================
@@ -14498,6 +13998,18 @@ not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
================================================================================
unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width 0.2.2
================================================================================
Licensed under the Apache License, Version 2.0
<LICENSE-APACHE or
http://www.apache.org/licenses/LICENSE-2.0> or the MIT
license <LICENSE-MIT or http://opensource.org/licenses/MIT>,
at your option. All files in the project carrying such
notice may not be copied, modified, or distributed except
according to those terms.
================================================================================
untrusted 0.9.0
================================================================================
@@ -15214,7 +14726,7 @@ of Data, including for example machine learning models and models'
insights.
================================================================================
weezl 0.1.12, weezl 0.2.1
weezl 0.1.12
================================================================================
The MIT License (MIT)
@@ -16274,6 +15786,36 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
zeroize_derive 1.5.0
================================================================================
Copyright (c) 2019-2026 The RustCrypto Project Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
zip 9.1.3
================================================================================
@@ -16593,7 +16135,6 @@ accesskit_macos 0.27.2: MIT OR Apache-2.0 (https://github.com/AccessKit/accesski
accesskit_unix 0.24.1: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_windows 0.35.2: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
accesskit_winit 0.34.2: Apache-2.0 (https://github.com/AccessKit/accesskit)
alloc-stdlib 0.2.4: BSD-3-Clause (https://github.com/dropbox/rust-alloc-no-stdlib)
block2 0.5.1: MIT (https://github.com/madsmtm/objc2)
block2 0.6.2: MIT (https://github.com/madsmtm/objc2)
cadmpeg-codec-freecad 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
+1 -1
View File
@@ -76,7 +76,7 @@ const USAGE_BENCH_ASSISTANT: &str = " solidcraft-cli bench-assistant --connect
/// `snapshot`'s lines in the usage.
const USAGE_SNAPSHOT: &str = " solidcraft-cli snapshot <script|design> [--in design.sc|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]\n [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27] [--quality coarse|normal|fine|ultra]\n solidcraft-cli snapshot --in <design.sc|part.step> --out shot.png ...\n (as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;\n --view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;\n --camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);\n --no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture;\n --body: how one body is drawn, as appearance.assign takes it: opacity 0-1 (below 1 is see-through, so what is inside\n it shows) and color #rrggbb, either or both, once per body;\n --tool: draw this body solid in the tool colour and every other body see-through, so a cut's or a join's tool body\n is visible inside the material it meets; a later --body overrides it;\n --section: cut the model as inspect.section does, so the inside shows: z=27 (the plane through that world\n coordinate, keeping the smaller side), add ,flip to keep the other side, or XY, XZ, YZ, or its own JSON;\n --quality: the display quality the bodies are meshed at, as the app's setting (default normal))\n";
/// `render`'s lines in the usage.
const USAGE_RENDER: &str = " solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]\n [--progressive] [--samples N] [--max-depth N] [--seed N] [--exposure EV] [--lighting FILE.json] [--environment FILE.json] [--tone-map reinhard|aces_fitted] [--legacy-memory-admission]\n --legacy-memory-admission skips available-RAM checks; fixed shared/host budgets and allocation limits still apply.\n";
const USAGE_RENDER: &str = " solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]\n [--progressive] [--samples N] [--max-depth N] [--seed N] [--exposure EV] [--lighting FILE.json] [--environment FILE.json] [--tone-map reinhard|aces_fitted]\n";
/// `exec`'s lines in the usage.
const USAGE_EXEC: &str = " solidcraft-cli exec <command> [json-params]\n";
/// `commands`'s lines in the usage.
+3 -11
View File
@@ -3,7 +3,7 @@ use serde_json::{Value, json};
use solidcraft_engine::{
Session,
doc::render_settings::{RenderJob, RenderSetup},
render::job::{CpuHost, JobId, NativeMemoryPolicy, Phase, ToneMap},
render::job::{CpuHost, JobId, Phase, ToneMap},
};
/// Only explicit runtime CLI flags are stored here. A typed setup owns these
@@ -74,7 +74,6 @@ pub fn run(args: &[String]) -> Result<(), String> {
let mut height = None;
let mut exposure = None;
let mut runtime = RuntimeOptions::default();
let mut memory_policy = NativeMemoryPolicy::AvailableRam;
let mut overwrite = false;
let mut timeout = 120u64;
let mut i = 1;
@@ -83,7 +82,6 @@ pub fn run(args: &[String]) -> Result<(), String> {
"--progressive" => runtime.progressive = true,
"--transparent" => runtime.transparent = true,
"--overwrite" => overwrite = true,
"--legacy-memory-admission" => memory_policy = NativeMemoryPolicy::SoftwarePoolOnly,
"--out" | "--setup" | "--width" | "--height" | "--timeout" | "--samples" | "--max-depth" | "--seed" | "--exposure" | "--lighting"
| "--tone-map" | "--environment" => {
i += 1;
@@ -136,15 +134,9 @@ pub fn run(args: &[String]) -> Result<(), String> {
// Resolve saved intent before opening a GPU or explicitly settling geometry.
// Width/height/exposure only change this validated inline settings clone.
let (mut params, needs_gpu) = runtime.request(&session, settings)?;
// The machine admission policy may accompany any saved setup; it does not
// replace the setup's image-quality options or alter the saved document.
let cpu_host = CpuHost::for_platform().map_err(|e| e.to_string())?;
cpu_host.set_native_memory_policy(memory_policy);
session.set_render_job_host(Some(cpu_host)).map_err(|e| e.to_string())?;
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
if needs_gpu {
let gpu_host = solidcraft_gpu::path::host().map_err(|e| e.to_string())?;
gpu_host.set_native_memory_policy(memory_policy);
session.register_render_job_host(gpu_host).map_err(|e| e.to_string())?;
session.register_render_job_host(solidcraft_gpu::path::host().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
}
session.execute("render.scene", &json!({})).map_err(|e| e.to_string())?;
if setup.is_none() {
+1 -11
View File
@@ -152,17 +152,7 @@ fn render_help_keeps_the_registered_gpu_request_controls() {
assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.stderr.is_empty());
let stdout = String::from_utf8_lossy(&output.stdout);
for flag in [
"--progressive",
"--samples",
"--max-depth",
"--seed",
"--exposure",
"--lighting",
"--environment",
"--tone-map",
"--legacy-memory-admission",
] {
for flag in ["--progressive", "--samples", "--max-depth", "--seed", "--exposure", "--lighting", "--environment", "--tone-map"] {
assert!(stdout.contains(flag), "{args:?} lost {flag}: {stdout}");
}
}
+2 -10
View File
@@ -673,7 +673,6 @@ fn render_uses_typed_cpu_setups_from_scripts_and_saved_designs() {
.unwrap(),
);
assert_eq!(result["preflight"]["resolved"]["executor"], "cpu-raytrace");
assert_eq!(result["preflight"]["admission"]["host_memory"]["policy"], "available_ram");
assert_eq!(result["export"]["frame"]["executor"], "cpu-raytrace");
assert_eq!(result["preflight"]["request"]["integrator"], "raytrace");
assert_eq!(result["preflight"]["request"]["raytrace"].get("progressive"), options.get("progressive"));
@@ -690,12 +689,11 @@ fn render_uses_typed_cpu_setups_from_scripts_and_saved_designs() {
.arg(&saved)
.args(["--setup", "Portable setup", "--out"])
.arg(&overridden)
.args(["--width", "12", "--height", "8", "--exposure", "1.25", "--legacy-memory-admission"])
.args(["--width", "12", "--height", "8", "--exposure", "1.25"])
.env("SOLIDCRAFT_GPU", "must-not-be-read-for-cpu")
.output()
.unwrap(),
);
assert_eq!(result["preflight"]["admission"]["host_memory"]["policy"], "software_pool_only");
assert_eq!(result["preflight"]["request"]["width"], 12);
assert_eq!(result["preflight"]["request"]["height"], 8);
assert_eq!(result["preflight"]["request"]["raytrace"]["exposure_ev"], 1.25);
@@ -775,13 +773,7 @@ fn typed_gpu_render_selects_its_host_without_a_progressive_runtime_flag() {
let script = render_setup_script(&dir, job.clone());
let out = dir.0.join("gpu.png");
let mut command = cli();
command
.arg("render")
.arg(&script)
.args(["--setup", "Portable setup", "--out"])
.arg(&out)
.arg("--legacy-memory-admission")
.env("SOLIDCRAFT_GPU", "not-a-gpu-policy");
command.arg("render").arg(&script).args(["--setup", "Portable setup", "--out"]).arg(&out).env("SOLIDCRAFT_GPU", "not-a-gpu-policy");
if job.is_null() {
command.arg("--progressive");
}
+3 -3
View File
@@ -360,7 +360,7 @@ mod web {
/// The start page's recent designs were filled from the stored ones.
recents_synced: bool,
/// On-demand script fonts: fetched only when a CJK language is selected.
font: solidcraft_ui_egui::ui_kit::type_face::CjkFontLoader,
font: solidcraft_ui_egui::fonts::CjkFontLoader,
font_result: FontLoadResult,
/// The fetched Japanese font's bytes: kept so a language change can re-order the fallbacks.
cjk_bytes: Option<Vec<u8>>,
@@ -1059,7 +1059,7 @@ mod web {
}
};
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if !installed && solidcraft_ui_egui::ui_kit::type_face::needs_cjk_font(effective) {
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(effective) {
log::warn!("script font unavailable; showing English for this session");
}
self.font.finished(installed, &mut self.app.preferences.language);
@@ -1093,7 +1093,7 @@ mod web {
/// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name):
/// fetch the Japanese font, so the text does not stay tofu (#720).
fn watch_text_for_font(&mut self, ctx: &egui::Context) {
if self.font.watching_text() && solidcraft_ui_egui::ui_kit::type_face::painted_cjk(ctx) && self.font.text_needs_font() {
if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() {
self.fetch_font(ctx, "ja");
}
}
+5 -5
View File
@@ -150,16 +150,16 @@ pub(crate) fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, h
}
/// The pointer target around a compass end: larger than the drawn disc, so it is easy to hit.
pub(crate) const AXIS_RADIUS: f32 = 15.0;
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens) {
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) {
axis_tip_scaled(painter, c, name, d, at, depth, hovered, t, 1.0);
}
/// The same compass artwork at a uniform scale, including its lettering and strokes.
#[allow(clippy::too_many_arguments)]
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
let col = if d[0] != 0.0 {
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
let col = if d.x != 0.0 {
t.axis_x
} else if d[1] != 0.0 {
} else if d.y != 0.0 {
t.axis_y
} else {
t.axis_z
@@ -196,7 +196,7 @@ pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: [
// On a hovered (white) disc the letter takes the axis colour.
let letter = if hovered { col } else { t.on_accent };
let label = name.to_uppercase();
let galley = painter.layout_no_wrap(label, crate::ui_kit::type_face::bold(solidcraft_theme::Tokens::get().font_compass * scale), letter);
let galley = painter.layout_no_wrap(label, crate::fonts::bold(solidcraft_theme::Tokens::get().font_compass * scale), letter);
let origin = at - galley.size() / 2.0;
let ppp = painter.ctx().pixels_per_point().max(0.5);
let origin = pos2((origin.x * ppp).round() / ppp, (origin.y * ppp).round() / ppp);
+1 -3
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "إيقاف السجل",
"cmd.timeline_suppress": "تعطيل الميزة",
"cmd.ui.assistant": "إظهار المساعد أو إخفاؤه",
"cmd.ui.palette": "البحث في الأوامر",
"cmd.view.navigator": "متصفح العرض المخصص",
"cmd.view.render_browser": "مستعرض التصيير",
"cmd.view.render_dock": "نقل لوحة التصيير",
@@ -2432,7 +2431,6 @@
"parts.dimension.width": "العرض",
"parts.family.bearing": "محمل كريات أخدودي عميق (الغلاف)",
"parts.family.button_head_cap_screw": "برغي برأس زرّي بتجويف سداسي",
"parts.family.countersunk_socket_screw": "برغي برأس غاطس بتجويف سداسي",
"parts.family.dowel_pin": "مسمار توجيه أسطواني",
"parts.family.hex_bolt": "برغي سداسي الرأس",
"parts.family.hex_cap_screw_inch": "برغي سداسي الرأس (بوصة)",
@@ -3400,7 +3398,7 @@
"render.cpu_opaque_required": "يتطلب تتبع الأشعة على CPU خلفية معتمة. ألغِ تحديد «خلفية شفافة» للمتابعة.",
"render.cpu_options_pending": "لا تستطيع لوحة CPU هذه تحرير الانبعاث ولا تأثيرات السطح ولا الزجاج ولا تباين الخواص بعد. امسح خيارات API هذه قبل استخدامها.",
"render.cpu_path_depth": "عمق المسار",
"render.cpu_path_help": "تضيف الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءًا من أول تقاطع (1–64). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتًا أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_path_help": "تضيف الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءًا من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتًا أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_path_lighting": "إضاءة غير مباشرة",
"render.cpu_path_optics_reason": "تبقى إعداداتك كما هي. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_point": "ضوء نقطي",
+1 -3
View File
@@ -1319,7 +1319,6 @@
"cmd.timeline_stop": "Verlauf anhalten",
"cmd.timeline_suppress": "Merkmal unterdrücken",
"cmd.ui.assistant": "Assistenten ein-/ausblenden",
"cmd.ui.palette": "Befehle suchen",
"cmd.view.navigator": "Eigener Ansichtsnavigator",
"cmd.view.render_browser": "Render-Browser",
"cmd.view.render_dock": "Render-Panel verschieben",
@@ -2222,7 +2221,6 @@
"parts.dimension.width": "Breite",
"parts.family.bearing": "Rillenkugellager (Hüllform)",
"parts.family.button_head_cap_screw": "Linsenkopfschraube mit Innensechskant",
"parts.family.countersunk_socket_screw": "Senkschraube mit Innensechskant",
"parts.family.dowel_pin": "Zylinderstift",
"parts.family.hex_bolt": "Sechskantschraube",
"parts.family.hex_cap_screw_inch": "Sechskantschraube (Zoll)",
@@ -3154,7 +3152,7 @@
"render.cpu_opaque_required": "CPU-Raytracing benötigt einen undurchsichtigen Hintergrund. Entfernen Sie das Häkchen bei „Transparenter Hintergrund“, um fortzufahren.",
"render.cpu_options_pending": "Dieses CPU-Bedienfeld kann Emission, Oberflächeneffekte, Glas oder Anisotropie noch nicht bearbeiten. Entfernen Sie diese API-Optionen, bevor Sie es verwenden.",
"render.cpu_path_depth": "Pfadtiefe",
"render.cpu_path_help": "Berechnet Licht, das zwischen undurchsichtigen Flächen und durch unterstützte Glasformen läuft. Erfordert progressives Rendern und Raytracing-Schatten. Die Pfadtiefe zählt beleuchtete Flächen, Glasgrenzen und Streuereignisse ab dem ersten Treffer (1–64). Tiefe 1 liefert direkte Beleuchtung auf undurchsichtigen Flächen; höhere Werte erlauben mehr Reflexionen oder Glasdurchgänge und können länger dauern. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
"render.cpu_path_help": "Berechnet Licht, das zwischen undurchsichtigen Flächen und durch unterstützte Glasformen läuft. Erfordert progressives Rendern und Raytracing-Schatten. Die Pfadtiefe zählt beleuchtete Flächen, Glasgrenzen und Streuereignisse ab dem ersten Treffer (1–16). Tiefe 1 liefert direkte Beleuchtung auf undurchsichtigen Flächen; höhere Werte erlauben mehr Reflexionen oder Glasdurchgänge und können länger dauern. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
"render.cpu_path_lighting": "Indirekte Beleuchtung",
"render.cpu_path_optics_reason": "Die Einstellungen bleiben erhalten. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
"render.cpu_point": "Punktlicht",
+1 -3
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "Stop History",
"cmd.timeline_suppress": "Suppress Feature",
"cmd.ui.assistant": "Toggle Assistant",
"cmd.ui.palette": "Search Commands",
"cmd.view.navigator": "Custom View Navigator",
"cmd.view.render_browser": "Render Browser",
"cmd.view.render_dock": "Move Render Dock",
@@ -2432,7 +2431,6 @@
"parts.dimension.width": "Width",
"parts.family.bearing": "Deep groove ball bearing (envelope)",
"parts.family.button_head_cap_screw": "Button head cap screw",
"parts.family.countersunk_socket_screw": "Countersunk socket screw",
"parts.family.dowel_pin": "Parallel pin (dowel)",
"parts.family.hex_bolt": "Hex head bolt / screw",
"parts.family.hex_cap_screw_inch": "Hex cap screw (inch)",
@@ -3400,7 +3398,7 @@
"render.cpu_opaque_required": "CPU ray tracing needs an opaque background. Clear Transparent background to continue.",
"render.cpu_options_pending": "This CPU panel cannot edit emission, surface effects, glass or anisotropy yet. Clear those API options before using it.",
"render.cpu_path_depth": "Path depth",
"render.cpu_path_help": "Adds light bouncing between opaque surfaces and through supported Glass shapes. Requires progressive rendering and traced shadows. Path depth counts shaded surfaces, Glass boundaries and scattering events, starting with the first hit (1–64). Depth 1 gives direct lighting on opaque surfaces; higher values allow more bounces or Glass crossings and may take longer. Turn off Indirect lighting to use custom reflections or anisotropy.",
"render.cpu_path_help": "Adds light bouncing between opaque surfaces and through supported Glass shapes. Requires progressive rendering and traced shadows. Path depth counts shaded surfaces, Glass boundaries and scattering events, starting with the first hit (1–16). Depth 1 gives direct lighting on opaque surfaces; higher values allow more bounces or Glass crossings and may take longer. Turn off Indirect lighting to use custom reflections or anisotropy.",
"render.cpu_path_lighting": "Indirect lighting",
"render.cpu_path_optics_reason": "Your settings are kept. Turn off Indirect lighting to use custom reflections or anisotropy.",
"render.cpu_point": "Point light",
+1 -3
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "עצירת ציר הזמן",
"cmd.timeline_suppress": "השבתת פיצ'ר",
"cmd.ui.assistant": "הצגה או הסתרה של העוזר",
"cmd.ui.palette": "חיפוש פקודות",
"cmd.view.navigator": "ניווט תצוגה מותאם אישית",
"cmd.view.render_browser": "דפדפן רינדור",
"cmd.view.render_dock": "הזז את עגינת הרינדור",
@@ -2432,7 +2431,6 @@
"parts.dimension.width": "רוחב",
"parts.family.bearing": "מסב כדורי חריץ עמוק (מעטפת)",
"parts.family.button_head_cap_screw": "בורג ראש כפתור עם שקע משושה",
"parts.family.countersunk_socket_screw": "בורג ראש שקוע עם שקע משושה",
"parts.family.dowel_pin": "פין גלילי",
"parts.family.hex_bolt": "בורג ראש משושה",
"parts.family.hex_cap_screw_inch": "בורג ראש משושה (אינץ')",
@@ -3400,7 +3398,7 @@
"render.cpu_opaque_required": "מעקב קרניים ב־CPU דורש רקע אטום. יש לבטל את „רקע שקוף” כדי להמשיך.",
"render.cpu_options_pending": "לוח CPU זה עדיין אינו יכול לערוך פליטה, אפקטי משטח, זכוכית או אנאיזוטרופיה. יש לנקות את אפשרויות ה־API האלה לפני השימוש בו.",
"render.cpu_path_depth": "עומק מסלול",
"render.cpu_path_help": "מחשבת אור שמוחזר בין משטחים אטומים ועובר דרך צורות זכוכית נתמכות. נדרשים רינדור הדרגתי וצללים במעקב קרניים. עומק המסלול סופר משטחים מוצללים, גבולות זכוכית ואירועי פיזור החל מהמפגש הראשון (1–64). עומק 1 נותן תאורה ישירה למשטחים אטומים; ערכים גבוהים מאפשרים יותר החזרות או מעברים בזכוכית ועשויים לדרוש יותר זמן. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
"render.cpu_path_help": "מחשבת אור שמוחזר בין משטחים אטומים ועובר דרך צורות זכוכית נתמכות. נדרשים רינדור הדרגתי וצללים במעקב קרניים. עומק המסלול סופר משטחים מוצללים, גבולות זכוכית ואירועי פיזור החל מהמפגש הראשון (1–16). עומק 1 נותן תאורה ישירה למשטחים אטומים; ערכים גבוהים מאפשרים יותר החזרות או מעברים בזכוכית ועשויים לדרוש יותר זמן. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
"render.cpu_path_lighting": "תאורה עקיפה",
"render.cpu_path_optics_reason": "ההגדרות שלך נשמרות. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
"render.cpu_point": "אור נקודתי",
+1 -3
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "履歴を停止",
"cmd.timeline_suppress": "フィーチャーを抑制",
"cmd.ui.assistant": "アシスタントの表示切り替え",
"cmd.ui.palette": "コマンドを検索",
"cmd.view.navigator": "カスタム ビューナビゲーター",
"cmd.view.render_browser": "Renderブラウザー",
"cmd.view.render_dock": "レンダードックを移動",
@@ -2432,7 +2431,6 @@
"parts.dimension.width": "幅",
"parts.family.bearing": "深溝玉軸受(外形)",
"parts.family.button_head_cap_screw": "ボタンヘッド六角穴付きボルト",
"parts.family.countersunk_socket_screw": "六角穴付き皿ボルト",
"parts.family.dowel_pin": "平行ピン",
"parts.family.hex_bolt": "六角ボルト",
"parts.family.hex_cap_screw_inch": "六角ボルト(インチ)",
@@ -3400,7 +3398,7 @@
"render.cpu_opaque_required": "CPU レイトレーシングには不透明な背景が必要です。透明な背景を解除してください。",
"render.cpu_options_pending": "このCPUパネルでは、発光、表面効果、ガラス、異方性はまだ編集できません。使用前にそれらのAPIオプションを解除してください。",
"render.cpu_path_depth": "パス深度",
"render.cpu_path_help": "不透明な面の間で反射し、対応するガラス形状を通る光を計算します。プログレッシブ描画とレイトレーシングの影が必要です。パス深度は最初の交点から、照明を計算する面、ガラス境界、散乱イベントを数えます(1~64)。深度1は不透明面の直接照明のみです。値を上げると反射やガラスの通過を増やせますが、時間がかかる場合があります。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
"render.cpu_path_help": "不透明な面の間で反射し、対応するガラス形状を通る光を計算します。プログレッシブ描画とレイトレーシングの影が必要です。パス深度は最初の交点から、照明を計算する面、ガラス境界、散乱イベントを数えます(1~16)。深度1は不透明面の直接照明のみです。値を上げると反射やガラスの通過を増やせますが、時間がかかる場合があります。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
"render.cpu_path_lighting": "間接照明",
"render.cpu_path_optics_reason": "設定は保持されます。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
"render.cpu_point": "点光源",
+1 -2
View File
@@ -1252,7 +1252,6 @@
"cmd.timeline_stop": "기록 재생 중지",
"cmd.timeline_suppress": "특성 비활성화",
"cmd.ui.assistant": "도우미 표시 전환",
"cmd.ui.palette": "명령 검색",
"cmd.view.navigator": "사용자 지정 뷰 탐색기",
"cmd.view.render_browser": "렌더 브라우저",
"cmd.view.render_dock": "렌더 도크 이동",
@@ -2670,7 +2669,7 @@
"render.cpu_opaque_required": "CPU 레이 트레이싱에는 불투명 배경이 필요합니다. 계속하려면 투명 배경을 해제하세요.",
"render.cpu_options_pending": "이 CPU 패널은 아직 발광, 표면 효과, 유리, 이방성을 편집할 수 없습니다. 사용하기 전에 해당 API 옵션을 해제하세요.",
"render.cpu_path_depth": "경로 깊이",
"render.cpu_path_help": "불투명 표면 사이에서 반사되고 지원되는 유리 형상을 통과하는 빛을 계산합니다. 점진적 렌더링과 레이 추적 그림자가 필요합니다. 경로 깊이는 첫 교점부터 조명이 계산되는 표면, 유리 경계 및 산란 이벤트를 셉니다(1–64). 깊이 1은 불투명 표면의 직접 조명만 계산합니다. 값을 높이면 반사나 유리 통과가 늘어나며 시간이 더 걸릴 수 있습니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
"render.cpu_path_help": "불투명 표면 사이에서 반사되고 지원되는 유리 형상을 통과하는 빛을 계산합니다. 점진적 렌더링과 레이 추적 그림자가 필요합니다. 경로 깊이는 첫 교점부터 조명이 계산되는 표면, 유리 경계 및 산란 이벤트를 셉니다(1–16). 깊이 1은 불투명 표면의 직접 조명만 계산합니다. 값을 높이면 반사나 유리 통과가 늘어나며 시간이 더 걸릴 수 있습니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
"render.cpu_path_lighting": "간접 조명",
"render.cpu_path_optics_reason": "설정은 유지됩니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
"render.cpu_point": "점 조명",
-66
View File
@@ -1,66 +0,0 @@
{
"ar": {
"render.caustics_area_lights": "تأثيرات الضوء المركّز من الأضواء المساحية",
"render.caustics_environment": "تأثيرات الضوء المركّز من البيئة",
"render.metal_energy_compensation": "استعادة انعكاس المعادن الخشنة",
"render.metal_energy_compensation_help": "عوّض الضوء الذي تفقده الأسطح المعدنية الخشنة بين أوجهها المجهرية. يتطلب معالج CPU إضاءة غير مباشرة.",
"render.texture_filtering": "ترشيح تفاصيل الخامات",
"render.texture_filtering_help": "استخدم مستويات التفاصيل والترشيح المتباين لتقليل تعرّج الخامات عند التصغير. يزيد استهلاك الذاكرة؛ يبقى الوضع القديم معطّلاً."
},
"de": {
"render.caustics_area_lights": "Kaustiken von Flächenlichtern",
"render.caustics_environment": "Kaustiken aus der Umgebung",
"render.metal_energy_compensation": "Reflexion rauer Metalle wiederherstellen",
"render.metal_energy_compensation_help": "Gleicht den Lichtverlust zwischen den Mikrofacetten rauer Metalle aus. Die CPU benötigt indirekte Beleuchtung.",
"render.texture_filtering": "Texturdetails filtern",
"render.texture_filtering_help": "Mip-Stufen und anisotrope Filterung verringern Flimmern verkleinerter Texturen. Benötigt zusätzlichen Speicher; ausgeschaltet bleibt die bisherige Filterung."
},
"en": {
"render.caustics_area_lights": "Area light caustics",
"render.caustics_environment": "Environment caustics",
"render.metal_energy_compensation": "Recover rough metal reflectance",
"render.metal_energy_compensation_help": "Compensate for light lost between rough metal microfacets. CPU rendering needs Indirect lighting.",
"render.texture_filtering": "Filter texture detail",
"render.texture_filtering_help": "Use mip levels and anisotropic filtering to reduce texture aliasing under minification. Uses more memory; off retains the previous filtering."
},
"he": {
"render.caustics_area_lights": "קאוסטיקה מתאורת שטח",
"render.caustics_environment": "קאוסטיקה מתאורת הסביבה",
"render.metal_energy_compensation": "שחזור ההחזר של מתכת מחוספסת",
"render.metal_energy_compensation_help": "מפצה על האור שאובד בין הפאות המיקרוסקופיות של מתכת מחוספסת. עיבוד במעבד דורש תאורה עקיפה.",
"render.texture_filtering": "סינון פרטי מרקם",
"render.texture_filtering_help": "שימוש ברמות פירוט ובסינון אנאיזוטרופי מפחית שינון במרקמים מוקטנים. דורש יותר זיכרון; כשהאפשרות כבויה נשמר הסינון הקודם."
},
"ja": {
"render.caustics_area_lights": "面光源のコースティクス",
"render.caustics_environment": "環境光のコースティクス",
"render.metal_energy_compensation": "粗い金属の反射を補正",
"render.metal_energy_compensation_help": "粗い金属の微小面間で失われる光を補います。CPU レンダリングには間接照明が必要です。",
"render.texture_filtering": "テクスチャの細部をフィルタリング",
"render.texture_filtering_help": "ミップレベルと異方性フィルタリングで縮小時のちらつきを抑えます。メモリ使用量が増えます。オフでは従来のフィルタリングを維持します。"
},
"ko": {
"render.caustics_area_lights": "면광원 집광 효과",
"render.caustics_environment": "환경광 집광 효과",
"render.metal_energy_compensation": "거친 금속의 반사 복원",
"render.metal_energy_compensation_help": "거친 금속의 미세면 사이에서 손실되는 빛을 보정합니다. CPU 렌더링에는 간접 조명이 필요합니다.",
"render.texture_filtering": "텍스처 세부 필터링",
"render.texture_filtering_help": "밉 레벨과 비등방성 필터링으로 축소 시 텍스처 계단 현상을 줄입니다. 메모리를 더 사용하며 끄면 기존 필터링을 유지합니다."
},
"vi": {
"render.caustics_area_lights": "Tụ quang từ đèn diện tích",
"render.caustics_environment": "Tụ quang từ ánh sáng môi trường",
"render.metal_energy_compensation": "Khôi phục phản xạ của kim loại nhám",
"render.metal_energy_compensation_help": "Bù ánh sáng thất thoát giữa các vi diện của kim loại nhám. Kết xuất CPU cần chiếu sáng gián tiếp.",
"render.texture_filtering": "Lọc chi tiết kết cấu",
"render.texture_filtering_help": "Dùng các mức mip và lọc bất đẳng hướng để giảm răng cưa khi thu nhỏ kết cấu. Tốn thêm bộ nhớ; tắt để giữ cách lọc trước đây."
},
"zh": {
"render.caustics_area_lights": "面光源焦散",
"render.caustics_environment": "环境光焦散",
"render.metal_energy_compensation": "恢复粗糙金属的反射",
"render.metal_energy_compensation_help": "补偿粗糙金属微表面之间损失的光能。CPU 渲染需要间接照明。",
"render.texture_filtering": "过滤纹理细节",
"render.texture_filtering_help": "使用多级纹理和各向异性过滤,减少纹理缩小时的锯齿。需要更多内存;关闭时保留原有过滤方式。"
}
}
@@ -1,418 +0,0 @@
{
"en": {
"prefs.render_legacy_memory_admission": "Use previous render memory checks",
"prefs.render_legacy_memory_admission_help": "Allow native renders within the fixed shared and host memory budgets even when their estimate exceeds available RAM. Device limits and allocation checks still apply.",
"render.cpu_threads": "CPU workers",
"render.cpu_threads_auto": "Auto",
"render.cpu_threads_custom": "Custom",
"render.cpu_threads_help": "Auto uses available CPU workers. One worker runs serially. More workers can need more memory.",
"render.cpu_threads_single": "Single",
"render.cpu_threads_web": "Browser rendering uses one cooperative worker. The saved desktop worker choice is preserved.",
"render.denoise_class_background": "Skipped: background",
"render.denoise_class_coverage": "Skipped: partial coverage",
"render.denoise_class_degenerate": "Skipped: degenerate path",
"render.denoise_class_depth": "Skipped: path depth limit",
"render.denoise_class_emitter": "Skipped: light emitter",
"render.denoise_class_exit": "Skipped: incompatible exit",
"render.denoise_class_identity": "Skipped: guide identity limit",
"render.denoise_class_lens": "Skipped: depth of field",
"render.denoise_class_material": "Skipped: unsupported material",
"render.denoise_class_nested": "Skipped: nested medium",
"render.denoise_class_optical": "Eligible: refracted surface",
"render.denoise_class_reflection": "Skipped: reflective endpoint",
"render.denoise_class_surface": "Eligible: surface",
"render.denoise_class_tir": "Skipped: total internal reflection",
"render.denoise_class_transmission": "Skipped: further transmission",
"render.denoise_class_unavailable": "Skipped: no guide",
"render.denoise_class_volume": "Skipped: volume scattering",
"render.denoise_diagnostics_counts": "Eligible: {eligible} · Skipped: {skipped}",
"render.denoise_diagnostics_enable": "Collect denoising diagnostics",
"render.denoise_diagnostics_grid": "Pixel counts at internal size {width} × {height}.",
"render.denoise_diagnostics_help": "Collects eligible and skipped pixel counts even when denoising is off. Counts use the internal render size; the mask uses the output size.",
"render.denoise_diagnostics_title": "Denoising diagnostics",
"render.denoise_export_mask": "Export mask PNG",
"render.denoise_firefly": "Suppress isolated bright pixels",
"render.denoise_format_mask": "Mask PNG",
"render.denoise_mask_copy_help": "Export the denoising mask as PNG, or return to the rendered image to copy it.",
"render.denoise_mask_unavailable": "Enable denoising diagnostics and render an image to inspect its mask.",
"render.denoise_optical_filter": "Filter mirror and glass",
"render.denoise_optical_filter_help": "Filter eligible reflected and transmitted light. Turn off to denoise opaque surfaces only.",
"render.denoise_passes": "Filter passes",
"render.denoise_settings_help": "More passes smooth a wider area. Strength controls how much filtering each pass applies. Unsupported optical paths remain unfiltered.",
"render.denoise_strength": "Denoising strength",
"render.denoise_view_image": "Rendered image",
"render.denoise_view_mask": "Denoising mask",
"render.quality_legacy_samples": "Saved value: {samples} spp. New selections are limited to {maximum} spp.",
"render.quality_preset_limit": "This preset exceeds the 16384 spp selection limit. Choose a sample count up to 16384.",
"render.size_8k_uhd": "8K UHD",
"render.supersampling": "Supersampling",
"render.supersampling_dimensions": "Renders {width} × {height} and reduces to {output_width} × {output_height}. Uses four times the pixels.",
"render.supersampling_help": "Render at twice the width and height, then reduce in linear light. The output size stays unchanged.",
"render.supersampling_off": "Off",
"render.supersampling_x2": "2×"
},
"ar": {
"prefs.render_legacy_memory_admission": "استخدام فحوصات ذاكرة التصيير السابقة",
"prefs.render_legacy_memory_admission_help": "السماح بالتصيير المحلي ضمن حدود الذاكرة المشتركة وحدود المضيف الثابتة حتى عندما يتجاوز التقدير الذاكرة المتاحة. تظل حدود الجهاز وفحوصات تخصيص الذاكرة سارية.",
"render.cpu_threads": "خيوط المعالجة",
"render.cpu_threads_auto": "تلقائي",
"render.cpu_threads_custom": "مخصص",
"render.cpu_threads_help": "يستخدم الوضع التلقائي خيوط المعالجة المتاحة. ينفّذ خيط واحد العمل تسلسليًا. قد تحتاج الخيوط الإضافية إلى ذاكرة أكبر.",
"render.cpu_threads_single": "واحد",
"render.cpu_threads_web": "يستخدم التصيير في المتصفح خيطًا تعاونيًا واحدًا. يُحفظ اختيار خيوط المعالجة الخاص بسطح المكتب.",
"render.denoise_class_background": "متجاوز: خلفية",
"render.denoise_class_coverage": "متجاوز: تغطية جزئية",
"render.denoise_class_degenerate": "متجاوز: مسار متدهور",
"render.denoise_class_depth": "متجاوز: حد عمق المسار",
"render.denoise_class_emitter": "متجاوز: مصدر ضوء",
"render.denoise_class_exit": "متجاوز: خروج غير متوافق",
"render.denoise_class_identity": "متجاوز: حد هوية الدليل",
"render.denoise_class_lens": "متجاوز: عمق المجال",
"render.denoise_class_material": "متجاوز: مادة غير مدعومة",
"render.denoise_class_nested": "متجاوز: وسط متداخل",
"render.denoise_class_optical": "مؤهل: سطح عبر انكسار",
"render.denoise_class_reflection": "متجاوز: نهاية عاكسة",
"render.denoise_class_surface": "مؤهل: سطح",
"render.denoise_class_tir": "متجاوز: انعكاس داخلي كلي",
"render.denoise_class_transmission": "متجاوز: نفاذ إضافي",
"render.denoise_class_unavailable": "متجاوز: بلا دليل",
"render.denoise_class_volume": "متجاوز: تشتت حجمي",
"render.denoise_diagnostics_counts": "المؤهلة: {eligible} · المتجاوزة: {skipped}",
"render.denoise_diagnostics_enable": "جمع تشخيصات إزالة الضوضاء",
"render.denoise_diagnostics_grid": "أعداد البكسلات بالحجم الداخلي {width} × {height}.",
"render.denoise_diagnostics_help": "يجمع أعداد البكسلات المؤهلة والمتجاوزة حتى عند إيقاف إزالة الضوضاء. تعتمد الأعداد على حجم التصيير الداخلي، ويعتمد القناع على حجم الإخراج.",
"render.denoise_diagnostics_title": "تشخيصات إزالة الضوضاء",
"render.denoise_export_mask": "تصدير القناع بصيغة PNG",
"render.denoise_firefly": "تقليل البكسلات الساطعة المعزولة",
"render.denoise_format_mask": "قناع PNG",
"render.denoise_mask_copy_help": "صدّر قناع إزالة الضوضاء بصيغة PNG، أو عُد إلى الصورة المصيّرة لنسخها.",
"render.denoise_mask_unavailable": "فعّل تشخيصات إزالة الضوضاء وصيّر صورة لفحص قناعها.",
"render.denoise_optical_filter": "تصفية المرايا والزجاج",
"render.denoise_optical_filter_help": "تصفية الضوء المنعكس والنافذ المؤهّل. أوقف هذا الخيار لإزالة الضجيج من الأسطح المعتمة فقط.",
"render.denoise_passes": "مرات الترشيح",
"render.denoise_settings_help": "تُنعّم مرات الترشيح الإضافية مساحة أوسع. تحدد القوة مقدار الترشيح المطبّق في كل مرة. تبقى المسارات البصرية غير المدعومة دون ترشيح.",
"render.denoise_strength": "قوة إزالة الضوضاء",
"render.denoise_view_image": "الصورة المصيّرة",
"render.denoise_view_mask": "قناع إزالة الضوضاء",
"render.quality_legacy_samples": "القيمة المحفوظة: {samples} عينة لكل بكسل. تقتصر الاختيارات الجديدة على {maximum} عينة لكل بكسل.",
"render.quality_preset_limit": "يتجاوز هذا الإعداد المسبق حد الاختيار البالغ 16384 عينة لكل بكسل. اختر عدد عينات لا يتجاوز 16384.",
"render.size_8k_uhd": "8K UHD",
"render.supersampling": "أخذ عينات فائقة",
"render.supersampling_dimensions": "يُصيَّر بحجم {width} × {height} ويُصغَّر إلى {output_width} × {output_height}. يستخدم أربعة أضعاف عدد البكسلات.",
"render.supersampling_help": "يُصيَّر المشهد بضعف العرض والارتفاع، ثم يُصغَّر في فضاء الإضاءة الخطي. يبقى حجم الإخراج دون تغيير.",
"render.supersampling_off": "إيقاف",
"render.supersampling_x2": "2×"
},
"he": {
"prefs.render_legacy_memory_admission": "שימוש בבדיקות הזיכרון הקודמות לרינדור",
"prefs.render_legacy_memory_admission_help": "מאפשר רינדור מקומי במסגרת מגבלות הזיכרון הקבועות המשותפות ושל המארח, גם כשההערכה חורגת מהזיכרון הזמין. מגבלות המכשיר ובדיקות הקצאת הזיכרון עדיין חלות.",
"render.cpu_threads": "תהליכוני CPU",
"render.cpu_threads_auto": "אוטומטי",
"render.cpu_threads_custom": "מותאם",
"render.cpu_threads_help": "המצב האוטומטי משתמש בתהליכוני ה־CPU הזמינים. תהליכון יחיד פועל באופן סדרתי. תהליכונים נוספים עשויים לדרוש יותר זיכרון.",
"render.cpu_threads_single": "יחיד",
"render.cpu_threads_web": "רינדור בדפדפן משתמש בתהליכון שיתופי אחד. בחירת התהליכונים השמורה למחשב נשמרת.",
"render.denoise_class_background": "דולג: רקע",
"render.denoise_class_coverage": "דולג: כיסוי חלקי",
"render.denoise_class_degenerate": "דולג: מסלול מנוון",
"render.denoise_class_depth": "דולג: מגבלת עומק מסלול",
"render.denoise_class_emitter": "דולג: מקור אור",
"render.denoise_class_exit": "דולג: יציאה לא תואמת",
"render.denoise_class_identity": "דולג: מגבלת מזהי הנחיה",
"render.denoise_class_lens": "דולג: עומק שדה",
"render.denoise_class_material": "דולג: חומר לא נתמך",
"render.denoise_class_nested": "דולג: תווך מקונן",
"render.denoise_class_optical": "מתאים: משטח דרך שבירה",
"render.denoise_class_reflection": "דולג: נקודת סיום מחזירה",
"render.denoise_class_surface": "מתאים: משטח",
"render.denoise_class_tir": "דולג: החזרה פנימית מלאה",
"render.denoise_class_transmission": "דולג: העברה נוספת",
"render.denoise_class_unavailable": "דולג: אין נתוני הנחיה",
"render.denoise_class_volume": "דולג: פיזור נפחי",
"render.denoise_diagnostics_counts": "מתאימים: {eligible} · דולגו: {skipped}",
"render.denoise_diagnostics_enable": "איסוף נתוני אבחון להפחתת רעש",
"render.denoise_diagnostics_grid": "ספירת פיקסלים בגודל הפנימי {width} × {height}.",
"render.denoise_diagnostics_help": "נאספים מספרי הפיקסלים המתאימים ואלה שדולגו, גם כשהפחתת הרעש כבויה. הספירה לפי גודל הרינדור הפנימי; המסכה לפי גודל הפלט.",
"render.denoise_diagnostics_title": "אבחון הפחתת רעש",
"render.denoise_export_mask": "ייצוא המסכה כ־PNG",
"render.denoise_firefly": "דיכוי פיקסלים בהירים מבודדים",
"render.denoise_format_mask": "מסכה PNG",
"render.denoise_mask_copy_help": "אפשר לייצא את מסכת הפחתת הרעש כ־PNG, או לחזור לתמונה המרונדרת כדי להעתיק אותה.",
"render.denoise_mask_unavailable": "יש להפעיל אבחון הפחתת רעש ולרנדר תמונה כדי לבדוק את המסכה שלה.",
"render.denoise_optical_filter": "סינון מראות וזכוכית",
"render.denoise_optical_filter_help": "סינון אור מוחזר ומועבר שמתאים לסינון. כיבוי האפשרות מגביל את הסרת הרעש למשטחים אטומים.",
"render.denoise_passes": "מעברי סינון",
"render.denoise_settings_help": "מעברים נוספים מחליקים אזור רחב יותר. העוצמה קובעת כמה סינון מופעל בכל מעבר. מסלולים אופטיים שאינם נתמכים נשארים ללא סינון.",
"render.denoise_strength": "עוצמת הפחתת רעש",
"render.denoise_view_image": "תמונה מרונדרת",
"render.denoise_view_mask": "מסכת הפחתת רעש",
"render.quality_legacy_samples": "ערך שמור: {samples} דגימות לפיקסל. בחירות חדשות מוגבלות ל־{maximum} דגימות לפיקסל.",
"render.quality_preset_limit": "הגדרה קבועה זו חורגת ממגבלת הבחירה של 16384 דגימות לפיקסל. יש לבחור עד 16384 דגימות.",
"render.size_8k_uhd": "8K UHD",
"render.supersampling": "דגימת יתר",
"render.supersampling_dimensions": "רינדור בגודל {width} × {height} והקטנה ל־{output_width} × {output_height}. נעשה שימוש בפי ארבעה פיקסלים.",
"render.supersampling_help": "רינדור ברוחב ובגובה כפולים, ואז הקטנה במרחב אור ליניארי. גודל הפלט נשאר ללא שינוי.",
"render.supersampling_off": "כבוי",
"render.supersampling_x2": "2×"
},
"ja": {
"prefs.render_legacy_memory_admission": "以前のレンダリング用メモリチェックを使用",
"prefs.render_legacy_memory_admission_help": "推定使用量が使用可能なRAMを超えていても、共有およびホストの固定メモリ上限内でネイティブレンダリングを許可します。デバイスの制限とメモリ割り当てのチェックは引き続き適用されます。",
"render.cpu_threads": "CPU ワーカー",
"render.cpu_threads_auto": "自動",
"render.cpu_threads_custom": "カスタム",
"render.cpu_threads_help": "自動では利用可能な CPU ワーカーを使用します。1 ワーカーでは順次実行します。ワーカー数を増やすとメモリ使用量が増える場合があります。",
"render.cpu_threads_single": "1 ワーカー",
"render.cpu_threads_web": "ブラウザーでのレンダリングは単一の協調ワーカーを使用します。保存されたデスクトップ用ワーカー設定は保持されます。",
"render.denoise_class_background": "対象外: 背景",
"render.denoise_class_coverage": "対象外: 部分的なカバレッジ",
"render.denoise_class_degenerate": "対象外: 退化した経路",
"render.denoise_class_depth": "対象外: 経路深度の上限",
"render.denoise_class_emitter": "対象外: 発光体",
"render.denoise_class_exit": "対象外: 不整合な出射",
"render.denoise_class_identity": "対象外: ガイド識別子の上限",
"render.denoise_class_lens": "対象外: 被写界深度",
"render.denoise_class_material": "対象外: 未対応のマテリアル",
"render.denoise_class_nested": "対象外: 入れ子の媒質",
"render.denoise_class_optical": "対象: 屈折越しのサーフェス",
"render.denoise_class_reflection": "対象外: 反射する終点",
"render.denoise_class_surface": "対象: サーフェス",
"render.denoise_class_tir": "対象外: 全反射",
"render.denoise_class_transmission": "対象外: 追加の透過",
"render.denoise_class_unavailable": "対象外: ガイドなし",
"render.denoise_class_volume": "対象外: ボリューム散乱",
"render.denoise_diagnostics_counts": "対象: {eligible} · 対象外: {skipped}",
"render.denoise_diagnostics_enable": "ノイズ除去の診断情報を収集",
"render.denoise_diagnostics_grid": "内部サイズ {width} × {height} でのピクセル数。",
"render.denoise_diagnostics_help": "ノイズ除去がオフでも、対象と対象外のピクセル数を収集します。集計は内部レンダリングサイズ、マスクは出力サイズに対応します。",
"render.denoise_diagnostics_title": "ノイズ除去の診断",
"render.denoise_export_mask": "マスク PNG を書き出す",
"render.denoise_firefly": "孤立した高輝度ピクセルを抑制",
"render.denoise_format_mask": "マスク PNG",
"render.denoise_mask_copy_help": "ノイズ除去マスクを PNG で書き出すか、レンダリング画像に戻ってコピーしてください。",
"render.denoise_mask_unavailable": "ノイズ除去の診断を有効にして画像をレンダリングすると、マスクを確認できます。",
"render.denoise_optical_filter": "鏡とガラスをフィルター処理",
"render.denoise_optical_filter_help": "対象となる反射光と透過光にフィルターを適用します。オフにすると、不透明な表面のみのノイズを除去します。",
"render.denoise_passes": "フィルターパス数",
"render.denoise_settings_help": "パス数を増やすと、より広い範囲が平滑化されます。強度は各パスでのフィルター適用量を調整します。未対応の光路にはフィルターを適用しません。",
"render.denoise_strength": "ノイズ除去の強度",
"render.denoise_view_image": "レンダリング画像",
"render.denoise_view_mask": "ノイズ除去マスク",
"render.quality_legacy_samples": "保存値: {samples} spp。新しい選択値の上限は {maximum} spp です。",
"render.quality_preset_limit": "このプリセットは選択上限の 16384 spp を超えています。16384 以下のサンプル数を選択してください。",
"render.size_8k_uhd": "8K UHD",
"render.supersampling": "スーパーサンプリング",
"render.supersampling_dimensions": "{width} × {height} でレンダリングし、{output_width} × {output_height} に縮小します。ピクセル数は 4 倍になります。",
"render.supersampling_help": "幅と高さをそれぞれ 2 倍にしてレンダリングし、リニアライトで縮小します。出力サイズは変わりません。",
"render.supersampling_off": "オフ",
"render.supersampling_x2": "2×"
},
"vi": {
"prefs.render_legacy_memory_admission": "Dùng cách kiểm tra bộ nhớ kết xuất trước đây",
"prefs.render_legacy_memory_admission_help": "Cho phép kết xuất gốc trong giới hạn bộ nhớ dùng chung và của máy chủ cố định, ngay cả khi mức ước tính vượt quá RAM khả dụng. Giới hạn thiết bị và kiểm tra cấp phát bộ nhớ vẫn được áp dụng.",
"render.cpu_threads": "Luồng CPU",
"render.cpu_threads_auto": "Tự động",
"render.cpu_threads_custom": "Tùy chỉnh",
"render.cpu_threads_help": "Chế độ tự động dùng các luồng CPU khả dụng. Một luồng chạy tuần tự. Nhiều luồng hơn có thể cần nhiều bộ nhớ hơn.",
"render.cpu_threads_single": "Một luồng",
"render.cpu_threads_web": "Kết xuất trong trình duyệt dùng một luồng phối hợp. Lựa chọn luồng đã lưu cho ứng dụng máy tính được giữ nguyên.",
"render.denoise_class_background": "Bỏ qua: nền",
"render.denoise_class_coverage": "Bỏ qua: độ phủ một phần",
"render.denoise_class_degenerate": "Bỏ qua: đường truyền suy biến",
"render.denoise_class_depth": "Bỏ qua: giới hạn độ sâu đường truyền",
"render.denoise_class_emitter": "Bỏ qua: nguồn phát sáng",
"render.denoise_class_exit": "Bỏ qua: điểm thoát không tương thích",
"render.denoise_class_identity": "Bỏ qua: giới hạn định danh hướng dẫn",
"render.denoise_class_lens": "Bỏ qua: độ sâu trường ảnh",
"render.denoise_class_material": "Bỏ qua: vật liệu không hỗ trợ",
"render.denoise_class_nested": "Bỏ qua: môi trường lồng nhau",
"render.denoise_class_optical": "Đủ điều kiện: bề mặt qua khúc xạ",
"render.denoise_class_reflection": "Bỏ qua: điểm cuối phản xạ",
"render.denoise_class_surface": "Đủ điều kiện: bề mặt",
"render.denoise_class_tir": "Bỏ qua: phản xạ toàn phần",
"render.denoise_class_transmission": "Bỏ qua: truyền qua tiếp",
"render.denoise_class_unavailable": "Bỏ qua: không có dữ liệu hướng dẫn",
"render.denoise_class_volume": "Bỏ qua: tán xạ thể tích",
"render.denoise_diagnostics_counts": "Đủ điều kiện: {eligible} · Bỏ qua: {skipped}",
"render.denoise_diagnostics_enable": "Thu thập chẩn đoán khử nhiễu",
"render.denoise_diagnostics_grid": "Số pixel ở kích thước nội bộ {width} × {height}.",
"render.denoise_diagnostics_help": "Thu thập số pixel đủ điều kiện và bị bỏ qua ngay cả khi tắt khử nhiễu. Số lượng dựa trên kích thước kết xuất nội bộ; mặt nạ dùng kích thước đầu ra.",
"render.denoise_diagnostics_title": "Chẩn đoán khử nhiễu",
"render.denoise_export_mask": "Xuất mặt nạ PNG",
"render.denoise_firefly": "Giảm các pixel sáng đơn lẻ",
"render.denoise_format_mask": "PNG mặt nạ",
"render.denoise_mask_copy_help": "Xuất mặt nạ khử nhiễu dưới dạng PNG, hoặc quay lại ảnh đã kết xuất để sao chép.",
"render.denoise_mask_unavailable": "Bật chẩn đoán khử nhiễu và kết xuất ảnh để kiểm tra mặt nạ.",
"render.denoise_optical_filter": "Lọc gương và kính",
"render.denoise_optical_filter_help": "Lọc ánh sáng phản xạ và truyền qua đủ điều kiện. Tắt để chỉ khử nhiễu các bề mặt không trong suốt.",
"render.denoise_passes": "Số lượt lọc",
"render.denoise_settings_help": "Nhiều lượt lọc hơn làm mịn vùng rộng hơn. Cường độ điều chỉnh mức lọc áp dụng trong mỗi lượt. Các đường truyền quang học không được hỗ trợ vẫn không được lọc.",
"render.denoise_strength": "Cường độ khử nhiễu",
"render.denoise_view_image": "Ảnh đã kết xuất",
"render.denoise_view_mask": "Mặt nạ khử nhiễu",
"render.quality_legacy_samples": "Giá trị đã lưu: {samples} mẫu/pixel. Lựa chọn mới được giới hạn ở {maximum} mẫu/pixel.",
"render.quality_preset_limit": "Thiết lập sẵn này vượt quá giới hạn 16384 mẫu/pixel. Hãy chọn số mẫu tối đa là 16384.",
"render.size_8k_uhd": "8K UHD",
"render.supersampling": "Lấy mẫu siêu phân giải",
"render.supersampling_dimensions": "Kết xuất ở {width} × {height} rồi thu nhỏ về {output_width} × {output_height}. Sử dụng số pixel gấp bốn lần.",
"render.supersampling_help": "Kết xuất với chiều rộng và chiều cao gấp đôi, rồi thu nhỏ trong không gian ánh sáng tuyến tính. Kích thước đầu ra không đổi.",
"render.supersampling_off": "Tắt",
"render.supersampling_x2": "2×"
},
"zh": {
"prefs.render_legacy_memory_admission": "使用以前的渲染内存检查",
"prefs.render_legacy_memory_admission_help": "即使估算用量超过可用RAM,也允许在固定的共享内存和主机内存限额内进行原生渲染。设备限制和内存分配检查仍然适用。",
"render.cpu_threads": "CPU 工作线程",
"render.cpu_threads_auto": "自动",
"render.cpu_threads_custom": "自定义",
"render.cpu_threads_help": "自动模式使用可用的 CPU 工作线程。单线程按顺序执行。更多线程可能需要更多内存。",
"render.cpu_threads_single": "单线程",
"render.cpu_threads_web": "浏览器渲染使用一个协作式工作线程。已保存的桌面线程选择会保留。",
"render.denoise_class_background": "已跳过:背景",
"render.denoise_class_coverage": "已跳过:部分覆盖",
"render.denoise_class_degenerate": "已跳过:退化路径",
"render.denoise_class_depth": "已跳过:路径深度上限",
"render.denoise_class_emitter": "已跳过:发光体",
"render.denoise_class_exit": "已跳过:不兼容的出射",
"render.denoise_class_identity": "已跳过:引导标识上限",
"render.denoise_class_lens": "已跳过:景深",
"render.denoise_class_material": "已跳过:不支持的材质",
"render.denoise_class_nested": "已跳过:嵌套介质",
"render.denoise_class_optical": "可滤波:折射后的表面",
"render.denoise_class_reflection": "已跳过:反射终点",
"render.denoise_class_surface": "可滤波:表面",
"render.denoise_class_tir": "已跳过:全内反射",
"render.denoise_class_transmission": "已跳过:进一步透射",
"render.denoise_class_unavailable": "已跳过:无引导数据",
"render.denoise_class_volume": "已跳过:体积散射",
"render.denoise_diagnostics_counts": "可滤波:{eligible} · 已跳过:{skipped}",
"render.denoise_diagnostics_enable": "收集降噪诊断信息",
"render.denoise_diagnostics_grid": "内部尺寸 {width} × {height} 下的像素计数。",
"render.denoise_diagnostics_help": "即使关闭降噪,也会收集可滤波和跳过的像素数量。计数基于内部渲染尺寸;蒙版使用输出尺寸。",
"render.denoise_diagnostics_title": "降噪诊断",
"render.denoise_export_mask": "导出蒙版 PNG",
"render.denoise_firefly": "抑制孤立亮点",
"render.denoise_format_mask": "蒙版 PNG",
"render.denoise_mask_copy_help": "将降噪蒙版导出为 PNG,或返回渲染图像后复制图像。",
"render.denoise_mask_unavailable": "启用降噪诊断并渲染图像后,即可查看其蒙版。",
"render.denoise_optical_filter": "过滤镜面和玻璃",
"render.denoise_optical_filter_help": "对符合条件的反射光和透射光进行过滤。关闭后,仅对不透明表面降噪。",
"render.denoise_passes": "滤波轮数",
"render.denoise_settings_help": "更多轮次会平滑更大范围。强度控制每轮滤波的应用程度。不支持的光路保持未滤波状态。",
"render.denoise_strength": "降噪强度",
"render.denoise_view_image": "渲染图像",
"render.denoise_view_mask": "降噪蒙版",
"render.quality_legacy_samples": "已保存的值:{samples} spp。新选择的上限为 {maximum} spp。",
"render.quality_preset_limit": "此预设超过 16384 spp 的选择上限。请选择不超过 16384 的采样数。",
"render.size_8k_uhd": "8K UHD",
"render.supersampling": "超采样",
"render.supersampling_dimensions": "以 {width} × {height} 渲染,再缩小至 {output_width} × {output_height}。使用四倍像素。",
"render.supersampling_help": "以双倍宽度和高度渲染,然后在线性光空间中缩小。输出尺寸保持不变。",
"render.supersampling_off": "关闭",
"render.supersampling_x2": "2×"
},
"de": {
"prefs.render_legacy_memory_admission": "Bisherige Render-Speicherprüfung verwenden",
"prefs.render_legacy_memory_admission_help": "Native Renderaufträge innerhalb der festen gemeinsamen und hostspezifischen Speichergrenzen zulassen, auch wenn der geschätzte Bedarf den verfügbaren RAM übersteigt. Gerätegrenzen und Prüfungen der Speicherzuweisung gelten weiterhin.",
"render.cpu_threads": "CPU-Threads",
"render.cpu_threads_auto": "Auto",
"render.cpu_threads_custom": "Eigene",
"render.cpu_threads_help": "Auto verwendet die verfügbaren CPU-Threads. Ein Thread arbeitet seriell. Mehr Threads können mehr Speicher benötigen.",
"render.cpu_threads_single": "Einzeln",
"render.cpu_threads_web": "Im Browser wird ein kooperativer Thread verwendet. Die gespeicherte Desktop-Auswahl bleibt erhalten.",
"render.denoise_class_background": "Übersprungen: Hintergrund",
"render.denoise_class_coverage": "Übersprungen: teilweise Abdeckung",
"render.denoise_class_degenerate": "Übersprungen: entarteter Pfad",
"render.denoise_class_depth": "Übersprungen: Pfadtiefengrenze",
"render.denoise_class_emitter": "Übersprungen: Lichtquelle",
"render.denoise_class_exit": "Übersprungen: unpassender Austritt",
"render.denoise_class_identity": "Übersprungen: Grenze der Führungskennungen",
"render.denoise_class_lens": "Übersprungen: Schärfentiefe",
"render.denoise_class_material": "Übersprungen: nicht unterstütztes Material",
"render.denoise_class_nested": "Übersprungen: verschachteltes Medium",
"render.denoise_class_optical": "Geeignet: Oberfläche durch Brechung",
"render.denoise_class_reflection": "Übersprungen: reflektierender Endpunkt",
"render.denoise_class_surface": "Geeignet: Oberfläche",
"render.denoise_class_tir": "Übersprungen: Totalreflexion",
"render.denoise_class_transmission": "Übersprungen: weitere Transmission",
"render.denoise_class_unavailable": "Übersprungen: keine Führungsdaten",
"render.denoise_class_volume": "Übersprungen: Volumenstreuung",
"render.denoise_diagnostics_counts": "Geeignet: {eligible} · Übersprungen: {skipped}",
"render.denoise_diagnostics_enable": "Entrauschungsdiagnose erfassen",
"render.denoise_diagnostics_grid": "Pixelzahlen bei interner Größe {width} × {height}.",
"render.denoise_diagnostics_help": "Erfasst geeignete und übersprungene Pixel auch ohne Entrauschung. Die Zählung nutzt die interne Rendergröße, die Maske die Ausgabegröße.",
"render.denoise_diagnostics_title": "Entrauschungsdiagnose",
"render.denoise_export_mask": "Maske als PNG exportieren",
"render.denoise_firefly": "Einzelne helle Pixel unterdrücken",
"render.denoise_format_mask": "Maske PNG",
"render.denoise_mask_copy_help": "Exportieren Sie die Entrauschungsmaske als PNG oder kehren Sie zum gerenderten Bild zurück, um es zu kopieren.",
"render.denoise_mask_unavailable": "Aktivieren Sie die Entrauschungsdiagnose und rendern Sie ein Bild, um seine Maske zu prüfen.",
"render.denoise_optical_filter": "Spiegel und Glas filtern",
"render.denoise_optical_filter_help": "Geeignetes reflektiertes und durchgelassenes Licht filtern. Ausschalten, um nur undurchsichtige Oberflächen zu entrauschen.",
"render.denoise_passes": "Filterdurchläufe",
"render.denoise_settings_help": "Mehr Durchläufe glätten einen größeren Bereich. Die Stärke regelt die Filterwirkung jedes Durchlaufs. Nicht unterstützte Lichtwege bleiben ungefiltert.",
"render.denoise_strength": "Entrauschungsstärke",
"render.denoise_view_image": "Gerendertes Bild",
"render.denoise_view_mask": "Entrauschungsmaske",
"render.quality_legacy_samples": "Gespeicherter Wert: {samples} spp. Neue Werte sind auf {maximum} spp begrenzt.",
"render.quality_preset_limit": "Diese Vorgabe überschreitet die Auswahlgrenze von 16384 spp. Wählen Sie höchstens 16384 Samples.",
"render.size_8k_uhd": "8K UHD",
"render.supersampling": "Supersampling",
"render.supersampling_dimensions": "Rendert {width} × {height} und verkleinert auf {output_width} × {output_height}. Verwendet die vierfache Pixelzahl.",
"render.supersampling_help": "Mit doppelter Breite und Höhe rendern und anschließend im linearen Farbraum verkleinern. Die Ausgabegröße bleibt unverändert.",
"render.supersampling_off": "Aus",
"render.supersampling_x2": "2×"
},
"ko": {
"prefs.render_legacy_memory_admission": "이전 렌더링 메모리 검사 사용",
"prefs.render_legacy_memory_admission_help": "예상 사용량이 사용 가능한 RAM을 초과해도 고정된 공유 및 호스트 메모리 한도 내에서 네이티브 렌더링을 허용합니다. 장치 제한과 메모리 할당 검사는 계속 적용됩니다.",
"render.cpu_threads": "CPU 작업 스레드",
"render.cpu_threads_auto": "자동",
"render.cpu_threads_custom": "사용자 지정",
"render.cpu_threads_help": "자동은 사용 가능한 CPU 작업 스레드를 사용합니다. 단일 스레드는 순차적으로 실행됩니다. 스레드가 많을수록 메모리가 더 필요할 수 있습니다.",
"render.cpu_threads_single": "단일",
"render.cpu_threads_web": "브라우저 렌더링은 하나의 협력형 작업 스레드를 사용합니다. 저장된 데스크톱 스레드 설정은 유지됩니다.",
"render.denoise_class_background": "건너뜀: 배경",
"render.denoise_class_coverage": "건너뜀: 부분 커버리지",
"render.denoise_class_degenerate": "건너뜀: 퇴화 경로",
"render.denoise_class_depth": "건너뜀: 경로 깊이 한도",
"render.denoise_class_emitter": "건너뜀: 발광체",
"render.denoise_class_exit": "건너뜀: 호환되지 않는 출사",
"render.denoise_class_identity": "건너뜀: 가이드 식별자 한도",
"render.denoise_class_lens": "건너뜀: 피사계 심도",
"render.denoise_class_material": "건너뜀: 지원되지 않는 재질",
"render.denoise_class_nested": "건너뜀: 중첩된 매질",
"render.denoise_class_optical": "적용 가능: 굴절을 통해 보이는 표면",
"render.denoise_class_reflection": "건너뜀: 반사 종점",
"render.denoise_class_surface": "적용 가능: 표면",
"render.denoise_class_tir": "건너뜀: 전반사",
"render.denoise_class_transmission": "건너뜀: 추가 투과",
"render.denoise_class_unavailable": "건너뜀: 가이드 없음",
"render.denoise_class_volume": "건너뜀: 볼륨 산란",
"render.denoise_diagnostics_counts": "적용 가능: {eligible} · 건너뜀: {skipped}",
"render.denoise_diagnostics_enable": "노이즈 제거 진단 수집",
"render.denoise_diagnostics_grid": "내부 크기 {width} × {height} 기준 픽셀 수입니다.",
"render.denoise_diagnostics_help": "노이즈 제거가 꺼져 있어도 적용 가능 픽셀과 건너뛴 픽셀 수를 수집합니다. 집계는 내부 렌더링 크기 기준이며 마스크는 출력 크기를 사용합니다.",
"render.denoise_diagnostics_title": "노이즈 제거 진단",
"render.denoise_export_mask": "마스크 PNG 내보내기",
"render.denoise_firefly": "고립된 밝은 픽셀 억제",
"render.denoise_format_mask": "마스크 PNG",
"render.denoise_mask_copy_help": "노이즈 제거 마스크를 PNG로 내보내거나 렌더링 이미지로 돌아가 이미지를 복사하세요.",
"render.denoise_mask_unavailable": "노이즈 제거 진단을 켜고 이미지를 렌더링하면 마스크를 확인할 수 있습니다.",
"render.denoise_optical_filter": "거울과 유리 필터링",
"render.denoise_optical_filter_help": "필터링 가능한 반사광과 투과광을 처리합니다. 끄면 불투명한 표면의 노이즈만 제거합니다.",
"render.denoise_passes": "필터 반복 횟수",
"render.denoise_settings_help": "반복 횟수가 많을수록 더 넓은 영역을 부드럽게 합니다. 강도는 각 단계에서 적용하는 필터의 정도를 조절합니다. 지원되지 않는 광 경로에는 필터를 적용하지 않습니다.",
"render.denoise_strength": "노이즈 제거 강도",
"render.denoise_view_image": "렌더링 이미지",
"render.denoise_view_mask": "노이즈 제거 마스크",
"render.quality_legacy_samples": "저장된 값: {samples} spp. 새 선택값은 {maximum} spp로 제한됩니다.",
"render.quality_preset_limit": "이 프리셋은 선택 한도인 16384 spp를 초과합니다. 샘플 수를 16384 이하로 선택하세요.",
"render.size_8k_uhd": "8K UHD",
"render.supersampling": "슈퍼샘플링",
"render.supersampling_dimensions": "{width} × {height}로 렌더링한 뒤 {output_width} × {output_height}로 축소합니다. 픽셀 수가 네 배가 됩니다.",
"render.supersampling_help": "가로와 세로를 각각 두 배로 렌더링한 뒤 선형 광 공간에서 축소합니다. 출력 크기는 그대로 유지됩니다.",
"render.supersampling_off": "끄기",
"render.supersampling_x2": "2×"
}
}
+1 -3
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "Dừng lịch sử",
"cmd.timeline_suppress": "Tạm nén tính năng",
"cmd.ui.assistant": "Bật/tắt trợ lý",
"cmd.ui.palette": "Tìm kiếm lệnh",
"cmd.view.navigator": "Khối điều hướng góc nhìn tùy chỉnh",
"cmd.view.render_browser": "Trình duyệt kết xuất",
"cmd.view.render_dock": "Di chuyển neo kết xuất",
@@ -2432,7 +2431,6 @@
"parts.dimension.width": "Bề rộng",
"parts.family.bearing": "Ổ bi cầu rãnh sâu (bao ngoài)",
"parts.family.button_head_cap_screw": "Vít đầu tròn lục giác chìm",
"parts.family.countersunk_socket_screw": "Vít đầu chìm lục giác chìm",
"parts.family.dowel_pin": "Chốt trụ",
"parts.family.hex_bolt": "Bu lông đầu lục giác",
"parts.family.hex_cap_screw_inch": "Bu lông đầu lục giác (inch)",
@@ -3400,7 +3398,7 @@
"render.cpu_opaque_required": "Dò tia trên CPU yêu cầu nền đục. Hãy bỏ chọn 'Nền trong suốt' để tiếp tục.",
"render.cpu_options_pending": "Bảng điều khiển CPU này chưa hỗ trợ chỉnh sửa tự phát xạ, hiệu ứng bề mặt, thủy tinh hoặc bất đẳng hướng. Hãy xóa các tùy chọn API đó trước khi sử dụng.",
"render.cpu_path_depth": "Độ sâu đường truyền",
"render.cpu_path_help": "Tính ánh sáng phản xạ giữa các bề mặt đục và truyền qua các hình kính được hỗ trợ. Cần kết xuất lũy tiến và bóng dò tia. Độ sâu đường đi đếm các bề mặt được chiếu sáng, ranh giới kính và các sự kiện tán xạ từ giao điểm đầu tiên (1–64). Độ sâu 1 chỉ chiếu sáng trực tiếp bề mặt đục; giá trị cao hơn cho phép thêm lần phản xạ hoặc xuyên kính và có thể lâu hơn. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
"render.cpu_path_help": "Tính ánh sáng phản xạ giữa các bề mặt đục và truyền qua các hình kính được hỗ trợ. Cần kết xuất lũy tiến và bóng dò tia. Độ sâu đường đi đếm các bề mặt được chiếu sáng, ranh giới kính và các sự kiện tán xạ từ giao điểm đầu tiên (1–16). Độ sâu 1 chỉ chiếu sáng trực tiếp bề mặt đục; giá trị cao hơn cho phép thêm lần phản xạ hoặc xuyên kính và có thể lâu hơn. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
"render.cpu_path_lighting": "Chiếu sáng gián tiếp",
"render.cpu_path_optics_reason": "Các thiết lập được giữ nguyên. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
"render.cpu_point": "Nguồn sáng điểm",
+1 -3
View File
@@ -1509,7 +1509,6 @@
"cmd.timeline_stop": "停止历史播放",
"cmd.timeline_suppress": "抑制特征",
"cmd.ui.assistant": "切换助手显示",
"cmd.ui.palette": "搜索命令",
"cmd.view.navigator": "自定义视图导航器",
"cmd.view.render_browser": "渲染浏览器",
"cmd.view.render_dock": "移动渲染停靠面板",
@@ -2432,7 +2431,6 @@
"parts.dimension.width": "宽度",
"parts.family.bearing": "深沟球轴承(外形)",
"parts.family.button_head_cap_screw": "圆头内六角螺钉",
"parts.family.countersunk_socket_screw": "沉头内六角螺钉",
"parts.family.dowel_pin": "圆柱销",
"parts.family.hex_bolt": "六角头螺栓",
"parts.family.hex_cap_screw_inch": "六角头螺栓(英制)",
@@ -3400,7 +3398,7 @@
"render.cpu_opaque_required": "CPU 光线追踪需要不透明背景。请关闭透明背景。",
"render.cpu_options_pending": "此CPU面板暂不支持编辑自发光、表面效果、玻璃或各向异性。使用前请清除这些API选项。",
"render.cpu_path_depth": "路径深度",
"render.cpu_path_help": "计算在不透明表面之间反射并穿过受支持玻璃形状的光。需要渐进渲染和光线追踪阴影。路径深度从首次相交开始计算着色表面、玻璃边界和散射事件(1–64)。深度 1 只计算不透明表面的直接照明;更高的值允许更多反射或玻璃穿越,也可能耗时更长。使用自定义反射或各向异性时,请关闭间接照明。",
"render.cpu_path_help": "计算在不透明表面之间反射并穿过受支持玻璃形状的光。需要渐进渲染和光线追踪阴影。路径深度从首次相交开始计算着色表面、玻璃边界和散射事件(1–16)。深度 1 只计算不透明表面的直接照明;更高的值允许更多反射或玻璃穿越,也可能耗时更长。使用自定义反射或各向异性时,请关闭间接照明。",
"render.cpu_path_lighting": "间接照明",
"render.cpu_path_optics_reason": "设置会保留。使用自定义反射或各向异性时,请关闭间接照明。",
"render.cpu_point": "点光源",
-64
View File
@@ -1,64 +0,0 @@
//! CSV text fields, as every SolidCraft export writes them and the parameter import reads them.
//!
//! A spreadsheet reads a field that starts with `=`, `+`, `-` or `@` as a formula, so a part
//! numbered `=1+1` would show `2`. A field like that is written with a `'` first, which
//! spreadsheets show as text (OWASP's CSV Injection page). [`unguard`] takes the `'` off again
//! when SolidCraft reads its own file back.
use std::borrow::Cow;
/// The first characters that make a spreadsheet read a field as a formula, from OWASP's list.
/// The full-width forms act as formulas in some locales, such as Japanese, so they are here too.
const FORMULA_STARTS: [char; 11] = ['=', '+', '-', '@', '\t', '\r', '\n', '=', '+', '-', '@'];
/// What makes a field need quotes: a separator (`;` as well as `,`, since a spreadsheet set to
/// `;` splits there and the new cell could start a formula), a quote, a tab or a line break.
const NEEDS_QUOTES: [char; 6] = [',', ';', '"', '\t', '\r', '\n'];
/// A text field, quoted only when it needs quotes.
pub fn field(text: &str) -> String {
let text = guard(text);
if text.contains(NEEDS_QUOTES) { quote(&text) } else { text.into_owned() }
}
/// A text field, always in quotes.
pub fn quoted(text: &str) -> String {
quote(&guard(text))
}
/// A field as SolidCraft wrote it, with the `'` that [`field`] or [`quoted`] put first taken off.
/// A field without one is returned as it is.
pub fn unguard(text: &str) -> &str {
match text.strip_prefix('\'') {
Some(rest) if starts_a_formula(rest) => rest,
_ => text,
}
}
/// `text` with a `'` first when a spreadsheet would read it as a formula. A field that already
/// starts with `'` and then a formula gets one more, so [`unguard`] gives back exactly what was
/// written. A whole plain number (`-5`, `+1e-9`) is read as that number, not as a formula, so it
/// is left as it is.
fn guard(text: &str) -> Cow<'_, str> {
if starts_a_formula(text) && !plain_number(text) { Cow::Owned(format!("'{text}")) } else { Cow::Borrowed(text) }
}
/// Whether `text`, after any `'` and spaces it starts with, starts as a formula does. Spaces count
/// because a spreadsheet may take them off as it reads (LibreOffice's "Trim spaces").
fn starts_a_formula(text: &str) -> bool {
text.trim_start_matches(['\'', ' ']).starts_with(FORMULA_STARTS)
}
/// A finite number and nothing else, spaces included. Rust's `f64` parsing also takes `inf`,
/// `infinity` and `NaN` in any case, and none of them is finite.
fn plain_number(text: &str) -> bool {
text.parse::<f64>().is_ok_and(f64::is_finite)
}
fn quote(text: &str) -> String {
format!("\"{}\"", text.replace('"', "\"\""))
}
#[cfg(test)]
#[path = "csv_tests.rs"]
mod tests;
-89
View File
@@ -1,89 +0,0 @@
use super::{FORMULA_STARTS, NEEDS_QUOTES, field, quoted, unguard};
#[test]
fn a_field_a_spreadsheet_would_run_gets_an_apostrophe() {
for (text, written) in [
("=1+1", "'=1+1"),
("+2 for the fit", "'+2 for the fit"),
("-5 mm shim", "'-5 mm shim"),
("@SUM(1,1)", "\"'@SUM(1,1)\""),
("=1+1", "'=1+1"),
("+2", "'+2"),
("-5", "'-5"),
("@x", "'@x"),
("\tx", "\"'\tx\""),
("\rx", "\"'\rx\""),
("\nx", "\"'\nx\""),
// Already an apostrophe, then a formula: one more, so the import gives back the original.
("'=x", "''=x"),
("''-x", "'''-x"),
// Spaces before a formula count: a spreadsheet may take them off.
(" =x", "' =x"),
(" -5", "' -5"),
("' @x", "'' @x"),
] {
assert_eq!(field(text), written, "{text:?}");
assert_eq!(unguard(written.trim_matches('"')), text, "{written:?}");
}
}
#[test]
fn a_whole_plain_number_stays_a_number() {
for number in ["-5", "+5", "-0", "0", "1e-9", "-1.5E+3", ".5", "-.5", "5.", "+1e5", "-12.000000000"] {
assert_eq!(field(number), number, "{number:?}");
}
// Not plain numbers: Rust's parse takes the words, spaces aren't a number's, and one too large isn't finite.
for text in ["-inf", "+inf", "-infinity", "-NaN", "+nan", "-", "+", "--5", "-5 mm", "-5 ", "-1e400", "-0x10", "-5,5"] {
assert!(field(text).trim_start_matches('"').starts_with('\''), "{text:?}: {}", field(text));
}
}
#[test]
fn other_fields_are_left_as_they_are() {
for text in ["", "Plate", "PL-100", "a=b", "x+1", "'quoted' word", "'", "''", "5 mm", " 5", "é=", "μ-5"] {
assert_eq!(field(text), text, "{text:?}");
assert_eq!(unguard(text), text, "{text:?}");
}
}
#[test]
fn a_field_with_a_separator_a_quote_or_a_break_is_quoted() {
assert_eq!(field("Plate, 3 mm"), "\"Plate, 3 mm\"");
assert_eq!(field("one; two"), "\"one; two\"");
assert_eq!(field("Pin \"short\""), "\"Pin \"\"short\"\"\"");
assert_eq!(field("a\tb"), "\"a\tb\"");
assert_eq!(field("line one\r\nline two"), "\"line one\r\nline two\"");
assert_eq!(quoted("Plate"), "\"Plate\"");
assert_eq!(quoted("=A1"), "\"'=A1\"");
assert_eq!(quoted("-5"), "\"-5\"");
}
/// Random fields from an alphabet heavy in what matters: every formula start, apostrophes, quotes,
/// separators, spaces, digits and the letters of `inf` and `NaN`. Each one reads back exactly, never starts
/// a formula once written unless it is a whole number, and is quoted whenever it holds a separator.
#[test]
fn fuzz_every_field_reads_back_and_never_starts_a_formula() {
let mut alphabet: Vec<char> = "'''\"\";,.eE0123456789 infaNxé".chars().collect();
alphabet.extend(FORMULA_STARTS);
let mut seed: u64 = 0x9e37_79b9_7f4a_7c15;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
for _ in 0..50_000 {
let n = (rnd() % 12) as usize;
let text: String = (0..n).filter_map(|_| alphabet.get((rnd() % alphabet.len() as u64) as usize)).collect();
let written = field(&text);
let inner = match written.strip_prefix('"').and_then(|w| w.strip_suffix('"')) {
Some(inner) => inner.replace("\"\"", "\""),
None => written.clone(),
};
assert_eq!(unguard(&inner), text, "{text:?} written as {written:?}");
let bare = inner.trim_start_matches(' ');
assert!(!bare.starts_with(FORMULA_STARTS) || text.parse::<f64>().is_ok_and(f64::is_finite), "{text:?} written as {written:?}");
assert!(!text.contains(NEEDS_QUOTES) || written.starts_with('"'), "{text:?} written as {written:?}");
assert_eq!(quoted(&text), format!("\"{}\"", inner.replace('"', "\"\"")), "{text:?}");
}
}
-34
View File
@@ -2294,10 +2294,6 @@ pub struct MeshData {
pub positions: std::sync::Arc<[f64]>,
/// Three vertex indices per triangle.
pub triangles: std::sync::Arc<[u32]>,
/// Unit shading normals, x/y/z for each triangle corner. Empty means flat shading.
/// Independent of the welded positions and the geometric boundary certificate.
#[serde(default, skip_serializing_if = "<[f64]>::is_empty")]
pub corner_normals: std::sync::Arc<[f64]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<[f32; 3]>,
}
@@ -2313,36 +2309,6 @@ impl PartialEq for MeshData {
&& self.color == other.color
&& (Arc::ptr_eq(&self.triangles, &other.triangles) || self.triangles == other.triangles)
&& (Arc::ptr_eq(&self.positions, &other.positions) || self.positions == other.positions)
&& (Arc::ptr_eq(&self.corner_normals, &other.corner_normals) || self.corner_normals == other.corner_normals)
}
}
impl MeshData {
/// Explicitly generate crease-aware shading for an existing imported source.
/// Coordinates, triangle indices, topology and body identity remain unchanged.
pub fn generate_crease_normals(&mut self, angle_degrees: f64) -> crate::Result<()> {
if !angle_degrees.is_finite()
|| !(0.0..=180.0).contains(&angle_degrees)
|| !self.positions.len().is_multiple_of(3)
|| !self.triangles.len().is_multiple_of(3)
{
return Err(crate::DocError::Invalid("mesh crease angle must be between 0 and 180 degrees with complete triangles".into()));
}
let allocation = |_| crate::DocError::Invalid("could not reserve mesh normal generation".into());
let mut positions = Vec::new();
positions.try_reserve_exact(self.positions.len() / 3).map_err(allocation)?;
positions.extend(self.positions.as_chunks::<3>().0.iter().map(|p| solidcore_record::Vec3::new(p[0], p[1], p[2])));
let mut triangles = Vec::new();
triangles.try_reserve_exact(self.triangles.len() / 3).map_err(allocation)?;
triangles.extend_from_slice(self.triangles.as_chunks::<3>().0);
let mesh = solidcraft_geom::Mesh { positions, triangles, ..Default::default() };
let normals = mesh.crease_corner_normals(angle_degrees.to_radians(), None).map_err(crate::DocError::Invalid)?;
let count = normals.len().checked_mul(9).ok_or_else(|| crate::DocError::Invalid("mesh normal count overflow".into()))?;
let mut values = Vec::new();
values.try_reserve_exact(count).map_err(allocation)?;
values.extend(normals.iter().flatten().flat_map(|n| [n.x, n.y, n.z]));
self.corner_normals = values.into();
Ok(())
}
}
+37 -250
View File
@@ -1163,9 +1163,6 @@ pub struct Model {
placement_local: bool,
/// A failed sketch shown while it is edited ([`Model::hold_failed_sketch`]).
held: Option<HeldSketch>,
/// The [`placement_digest`] each [`pose_only`] feature was last evaluated at, by feature id.
/// Their fingerprints leave it out, so a pose change doesn't reach the features after them.
poses: std::collections::HashMap<u64, u64>,
}
/// A failed sketch built for its editor (#12085): `shown` is the final state `base` with that
@@ -1227,15 +1224,12 @@ struct ImportDigests {
struct MeshDigest {
positions: Arc<[f64]>,
triangles: Arc<[u32]>,
corner_normals: Arc<[f64]>,
digest: u64,
}
impl MeshDigest {
fn matches(&self, mesh: &crate::MeshData) -> bool {
Arc::ptr_eq(&self.positions, &mesh.positions)
&& Arc::ptr_eq(&self.triangles, &mesh.triangles)
&& Arc::ptr_eq(&self.corner_normals, &mesh.corner_normals)
Arc::ptr_eq(&self.positions, &mesh.positions) && Arc::ptr_eq(&self.triangles, &mesh.triangles)
}
}
@@ -1254,12 +1248,7 @@ impl ImportDigests {
.enumerate()
.map(|(index, mesh)| {
let digest = known.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh));
MeshDigest {
positions: mesh.positions.clone(),
triangles: mesh.triangles.clone(),
corner_normals: mesh.corner_normals.clone(),
digest,
}
MeshDigest { positions: mesh.positions.clone(), triangles: mesh.triangles.clone(), digest }
})
.collect();
out.meshes.insert(f.id, digests);
@@ -1288,9 +1277,6 @@ fn mesh_content_digest(mesh: &crate::MeshData) -> u64 {
coordinate.to_bits().hash(&mut h);
}
mesh.triangles.hash(&mut h);
for n in mesh.corner_normals.iter() {
n.to_bits().hash(&mut h);
}
h.finish()
}
@@ -1382,7 +1368,6 @@ fn feature_json(f: &Feature, steps: &ImportDigests) -> String {
digests.push(steps.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh)));
mesh.positions = Arc::from([]);
mesh.triangles = Arc::from([]);
mesh.corner_normals = Arc::from([]);
}
}
// Keep the complete definition (including mesh order, names and colors) without
@@ -1494,10 +1479,7 @@ fn chain_fingerprint(
steps: &ImportDigests,
st: Option<&ModelState>,
) -> u64 {
// A pose-only feature's placement is checked on its own ([`Model::poses`]), so moving the
// parts it reads doesn't change the fingerprint of every feature after it (#12139).
let placed = if pose_only(f) { 0 } else { placement_digest(doc, f, st) };
let mut fp = fingerprint(doc, prev, f, vals, rolled_back, steps) ^ placed;
let mut fp = fingerprint(doc, prev, f, vals, rolled_back, steps) ^ placement_digest(doc, f, st);
// Sheet metal features also depend on the rules (and the parameters they use).
if f.kind.is_sheet() {
let mut h = std::collections::hash_map::DefaultHasher::new();
@@ -1811,70 +1793,6 @@ fn mirror_placements_digest(doc: &Document, f: &Feature, placements: &[Vec<u64>]
h.finish()
}
/// Whether only the bodies `f` makes follow where parts are placed: a mirror of placed bodies
/// into new bodies. A feature after it reads its copies only by naming them, so a pose change
/// need not evaluate features that don't (#12139).
fn pose_only(f: &Feature) -> bool {
matches!(&f.kind, FeatureKind::Mirror { features, bodies, placements, combine: false, .. }
if features.is_empty() && !bodies.is_empty() && !placements.is_empty())
}
/// The new bodies of pose-only `f`, evaluated again (to `state`) for its pose alone: the result
/// `old` was on the same definitions and made bodies of the same names, and nothing besides.
fn pose_moved(old: Option<&FeatureResult>, f: &Feature, fp: u64, state: &ModelState) -> Option<Vec<ModelBody>> {
let old = old.filter(|o| o.id == f.id && o.fingerprint == fp && !o.stale && !o.skipped && !o.unkept && o.error.is_none())?;
let made = |st: &ModelState| st.bodies.iter().filter(|b| b.feature == f.id).cloned().collect::<Vec<_>>();
let (before, after) = (made(&old.state), made(state));
let names = |bodies: &[ModelBody]| bodies.iter().map(|b| b.name.clone()).collect::<std::collections::BTreeSet<_>>();
let same = names(&before) == names(&after)
&& old.state.bodies.len() == state.bodies.len()
&& old.state.threads.len() == state.threads.len()
&& old.state.sheets.len() == state.sheets.len()
&& old.state.construct.len() == state.construct.len();
same.then_some(after)
}
/// `old` (a result after the moved pose-only features) with their new bodies in place of theirs.
fn with_moved(old: &Arc<ModelState>, moved: &[(u64, Vec<ModelBody>)]) -> Arc<ModelState> {
let mut st = (**old).clone();
for b in &mut st.bodies {
if let Some(new) = moved.iter().filter(|(id, _)| *id == b.feature).flat_map(|(_, bodies)| bodies).find(|n| n.name == b.name) {
*b = new.clone();
}
}
Arc::new(st)
}
/// Whether `f` (starting from `st`) reads none of the bodies of `moved`: it reads no bodies, or
/// only bodies it names or would cut or join, none of them those. Anything else (a picked face,
/// a projection, To Object, ...) is taken as reading them.
fn reads_none_of(f: &Feature, st: &ModelState, moved: &[(u64, Vec<ModelBody>)], components: &std::collections::HashMap<u64, u64>) -> bool {
if !f.face_names.is_empty() || !f.edge_names.is_empty() || !f.edge_refs.is_empty() {
return false;
}
let reads: Vec<String> = match &f.kind {
_ if placement_reuse::local_geometry(f, components) => Vec::new(),
FeatureKind::Sketch { plane, sketch } if sketch.links.is_empty() && sketch.wires.is_empty() && placement_reuse::local_plane(plane, 0) => {
Vec::new()
}
FeatureKind::Mirror { features, bodies, plane, .. } if features.is_empty() && placement_reuse::local_plane(plane, 0) => {
bodies.iter().map(|b| b.0.clone()).collect()
}
FeatureKind::Extrude { extent, .. } if extent.to.is_some() || extent.from.is_some() => return false,
FeatureKind::Revolve { to: Some(_), .. } | FeatureKind::Revolve { axis: AxisRef::Model { .. }, .. } => return false,
kind if drawn_tools_change_bodies(kind) => {
let mut reads = PlacementReads::default();
reads.drawn_targets(Some(st), f);
if reads.all {
return false;
}
reads.bodies.into_iter().collect()
}
_ => return false,
};
!moved.iter().flat_map(|(_, bodies)| bodies).any(|b| reads.contains(&b.name))
}
fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64 {
// No shortcut for a design without placements: its first component (placed where the
// design is) must not change what its features read, or creating it evaluates them all.
@@ -2275,11 +2193,10 @@ impl Model {
let mut state = self.empty.clone();
doc.features.iter().zip(&self.results).enumerate().all(|(i, (f, r))| {
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &steps, Some(&state));
let posed = !pose_only(f) || self.poses.get(&f.id) == Some(&placement_digest(doc, f, Some(&state)));
if !r.skipped {
state = r.state.clone();
}
r.id == f.id && r.fingerprint == prev && posed
r.id == f.id && r.fingerprint == prev
})
}
@@ -2347,10 +2264,6 @@ impl Model {
feature_component.entry(f.id).or_insert(f.component);
}
let watched = crate::progress::begin(doc.features.len());
let mut poses = std::collections::HashMap::new();
// Pose-only features evaluated again for a pose change alone: their new bodies, which
// replace the old ones in the results reused after them (#12139).
let mut moved: Vec<(u64, Vec<ModelBody>)> = Vec::new();
// The features that failed so far in this pass, by id: their names and errors (#9075).
let mut failed: std::collections::HashMap<u64, (String, String)> = std::collections::HashMap::new();
for (i, f) in doc.features.iter().enumerate() {
@@ -2379,12 +2292,7 @@ impl Model {
let cancelled = watched.step(i);
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.import_digests, Some(&state));
prev_fp = fp;
let pose = pose_only(f).then(|| placement_digest(doc, f, Some(&state)));
if let Some(pose) = pose {
poses.insert(f.id, pose);
}
let posed = pose.is_none_or(|pose| self.poses.get(&f.id) == Some(&pose));
let current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale && posed;
let current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale;
// A state the saved model did not keep is evaluated again from the one before it; the
// results after it that the model did keep still stand (#3220).
let unkept = reuse && self.results.get(i).filter(current).is_some_and(|old| old.unkept);
@@ -2395,28 +2303,14 @@ impl Model {
// has a result only while those picks are what they were.
if reuse
&& !unkept
&& let Some(old) = self
.results
.get(i)
.filter(current)
.filter(|old| moved.is_empty() || old.skipped || reads_none_of(f, &state, &moved, &feature_component))
.filter(|old| old.skipped || legacy_current(doc, f, &state, &old.state))
&& let Some(old) = self.results.get(i).filter(current).filter(|old| old.skipped || legacy_current(doc, f, &state, &old.state))
{
if moved.is_empty() {
if !old.skipped {
state = old.state.clone();
}
out.push(old.clone());
} else {
if !old.skipped {
state = with_moved(&old.state, &moved);
}
out.push(FeatureResult { state: state.clone(), ..old.clone() });
if !old.skipped {
state = old.state.clone();
}
out.push(old.clone());
continue;
}
// Reuse goes on past a pose-only feature evaluated again for its pose alone.
let reusing = reuse && !unkept && pose.is_some();
reuse = unkept && !cancelled;
if f.suppressed || rolled_back || cancelled {
out.push(FeatureResult {
@@ -2492,14 +2386,6 @@ impl Model {
}
// A kept result after a failed one would not be this evaluation's.
reuse &= c.error.is_none();
if reusing
&& c.error.is_none()
&& let Some(bodies) = pose_moved(self.results.get(i), f, fp, &state)
{
moved.retain(|(id, _)| *id != f.id);
moved.push((f.id, bodies));
reuse = true;
}
out.push(FeatureResult {
id: f.id,
name: f.name.clone(),
@@ -2521,9 +2407,8 @@ impl Model {
// (the same chain of definitions from the start). Changed inputs never reuse it.
if let Some(r) = rolled
.as_ref()
.filter(|_| moved.is_empty())
.filter(current)
.or_else(|| primed.as_ref().filter(|_| moved.is_empty()).and_then(|p| p.get(&f.id)).filter(current))
.or_else(|| primed.as_ref().and_then(|p| p.get(&f.id)).filter(current))
.filter(|r| r.error.is_some() || legacy_current(doc, f, &state, &r.state))
{
restored += 1;
@@ -2664,14 +2549,6 @@ impl Model {
}
}
reuse &= error.is_none();
if reusing
&& error.is_none()
&& let Some(bodies) = pose_moved(self.results.get(i), f, fp, &state)
{
moved.retain(|(id, _)| *id != f.id);
moved.push((f.id, bodies));
reuse = true;
}
out.push(FeatureResult {
id: f.id,
name: f.name.clone(),
@@ -2689,7 +2566,6 @@ impl Model {
});
}
self.placement_local = self.placement_local && out.iter().all(|r| !r.skipped && !r.stale && !r.unkept && r.error.is_none());
self.poses = poses;
self.results = out;
self.last_recomputed = recomputed;
self.recomputed += recomputed;
@@ -4878,13 +4754,9 @@ fn hole_position(vals: &BTreeMap<String, Value>, position: &crate::FacePick, at:
// Pick an actual cylindrical wall, not a depth derived from its radius: a large pipe bore can
// be shallow or pass through a thin plate. Sampling its finite face also skips a wider entry
// pocket and keeps metadata attached to the narrow cylindrical bore.
//
// A hole drilled across a pocket or a slot leaves its wall in pieces along the axis (#13505):
// every piece the hole reaches (`reach`, its depth from `entry`) is returned, nearest the entry
// first, each as (its start and end from the entry along `inward`, a point on it).
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64, reach: f64) -> Vec<(f64, f64, Vec3)> {
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64) -> Option<Vec3> {
let tol = (radius * 1e-6).max(1e-6);
let mut walls: Vec<(f64, f64, Vec3)> = Vec::new();
let mut best: Option<(f64, Vec3)> = None;
for body in &st.bodies {
// A wall found lies on the line through `entry` along `inward`, give or take its radius
// (and the slant its axis may have off `inward` over the body's distance), and its
@@ -4911,15 +4783,12 @@ fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64,
let b = (c.axis_point + c.axis * c.end - entry).dot(inward);
let lo = a.min(b).max(0.0);
let hi = a.max(b);
// One piece of wall made of several faces (a cylinder split at its seams) is one.
let mid = (lo + hi) * 0.5;
if hi > lo + 1e-9 && lo < reach - 1e-6 && !walls.iter().any(|(l, h, _)| (*l..=*h).contains(&mid)) {
walls.push((lo, hi, entry + inward * mid + inward.any_perp() * radius));
if hi > lo + 1e-9 && best.as_ref().is_none_or(|(old, _)| lo < *old) {
best = Some((lo, entry + inward * ((lo + hi) * 0.5) + inward.any_perp() * radius));
}
}
}
walls.sort_by(|a, b| a.0.total_cmp(&b.0));
walls
best.map(|(_, wall)| wall)
}
/// The cutting tools of a hole feature at every spot, each with its exact geometry when it is a
@@ -7090,30 +6959,6 @@ fn replay(
}
continue;
}
// A thread is cut again on the copy of its face. A copy keeps the thread's hand: the
// mirror of a right-hand M8 hole is a right-hand M8 hole, not a left-hand one.
if matches!(src.kind, FeatureKind::Thread { .. }) {
for m in mats {
let mut copy = (*src).clone();
copy.id = f.id;
copy.face_names.clear();
if let FeatureKind::Thread { face, from, up_to, .. } = &mut copy.kind {
face.0 = crate::apply_point(m, face.0);
if let Some(q) = from {
q.0 = crate::apply_point(m, q.0);
}
match up_to {
Some(crate::ThreadLimit::Face(q)) => q.0 = crate::apply_point(m, q.0),
Some(crate::ThreadLimit::Plane(_)) => {
return Err(DocError::Invalid(format!("{} ends at a plane, so it cannot be patterned or mirrored yet", src.name)));
}
None => {}
}
}
thread::eval(doc, vals, &copy, st, warning)?;
}
continue;
}
let (op, targets) = feature_op(src);
// A primitive's or a hole's targets are its participants (#1108): the copies change
// those bodies only, like the feature itself (#2755).
@@ -7671,7 +7516,7 @@ fn eval_feature(
let extra: Vec<String> = if *operation == Operation::Join { to_body.into_iter().collect() } else { Vec::new() };
apply_traced_with(doc, st, f, tools, *operation, &crate::plain(targets), &extra)
}
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, depth, to, .. } => {
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, .. } => {
let thread_depth = thread_depth.as_ref().map(|d| val(vals, d, Kind::Length)).transpose()?;
if thread_depth.is_some() && thread.is_none() {
return Err(DocError::Invalid("thread depth requires a tapped hole".into()));
@@ -7686,19 +7531,6 @@ fn eval_feature(
return Err(DocError::Invalid("thread depth must be finite and positive".into()));
}
let (spots, dir) = hole_spots(vals, st, f)?;
// How deep each hole goes (through all: as far as the wall goes), read before the cut:
// the pieces of a wall the thread runs along lie within it.
let reaches: Vec<f64> = if thread.is_some() {
spots
.iter()
.map(|at| match hole_depth_to(st, at, dir, &to.map(Vec3::from), depth)? {
Some(e) => val(vals, &e, Kind::Length),
None => Ok(f64::INFINITY),
})
.collect::<Result<_>>()?
} else {
Vec::new()
};
let tools = hole_feature_tools(vals, st, f)?;
let handled = sheet_hole_cut(st, f, &tools, warning)?;
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
@@ -7734,77 +7566,42 @@ fn eval_feature(
)
});
let ease = thread::runout(vals, ease_out)?;
for (p, reach) in spots.into_iter().zip(reaches) {
let walls = parallel_tapped_wall(st, p, dir, r, reach);
if pipe && walls.is_empty() {
return Err(DocError::Invalid("no cylindrical pipe-thread wall remains".into()));
}
// A pipe thread is one taper from the entry: its first wall only.
let walls: Vec<(f64, f64, Vec3)> = match walls.first() {
None => vec![(0.0, f64::INFINITY, p + dir.any_perp() * r + dir * (r * 0.5))],
Some(first) if pipe => vec![*first],
Some(_) => walls,
for p in spots {
let wall = if pipe {
parallel_tapped_wall(st, p, dir, r).ok_or_else(|| DocError::Invalid("no cylindrical pipe-thread wall remains".into()))?
} else {
parallel_tapped_wall(st, p, dir, r).unwrap_or(p + dir.any_perp() * r + dir * (r * 0.5))
};
// The thread depth runs from where the first wall starts, through every piece
// of the wall it reaches (#13505).
let first = walls.first().map_or(0.0, |w| w.0);
let stop = thread_depth.map(|d| first + d);
let ask = thread::ThreadAsk { designation: Some(d), tapped: true, hand: *hand, ..Default::default() };
if *modeled {
// Cut from the entry, as deep as `thread_depth` or along the whole wall:
// at a blind hole's bottom the groove stops clear of it, or fades out
// before it with a runout. Every piece is found before any is cut.
let mut on = Vec::new();
for (lo, hi, wall) in &walls {
let extent = match stop {
None => thread::Extent::Full,
Some(s) if s >= *hi => thread::Extent::Full,
Some(s) if s > *lo => thread::Extent::Length(s - lo),
Some(_) => break,
};
// A piece past the first starts at its end nearest the entry.
let from = if on.is_empty() { p } else { p + dir * *lo };
let ask = thread::ThreadAsk { modeled: true, extent, from: Some(from), ease_out: ease, ..ask };
on.push(thread_on(st, f.id, *wall, &ask)?);
}
for o in on {
thread::cut(st, o, (crate::thread_forms::ThreadTip::None, None, None))?;
}
// before it with a runout.
let extent = thread_depth.map_or(thread::Extent::Full, thread::Extent::Length);
let ask = thread::ThreadAsk { modeled: true, extent, from: Some(p), ease_out: ease, ..ask };
let on = thread_on(st, f.id, wall, &ask)?;
thread::cut(st, on, (crate::thread_forms::ThreadTip::None, None, None))?;
continue;
}
let mut threads = Vec::new();
for (lo, _, wall) in &walls {
if stop.is_some_and(|s| s <= *lo) {
break;
}
threads.push(thread_on(st, f.id, *wall, &ask)?.info);
}
let mut t = thread_on(st, f.id, wall, &ask)?.info;
if let Some(depth) = thread_depth {
let useful =
|t: &ThreadInfo| (t.axis_point + t.axis * t.start - p).dot(dir).max((t.axis_point + t.axis * t.end - p).dot(dir));
let available = threads.last().map_or(0.0, |t| useful(t) - first);
if depth > available + 1e-9 {
let available = t.end - t.start;
if depth > available {
return Err(DocError::Invalid(format!(
"thread depth {} mm exceeds the cylindrical wall's useful length of {} mm; reduce the thread depth or use full thread depth",
show_mm(depth),
show_mm(available)
)));
}
// Stop `depth` past the first wall's start in the drilling direction,
// independently of the wall probe's position and the cylinder surface's
// axis orientation.
if let (Some(t), Some(s)) = (threads.last_mut(), stop) {
let past = useful(t) - s;
if past > 0.0 {
if dir.dot(t.axis) > 0.0 {
t.end -= past;
} else {
t.start += past;
}
}
// Start at the entry in the drilling direction, independently of the
// wall probe's position and the cylinder surface's axis orientation.
if dir.dot(t.axis) > 0.0 {
t.end = t.start + depth;
} else {
t.start = t.end - depth;
}
}
st.threads.extend(threads);
st.threads.push(t);
}
}
Ok(())
@@ -8601,17 +8398,7 @@ fn mesh_bodies(meshes: &[crate::MeshData]) -> Result<Vec<(String, Body)>> {
for m in meshes {
let positions: Vec<Vec3> = m.positions.as_chunks::<3>().0.iter().map(|c| Vec3::new(c[0], c[1], c[2])).collect();
let triangles: Vec<[u32; 3]> = m.triangles.as_chunks::<3>().0.to_vec();
if !m.corner_normals.is_empty() && m.corner_normals.len() != m.triangles.len().saturating_mul(3) {
return Err(DocError::Invalid(format!("mesh `{}`: invalid shading normal count", m.name)));
}
let normals: Vec<[Vec3; 3]> = m
.corner_normals
.as_chunks::<9>()
.0
.iter()
.map(|c| [Vec3::new(c[0], c[1], c[2]), Vec3::new(c[3], c[4], c[5]), Vec3::new(c[6], c[7], c[8])])
.collect();
let b = kernel::mesh_body_with_normals(&positions, &triangles, &normals).map_err(|e| DocError::Invalid(format!("mesh `{}`: {e}", m.name)))?;
let b = kernel::mesh_body(&positions, &triangles).map_err(|e| DocError::Invalid(format!("mesh `{}`: {e}", m.name)))?;
out.push((m.name.clone(), b.with_color(m.color)));
}
Ok(out)
+1 -15
View File
@@ -911,7 +911,6 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
positions: Arc::from([0., 0., 0., 1., 0., 0., 0., 1., 0.]),
triangles: Arc::from([0, 1, 2]),
color: None,
corner_normals: Default::default(),
}],
},
None,
@@ -925,7 +924,7 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
Arc::make_mut(&mut doc).occurrences[0].transform = rigid(Vec3::new(10., 0., 0.), Vec3::ZERO, Vec3::Z, 0.3);
assert!(!model.evaluate_shared(&doc));
assert!(Arc::ptr_eq(&original, &model.state()));
for edit in 0..6 {
for edit in 0..5 {
let FeatureKind::MeshImport { meshes, .. } = &mut Arc::make_mut(&mut doc).feature_mut(feature).unwrap().kind else { panic!("mesh") };
let mesh = &mut meshes[0];
match edit {
@@ -933,7 +932,6 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
1 => Arc::make_mut(&mut mesh.triangles).swap(1, 2),
2 => mesh.color = Some([0.2, 0.4, 0.6]),
3 => mesh.name = "Renamed".into(),
4 => mesh.corner_normals = Arc::from([0., 1., 1., 0., 1., 1., 0., 1., 1.]),
_ => mesh.positions = Arc::from(mesh.positions.to_vec()),
}
assert!(model.evaluate_shared(&doc), "mesh edit {edit} must invalidate");
@@ -1046,7 +1044,6 @@ fn comparing_a_copied_mesh_does_not_read_its_shared_arrays() {
positions: Arc::from([0., 0., 0., 1., 0., 0., 0., 1., f64::NAN]),
triangles: Arc::from([0, 1, 2]),
color: None,
corner_normals: Default::default(),
};
let copy = mesh.clone();
assert!(copy == mesh, "a copy shares the arrays and is the same mesh without reading them");
@@ -1774,7 +1771,6 @@ fn imported_meshes() -> Document {
positions: [0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0].into(),
triangles: [0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3].into(),
color: Some([0.2, 0.4, 0.6]),
corner_normals: Default::default(),
})
.collect();
let id = doc.add_feature(FeatureKind::MeshImport { file: "tetrahedra.stl".into(), meshes }, Some("Meshes")).unwrap();
@@ -1806,16 +1802,6 @@ fn same_as_fresh(doc: &Document, model: &Model) {
#[test]
fn unchanged_mesh_payloads_are_not_read_again_for_rigid_motion() {
let mut doc = imported_meshes();
for mesh in meshes_mut(&mut doc) {
mesh.generate_crease_normals(10.0).unwrap();
assert!(!mesh.corner_normals.is_empty());
}
let compact: serde_json::Value = serde_json::from_str(&super::feature_json(&doc.features[0], &super::ImportDigests::default())).unwrap();
for mesh in compact[0]["meshes"].as_array().unwrap() {
assert_eq!(mesh["positions"], serde_json::json!([]));
assert_eq!(mesh["triangles"], serde_json::json!([]));
assert!(mesh.get("corner_normals").is_none(), "the fingerprint must hash the retained digest without serializing shading payloads");
}
let mut model = Model::new();
assert_eq!(counted(|| model.evaluate(&doc)).mesh_digested, 2);
for k in 1..5 {
@@ -1,33 +0,0 @@
//! Imported mesh sources may save optional `corner_normals`: nine f64 values per
//! triangle, independent of positions, topology and optical boundary certification.
//! Missing/empty normals retain the previous flat shading. OBJ `vn` and `s` import
//! preserve authored corner normals and smoothing groups. Explicit crease generation
//! stores only shading metadata and preserves original coordinates and triangle indices.
//! Older released readers warn about this format and retain fields they cannot read.
//!
//! Render jobs may save opt-in `texture_filtering`; omission/false preserves LOD0
//! texture sampling. Supported consumers use the captured texture's mip chain and
//! ray footprint without changing source image identity or source color semantics.
//!
//! Optional `metal_energy_compensation` restores a bounded share of rough metal
//! reflectance in both path tracers. Omission or false keeps the previous BRDF.
//!
//! Caustics options may save `area_lights: true` and `environment: true` for
//! both path tracers. Omission preserves the hard-source photon map.
//!
//! Caustics may also save `guided_emission: true` to concentrate photon emission
//! around captured optical bounds with probability-corrected power. Omission or
//! false preserves the original emission proposal. Older released readers warn
//! about the newer format and retain fields they cannot interpret when saving.
//!
//! Caustics may save `refresh_per_pass: true` to build one photon map per camera
//! pass. Omission or false retains one map per render job.
//!
//! Caustics may save `power_weighted_sources: true` to allocate photon launches
//! using estimated source power with a uniform support component. Each source
//! retains its matching probability correction. Omission or false preserves
//! uniform source selection and the original random sequence.
//!
//! CPU jobs may save `environment_importance_sampling: true` to use the captured
//! HDR radiance distribution for lighting and photon emission. Omission or false
//! retains uniform sphere sampling. Older readers warn and retain unknown fields.
@@ -1,6 +0,0 @@
//! Saves CPU worker selection, linear 2x supersampling and denoising controls in render setups.
//! Older designs retain native-size output, three full-strength denoising passes and no firefly suppression.
//! DenoiseSettings.optical_filter defaults to true for supported mirror/glass filtering; an explicit false
//! restores legacy opaque-only denoising and is retained in CPU and GPU setups. No rewrite is needed.
//! Omitted CPU worker selection uses the bounded automatic pool, with one worker available for serial work.
//! The last release warns about this newer design format and preserves fields it cannot read.
-1
View File
@@ -22,7 +22,6 @@ mod component_removal;
pub use component_removal::RemovedTree;
pub mod config;
pub mod construct;
pub mod csv;
pub mod display_quality;
pub mod display_units;
mod document;
+1 -10
View File
@@ -917,10 +917,7 @@ impl Model {
}
let mut model = Model { results, param_errors, import_digests, context: ctx, frame_bodies, validation, ..Model::default() };
let unkept = model.results.iter().filter(|r| r.unkept).count();
// A pose-only feature's fingerprint leaves its pose out, so its saved result may be from
// another pose than `doc`'s: evaluated again, with what reads none of it kept (#12139).
let posed = doc.features.iter().zip(&model.results).any(|(f, r)| !r.skipped && pose_only(f));
if unkept > 0 || posed {
if unkept > 0 {
// The features whose state the file did not keep, each evaluated again from the state
// before it: nothing else in the model is missing then.
model.evaluate(doc);
@@ -936,12 +933,6 @@ impl Model {
let mut before = Arc::new(ModelState::default());
if let Ok(mut cache) = model.cache.lock() {
for (f, r) in doc.features.iter().zip(&model.results).filter(|(_, r)| !r.skipped && r.error.is_none()) {
// A pose-only feature's fingerprint leaves its pose out, so its saved result may be
// from another pose than `doc`'s: it is no result for `doc`'s key (#12139).
if pose_only(f) {
before = r.state.clone();
continue;
}
let local = fingerprint(doc, 0, f, &vals, false, &model.import_digests) ^ placement_digest(doc, f, Some(&before));
cache.put(
cache_key(f, r.fingerprint, local, ctx, &before),
+2 -11
View File
@@ -50,11 +50,8 @@ pub struct EdgeHit {
}
/// The edge of a body a stored edge name finds: the edge with that name; else the one edge
/// between the faces it names exactly (see [`exactly`]). A numbered name (`~k`) was one of
/// several edges between its faces, told apart only by where they lay, so the one edge left
/// between them is it only where the reference's point still lies on it. `Err` explains when
/// there is none: no such edge, the named edge split into several, one of several edges whose
/// faces now bound one edge elsewhere, or a name saved before format 14.
/// between the faces it names exactly (see [`exactly`]). `Err` explains when there is none: no
/// such edge, the named edge split into several, or a name saved before format 14.
pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, String> {
if legacy_name(name).is_some() {
return Err(format!("edge `{name}` was named by a build before format 14"));
@@ -84,13 +81,7 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
keep_best(&mut best, level, i);
}
let (_, cands) = best.ok_or_else(|| format!("edge `{name}` no longer exists"))?;
// When the faces of several numbered edges bound one edge now, every one of their names
// finds the faces of that edge: taking it for each would put them all on one edge (#13332).
let numbered = wanted.len() != name.len();
match cands.as_slice() {
[i] if numbered && !edge_near(b, &m, *i, near) => {
Err(format!("edge `{name}` was one of several between its faces, and the one left is elsewhere"))
}
[i] => exact(*i),
_ => Err(format!("edge `{name}` is no longer one edge")),
}
+4 -6
View File
@@ -483,10 +483,9 @@ impl Document {
}
/// User parameters as CSV (`name,unit,expression,value,comment`). The `value` column is the
/// number in the `unit` column, rounded for reading (#10747); import reads `expression`. A text
/// field a spreadsheet would run as a formula is written as text ([`crate::csv`]).
/// number in the `unit` column, rounded for reading (#10747); import reads `expression`.
pub fn params_csv(&self) -> String {
let q = crate::csv::field;
let q = |s: &str| if s.contains([',', '"', '\r', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };
let mut out = String::from("name,unit,expression,value,comment,favorite\n");
for r in self.param_rows().into_iter().filter(|r| r.source == "user") {
out += &format!(
@@ -1138,14 +1137,13 @@ fn split_csv(text: &str, separator: char) -> impl Iterator<Item = &str> {
fn csv_fields(record: &str) -> Result<Vec<String>> {
split_csv(record, ',')
.map(|raw| {
// `unguard` takes off the `'` an export put before a value a spreadsheet would run.
let field = raw.trim();
if !field.contains('"') {
return Ok(crate::csv::unguard(field).to_string());
return Ok(field.to_string());
}
let inner = field.strip_prefix('"').and_then(|s| s.strip_suffix('"'));
match inner {
Some(s) if s.split("\"\"").all(|part| !part.contains('"')) => Ok(crate::csv::unguard(&s.replace("\"\"", "\"")).to_string()),
Some(s) if s.split("\"\"").all(|part| !part.contains('"')) => Ok(s.replace("\"\"", "\"")),
_ => Err(DocError::Invalid("invalid quoting in parameter CSV".into())),
}
})
+3 -7
View File
@@ -55,7 +55,7 @@ impl Model {
}
}
pub(super) fn local_plane(plane: &PlaneRef, depth: usize) -> bool {
fn local_plane(plane: &PlaneRef, depth: usize) -> bool {
if depth > crate::MAX_PLACEMENT_DEPTH {
return false;
}
@@ -240,12 +240,8 @@ fn same_feature(a: &Feature, b: &Feature) -> bool {
a == b
&& meshes.len() == other.len()
&& meshes.iter().zip(other).all(|(a, b)| {
let crate::MeshData { name, positions, triangles, corner_normals, color } = a;
*name == b.name
&& *color == b.color
&& Arc::ptr_eq(positions, &b.positions)
&& Arc::ptr_eq(triangles, &b.triangles)
&& Arc::ptr_eq(corner_normals, &b.corner_normals)
let crate::MeshData { name, positions, triangles, color } = a;
*name == b.name && *color == b.color && Arc::ptr_eq(positions, &b.positions) && Arc::ptr_eq(triangles, &b.triangles)
})
}
(
+2 -101
View File
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
use solidcraft_render::{
Camera,
job::{
CpuBudget, CpuProgressiveOptions, DenoiseSettings, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions,
RaytraceEnvironmentOptions, RaytraceOptions, RectangleLightOptions, StudioLighting, Sun, ThinLens, ToneMap,
CpuBudget, CpuProgressiveOptions, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions, RaytraceEnvironmentOptions,
RaytraceOptions, RectangleLightOptions, StudioLighting, Sun, ThinLens, ToneMap,
},
lighting::environment::SkyGradient,
};
@@ -82,18 +82,6 @@ impl RenderJob {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CpuJob {
/// Zero selects a bounded native worker count; one retains serial transport.
#[serde(skip_serializing_if = "zero_threads")]
pub threads: u16,
/// Spend HDR lighting samples according to captured environment radiance.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub environment_importance_sampling: bool,
/// Explicit ray-footprint mip and bounded anisotropic texture filtering.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub texture_filtering: bool,
/// Approximate the missing microfacet bounces of rough isotropic conductors.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub metal_energy_compensation: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub caustics: Option<solidcraft_render::job::CausticOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -110,13 +98,6 @@ pub struct CpuJob {
pub path: Option<solidcraft_render::job::CpuPathOptions>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub denoise: bool,
#[serde(skip_serializing_if = "DenoiseSettings::is_default")]
pub denoise_settings: DenoiseSettings,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub denoise_diagnostics: bool,
/// Internal scale; saved width and height remain the exported image size.
#[serde(skip_serializing_if = "solidcraft_render::job::single_supersample")]
pub supersampling: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub lens: Option<ThinLens>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -148,10 +129,6 @@ pub struct CpuJob {
impl Default for CpuJob {
fn default() -> Self {
Self {
threads: 0,
environment_importance_sampling: false,
texture_filtering: false,
metal_energy_compensation: false,
caustics: None,
volume: None,
sun: None,
@@ -159,9 +136,6 @@ impl Default for CpuJob {
aging: Default::default(),
adaptive: None,
denoise: false,
denoise_settings: DenoiseSettings::default(),
denoise_diagnostics: false,
supersampling: 1,
path: None,
lens: None,
output: None,
@@ -226,10 +200,6 @@ impl CpuJob {
})
.transpose()?;
let options = RaytraceOptions {
threads: self.threads,
environment_importance_sampling: self.environment_importance_sampling,
texture_filtering: self.texture_filtering,
metal_energy_compensation: self.metal_energy_compensation,
caustics: self.caustics,
volume: self.volume,
sun: self.sun.map(Box::new),
@@ -237,9 +207,6 @@ impl CpuJob {
aging: (!aging.is_empty()).then(|| Box::new(aging)),
adaptive: self.adaptive.map(Box::new),
denoise: self.denoise,
denoise_settings: self.denoise_settings,
denoise_diagnostics: self.denoise_diagnostics,
supersampling: self.supersampling,
path: self.path,
lens: self.lens,
output: self.output.clone(),
@@ -266,12 +233,6 @@ impl CpuJob {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct GpuJob {
/// Explicit ray-footprint mip and bounded anisotropic texture filtering.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub texture_filtering: bool,
/// Approximate the missing microfacet bounces of rough isotropic conductors.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub metal_energy_compensation: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub caustics: Option<solidcraft_render::job::CausticOptions>,
#[serde(skip_serializing_if = "crate::render_aging::SavedAgingStack::is_empty")]
@@ -294,13 +255,6 @@ pub struct GpuJob {
pub seed: u32,
pub tone_map: ToneMap,
pub denoise: bool,
#[serde(skip_serializing_if = "DenoiseSettings::is_default")]
pub denoise_settings: DenoiseSettings,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub denoise_diagnostics: bool,
/// Internal scale; saved width and height remain the exported image size.
#[serde(skip_serializing_if = "solidcraft_render::job::single_supersample")]
pub supersampling: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub lens: Option<ThinLens>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -315,8 +269,6 @@ impl Default for GpuJob {
fn default() -> Self {
let options = JobOptions::default();
Self {
texture_filtering: false,
metal_energy_compensation: false,
caustics: None,
aging: Default::default(),
nested_media: options.nested_media,
@@ -331,9 +283,6 @@ impl Default for GpuJob {
seed: options.seed,
tone_map: options.tone_map,
denoise: options.denoise,
denoise_settings: options.denoise_settings,
denoise_diagnostics: options.denoise_diagnostics,
supersampling: options.supersampling,
lens: options.lens,
lighting: options.lighting,
environment: None,
@@ -368,8 +317,6 @@ impl GpuJob {
environment.validate()?;
}
let options = JobOptions {
texture_filtering: self.texture_filtering,
metal_energy_compensation: self.metal_energy_compensation,
caustics: self.caustics,
aging: self.aging.parameters_only()?,
nested_media: self.nested_media,
@@ -384,9 +331,6 @@ impl GpuJob {
seed: self.seed,
tone_map: self.tone_map,
denoise: self.denoise,
denoise_settings: self.denoise_settings,
denoise_diagnostics: self.denoise_diagnostics,
supersampling: self.supersampling,
lens: self.lens,
lighting: self.lighting,
exposure_ev,
@@ -421,10 +365,6 @@ impl SkyEnvironment {
}
}
fn zero_threads(threads: &u16) -> bool {
*threads == 0
}
fn unit() -> f64 {
1.0
}
@@ -538,42 +478,3 @@ mod cpu_volume_portable_tests {
assert_eq!(serde_json::to_value(job).unwrap(), old);
}
}
#[cfg(test)]
#[test]
fn portable_quality_controls_preserve_explicit_values_and_legacy_defaults() {
let old_cpu: CpuJob = serde_json::from_str("{}").unwrap();
let old_gpu: GpuJob = serde_json::from_str("{}").unwrap();
assert_eq!(old_cpu.threads, 0);
assert_eq!((old_cpu.supersampling, old_gpu.supersampling), (1, 1));
assert_eq!(old_cpu.denoise_settings, DenoiseSettings::default());
assert_eq!(old_gpu.denoise_settings, DenoiseSettings::default());
assert!(!old_cpu.denoise_diagnostics && !old_gpu.denoise_diagnostics);
assert!(old_cpu.denoise_settings.optical_filter && old_gpu.denoise_settings.optical_filter);
let opaque_only = DenoiseSettings { optical_filter: false, ..Default::default() };
for wire in [
serde_json::to_value(CpuJob { denoise: true, denoise_settings: opaque_only, ..Default::default() }).unwrap(),
serde_json::to_value(GpuJob { denoise: true, denoise_settings: opaque_only, ..Default::default() }).unwrap(),
] {
assert_eq!(wire["denoise_settings"]["optical_filter"], false);
}
let filter = DenoiseSettings { passes: 2, strength: 0.35, firefly_suppression: false, optical_filter: false };
let cpu = CpuJob { threads: 4, supersampling: 2, denoise: true, denoise_settings: filter, denoise_diagnostics: true, ..Default::default() };
let gpu = GpuJob { samples: 128, supersampling: 2, denoise: true, denoise_settings: filter, denoise_diagnostics: true, ..Default::default() };
let cpu_reopened: CpuJob = serde_json::from_value(serde_json::to_value(&cpu).unwrap()).unwrap();
let gpu_reopened: GpuJob = serde_json::from_value(serde_json::to_value(&gpu).unwrap()).unwrap();
assert_eq!(cpu_reopened, cpu);
assert_eq!(gpu_reopened, gpu);
let cpu_runtime = cpu_reopened.to_runtime(-1.0).unwrap();
let gpu_runtime = gpu_reopened.to_runtime(-1.0).unwrap();
assert_eq!((cpu_runtime.threads, cpu_runtime.supersampling), (4, 2));
assert_eq!((gpu_runtime.samples, gpu_runtime.supersampling), (128, 2));
assert_eq!(cpu_runtime.denoise_settings, filter);
assert_eq!(gpu_runtime.denoise_settings, filter);
assert!(!cpu_runtime.denoise_settings.optical_filter && !gpu_runtime.denoise_settings.optical_filter);
assert!(cpu_runtime.denoise_diagnostics && gpu_runtime.denoise_diagnostics);
assert!(CpuJob { threads: 65, ..cpu.clone() }.validate(0.0).is_err());
assert!(GpuJob { supersampling: 3, ..gpu.clone() }.validate(0.0).is_err());
assert!(CpuJob { denoise_settings: DenoiseSettings { strength: f64::NAN, ..filter }, ..cpu }.validate(0.0).is_err());
assert!(GpuJob { denoise_settings: DenoiseSettings { passes: 4, ..filter }, ..gpu }.validate(0.0).is_err());
}
+9 -46
View File
@@ -147,8 +147,8 @@ impl OpticalBindings {
self.0.iter()
}
pub fn validate(&self) -> Result<()> {
if self.0.len() > solidcraft_render::job::MAX_OPTICAL_TARGETS || self.0.capacity() > solidcraft_render::job::MAX_OPTICAL_TARGETS {
return Err(DocError::Invalid("at most 128 portable optical targets".into()));
if self.0.len() > 32 || self.0.capacity() > 32 {
return Err(DocError::Invalid("at most 32 portable optical targets".into()));
}
let mut seen = BTreeSet::new();
for binding in &self.0 {
@@ -164,7 +164,7 @@ impl OpticalBindings {
}
pub fn validate_for_cpu_path(&self, path: bool) -> Result<()> {
self.validate()?;
if !path && self.len() > 8 {
if self.len() > 8 {
return Err(DocError::Invalid("CPU supports at most eight certified optical targets".into()));
}
for binding in self.iter() {
@@ -183,11 +183,11 @@ impl OpticalBindings {
}
*old = binding;
} else {
if self.0.len() >= solidcraft_render::job::MAX_OPTICAL_TARGETS {
return Err(DocError::Invalid("at most 128 portable optical targets".into()));
if self.0.len() >= 32 {
return Err(DocError::Invalid("at most 32 portable optical targets".into()));
}
self.0.try_reserve_exact(1).map_err(|_| DocError::Invalid("optical binding reservation failed".into()))?;
if self.0.capacity() > solidcraft_render::job::MAX_OPTICAL_TARGETS {
if self.0.capacity() > 32 {
return Err(DocError::Invalid("optical capacity exceeds its bound".into()));
}
self.0.push(binding);
@@ -202,7 +202,7 @@ impl OpticalBindings {
}
impl<'de> Deserialize<'de> for OpticalBindings {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let result = Self(bounded_sequence::<D, SavedOptics, { solidcraft_render::job::MAX_OPTICAL_TARGETS }>(d)?);
let result = Self(bounded_sequence::<D, SavedOptics, 32>(d)?);
result.validate().map_err(serde::de::Error::custom)?;
Ok(result)
}
@@ -212,25 +212,6 @@ impl<'de> Deserialize<'de> for OpticalBindings {
mod advanced_model_contract_tests {
use super::*;
use solidcraft_render::glass_model::{Dispersion, GlassModel, ThinFilm};
#[test]
fn portable_optics_keep_128_targets_and_legacy_cpu_keeps_its_explicit_limit() {
let mut bindings = OpticalBindings::default();
for feature in 1..=solidcraft_render::job::MAX_OPTICAL_TARGETS {
let binding = SavedOptics {
target: RenderBodyTarget { definition: "OPUS".into(), feature: feature as u64, component: 0, occurrence_path: Vec::new() },
optics: OpticalMaterial::default(),
};
bindings.set(binding, false).unwrap();
}
bindings.validate_for_cpu_path(true).unwrap();
assert!(bindings.validate_for_cpu().is_err());
let raw = serde_json::to_value(&bindings).unwrap();
assert_eq!(serde_json::from_value::<OpticalBindings>(raw.clone()).unwrap(), bindings);
let mut too_many = raw.as_array().unwrap().clone();
too_many.push(too_many[0].clone());
assert!(serde_json::from_value::<OpticalBindings>(serde_json::Value::Array(too_many)).is_err());
}
#[test]
fn advanced_optics_keep_every_authored_number_through_saved_and_cpu_lowering() {
let model = GlassModel {
@@ -257,32 +238,14 @@ mod advanced_model_contract_tests {
#[test]
fn caustic_quality_is_portable_between_both_saved_jobs_and_runtime_requests() {
use crate::render_job::{CpuJob, GpuJob};
let caustics = Some(solidcraft_render::job::CausticOptions {
photons: 8192,
radius_mm: 0.4,
max_depth: 12,
seed: 381,
area_lights: false,
environment: false,
guided_emission: true,
refresh_per_pass: true,
power_weighted_sources: true,
});
let cpu = CpuJob {
caustics,
environment_importance_sampling: true,
path: Some(Default::default()),
progressive: Some(Default::default()),
..Default::default()
};
let caustics = Some(solidcraft_render::job::CausticOptions { photons: 8192, radius_mm: 0.4, max_depth: 12, seed: 381 });
let cpu = CpuJob { caustics, path: Some(Default::default()), progressive: Some(Default::default()), ..Default::default() };
let gpu = GpuJob { caustics, ..Default::default() };
assert_eq!(cpu.to_runtime(0.0).unwrap().caustics, caustics);
assert!(cpu.to_runtime(0.0).unwrap().environment_importance_sampling);
assert_eq!(gpu.to_runtime(0.0).unwrap().caustics, caustics);
assert_eq!(serde_json::from_value::<CpuJob>(serde_json::to_value(&cpu).unwrap()).unwrap(), cpu);
assert_eq!(serde_json::from_value::<GpuJob>(serde_json::to_value(&gpu).unwrap()).unwrap(), gpu);
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("caustics").is_none());
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("environment_importance_sampling").is_none());
assert!(serde_json::to_value(GpuJob::default()).unwrap().get("caustics").is_none());
}
}
+10 -60
View File
@@ -212,27 +212,15 @@ impl RenderSetup {
));
}
job.validate(&self.camera, self.exposure_ev)?;
if let RenderJob::GpuPath { options, .. } = job {
solidcraft_render::job::supersampled_dimensions(self.width, self.height, options.supersampling)
.map_err(|e| DocError::Invalid(e.to_string()))?;
}
if let RenderJob::CpuRaytrace { options, .. } = job {
// Existing native-size Workstation intent remains readable. X2
// uses the bounded 8K output envelope and pays for its internal grid.
let (trace_width, trace_height) = if options.supersampling == 1 && !options.denoise && !options.denoise_diagnostics {
(self.width, self.height)
} else {
solidcraft_render::job::supersampled_dimensions(self.width, self.height, options.supersampling)
.map_err(|e| DocError::Invalid(e.to_string()))?
};
if options.denoise || options.denoise_diagnostics {
solidcraft_render::job::validate_cpu_denoise_image(trace_width, trace_height).map_err(|e| DocError::Invalid(e.to_string()))?;
if options.denoise {
solidcraft_render::job::validate_cpu_denoise_image(self.width, self.height).map_err(|e| DocError::Invalid(e.to_string()))?;
}
options
.budget
.validate_image(
trace_width,
trace_height,
self.width,
self.height,
solidcraft_render::job::cpu_camera_sampling(options.progressive, options.adaptive, options.budget)
.map_err(|e| DocError::Invalid(e.to_string()))?,
)
@@ -682,7 +670,7 @@ mod tests {
gpu(3840, 2160).validate().unwrap();
gpu(3840, 2160).validate_for_gpu().unwrap();
let message = gpu(8193, 8193).validate().unwrap_err().to_string();
assert!(message.contains("(8192 pixels per side, 33554432 pixels in all)"), "{message}");
assert!(message.contains("(8192 pixels per side, 16777216 pixels in all)"), "{message}");
assert!(!message.contains("CPU resource profile"), "{message}");
}
@@ -895,7 +883,7 @@ mod tests {
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"hdr","path":"not-portable.hdr"}}}}),
json!({"job":{"method":"gpu_path","options":{"exposure_ev":0}}}),
json!({"job":{"method":"gpu_path","options":{"samples":0}}}),
json!({"job":{"method":"gpu_path","options":{"max_depth":65}}}),
json!({"job":{"method":"gpu_path","options":{"max_depth":17}}}),
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"sky","intensity":101}}}}),
json!({"job":{"method":"gpu_path","options":{"lighting":{"environment":[-1,0,0]}}}}),
json!({"camera":Camera { fov: 0.0, ..Default::default() },"job":{"method":"gpu_path","options":{"lens":{"aperture_radius_mm":1,"focus_distance_mm":10}}}}),
@@ -1033,7 +1021,7 @@ mod cpu_budget_tests {
let old = CpuJob::default();
assert!(old.path.is_none());
assert!(serde_json::to_value(&old).unwrap().get("path").is_none());
for depth in [1, 8, 16, 64] {
for depth in [1, 8, 16] {
let setup:RenderSetup=serde_json::from_value(json!({"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{"path":{"max_depth":depth},"progressive":{"samples_per_pixel":7,"seed":9}}}})).unwrap();
setup.validate().unwrap();
let mut document = crate::Document::default();
@@ -1044,7 +1032,7 @@ mod cpu_budget_tests {
}
for raw in [
json!({"path":{"max_depth":0},"progressive":{"samples_per_pixel":7,"seed":9}}),
json!({"path":{"max_depth":65},"progressive":{"samples_per_pixel":7,"seed":9}}),
json!({"path":{"max_depth":17},"progressive":{"samples_per_pixel":7,"seed":9}}),
json!({"path":{"max_depth":8}}),
json!({"path":{"max_depth":8},"progressive":{"samples_per_pixel":7,"seed":9},"shadows":false}),
] {
@@ -1085,7 +1073,7 @@ fn cpu_denoise_saved_flag_roundtrips_offline_and_keeps_false_omitted() {
setup.validate().unwrap();
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else { panic!("CPU job expected") };
options.denoise = true;
assert_eq!(setup.validate().unwrap_err(), DocError::Invalid("LimitExceeded: output requires 1..8192 sides and at most 33554432 pixels".into()),);
assert!(setup.validate().unwrap_err().to_string().contains("CPU denoising"));
}
#[cfg(test)]
@@ -1232,9 +1220,7 @@ mod cpu_adaptive_saved_tests {
serde_json::from_value::<RenderSetup>(cap).unwrap().validate().is_err(),
"16 pilots cannot be hidden outside Compact's 64-sample cap"
);
for (budget, maximum) in
[(CpuBudget::Compact, 64), (CpuBudget::Studio, 1024), (CpuBudget::Workstation, solidcraft_render::job::MAX_CPU_SAMPLES_PER_PIXEL)]
{
for (budget, maximum) in [(CpuBudget::Compact, 64), (CpuBudget::Studio, 1024), (CpuBudget::Workstation, 4096)] {
let mut runtime = runtime.clone();
runtime.budget = budget;
runtime.progressive = Some(CpuProgressiveOptions { samples_per_pixel: maximum - 16, seed: 81 });
@@ -1399,39 +1385,3 @@ mod cpu_celestial_saved_tests {
assert!(defaults.get("sun").is_none() && defaults.get("sky").is_none());
}
}
#[test]
fn render_quality_saved_intent_preserves_output_size_and_validates_the_internal_grid() {
let gpu: RenderSetup = serde_json::from_value(serde_json::json!({
"width":7680,"height":4320,"job":{"method":"gpu_path","options":{
"samples":128,"supersampling":2,"denoise":true,"denoise_diagnostics":true,
"denoise_settings":{"passes":2,"strength":0.5,"firefly_suppression":false}
}}
}))
.unwrap();
gpu.validate().unwrap();
let reopened: RenderSetup = serde_json::from_value(serde_json::to_value(&gpu).unwrap()).unwrap();
assert_eq!(reopened, gpu);
assert_eq!((reopened.width, reopened.height), (7680, 4320));
let mut cpu: RenderSetup = serde_json::from_value(serde_json::json!({
"width":7680,"height":4320,"job":{"method":"cpu_raytrace","options":{
"threads":4,"budget":"workstation","progressive":{"samples_per_pixel":128},"supersampling":2
}}
}))
.unwrap();
cpu.validate().unwrap();
assert_eq!((cpu.width, cpu.height), (7680, 4320));
cpu.width = 1024;
cpu.height = 1024;
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut cpu.job else { panic!("expected CPU job") };
options.budget = solidcraft_render::job::CpuBudget::Compact;
options.progressive.as_mut().unwrap().samples_per_pixel = 8;
assert!(cpu.validate().is_err(), "X2 must charge four million internal pixels to Compact's progressive limit");
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut cpu.job else { panic!("expected CPU job") };
options.supersampling = 1;
cpu.validate().unwrap();
let mut invalid = gpu;
invalid.width = 8193;
invalid.height = 1;
assert!(invalid.validate().is_err());
}
@@ -1,231 +0,0 @@
//! Prepare an explicitly shaded OPUS copy and physical CPU/GPU reference setups.
//! No render is started. Geometry and the original OPUS Master setup are retained.
use serde_json::{Value, json};
use solidcore_record::Vec3;
use solidcraft_engine::{
Session,
doc::{Document, FeatureKind, MeshData, render_settings::RenderSetup},
};
use solidcraft_geom::prelude::*;
use std::{error::Error, fs, path::PathBuf, sync::Arc};
fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
if condition { Ok(()) } else { Err(message.into()) }
}
const CURVED: &[&str] = &[
"A3 - continuous sixteen facet freeform glass knot",
"A3 - six lobe quilted aspheric witness optic",
"Glass - smoked rounded toroid",
"Glass - clear calibration sphere",
"O2 - Lens 1 - Biconvex crown",
"O2 - Lens 2 - Biconcave flint",
"O2 - Lens 3 - Thick meniscus",
"O2 - Lens 4 - Plano convex magnifier",
"O2 - Lens 5 - Aspheric correction optic",
"R2 - clear chest reactor lens",
"R2 - near clear glass eye",
"R2 - far clear glass eye",
"S2 - amber solid ellipsoid",
"S2 - cobalt fluted solid decanter",
"R2 - thorax ivory swept breastplates",
"S2 - glass gallery turned wood pedestal -217",
"S2 - glass gallery turned wood pedestal 211",
];
fn scene_counts(scene: &Value) -> Result<(usize, u64), Box<dyn Error>> {
let bodies = scene["bodies"].as_array().ok_or("missing scene bodies")?;
let triangles = bodies
.iter()
.try_fold(0u64, |sum, body| sum.checked_add(body["triangles"].as_u64().ok_or("missing triangle count")?).ok_or("triangle sum overflow"))?;
Ok((bodies.len(), triangles))
}
fn physical(master: &RenderSetup, cpu: bool, pilot: bool) -> Result<Value, Box<dyn Error>> {
let mut setup = serde_json::to_value(master)?;
let mut optics = setup["optics"].as_array().ok_or("missing optics")?.clone();
for binding in &mut optics {
binding["optics"]["shadow_transmission"] = json!(false);
}
setup["optics"] = json!(optics);
setup["mode"] = json!("final");
setup["width"] = json!(if pilot { 320 } else { 3840 });
setup["height"] = json!(if pilot { 180 } else { 2160 });
let samples = if pilot { 8u32 } else { 4096u32 };
let mut options = setup["job"]["options"].clone();
options["texture_filtering"] = json!(true);
options["linear_exr"] = json!(true);
options["max_depth"] = json!(64);
options["samples"] = json!(samples);
options["nested_media"] = json!(true);
options["denoise"] = json!(false);
if cpu {
let lighting = &options["lighting"];
let area_lights = lighting["area_lights"]
.as_array()
.ok_or("missing area lights")?
.iter()
.map(|a| {
let radiance: [f64; 3] = serde_json::from_value(a["radiance"].clone())?;
let intensity = radiance.into_iter().fold(0.0, f64::max);
let color = radiance.map(|v| if intensity > 0.0 { v / intensity } else { 0.0 });
// GPU stores half-edges; the CPU rectangle contract takes full sides.
let u: [f64; 3] = serde_json::from_value(a["u"].clone())?;
let v: [f64; 3] = serde_json::from_value(a["v"].clone())?;
Ok(json!({"center_mm":a["center"],"edge_u_mm":u.map(|x|x*2.0),"edge_v_mm":v.map(|x|x*2.0),"color":color,"intensity":intensity}))
})
.collect::<Result<Vec<Value>, serde_json::Error>>()?;
let cpu_options = json!({
"budget":"workstation", "texture_filtering":true,
"progressive":{"samples_per_pixel":samples,"seed":4619},
"path":{"max_depth":64}, "primary_samples":1, "shadows":true,
"lights":[], "area_lights":area_lights,"area_samples":1,
"punctual_lights":lighting["punctual_lights"],
"environment":{"color":lighting["environment"],"intensity":1.0},
"aging":options["aging"], "denoise":false,
"output":{"tone_map":options["tone_map"],"linear_exr":true}
});
setup["job"] = json!({"method":"cpu_raytrace","transparent_background":false,"options":cpu_options});
} else {
setup["job"]["options"] = options;
}
let checked: RenderSetup = serde_json::from_value(setup.clone())?;
checked.validate()?;
require(checked.camera == master.camera, "reference camera differs from Master")?;
require(checked.surfaces == master.surfaces && checked.coats == master.coats, "reference lost surface maps or coats")?;
Ok(setup)
}
fn crease_audit(mesh: &MeshData) -> Result<Value, Box<dyn Error>> {
let positions: Vec<Vec3> = mesh.positions.as_chunks::<3>().0.iter().map(|p| Vec3::new(p[0], p[1], p[2])).collect();
let triangles = mesh.triangles.as_chunks::<3>().0;
let mut weld = std::collections::HashMap::new();
let mut canonical = Vec::with_capacity(positions.len());
for p in &positions {
let next = weld.len();
canonical.push(*weld.entry([(p.x + 0.0).to_bits(), (p.y + 0.0).to_bits(), (p.z + 0.0).to_bits()]).or_insert(next));
}
let mut edges = std::collections::HashMap::<(usize, usize), (usize, Option<usize>, usize)>::new();
let mut face_normals = Vec::with_capacity(triangles.len());
for (i, t) in triangles.iter().enumerate() {
let [a, b, c] = t.map(|k| positions.get(k as usize).copied());
let (Some(a), Some(b), Some(c)) = (a, b, c) else { return Err("audit index missing".into()) };
face_normals.push((b - a).cross(c - a).normalized().ok_or("audit triangle degenerate")?);
for k in 0..3 {
let (a, b) = (canonical[t[k] as usize], canonical[t[(k + 1) % 3] as usize]);
edges
.entry((a.min(b), a.max(b)))
.and_modify(|e| {
e.1 = Some(i * 3 + k);
e.2 += 1;
})
.or_insert((i * 3 + k, None, 1));
}
}
let normal = |corner: usize| -> Result<Vec3, Box<dyn Error>> {
let n = mesh.corner_normals.get(corner * 3..corner * 3 + 3).ok_or("audit normal missing")?;
Ok(Vec3::new(n[0], n[1], n[2]))
};
let mut bins = [0u64; 7];
let mut sharp = 0u64;
let mut sharp_discontinuous = 0u64;
let mut boundary_or_nonmanifold = 0u64;
for (_, (a, b, count)) in edges {
let Some(b) = b.filter(|_| count == 2) else {
boundary_or_nonmanifold += 1;
continue;
};
let angle = face_normals[a / 3].dot(face_normals[b / 3]).clamp(-1.0, 1.0).acos().to_degrees();
let bin = [1.0, 5.0, 10.0, 22.5, 45.0, 90.0].iter().position(|&max| angle <= max).unwrap_or(6);
bins[bin] += 1;
if angle > 10.0 + 1e-9 {
sharp += 1;
let (ak, bk) = (a % 3, b % 3);
let same_a = normal(a)?.dist(normal((b / 3) * 3 + (bk + 1) % 3)?) <= 1e-8;
let same_b = normal((a / 3) * 3 + (ak + 1) % 3)?.dist(normal(b)?) <= 1e-8;
if !same_a || !same_b {
sharp_discontinuous += 1;
}
}
}
Ok(json!({"manifold_edge_angle_bins_degrees":[[0,1],[1,5],[5,10],[10,22.5],[22.5,45],[45,90],[90,180]],
"manifold_edge_counts":bins,"geometric_sharp_edges_above_10_degrees_before":sharp,
"geometric_sharp_edges_above_10_degrees_after":sharp,"sharp_edges_with_shading_discontinuity":sharp_discontinuous,
"boundary_or_nonmanifold_edges":boundary_or_nonmanifold}))
}
fn main() -> Result<(), Box<dyn Error>> {
let mut args = std::env::args_os().skip(1);
let baseline = PathBuf::from(args.next().ok_or("usage: opus_quality_fixture BASELINE.sc NEW_OUTPUT_DIRECTORY")?);
let output = PathBuf::from(args.next().ok_or("missing new output directory")?);
require(args.next().is_none(), "unexpected argument")?;
fs::create_dir(&output)?;
let original = fs::read(&baseline)?;
let mut doc: Document = solidcraft_io::read_design(&original)?;
let master = doc.render.setups.get("OPUS Master").cloned().ok_or("missing OPUS Master")?;
require(master.surfaces.len() == 104 && master.optics.len() == 30 && master.coats.len() == 6, "fixture bindings differ from OPUS baseline")?;
require(doc.components.len() == 82 && doc.assembly.joints.len() == 81 && doc.assembly.links.len() == 15, "fixture assembly counts differ")?;
let baseline_doc = doc.clone();
let mut baseline_session = Session::new(doc.clone());
let baseline_scene = baseline_session.execute("render.scene", &json!({}))?;
require(scene_counts(&baseline_scene)? == (511, 729874), "baseline scene geometry counts differ")?;
let mut edits = Vec::new();
for feature in &mut doc.features {
if let FeatureKind::MeshImport { meshes, .. } = &mut feature.kind {
for mesh in meshes {
if CURVED.contains(&mesh.name.as_str()) {
let positions = mesh.positions.clone();
let triangles = mesh.triangles.clone();
mesh.generate_crease_normals(10.0)?;
require(
Arc::ptr_eq(&positions, &mesh.positions) && Arc::ptr_eq(&triangles, &mesh.triangles),
"normal generation changed geometry arrays",
)?;
edits.push(json!({"feature":feature.id,"body":mesh.name,"crease_angle_degrees":10.0,"triangles":mesh.triangles.len()/3,"normal_values":mesh.corner_normals.len(),"crease_audit":crease_audit(mesh)?}));
}
}
}
}
require(edits.len() == CURVED.len(), "a curated normal target is missing or duplicated")?;
doc.name = format!("{} — curated 10 degree shading reference", doc.name);
let mut session = Session::new(doc);
let mut setup_receipts = Vec::new();
for (cpu, pilot, name) in [
(true, false, "OPUS Physical CPU 4K"),
(false, false, "OPUS Physical GPU 4K"),
(true, true, "OPUS Physical CPU Pilot"),
(false, true, "OPUS Physical GPU Pilot"),
] {
let setup = physical(&master, cpu, pilot)?;
setup_receipts.push(session.execute("render.settings.set", &json!({"name":name,"set":setup}))?);
}
require(session.doc.render.setups.get("OPUS Master") == Some(&master), "original Master setup changed")?;
let scene = session.execute("render.scene", &json!({}))?;
require(scene == baseline_scene, "shading changed captured geometry, names, bounds or stable targets")?;
require(
session.doc.components == baseline_doc.components
&& session.doc.occurrences == baseline_doc.occurrences
&& session.doc.assembly == baseline_doc.assembly,
"assembly, pose or occurrence metadata changed",
)?;
let copy_path = output.join("OPUS-04-physical-reference.sc");
let copy = solidcraft_io::write_design_container(&session.doc)?;
fs::write(&copy_path, &copy)?;
let reopened = solidcraft_io::read_design(&copy)?;
require(reopened == *session.doc, "copy changed on cold reopen")?;
fs::write(output.join("baseline-scene.json"), serde_json::to_vec_pretty(&baseline_scene)?)?;
fs::write(output.join("reference-setups.json"), serde_json::to_vec_pretty(&setup_receipts)?)?;
let report = json!({
"baseline":baseline,"reference":copy_path,"baseline_bytes":original.len(),"reference_bytes":copy.len(),
"geometry":{"bodies":511,"triangles":729874,"unchanged":true},
"assembly":{"components":82,"joints":81,"couplings":15,"unchanged":true},
"bindings_per_reference":{"surfaces":104,"optics":30,"coats":6,"aging":7,"area_lights":4,"punctual_lights":7},
"curated_normal_edits":edits,"master_unchanged":true,"camera_unchanged":true,
"assembly_and_saved_pose_unchanged":true,"recorded_motion_step15_applied":false,
"reference_shadow_transmission":false,"render_started":false,
"qualification":"fixture preparation and public settings/scene commands only; no transport convergence claim"
});
fs::write(output.join("fixture-manifest.json"), serde_json::to_vec_pretty(&report)?)?;
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
+6 -3
View File
@@ -421,10 +421,13 @@ fn rows(s: &Session, ix: &Index, counts: &BTreeMap<u64, u64>, group: Group, kits
out
}
/// The bill of materials as CSV: fixed columns, every field quoted, and a field a spreadsheet
/// would run as a formula written as text.
/// A CSV field, always quoted.
fn quote(s: &str) -> String {
format!("\"{}\"", s.replace('"', "\"\""))
}
/// The bill of materials as CSV: fixed columns, every field quoted.
fn csv(rows: &[Value]) -> String {
let quote = solidcraft_doc::csv::quoted;
let mut out = ["item", "part_number", "description", "revision", "count", "definition"].map(quote).join(",");
out.push('\n');
let lines = rows.iter().flat_map(|r| std::iter::once(r).chain(r["children"].as_array().into_iter().flatten()));
-14
View File
@@ -201,20 +201,6 @@ fn csv_quotes_every_field_and_json_writes_the_rows() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A field that a spreadsheet would run as a formula gets a `'` first, so it opens as text
/// (#12728). A count stays a number.
#[test]
fn a_csv_field_a_spreadsheet_would_run_opens_as_text() {
let dir = scratch("formula");
let mut s = assembly();
run(&mut s, "component.metadata.set", json!({"component": "Plate", "part_number": "=1+1", "description": "-5 mm shim", "revision": "@A"}));
let csv = dir.join("bom.csv");
run(&mut s, "assembly.bom", json!({"path": csv}));
let text = std::fs::read_to_string(&csv).unwrap();
assert!(text.contains("\n\"1\",\"'=1+1\",\"'-5 mm shim\",\"'@A\",\"3\",\"Plate\"\n"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn standard_parts_carry_a_part_number_that_follows_a_resize() {
let mut s = Session::default();
-16
View File
@@ -30,14 +30,6 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&ASSISTANT_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::edit::AssistantResult>)
.params("Show or hide Assistant in the app. Headless calls are refused without changing the design"),
CommandSpec::new("ui.palette", "Search Commands", crate::checked!("ui.palette", palette))
.icon("zoom")
.key("S")
.noundo()
.doc_only()
.contract(&PALETTE_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::edit::PaletteResult>)
.params("Open command search in the app. Headless calls are refused without changing the design"),
CommandSpec::new("edit.undo", "Undo", crate::checked!("edit.undo", undo))
.icon("undo")
.key("Ctrl+Z")
@@ -265,14 +257,6 @@ fn assistant(_s: &mut Session, _p: &Value) -> Result<Value> {
Err(EngineError::Other("ui.assistant shows or hides Assistant in the app; no assistant panel exists in a headless session".into()))
}
const PALETTE_CONTRACT: crate::schema::TypedContract<crate::schema::edit::PaletteParams> = crate::schema::TypedContract::new();
/// Command search (#12190) is the app's: it opens in `SolveApp::start`, and its key can be moved
/// in Preferences › Shortcuts like any other command's.
fn palette(_s: &mut Session, _p: &Value) -> Result<Value> {
Err(EngineError::Other("ui.palette opens command search in the app; there is no search box in a headless session".into()))
}
const REPEAT_CONTRACT: crate::schema::TypedContract<crate::schema::edit::RepeatParams> = crate::schema::TypedContract::new();
/// Repeat Last Command (#7077) starts a tool, which only the app has: it starts the last one in
-6
View File
@@ -1687,12 +1687,6 @@ fn assistant_toggle_is_refused_headlessly_without_changing_the_document() {
assert_eq!((s.doc.to_json(), s.undo.len()), before);
let spec = crate::find_command("ui.assistant").unwrap();
assert_eq!((spec.shortcut, spec.tab, spec.undoable), (Some("Ctrl+Shift+A"), "", false));
// Command search likewise (#12190): S is its default key, not a reserved one.
let error = s.execute("ui.palette", &json!({})).unwrap_err().to_string();
assert!(error.contains("headless session"), "{error}");
assert_eq!((s.doc.to_json(), s.undo.len()), before);
let spec = crate::find_command("ui.palette").unwrap();
assert_eq!((spec.shortcut, spec.tab, spec.undoable), (Some("S"), "", false));
}
#[test]
@@ -261,9 +261,9 @@ fn an_older_two_sided_extrude_with_a_negative_distance_keeps_its_sides() {
let mut base = fixture(json!({"direction":"positive","distance":"10 mm","distance2":"10 mm"}));
run(&mut base, "parameters.add", json!({"name":"width","expression":"20 mm"}));
let mut saved: Value = serde_json::from_str(&base.doc.to_json()).unwrap();
// Format 50 predates the v51 second-side migration. A moving current-1 tag
// can already include that migration after unrelated format changes.
saved["format"] = json!("solidcraft/50");
// As the last release saved it: the format before this build's pending changes.
let version: u32 = saved["format"].as_str().unwrap().trim_start_matches("solidcraft/").parse().unwrap();
saved["format"] = json!(format!("solidcraft/{}", version - 1));
saved.as_object_mut().unwrap().remove("format_pending");
for (distance, lo, hi) in [("-width", -20.0, 10.0), ("-0 mm", -10.0, 0.0), ("-10 mm", -10.0, 10.0)] {
let mut file = saved.clone();
+5 -15
View File
@@ -15,16 +15,14 @@ use crate::params::{UNKNOWN_COMMAND, bad, bool_, expr, expr_problem, req_expr, s
use crate::schema::features::SweepParams;
use crate::schema::features::{
AppliedRadius, BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams,
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, RevolveParams,
ScaleParams, SphereParams, TorusParams,
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams,
SphereParams, TorusParams,
};
use crate::schema::split::{SplitBodyParams, SplitFaceParams};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const BOX_CONTRACT: TypedContract<BoxParams> = TypedContract::new();
const EXTRUDE_CONTRACT: TypedContract<ExtrudeParams> = TypedContract::new();
const REVOLVE_CONTRACT: TypedContract<RevolveParams> = TypedContract::new();
const CYLINDER_CONTRACT: TypedContract<CylinderParams> = TypedContract::new();
const SPHERE_CONTRACT: TypedContract<SphereParams> = TypedContract::new();
const TORUS_CONTRACT: TypedContract<TorusParams> = TypedContract::new();
@@ -38,8 +36,6 @@ const OFFSET_PLANE_CONTRACT: TypedContract<OffsetPlaneParams> = TypedContract::n
const PLANE_AT_ANGLE_CONTRACT: TypedContract<PlaneAtAngleParams> = TypedContract::new();
const FILLET_CONTRACT: TypedContract<FilletParams> = TypedContract::new();
const CHAMFER_CONTRACT: TypedContract<ChamferParams> = TypedContract::new();
const SPLIT_FACE_CONTRACT: TypedContract<SplitFaceParams> = TypedContract::new();
const SPLIT_BODY_CONTRACT: TypedContract<SplitBodyParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", extrude))
@@ -49,11 +45,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&EXTRUDE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("distance: expr (or through_all: true); taper?: angle expr (positive: outward, so the far end is larger along the extrusion direction; negative: inward, so the far end is smaller); corner?: preserve_curvature|tangent|round (how a taper closes a sharp corner its offset opens: each wall continues as itself, a line's plane or an arc's cone, until it meets its neighbour; each continues straight along its end tangent; or an arc about the corner. Without it, each profile preserves curvature where every curve is a line or an arc and takes tangent where another curve has a corner, and the feature's warning says so; a corner rule given is kept, and refused where the kernel cannot build it); sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile; a pick on a drawn profile that reference geometry, such as the projected outline of the face the sketch is on, cuts into regions takes the whole drawn profile); exact_regions?: bool (each index or point pick is exactly the region it names, as the dialogs send); default: every drawn profile, except that a loop inside another is its hole and a loop inside that hole is material again; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (how a symmetric distance is read; default total_distance, split evenly around the start plane; legacy_per_side: the distance on each side, as older symmetric extrudes keep); distance2?: expr (Two Sides: a second distance on the side direction leaves free, the first side may be through_all or to; with direction: negative the first side goes against the normal and distance2 along it; a negative distance only turns its own side over); start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); touching?: separate|one_body (what a join makes of pieces that share no face: separate, the default, makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
CommandSpec::new("solid.revolve", "Revolve", crate::checked!("solid.revolve", revolve))
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
.contract(&REVOLVE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | {type: world, axis: X|Y|Z} (as returned by timeline.inspect) | construction axis name (followed when edited) | {origin, dir} (origin: numbers or length expressions, followed when parameters change) | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, touching?, targets?, name?, body_name?"),
CommandSpec::new("solid.sweep", "Sweep", crate::checked!("solid.sweep", sweep))
.at("SOLID", "CREATE")
@@ -179,19 +173,15 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&PLANE_AT_ANGLE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; axis: X|Y|Z|construction axis name (where it is now)|{origin, dir} | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle: expr; direction_only?: bool (default false: turn about the axis as a line; true: project the axis direction onto base and turn about the line through pivot, or the base origin); pivot?: point ref (only with direction_only; vertex, sketch point, construction point); name?"),
CommandSpec::new("solid.split_face", "Split Face", crate::checked!("solid.split_face", split_face))
CommandSpec::new("solid.split_face", "Split Face", split_face)
.at("SOLID", "MODIFY")
.icon("split_face")
.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)",
),
CommandSpec::new("solid.split_body", "Split Body", crate::checked!("solid.split_body", split_body))
CommandSpec::new("solid.split_body", "Split Body", split_body)
.at("SOLID", "MODIFY")
.icon("split_body")
.contract(&SPLIT_BODY_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id, line: curve id (a straight sketch line, along the sketch's normal)"),
CommandSpec::new("solid.press_pull", "Press Pull", press_pull)
.at("SOLID", "MODIFY")
+28 -78
View File
@@ -13,7 +13,6 @@ use super::features::{
add_feature, axis_dir_param, check_expr, feature_sketch, length_operands, plane_param, point_param, profiles, typed_length_unit,
};
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::schema::align::AlignParams;
use crate::schema::features::FeatureResult;
use crate::schema::features_more::{BoundaryFillParams, RemoveParams, RibParams};
use crate::schema::{TypedContract, output_schema_for};
@@ -21,7 +20,6 @@ use crate::{Result, Session};
/// Rib and Web take the same keys.
const RIB_CONTRACT: TypedContract<RibParams> = TypedContract::new();
const ALIGN_CONTRACT: TypedContract<AlignParams> = TypedContract::new();
const REMOVE_CONTRACT: TypedContract<RemoveParams> = TypedContract::new();
const BOUNDARY_FILL_CONTRACT: TypedContract<BoundaryFillParams> = TypedContract::new();
@@ -60,11 +58,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("replace_face")
.params("faces: [[x,y,z] points on planar faces, or face names from model.faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
CommandSpec::new("solid.align", "Align", crate::checked!("solid.align", align))
CommandSpec::new("solid.align", "Align", align)
.at("SOLID", "MODIFY")
.icon("align")
.contract(&ALIGN_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("bodies: [names]; from: [x,y,z] | \"origin\" | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z] | from_plane: XY|XZ|YZ|construction or offset plane name or id|{origin, normal}; to: (the same) | to_face: [x,y,z] | to_plane: (a plane as from_plane; the point or face goes onto it along its normal, and the plane may move later: the bodies follow); from_frame / to_frame: a frame found again whenever the design changes, so the bodies follow it, in place of that side's point, face or plane: a joint origin as joint.create takes one ({face|circle|edge|point: [x,y,z]}, a face's name, a named joint origin) or {sketch, point|curve} (a sketch point, or a circle's, arc's or ellipse's centre, z along the sketch's normal), on the bodies' own component; faces, planes and frames also turn to meet; flip?: bool; axes?: [x, y, z] (point-to-point only: which world axes the offset moves along; default all three, at least one true); angle?: expr (a turn about the target's direction after the move)"),
CommandSpec::new("solid.remove", "Remove", crate::checked!("solid.remove", remove))
.at("SOLID", "MODIFY")
@@ -425,58 +421,21 @@ fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::ReplaceFace { faces: faces.into_iter().map(FacePick).collect(), target, body: body.map(BodyName) })
}
/// What `solid.align` says when `axes` is not three booleans, or when none is true.
pub(crate) const AXES_SHAPE: &str = "`axes` must be [x, y, z] booleans";
pub(crate) const AXES_NONE: &str = "`axes` must enable at least one of X, Y and Z";
/// `axes` as `solid.align` reads it: three booleans, one per world axis, at least one of them
/// true; the message is the handler's.
pub(crate) fn axes_mask(v: &Value) -> std::result::Result<[bool; 3], &'static str> {
let a = v.as_array().filter(|a| a.len() == 3).ok_or(AXES_SHAPE)?;
let mut mask = [false; 3];
for (slot, v) in mask.iter_mut().zip(a) {
*slot = v.as_bool().ok_or(AXES_SHAPE)?;
}
if !mask.iter().any(|on| *on) {
return Err(AXES_NONE);
}
Ok(mask)
}
/// `axes`: three booleans, one per world axis, for a point-to-point Align.
/// `None` when the call does not restrict the move.
fn axes_param(p: &Value, cmd: &str) -> Result<Option<[bool; 3]>> {
let Some(v) = p.get("axes").filter(|v| !v.is_null()) else { return Ok(None) };
let mask = axes_mask(v).map_err(|why| bad(cmd, why))?;
let a = v.as_array().filter(|a| a.len() == 3).ok_or_else(|| bad(cmd, "`axes` must be [x, y, z] booleans"))?;
let mut mask = [false; 3];
for (slot, v) in mask.iter_mut().zip(a) {
*slot = v.as_bool().ok_or_else(|| bad(cmd, "`axes` must be [x, y, z] booleans"))?;
}
if !mask.iter().any(|on| *on) {
return Err(bad(cmd, "`axes` must enable at least one of X, Y and Z"));
}
Ok((mask != [true; 3]).then_some(mask))
}
/// An Align side given as a point (`from` or `to`), as the handler reads it.
pub(crate) enum SidePoint {
/// The text `origin` (any case): the world origin.
Origin,
/// `{snap?, at}`: the nearest `snap` (vertex unless the object says another) to `at`.
Snap { at: Vec3, kind: String },
/// `[x, y, z]`.
Point(Vec3),
}
/// The side `pk` (`from` or `to`) as a point, with the handler's message when it is none of the
/// forms; `fk` is the face key beside it (`from_face`).
pub(crate) fn side_point(v: Option<&Value>, pk: &str, fk: &str) -> std::result::Result<SidePoint, String> {
// The origin point.
if v.and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("origin")) {
return Ok(SidePoint::Origin);
}
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
if let Some(o) = v.filter(|v| v.is_object()) {
let at = o.get("at").and_then(vec3).ok_or_else(|| format!("`{pk}.at` must be [x, y, z]"))?;
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex").to_string();
return Ok(SidePoint::Snap { at, kind });
}
v.and_then(vec3).map(SidePoint::Point).ok_or_else(|| format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)"))
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.align";
let bodies = string_list(cmd, p, "bodies")?;
@@ -533,13 +492,20 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
let n = super::face::face_normal_world(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
return Ok((fp, Some(n)));
}
match side_point(p.get(pk), pk, fk).map_err(|why| bad(cmd, why))? {
SidePoint::Origin => Ok((Vec3::ZERO, None)),
SidePoint::Snap { at, kind } => {
snap_point(&*s.world_state()?, &kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")))
}
SidePoint::Point(v) => Ok((v, None)),
// The origin point.
if p.get(pk).and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("origin")) {
return Ok((Vec3::ZERO, None));
}
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
if let Some(o) = p.get(pk).filter(|v| v.is_object()) {
let at = o.get("at").and_then(vec3).ok_or_else(|| bad(cmd, format!("`{pk}.at` must be [x, y, z]")))?;
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex");
return snap_point(&*s.world_state()?, kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")));
}
p.get(pk)
.and_then(vec3)
.map(|v| (v, None))
.ok_or_else(|| bad(cmd, format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)")))
};
let pick = |s: &Session, pk: &str, fk: &str, held: bool| -> Result<(Vec3, Option<Vec3>)> {
let (at, n) = world_pick(s, pk, fk, held)?;
@@ -602,24 +568,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The longest sketch point or curve id an Align frame takes.
pub(crate) const MAX_SKETCH_ID: usize = 256;
/// The point or curve of an Align `{sketch, point|curve}` frame: an id of 1 to
/// [`MAX_SKETCH_ID`] characters. `None` when the frame gives both, neither or an id that is not
/// one.
pub(crate) enum SketchFrameId {
Point(String),
Curve(String),
}
pub(crate) fn sketch_frame_id(v: &Value) -> Option<SketchFrameId> {
let id = |k: &str| str_(v, k).filter(|n| !n.is_empty() && n.chars().count() <= MAX_SKETCH_ID).map(str::to_string);
match (id("point"), id("curve")) {
(Some(point), None) => Some(SketchFrameId::Point(point)),
(None, Some(curve)) => Some(SketchFrameId::Curve(curve)),
_ => None,
}
}
const MAX_SKETCH_ID: usize = 256;
/// An Align side's frame (#6257), `<key>_frame`: a joint origin as `joint.create` takes one, or a
/// sketch point or the centre of a sketch circle, arc or ellipse (`{sketch, point|curve}`), on the
@@ -636,10 +585,11 @@ fn align_frame(s: &Session, p: &Value, key: &str, bodies: &[String]) -> Result<O
let (frame, on) = match v.get("sketch") {
Some(sk) => {
let sketch = super::features::sketch_id(s, Some(sk), cmd, &fk)?;
let frame = match sketch_frame_id(v) {
Some(SketchFrameId::Point(point)) => AlignFrame::SketchPoint { sketch: SketchId(sketch), point },
Some(SketchFrameId::Curve(curve)) => AlignFrame::SketchCurve { sketch: SketchId(sketch), curve },
None => return Err(bad(cmd, format!("`{fk}`: a sketch frame takes `point` or `curve` (an id of 1 to {MAX_SKETCH_ID} characters)"))),
let id = |k: &str| str_(v, k).filter(|n| !n.is_empty() && n.chars().count() <= MAX_SKETCH_ID).map(str::to_string);
let frame = match (id("point"), id("curve")) {
(Some(point), None) => AlignFrame::SketchPoint { sketch: SketchId(sketch), point },
(None, Some(curve)) => AlignFrame::SketchCurve { sketch: SketchId(sketch), curve },
_ => return Err(bad(cmd, format!("`{fk}`: a sketch frame takes `point` or `curve` (an id of 1 to {MAX_SKETCH_ID} characters)"))),
};
(frame, s.doc.feature(sketch).map_or(0, |f| f.component))
}
+31 -204
View File
@@ -23,26 +23,12 @@ use solidcraft_io::Format;
use super::CommandSpec;
use super::print_face;
use crate::params::{bad, bool_, num, str_, string_list};
use crate::schema::file::ExportResult;
use crate::schema::insert_mesh::{InsertMeshParams, MeshInserted};
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};
use crate::{EngineError, Result, Session};
const INSERT_MESH_CONTRACT: TypedContract<InsertMeshParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("file.new", "New Design", crate::checked!("file.new", new_doc))
.icon("new")
.key("Ctrl+N")
.noundo()
.contract(&NEW_DESIGN_CONTRACT)
.output(output_schema_for::<NewDesignResult>)
.params("name?"),
CommandSpec::new("file.new", "New Design", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"),
CommandSpec::new("doc.open", "Open", open)
.icon("open")
.key("Ctrl+O")
@@ -52,12 +38,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "INSERT")
.icon("import")
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?, group?: the folder); folder?: bool (default false; true: an import that brings more than one item into the history (its Import feature and the rigid joints holding its parts) goes into a new history folder named `Group #N`); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: auto|solid|product|instance (default auto: keep meaningful solid names, replace placeholders with the nearest named product, else instance; solid: the solid's own name; product: the part's name; instance: the occurrence's name, else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
CommandSpec::new("file.insert_mesh", "Insert Mesh", crate::checked!("file.insert_mesh", insert_mesh))
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
.at("SOLID", "INSERT")
.icon("import")
.contract(&INSERT_MESH_CONTRACT)
.output(output_schema_for::<MeshInserted>)
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another; origin?: bool (default false: the mesh keeps its file's coordinates; true: once read, scaled and stood up, the import's occurrence is moved so the box around its bodies is centred on X and Y of the active component with its lowest point on Z = 0 → moved_to_origin: [x, y, z], the shift made); crease_angle_degrees?: 0..180 (0 clears imported shading normals for the previous flat-shading behavior; positive angles regenerate angle-weighted normals across manifold edges below this crease angle; omitted keeps authored OBJ normals/smoothing groups, or flat shading when absent; geometry stays unchanged)"),
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another; origin?: bool (default false: the mesh keeps its file's coordinates; true: once read, scaled and stood up, the import's occurrence is moved so the box around its bodies is centred on X and Y of the active component with its lowest point on Z = 0 → moved_to_origin: [x, y, z], the shift made)"),
CommandSpec::new("file.save", "Save", crate::checked!("file.save", save))
.icon("save")
.key("Ctrl+S")
@@ -79,11 +63,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.params("id (from doc.recovery_list); dir?; discard?: bool (delete the entry once open) — opens it with its file path, unsaved"),
CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).icon("delete").noundo().params("id, or all: true; dir?"),
CommandSpec::new("file.export", "Export", crate::checked!("file.export", export))
CommandSpec::new("file.export", "Export", export)
.icon("export")
.noundo()
.contract(&EXPORT_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::FileExportResult>)
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|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; 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 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)"),
// 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
@@ -118,12 +100,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&EXPORT_URDF_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::UrdfExportResult>)
.params("path; mesh_prefix?; effort? (default 1000); velocity? (default 10) → {path, format, files, links, joints, warnings}: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md"),
CommandSpec::new("file.save_mesh", "Save As Mesh", crate::checked!("file.save_mesh", save_mesh))
CommandSpec::new("file.save_mesh", "Save As Mesh", save_mesh)
.at("INSERT", "FILES")
.icon("mesh_export")
.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)"),
CommandSpec::new("file.send_to_slicer", "Send to Slicer", send_to_slicer)
.enabled(starts_programs)
@@ -135,11 +115,8 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("mesh_export")
.noundo()
.params("consent: bool (required: true only once the user has agreed to upload the model to Printables' servers; it stays there for 30 minutes and opening it needs a Printables sign-in); bodies?: [names] (default: the selected bodies); format?: 3mf|stl (default 3mf, in millimetres) → uploads the bodies to EasyPrint, Printables' slicer in the browser, and opens it in the browser. 100 MB at most. Works in the desktop app and the browser build; the CLI has no way to upload yet"),
CommandSpec::new("file.slicers", "Installed Slicers", crate::checked!("file.slicers", slicers))
.icon("export")
CommandSpec::new("file.slicers", "Installed Slicers", slicers).icon("export")
.noundo()
.contract(&SLICERS_CONTRACT)
.output(output_schema_for::<SlicersResult>)
.params("→ the slicers found on this machine: [{id, name, program, icon?}]; pass an id, a name or the program path as file.send_to_slicer's or file.save_mesh's `slicer`"),
];
@@ -148,22 +125,16 @@ fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
s.replace_design(Session::new(Document::new(name)));
// A name given here is the user's own, so Save As keeps it (#4907); "Untitled" is none.
s.user_named = !name.trim().is_empty() && name.trim() != DEFAULT_NAME;
let reply = NewDesignResult { name: name.to_string(), param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| bad("file.new", e.to_string()))
Ok(json!({"name": name}))
}
const NEW_DESIGN_CONTRACT: TypedContract<NewDesignParams> = TypedContract::new();
const EXPORT_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportParams> = crate::schema::TypedContract::new();
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::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();
const EXPORT_URDF_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportUrdfParams> = crate::schema::TypedContract::new();
const SAVE_MESH_CONTRACT: crate::schema::TypedContract<crate::schema::save_mesh::SaveMeshParams> = crate::schema::TypedContract::new();
fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))
@@ -299,19 +270,11 @@ fn created_bodies(s: &Session, feature: u64) -> Vec<String> {
/// Add a MeshImport feature for a 3MF, STL, OBJ or glTF file, in a new component named after the
/// file. A glTF file's nodes become components in it, and its materials the bodies' looks; it is
/// in metres with Y up, so unless `y_up` is false its component is turned to Z up (#1505).
fn add_mesh(
s: &mut Session,
path: &str,
name: Option<&str>,
y_up: Option<bool>,
unit: Option<(&'static str, f64)>,
origin: bool,
crease_angle: Option<f64>,
) -> Result<Value> {
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>, unit: Option<(&'static str, f64)>, origin: bool) -> Result<Value> {
let err = |e: solidcraft_io::IoError| EngineError::Other(format!("{path}: {e}"));
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_3MF_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let gltf = solidcraft_io::is_gltf_path(path);
let (mut mf, plan, looks) = if gltf {
let (mf, plan, looks) = if gltf {
let dir = std::path::Path::new(path).parent().map(std::path::Path::to_path_buf).unwrap_or_default();
let mut load =
|name: &str| solidcraft_io::vfs::read(&dir.join(name).to_string_lossy(), solidcraft_io::MAX_GLTF_BYTES).map_err(|e| e.to_string());
@@ -323,19 +286,6 @@ fn add_mesh(
} else {
(solidcraft_io::mesh_import_feature_in(&bytes, path, unit.map_or(1.0, |(_, mm)| mm)).map_err(err)?, None, Vec::new())
};
if let Some(angle) = crease_angle
&& let solidcraft_doc::FeatureKind::MeshImport { meshes, .. } = &mut mf.kind
{
for mesh in meshes {
if angle == 0.0 {
// Keep the original flat display path, including its normalization
// and storage behavior, when the caller opts out of imported shading.
mesh.corner_normals = Default::default();
} else {
mesh.generate_crease_normals(angle).map_err(|e| bad("file.insert_mesh", e.to_string()))?;
}
}
}
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string();
let cname = import::component_name(path, name);
let tree = match &plan {
@@ -357,24 +307,18 @@ fn add_mesh(
// After the turn, so it is the stood-up mesh that sits on Z = 0.
let moved = if origin { import::move_to_origin(s, &placed)? } else { None };
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
let reply = MeshInserted {
path: path.to_string(),
feature: id,
name: fname,
bodies: bodies.clone(),
created_bodies: bodies,
warnings: mf.warnings,
component: placed.component,
component_name: placed.name.clone(),
occurrence: placed.occurrence,
subcomponents: (placed.components > 0).then_some(placed.components),
rigid_joints: (placed.components > 0).then_some(placed.joints.len()),
moved_to_origin: moved.map(|shift| [shift.x, shift.y, shift.z]),
units: unit.map(|(name, _)| name.to_string()),
stand_up: turned,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| bad("file.insert_mesh", e.to_string()))
let mut out = json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": mf.warnings});
placed.report(&mut out);
if let Some(shift) = moved {
out["moved_to_origin"] = json!([shift.x, shift.y, shift.z]);
}
if let Some((name, _)) = unit {
out["units"] = json!(name);
}
if let Some(turned) = turned {
out["stand_up"] = json!(turned);
}
Ok(out)
}
/// Turn an import's component a quarter turn about X (the file's Y up becomes Z up), contents
@@ -429,20 +373,7 @@ fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
}
let up = mesh_up(p, path, cmd)?;
let unit = mesh_units(p, path, cmd)?;
let crease_angle = mesh_crease_angle(p.get("crease_angle_degrees")).map_err(|message| bad(cmd, message))?;
add_mesh(s, path, str_(p, "name"), up, unit, mesh_origin(p, cmd)?, crease_angle)
}
/// The shading-only crease reader, shared by the handler and its typed contract.
pub(crate) fn mesh_crease_angle(value: Option<&Value>) -> std::result::Result<Option<f64>, &'static str> {
match value {
None | Some(Value::Null) => Ok(None),
Some(value) => value
.as_f64()
.filter(|v| v.is_finite() && (0.0..=180.0).contains(v))
.map(Some)
.ok_or("crease_angle_degrees must be between 0 and 180 degrees"),
}
add_mesh(s, path, str_(p, "name"), up, unit, mesh_origin(p, cmd)?)
}
/// `origin`: whether a mesh import is moved to the origin (#12543). Off unless asked, so a mesh
@@ -535,7 +466,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
let r = if solidcraft_io::is_step_path(path) {
add_step(&mut fresh, path, None, &step_options(p, "doc.open")?)?
} else {
add_mesh(&mut fresh, path, None, mesh_up(p, path, "doc.open")?, mesh_units(p, path, "doc.open")?, mesh_origin(p, "doc.open")?, None)?
add_mesh(&mut fresh, path, None, mesh_up(p, path, "doc.open")?, mesh_units(p, path, "doc.open")?, mesh_origin(p, "doc.open")?)?
};
// The design is the file: its component is the one being worked in.
if let Some(c) = r["component"].as_u64() {
@@ -919,7 +850,7 @@ fn saved_preview(s: &Session) -> Option<solidcraft_io::DesignPreview> {
for body in &world.bodies {
let mesh = body.cached_mesh()?;
bytes =
bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(84)).saturating_add(32);
bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(12)).saturating_add(24);
triangles = triangles.saturating_add(mesh.triangles.len());
if bytes > 8 << 20 || triangles > 100_000 {
return None;
@@ -929,7 +860,6 @@ fn saved_preview(s: &Session) -> Option<solidcraft_io::DesignPreview> {
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
triangles: mesh.triangles.iter().flatten().copied().collect(),
color: s.doc.body_color(body),
corner_normals: mesh.triangles.iter().flatten().filter_map(|&i| mesh.normals.get(i as usize)).flat_map(|n| [n.x, n.y, n.z]).collect(),
});
}
Some(preview)
@@ -1047,9 +977,7 @@ fn export_to(
solidcraft_io::export_with_parts(&s.doc.painted(&*s.world_state()?), bodies, parts, format, &name, &s.doc.metadata, quality)?
};
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:?}"), files: Vec::new(), param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")}))
}
/// [`export_to`] for a mesh file a slicer is sent (STL or 3MF): each body laid on its print face.
@@ -1067,16 +995,9 @@ fn export_laid_to(
let laid = print_face::lay_on_print_faces(s, &mut state, bodies, parts);
let bytes = solidcraft_io::export_with_parts(&state, bodies, parts, format, &name, &s.doc.metadata, quality)?;
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let reply = SaveMeshResult {
path: path.to_string(),
bytes: bytes.len(),
format: format!("{format:?}"),
print_faces: laid.bodies,
warnings: laid.warnings,
delivery: SlicerDelivery::default(),
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
let mut out = json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")});
laid.report(&mut out);
Ok(out)
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1295,16 +1216,11 @@ fn send_to_slicer(s: &mut Session, p: &Value) -> Result<Value> {
}
fn slicers(_: &mut Session, _: &Value) -> Result<Value> {
let slicers = crate::slicers::detect()
let list: Vec<Value> = crate::slicers::detect()
.iter()
.map(|d| SlicerInfo {
id: d.id.clone(),
name: d.name.clone(),
program: d.command.first().cloned(),
icon: d.icon.as_ref().map(|i| i.path().to_string_lossy().into_owned()),
})
.map(|d| json!({"id": d.id, "name": d.name, "program": d.command.first(), "icon": d.icon.as_ref().map(|i| i.path().to_string_lossy().into_owned())}))
.collect();
serde_json::to_value(SlicersResult { slicers, param_warnings: Vec::new() }).map_err(|e| bad("file.slicers", e.to_string()))
Ok(json!({ "slicers": list }))
}
fn recovery_dir(p: &Value, cmd: &str) -> Result<std::path::PathBuf> {
@@ -2082,95 +1998,6 @@ mod tests {
std::fs::remove_file(path).unwrap();
}
#[test]
fn mesh_import_explicit_crease_normals_are_undoable_and_survive_cached_reopen() {
let dir = std::env::temp_dir().join(format!("solidcraft-mesh-normals-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("fold.obj");
std::fs::write(&path, "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 -0.8660254037844386 0.5\nf 1 2 3\nf 2 1 4\n").unwrap();
let mut session = Session { param_check: crate::schema::ParamCheck::Strict, ..Session::default() };
let before = session.doc.to_json();
for bad_angle in [json!(-1), json!(181), json!("smooth")] {
let result = session.execute("file.insert_mesh", &json!({"path":path, "crease_angle_degrees":bad_angle}));
assert!(result.unwrap_err().to_string().contains("crease_angle_degrees"));
assert_eq!(session.doc.to_json(), before);
}
session.execute("file.insert_mesh", &json!({"path":path, "crease_angle_degrees":40})).unwrap();
let normals = |s: &Session| s.model.state().bodies[0].mesh().normals.clone();
let expected = normals(&session);
assert!(expected[0].y > 0.2 && expected[0].z > 0.9);
session.execute("edit.undo", &json!({})).unwrap();
assert!(session.model.state().bodies.is_empty());
session.execute("edit.redo", &json!({})).unwrap();
assert_eq!(normals(&session), expected);
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
let cached = solidcraft_io::read_design_geometry(&bytes).expect("shaded mesh retains native cache");
assert!(!cached.bodies[0].body.triangle_corner_normals().unwrap().is_empty());
let saved_path = dir.join("shaded.sc");
std::fs::write(&saved_path, &bytes).unwrap();
let mut reopened = Session::default();
reopened.execute("doc.open", &json!({"path":saved_path})).unwrap();
assert_eq!(normals(&reopened), expected);
let doc = solidcraft_io::read_design(&bytes).unwrap();
let cold = Session::new(doc);
assert_eq!(normals(&cold), expected);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn mesh_import_zero_crease_restores_legacy_flat_shading_and_survives_reopen() {
let dir = std::env::temp_dir().join(format!("solidcraft-obj-flat-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let vertices = "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 -0.8660254037844386 0.5\n";
let faces = "f 1 2 3\nf 2 1 4\n";
let reference_path = dir.join("reference.obj");
std::fs::write(&reference_path, format!("{vertices}{faces}")).unwrap();
let mut reference = Session { param_check: crate::schema::ParamCheck::Strict, ..Session::default() };
reference.execute("file.insert_mesh", &json!({"path":reference_path})).unwrap();
let display = |s: &Session| {
let mesh = s.model.state().bodies[0].mesh();
(mesh.positions.clone(), mesh.triangles.clone(), mesh.normals.clone())
};
let expected = display(&reference);
for (name, source) in
[("groups", format!("{vertices}s 1\n{faces}")), ("authored", format!("{vertices}vn 0 1 1\nf 1//1 2//1 3//1\nf 2//1 1//1 4//1\n"))]
{
let path = dir.join(format!("{name}.obj"));
std::fs::write(&path, source).unwrap();
let mut annotated = Session { param_check: crate::schema::ParamCheck::Strict, ..Session::default() };
annotated.execute("file.insert_mesh", &json!({"path":path})).unwrap();
assert_ne!(display(&annotated).2, expected.2, "{name} must exercise imported shading");
let mut session = Session { param_check: crate::schema::ParamCheck::Strict, ..Session::default() };
session.execute("file.insert_mesh", &json!({"path":path, "crease_angle_degrees":0})).unwrap();
assert_eq!(display(&session), expected, "{name} uses the exact former flat display path");
let meshes = session
.doc
.features
.iter()
.find_map(|f| match &f.kind {
solidcraft_doc::FeatureKind::MeshImport { meshes, .. } => Some(meshes),
_ => None,
})
.unwrap();
assert!(meshes.iter().all(|mesh| mesh.corner_normals.is_empty()));
session.execute("edit.undo", &json!({})).unwrap();
assert!(session.model.state().bodies.is_empty());
session.execute("edit.redo", &json!({})).unwrap();
assert_eq!(display(&session), expected);
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
let cached = solidcraft_io::read_design_geometry(&bytes).unwrap();
assert!(cached.bodies[0].body.triangle_corner_normals().unwrap().is_empty());
let saved_path = dir.join(format!("{name}.sc"));
std::fs::write(&saved_path, &bytes).unwrap();
let mut reopened = Session::default();
reopened.execute("doc.open", &json!({"path":saved_path})).unwrap();
assert_eq!(display(&reopened), expected);
let cold = Session::new(solidcraft_io::read_design(&bytes).unwrap());
assert_eq!(display(&cold), expected);
}
std::fs::remove_dir_all(dir).unwrap();
}
/// #9170: a design holding a mesh body (an inserted STL) keeps its saved model and opens from
/// it. One mesh body left the cache out for the whole model, so every feature, the boxes
/// too, was evaluated again on opening.
+5 -12
View File
@@ -13,7 +13,6 @@ use solidcraft_io::Format;
use super::easyprint::{Exported, export_bodies};
use super::export_laid_to;
use crate::params::bad;
use crate::schema::save_mesh::SlicerDelivery;
use crate::slicers::{self, Slicer, TargetKind};
use crate::{EngineError, Result, Session};
@@ -121,24 +120,18 @@ fn deliver_via(
out: &mut Value,
) {
let Some(obj) = out.as_object_mut() else { return };
// What happened, as the typed value the reply's schema describes; only its one key is set.
let mut say = |delivery: SlicerDelivery| {
if let Ok(Value::Object(keys)) = serde_json::to_value(delivery) {
obj.extend(keys);
}
};
// A target that drops a file given at a cold start is started alone, and sent the file once it is ready.
if wait != Wait::No && target.waits_for_ready().is_some() && target.ready_via(busctl, slicers::PROBE_LIMIT) == Some(false) {
if let Err(e) = target.start(single_instance) {
say(SlicerDelivery { slicer_error: Some(e), ..Default::default() });
obj.insert("slicer_error".into(), json!(e));
return;
}
if wait == Wait::Return {
say(SlicerDelivery { starting: Some(target.name.clone()), ..Default::default() });
obj.insert("starting".into(), json!(target.name));
return;
}
if !slicers::wait_for_ready_via(target, busctl, limit, poll) {
say(SlicerDelivery { slicer_timeout: Some(target.name.clone()), ..Default::default() });
obj.insert("slicer_timeout".into(), json!(target.name));
return;
}
// After a start the file has to reach the instance that is now running, so the flag goes
@@ -147,8 +140,8 @@ fn deliver_via(
}
// The file is saved either way; a program that won't start is reported, not fatal.
match target.open(path, single_instance) {
Ok(()) => say(SlicerDelivery { slicer: Some(target.name.clone()), ..Default::default() }),
Err(e) => say(SlicerDelivery { slicer_error: Some(e), ..Default::default() }),
Ok(()) => obj.insert("slicer".into(), json!(target.name)),
Err(e) => obj.insert("slicer_error".into(), json!(e)),
};
}
-2
View File
@@ -46,8 +46,6 @@ pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key};
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::mesh_crease_angle;
pub use file::open_steps::OpenSteps;
pub(crate) use inspect::color_param;
pub use join_pieces::{Addition, addition};
+1 -1
View File
@@ -913,7 +913,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
c += &format!(
"{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
Value::from(r.step),
solidcraft_doc::csv::field(&at.occurrence),
at.occurrence.replace(',', " "),
o[0],
o[1],
o[2],
-19
View File
@@ -687,25 +687,6 @@ fn motion_study_plays_and_exports() {
assert_eq!(run(&mut s, "motion.list", json!({}))["studies"].as_array().map(Vec::len), Some(0));
}
/// A placement's name in a motion CSV is one field, whatever it holds (#12728). A comma is kept in
/// quotes, where it used to become a space; a quote or a line break no longer splits the row; and a
/// name that a spreadsheet would run as a formula opens as text.
#[test]
fn a_motion_csv_keeps_each_placement_name_in_its_field() {
let mut s = rail();
run(&mut s, "motion.study", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]}));
run(&mut s, "component.rename", json!({"component": "B", "name": "=B, \"x\"\ny"}));
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-motion-names-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let csv = dir.join("names.csv");
run(&mut s, "motion.export", json!({"study": "Push", "samples": 2, "path": csv.to_string_lossy()}));
let text = std::fs::read_to_string(&csv).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(text.contains(",A:1,") && text.contains(",\"'=B, \"\"x\"\"\ny:1\","), "{text}");
// A header, a row for each placement at each of the two samples, and the line break in each of B's rows.
assert_eq!(text.matches('\n').count(), 1 + 2 * 2 + 2, "{text}");
}
/// Regression (#9741): `motion.study` takes degrees for angle keys, so `motion.list` must give
/// the keys back in those same units — feeding a listed study straight back to `motion.study`
/// must not turn a 90° key into 1.57°.
-21
View File
@@ -400,27 +400,6 @@ fn csv_and_json_round_trip() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A value a spreadsheet would run as a formula is exported as text, with a `'` first, and
/// imports back as it was (#12728). A plain number keeps its sign, and `;` makes a field quoted.
#[test]
fn csv_keeps_formulas_as_text_and_imports_them_back() {
let mut s = Session::default();
let rows = [("d3", "4", "plain"), ("a", "=d3", "+2 for the fit"), ("b", "=5", "'=kept"), ("c", "-5 mm", "@x"), ("e", "6", "one; two")];
for (name, expression, comment) in rows {
run(&mut s, "parameters.add", json!({"name": name, "expression": expression, "comment": comment}));
}
let csv = run(&mut s, "parameters.export", json!({"format": "csv"}))["text"].as_str().unwrap_or_default().to_string();
for written in [",'=d3,", ",'+2 for the fit,", ",'=5,", ",''=kept,", ",'-5 mm,-5,'@x,", ",\"one; two\","] {
assert!(csv.contains(written), "{written}: {csv}");
}
let mut t = Session::default();
run(&mut t, "parameters.import", json!({"text": csv}));
for (name, _, comment) in rows {
let (before, after) = (s.doc.param(name), t.doc.param(name));
assert_eq!(after.map(|p| (p.expr.as_str(), p.comment.as_str())), before.map(|p| (p.expr.as_str(), comment)), "{name}: {csv}");
}
}
#[test]
fn csv_round_trips_comments_with_line_breaks() {
for comment in ["line one\nline two", "\r\nfirst, \"quoted\"\r\n\r\nlast\r\n", "one\rtwo"] {
+9 -186
View File
@@ -7,10 +7,7 @@
//! once per hole, each placement with its own joint.
//!
//! Part frame: the seating plane is z = 0 (the underside of a head, the bottom of a nut or
//! washer, the top of a set screw or pin); the shank runs down −z. A countersunk screw is the
//! exception: its head sinks into the part, so z = 0 is the top of the head, level with the face.
//! Seated at a hole, its cone lies in the hole's 90 degree countersink: pick the rim where the
//! countersink meets the bore.
//! washer, the top of a set screw or pin); the shank runs down −z.
use serde_json::{Value, json};
use solidcore_record::Vec3;
@@ -44,12 +41,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&INSERT_CONTRACT)
.output(output_schema_for::<InsertedPart>)
.params(
"family (socket_head_cap_screw | button_head_cap_screw | countersunk_socket_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing | \
"family (socket_head_cap_screw | button_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing | \
hex_cap_screw_inch | hex_nut_inch | washer_sae | washer_uss); size (\"M6\", \"608\" for bearings, \"1/2-13 UNC\" for inch screws and nuts, \
\"1/2\" for inch washers); length? (mm, inch parts too; screws, bolts, set screws, pins; default a standard length near 2.5 d); \
at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint), or a list of them (one component, \
placed and seated once per hole) | point?: [x,y,z] with direction?: [x,y,z] (no joint: the part's seating plane, under a head or \
the bottom of a nut, washer or bearing, the top of a set screw, pin or countersunk head, goes to point, and direction is its +z, from the shank's tip toward the head; default [0,0,1], \
the bottom of a nut, washer or bearing, the top of a set screw or pin, goes to point, and direction is its +z, from the shank's tip toward the head; default [0,0,1], \
head up, shank down -z); name? → component, occurrence, joint?, \
and with at: occurrences, joints (one per hole, in pick order)",
),
@@ -64,7 +61,6 @@ pub static COMMANDS: &[CommandSpec] = &[
const FAMILIES: &[(&str, &str, &str)] = &[
("socket_head_cap_screw", "Socket head cap screw", "ISO 4762"),
("button_head_cap_screw", "Button head cap screw", "ISO 7380-1"),
("countersunk_socket_screw", "Countersunk socket screw", "ISO 10642"),
("hex_bolt", "Hex head bolt / screw", "ISO 4014 / ISO 4017"),
("hex_nut", "Hex nut", "ISO 4032"),
("washer", "Plain washer", "ISO 7089"),
@@ -140,26 +136,6 @@ const BHCS: [(&str, f64, f64, f64, f64); 8] = [
const BUTTON_FLANK: f64 = 0.15;
const BUTTON_FLAT: f64 = 0.7;
/// ISO 10642: (size, theoretical head diameter dk, head height k, socket s, key engagement t).
/// dk is the diameter of the 90° cone where it meets the top plane, so k = (dk − d) / 2 at every
/// size; the standard's real rim is rounded and a little smaller (dk max 5.81 at M3), which the
/// sharp cone does not draw. t is the standard's minimum engagement depth: the 2013 edition has it
/// larger at M4, M5, M8 and M10 (1.5, 1.9, 3 and 3.6), and these are the newer edition's. M14 is
/// the standard's non-preferred size. The standard runs M2 to M20; M2 and M2.5 are left out here
/// because only one of the tables we compared lists them.
const CSK: [(&str, f64, f64, f64, f64); 10] = [
("M3", 6.72, 1.86, 2.0, 1.1),
("M4", 8.96, 2.48, 2.5, 1.4),
("M5", 11.2, 3.1, 3.0, 1.75),
("M6", 13.44, 3.72, 4.0, 2.2),
("M8", 17.92, 4.96, 5.0, 2.9),
("M10", 22.4, 6.2, 6.0, 3.5),
("M12", 26.88, 7.44, 8.0, 4.3),
("M14", 30.8, 8.4, 10.0, 4.5),
("M16", 33.6, 8.8, 10.0, 4.8),
("M20", 40.32, 10.16, 12.0, 5.6),
];
/// ISO 4014/4017 hex head: (size, across flats s, head height k).
const HEX_BOLT: [(&str, f64, f64); 14] = [
("M2", 4.0, 1.4),
@@ -420,21 +396,12 @@ enum Head {
k: f64,
flat: f64,
},
/// A countersunk head: a 90° cone from the shank, k deep, opening to dk at the top plane
/// z = 0. The screw's length is measured from that plane, head included.
Countersunk {
dk: f64,
k: f64,
},
Hex {
s: f64,
k: f64,
},
}
/// The shape of one part of a family, from its standard's table: the size and, for screws, bolts,
/// set screws and pins, a length in mm (a standard one near 2.5 d when none is given). The error
/// says which family, size or length does not exist.
fn shape(family: &str, size: &str, length: Option<f64>) -> std::result::Result<Shape, String> {
let need = |what: &str| format!("no {what} size `{size}` (parts.library lists them)");
let len = |d: f64| length.unwrap_or_else(|| default_length(d));
@@ -449,15 +416,6 @@ fn shape(family: &str, size: &str, length: Option<f64>) -> std::result::Result<S
let (_, dk, k, s, t) = BHCS.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 7380-1"))?;
Shape::Screw { d, pitch: Some(pitch), len: len(d), head: Head::Dome { dk, k, flat: BUTTON_FLAT * s }, socket: Some((s, t)) }
}
"countersunk_socket_screw" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, dk, k, s, t) = CSK.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 10642"))?;
let len = len(d);
if len <= k {
return Err(format!("a {size} countersunk screw's length counts its head, so it must be over {k} mm (it is {len} mm)"));
}
Shape::Screw { d, pitch: Some(pitch), len, head: Head::Countersunk { dk, k }, socket: Some((s, t)) }
}
"hex_bolt" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, s, k) = HEX_BOLT.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4014"))?;
@@ -615,12 +573,6 @@ fn build(s: &mut Session, sh: &Shape) -> Result<()> {
revolve_domed(s, &outline, crown(a, c, flat, k), "new")?;
k
}
// One revolved outline again, but the head hangs below z = 0: the shank, the cone
// opening up to dk at the top, and the top's flat, which the socket is cut into.
Head::Countersunk { dk, k } => {
revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, -k), (dk / 2.0, 0.0), (0.0, 0.0)], "new")?;
0.0
}
// A hex head joined over a shank that runs up into it.
Head::Hex { s: af, k } => {
hex(s, af, 0.0, k, "new")?;
@@ -632,12 +584,7 @@ fn build(s: &mut Session, sh: &Shape) -> Result<()> {
hex(s, af, k, k - t, "cut")?;
}
if let Some(p) = pitch {
// Halfway down the shank, which a countersunk head leaves shorter than the length.
let mid = match head {
Head::Countersunk { k, .. } => -(len + k) / 2.0,
_ => -len / 2.0,
};
thread(s, d, p, Vec3::new(d / 2.0, 0.0, mid));
thread(s, d, p, Vec3::new(d / 2.0, 0.0, -len / 2.0));
}
}
Shape::Nut { d, pitch, s: af, m } => {
@@ -663,14 +610,11 @@ fn build(s: &mut Session, sh: &Shape) -> Result<()> {
Ok(())
}
/// `parts.library`: the families, or with `family` one family's sizes (the rows of its table, in
/// mm) and its standard lengths.
fn library(_s: &mut Session, p: &Value) -> Result<Value> {
let fam = |id: &str| -> PartFamily {
let mut sizes: Vec<Value> = match id {
"socket_head_cap_screw" => SHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
"button_head_cap_screw" => BHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
"countersunk_socket_screw" => CSK.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
"hex_bolt" => HEX_BOLT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "head_height": r.2})).collect(),
"hex_nut" => HEX_NUT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "height": r.2})).collect(),
"washer" => WASHER.iter().map(|r| json!({"size": r.0, "inner": r.1, "outer": r.2, "thickness": r.3})).collect(),
@@ -693,9 +637,7 @@ fn library(_s: &mut Session, p: &Value) -> Result<Value> {
};
let (_, name, standard) = FAMILIES.iter().find(|f| f.0 == id).copied().unwrap_or(("", "", ""));
let lengths = match id {
"socket_head_cap_screw" | "button_head_cap_screw" | "countersunk_socket_screw" | "hex_bolt" | "set_screw" | "dowel_pin" => {
Some(LENGTHS.to_vec())
}
"socket_head_cap_screw" | "button_head_cap_screw" | "hex_bolt" | "set_screw" | "dowel_pin" => Some(LENGTHS.to_vec()),
"hex_cap_screw_inch" => Some(inch_lengths()),
_ => None,
};
@@ -745,7 +687,6 @@ fn identity(family: &str, size: &str, sh: &Shape) -> (String, String) {
let standard = match family {
"socket_head_cap_screw" => "ISO 4762",
"button_head_cap_screw" => "ISO 7380-1",
"countersunk_socket_screw" => "ISO 10642",
"hex_bolt" => "ISO 4017",
"hex_nut" => "ISO 4032",
"washer" => "ISO 7089",
@@ -784,12 +725,10 @@ fn set_identity(s: &mut Session, comp: u64, now: (String, String), was: Option<(
}
}
/// The short label a family's parts are named with: `SHCS` in `SHCS M6x20`, `CSK` in `CSK M6x20`.
fn label(family: &str) -> &'static str {
match family {
"socket_head_cap_screw" => "SHCS",
"button_head_cap_screw" => "BHCS",
"countersunk_socket_screw" => "CSK",
"hex_bolt" => "Hex Bolt",
"hex_nut" => "Hex Nut",
"washer" => "Washer",
@@ -826,8 +765,6 @@ fn insert_atomic(s: &mut Session, p: &Value) -> Result<Value> {
Ok(result)
}
/// `parts.insert`: build the part as its own component, then seat it with a rigid joint at each
/// hole picked in `at`, or place it at `point`, or leave it at the origin.
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parts.insert (Insert Fastener)";
let family = str_(p, "family").ok_or_else(|| bad(cmd, "`family` is required (parts.library)"))?.to_string();
@@ -839,7 +776,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let mut holes: Vec<(Vec3, u64)> = Vec::new();
let mut seats: Vec<(u64, HoleWall)> = Vec::new();
for a in hole_picks(p, cmd)? {
let wall = hole_wall(s, a).unwrap_or(HoleWall { at: a, seat: a, axis: None, ends: None, open: true, radius: 0.0 });
let wall = hole_wall(s, a).unwrap_or(HoleWall { at: a, seat: a, axis: None, ends: None, open: true });
let host = super::joints::occurrence_at(s, wall.at).ok_or_else(|| bad(cmd, "no body at `at` (pick a hole's rim or wall)"))?;
// A rim is drawn as more than one edge, and one click on it picks them all; a hole can
// also be picked at both its ends (#12191). The picks of one hole seat one part, at an
@@ -895,23 +832,10 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
// Seat it: its axis on the hole's, its seating plane on the hole's face, the shank
// going into the hole (the joint mates the part's −z with the face's outward z).
let seat = if i == 0 { format!("{name} seat") } else { format!("{name} seat {}", i + 1) };
// A countersunk head sinks into the part: the foot of its cone, not its top, goes on
// the rim picked (where the hole's cylinder meets its countersink), moved along the
// axis by the bore's radius over the shank's, so that its 90 degree cone lies on the
// countersink's, whose cone is as wide as the head's at the face. The difference is
// signed: a clearance hole is wider than the shank and the cone goes deeper, a tap
// drill is narrower and the cone goes higher. With no bore known, there is no move.
let at_rim = match sh {
Shape::Screw { d, head: Head::Countersunk { k, .. }, .. } => {
let clearance = seats.get(i).map_or(0.0, |(_, w)| if w.radius > 0.0 { w.radius - d / 2.0 } else { 0.0 });
[0.0, 0.0, -k + clearance]
}
_ => [0.0, 0.0, 0.0],
};
let j = run(
s,
"joint.create",
json!({"type": "rigid", "a": {"occurrence": h, "circle": a}, "b": {"occurrence": o, "point": at_rim, "z": [0, 0, -1]}, "name": seat}),
json!({"type": "rigid", "a": {"occurrence": h, "circle": a}, "b": {"occurrence": o, "point": [0, 0, 0], "z": [0, 0, -1]}, "name": seat}),
)?;
joints.push(j["joint"].as_u64().ok_or_else(|| bad(cmd, "the joint has no id"))?);
}
@@ -971,8 +895,6 @@ struct HoleWall {
ends: Option<[Vec3; 2]>,
/// The picked rim opens onto air, not onto the floor of a blind hole.
open: bool,
/// The wall's radius (0 when the pick is not on a hole).
radius: f64,
}
impl HoleWall {
@@ -1015,14 +937,7 @@ fn hole_wall(s: &Session, a: Vec3) -> Option<HoleWall> {
let past = c.axis_point + axis * (end + out * (c.end - c.start).min(1.0) * 0.25);
let open = !matches!(b.body.classify_point(past), Ok(solidcraft_doc::kernel::PointClass::Inside));
let ends = [c.axis_point + axis * c.start, c.axis_point + axis * c.end];
return Some(HoleWall {
at: q,
seat: c.axis_point + axis * along,
axis: Some(axis),
ends: Some(ends),
open,
radius: c.radius,
});
return Some(HoleWall { at: q, seat: c.axis_point + axis * along, axis: Some(axis), ends: Some(ends), open });
}
}
}
@@ -1285,91 +1200,6 @@ mod tests {
assert_eq!(number, Some("ISO 7380-1 M6x20"));
}
/// #13361: a countersunk socket screw (ISO 10642) takes the same parameters as the other
/// screws, with a 90° cone sunk below its seating plane: the top of the head is level with
/// the face, and the length counts the head.
#[test]
fn a_countersunk_head_is_a_cone_sunk_below_the_seating_plane() {
let l = run(&mut Session::default(), "parts.library", json!({"family": "countersunk_socket_screw"}));
let row = |size: &str| l["sizes"].as_array().and_then(|a| a.iter().find(|x| x["size"] == size)).cloned().unwrap_or_default();
let m6 = row("M6");
let dims = [&m6["head_diameter"], &m6["head_height"], &m6["socket"], &m6["socket_depth"]].map(|v| v.as_f64());
assert_eq!(dims, [Some(13.44), Some(3.72), Some(4.0), Some(2.2)]);
// M2 and M2.5 are left out (one source only), and the cone is 90° at every size: k is half
// the way from the shank to dk.
let sizes: Vec<&str> = l["sizes"].as_array().map(|a| a.iter().filter_map(|x| x["size"].as_str()).collect()).unwrap_or_default();
assert_eq!(sizes, ["M3", "M4", "M5", "M6", "M8", "M10", "M12", "M14", "M16", "M20"]);
for size in sizes {
let (d, dk, k) = (
row(size)["d"].as_f64().unwrap_or(0.0),
row(size)["head_diameter"].as_f64().unwrap_or(0.0),
row(size)["head_height"].as_f64().unwrap_or(0.0),
);
assert!(((dk - d) / 2.0 - k).abs() < 0.006, "{size}: dk {dk}, d {d}, k {k}");
}
assert!(l["lengths"].as_array().is_some_and(|a| !a.is_empty()), "{l}");
// An M6 x 20: head Ø13.44 at the top, which is z = 0; the whole 20 hangs below it.
let mut s = Session::default();
run(&mut s, "parts.insert", json!({"family": "countersunk_socket_screw", "size": "M6", "length": 20}));
let (lo, hi) = bbox(&s, "CSK M6x20");
assert!((hi.x - lo.x - 13.44).abs() < 1e-3 && (lo.z + 20.0).abs() < 1e-6 && hi.z.abs() < 1e-6, "{lo:?} {hi:?}");
assert!(s.model.state().threads.iter().any(|t| t.designation.starts_with("M6")), "{:?}", s.model.state().threads);
// The profile: the rim at the top, the shank (Ø6) below the cone, and the cone's foot
// 3.72 down, where it meets the shank.
let st = s.world_state().unwrap();
let points: Vec<_> = st.bodies.iter().flat_map(|b| b.mesh().positions.clone()).collect();
let radius = |p: &solidcore_record::Vec3| p.x.hypot(p.y);
let rim = points.iter().filter(|p| p.z > -0.01).map(radius).fold(0.0, f64::max);
let shank = points.iter().filter(|p| p.z < -3.8).map(radius).fold(0.0, f64::max);
// The cone's foot is the highest ring of the shank's radius below the top; the mesh
// splits the cone itself into rings of its own, so the cone is not measured by vertices.
let foot = points.iter().filter(|p| (radius(p) - 3.0).abs() < 1e-3 && p.z < -0.5).map(|p| p.z).fold(f64::MIN, f64::max);
assert!((rim - 6.72).abs() < 1e-3, "the rim: {rim}");
assert!((shank - 3.0).abs() < 1e-3, "the shank: {shank}");
assert!((foot + 3.72).abs() < 1e-3, "the cone's foot: {foot}");
let comp = s.doc.components.iter().find(|c| c.name.starts_with("CSK")).map(|c| c.id).unwrap_or(0);
let number = s.doc.components.iter().find(|c| c.id == comp).and_then(|c| c.metadata.text(solidcraft_doc::metadata::PART_NUMBER));
assert_eq!(number, Some("ISO 10642 M6x20"));
// The length counts the head, so one no longer than the head is refused, and nothing is made.
let mut short = Session::default();
assert!(short.execute("parts.insert", &json!({"family": "countersunk_socket_screw", "size": "M20", "length": 8})).is_err());
assert!(short.doc.components.is_empty());
}
/// #13361: seated at a countersunk hole (picked where the countersink meets the bore), the
/// head's cone lies in the countersink and its top is level with the face, not at the bore.
#[test]
fn a_countersunk_screw_seats_flush_in_a_countersunk_hole() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
let hole = json!({"position": [20, 15, 10], "type": "countersink", "diameter": 6.6, "cs_diameter": 13.44, "cs_angle": "90 deg", "depth": 10});
run(&mut s, "solid.hole", hole);
let r = run(&mut s, "parts.fastener", json!({"family": "countersunk_socket_screw", "size": "M6", "length": 20, "at": [23.3, 15, 6.4]}));
assert!(r["joint"].as_u64().is_some(), "{r}");
// The plate's face is z = 10: the head's top is there, and the 20 mm length hangs from it.
let (lo, hi) = bbox(&s, "CSK M6x20");
assert!((hi.z - 10.0).abs() < 0.02 && (lo.z + 10.0).abs() < 0.02, "{lo:?} {hi:?}");
assert!((hi.x - lo.x - 13.44).abs() < 1e-3, "{lo:?} {hi:?}");
}
/// #13361: in a tapped countersink the bore is a tap drill, narrower than the screw's shank, so
/// the cone sits higher on the rim than in a clearance hole, and the head is still level with
/// the face (it was 0.6 mm off for an M8 when the difference of the radii was cut off at 0).
#[test]
fn a_countersunk_screw_seats_flush_in_a_tapped_countersink() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
let hole =
json!({"position": [20, 15, 10], "type": "countersink", "thread": "M8x1.25", "cs_diameter": 17.92, "cs_angle": "90 deg", "depth": 10});
run(&mut s, "solid.hole", hole);
// The tap drill is 6.8, so the bore's wall is at r = 3.4 and meets the countersink at z = 4.44.
let r = run(&mut s, "parts.fastener", json!({"family": "countersunk_socket_screw", "size": "M8", "length": 20, "at": [23.4, 15, 4.3]}));
assert!(r["joint"].as_u64().is_some(), "{r}");
let (lo, hi) = bbox(&s, "CSK M8x20");
assert!((hi.z - 10.0).abs() < 0.02 && (lo.z + 10.0).abs() < 0.02, "{lo:?} {hi:?}");
assert!((hi.x - lo.x - 17.92).abs() < 1e-3, "{lo:?} {hi:?}");
}
/// #2091: the second and larger ISO sizes, M2, M2.5, M14, M24 and M30, are in the screw, bolt,
/// nut and washer tables (they stopped at M3 and M20).
#[test]
@@ -1579,13 +1409,11 @@ mod tests {
assert_eq!(r["occurrences"].as_array().map(Vec::len), Some(2), "one screw per hole: {r}");
}
/// Every family's sketches are fully defined when the part is inserted and when it is resized.
#[test]
fn every_sketch_of_a_standard_part_is_fully_defined() {
for (family, size, resized) in [
("socket_head_cap_screw", "M6", "M8"),
("button_head_cap_screw", "M6", "M8"),
("countersunk_socket_screw", "M6", "M8"),
("hex_bolt", "M6", "M8"),
("hex_nut", "M6", "M8"),
("washer", "M6", "M8"),
@@ -1610,13 +1438,11 @@ mod tests {
}
}
/// A placed part is resized in its own frame and in millimetres, whatever the design's units.
#[test]
fn resizing_placed_parts_uses_local_geometry_and_millimetres_in_every_design_unit() {
for (family, initial, resized, prefix) in [
("socket_head_cap_screw", "M6", "M8", "SHCS"),
("button_head_cap_screw", "M6", "M8", "BHCS"),
("countersunk_socket_screw", "M6", "M8", "CSK"),
("hex_bolt", "M6", "M8", "Hex Bolt"),
("hex_nut", "M6", "M8", "Hex Nut"),
("washer", "M6", "M8", "Washer"),
@@ -1633,10 +1459,7 @@ mod tests {
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{family} {units}");
let b = bbox(&s, prefix);
assert!(b.0.x < b.1.x && b.0.y < b.1.y && b.0.z < b.1.z, "{family} {units}: {b:?}");
if matches!(
family,
"socket_head_cap_screw" | "button_head_cap_screw" | "countersunk_socket_screw" | "hex_bolt" | "hex_nut" | "set_screw"
) {
if matches!(family, "socket_head_cap_screw" | "button_head_cap_screw" | "hex_bolt" | "hex_nut" | "set_screw") {
assert!(s.model.state().threads.iter().any(|t| t.designation == "M8" && (t.pitch - 1.25).abs() < 1e-9), "{family} {units}");
}
if family == "dowel_pin" {
-28
View File
@@ -1239,31 +1239,3 @@ fn circular_pattern_of_a_moved_copy_adds_one_body_per_instance() {
let far = s.world_state().unwrap().bodies.iter().map(|b| b.mesh().bounds().max.x).fold(f64::MIN, f64::max);
assert!(far > 20.0, "the repeats follow the redefined Move: {far}");
}
/// #13278: a mirror of a Thread repeats it on the mirrored hole, whether the hole was mirrored
/// earlier or is mirrored together with the thread.
#[test]
fn mirror_of_a_thread_threads_the_mirrored_hole() {
let plane = json!({"origin": [20, 0, 0], "normal": [1, 0, 0]});
for (together, modeled) in [(false, false), (true, false), (false, true), (true, true)] {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
run(&mut s, "solid.hole", json!({"position": [10, 15, 10], "diameter": 6.8, "depth": 10, "name": "Hole1"}));
if !together {
let r = run(&mut s, "solid.mirror", json!({"features": ["Hole1"], "plane": plane}));
assert!(r["error"].is_null(), "{r}");
}
run(&mut s, "solid.thread", json!({"face": [13.4, 15, 5], "designation": "M8", "modeled": modeled, "name": "Thread1"}));
let features = if together { json!(["Hole1", "Thread1"]) } else { json!(["Thread1"]) };
let r = run(&mut s, "solid.mirror", json!({"features": features, "plane": plane}));
assert!(r["error"].is_null(), "together {together}: {r}");
let state = s.world_state().unwrap();
if modeled {
assert_eq!(state.bodies.len(), 1, "{r}");
let threaded = volume(&mut s);
assert!(threaded < 40.0 * 30.0 * 10.0 - 2.0 * std::f64::consts::PI * 3.4 * 3.4 * 10.0 - 1.0, "both holes are threaded: {threaded}");
} else {
assert_eq!(state.threads.len(), 2, "together {together}: the source and its mirror");
}
}
}
File diff suppressed because one or more lines are too long
+17 -232
View File
@@ -1,37 +1,6 @@
use super::*;
use solidcraft_render::job::{CpuHost, JobId};
#[cfg(not(target_arch = "wasm32"))]
// Validate each live receipt before comparing deterministic accounting. RAM
// snapshots and other retained leases can change between otherwise equal checks.
fn preflight_without_memory_observations(mut reply: Value) -> Value {
let total = reply["admission"]["estimated_total_bytes"].as_u64().unwrap();
let memory = reply["admission"]["host_memory"].as_object_mut().expect("native preflight reports its RAM admission");
let required = memory["required_bytes"].as_u64().unwrap();
let admitted = memory["admitted_bytes"].as_u64().unwrap();
let credited = memory["credited_bytes"].as_u64().unwrap();
let additional = memory["additional_bytes"].as_u64().unwrap();
let physical = memory["physical_debit_bytes"].as_u64().unwrap();
let available = memory["available_bytes"].as_u64().unwrap();
let capacity = memory["capacity_bytes"].as_u64().unwrap();
assert_eq!(required, total, "native debit includes the complete public admission");
assert_eq!(required.checked_sub(credited), Some(additional));
assert_eq!(credited, required, "native preflight reports its fully funded prepared capture");
assert_eq!(additional, 0, "the final capture self-check needs no new allocation");
assert!(additional <= available, "new native debit fits its available RAM: {memory:?}");
assert_eq!(available, capacity.saturating_sub(physical), "physical debit accounts for the snapshot allowance");
assert!(admitted.checked_add(required).unwrap() <= memory["software_limit_bytes"].as_u64().unwrap());
assert!(
matches!(memory["source"].as_str(), Some("system_available" | "system_and_cgroup" | "conservative_fallback")),
"native receipt identifies its capacity source"
);
// Keep required_bytes and every non-observation field in the exact comparison.
for field in ["admitted_bytes", "available_bytes", "capacity_bytes", "physical_debit_bytes", "source"] {
memory.remove(field).unwrap();
}
reply
}
fn session() -> (Session, CpuHost) {
let host = CpuHost::cooperative();
let mut s = Session::default();
@@ -159,7 +128,7 @@ fn saved_gpu_job_routes_before_dispatch_and_reaches_preflight_and_start_without_
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
let checked = s.execute("render.preflight", &named).unwrap();
let inline = s.execute("render.preflight", &json!({"settings":saved})).unwrap();
assert_eq!(preflight_without_memory_observations(checked.clone()), preflight_without_memory_observations(inline));
assert_eq!(checked, inline);
assert_eq!(checked["resolved"]["executor"], "gpu-path");
assert_eq!(checked["options"]["exposure_ev"], 1.75);
assert_eq!(checked["options"]["samples"], 7);
@@ -240,10 +209,7 @@ fn saved_gpu_job_routes_before_dispatch_and_reaches_preflight_and_start_without_
let doc = s.doc.clone();
let before = (s.revision, s.undo.len(), s.redo.len(), s.is_dirty());
let checked = s.execute("render.preflight", &json!({"setup":"HDR"})).unwrap();
assert_eq!(
preflight_without_memory_observations(checked.clone()),
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap())
);
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap());
assert_eq!(checked["environment"]["source_sha256"], info["sha256"]);
assert_eq!(checked["environment"]["kind"], "radiance_hdr");
assert_eq!(checked["environment"]["color_policy"], "declared_linear_srgb_d65");
@@ -287,10 +253,7 @@ fn saved_cpu_job_reopens_and_supplies_exact_options_and_one_exposure_to_check_an
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
let named = json!({"setup":"CPU"});
let checked = s.execute("render.preflight", &named).unwrap();
assert_eq!(
preflight_without_memory_observations(checked.clone()),
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":saved})).unwrap())
);
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":saved})).unwrap());
assert_eq!(checked["resolved"]["executor"], "cpu-raytrace");
assert_eq!(checked["request"]["integrator"], "raytrace");
assert_eq!(checked["request"]["raytrace"]["exposure_ev"], 1.25);
@@ -337,10 +300,7 @@ fn saved_cpu_hdr_reopens_without_source_and_drives_identical_check_start_and_rea
let doc = s.doc.clone();
let before = (s.revision, s.undo.len(), s.redo.len(), s.is_dirty());
let checked = s.execute("render.preflight", &json!({"setup":"HDR"})).unwrap();
assert_eq!(
preflight_without_memory_observations(checked.clone()),
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap())
);
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap());
assert_eq!(checked["request"]["raytrace"]["exposure_ev"], 1.25);
assert_eq!(checked["environment"]["samples"], 64);
assert_eq!(checked["environment"]["rotation_radians"], 0.75);
@@ -348,10 +308,7 @@ fn saved_cpu_hdr_reopens_without_source_and_drives_identical_check_start_and_rea
let runtime = json!({"integrator":"raytrace","settings":{"width":9,"height":9,"background":[13,27,41]},
"raytrace":{"lights":[],"shadows":false,"primary_samples":4,"exposure_ev":1.25},
"environment":{"resource":info["sha256"],"rotation_radians":0.75,"intensity":2,"samples":64}});
assert_eq!(
preflight_without_memory_observations(checked.clone()),
preflight_without_memory_observations(s.execute("render.preflight", &runtime).unwrap())
);
assert_eq!(checked, s.execute("render.preflight", &runtime).unwrap());
for environment in [
json!({"resource":"0".repeat(64)}),
json!({"resource":info["sha256"],"path":"must-not-open.hdr"}),
@@ -468,7 +425,7 @@ fn additional_executor_routes_by_capability_and_survives_document_adoption() {
session.execute("file.new", &json!({})).unwrap();
assert_eq!(session.execute("render.preflight", &request).unwrap()["resolved"]["executor"], "test-progressive");
assert!(session.execute("render.status", &json!({"job":id})).is_err());
for bad in [json!({"samples":0}), json!({"max_depth":65}), json!({"seed":-1}), json!({"samples":65537})] {
for bad in [json!({"samples":0}), json!({"max_depth":17}), json!({"seed":-1}), json!({"samples":4097})] {
let mut params = request.clone();
for (key, value) in bad.as_object().unwrap() {
params[key] = value.clone();
@@ -1555,9 +1512,7 @@ fn saved_cpu_denoise_reports_its_actual_native_request_and_keeps_regrade_raw() {
s.execute(
"render.settings.set",
&json!({"name":"CPU denoise","set":{"width":8,"height":8,
"job":{"method":"cpu_raytrace","options":{"threads":2,"supersampling":2,"denoise":true,
"denoise_settings":{"passes":2,"strength":0.5,"firefly_suppression":false,"optical_filter":false},"denoise_diagnostics":true,
"progressive":{"samples_per_pixel":3,"seed":81},"output":{"linear_exr":true}}}}}),
"job":{"method":"cpu_raytrace","options":{"denoise":true,"progressive":{"samples_per_pixel":3,"seed":81},"output":{"linear_exr":true}}}}}),
)
.unwrap();
s = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
@@ -1569,15 +1524,7 @@ fn saved_cpu_denoise_reports_its_actual_native_request_and_keeps_regrade_raw() {
assert_eq!(checked["resolved"]["executor"], "cpu-raytrace");
assert_eq!(checked["request"]["raytrace"]["denoise"], true);
assert_eq!(checked["denoising"]["backend"], "native_cpu");
assert_eq!(checked["denoising"]["passes"], 2);
assert_eq!(checked["denoising"]["steps"], json!([1, 2]));
assert_eq!(checked["denoising"]["settings"], json!({"passes":2,"strength":0.5,"firefly_suppression":false,"optical_filter":false}));
assert_eq!(checked["denoising"]["outlier_policy"], "disabled");
assert_eq!(checked["denoising"]["optical_policy"], "legacy opaque-only filtering; mirror and glass radiance bypass display filtering");
assert_eq!(checked["request"]["raytrace"]["denoise_settings"]["optical_filter"], false);
assert_eq!(checked["request"]["raytrace"]["threads"], 2);
assert_eq!(checked["request"]["raytrace"]["supersampling"], 2);
assert_eq!(checked["request"]["raytrace"]["denoise_diagnostics"], true);
assert_eq!(checked["denoising"]["passes"], 3);
assert_eq!(checked["denoising"]["raw_linear_accumulator_preserved"], true);
let started = s.execute("render.start", &json!({"setup":"CPU denoise"})).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
@@ -1587,49 +1534,13 @@ fn saved_cpu_denoise_reports_its_actual_native_request_and_keeps_regrade_raw() {
assert_eq!(json!(status.request), checked["request"]);
let frame = handle.frame().unwrap().unwrap();
let raw = frame.linear_exr().unwrap().to_vec();
assert_eq!((frame.info.width, frame.info.height), (8, 8));
assert_eq!(image::load_from_memory(frame.png()).unwrap().to_rgba8().dimensions(), (8, 8));
let report = status.denoise_diagnostics.unwrap();
assert_eq!((report.width, report.height), (16, 16));
assert_eq!(report.counts.iter().sum::<u64>(), 256);
let mask = frame.denoise_mask_png().unwrap().to_vec();
let mask_info = frame.denoise_mask_info().unwrap();
assert_eq!((mask_info.width, mask_info.height), (8, 8));
assert_eq!(mask_info.bytes, mask.len() as u64);
assert_ne!(mask_info.sha256, frame.info.png_sha256);
let decoded = image::load_from_memory(&mask).unwrap().to_rgba8();
assert_eq!(decoded.dimensions(), (8, 8));
assert!(decoded.pixels().all(|pixel| solidcraft_render::job::DenoiseGuideClass::ALL.iter().any(|class| class.color() == pixel.0)));
let status_reply = s.execute("render.status", &json!({"job":id})).unwrap();
assert_eq!(status_reply["denoise_mask"], json!(mask_info));
assert_eq!(status_reply["denoise_diagnostics"], json!(report));
let viewed = s.execute("render.result.view", &json!({"view":"denoise_mask"})).unwrap();
assert_eq!(viewed, json!({"view":"denoise_mask","job":id}));
let path = std::env::temp_dir().join(format!("solidcraft-cpu-guide-mask-{}-{}.png", std::process::id(), id.session));
let previous_check = s.param_check;
s.param_check = crate::schema::ParamCheck::Strict;
let exported = s.execute("render.export", &json!({"job":id,"path":path,"format":"denoise_mask"})).unwrap();
s.param_check = previous_check;
assert_eq!(std::fs::read(&path).unwrap(), mask);
assert_eq!(exported["format"], "denoise_mask");
assert_eq!(exported["encoding"], "categorical_srgb");
assert_eq!(exported["alpha"], "opaque");
assert_eq!(exported["bytes"], mask_info.bytes);
assert!(exported.get("param_warnings").is_none());
assert_eq!(exported["denoise_mask"], json!(mask_info));
assert_eq!(exported["frame"]["png_sha256"], frame.info.png_sha256);
std::fs::remove_file(path).unwrap();
let result = s.execute("render.result.regrade", &json!({"job":id,"presentation":{"exposure_ev":-1.0}})).unwrap();
let derivative: JobId = serde_json::from_value(result["job"].clone()).unwrap();
let derivative = s.render_job_handle(derivative).unwrap();
let finished = derivative.wait_timeout(std::time::Duration::from_secs(30)).unwrap();
assert_eq!(finished.progress.phase, Phase::Completed);
assert!(!finished.presentation.as_ref().unwrap().denoise_applied, "regrade never pretends to reconstruct absent geometry guides");
let derivative_frame = derivative.frame().unwrap().unwrap();
assert_eq!(derivative_frame.linear_exr().unwrap(), raw);
assert_eq!(derivative_frame.denoise_mask_png().unwrap(), mask);
assert_eq!(json!(derivative_frame.denoise_mask_info()), json!(Some(mask_info)));
assert_eq!(finished.denoise_diagnostics, Some(report));
assert_eq!(derivative.frame().unwrap().unwrap().linear_exr().unwrap(), raw);
assert_eq!(frame.linear_exr().unwrap(), raw);
assert!(Arc::ptr_eq(&document, &s.doc));
assert_eq!((s.revision, s.evaluations, s.undo.len(), s.redo.len()), before);
@@ -1683,15 +1594,12 @@ fn saved_cpu_optics_match_runtime_capture_pixels_prefixes_cancel_and_accounting(
let extra_bytes = (extra_slots * std::mem::size_of::<solidcraft_render::job::DielectricOptions>()) as u64;
assert_eq!(saved["retained_request_bytes"].as_u64().unwrap() + extra_bytes, runtime["retained_request_bytes"].as_u64().unwrap());
// Two options copies belong to image work; a third belongs to the retained
// immutable request. All other deterministic admission fields remain exact.
let mut expected_runtime_admission = preflight_without_memory_observations(saved.clone())["admission"].clone();
// immutable request. All other admission fields must remain exactly equal.
let mut expected_runtime_admission = saved["admission"].clone();
for (field, copies) in [("preparation_bytes", 2), ("estimated_total_bytes", 3)] {
expected_runtime_admission[field] = json!(saved["admission"][field].as_u64().unwrap() + copies * extra_bytes);
}
for field in ["required_bytes", "credited_bytes"] {
expected_runtime_admission["host_memory"][field] = json!(saved["admission"]["host_memory"][field].as_u64().unwrap() + 3 * extra_bytes);
}
assert_eq!(expected_runtime_admission, preflight_without_memory_observations(runtime.clone())["admission"]);
assert_eq!(expected_runtime_admission, runtime["admission"]);
assert_eq!(saved["refraction"], true);
let original_mesh = scene.meshes[0].mesh.clone();
assert!(!scene.has_transmission(), "saved CPU lowering leaves the captured product appearance and physical material unchanged");
@@ -2017,10 +1925,7 @@ fn saved_cpu_aging_cold_reopen_resolves_exact_target_and_changes_real_png_withou
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
let checked = s.execute("render.preflight", &json!({"setup":"CPU aging"})).unwrap();
assert_eq!(checked["request"]["raytrace"]["aging"][0]["target"]["body_name"], name);
assert_eq!(
preflight_without_memory_observations(checked.clone()),
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":saved})).unwrap())
);
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":saved})).unwrap());
let started = s.execute("render.start", &json!({"setup":"CPU aging"})).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let handle = s.render_job_handle(id).unwrap();
@@ -2324,9 +2229,8 @@ fn runtime_checked_hdr_keeps_two_stable_glass_targets_and_exact_cpu_options_with
raster["environment"]["path"] = json!("must-not-open-raster-stable-glass.hdr");
assert!(s.execute("render.preflight", &raster).unwrap_err().to_string().contains("stable optics require"));
let mut over_cap = params.clone();
let optical_limit = solidcraft_render::job::MAX_OPTICAL_TARGETS;
over_cap["settings"]["optics"] = json!(
(0..=optical_limit)
(0..9)
.map(|i| {
let mut row = params["settings"]["optics"][0].clone();
row["target"]["definition"] = json!(format!("Unique unknown optical body {i}"));
@@ -2335,7 +2239,7 @@ fn runtime_checked_hdr_keeps_two_stable_glass_targets_and_exact_cpu_options_with
.collect::<Vec<_>>()
);
over_cap["environment"]["path"] = json!("must-not-open-over-cap-stable-glass.hdr");
assert!(s.execute("render.preflight", &over_cap).unwrap_err().to_string().contains(&format!("at most {optical_limit}")));
assert!(s.execute("render.preflight", &over_cap).unwrap_err().to_string().contains("at most eight"));
let mut changed_hdr = params.clone();
changed_hdr["environment"]["expected_sha256"] = json!("0".repeat(64));
assert!(s.execute("render.preflight", &changed_hdr).unwrap_err().to_string().contains("changed since selection"));
@@ -2373,7 +2277,7 @@ fn saved_cpu_path_maps_glass_and_targeted_aging_reach_real_preflight_and_render_
s.execute(
"render.settings.set",
&json!({"name":"Mixed","set":{"camera":camera,"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{
"texture_filtering":true,"progressive":{"samples_per_pixel":3,"seed":9},"path":{"max_depth":4},"lights":[],
"progressive":{"samples_per_pixel":3,"seed":9},"path":{"max_depth":4},"lights":[],
"environment":{"color":[1,1,1],"intensity":1},"output":{"linear_exr":true},
"aging":[{"kind":"fading","target":panel["stable_target"],"strength":0.25,"coverage":1.0,"upward_bias":0.0}]
}}}}),
@@ -2397,8 +2301,6 @@ fn saved_cpu_path_maps_glass_and_targeted_aging_reach_real_preflight_and_render_
let history = (s.revision, s.evaluations, s.undo.len(), s.redo.len());
let checked = s.execute("render.preflight", &json!({"setup":"Mixed"})).unwrap();
assert_eq!(checked["textures"]["bodies"], 1);
assert_eq!(checked["textures"]["filtering"], true);
assert_eq!(checked["request"]["raytrace"]["texture_filtering"], true);
assert_eq!(checked["request"]["raytrace"]["path"]["max_depth"], 4);
assert_eq!(checked["request"]["raytrace"]["dielectrics"][0]["body_name"], "Glass");
assert_eq!(checked["request"]["raytrace"]["dielectrics"][0]["boundary"], "closed_mesh");
@@ -2568,11 +2470,7 @@ fn saved_coat_cpu_direct_and_path_render_match_independent_normal_incidence_laye
let mut plain = setup.clone();
plain.coats = Default::default();
let plain_check = s.execute("render.preflight", &json!({"settings":plain})).unwrap();
assert_eq!(
preflight_without_memory_observations(checked.clone())["admission"],
preflight_without_memory_observations(plain_check.clone())["admission"],
"coat uses existing inline surface metadata and the same bounded producer stages"
);
assert_eq!(checked["admission"], plain_check["admission"], "coat uses existing inline surface metadata and the same bounded producer stages");
assert_eq!(checked["retained_request_bytes"], plain_check["retained_request_bytes"]);
let mut zero = setup;
let row = zero.coats.iter().next().unwrap().clone();
@@ -3275,56 +3173,6 @@ mod native_camera_queue_preparation {
assert_eq!((s.revision, s.evaluations, s.undo.len(), s.redo.len()), history);
}
#[test]
fn native_batch_retained_plan_and_completed_images_allow_a_new_ram_epoch_before_next_capture() {
let (mut s, _) = fixture(2);
let host = CpuHost::for_platform().unwrap();
s.set_render_job_host(Some(host.clone())).unwrap();
let wait_released = |handle: &solidcraft_render::job::JobHandle| {
assert_eq!(handle.wait_timeout(std::time::Duration::from_secs(10)).unwrap().progress.phase, Phase::Completed);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !handle.work_released() && std::time::Instant::now() < deadline {
std::thread::yield_now();
}
assert!(handle.work_released(), "terminal publication must finish releasing task backing");
};
let (mut first, worker) = initial(&mut s, json!({}));
batch::finish_retained(&mut s, &mut first).unwrap();
let mut plan = batch::take_plan(&mut first).unwrap();
let plan_bytes = plan.as_ref().unwrap().retained_bytes();
worker.retire(first);
let first_handle = s.render_job_handle(s.render_queue_state().job.unwrap()).unwrap();
wait_released(&first_handle);
let frame = first_handle.frame().unwrap().unwrap();
let held = host.admitted_bytes();
assert!(held > plan_bytes, "the Plan, request metadata, and completed image remain owned");
let receipt = s.execute("render.preflight", &json!({"setup":"Shot00"})).unwrap();
let memory = &receipt["admission"]["host_memory"];
assert_eq!(memory["admitted_bytes"], json!(held));
let credit = memory["credited_bytes"].as_u64().unwrap();
assert!(credit > 0, "engine preflight owns a newly funded capture at its receipt check");
assert_eq!(memory["required_bytes"], json!(credit));
assert_eq!(memory["additional_bytes"], json!(0));
let expected_debit = if memory["source"] == "conservative_fallback" { held.checked_add(credit).unwrap() } else { credit };
assert_eq!(memory["physical_debit_bytes"], json!(expected_debit), "only the new capture debits a refreshed actual-RAM snapshot");
assert_eq!(host.admitted_bytes(), held, "rebasing does not release software accounting");
// The next camera borrows old-epoch Plan metadata while funding a new
// capture. Its final held/held self-check must not count that credit twice.
let claim = batch::claim_next(&mut s, &mut plan, CancelToken::default()).unwrap().unwrap();
assert!(plan.is_none());
let (mut next, worker) = Worker::prepare(claim);
let reply = batch::finish_retained(&mut s, &mut next).unwrap();
assert!(batch::take_plan(&mut next).unwrap().is_none());
worker.retire(next);
let id: JobId = serde_json::from_value(reply["job"].clone()).unwrap();
let second_handle = s.render_job_handle(id).unwrap();
wait_released(&second_handle);
assert!(second_handle.frame().unwrap().is_some());
assert!(!frame.png().is_empty(), "the earlier image remains valid across the next epoch");
}
#[test]
fn native_batch_owner_and_cancelled_identical_front_cannot_publish_into_wrong_or_replacement_registry() {
let (mut s, host) = fixture(2);
@@ -3800,66 +3648,3 @@ fn tessellated_cpu_sheet_saved_and_prospective_bindings_certify_and_render_witho
assert!(Arc::ptr_eq(&document, &s.doc) && Arc::ptr_eq(&geometry, &s.model.state()));
assert_eq!(history, (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty()));
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn saved_final_cpu_image_preserves_mode_executor_and_authored_quality_through_start() {
let mut session = Session::default();
let host = CpuHost::for_platform().unwrap();
session.set_render_job_host(Some(host)).unwrap();
session.execute("solid.box", &json!({"length":2,"width":3,"height":4})).unwrap();
session
.execute(
"render.settings.set",
&json!({"name":"Final OPUS reference","set":{
"mode":"final","width":2,"height":2,"job":{"method":"cpu_raytrace","options":{
"budget":"workstation","path":{"max_depth":64},"progressive":{"samples_per_pixel":3,"seed":81},
"lights":[],"environment":{"color":[0.1,0.2,0.3],"intensity":1},"output":{"linear_exr":true}
}}}}),
)
.unwrap();
let params = json!({"setup":"Final OPUS reference"});
let before = session.doc.clone();
let preflight = session.execute("render.preflight", &params).unwrap();
assert_eq!(preflight["resolved"]["profile"]["mode"], "final");
assert_eq!(preflight["resolved"]["executor"], "cpu-raytrace");
let started = session.execute("render.start", &params).unwrap();
let id = serde_json::from_value(started["job"].clone()).unwrap();
let job = session.render_job_handle(id).unwrap();
let status = job.wait_timeout(std::time::Duration::from_secs(30)).unwrap();
assert_eq!(status.progress.phase, solidcraft_render::job::Phase::Completed);
assert_eq!(status.resolved.profile.mode, solidcraft_render::RenderMode::Final);
assert_eq!(status.resolved.executor.as_str(), "cpu-raytrace");
let options = status.request.raytrace.unwrap();
assert_eq!(options.path.unwrap().max_depth, 64);
assert_eq!(options.progressive.unwrap().samples_per_pixel, 3);
assert!(job.frame().unwrap().unwrap().linear_master_info().is_some());
assert!(Arc::ptr_eq(&before, &session.doc));
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn cpu_quality_limits_refuse_before_input_io_and_scene_capture() {
let mut s = Session::default();
let host = CpuHost::for_platform().unwrap();
s.set_render_job_host(Some(host.clone())).unwrap();
let document = s.doc.clone();
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len());
for (width, height, options) in [
(1024, 1024, json!({"supersampling":2,"progressive":{"samples_per_pixel":8}})),
(8, 8, json!({"threads":65})),
(8, 8, json!({"supersampling":3})),
(8, 8, json!({"denoise_settings":{"passes":4}})),
(8193, 1, json!({"budget":"workstation","denoise_diagnostics":true})),
] {
let request = json!({"integrator":"raytrace","settings":{"width":width,"height":height},
"raytrace":options,"environment":{"path":"must-not-open-quality-controls.hdr"}});
for command in ["render.preflight", "render.start"] {
let error = s.execute(command, &request).unwrap_err().to_string();
assert!(!error.contains("must-not-open-quality-controls.hdr"), "quality must refuse before source IO: {error}");
assert_eq!(host.admitted_bytes(), 0);
assert!(Arc::ptr_eq(&document, &s.doc));
assert_eq!(before, (s.revision, s.evaluations, s.undo.len(), s.redo.len()));
}
}
}
+18 -97
View File
@@ -12,11 +12,6 @@ use solidcraft_render::{
job::{ImageRequest, Integrator, JobId, RaytraceOptions},
};
const DENOISE_OUTLIER_POLICY: &str = "suppress isolated high radiance on locally matching guides in display output";
const DENOISE_OPTICAL_POLICY: &str =
"supported sharp reflections and smooth glass use separate reflected and transmitted endpoint geometry and material guides";
const DENOISE_OPAQUE_ONLY_POLICY: &str = "legacy opaque-only filtering; mirror and glass radiance bypass display filtering";
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct Start {
@@ -57,16 +52,11 @@ impl Default for EnvironmentRequest {
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct ProgressiveStart {
supersampling: Option<u32>,
denoise_settings: solidcraft_render::job::DenoiseSettings,
denoise_diagnostics: bool,
caustics: Option<solidcraft_render::job::CausticOptions>,
studio_weights: Option<[f64; 3]>,
aging: solidcraft_doc::render_aging::SavedAgingStack,
#[serde(default)]
nested_media: bool,
texture_filtering: bool,
metal_energy_compensation: bool,
volume: Option<solidcraft_render::job::volume::HomogeneousVolume>,
adaptive: Option<solidcraft_render::job::adaptive::AdaptiveSampling>,
transmission_samples: Option<u32>,
@@ -207,9 +197,8 @@ fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Opt
"samples":options.samples,"max_depth":options.max_depth,"seed":options.seed,
"lens":options.lens,"lighting":options.lighting,"tone_map":options.tone_map,
"denoise":options.denoise,"environment":options.environment,
"supersampling":options.supersampling,"denoise_settings":options.denoise_settings,"denoise_diagnostics":options.denoise_diagnostics,
"finishing":options.finishing,"backdrop":options.backdrop,"linear_exr":options.linear_exr,
"texture_filtering":options.texture_filtering,"metal_energy_compensation":options.metal_energy_compensation,"caustics":options.caustics,"adaptive":options.adaptive,"transmission_samples":options.transmission_samples,"volume":options.volume,"nested_media":options.nested_media,"aging":options.aging
"caustics":options.caustics,"adaptive":options.adaptive,"transmission_samples":options.transmission_samples,"volume":options.volume,"nested_media":options.nested_media,"aging":options.aging
})
}
};
@@ -261,7 +250,7 @@ pub(crate) fn prepare_with_metadata(
params.get("raytrace").and_then(Value::as_object).is_some_and(|o| ["clear_coats", "textures"].iter().any(|key| o.contains_key(*key)));
if gpu_raytrace
|| params.get("environment").is_some_and(|e| e.get("kind").is_some())
|| ["lens", "lighting", "tone_map", "supersampling", "denoise_settings", "denoise_diagnostics"].iter().any(|key| params.get(*key).is_some())
|| ["lens", "lighting", "tone_map"].iter().any(|key| params.get(*key).is_some())
|| params.get("denoise").is_some_and(|value| value != &json!(false))
{
return Err(bad(
@@ -365,15 +354,7 @@ pub(crate) fn prepare_with_metadata(
let defaults = RenderSetup::default();
(defaults.width, defaults.height)
};
let trace_dimensions = if options.supersampling == 1 && !options.denoise && !options.denoise_diagnostics {
dimensions
} else {
solidcraft_render::job::supersampled_dimensions(dimensions.0, dimensions.1, options.supersampling).map_err(job_error)?
};
options.budget.validate_image(trace_dimensions.0, trace_dimensions.1, options.camera_sampling().map_err(job_error)?).map_err(job_error)?;
if options.denoise || options.denoise_diagnostics {
solidcraft_render::job::validate_cpu_denoise_image(trace_dimensions.0, trace_dimensions.1).map_err(job_error)?;
}
options.budget.validate_image(dimensions.0, dimensions.1, options.camera_sampling().map_err(job_error)?).map_err(job_error)?;
if let Some(lens) = options.lens {
let camera = input
.setup
@@ -453,19 +434,6 @@ pub(crate) fn prepare_with_metadata(
}
options.dielectrics = super::optical_bindings::resolve_cpu_path(s, &scene, bindings, options.path.is_some(), command)?;
options.validate().map_err(job_error)?;
// An explicit optical binding owns transmission. Viewport opacity is
// only a display proxy and must not enter CPU transport.
for optical in &options.dielectrics {
let body = scene
.meshes
.get_mut(optical.body_index)
.filter(|body| body.name == optical.body_name)
.ok_or_else(|| bad(command, "saved optical target changed during capture"))?;
body.opacity = 1.0;
for face in &mut body.faces {
face.opacity = 1.0;
}
}
}
super::coat_bindings::apply(s, &mut scene, &coats, input.raytrace.as_ref(), command)?;
if !input.aging.is_empty() {
@@ -555,13 +523,7 @@ pub(crate) fn prepare_with_metadata(
}
let textures = if let Some(bindings) = saved_surfaces {
let overrides = super::surface_bindings::resolve(s, &scene, bindings, command)?;
super::textures::capture_with_resources_and_filtering(
&scene,
&overrides,
Some(&s.doc.render.texture_resources),
command,
input.raytrace.as_ref().is_some_and(|options| options.texture_filtering),
)?
super::textures::capture_with_resources(&scene, &overrides, Some(&s.doc.render.texture_resources), command)?
} else {
None
};
@@ -577,13 +539,9 @@ pub(crate) fn prepare_with_metadata(
background: settings.background,
transparent_background: input.transparent_background,
};
let environment_bytes = environment
.as_ref()
.map_or(Ok(0), |e| e.required_bytes_with_importance(request.raytrace.as_ref().is_some_and(|options| options.environment_importance_sampling)))
.map_err(|e| bad(command, e.to_string()))?;
let mut submission =
let environment_bytes = environment.as_ref().map_or(Ok(0), |e| e.required_bytes()).map_err(|e| bad(command, e.to_string()))?;
let submission =
host.prepare_submission_with_metadata(scene, request, Default::default(), environment, cancel.clone(), retained).map_err(job_error)?;
submission.select_image_mode(settings.mode).map_err(job_error)?;
let request = submission.request();
let mut admission = submission.admission();
let retained_lights = request.raytrace.as_ref().map_or(Ok(0), |o| o.retained_bytes()).map_err(job_error)?;
@@ -625,16 +583,12 @@ pub(crate) fn prepare_with_metadata(
if !coats.is_empty() {
reply["coats"] = json!(coats);
}
if let Some(options) = request.raytrace.as_ref().filter(|options| options.denoise) {
if request.raytrace.as_ref().is_some_and(|options| options.denoise) {
reply["denoising"] = json!({
"method":"edge_aware_atrous", "backend":"native_cpu", "passes":options.denoise_settings.passes,
"steps":denoise_steps(options.denoise_settings.passes), "settings":options.denoise_settings,
"diagnostics_requested":options.denoise_diagnostics,
"outlier_policy":if options.denoise_settings.firefly_suppression {DENOISE_OUTLIER_POLICY} else {"disabled"},
"optical_policy":if options.denoise_settings.optical_filter {DENOISE_OPTICAL_POLICY} else {DENOISE_OPAQUE_ONLY_POLICY},
"method":"edge_aware_atrous", "backend":"native_cpu", "passes":3, "steps":[1,2,4],
"guides":"deterministic_center_ray_albedo_normal_camera_relative_position_depth",
"bypass":if options.denoise_settings.optical_filter {"unsupported or ambiguous optical paths, emitter, background and partial coverage"} else {"all optical paths, emitter, background and partial coverage"},
"limitation":"biased spatial filter; optical guides do not decompose reflected and transmitted radiance or reconstruct depth-of-field correspondences",
"bypass":"glass, sharp CPU mirror overrides, smooth metal/coat, emitter, background and partial coverage first hits",
"limitation":"biased spatial filter; first-hit guides do not reconstruct reflection/refraction or depth-of-field correspondences",
"raw_linear_accumulator_preserved":true,"raw_linear_exr_preserved":true,
"output_stage":"linear_before_tone_map", "work_units":"one pixel visit per pass, each with at most 25 taps",
"checkpoints":"every complete published pass uses the same filter"
@@ -760,22 +714,16 @@ fn prepare_progressive(
options.lighting = input.lighting;
options.tone_map = input.tone_map.unwrap_or_default();
options.denoise = input.denoise;
options.denoise_settings = input.denoise_settings;
options.denoise_diagnostics = input.denoise_diagnostics;
options.supersampling = input.supersampling.unwrap_or(1);
options.finishing = input.finishing;
options.backdrop = input.backdrop;
options.linear_exr = input.linear_exr;
options.aging = super::effects::resolve_aging(s, &scene, &input.aging, command)?;
options.nested_media = input.nested_media;
options.texture_filtering = input.texture_filtering;
options.metal_energy_compensation = input.metal_energy_compensation;
options.volume = input.volume;
options.caustics = input.caustics;
options.adaptive = input.adaptive;
options.transmission_samples = input.transmission_samples.unwrap_or(1);
options.validate().map_err(job_error)?;
solidcraft_render::job::supersampled_dimensions(settings.width, settings.height, options.supersampling).map_err(job_error)?;
if let Some(super::environment::Input::BuiltinHdr { id, .. }) = &input.environment
&& options.lighting.as_ref().is_some_and(|lighting| lighting.sun.is_some())
&& super::library::owns_sun(id, command)?
@@ -850,17 +798,10 @@ fn prepare_progressive(
height: settings.height,
background: settings.background,
transparent_background: input.transparent_background,
textures: super::textures::capture_with_resources_and_filtering(
&scene,
&input.raytrace.textures,
Some(&s.doc.render.texture_resources),
command,
input.texture_filtering,
)?,
textures: super::textures::capture_with_resources(&scene, &input.raytrace.textures, Some(&s.doc.render.texture_resources), command)?,
};
let refraction = input.progressive && scene.has_transmission();
let mut submission = host.prepare_submission_with_metadata(scene, request, options, None, cancel.clone(), retained).map_err(job_error)?;
submission.select_image_mode(settings.mode).map_err(job_error)?;
let submission = host.prepare_submission_with_metadata(scene, request, options, None, cancel.clone(), retained).map_err(job_error)?;
let request = submission.request();
let mut admission = submission.admission();
let retained_options_bytes = options.retained_bytes().map_err(job_error)?;
@@ -877,14 +818,10 @@ fn prepare_progressive(
}
if options.denoise {
reply["denoising"] = json!({
"method":"edge_aware_atrous", "passes":options.denoise_settings.passes,
"steps":denoise_steps(options.denoise_settings.passes), "settings":options.denoise_settings,
"diagnostics_requested":options.denoise_diagnostics,
"outlier_policy":if options.denoise_settings.firefly_suppression {DENOISE_OUTLIER_POLICY} else {"disabled"},
"optical_policy":if options.denoise_settings.optical_filter {DENOISE_OPTICAL_POLICY} else {DENOISE_OPAQUE_ONLY_POLICY},
"method":"edge_aware_atrous", "passes":3, "steps":[1,2,4],
"guides":"deterministic_center_ray_albedo_normal_position_depth",
"bypass":if options.denoise_settings.optical_filter {"unsupported or ambiguous optical paths, emitter, background and partial coverage"} else {"all optical paths, emitter, background and partial coverage"},
"limitation":"optical guides do not decompose reflected and transmitted radiance or reconstruct depth-of-field correspondences",
"bypass":"glass, smooth metal/coat, emitter, background and partial coverage first hits",
"limitation":"first-hit guides do not reconstruct reflection/refraction or depth-of-field correspondences",
"raw_linear_accumulator_preserved":true, "output_stage":"linear_before_tone_map"
});
}
@@ -1050,9 +987,6 @@ pub(super) fn export(s: &mut Session, params: &Value) -> Result<Value> {
let frame = s.render_jobs.frame(job).map_err(job_error)?;
let bytes = match input.format {
ExportFormat::Png => frame.png(),
ExportFormat::DenoiseMask => frame
.denoise_mask_png()
.ok_or_else(|| bad("render.export", "this completed job has no guide mask; enable denoising diagnostics before rendering"))?,
ExportFormat::Exr => frame
.linear_exr()
.ok_or_else(|| bad("render.export", "this completed job has no raw linear master; enable retention before rendering"))?,
@@ -1061,40 +995,27 @@ pub(super) fn export(s: &mut Session, params: &Value) -> Result<Value> {
let frame_info = retyped("render.export", &frame.info)?;
let linear_master = frame.linear_master_info().map(|info| retyped("render.export", info)).transpose()?;
let presentation = frame.presentation().map(|info| retyped("render.export", info)).transpose()?;
let denoise_mask = frame.denoise_mask_info().map(|info| retyped("render.export", info)).transpose()?;
// Replace atomically without reading an arbitrarily large previous target into memory.
// Design saves retain their independent backup policy.
let result =
if input.overwrite { solidcraft_io::write_atomic(path, bytes, false) } else { solidcraft_io::safe_write::create_atomic(path, bytes) };
result.map_err(|e| bad("render.export", format!("could not export completed {}: {e}", input.format.extension())))?;
let exr = input.format == ExportFormat::Exr;
let reply = ImageExported {
job: Job(job),
bytes: bytes.len(),
path: input.path,
format: input.format,
encoding: match input.format {
ExportFormat::Png => "srgb",
ExportFormat::Exr => "scene_linear_srgb_d65",
ExportFormat::DenoiseMask => "categorical_srgb",
},
alpha: match input.format {
ExportFormat::Png => "straight",
ExportFormat::Exr => "coverage_premultiplied",
ExportFormat::DenoiseMask => "opaque",
},
encoding: if exr { "scene_linear_srgb_d65" } else { "srgb" },
alpha: if exr { "coverage_premultiplied" } else { "straight" },
frame: frame_info,
linear_master,
presentation,
denoise_mask,
param_warnings: Vec::new(),
};
super::typed_reply("render.export", reply)
}
fn denoise_steps(passes: u32) -> Vec<u32> {
(0..passes.min(3)).map(|pass| 1 << pass).collect()
}
fn validate_studio_weights(weights: Option<[f64; 3]>, command: &str) -> Result<Option<[f64; 3]>> {
if weights.is_some_and(|weights| weights.iter().any(|value| !value.is_finite() || !(0.0..=1.0).contains(value))) {
return Err(bad(command, "studio visibility weights must be finite in 0..1"));
@@ -784,7 +784,7 @@ fn invalid_saved_jobs_never_partially_replace_a_setup() {
save(&mut s, json!({"job":{"method":"cpu_raytrace","options":{"primary_samples":4}},"exposure_ev":2}));
for patch in [
json!({"width":800,"job":{"method":"cpu_raytrace","options":{"primary_samples":3}}}),
json!({"job":{"method":"gpu_path","options":{"samples":65537}}}),
json!({"job":{"method":"gpu_path","options":{"samples":4097}}}),
json!({"job":{"method":"cpu_raytrace","options":{"emissions":[]}}}),
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"hdr","path":"not-opened.hdr"}}}}),
json!({"job":{"method":"raster"}}),
+1 -13
View File
@@ -115,23 +115,11 @@ pub(super) fn validate_targets(scene: &ProductScene, overrides: &[Override], com
pub(super) fn capture(scene: &ProductScene, overrides: &[Override], command: &str) -> Result<Option<Arc<RuntimeTextures>>> {
capture_with_resources(scene, overrides, None, command)
}
#[cfg(test)]
pub(super) fn capture_with_resources(
scene: &ProductScene,
overrides: &[Override],
resources: Option<&solidcraft_doc::render_texture_resources::TextureResources>,
command: &str,
) -> Result<Option<Arc<RuntimeTextures>>> {
capture_with_resources_and_filtering(scene, overrides, resources, command, false)
}
pub(super) fn capture_with_resources_and_filtering(
scene: &ProductScene,
overrides: &[Override],
resources: Option<&solidcraft_doc::render_texture_resources::TextureResources>,
command: &str,
filtering: bool,
) -> Result<Option<Arc<RuntimeTextures>>> {
if overrides.is_empty() {
return Ok(None);
@@ -176,7 +164,7 @@ pub(super) fn capture_with_resources_and_filtering(
flip_v: input.flip_v,
});
}
let resources = RuntimeTextures::new(bindings).map_err(|e| bad(command, e.to_string()))?.with_filtering(filtering);
let resources = RuntimeTextures::new(bindings).map_err(|e| bad(command, e.to_string()))?;
resources.validate_scene(scene).map_err(|e| bad(command, e.to_string()))?;
Ok(Some(Arc::new(resources)))
}
+2 -2
View File
@@ -21,7 +21,7 @@ fn bounded_dielectrics<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::R
impl<'de> serde::de::Visitor<'de> for Inputs {
type Value = Vec<Override>;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("at most 128 dielectric body overrides")
f.write_str("at most 32 disjoint dielectric body overrides")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error> {
let mut result = Vec::new();
@@ -74,7 +74,7 @@ impl TryFrom<String> for Boundary {
}
pub(super) fn apply(scene: &mut ProductScene, overrides: &[Override], command: &str) -> Result<()> {
if overrides.len() > solidcraft_render::job::MAX_GPU_DIELECTRICS {
return Err(bad(command, "at most 128 runtime dielectric overrides are supported"));
return Err(bad(command, "at most 32 runtime dielectric overrides are supported"));
}
let mut ordinals = std::collections::BTreeSet::new();
for input in overrides {
+8 -35
View File
@@ -65,12 +65,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("settings")
.noundo()
.doc_only()
.params("width, height: 1..16384; custom?: bool. Validated Render output draft request; headless echo only, no design edits or job. The UI separately limits new selections to 8192 per side, 33554432 output pixels and the selected rendering profile."),
.params("width, height: 1..16384; custom?: bool. Validated Render output draft request; headless echo only, no design edits or job."),
CommandSpec::new("render.result.view", "View Render Image", result_view)
.icon("render_image")
.noundo()
.doc_only()
.params("view: model|image|fit|actual|denoise_mask. Guide-mask view requires a selected completed result captured with denoising diagnostics. Validated result canvas request; headless echo only, never starts a job."),
.params("view: model|image|fit|actual. Validated result canvas request; headless echo only, never starts a job."),
CommandSpec::new("render.result.close", "Close Render Image", close_result)
.icon("close")
.key("Escape")
@@ -275,18 +275,8 @@ struct ResultView {
}
fn result_view(s: &mut Session, params: &Value) -> Result<Value> {
let input: ResultView = serde_json::from_value(params.clone()).map_err(|e| bad("render.result.view", e.to_string()))?;
if !matches!(input.view.as_str(), "model" | "image" | "fit" | "actual" | "denoise_mask") {
return Err(bad("render.result.view", "view must be model, image, fit, actual or denoise_mask"));
}
if input.view == "denoise_mask" {
s.render_jobs.collect().map_err(crate::render_jobs::job_error)?;
let job = s.render_selected_result().ok_or_else(|| bad("render.result.view", "no completed result is selected"))?;
let frame = s.render_jobs.frame(job).map_err(crate::render_jobs::job_error)?;
if frame.denoise_mask_png().is_none() {
return Err(bad("render.result.view", "the selected result has no denoising guide mask"));
}
s.render_jobs.set_comparison(None);
return Ok(json!({"view":input.view,"job":job}));
if !matches!(input.view.as_str(), "model" | "image" | "fit" | "actual") {
return Err(bad("render.result.view", "view must be model, image, fit or actual"));
}
if input.view == "model" {
s.render_jobs.set_comparison(None);
@@ -307,16 +297,7 @@ fn output_viewer_requests_are_validated_headless_echoes() {
let mut s = Session::default();
let document = s.doc.clone();
let before = (s.revision, s.undo.len(), s.evaluations);
// Headless requests keep the established per-side range. They allocate no
// image; the UI and actual renderer independently enforce their envelopes.
for size in [
json!({"width":1280,"height":720}),
json!({"width":1920,"height":1080}),
json!({"width":7680,"height":4320}),
json!({"width":16384,"height":1}),
json!({"width":1,"height":16384}),
json!({"width":16384,"height":16384}),
] {
for size in [json!({"width":1280,"height":720}), json!({"width":1920,"height":1080})] {
assert_eq!(s.execute("render.output.size", &size).unwrap(), size);
}
let custom = json!({"width":1920,"height":1080,"custom":true});
@@ -326,18 +307,10 @@ fn output_viewer_requests_are_validated_headless_echoes() {
let request = json!({"view":view});
assert_eq!(s.execute("render.result.view", &request).unwrap(), request);
}
assert!(s.execute("render.result.view", &json!({"view":"denoise_mask"})).is_err());
assert_eq!(s.execute("render.result.close", &json!({})).unwrap(), json!({"view":"model"}));
for request in [
json!(null),
json!({}),
json!({"width":0,"height":1}),
json!({"width":1,"height":0}),
json!({"width":16385,"height":1}),
json!({"width":1,"height":16385}),
json!({"width":u64::MAX,"height":1}),
json!({"width":1,"height":1,"extra":true}),
] {
for request in
[json!(null), json!({}), json!({"width":0,"height":1}), json!({"width":u64::MAX,"height":1}), json!({"width":1,"height":1,"extra":true})]
{
assert!(s.execute("render.output.size", &request).is_err());
}
for request in [json!(null), json!({"view":"unknown"}), json!({"view":"image","extra":true})] {
+2 -3
View File
@@ -1205,9 +1205,8 @@ fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
let text = if format == BendTableFormat::Json {
serde_json::to_string_pretty(&table).map_err(|e| EngineError::Other(e.to_string()))?
} else {
// Always quote text fields: body/rule names may include commas, quotes or newlines, or start
// as a spreadsheet formula does.
let quote = solidcraft_doc::csv::quoted;
// Always quote text fields: body/rule names may include commas, quotes or newlines.
let quote = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
let mut csv =
"body,rule,thickness_mm,k_factor,bend,angle_deg,direction,radius_mm,allowance_mm,length_mm,start_x_mm,start_y_mm,end_x_mm,end_y_mm,included_angle_deg\r\n"
.to_string();
-11
View File
@@ -2069,17 +2069,6 @@ fn a_flange_bent_inside_keeps_the_base_sketchs_arcs_in_the_flat_pattern_and_the_
}
}
/// A rule or body name that starts as a spreadsheet formula does is written as text (#12728).
#[test]
fn bend_table_writes_a_formula_like_name_as_text() {
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "=A1+1", "thickness": 2, "bend_radius": 3, "k_factor": 0.4}));
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20}));
let text = run(&mut s, "sheet.export_bend_table", json!({}))["text"].as_str().unwrap_or_default().to_string();
assert!(text.lines().nth(1).is_some_and(|row| row.contains(",\"'=A1+1\",2.000000000,")), "{text}");
}
#[test]
fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() {
let mut s = Session::default();
+1 -5
View File
@@ -15,7 +15,6 @@ use crate::schema::sketch::{
MidpointParams, ParallelParams, PerpendicularParams, SymmetryParams, SymmetryResult, TangentMade, TangentParams, TangentResult,
};
use crate::schema::sketch_dimension::{DimensionResult, SketchDimensionParams};
use crate::schema::sketch_dimensioned::{DrawDimensionedParams, DrawDimensionedResult};
use crate::schema::sketch_session::{
Activated, Attachment, ChainParams, ChainResult, ConstructionParams, ConstructionResult, CreateParams, CreateResult, DeleteParams, DeleteResult,
DimensionTextParams, DimensionTextResult, DragEndParams, DragEndResult, DragParams, DragResult, Frame, MovePointParams, MovePointResult,
@@ -64,7 +63,6 @@ const DIMENSION_TEXT_CONTRACT: TypedContract<DimensionTextParams> = TypedContrac
const OPTIONS_CONTRACT: TypedContract<OptionsParams> = TypedContract::new();
const CHAIN_CONTRACT: TypedContract<ChainParams> = TypedContract::new();
const SOLVE_CONTRACT: TypedContract<SolveParams> = TypedContract::new();
const DRAW_DIMENSIONED_CONTRACT: TypedContract<DrawDimensionedParams> = TypedContract::new();
const EDIT_CONTRACT: TypedContract<EditParams> = TypedContract::new();
const EXIT_CONTRACT: TypedContract<ExitParams> = TypedContract::new();
const HORIZONTAL_VERTICAL_CONTRACT: TypedContract<HorizontalVerticalParams> = TypedContract::new();
@@ -125,10 +123,8 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&LINE_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…] (x and y each a number or an expression over the design's parameters, \"W / 2\": such a point is fixed where it comes to and follows the parameters; likewise wherever a point argument also takes a point id), closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", crate::checked!("sketch.draw.dimensioned", draw_dimensioned)).icon("dimension")
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned).icon("dimension")
.enabled(in_sketch)
.contract(&DRAW_DIMENSIONED_CONTRACT)
.output(output_schema_for::<DrawDimensionedResult>)
.params(concat!("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value, or `name = expression` (also `#name = expression`), which defines the user parameter `name` as sketch.dimension does (the reply's `defined` lists the names) (optional with as_drawn or locate), as_drawn?: bool (also dimension every other role of the shape at its size as drawn; a radius or diameter given takes the place of the other, as an edge polygon's side length does its radius, a circle otherwise takes its diameter, and a size the sketch already determines, such as the angle of a line inferred horizontal, is left out), locate?: bool (then place the shape from the sketch origin until nothing of it is left free: horizontal and vertical distances from the origin to its points, centres first, each kept only where it removes freedom, so a shape anchored to the origin or to placed geometry gets none; a point on an axis goes onto that axis instead of a 0 distance; the reply's located lists them); creates geometry and all driving dimensions atomically in one undo step", created_doc!())),
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", crate::checked!("sketch.rectangle.two_point", rect_two_point))
.at("SKETCH", "CREATE")
+12 -7
View File
@@ -17,7 +17,6 @@ use solidcraft_doc::expr::{Kind, definition};
use crate::{
Result, Session,
params::{bad, bool_, expr, str_},
schema::sketch_dimensioned::DrawDimensionedResult,
};
/// The dimension roles of a sketch creator, in the order `as_drawn` adds them, or None when
@@ -136,13 +135,13 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let mut scratch = s.scratch();
let out = scratch.execute(create, &params)?;
let mut out = scratch.execute(create, &params)?;
let curves = out.get("curves").and_then(Value::as_array).cloned().unwrap_or_default();
let curve = |index: usize| -> Result<Value> {
curves.get(index).filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "creator did not return the expected curve"))
};
let mut added = serde_json::Map::new();
let mut defined: Vec<String> = Vec::new();
let mut defined = Vec::new();
for (role, value) in wanted(roles, dimensions, as_drawn) {
let (entities, ty) = match (create, role) {
("sketch.line" | "sketch.line.midpoint", "length") => (vec![curve(0)?], "length"),
@@ -188,8 +187,8 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
dimension["value"] = value.clone();
dimension["if_redundant"] = json!("driven");
let result = scratch.execute("sketch.dimension", &dimension)?;
if let Some(name) = result.get("defined").and_then(Value::as_str) {
defined.push(name.to_string());
if let Some(name) = result.get("defined").filter(|v| v.is_string()) {
defined.push(name.clone());
}
added.insert(role.to_string(), result);
}
@@ -213,8 +212,14 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
// Session::execute records the wrapper's before snapshot. Nested history must not leak.
scratch.undo.clear();
s.accept_computed(scratch);
let reply = DrawDimensionedResult::new(out, added, defined, locate.then_some(located));
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
out["dimensions"] = Value::Object(added);
if !defined.is_empty() {
out["defined"] = Value::Array(defined);
}
if locate {
out["located"] = Value::Array(located);
}
Ok(out)
}
/// The sketch's degrees of freedom as a command reported them.
+1 -2
View File
@@ -206,11 +206,10 @@ fn child() {
}
// A clean exit whose journal cannot be removed: the directory is read-only.
"clean_read_only" => {
// Only Unix makes the folder read-only here; elsewhere the session just ends.
let sessions = dir.join("sessions");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let sessions = dir.join("sessions");
fs::set_permissions(&sessions, fs::Permissions::from_mode(0o500)).unwrap();
end_session();
fs::set_permissions(&sessions, fs::Permissions::from_mode(0o700)).unwrap();
+1 -4
View File
@@ -696,10 +696,7 @@ fn map_local(s: &Session, world: &solidcraft_doc::ModelState, id: &str, params:
Axis => {
if let Some(axis) = v.as_object() {
let mut axis = axis.clone();
// An `{origin, dir}` axis without its origin passes through the world's,
// which the component may have moved.
let origin = axis.get("origin").cloned().or_else(|| axis.contains_key("dir").then(|| pt(Vec3::ZERO)));
if let Some(origin) = origin.and_then(|o| m.make(&o)) {
if let Some(origin) = axis.get("origin").and_then(|o| m.make(o)) {
axis.insert("origin".into(), origin);
}
if let Some(dir) = axis.get("dir").and_then(vec3)
-32
View File
@@ -456,38 +456,6 @@ fn a_surface_revolve_about_a_picked_edge_of_a_moved_component() {
assert!(near(edge.0, line.0) && near(edge.1, line.1), "{edge:?} against {line:?}");
}
/// A revolve axis given only by its direction passes through the world origin in a moved
/// component, as one with `origin: [0, 0, 0]` does: solid and surface revolves, made and edited.
#[test]
fn an_axis_without_an_origin_passes_through_the_world_origin() {
let made = |command: &str, axis: Value| {
let mut s = moved_part();
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Ring"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 0], "p1": [10, 5]}));
run(&mut s, "sketch.finish", json!({}));
let mut p = json!({"sketch": "Ring", "axis": axis, "name": "Turn"});
if command == "solid.revolve" {
p["operation"] = json!("new");
}
run(&mut s, command, p.clone());
let body = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap();
let first = world_bbox(&s, &body);
// Edit Feature with the same axis given again.
run(&mut s, "timeline.redefine", json!({"feature": "Turn", "command": command, "params": p}));
let body = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap();
(first, world_bbox(&s, &body))
};
for command in ["solid.revolve", "surface.revolve"] {
let (given, given_edit) = made(command, json!({"origin": [0, 0, 0], "dir": [0, 0, 1]}));
let (omitted, omitted_edit) = made(command, json!({"dir": [0, 0, 1]}));
assert!(near(given.0, omitted.0) && near(given.1, omitted.1), "{command}: {given:?} against {omitted:?}");
assert!(
near(given_edit.0, omitted_edit.0) && near(given_edit.1, omitted_edit.1),
"{command}, edited: {given_edit:?} against {omitted_edit:?}"
);
}
}
/// A new placement given to `timeline.redefine` is still a world point.
#[test]
fn a_new_primitive_placement_is_still_mapped_from_the_world() {
-41
View File
@@ -125,47 +125,6 @@ fn an_upstream_cut_that_splits_a_face_keeps_the_edge_that_bounded_it() {
assert!(solid_at(&s, [0.1, 15.0, 9.9]), "the left one is not");
}
/// Three pockets of a pattern open onto the block's front wall, and a channel at their depth
/// joins their floors into one face: the three edges between the wall and that floor share one
/// name, numbered by where they lie, and a chamfer keeps all three. With the channel shallower,
/// each pocket has its own floor again, and only the first pocket's edge lies between the wall
/// and the floor the names name. The chamfer asks for its edges again instead of chamfering
/// that one edge for all three (#13332); with the floors joined again, it finds all three.
#[test]
fn edges_numbered_between_two_faces_are_not_all_taken_for_the_one_left_there() {
let mut s = Session::default();
block(&mut s);
run(&mut s, "parameters.add", json!({"name": "channel_floor", "expression": "5 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "PocketSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [3, -5], "p1": [9, 6]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "PocketSketch", "distance": 12, "start_offset": 5, "operation": "cut", "name": "Pocket"}));
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Pocket"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 12, "name": "Row"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "ChannelSketch"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [3, 3], "p1": [33, 9]}));
run(&mut s, "sketch.finish", json!({}));
let channel = json!({"sketch": "ChannelSketch", "distance": 12, "start_offset": "channel_floor", "operation": "cut", "name": "Channel"});
run(&mut s, "solid.extrude", channel);
run(&mut s, "solid.chamfer", json!({"edges": [[6, 0, 5], [18, 0, 5], [30, 0, 5]], "distance": 1, "name": "Chamfer"}));
let (block, pocket) = (id(&s, "Block"), id(&s, "Pocket"));
let chamfer = s.doc.find_feature("Chamfer").cloned().unwrap();
let mut stored = chamfer.edge_names.clone();
stored.sort();
assert_eq!(stored, (1..=3).map(|k| format!("F{block}:side:l1|F{pocket}:top~{k}")).collect::<Vec<_>>());
// Inside the block just below each pocket's floor, at the front: cut away by a chamfer there.
let chamfered = |s: &Session| [6.0, 18.0, 30.0].map(|x| !solid_at(s, [x, 0.2, 4.8]));
assert_eq!(chamfered(&s), [true; 3]);
change(&mut s, "channel_floor", "6 mm");
let asked = reselect(&s, "Chamfer");
assert!(asked.as_ref().is_some_and(|name| chamfer.edge_names.contains(name)), "{asked:?}, {:?}", error(&s, "Chamfer"));
assert_eq!(chamfered(&s), [false; 3], "no pocket's edge is chamfered for another's");
change(&mut s, "channel_floor", "5 mm");
assert_eq!(error(&s, "Chamfer"), None);
assert_eq!(chamfered(&s), [true; 3]);
}
#[test]
fn a_blend_follows_its_named_edge_to_a_moved_lid_instead_of_the_body_below() {
for (command, size, removed) in [("solid.fillet", "radius", (4.0 - std::f64::consts::PI) * 5.0), ("solid.chamfer", "distance", 2.0 * 5.0)] {
@@ -247,11 +247,10 @@ fn first(claim: Claim, out: &mut Prepared) -> Result<()> {
// No normalized Arc<Value> remains alongside this arena. The exact original
// Batch input belongs only to this first Prepared command metadata owner,
// never to the persistent Plan, and retires on the same worker after finish.
let mut plan = Plan { ticket: claim.ticket, payload, rows, scope: claim.scope, next: 0, lease };
let plan = Plan { ticket: claim.ticket, payload, rows, scope: claim.scope, next: 0, lease };
if plan.actual_bytes()? != plan.lease.bytes() {
return Err(bad("batch arena actual backing disagrees with reservation"));
}
plan.lease.settle().map_err(crate::render_jobs::job_error)?;
out.plan = Some(plan);
Ok(())
}
-344
View File
@@ -1,344 +0,0 @@
//! Contract for `solid.align` in `cmd/features_more.rs` (#13314). Align adds a feature and returns
//! `add_feature`'s reply, a [`super::features::FeatureResult`].
//!
//! The handler reads its parameters from the raw JSON, so each key here reads as the handler reads
//! it and fails with the handler's message. Whether a body, a plane, a sketch or a joint origin
//! exists, whether a pick lies on a planar face, and which combinations of keys go together, stay
//! the handler's to check.
//!
//! Align reads some keys only in some cases, and the contract reads them in the same cases:
//!
//! - a side (`from` or `to`) that has a plane or a frame (`from_plane`, `from_frame`) is worked
//! out from that, and its point and face keys are not read (a call that gives both is refused by
//! the handler as a whole);
//! - otherwise a face that is a point (`from_face`) wins, and `from` is not read at all;
//! - otherwise `from` is read: `"origin"`, `{snap?, at}` or `[x, y, z]`.
//!
//! So the type is written by hand, in the handler's order, and a call that works today gets no
//! warning for a key the handler does not read. Where the handler reads a key of the wrong type as
//! if it were left out (`flip`, `angle`, a `from_face` that is not a point, a `snap` that is not
//! text), the contract refuses it: that is what warn and strict are for. `bodies` is optional in
//! the type and required in the schema, and so is one of `from`, `from_face`, `from_plane` and
//! `from_frame` (and the same for `to`), so that the handler's own message is what a caller sees.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use serde_json::{Map, Value, json};
use super::check::whole;
use super::config::NameList;
use super::joints::JointOriginArg;
use super::motion::require;
use super::types::{AngleExpr, FeatureCommon, PlaneParam, Point3, SketchRef, StringList, feature_common};
use crate::cmd::{MAX_SKETCH_ID, SidePoint, SketchFrameId, axes_mask, sketch_frame_id, sketch_key};
use crate::params::{self, MAX_STRING_LIST};
/// The handler's message for `bodies` left out, `null` or empty.
const BODIES_MESSAGE: &str = "`bodies` must list the bodies to move";
/// The kinds of snap point `from` and `to` take.
const SNAPS: [&str; 4] = ["vertex", "edge_mid", "face_center", "circle_center"];
/// The `bodies` schema: one name, or a list of 1 to 10 000.
fn bodies_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "The bodies to move, by name: one, or a list of 1 to 10 000.",
"anyOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": MAX_STRING_LIST}
]
})
}
/// A point of an Align side: `"origin"`, a snap `{snap?, at}` or `[x, y, z]`. Only for the schema:
/// whether it is read at all depends on the side's other keys, so [`AlignParams`] reads it.
struct SidePointSchema;
impl JsonSchema for SidePointSchema {
fn schema_name() -> Cow<'static, str> {
"AlignPoint".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
let c = json!({"type": "number", "exclusiveMinimum": -1e9, "exclusiveMaximum": 1e9});
json_schema!({
"description": "A point: [x, y, z] in mm, the text \"origin\" (any case), or {\"snap\"?, \"at\"}: the nearest vertex, edge midpoint, planar face centre or circular edge centre of the model to the point `at`. {\"x\", \"y\", \"z\"} is not a point here.",
"anyOf": [
{"type": "array", "items": c, "minItems": 3, "maxItems": 3},
{"type": "string", "pattern": "^[Oo][Rr][Ii][Gg][Ii][Nn]$"},
{
"type": "object",
"properties": {
"snap": {"description": "What to snap to. Default: vertex.", "type": "string", "enum": SNAPS},
"at": {"description": "The point to snap near: [x, y, z] or {\"x\", \"y\", \"z\"}, in mm.", "anyOf": [
{"type": "array", "items": c, "minItems": 3, "maxItems": 3},
{"type": "object", "properties": {"x": c, "y": c, "z": c}, "required": ["x", "y", "z"]}
]}
},
"required": ["at"]
}
]
})
}
}
/// A frame of an Align side: a joint origin as `joint.create` takes one, or `{sketch, point|curve}`.
/// Only for the schema; [`AlignParams`] reads it with [`frame_problem`].
struct FrameSchema;
impl JsonSchema for FrameSchema {
fn schema_name() -> Cow<'static, str> {
"AlignFrame".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let sketch = generator.subschema_for::<SketchRef>();
let origin = generator.subschema_for::<JointOriginArg>();
let id = json!({"type": "string", "minLength": 1, "maxLength": MAX_SKETCH_ID});
json_schema!({
"description": "A frame that is found again whenever the design changes, so the bodies follow it: a sketch point, or the centre of a sketch circle, arc or ellipse, as {\"sketch\", \"point\" | \"curve\"} (z along the sketch's normal); or a joint origin as joint.create takes one: {face | circle | edge | point: [x, y, z]}, a face's name, or the name of a joint origin. On the bodies' own component, which must hold all of them and be placed once.",
"anyOf": [
{
"type": "object",
"properties": {
"sketch": sketch,
"point": {"description": "A sketch point's id: 1 to 256 characters. Give this or `curve`.", "allOf": [id]},
"curve": {"description": "A sketch circle's, arc's or ellipse's id: 1 to 256 characters. Give this or `point`.", "allOf": [id]}
},
"required": ["sketch"],
"anyOf": [
{"required": ["point"], "not": {"required": ["curve"]}},
{"required": ["curve"], "not": {"required": ["point"]}}
]
},
{"allOf": [origin], "not": {"type": "object", "required": ["sketch"]}}
]
})
}
}
/// `axes`: three booleans, at least one true. Only for the schema.
struct AxesSchema;
impl JsonSchema for AxesSchema {
fn schema_name() -> Cow<'static, str> {
"AlignAxes".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "Which world axes the move follows: [x, y, z] booleans, at least one true.",
"type": "array",
"items": {"type": "boolean"},
"minItems": 3,
"maxItems": 3,
"contains": {"const": true}
})
}
}
/// `bodies` is required, and so is one key for each side.
fn align_schema(schema: &mut Schema) {
require(schema, "bodies");
// Each side needs one of its four keys; the handler says which, in its own words.
let side = |keys: [&str; 4]| json!({"anyOf": keys.iter().map(|k| json!({"required": [k]})).collect::<Vec<_>>()});
let sides = [side(["from", "from_face", "from_plane", "from_frame"]), side(["to", "to_face", "to_plane", "to_frame"])];
match schema.get_mut("allOf") {
Some(Value::Array(all)) => all.extend(sides),
_ => {
schema.insert("allOf".into(), Value::Array(sides.into()));
}
}
}
/// Move bodies so that a point, face, plane or frame on them meets another one, as a feature in
/// the timeline (one undo step). Each side is one of: a point (`from`, `to`), a planar face
/// picked by a point on it (`from_face`, `to_face`), a plane (`from_plane`, `to_plane`) or a frame
/// (`from_frame`, `to_frame`); give one key for each side. A plane or a frame is worked out again
/// whenever the design changes, so the bodies follow it. Faces, planes and frames also turn the
/// bodies so that they meet (the first side's face against the second's, or with `flip` side by
/// side); a point onto a plane moves along the plane's normal. `axes` keeps the move to some
/// world axes and only goes with two points. Align makes no body, so `body_name` and `body_names`
/// do nothing here (as for Remove), and `participants` changes nothing in the result.
#[derive(Debug, JsonSchema)]
#[schemars(deny_unknown_fields, transform = align_schema)]
pub struct AlignParams {
// Optional in the type and required in the schema (`align_schema`): left out, `null` or
// empty, the handler refuses the call with its own message.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "bodies_schema")]
pub bodies: Option<NameList>,
/// Where the move starts, on the bodies. Not read when `from_face` is a point; not with
/// `from_plane` or `from_frame` (the handler refuses both).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "SidePointSchema")]
pub from: Option<Value>,
/// A point on a planar face of the bodies to start from: the bodies turn so that the face
/// meets the target's. When it is a point, `from` is not read. Not with `from_plane` or
/// `from_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point3")]
pub from_face: Option<Value>,
/// A plane to start from, kept as a plane so the bodies follow it when it moves; the bodies
/// turn so that it meets the target. Not with `from`, `from_face` or `from_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "PlaneParam")]
pub from_plane: Option<Value>,
/// A frame to start from, in place of the point, face or plane; the bodies turn so that it
/// meets the target. Not with `from`, `from_face` or `from_plane`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "FrameSchema")]
pub from_frame: Option<Value>,
/// Where the bodies go. Not read when `to_face` is a point; not with `to_plane` or
/// `to_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "SidePointSchema")]
pub to: Option<Value>,
/// A point on a planar face to go to. When it is a point, `to` is not read. Not with
/// `to_plane` or `to_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point3")]
pub to_face: Option<Value>,
/// A plane to go to, which may move later (the bodies follow); a point or a face goes onto
/// it along its normal. Not with `to`, `to_face` or `to_frame`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "PlaneParam")]
pub to_plane: Option<Value>,
/// A frame to go to, in place of the point, face or plane. It must not be on a body that
/// moves. Not with `to`, `to_face` or `to_plane`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "FrameSchema")]
pub to_frame: Option<Value>,
/// With faces, planes or frames: true turns the bodies so that the two normals point the same
/// way, instead of facing each other. Default: false.
#[serde(default)]
pub flip: Option<bool>,
/// Which world axes the offset moves along, with a point on each side: [x, y, z] booleans,
/// at least one true. Default: all three. Refused with a face, plane or frame.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "AxesSchema")]
pub axes: Option<[bool; 3]>,
/// A turn about the target's direction (a face's normal, a plane's normal or a frame's z)
/// after the move, in degrees or an expression. Needs a face, a plane or a frame as the
/// target. Default: none.
#[serde(default)]
pub angle: Option<AngleExpr>,
#[serde(flatten)]
pub common: FeatureCommon,
}
/// `bodies` as the handler reads it: names, at least one; `null` and a missing key are none.
fn bodies(m: &Map<String, Value>) -> Result<NameList, String> {
let names = match m.get("bodies") {
None | Some(Value::Null) => NameList::default(),
// Each of its messages starts with "must": the key goes in front, as `string_list` has it.
Some(v) => NameList::deserialize(v).map_err(|e| whole(format!("`bodies` {e}")))?,
};
if names.0.is_empty() { Err(whole(BODIES_MESSAGE)) } else { Ok(names) }
}
/// `key`, a plane, when it is given (`null` included: the handler reads it as a plane and
/// refuses it).
fn plane(m: &Map<String, Value>, key: &str) -> Result<Option<Value>, String> {
// The reader's own message is a whole sentence, marked as such.
m.get(key).map(|v| PlaneParam::deserialize(v).map(|p| p.0).map_err(|e| e.to_string())).transpose()
}
/// What is wrong with the frame `v` of `key` (`from_frame`), as the handler says it, marked
/// whole, or `None`.
fn frame_problem(v: &Value, key: &str) -> Option<String> {
if let Some(sketch) = v.get("sketch") {
if sketch_key(Some(sketch)).is_none() {
return Some(whole(format!("`{key}` must be a sketch id or name")));
}
return match sketch_frame_id(v) {
Some(SketchFrameId::Point(_) | SketchFrameId::Curve(_)) => None,
None => Some(whole(format!("`{key}`: a sketch frame takes `point` or `curve` (an id of 1 to {MAX_SKETCH_ID} characters)"))),
};
}
// A joint origin, which the handler reads as `joint.create` reads one.
JointOriginArg::deserialize(v).err().map(|e| e.to_string())
}
/// `<key>_frame`, when it is given and not `null`.
fn frame(m: &Map<String, Value>, key: &str) -> Result<Option<Value>, String> {
let frame_key = format!("{key}_frame");
match m.get(&frame_key) {
None | Some(Value::Null) => Ok(None),
// The handler refuses a frame given beside the side's other keys before it reads the frame.
Some(v) if [key.to_string(), format!("{key}_face"), format!("{key}_plane")].iter().any(|k| m.contains_key(k)) => Ok(Some(v.clone())),
Some(v) => frame_problem(v, &frame_key).map_or_else(|| Ok(Some(v.clone())), Err),
}
}
/// The point and face of the side `pk` (`from`), read as the handler reads them: not at all when
/// the side has a plane or a frame (`held`); the face if it is a point, and then `from` is not
/// read; else `from`.
fn side(m: &Map<String, Value>, pk: &str, held: bool) -> Result<(Option<Value>, Option<Value>), String> {
if held {
return Ok((None, None));
}
let fk = format!("{pk}_face");
match m.get(&fk) {
None | Some(Value::Null) => {}
Some(v) if params::vec3(v).is_some() => return Ok((None, Some(v.clone()))),
Some(_) => return Err(whole(format!("`{fk}` must be a point [x, y, z] of numbers"))),
}
let v = m.get(pk);
match crate::cmd::side_point(v, pk, &fk).map_err(whole)? {
SidePoint::Snap { .. } => {
// The handler reads a `snap` that is not text as a vertex; the word is its to look up.
if v.and_then(|o| o.get("snap")).is_some_and(|s| !s.is_null() && !s.is_string()) {
return Err(whole(format!("`{pk}.snap` must be {}", SNAPS.join(", "))));
}
}
SidePoint::Origin | SidePoint::Point(_) => {}
}
Ok((v.cloned(), None))
}
impl<'de> Deserialize<'de> for AlignParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let m = Map::<String, Value>::deserialize(d)?;
let fail = D::Error::custom;
// In the handler's order: the bodies, the planes, the frames, the two sides' points, the
// angle and the axes; then `add_feature`'s keys.
let bodies = bodies(&m).map_err(fail)?;
let (from_plane, to_plane) = (plane(&m, "from_plane").map_err(fail)?, plane(&m, "to_plane").map_err(fail)?);
let (from_frame, to_frame) = (frame(&m, "from").map_err(fail)?, frame(&m, "to").map_err(fail)?);
let (from, from_face) = side(&m, "from", from_plane.is_some() || from_frame.is_some()).map_err(fail)?;
let (to, to_face) = side(&m, "to", to_plane.is_some() || to_frame.is_some()).map_err(fail)?;
let angle = match m.get("angle") {
None | Some(Value::Null) => None,
Some(v) => Some(AngleExpr::deserialize(v).map_err(|e| fail(whole(format!("`angle` {e}"))))?),
};
let axes = match m.get("axes") {
None | Some(Value::Null) => None,
Some(v) => Some(axes_mask(v).map_err(|why| fail(whole(why)))?),
};
let flip = match m.get("flip") {
None | Some(Value::Null) => None,
Some(Value::Bool(b)) => Some(*b),
Some(_) => return Err(fail(whole("`flip` must be true or false"))),
};
// `add_feature` reads `body_names` and `participants` as `string_list` does, whose messages
// are `NameList`'s, not `FeatureCommon`'s; they are read here, and the rest go to it.
let mut m = m;
let mut lists = Vec::new();
for key in ["participants", "body_names"] {
if let Some(v) = m.remove(key).filter(|v| !v.is_null()) {
lists.push(StringList(NameList::deserialize(v).map_err(|e| fail(whole(format!("`{key}` {e}"))))?.0));
} else {
lists.push(StringList::default());
}
}
let mut common = feature_common::<_, FeatureCommon>(Value::Object(m)).map_err(|e| fail(e.to_string()))?;
let (body_names, participants) = (lists.pop().filter(|l| !l.0.is_empty()), lists.pop().filter(|l| !l.0.is_empty()));
(common.body_names, common.participants) = (body_names, participants);
Ok(AlignParams { bodies: Some(bodies), from, from_face, from_plane, from_frame, to, to_face, to_plane, to_frame, flip, axes, angle, common })
}
}
#[cfg(test)]
#[path = "align_tests.rs"]
mod tests;
-329
View File
@@ -1,329 +0,0 @@
use serde_json::{Value, json};
use super::AlignParams;
use crate::Session;
use crate::schema::features::FeatureResult;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
/// Box `A` (10 x 10 x 10 at the origin) and `B` (10 x 20 x 10 at (50, 0, 20)), and sketch `S` on
/// XY with a circle `ring` at (0, 5) and a point `p` at (20, 0).
fn scene(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
for (id, p) in [
("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"})),
("solid.box", json!({"length": 10, "width": 20, "height": 10, "corner": [50, 0, 20], "body_name": "B", "operation": "new"})),
("sketch.create", json!({"plane": "XY", "name": "S"})),
("sketch.circle.center", json!({"center": [0, 5], "radius": 3, "id": "ring"})),
("sketch.point", json!({"point": [20, 0], "id": "p"})),
("sketch.finish", json!({})),
] {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
}
s
}
/// `base` for body `B` with `extra`'s keys added.
fn call(extra: Value) -> Value {
let mut p = json!({"bodies": ["B"]});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
p
}
fn messages(p: &Value) -> Vec<String> {
issues("solid.align", p).into_iter().map(|i| i.message).collect()
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(
input_schema_for::<AlignParams>(),
&[
"angle",
"axes",
"bodies",
"body_name",
"body_names",
"flip",
"from",
"from_face",
"from_frame",
"from_plane",
"name",
"participants",
"to",
"to_face",
"to_frame",
"to_plane",
],
);
let schema = input_schema_for::<AlignParams>();
assert_eq!(schema.as_value()["required"], json!(["bodies"]));
}
/// Calls that fail today get the same message in strict, on a design with the bodies, a sketch
/// and its geometry (the handler's first check is that the bodies exist).
#[test]
fn strict_keeps_todays_errors() {
let long = "q".repeat(300);
let cases = [
json!({}),
json!({"bodies": null, "from": [0, 0, 0], "to": [1, 1, 1]}),
json!({"bodies": [], "from": [0, 0, 0], "to": [1, 1, 1]}),
json!({"bodies": 5, "from": [0, 0, 0], "to": [1, 1, 1]}),
json!({"bodies": ["B", 5], "from": [0, 0, 0], "to": [1, 1, 1]}),
call(json!({})),
call(json!({"from": [0, 0, 0]})),
call(json!({"to": [0, 0, 0]})),
call(json!({"from": [0, 0], "to": [0, 0, 0]})),
call(json!({"from": null, "to": [0, 0, 0]})),
call(json!({"from": "nowhere", "to": [0, 0, 0]})),
call(json!({"from": {"x": 1, "y": 2, "z": 3}, "to": [0, 0, 0]})),
call(json!({"from": {"snap": "vertex"}, "to": [0, 0, 0]})),
call(json!({"from": {"at": "here"}, "to": [0, 0, 0]})),
call(json!({"from": [0, 0, 0], "to": {"at": [1, 2]}})),
call(json!({"from": [0, 0, 0], "to": "centre"})),
call(json!({"from": "origin", "to_face": null})),
call(json!({"from_plane": null, "to": [0, 0, 0]})),
call(json!({"from_plane": true, "to": [0, 0, 0]})),
call(json!({"from_plane": -1, "to": [0, 0, 0]})),
call(json!({"from_plane": {"origin": [0, 0]}, "to": [0, 0, 0]})),
call(json!({"from_plane": {}, "to": [0, 0, 0]})),
call(json!({"from": [0, 0, 0], "to_plane": [1]})),
call(json!({"from_frame": 5, "to": [0, 0, 0]})),
// A frame beside the side's other keys is refused as "not both" before it is read.
call(json!({"from_frame": 5, "from": [0, 0, 0], "to": [1, 1, 1]})),
call(json!({"from_plane": "XY", "from_frame": {"sketch": "S"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"frame": {}}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": true, "point": "p"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": null, "point": "p"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": "p", "curve": "ring"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": ""}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": long}, "to": [0, 0, 0]})),
call(json!({"from": [0, 0, 0], "to_frame": {"sketch": "S", "point": 5}})),
call(json!({"from": [0, 0, 0], "to_frame": {"face": [1, 2]}})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": "x"})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": [true, false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": [true, 1, false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "axes": [false, false, false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": 5})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": ["Nope"]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": ["B", false]})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "participants": 5, "body_names": 6})),
call(json!({"from": [0], "to": [1]})),
call(json!({"from_plane": 7.5, "to_plane": true})),
call(json!({"from": [0, 0, 0], "to": [1, 1, 1], "body_names": [7]})),
];
for p in &cases {
let error = |level: ParamCheck| scene(level).execute("solid.align", p).err().map(|e| e.to_string());
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{p}");
}
// The ones that fail before any body is looked up, with the shared helper's session.
assert_strict_keeps_todays_errors(&[
("solid.align", json!({})),
("solid.align", json!({"bodies": []})),
("solid.align", json!({"bodies": 5})),
("solid.align", json!({"bodies": [1]})),
]);
}
/// A side that has a plane or a frame is read from that, and one that has a face that is a point
/// is read from the face: the keys the handler does not read in that case are not checked, so a
/// call that works today gets no warning.
#[test]
fn keys_the_handler_does_not_read_are_not_checked() {
let calls = [
// `from` is not read when `from_face` is a point.
call(json!({"from_face": [55, 10, 20], "from": "garbage", "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "from": null, "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "from": {"snap": 5}, "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 0], "to": 17})),
// A null face is a missing one.
call(json!({"from_face": null, "from": [50, 0, 20], "to": [0, 0, 0]})),
// A null frame is a missing one; so is a face that is null beside a frame.
call(json!({"from_frame": null, "from": [50, 0, 20], "to": "origin"})),
call(json!({"from": [50, 0, 20], "to_frame": null, "to": [1, 2, 3]})),
// A snap with no `snap` snaps to a vertex; the object's other keys are not read.
call(json!({"from": {"at": [50, 0, 20], "note": 1}, "to": {"snap": null, "at": [0, 0, 0]}})),
// `axes` and `angle` may be `null`.
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axes": null, "angle": null, "flip": null})),
];
for p in &calls {
assert!(scene(ParamCheck::Off).execute("solid.align", p).is_ok(), "{p} works today");
assert!(messages(p).is_empty(), "{p}: {:?}", messages(p));
let reply = scene(ParamCheck::Warn).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
assert!(scene(ParamCheck::Strict).execute("solid.align", p).is_ok(), "{p}");
}
// With a plane or a frame the point and face are not read: the handler refuses the call, in
// its own words, and the contract leaves that to it.
for p in [
call(json!({"from_plane": "XY", "from": "garbage", "to": [0, 0, 0]})),
call(json!({"from_plane": "XY", "from_face": "garbage", "to": [0, 0, 0]})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "from": 5, "to": [0, 0, 0]})),
] {
assert!(messages(&p).is_empty(), "{p}: {:?}", messages(&p));
let error = |level: ParamCheck| scene(level).execute("solid.align", &p).err().map(|e| e.to_string());
assert!(error(ParamCheck::Off).is_some_and(|e| e.contains("not both")), "{p}");
assert_eq!(error(ParamCheck::Strict), error(ParamCheck::Off), "{p}");
}
}
/// Keys of the wrong type that the handler reads as left out are refused by the contract: in Warn
/// the call still works, with a warning naming the key.
#[test]
fn the_contract_refuses_what_the_handler_ignores() {
let cases = [
(call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "flip": "yes"})), "flip"),
(call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "angle": true})), "angle"),
(call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "angle": []})), "angle"),
(call(json!({"from_face": "junk", "from": [50, 0, 20], "to": [0, 0, 0]})), "from_face"),
(call(json!({"from": {"snap": 5, "at": [50, 0, 20]}, "to": [0, 0, 0]})), "from.snap"),
];
for (p, key) in cases {
assert!(scene(ParamCheck::Off).execute("solid.align", &p).is_ok(), "{p} works today");
let warned = scene(ParamCheck::Warn).execute("solid.align", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(warned["param_warnings"].to_string().contains(key), "{p}: {warned}");
let error = scene(ParamCheck::Strict).execute("solid.align", &p).err().map(|e| e.to_string()).unwrap_or_default();
assert!(error.contains(&format!("`{key}`")), "{p}: {error}");
}
}
/// The messages the check gives, which are the handler's.
#[test]
fn the_check_names_what_is_wrong_with_a_key() {
let go = |extra: Value| messages(&call(extra));
assert_eq!(messages(&json!({})), ["`bodies` must list the bodies to move"]);
assert_eq!(messages(&json!({"bodies": [5]})), ["`bodies` must list names; the entry 1 is 5"]);
assert_eq!(go(json!({})), ["give `from`, `from_face` or `from_plane` ([x, y, z], \"origin\", or a plane)"]);
assert_eq!(go(json!({"from": "origin"})), ["give `to`, `to_face` or `to_plane` ([x, y, z], \"origin\", or a plane)"]);
assert_eq!(go(json!({"from": "ORIGIN", "to": {"snap": "vertex"}})), ["`to.at` must be [x, y, z]"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "axes": [false, false, false]})), ["`axes` must enable at least one of X, Y and Z"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "axes": [true]})), ["`axes` must be [x, y, z] booleans"]);
assert_eq!(
go(json!({"from": "origin", "to": "origin", "angle": "x".repeat(5000)})),
["`angle` must be an expression of at most 4096 characters"]
);
assert_eq!(go(json!({"from": "origin", "to": "origin", "participants": 5})), ["`participants` must be a list of names; it is 5"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "bodies2": 1})).len(), 1);
assert_eq!(go(json!({"from": "origin", "to": "origin", "flipp": true})), ["unknown key `flipp`; did you mean `flip`?"]);
assert_eq!(go(json!({"from": "origin", "to": "origin", "flip": 1})), ["`flip` must be true or false"]);
}
/// The schema takes what the handler takes where the keys alone decide it. Each call is checked
/// against the schema and against the handler, with the check off.
#[test]
fn the_schema_takes_what_the_handler_takes() {
let schema = input_schema_for::<AlignParams>();
let schema = schema.as_value();
let ring = json!({"sketch": "S", "curve": "ring"});
let calls = [
call(json!({"from": [50, 0, 20], "to": [0, 0, 0]})),
call(json!({"from": "Origin", "to": {"snap": "vertex", "at": [0, 0, 0]}})),
call(json!({"from": {"at": {"x": 50, "y": 0, "z": 20}}, "to": [0, 0, 0], "axes": [false, false, true]})),
call(json!({"from_face": [55, 10, 20], "to_plane": "XY"})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 0], "flip": true, "angle": "30 deg"})),
call(json!({"from_plane": {"origin": [0, 0, 5], "normal": [0, 0, 1]}, "to": [0, 0, 7]})),
call(json!({"from_face": {"x": 55, "y": 10, "z": 20}, "to_plane": "XY"})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"frame": {"origin": [55, 10, 20], "z": [0, 0, -1]}}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to_frame": ring, "angle": 90})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to_frame": ring, "angle": 90})),
call(json!({"from_frame": {"sketch": "S", "point": "p"}, "to": [20, 0, 5]})),
json!({"bodies": "B", "from": [50, 0, 20], "to": [0, 0, 0], "name": "Move", "participants": ["B"]}),
json!({}),
json!({"bodies": ["B"]}),
json!({"bodies": [], "from": [50, 0, 20], "to": [0, 0, 0]}),
call(json!({"from": [50, 0, 20]})),
call(json!({"to": [50, 0, 20]})),
call(json!({"from": [50, 0], "to": [0, 0, 0]})),
call(json!({"from": {"x": 50, "y": 0, "z": 20}, "to": [0, 0, 0]})),
call(json!({"from": {"snap": "vertex"}, "to": [0, 0, 0]})),
call(json!({"from_plane": 7.5, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": "p", "curve": "ring"}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": ""}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"sketch": "S", "point": "q".repeat(257)}, "to": [0, 0, 0]})),
call(json!({"from_frame": 5, "to": [0, 0, 0]})),
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axes": [true]})),
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "flip": "yes"})),
];
for p in &calls {
// The schema takes a call when the handler does and the check has nothing to say (a
// `flip` that is not true or false runs today, and is a warning).
let takes = scene(ParamCheck::Off).execute("solid.align", p).is_ok();
assert_eq!(conforms(schema, schema, p), takes && messages(p).is_empty(), "{p}: {:?}", messages(p));
}
}
/// Each form of the command, in Warn: a reply that matches the output schema and carries no
/// warning, since Warn runs a call the contract refuses as well. The same calls pass strict.
#[test]
fn replies_match_the_output_schema() {
let ring = json!({"sketch": "S", "curve": "ring"});
let calls = [
call(json!({"from": [50, 0, 20], "to": [0, 0, 0]})),
call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axes": [true, false, true]})),
call(json!({"from": "ORIGIN", "to": {"snap": "vertex", "at": [10, 20, 30]}})),
call(json!({"from": {"snap": "edge_mid", "at": [50, 0, 25]}, "to": {"at": [0, 0, 0]}})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 10]})),
call(json!({"from_face": [55, 10, 20], "to_face": [5, 5, 10], "flip": true, "angle": "30 deg"})),
call(json!({"from_face": {"x": 55, "y": 10, "z": 20}, "to_plane": "XY"})),
call(json!({"from_plane": {"origin": [0, 0, 5], "normal": [0, 0, 1]}, "to": [0, 0, 7]})),
call(json!({"from_plane": "XY", "to_plane": {"origin": [0, 0, 5], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})),
call(json!({"from_frame": {"face": [55, 10, 20]}, "to_frame": ring, "angle": 90})),
call(json!({"from_frame": {"sketch": "S", "point": "p"}, "to": [20, 0, 5], "flip": true})),
call(json!({"from_frame": {"point": [55, 10, 20]}, "to": [0, 0, 0]})),
call(json!({"from_frame": {"frame": {"origin": [55, 10, 20], "z": [0, 0, -1]}}, "to": [0, 0, 0]})),
call(json!({"from": [50, 0, 20], "to_frame": {"sketch": "S", "point": "p"}})),
json!({"bodies": "B", "from": [50, 0, 20], "to": [0, 0, 0], "name": "Move B", "participants": ["B", "A"]}),
json!({"bodies": ["A", "B"], "from": [0, 0, 0], "to": [0, 0, 5], "body_name": "unused", "participants": "*"}),
];
let schema = output_schema_for::<FeatureResult>();
for p in &calls {
let reply = scene(ParamCheck::Warn).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{p}: {reply}");
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
assert!(messages(p).is_empty(), "{p}: {:?}", messages(p));
let strict = scene(ParamCheck::Strict).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
// The replies are the same but for the time it took.
let same = |mut r: Value| {
r.as_object_mut().map(|o| o.remove("ms"));
r
};
let off = scene(ParamCheck::Off).execute("solid.align", p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert_eq!(same(strict), same(reply.clone()), "{p}");
assert_eq!(same(off), same(reply), "{p}");
}
// A misspelt key still runs in Warn, and says so, with a reply that matches.
let p = call(json!({"from": [50, 0, 20], "to": [0, 0, 0], "axis": [true, true, true]}));
let warned = scene(ParamCheck::Warn).execute("solid.align", &p).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(warned["param_warnings"], json!(["unknown key `axis`; did you mean `axes`?"]), "{warned}");
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
}
/// The patterns and schemas the tests above can't read (`conforms` skips patterns and `contains`).
#[test]
fn the_schema_text_is_pinned() {
let schema = input_schema_for::<AlignParams>();
let all = schema.as_value().to_string();
assert!(all.contains(r#""pattern":"^[Oo][Rr][Ii][Gg][Ii][Nn]$""#), "{all}");
assert!(all.contains(r#""contains":{"const":true}"#), "{all}");
assert!(all.contains(r#""maxLength":256"#), "{all}");
assert!(schema.as_value()["properties"]["from"]["description"].is_string());
for key in ["from", "to"] {
let any = format!(
r#"{{"anyOf":[{{"required":["{key}"]}},{{"required":["{key}_face"]}},{{"required":["{key}_plane"]}},{{"required":["{key}_frame"]}}]}}"#
);
assert!(all.contains(&any), "{key}: {all}");
}
// `origin` in any case is what the handler reads, and nothing around it.
let origin = |t: &str| scene(ParamCheck::Off).execute("solid.align", &call(json!({"from": t, "to": [0, 0, 0]}))).is_ok();
assert!(origin("origin") && origin("OrIgIn") && !origin(" origin") && !origin("origin\n"));
}
+1 -7
View File
@@ -331,13 +331,7 @@ pub fn run_checked(s: &mut Session, p: &Value, id: &str, contract: &dyn Contract
}
match result {
Ok(Value::Object(mut o)) if level == ParamCheck::Warn && !issues.is_empty() => {
// A command that runs another checked command keeps that one's warnings too.
let mut warnings = match o.remove("param_warnings") {
Some(Value::Array(inner)) => inner,
_ => Vec::new(),
};
warnings.extend(issues.iter().map(|i| json!(i.message)));
o.insert("param_warnings".into(), Value::Array(warnings));
o.insert("param_warnings".into(), json!(issues.iter().map(|i| i.message.as_str()).collect::<Vec<_>>()));
Ok(Value::Object(o))
}
other => other,
+1 -11
View File
@@ -19,15 +19,6 @@ pub struct AssistantParams {}
#[derive(Debug, Serialize, JsonSchema)]
pub struct AssistantResult {}
/// Command search is UI-owned and takes no parameters.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PaletteParams {}
/// Headless sessions refuse to open command search and produce no result.
#[derive(Debug, Serialize, JsonSchema)]
pub struct PaletteResult {}
/// `edit.repeat` takes no parameters: it starts the last tool with its defaults.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
@@ -1528,7 +1519,7 @@ mod tests {
use super::{
AssistantParams, CheckpointParams, CheckpointResult, FeatureKeys, FeatureRef, FeatureSelection, FolderId, FolderIds, MarkerPosition, NewName,
PaletteParams, RedoParams, RedoResult, RepeatParams, RestoreCheckpointParams, SetFields, TimelineComputeAllParams, TimelineComputeAllResult,
RedoParams, RedoResult, RepeatParams, RestoreCheckpointParams, SetFields, TimelineComputeAllParams, TimelineComputeAllResult,
TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams, TimelineEditResult,
TimelineInspectParams, TimelineInspectResult, TimelineMarkerResult, TimelineRenameParams, TimelineRenameResult, TimelineRollToParams,
TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult, UndoParams, UndoResult,
@@ -1546,7 +1537,6 @@ mod tests {
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<RepeatParams>(), &[]);
assert_keys(input_schema_for::<AssistantParams>(), &[]);
assert_keys(input_schema_for::<PaletteParams>(), &[]);
assert_keys(input_schema_for::<UndoParams>(), &[]);
assert_keys(input_schema_for::<RedoParams>(), &[]);
assert_keys(input_schema_for::<CheckpointParams>(), &[]);
-73
View File
@@ -18,7 +18,6 @@ use super::types::{
OperationParam, PlaneCommon, PlaneOrFace, Point3, PointExpr3, ProfilesParam, ScaleFactors, SketchRef, StringList, ThinSideParam, ToPoint,
TouchingParam, feature_common, present,
};
use super::types::{RevolveAxis, RevolveConventionParam, RevolveDirection, RevolveToPoint};
use crate::params::MAX_STRING_LIST;
/// Add a box primitive.
@@ -182,78 +181,6 @@ pub struct ExtrudeParams {
pub thickness2: Option<LengthExpr>,
}
/// Revolve sketch profiles (or a planar body face) about an axis into a solid.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RevolveParams {
/// The axis to turn about.
pub axis: RevolveAxis,
/// How far to turn: more than 0 and at most 360 degrees. With `angle2`, the angle on the
/// first side, the two adding up to the turn; with `direction: symmetric`, the whole turn,
/// split either side. Default: 360 deg. Not used when `to` is given, though it must still
/// evaluate.
#[serde(default)]
pub angle: Option<AngleExpr>,
/// Two Sides: the angle on the other side, turned back from the profile. Not used when `to`
/// is given; used instead of `direction: symmetric` when both are given.
#[serde(default)]
pub angle2: Option<AngleExpr>,
/// `symmetric` splits the angle evenly either side of the profile, whichever way the axis
/// points. The handler ignores every other value.
#[serde(default)]
pub direction: Option<RevolveDirection>,
/// When true, the same as `direction: symmetric`.
#[serde(default)]
pub symmetric: Option<bool>,
/// Where the angle starts. Default: sketch_normal.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "RevolveConventionParam")]
pub convention: Option<RevolveConventionParam>,
/// Turn until this face, vertex or point (To Object). Takes the place of `angle`, `angle2`
/// and `direction`.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "RevolveToPoint")]
pub to: Option<RevolveToPoint>,
/// The sketch to revolve, by id or name. Default: the active sketch, else the last one.
/// Ignored when `face` is given.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "IdOrName")]
pub sketch: Option<IdOrName>,
/// Which of the sketch's profiles to revolve. Default: all of them. Ignored when `face` is
/// given.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ProfilesParam")]
pub profiles: Option<ProfilesParam>,
/// Each index or point pick is exactly the region it names. Without it, a pick on a drawn
/// profile that reference geometry (a face's projected outline) cuts into regions takes the
/// whole drawn profile. The dialogs send `true`: they highlight the regions they pick.
#[serde(default)]
pub exact_regions: Option<bool>,
/// A point on a planar body face to revolve instead of a sketch profile.
#[serde(default)]
pub face: Option<Point3>,
/// The body whose face to use, where faces of several bodies meet at `face`.
#[serde(default)]
pub face_body: Option<String>,
/// With `face`, among placed instances: the body whose placement `face` is read in (read by
/// the engine's frame mapping, before the command runs). Default: `face_body`, else the
/// body nearest the point.
#[serde(default)]
pub body: Option<String>,
/// How the revolve combines with existing bodies. Default: new.
#[serde(default)]
pub operation: Option<OperationParam>,
/// The bodies a join, cut or intersect works on. Default for a join from a body's face, or
/// from a sketch on one: that body. Explicit `targets` or `participants` replace it.
#[serde(default)]
pub targets: Option<StringList>,
/// What a join makes of pieces that share no face. Default: separate.
#[serde(default)]
pub touching: Option<TouchingParam>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// Something a feature's result tells the user without stopping them, with an action to take.
#[derive(Debug, Serialize, JsonSchema)]
pub struct FeatureNotice {
+1 -167
View File
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
use super::{
BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams, FeatureResult,
FilletParams, FilletResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, RevolveParams, ScaleParams, SphereParams, TorusParams,
FilletParams, FilletResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
};
use super::{SurfaceSweepParams, SweepParams};
use crate::Session;
@@ -52,31 +52,6 @@ fn schemas_and_the_key_check_agree() {
"touching",
],
);
assert_keys(
input_schema_for::<RevolveParams>(),
&[
"angle",
"angle2",
"axis",
"body",
"body_name",
"body_names",
"convention",
"direction",
"exact_regions",
"face",
"face_body",
"name",
"operation",
"participants",
"profiles",
"sketch",
"symmetric",
"targets",
"to",
"touching",
],
);
assert_keys(
input_schema_for::<CylinderParams>(),
&["axis", "base", "body_name", "body_names", "diameter", "height", "name", "operation", "participants", "radius", "references", "targets"],
@@ -190,41 +165,6 @@ fn strict_keeps_todays_errors() {
("solid.extrude", json!({"distance": 5, "convention": "per_side"})),
("solid.extrude", json!({"distance": " "})),
("solid.extrude", json!({"distance": 5, "operation": "x".repeat(1000)})),
("solid.revolve", json!({"axis": "y", "operation": "bogus"})),
("solid.revolve", json!({"axis": "y", "operation": "auto"})),
("solid.revolve", json!({"axis": "y", "angle": "no_such_parameter"})),
("solid.revolve", json!({"axis": "y", "angle2": "no_such_parameter"})),
("solid.revolve", json!({"axis": "y", "to": [1, 2]})),
("solid.revolve", json!({"axis": "y", "to": null})),
("solid.revolve", json!({"axis": "y", "convention": "per_side"})),
("solid.revolve", json!({"axis": "y", "convention": "SKETCH_NORMAL"})),
("solid.revolve", json!({"axis": "y", "convention": null})),
("solid.revolve", json!({"axis": "y", "sketch": true})),
("solid.revolve", json!({"axis": "y", "sketch": null})),
("solid.revolve", json!({"axis": "y", "sketch": "Missing"})),
("solid.revolve", json!({"axis": "y", "profiles": []})),
("solid.revolve", json!({"axis": "y", "profiles": [{"point": [1]}]})),
("solid.revolve", json!({"axis": "y", "profiles": [true]})),
("solid.revolve", json!({"axis": "y", "participants": ["Missing"]})),
("solid.revolve", json!({"axis": null})),
("solid.revolve", json!({"axis": 5})),
("solid.revolve", json!({"axis": [0, 1, 0]})),
("solid.revolve", json!({"axis": true})),
("solid.revolve", json!({"axis": {}})),
("solid.revolve", json!({"axis": {"origin": [0, 0, 0]}})),
("solid.revolve", json!({"axis": {"dir": [0, 1]}})),
("solid.revolve", json!({"axis": {"dir": "y"}})),
("solid.revolve", json!({"axis": {"origin": [0, 0], "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"origin": "o", "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"origin": null, "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"origin": ["no_such_parameter", 0, 0], "dir": [0, 1, 0]}})),
("solid.revolve", json!({"axis": {"type": "world"}})),
("solid.revolve", json!({"axis": {"type": "world", "axis": "y"}})),
("solid.revolve", json!({"axis": {"type": "world", "axis": 5}})),
("solid.revolve", json!({"axis": {"edge": "x"}})),
("solid.revolve", json!({"axis": {"face": [1, 2]}})),
("solid.revolve", json!({"axis": {"type": "edge"}})),
("solid.revolve", json!({"axis": {"edge": [1, 2, 3]}})),
("solid.cylinder", json!({"radius": 5, "height": 5, "axis": "W"})),
("solid.cylinder", json!({"radius": 5, "height": 5, "axis": " w "})),
("solid.cylinder", json!({"radius": 5, "height": 5, "axis": [0, 0, 0]})),
@@ -926,109 +866,3 @@ fn converted_primitives_and_body_commands_reply_with_feature_result() {
let (changed, removed) = (r["changed_bodies"].as_array().cloned(), r["removed_bodies"].as_array().cloned());
assert!(changed.is_some_and(|c| c.contains(&json!("Bounding"))) && removed.is_some_and(|r| r.contains(&json!("Peg"))), "{r}");
}
/// A 2 x 10 profile standing off the Y axis in the XY plane, a box to join to or turn a face of,
/// and a construction axis to turn about.
fn revolve_rig(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
for (id, p) in [
("sketch.create", json!({"name": "Profile", "plane": "XY"})),
("sketch.rectangle.two_point", json!({"p0": [2, 0], "p1": [4, 10]})),
("sketch.finish", json!({})),
("solid.box", json!({"corner": [20, 0, 0], "length": 5, "width": 5, "height": 5, "body_name": "Block"})),
("construct.axis.two_points", json!({"a": {"point": [0, 0, 0]}, "b": {"point": [0, 1, 0]}, "name": "Spindle"})),
] {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
}
s
}
/// Every form a revolve call takes.
fn revolve_calls() -> Vec<Value> {
vec![
json!({"sketch": "Profile", "axis": "y", "angle": "90 deg", "operation": "new", "name": "A", "body_name": "A"}),
json!({"axis": "Y", "angle": 45, "direction": "Symmetric", "body_names": ["B"]}),
json!({"sketch": 1, "axis": {"origin": ["0 mm", 0, 0], "dir": {"x": 0, "y": 1, "z": 0}}, "symmetric": true, "convention": "legacy_axis_start"}),
json!({"axis": {"dir": [0, 1, 0]}, "angle": "30 deg", "angle2": "15 deg", "profiles": "all", "exact_regions": true}),
json!({"axis": {"type": "world", "axis": "Y"}, "angle": 20, "profiles": [0], "convention": "sketch_normal"}),
json!({"axis": "Spindle", "angle": 10, "direction": null, "symmetric": null, "touching": null}),
json!({"axis": {"type": "sketch_line", "dir": [0, 1, 0]}, "to": [0, 5, 3], "angle": 5, "angle2": null}),
json!({"axis": "y", "angle": "60 deg", "operation": "union", "targets": ["Block"], "participants": [], "touching": "one_body"}),
json!({"axis": "y", "angle": 15, "operation": "join", "participants": ["A"], "name": "Join"}),
json!({"axis": "y", "angle": 15, "operation": "cut", "targets": "A"}),
json!({"axis": {"origin": [0, 0, 5], "dir": [0, 1, 0]}, "face": [22.5, 2.5, 5], "face_body": "Block", "body": "Block", "angle": 90}),
]
}
/// `solid.revolve` replies with `FeatureResult` in each of its forms, its contract taking each: in
/// Warn a contract that refused one would still run it, so the replies are checked for warnings.
#[test]
fn revolve_replies_match_the_output_schema_and_forms_are_accepted() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
let mut s = revolve_rig(ParamCheck::Warn);
for p in revolve_calls() {
let r = s.execute("solid.revolve", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
}
// Warn reports what the contract refuses and runs the call anyway.
let typo = s.execute("solid.revolve", &json!({"axis": "y", "angel": 10})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(typo["param_warnings"], json!(["unknown key `angel`"]));
assert!(conforms(schema, schema, &typo), "{typo}");
}
/// The check changes nothing about the reply: each call gives the same JSON with the check off
/// and in strict (`ms`, the time it took, apart), and its keys are the ones the command always
/// answered with.
#[test]
fn revolve_reply_keeps_its_old_shape_whatever_the_check() {
let (mut off, mut strict) = (revolve_rig(ParamCheck::Off), revolve_rig(ParamCheck::Strict));
for p in revolve_calls() {
let run = |s: &mut Session| {
let mut r = s.execute("solid.revolve", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
let keys: BTreeSet<String> = r.as_object().into_iter().flat_map(|o| o.keys().cloned()).collect();
assert!(r["ms"].is_null() || r["ms"].is_number(), "{r}");
r["ms"] = Value::Null;
(r, keys)
};
let (a, a_keys) = run(&mut off);
let (b, _) = run(&mut strict);
assert_eq!(a, b, "{p}");
let old = ["bodies", "changed_bodies", "created_bodies", "feature", "ms", "name", "removed_bodies", "warning"];
assert!(old.iter().all(|k| a_keys.contains(*k)), "{a}");
assert!(a_keys.iter().all(|k| old.contains(&k.as_str()) || matches!(k.as_str(), "component" | "notice")), "{a}");
}
}
/// What the handler ignores or reads as left out, strict refuses; off and warn run it as before.
#[test]
fn revolve_wrong_typed_keys_are_reported() {
for (key, value) in [
("direction", json!("negative")),
("direction", json!(true)),
("symmetric", json!("yes")),
("exact_regions", json!(1)),
("angle", json!(true)),
("angle", json!(" ")),
("angle2", json!([])),
("face", json!("x")),
("face", json!([1, 2])),
("face_body", json!(5)),
("body", json!(5)),
("name", json!(5)),
("body_name", json!(5)),
("operation", json!(5)),
("colour", json!("red")),
] {
let p = json!({"axis": "y", "angle": 30, key: value});
let mut off = revolve_rig(ParamCheck::Off);
let r = off.execute("solid.revolve", &p);
assert!(r.is_ok(), "{p}: {r:?}");
let mut warn = revolve_rig(ParamCheck::Warn);
let r = warn.execute("solid.revolve", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(r["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{p}: {r}");
let mut strict = revolve_rig(ParamCheck::Strict);
assert!(strict.execute("solid.revolve", &p).is_err(), "{p}");
}
}
+6 -152
View File
@@ -1,14 +1,10 @@
//! Contracts and result types for the commands in `cmd/file.rs`: so far `file.export` and the
//! export command of each format (`file.export_step`, `file.export_stl`, `file.export_3mf`,
//! `file.export_obj`, `file.export_urdf`), which take what `file.export` takes for that format,
//! without `format`.
//! Contracts and result types for the commands in `cmd/file.rs`: so far the export command of
//! each format (`file.export_step`, `file.export_stl`, `file.export_3mf`, `file.export_obj`,
//! `file.export_urdf`), which take what `file.export` takes for that format, without `format`.
use schemars::{JsonSchema, Schema};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use solidcraft_io::Format;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::check::whole;
use super::types::StringList;
/// How closely an exported mesh follows curved faces.
@@ -97,137 +93,6 @@ pub struct ExportUrdfParams {
pub velocity: Option<f64>,
}
/// Export bodies, or the whole design, to a file. `format` names the format; without it the
/// path's extension does. STL, OBJ and 3MF take `quality` (and 3MF `objects`); URDF takes
/// `mesh_prefix`, `effort` and `velocity` and exports the whole assembly; design JSON the
/// complete document.
#[derive(Debug, JsonSchema)]
#[schemars(deny_unknown_fields, transform = export_required)]
pub struct ExportParams {
// `path` is optional in the type and required in the schema: left out, the handler refuses it
// with its own message.
/// The file to write.
#[serde(default)]
pub path: Option<String>,
/// The format: stl, stla, obj, step, 3mf, 3mf-tagged, urdf or json, or a file name whose
/// extension names it. Default: from the path's extension.
#[serde(default)]
pub format: Option<String>,
/// The bodies to export, by name. Default: every body. Design JSON and URDF export the
/// complete design, so they refuse a list.
#[serde(default)]
pub bodies: Option<StringList>,
/// How closely the mesh follows curved faces (STL, OBJ and 3MF). Default: standard. Not read
/// for URDF.
#[serde(default)]
pub quality: Option<MeshQualityParam>,
/// What each object in a 3MF file holds. Default: body; `component` is refused for the other
/// formats. Not read for URDF.
#[serde(default)]
pub objects: Option<ObjectsParam>,
/// URDF only: put before each mesh file name, such as `package://robot/meshes/`. Default: none.
#[serde(default)]
pub mesh_prefix: Option<String>,
/// URDF only: each joint's effort limit, a positive number. Default: 1000.
#[serde(default)]
pub effort: Option<f64>,
/// URDF only: each joint's velocity limit, a positive number. Default: 10.
#[serde(default)]
pub velocity: Option<f64>,
}
fn export_required(schema: &mut Schema) {
super::motion::require(schema, "path");
}
/// The handler's own message for a key, whole.
fn refuse<E: serde::de::Error>(message: impl std::fmt::Display) -> E {
E::custom(whole(message))
}
/// A key the handler reads as text (`str_`): any other value but `null` is refused, where the
/// handler would read it as left out.
fn text<E: serde::de::Error>(m: &Map<String, Value>, key: &str) -> Result<Option<String>, E> {
match m.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(E::custom(format!("`{key}` must be text"))),
}
}
/// `bodies` as the handler reads it (`params::string_list`), with its messages: `null` is left out.
fn names<E: serde::de::Error>(m: &Map<String, Value>) -> Result<Option<StringList>, E> {
match m.get("bodies") {
None | Some(Value::Null) => Ok(None),
Some(v) => {
let p = Value::Object(Map::from_iter([("bodies".to_string(), v.clone())]));
match crate::params::string_list("file.export", &p, "bodies") {
Ok(list) => Ok(Some(StringList(list))),
Err(crate::EngineError::BadParams { msg, .. }) => Err(refuse(msg)),
Err(e) => Err(refuse(e)),
}
}
}
}
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 };
// 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") {
None => return Ok(out),
Some(Value::String(s)) if !s.trim().is_empty() && s.len() < 4096 => Some(s.clone()),
Some(_) => return Err(refuse::<D::Error>("`path` is required")),
};
out.format = text::<D::Error>(&m, "format")?;
let Some(format) = out.format.as_deref().or(out.path.as_deref()).and_then(|n| Format::from_name(n).ok()) else {
// The handler refuses a format it does not know before it reads anything else.
return Ok(out);
};
out.bodies = names::<D::Error>(&m)?;
if format == Format::Urdf {
// URDF reads its own keys; `quality` and `objects` are not read.
out.mesh_prefix = text::<D::Error>(&m, "mesh_prefix")?;
let positive = |key: &str| -> Result<Option<f64>, D::Error> {
match m.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|x| x.is_finite() && x.abs() < 1e9 && *x > 0.0)
.map(Some)
.ok_or_else(|| refuse(format!("`{key}` must be a positive number"))),
}
};
out.effort = positive("effort")?;
out.velocity = positive("velocity")?;
return Ok(out);
}
// `mesh_prefix`, `effort` and `velocity` are not read for the other formats.
out.quality = match m.get("quality") {
None | Some(Value::Null) => None,
Some(v) => Some(match v.as_str().and_then(solidcraft_io::MeshQuality::from_name) {
Some(solidcraft_io::MeshQuality::Standard) => MeshQualityParam::Standard,
Some(solidcraft_io::MeshQuality::Fine) => MeshQualityParam::Fine,
Some(solidcraft_io::MeshQuality::Finest) => MeshQualityParam::Finest,
None => return Err(refuse(format!("`quality` must be standard, fine or finest, not {v}"))),
}),
};
out.objects = match m.get("objects") {
None | Some(Value::Null) => None,
Some(v) => match v.as_str() {
Some("body") => Some(ObjectsParam::Body),
Some("component") if matches!(format, Format::ThreeMf | Format::ThreeMfTagged) => Some(ObjectsParam::Component),
Some("component") => return Err(refuse("`objects: component` is for 3MF: other formats have no parts")),
_ => return Err(refuse(format!("`objects` must be body or component, not {v}"))),
},
};
Ok(out)
}
}
/// What `file.export_step`, `file.export_stl`, `file.export_3mf` and `file.export_obj` return.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ExportResult {
@@ -235,8 +100,7 @@ pub struct ExportResult {
pub path: String,
/// Its size in bytes.
pub bytes: usize,
/// The format written: Step, StlBinary, ThreeMf or Obj (and for `file.export` and
/// `file.save_mesh` also StlAscii or ThreeMfTagged, and for `file.export` also Design).
/// The format written: Step, StlBinary, ThreeMf or Obj.
pub format: String,
/// OBJ only: every file written, the material library first and the mesh file last.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -266,16 +130,6 @@ pub struct UrdfExportResult {
pub param_warnings: Vec<String>,
}
/// What `file.export` returns: what the export command of the format returns.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum FileExportResult {
/// STL, OBJ, STEP, 3MF or design JSON.
File(ExportResult),
/// URDF.
Urdf(UrdfExportResult),
}
#[cfg(test)]
#[path = "file_tests.rs"]
mod tests;
+4 -150
View File
@@ -1,9 +1,9 @@
use serde_json::{Value, json};
use serde_json::json;
use super::{Export3mfParams, ExportMeshParams, ExportParams, ExportStepParams, ExportUrdfParams, FileExportResult};
use super::{Export3mfParams, ExportMeshParams, ExportStepParams, ExportUrdfParams};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
use crate::schema::tests::assert_keys;
use crate::schema::{ParamCheck, input_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
@@ -11,7 +11,6 @@ fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<ExportMeshParams>(), &["bodies", "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", "mesh_prefix", "objects", "path", "quality", "velocity"]);
}
#[test]
@@ -37,148 +36,3 @@ fn bad_calls_say_what_is_wrong_and_write_nothing() {
assert_eq!(std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0), 0, "nothing written");
let _ = std::fs::remove_dir_all(&dir);
}
/// A fresh folder for one test's files.
fn folder(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-export-schema-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
dir
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
/// 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();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [x, 0, 10], "operation": "new"}));
}
let a = s.doc.find_component("A").and_then(|c| s.doc.occurrence_of(c)).map(|o| o.id).unwrap_or(0);
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
s
}
#[test]
fn strict_keeps_todays_errors() {
let dir = folder("strict");
let at = |name: &str| dir.join(name).to_string_lossy().to_string();
assert_strict_keeps_todays_errors(&[
("file.export", json!({})),
("file.export", json!({"path": ""})),
("file.export", json!({"path": " "})),
("file.export", json!({"path": 5})),
("file.export", json!({"path": null})),
("file.export", json!({"path": "x".repeat(4096)})),
("file.export", json!({"path": at("a.xyz")})),
("file.export", json!({"path": at("a.stl"), "format": "xyz"})),
("file.export", json!({"path": at("a.xyz"), "quality": "rough"})),
("file.export", json!({"path": at("a.stl"), "quality": "rough"})),
("file.export", json!({"path": at("a.stl"), "quality": 3})),
("file.export", json!({"path": at("a.stl"), "objects": "component"})),
("file.export", json!({"path": at("a.3mf"), "objects": "parts"})),
("file.export", json!({"path": at("a.stl"), "bodies": [1]})),
("file.export", json!({"path": at("a.stl"), "bodies": 1})),
("file.export", json!({"path": at("a.json"), "bodies": ["Body1"]})),
("file.export", json!({"path": at("a.urdf"), "bodies": ["Body1"]})),
("file.export", json!({"path": at("a.urdf"), "effort": -1})),
("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})),
]);
assert_eq!(std::fs::read_dir(&dir).map(|d| d.count()).unwrap_or(0), 0, "nothing written");
let _ = std::fs::remove_dir_all(&dir);
}
/// Each format's keys are read only for that format, as the handler reads them: a key another
/// format takes is not checked, and `format` picks the format before the path's extension does.
#[test]
fn the_export_type_reads_each_key_only_for_its_format() {
let read = |p: Value| serde_json::from_value::<ExportParams>(p).map_err(|e| e.to_string());
let ok = |p: Value| read(p.clone()).unwrap_or_else(|e| panic!("{p}: {e}"));
let p = ok(json!({"path": "a.urdf", "quality": "rough", "objects": 3, "effort": 5}));
assert!(p.quality.is_none() && p.objects.is_none() && p.effort == Some(5.0), "{p:?}");
let p = ok(json!({"path": "a.stl", "effort": -1, "velocity": "fast", "mesh_prefix": 3, "quality": " Fine "}));
assert!(p.effort.is_none() && p.velocity.is_none() && p.mesh_prefix.is_none() && p.quality.is_some(), "{p:?}");
assert!(ok(json!({"path": "a.stl", "format": "3mf", "objects": "component"})).objects.is_some());
assert!(read(json!({"path": "a.3mf", "format": "stl", "objects": "component"})).is_err());
assert!(read(json!({"path": "a", "format": "urdf", "effort": 0})).is_err());
// An unknown format, or no path to write to, is the handler's to refuse: nothing else is read.
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());
}
/// (`conforms` does not read `oneOf`, so `quality` and `objects` values are checked by the type,
/// in `the_export_type_reads_each_key_only_for_its_format`.)
#[test]
fn the_export_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<ExportParams>();
let schema = schema.as_value();
for (p, takes) in [
(json!({"path": "a.stl"}), true),
(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!({}), false),
(json!({"path": "a.stl", "bodies": [1]}), false),
(json!({"path": "a.stl", "colour": true}), false),
] {
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
/// Real exports of every format in warn: no `param_warnings`, and each reply matches the output
/// schema. The same calls go through in strict.
#[test]
fn export_replies_match_the_output_schema() {
let dir = folder("replies");
let at = |name: &str| dir.join(name).to_string_lossy().to_string();
let schema = output_schema_for::<FileExportResult>();
let schema = schema.as_value();
let calls = [
json!({"path": at("a.stl")}),
json!({"path": at("b.stl"), "format": "stla", "quality": "finest"}),
json!({"path": at("c.obj"), "quality": "fine"}),
json!({"path": at("d.step")}),
json!({"path": at("e.3mf"), "objects": "component"}),
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}),
];
for level in [ParamCheck::Warn, ParamCheck::Strict] {
let mut s = two_cubes();
s.param_check = level;
for p in &calls {
let reply = run(&mut s, "file.export", p.clone());
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
assert!(conforms(schema, schema, &reply), "{p}: {reply} doesn't match {schema}");
}
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A misspelt key is reported in warn and refused in strict, before anything is written; a key of
/// the wrong type the handler reads as left out is reported too.
#[test]
fn a_misspelt_or_wrong_typed_export_key_is_reported() {
let dir = folder("misspelt");
let at = |name: &str| dir.join(name).to_string_lossy().to_string();
let mut s = two_cubes();
s.param_check = ParamCheck::Warn;
let reply = run(&mut s, "file.export", json!({"path": at("a.stl"), "qualty": "fine"}));
let warnings = reply["param_warnings"].to_string();
assert!(warnings.contains("qualty"), "{reply}");
let reply = run(&mut s, "file.export", json!({"path": at("b.stl"), "format": 3}));
assert!(reply["param_warnings"].to_string().contains("format"), "{reply}");
s.param_check = ParamCheck::Strict;
let e = s.execute("file.export", &json!({"path": at("c.stl"), "qualty": "fine"})).unwrap_err().to_string();
assert!(e.contains("qualty"), "{e}");
assert!(!dir.join("c.stl").exists(), "nothing written");
let _ = std::fs::remove_dir_all(&dir);
}
-374
View File
@@ -1,374 +0,0 @@
//! Contract and reply type for `file.insert_mesh` in `cmd/file.rs` (#13309).
//!
//! The handler reads its parameters from the raw JSON, so each key here reads as the handler
//! reads it and fails with the handler's message. Whether the file can be read stays the
//! handler's to check. A key the handler refuses when it is left out (`path`) is optional in the
//! type and required in the schema, so the handler's message, not the check's "is required", is
//! what a caller sees.
//!
//! Some keys are read only in some cases, and the contract reads them in the same cases:
//!
//! - nothing but `path` is read when the file is not a mesh file (the handler refuses it first);
//! - `up` is read only for a glTF file (any other file is refused with the handler's own message,
//! whatever `up` says, so that message is left to the handler);
//! - `names` and `folder` are read only when `up` is given for a glTF file: the handler checks
//! them there, as it does for a STEP file, and they change nothing for a mesh;
//! - `units` is read whatever the file (a glTF or 3MF file is refused it afterwards, which the
//! handler says).
//!
//! A value of the wrong type that the handler reads as if the key were left out (a `name` that is
//! not text) is refused by the contract, as schemas.md has it: that difference is what warn and
//! strict report. A call with several problems may get a different one first in strict than with
//! the check off (the check goes key by key, the handler in its own order), except that a bad
//! `path` is always the first.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use super::check::{located, whole};
use super::types::{not_blank_pattern, whitespace_class};
/// What the handler says of a `path` that is missing, not text, blank or too long.
const PATH_REQUIRED: &str = "`path` is required";
const UP_MESSAGE: &str = "`up` must be \"z\" (the file's axes are kept) or \"y\" (the file has Y up: its Y becomes Z)";
const NAMES_MESSAGE: &str = "`names` must be \"auto\", \"solid\", \"product\" or \"instance\"";
const FOLDER_MESSAGE: &str =
"`folder` must be true (an import that brings more than one item goes into a `Group #N` history folder) or false (no folder)";
const ORIGIN_MESSAGE: &str =
"`origin` must be true (the mesh is moved so its box is centred on X and Y with its base on Z = 0) or false (the file's coordinates are kept)";
/// A key that is absent or `null` (the handler reads both as left out), else the value read by
/// `read`, with the handler's `message` when `read` refuses it.
fn read_key<'de, D: Deserializer<'de>, T>(d: D, message: &str, read: impl FnOnce(&Value) -> Option<T>) -> Result<Option<T>, D::Error> {
let value = Value::deserialize(d)?;
if value.is_null() {
return Ok(None);
}
read(&value).map(Some).ok_or_else(|| D::Error::custom(whole(message)))
}
fn path_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if !s.trim().is_empty() && s.len() < 4096 => Ok(Some(s)),
_ => Err(D::Error::custom(whole(PATH_REQUIRED))),
}
}
fn up_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<UpAxis>, D::Error> {
read_key(d, UP_MESSAGE, |v| match v.as_str().map(|u| u.trim().to_ascii_lowercase()).as_deref() {
Some("z") => Some(UpAxis::Z),
Some("y") => Some(UpAxis::Y),
_ => None,
})
}
fn names_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<StepNamesParam>, D::Error> {
read_key(d, NAMES_MESSAGE, |v| {
let word = v.as_str().and_then(solidcraft_io::StepNames::parse)?;
Some(match word {
solidcraft_io::StepNames::Auto => StepNamesParam::Auto,
solidcraft_io::StepNames::Solid => StepNamesParam::Solid,
solidcraft_io::StepNames::Product => StepNamesParam::Product,
solidcraft_io::StepNames::Instance => StepNamesParam::Instance,
})
})
}
fn units_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<MeshUnits>, D::Error> {
let value = Value::deserialize(d)?;
match &value {
Value::Null => Ok(None),
Value::String(u) => match solidcraft_doc::point_cloud::UNITS.iter().find(|(name, _)| name.eq_ignore_ascii_case(u)) {
Some((name, _)) => Ok(Some(MeshUnits((*name).to_string()))),
None => Err(D::Error::custom(whole(format!("`units` must be one of {} (not `{u}`)", unit_names())))),
},
_ => Err(D::Error::custom(whole(format!("`units` must be one of {}", unit_names())))),
}
}
fn flag_key<'de, D: Deserializer<'de>>(d: D, message: &str) -> Result<Option<bool>, D::Error> {
read_key(d, message, Value::as_bool)
}
fn folder_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
flag_key(d, FOLDER_MESSAGE)
}
fn origin_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
flag_key(d, ORIGIN_MESSAGE)
}
fn crease_angle_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
let value = Value::deserialize(d)?;
crate::cmd::mesh_crease_angle(Some(&value)).map_err(|message| D::Error::custom(whole(message)))
}
/// The unit names, as the handler lists them in its messages.
fn unit_names() -> String {
solidcraft_doc::point_cloud::UNITS.iter().map(|(n, _)| *n).collect::<Vec<_>>().join(", ")
}
/// The keys `file.insert_mesh` reads. See [`InsertMeshParams`], which leaves out the keys the
/// handler does not read for the call.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = path_required)]
pub struct InsertMeshKeys {
// Optional in the type and required in the schema (`path_required`): left out, the handler
// refuses it with its own message.
/// The file to insert: `.3mf`, `.stl`, `.obj`, `.ply` (with faces), `.glb` or `.gltf` (any
/// case), not blank and fewer than 4096 UTF-8 bytes. Any other kind of file is refused. Its
/// meshes join the design as mesh bodies in a new component, placed in the active component.
#[serde(default, deserialize_with = "path_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "String")]
pub path: Option<String>,
/// The new component's name, and the feature's. Default (also when blank): the file's name
/// without its extension.
#[serde(default)]
pub name: Option<String>,
/// For a glTF file only (any other file is refused it): `y` (the default: glTF has Y up)
/// turns the import's component a quarter turn about X, so Y becomes Z, and the reply gives
/// its occurrence as `stand_up`; `z` keeps the file's axes. Any case.
#[serde(default, deserialize_with = "up_key", skip_serializing_if = "Option::is_none")]
pub up: Option<UpAxis>,
/// Read only when `up` is given for a glTF file, where it is checked and changes nothing:
/// `auto`, `solid`, `product` or `instance`, in any case. It is how a STEP file's bodies are
/// named (`file.insert_step`).
#[serde(default, deserialize_with = "names_key", skip_serializing_if = "Option::is_none")]
pub names: Option<StepNamesParam>,
/// Read only when `up` is given for a glTF file, where it is checked and changes nothing:
/// true or false. It is whether a STEP import goes into a history folder (`file.insert_step`).
#[serde(default, deserialize_with = "folder_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub folder: Option<bool>,
/// For an STL, OBJ or PLY file: the unit its coordinates are in, scaled to millimetres as the
/// file is read: `mm`, `cm`, `m`, `in` or `ft`, in any case. Default: mm. A 3MF or glTF file
/// states its own, and is refused `units`. The design keeps the millimetre mesh and not the
/// unit, so insert the file again to try another.
#[serde(default, deserialize_with = "units_key", skip_serializing_if = "Option::is_none")]
pub units: Option<MeshUnits>,
/// Whether, once the mesh is read, scaled and stood up, the import's occurrence is moved so
/// the box around its bodies is centred on X and Y of the active component with its lowest
/// point on Z = 0 (the reply gives the shift as `moved_to_origin`). Default: false, so the
/// mesh keeps the coordinates its file gives it.
#[serde(default, deserialize_with = "origin_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub origin: Option<bool>,
/// Shading crease angle in degrees, from 0 through 180. Zero clears imported normals for
/// the previous flat-shading behavior; positive angles regenerate angle-weighted normals
/// across manifold edges below this angle. Omitted or null keeps authored OBJ normals and
/// smoothing groups, or flat shading when absent. Geometry stays unchanged.
#[serde(default, deserialize_with = "crease_angle_key", skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 0.0, max = 180.0))]
pub crease_angle_degrees: Option<f64>,
}
/// Leaves out the keys the handler does not read for this call, in the order it looks at them.
fn leave_unread(value: &mut Value) {
let Some(keys) = value.as_object_mut() else { return };
let Some(path) = keys.get("path").and_then(Value::as_str).filter(|p| !p.trim().is_empty() && p.len() < 4096).map(str::to_string) else {
return;
};
if !solidcraft_io::is_mesh_path(&path) && !solidcraft_io::is_ply_path(&path) {
// Refused for its kind before anything else is read.
for key in ["name", "up", "names", "folder", "units", "origin", "crease_angle_degrees"] {
keys.remove(key);
}
return;
}
let up_given = keys.get("up").is_some_and(|u| !u.is_null());
if !(up_given && solidcraft_io::is_gltf_path(&path)) {
keys.remove("names");
keys.remove("folder");
}
if !solidcraft_io::is_gltf_path(&path) {
keys.remove("up");
}
}
/// Insert a 3MF, STL, OBJ, PLY or glTF mesh file into the design as mesh bodies, in a new
/// component named after the file and placed in the active component. A glTF file's node tree
/// becomes nested components held as one rigid group until `component.unlock`, a mesh placed
/// again becomes another occurrence, its materials become the bodies' appearances, and its
/// metres become millimetres.
#[derive(Debug)]
pub struct InsertMeshParams(pub InsertMeshKeys);
impl<'de> Deserialize<'de> for InsertMeshParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let mut value = Value::deserialize(d)?;
// A bad `path` is the handler's first refusal, whatever else is wrong.
if let Some(path) = value.as_object().map(|o| o.get("path")) {
let ok = path.and_then(Value::as_str).is_some_and(|p| !p.trim().is_empty() && p.len() < 4096);
if !ok {
let only = serde_json::json!({"path": path.cloned().unwrap_or(Value::Null)});
return serde_path_to_error::deserialize::<_, InsertMeshKeys>(&only).map(InsertMeshParams).map_err(|e| D::Error::custom(located(&e)));
}
}
leave_unread(&mut value);
serde_path_to_error::deserialize(&value).map(InsertMeshParams).map_err(|e| D::Error::custom(located(&e)))
}
}
impl JsonSchema for InsertMeshParams {
fn schema_name() -> Cow<'static, str> {
"InsertMeshParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = InsertMeshKeys::json_schema(generator);
schema.insert("description".into(), INSERT_MESH.into());
schema
}
}
const INSERT_MESH: &str = "Insert a 3MF, STL, OBJ, PLY or glTF mesh file into the design as mesh bodies, in a new component named after the file and placed in the active component. A glTF file's node tree becomes nested components held as one rigid group until `component.unlock`, a mesh placed again becomes another occurrence, its materials become the bodies' appearances, and its metres become millimetres.";
/// `path` is required, as the handler has it (not blank, fewer than 4096 bytes; `maxLength`
/// counts characters, so 4095 is as close as the schema gets). `names` and `folder` have their
/// shapes only where the handler reads them: when `up` is given (not null) for a glTF file.
fn path_required(schema: &mut Schema) {
super::motion::require(schema, "path");
let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return };
props.insert(
"path".into(),
serde_json::json!({
"description": props.get("path").and_then(|p| p.get("description")).cloned().unwrap_or_default(),
"type": "string",
"maxLength": 4095,
"pattern": not_blank_pattern(),
}),
);
let mut read_only_with_up = |key: &str| {
let shape = props.get_mut(key).map(Value::take).unwrap_or_default();
props.insert(key.into(), serde_json::json!({"description": shape.get("description").cloned().unwrap_or_default()}));
shape
};
let names = read_only_with_up("names");
let folder = read_only_with_up("folder");
schema.insert("anyOf".into(), serde_json::json!([{"properties": {"up": {"type": "null"}}}, {"properties": {"names": names, "folder": folder}}]));
}
/// Which way is up in a glTF file.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UpAxis {
/// The file's axes are kept.
Z,
/// The file has Y up: its Y becomes Z.
Y,
}
/// Case-insensitive words (`word`), each letter in both cases, as a pattern; the pattern allows
/// the whitespace Rust's `trim` removes around them when `padded`.
fn words_pattern(words: &[&str], padded: bool) -> String {
let word = |w: &str| w.chars().map(|c| format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase())).collect::<String>();
let alternatives = words.iter().map(|w| word(w)).collect::<Vec<_>>().join("|");
if padded {
let ws = whitespace_class();
format!("^[{ws}]*({alternatives})[{ws}]*$")
} else {
format!("^({alternatives})$")
}
}
impl JsonSchema for UpAxis {
fn schema_name() -> Cow<'static, str> {
"UpAxis".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "`z` (the file's axes are kept) or `y` (the file has Y up: its Y becomes Z), in any case.",
"type": "string",
"pattern": words_pattern(&["z", "y"], true)
})
}
}
/// How a STEP file's bodies are named; read, and checked, for a glTF file with `up` too.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StepNamesParam {
Auto,
Solid,
Product,
Instance,
}
impl JsonSchema for StepNamesParam {
fn schema_name() -> Cow<'static, str> {
"StepNamesParam".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "`auto`, `solid`, `product` or `instance`, in any case.",
"type": "string",
"pattern": words_pattern(&["auto", "solid", "product", "instance"], true)
})
}
}
/// A length unit of an STL, OBJ or PLY file: one of `solidcraft_doc::point_cloud::UNITS`, in the
/// case the table spells it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MeshUnits(pub String);
impl JsonSchema for MeshUnits {
fn schema_name() -> Cow<'static, str> {
"MeshUnits".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
let names: Vec<&str> = solidcraft_doc::point_cloud::UNITS.iter().map(|(n, _)| *n).collect();
json_schema!({
"description": "`mm`, `cm`, `m`, `in` or `ft`, in any case.",
"type": "string",
"pattern": words_pattern(&names, false)
})
}
}
/// What `file.insert_mesh` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct MeshInserted {
/// The file, as given.
pub path: String,
/// The mesh import feature's id.
pub feature: u64,
/// The feature's name.
pub name: String,
/// The bodies the import made, by name.
pub bodies: Vec<String>,
/// The same names as `bodies`.
pub created_bodies: Vec<String>,
/// What the file reader noticed and put right.
pub warnings: Vec<String>,
/// The new component's id.
pub component: u64,
/// The new component's name.
pub component_name: String,
/// The component's occurrence in the active component; null when it has none.
pub occurrence: Option<u64>,
/// How many components were made below it (a glTF file's nodes). Only when there are some.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subcomponents: Option<usize>,
/// How many rigid joints hold its contents together. Only when `subcomponents` is given.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rigid_joints: Option<usize>,
/// With `origin`: the shift, [x, y, z] in mm, that moved the import to the origin; absent
/// when it was not moved.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub moved_to_origin: Option<[f64; 3]>,
/// The unit the file's coordinates were read in, when `units` was given.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub units: Option<String>,
/// For a glTF file stood up from Y up to Z up: the occurrence that was turned.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stand_up: Option<u64>,
/// 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>,
}
#[cfg(test)]
#[path = "insert_mesh_tests.rs"]
mod tests;
@@ -1,326 +0,0 @@
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use super::{InsertMeshParams, MeshInserted, StepNamesParam, UpAxis};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<InsertMeshParams>(), &["crease_angle_degrees", "folder", "name", "names", "origin", "path", "units", "up"]);
}
#[test]
fn strict_keeps_todays_errors() {
let long = "x".repeat(4096);
assert_strict_keeps_todays_errors(&[
("file.insert_mesh", json!({})),
("file.insert_mesh", json!({"path": ""})),
("file.insert_mesh", json!({"path": " "})),
("file.insert_mesh", json!({"path": 5})),
("file.insert_mesh", json!({"path": null})),
("file.insert_mesh", json!({"path": long})),
("file.insert_mesh", json!({"up": "q", "units": "yd"})),
("file.insert_mesh", json!({"crease_angle_degrees": "smooth"})),
// The kind of file is refused before anything else is read.
("file.insert_mesh", json!({"path": "x.step"})),
("file.insert_mesh", json!({"path": "x.step", "up": "q", "units": 5, "origin": 1})),
("file.insert_mesh", json!({"path": "x.step", "name": 5})),
("file.insert_mesh", json!({"path": "x.step", "crease_angle_degrees": "smooth"})),
// `up` is for a glTF file only, whatever it says.
("file.insert_mesh", json!({"path": "x.stl", "up": "y"})),
("file.insert_mesh", json!({"path": "x.stl", "up": "q"})),
("file.insert_mesh", json!({"path": "x.obj", "up": 5})),
("file.insert_mesh", json!({"path": "x.glb", "up": "q"})),
("file.insert_mesh", json!({"path": "x.glb", "up": 5})),
("file.insert_mesh", json!({"path": "x.glb", "up": "y", "names": "bogus"})),
("file.insert_mesh", json!({"path": "x.gltf", "up": "z", "names": 5})),
("file.insert_mesh", json!({"path": "x.gltf", "up": "z", "folder": "yes"})),
("file.insert_mesh", json!({"path": "x.stl", "units": "yd"})),
("file.insert_mesh", json!({"path": "x.stl", "units": 5})),
("file.insert_mesh", json!({"path": "x.stl", "units": true})),
("file.insert_mesh", json!({"path": "x.3mf", "units": "mm"})),
("file.insert_mesh", json!({"path": "x.glb", "units": "mm"})),
("file.insert_mesh", json!({"path": "x.stl", "origin": "yes"})),
("file.insert_mesh", json!({"path": "x.stl", "origin": 1})),
("file.insert_mesh", json!({"path": "x.obj", "crease_angle_degrees": -1})),
("file.insert_mesh", json!({"path": "x.obj", "crease_angle_degrees": 181})),
("file.insert_mesh", json!({"path": "x.obj", "crease_angle_degrees": "40"})),
("file.insert_mesh", json!({"path": "x.obj", "crease_angle_degrees": true})),
// The kind of file is read in any case, and a PLY file is a mesh too.
("file.insert_mesh", json!({"path": "X.GLB", "up": "q"})),
("file.insert_mesh", json!({"path": "X.STL", "up": "y"})),
("file.insert_mesh", json!({"path": "X.3MF", "units": "mm"})),
("file.insert_mesh", json!({"path": "x.ply", "units": "yd"})),
("file.insert_mesh", json!({"path": "x.ply", "up": "y"})),
// A file that isn't there.
("file.insert_mesh", json!({"path": "does-not-exist.stl"})),
("file.insert_mesh", json!({"path": "does-not-exist.glb", "up": "Z", "names": " Auto ", "folder": false})),
]);
}
fn parse(p: Value) -> Result<InsertMeshParams, String> {
serde_json::from_value(p).map_err(|e| e.to_string())
}
/// The keys read as the handler reads them: the same words in any case, `null` as left out, and
/// the keys it doesn't read in a call left unread, so a bad one there is no problem.
#[test]
fn the_types_read_each_key_as_the_handler_does() {
let keys = |p: Value| parse(p).unwrap_or_else(|e| panic!("{e}")).0;
let k = keys(json!({"path": "a.glb", "name": "Mine", "up": " Y ", "names": " Solid ", "folder": true, "units": "IN", "origin": false}));
assert_eq!(k.path.as_deref(), Some("a.glb"));
assert_eq!(k.name.as_deref(), Some("Mine"));
assert_eq!(k.up, Some(UpAxis::Y));
assert_eq!(k.names, Some(StepNamesParam::Solid));
assert_eq!(k.folder, Some(true));
assert_eq!(k.units.map(|u| u.0), Some("in".to_string()));
assert_eq!(k.origin, Some(false));
for angle in [0.0, -0.0, 40.5, 180.0] {
assert_eq!(keys(json!({"path": "a.obj", "crease_angle_degrees": angle})).crease_angle_degrees, Some(angle));
}
assert!(keys(json!({"path": "a.obj"})).crease_angle_degrees.is_none());
assert!(keys(json!({"path": "a.obj", "crease_angle_degrees": null})).crease_angle_degrees.is_none());
assert_eq!(keys(json!({"path": "a.glb", "up": "Z"})).up, Some(UpAxis::Z));
let none = keys(json!({"path": "a.stl", "name": null, "up": null, "names": null, "folder": null, "units": null, "origin": null}));
assert!(none.name.is_none() && none.up.is_none() && none.units.is_none() && none.origin.is_none());
// Not read: `names` and `folder` without an `up` for a glTF file, `up` for any other file,
// everything but `path` for a file that isn't a mesh.
for p in [
json!({"path": "a.stl", "names": 5, "folder": "x"}),
json!({"path": "a.glb", "names": 5, "folder": "x"}),
json!({"path": "a.glb", "up": null, "names": 5, "folder": "x"}),
json!({"path": "a.stl", "up": "sideways", "names": 5}),
json!({"path": "a.step", "up": 1, "names": 5, "folder": 5, "units": 5, "origin": 5, "crease_angle_degrees": "smooth"}),
] {
parse(p.clone()).unwrap_or_else(|e| panic!("{p}: {e}"));
}
parse(json!({"path": "a.GLB", "up": "y", "names": "auto"})).unwrap_or_else(|e| panic!("{e}"));
assert!(parse(json!({"path": "a.GLB", "up": "y", "names": "x"})).is_err());
parse(json!({"path": "a.PLY", "up": 5, "names": 5})).unwrap_or_else(|e| panic!("{e}"));
let k = keys(json!({"path": "a.glb", "names": 5}));
assert!(k.names.is_none() && k.folder.is_none());
// Read, and refused with the handler's message.
let refused = |p: Value| crate::schema::issues("file.insert_mesh", &p).into_iter().next().map(|i| i.message).unwrap_or_default();
for (p, message) in [
(json!({"path": "a.glb", "up": "x"}), "`up` must be \"z\" (the file's axes are kept) or \"y\" (the file has Y up: its Y becomes Z)"),
(json!({"path": "a.glb", "up": "y", "names": "x"}), "`names` must be \"auto\", \"solid\", \"product\" or \"instance\""),
(json!({"path": "a.stl", "units": "yd"}), "`units` must be one of mm, cm, m, in, ft (not `yd`)"),
(json!({"path": "a.stl", "units": 1}), "`units` must be one of mm, cm, m, in, ft"),
(json!({"path": "a.3mf", "units": []}), "`units` must be one of mm, cm, m, in, ft"),
(json!({"path": "a.stl", "origin": "yes"}), "`origin` must be true"),
(json!({"path": "a.glb", "up": "z", "folder": 0}), "`folder` must be true"),
(json!({"path": "a.obj", "crease_angle_degrees": -1}), "crease_angle_degrees must be between 0 and 180 degrees"),
(json!({"path": "a.obj", "crease_angle_degrees": 181}), "crease_angle_degrees must be between 0 and 180 degrees"),
(json!({"path": "a.obj", "crease_angle_degrees": "40"}), "crease_angle_degrees must be between 0 and 180 degrees"),
(json!({}), "`path` is required"),
(json!({"path": " "}), "`path` is required"),
(json!({"up": "x", "path": 5}), "`path` is required"),
] {
let e = refused(p.clone());
assert!(e.starts_with(message), "{p}: {e}");
}
}
/// The check names what is wrong, in the handler's words, and a key the handler would ignore is
/// still a known key.
#[test]
fn the_check_names_what_is_wrong() {
let issues = |p: Value| crate::schema::issues("file.insert_mesh", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
assert_eq!(issues(json!({"path": "a.stl", "unit": "in"})), ["unknown key `unit`; did you mean `units`?"]);
assert_eq!(issues(json!({"path": "a.stl", "units": "yd"})), ["`units` must be one of mm, cm, m, in, ft (not `yd`)"]);
assert_eq!(issues(json!({"path": "a.stl", "name": 5})), ["`name` must be text"]);
assert_eq!(
issues(json!({"path": "a.obj", "crease_angle_degree": 40})),
["unknown key `crease_angle_degree`; did you mean `crease_angle_degrees`?"]
);
assert_eq!(issues(json!({})), ["`path` is required"]);
// `name` is read as text or not at all; a number is ignored by the handler but is a mistake.
assert_eq!(issues(json!({"path": "a.stl", "name": null})), Vec::<String>::new());
assert!(issues(json!({"path": "a.stl", "up": "y", "names": 5})).is_empty(), "an `up` for another file is the handler's to refuse");
assert!(issues(json!({"path": "a.stl", "names": 5, "folder": 5})).is_empty());
}
/// The published schema: `path` is required and shaped as the handler reads it; `names` and
/// `folder` have their shapes only where they are read.
#[test]
fn the_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<InsertMeshParams>();
let schema = schema.as_value();
for (p, takes) in [
(json!({"path": "a.stl"}), true),
(json!({"path": "a.glb", "up": "Y", "names": "Auto", "folder": true, "name": "N"}), true),
(json!({"path": "a.stl", "units": "in", "origin": true}), true),
(json!({"path": "a.stl", "units": null, "origin": null, "up": null}), true),
(json!({"path": "a.obj", "crease_angle_degrees": null}), true),
(json!({"path": "a.obj", "crease_angle_degrees": 0}), true),
(json!({"path": "a.obj", "crease_angle_degrees": 40.5}), true),
(json!({"path": "a.obj", "crease_angle_degrees": 180}), true),
// Left unread, so not shaped.
(json!({"path": "a.stl", "names": 5, "folder": "x"}), true),
(json!({"path": "a.glb", "up": null, "names": 5, "folder": "x"}), true),
(json!({}), false),
(json!({"path": 5}), false),
(json!({"path": "a.stl", "origin": "yes"}), false),
(json!({"path": "a.stl", "units": 5}), false),
(json!({"path": "a.stl", "folder": "x", "up": "y"}), false),
(json!({"path": "a.glb", "up": "y", "names": 5}), false),
(json!({"path": "a.stl", "name": 5}), false),
(json!({"path": "a.obj", "crease_angle_degrees": -1}), false),
(json!({"path": "a.obj", "crease_angle_degrees": 181}), false),
(json!({"path": "a.obj", "crease_angle_degrees": "40"}), false),
(json!({"path": "a.obj", "crease_angle_degrees": true}), false),
] {
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
/// `conforms` doesn't read patterns: pin the text of the ones the schema carries.
#[test]
fn the_patterns_are_pinned() {
let schema = input_schema_for::<InsertMeshParams>();
let schema = schema.as_value();
assert_eq!(schema["properties"]["path"]["pattern"], json!(crate::schema::types::not_blank_pattern()));
assert_eq!(schema["properties"]["path"]["maxLength"], json!(4095));
let ws = crate::schema::types::whitespace_class();
assert_eq!(schema["$defs"]["UpAxis"]["pattern"], json!(format!("^[{ws}]*([zZ]|[yY])[{ws}]*$")));
assert_eq!(schema["$defs"]["MeshUnits"]["pattern"], json!("^([mM][mM]|[cC][mM]|[mM]|[iI][nN]|[fF][tT])$"));
assert_eq!(
schema["$defs"]["StepNamesParam"]["pattern"],
json!(format!("^[{ws}]*([aA][uU][tT][oO]|[sS][oO][lL][iI][dD]|[pP][rR][oO][dD][uU][cC][tT]|[iI][nN][sS][tT][aA][nN][cC][eE])[{ws}]*$"))
);
}
fn folder(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-insert-mesh-schema-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
dir
}
/// A cube of side 10 mm, saved as STL, and the same as 3MF.
fn cube_files(dir: &Path) -> (String, String) {
let mut part = Session::default();
let (stl, threemf) = (dir.join("cube.stl").to_string_lossy().to_string(), dir.join("cube.3mf").to_string_lossy().to_string());
for (id, p) in [
("solid.box", json!({"length": 10, "width": 10, "height": 10})),
("file.save_mesh", json!({"path": stl})),
("file.export_3mf", json!({"path": threemf})),
] {
part.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
}
(stl, threemf)
}
/// A glTF file (a glb): a cube of side 0.01 m placed by a `Base` node with an `Arm` node below
/// it, so the import has components of its own.
fn glb(dir: &Path) -> String {
let s = 0.01f32;
let p: Vec<[f32; 3]> = (0..8).map(|i| [(i & 1) as f32 * s, ((i >> 1) & 1) as f32 * s, ((i >> 2) & 1) as f32 * s]).collect();
let t: [u16; 36] = [0, 2, 1, 1, 2, 3, 4, 5, 6, 5, 7, 6, 0, 1, 4, 1, 5, 4, 2, 6, 3, 3, 6, 7, 0, 4, 2, 2, 4, 6, 1, 3, 5, 3, 7, 5];
let mut bin: Vec<u8> = p.iter().flatten().flat_map(|v| v.to_le_bytes()).collect();
bin.extend(t.iter().flat_map(|v| v.to_le_bytes()));
let json = json!({
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0]}],
"nodes": [
{"name": "Base", "mesh": 0, "children": [1]},
{"name": "Arm", "mesh": 1, "translation": [0.0, 0.05, 0.0]},
],
"meshes": [
{"name": "Block", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]},
{"name": "Peg", "primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]},
],
"accessors": [
{"bufferView": 0, "componentType": 5126, "count": 8, "type": "VEC3", "min": [0.0, 0.0, 0.0], "max": [s, s, s]},
{"bufferView": 1, "componentType": 5123, "count": 36, "type": "SCALAR"},
],
"bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 96}, {"buffer": 0, "byteOffset": 96, "byteLength": 72}],
"buffers": [{"byteLength": bin.len()}],
});
let path = dir.join("robot.glb");
std::fs::write(&path, solidcraft_io::glb_container(json.to_string().as_bytes(), &bin)).unwrap_or_else(|e| panic!("{e}"));
path.to_string_lossy().to_string()
}
fn matches(reply: &Value) {
let schema = output_schema_for::<MeshInserted>();
let schema = schema.as_value();
assert!(conforms(schema, schema, reply), "{reply} doesn't match {schema}");
}
/// Every form the command takes, run in Warn: each reply matches its output schema and carries
/// no warning (Warn runs a call the contract refuses anyway, so this shows the contract takes
/// each call). The keys the handler leaves unread are given bad values on purpose.
#[test]
fn replies_match_the_output_schema() {
let dir = folder("replies");
let (stl, threemf) = cube_files(&dir);
let robot = glb(&dir);
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let mut run = |p: Value| {
let r = s.execute("file.insert_mesh", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
matches(&r);
r
};
let plain = run(json!({"path": stl}));
assert_eq!(plain["component_name"], "cube", "{plain}");
assert!(plain.get("units").is_none() && plain.get("moved_to_origin").is_none() && plain.get("stand_up").is_none(), "{plain}");
assert!(plain.get("subcomponents").is_none() && plain["bodies"] == plain["created_bodies"], "{plain}");
assert!(plain["occurrence"].is_u64(), "{plain}");
let named = run(json!({"path": stl, "name": "Block", "units": "IN", "origin": true}));
assert_eq!(named["units"], "in", "{named}");
assert_eq!(named["component_name"], "Block", "{named}");
assert!(named["moved_to_origin"].as_array().is_some_and(|a| a.len() == 3), "{named}");
run(json!({"path": stl, "units": "cm", "origin": false, "up": null, "name": null}));
for angle in [Value::Null, json!(0), json!(40.5), json!(180)] {
run(json!({"path": stl, "crease_angle_degrees": angle}));
}
// Keys left unread, with values that would be refused if they were read.
run(json!({"path": stl, "names": 5, "folder": "x"}));
run(json!({"path": threemf, "names": [], "folder": {}}));
run(json!({"path": robot, "names": 5, "folder": "x"}));
let stood = run(json!({"path": robot}));
assert!(stood["stand_up"].is_u64() && stood["subcomponents"].is_u64() && stood["rigid_joints"].is_u64(), "{stood}");
let kept = run(json!({"path": robot, "up": "Z", "names": " Instance ", "folder": true, "origin": true}));
assert!(kept.get("stand_up").is_none() && kept["subcomponents"].is_u64(), "{kept}");
let turned = run(json!({"path": robot, "up": " y "}));
assert!(turned["stand_up"].is_u64(), "{turned}");
// A misspelt key still runs in Warn, says so, and the reply still matches.
let warned = s.execute("file.insert_mesh", &json!({"path": stl, "unit": "in"})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(warned["param_warnings"], json!(["unknown key `unit`; did you mean `units`?"]), "{warned}");
matches(&warned);
let _ = std::fs::remove_dir_all(&dir);
}
/// In Strict a call that works today is not refused, and one that fails fails with the
/// handler's message (see `strict_keeps_todays_errors`).
#[test]
fn strict_runs_the_calls_that_work() {
let dir = folder("strict");
let (stl, _) = cube_files(&dir);
let robot = glb(&dir);
let mut s = Session { param_check: ParamCheck::Strict, ..Session::default() };
for p in [
json!({"path": stl, "units": "mm"}),
json!({"path": stl, "crease_angle_degrees": null}),
json!({"path": stl, "crease_angle_degrees": 0}),
json!({"path": stl, "crease_angle_degrees": 40.5}),
json!({"path": stl, "crease_angle_degrees": 180}),
json!({"path": robot, "up": "y", "names": "auto", "folder": false}),
] {
s.execute("file.insert_mesh", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
}
let e = s.execute("file.insert_mesh", &json!({"path": stl, "unit": "in"})).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("unknown key `unit`"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
-7
View File
@@ -54,7 +54,6 @@ mod coverage_tests;
mod tests;
// The contracts, by command file.
pub mod align;
pub mod appearance;
pub mod bom;
pub mod browser;
@@ -72,7 +71,6 @@ pub mod features;
pub mod features_more;
pub mod file;
pub mod frame;
pub mod insert_mesh;
pub mod inspect;
pub mod interference;
pub mod joint_sweep;
@@ -86,7 +84,6 @@ pub mod metadata;
pub mod motion;
pub mod motion_analysis;
pub mod motion_play;
pub mod new_design;
pub mod panels;
pub mod parameter_sweep;
pub mod parameters;
@@ -108,7 +105,6 @@ pub mod render_stage;
pub mod render_surface_resources;
pub mod render_ui;
pub mod save;
pub mod save_mesh;
pub mod section;
pub mod select;
pub mod sheet;
@@ -119,7 +115,6 @@ pub mod sketch_clipboard;
pub mod sketch_constraints;
pub mod sketch_create;
pub mod sketch_dimension;
pub mod sketch_dimensioned;
pub mod sketch_ends;
pub mod sketch_equation;
pub mod sketch_import;
@@ -128,8 +123,6 @@ pub mod sketch_modify;
pub mod sketch_project;
pub mod sketch_session;
pub mod sketch_shapes;
pub mod slicers;
pub mod split;
pub mod surface;
pub mod surface_analysis;
pub mod surface_boundary;
-34
View File
@@ -1,34 +0,0 @@
//! Contracts and result types for the commands in `cmd/file.rs` that start a design: `file.new`
//! (#13305).
//!
//! The handler reads its parameters from the raw JSON, so the key here reads as the handler reads
//! it. `file.new` refuses nothing: a `name` that is not text is read as left out, so the
//! handler has no message of its own for the check to keep.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Start a new, empty design and make it the current one, replacing the design that is open
/// (save it first: this does not ask). A name you give is kept by Save As. Not an undo step.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NewDesignParams {
/// The design's name. Default: "Untitled". A name other than "Untitled" (and not blank) is
/// the user's own, so Save As offers it as the file name.
#[serde(default)]
pub name: Option<String>,
}
/// What `file.new` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NewDesignResult {
/// The new design's name: the `name` given, else "Untitled".
pub name: 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>,
}
#[cfg(test)]
#[path = "new_design_tests.rs"]
mod tests;
@@ -1,92 +0,0 @@
use serde_json::{Value, json};
use super::{NewDesignParams, NewDesignResult};
use crate::Session;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<NewDesignParams>(), &["name"]);
let issues = |p: Value| crate::schema::issues("file.new", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
assert!(issues(json!({})).is_empty());
assert!(issues(json!({"name": "Gearbox"})).is_empty());
assert_eq!(issues(json!({"nmae": "Gearbox"})).len(), 1);
}
/// `file.new` has no call the handler refuses, so strict has no error of today's to keep: every
/// call that works with the check off still works in strict, with the same reply. A `name` that
/// is not text is the one call strict now refuses, which the handler read as left out.
#[test]
fn strict_keeps_todays_replies() {
let reply = |level: ParamCheck, p: &Value| {
let mut s = Session { param_check: level, ..Session::default() };
s.execute("file.new", p).map_err(|e| e.to_string())
};
for p in [
json!({}),
json!(null),
json!({"name": null}),
json!({"name": "Gearbox lid"}),
json!({"name": ""}),
json!({"name": " "}),
json!({"name": "Untitled"}),
] {
let off = reply(ParamCheck::Off, &p);
assert!(off.is_ok(), "{p}");
assert_eq!(reply(ParamCheck::Strict, &p), off, "{p}");
}
assert_eq!(reply(ParamCheck::Off, &json!({"name": 5})), Ok(json!({"name": "Untitled"})));
assert!(reply(ParamCheck::Strict, &json!({"name": 5})).is_err());
}
/// The type reads `name` as the handler does: text, or `null` and absent as left out.
#[test]
fn the_types_read_each_key_as_the_handler_does() {
let read = |p: Value| serde_json::from_value::<NewDesignParams>(p).map(|p| p.name);
assert_eq!(read(json!({})).ok(), Some(None));
assert_eq!(read(json!({"name": null})).ok(), Some(None));
assert_eq!(read(json!({"name": "Lid"})).ok(), Some(Some("Lid".to_string())));
assert_eq!(read(json!({"name": ""})).ok(), Some(Some(String::new())));
assert!(read(json!({"name": 5})).is_err());
}
#[test]
fn the_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<NewDesignParams>();
let schema = schema.as_value();
for (p, takes) in [(json!({}), true), (json!({"name": "Lid"}), true), (json!({"name": 5}), false), (json!({"title": "x"}), false)] {
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
/// Real calls in Warn reply as before, with no warnings, and match the output schema.
#[test]
fn replies_match_the_output_schema() {
let schema = output_schema_for::<NewDesignResult>();
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
for (p, name) in [
(json!({}), "Untitled"),
(json!({"name": null}), "Untitled"),
(json!({"name": "Gearbox lid"}), "Gearbox lid"),
(json!({"name": ""}), ""),
(json!({"name": "Untitled"}), "Untitled"),
] {
let reply = s.execute("file.new", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert_eq!(reply, json!({"name": name}), "{p}");
assert!(reply.get("param_warnings").is_none(), "{p}: {reply}");
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{p}: {reply}");
}
assert_eq!(s.doc.name, "Untitled");
}
/// A misspelt key still runs in Warn, says so, and the reply still matches.
#[test]
fn a_misspelt_key_is_reported_in_warn() {
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let reply = s.execute("file.new", &json!({"nmae": "Lid"})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(reply["name"], "Untitled", "{reply}");
assert!(reply["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{reply}");
let schema = output_schema_for::<NewDesignResult>();
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{reply}");
}
+1 -1
View File
@@ -27,7 +27,7 @@ pub struct PartsLibraryParams {
#[schemars(deny_unknown_fields)]
pub struct PartsInsertParams {
/// The family, as `parts.library` lists it: socket_head_cap_screw, button_head_cap_screw,
/// countersunk_socket_screw, hex_bolt, hex_nut, washer, set_screw, dowel_pin, bearing, hex_cap_screw_inch,
/// hex_bolt, hex_nut, washer, set_screw, dowel_pin, bearing, hex_cap_screw_inch,
/// hex_nut_inch, washer_sae or washer_uss.
#[serde(deserialize_with = "family")]
pub family: String,
+7 -14
View File
@@ -186,10 +186,9 @@ impl JsonSchema for PresentationParam {
}
}
/// The file format Export Render Image writes: `png`, the finished image as shown; `exr`,
/// the raw linear FLOAT32 master, which the job must have kept (`linear_exr`); or
/// `denoise_mask`, the categorical PNG guide mask kept with `denoise_diagnostics`.
/// The handler reads it as this type.
/// The file format Export Render Image writes: `png`, the finished image as shown, or `exr`,
/// the raw linear FLOAT32 master, which the job must have kept (`linear_exr`). The handler
/// reads it as this type.
// The variants have no doc comments on purpose: with them, schemars publishes `oneOf` and
// `const` instead of a plain `enum`, which the schema checks in the tests don't read.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
@@ -198,14 +197,13 @@ pub enum ExportFormat {
#[default]
Png,
Exr,
DenoiseMask,
}
impl ExportFormat {
/// The file extension the path must end with.
pub fn extension(self) -> &'static str {
match self {
Self::Png | Self::DenoiseMask => "png",
Self::Png => "png",
Self::Exr => "exr",
}
}
@@ -399,22 +397,17 @@ pub struct ImageExported {
/// How many bytes were written.
pub bytes: usize,
pub format: ExportFormat,
/// The file's colour encoding: `srgb` for a colour PNG, `scene_linear_srgb_d65` for an
/// EXR, or `categorical_srgb` for a guide mask.
/// The file's colour encoding: `srgb` for a PNG, `scene_linear_srgb_d65` for an EXR.
pub encoding: &'static str,
/// The file's alpha: `straight` for a colour PNG, `coverage_premultiplied` for an EXR,
/// or `opaque` for a guide mask.
/// The file's alpha: `straight` for a PNG, `coverage_premultiplied` for an EXR.
pub alpha: &'static str,
/// The parent finished colour image, including its colour PNG hash when a guide mask
/// is exported. The separate mask metadata describes the exported mask bytes.
/// The finished image.
pub frame: Frame,
/// The linear master the job kept, as the render crate describes it, or `null`.
pub linear_master: Option<Map<String, Value>>,
/// How a regraded image was presented, as the render crate describes it, or `null` for an
/// image rendered directly.
pub presentation: Option<Map<String, Value>>,
/// The guide mask the job kept, including its PNG byte count, dimensions and SHA, or `null`.
pub denoise_mask: Option<Map<String, Value>>,
/// 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>,
+6 -30
View File
@@ -47,7 +47,6 @@ fn schemas_and_the_key_check_agree() {
}
assert_keys(input_schema_for::<RegradeParams>(), &["job", "presentation"]);
assert_keys(input_schema_for::<ExportParams>(), &["format", "job", "overwrite", "path"]);
assert_eq!(input_schema_for::<super::ExportFormat>().as_value()["enum"], json!(["png", "exr", "denoise_mask"]));
}
/// #12836: `JobPhase` names every phase the render crate has, as it spells them. The match has
@@ -104,7 +103,6 @@ fn strict_keeps_todays_errors() {
("render.export", json!({"path": "shot.png", "format": null})),
("render.export", json!({"path": "shot.png", "overwrite": "yes"})),
("render.export", json!({"path": "shot.txt"})),
("render.export", json!({"path": "mask.exr", "format": "denoise_mask"})),
("render.export", json!({"path": "shot.png", "job": foreign_job()})),
]);
}
@@ -170,7 +168,6 @@ fn the_checks_take_what_the_schemas_allow() {
("render.export", json!({"path": "shot.png"}), true),
("render.export", json!({"path": "shot.txt"}), true),
("render.export", json!({"path": "shot.exr", "format": "exr", "overwrite": true, "job": null}), true),
("render.export", json!({"path": "mask.png", "format": "denoise_mask", "job": job}), true),
("render.export", json!({"path": 5}), false),
("render.export", json!({"path": "shot.png", "format": "PNG"}), false),
("render.export", json!({"path": "shot.png", "format": null}), false),
@@ -218,7 +215,7 @@ fn render_job_commands_reply_with_their_schemas() {
assert_eq!(
exported,
json!({"job": job, "path": path, "bytes": written, "format": "png", "encoding": "srgb", "alpha": "straight",
"frame": state["frame"], "linear_master": null, "presentation": null, "denoise_mask": null})
"frame": state["frame"], "linear_master": null, "presentation": null})
);
assert_eq!(reply(&mut s, "render.result.pin", json!({"job": null}), &pinned), json!({"frame": null}));
@@ -233,8 +230,8 @@ fn render_job_commands_reply_with_their_schemas() {
}
/// #12836: Regrade Linear Master replies as its output schema says, and so does Export Render
/// Image for the linear master (EXR), the regraded colour PNG and the retained guide masks.
/// Every export preserves the parent colour frame, master, presentation and mask metadata.
/// Image for the linear master (EXR) and for the regraded image, whose replies describe the
/// master and the presentation.
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn regrade_and_export_of_a_linear_master_reply_with_their_schemas() {
@@ -242,8 +239,7 @@ fn regrade_and_export_of_a_linear_master_reply_with_their_schemas() {
s.execute(
"render.settings.set",
&json!({"name": "Master", "set": {"width": 8, "height": 8, "job": {"method": "cpu_raytrace",
"options": {"progressive": {"samples_per_pixel": 1, "seed": 7}, "lights": [], "denoise_diagnostics": true,
"output": {"linear_exr": true}}}}}),
"options": {"progressive": {"samples_per_pixel": 1, "seed": 7}, "lights": [], "output": {"linear_exr": true}}}}}),
)
.unwrap_or_else(|e| panic!("{e}"));
s.set_render_job_host(Some(CpuHost::for_platform().unwrap_or_else(|e| panic!("{e}")))).unwrap_or_else(|e| panic!("{e}"));
@@ -261,30 +257,10 @@ fn regrade_and_export_of_a_linear_master_reply_with_their_schemas() {
let regraded: JobId = serde_json::from_value(regraded["job"].clone()).unwrap_or_else(|e| panic!("{e}"));
wait(&s, regraded);
let exported = output_schema_for::<ImageExported>();
for (job, format, extension) in
[(master, "exr", "exr"), (regraded, "png", "png"), (master, "denoise_mask", "png"), (regraded, "denoise_mask", "png")]
{
let frame = s.render_jobs.frame(job).unwrap_or_else(|e| panic!("{e}"));
let mask_info = json!(frame.denoise_mask_info().unwrap_or_else(|| panic!("missing retained guide mask")));
let expected = match format {
"exr" => frame.linear_exr().unwrap_or_else(|| panic!("missing retained linear master")),
"denoise_mask" => frame.denoise_mask_png().unwrap_or_else(|| panic!("missing retained guide mask")),
_ => frame.png(),
};
let path = std::env::temp_dir().join(format!("solidcraft-render-jobs-schema-{}-{}-{format}.{extension}", std::process::id(), job.serial));
for (job, format) in [(master, "exr"), (regraded, "png")] {
let path = std::env::temp_dir().join(format!("solidcraft-render-jobs-schema-{}-{}.{format}", std::process::id(), job.serial));
let r = reply(&mut s, "render.export", json!({"job": job, "path": path, "format": format, "overwrite": true}), &exported);
assert_eq!(std::fs::read(&path).unwrap_or_else(|e| panic!("{e}")), expected);
let _ = std::fs::remove_file(&path);
assert_eq!(r["format"], format);
assert_eq!(r["frame"], json!(frame.info));
assert_eq!(r["denoise_mask"], mask_info);
let (encoding, alpha) = match format {
"exr" => ("scene_linear_srgb_d65", "coverage_premultiplied"),
"denoise_mask" => ("categorical_srgb", "opaque"),
_ => ("srgb", "straight"),
};
assert_eq!(r["encoding"], encoding);
assert_eq!(r["alpha"], alpha);
assert!(r["linear_master"].is_object(), "{r}");
if job == regraded {
assert!(r["presentation"].is_object(), "{r}");
-364
View File
@@ -1,364 +0,0 @@
//! Contracts and result types for `file.save_mesh` in `cmd/file.rs`.
//!
//! The handler reads its parameters from the raw JSON, so each key here reads as the handler reads
//! it and fails with the handler's message. Whether a body exists, a slicer is installed or the file
//! can be written stays the handler's to check. A call with several problems may get a different
//! one first in strict than with the check off (the check goes key by key, the handler in its own
//! order).
//!
//! `path` is the one key the handler refuses when it is left out: it is optional in the type, so the
//! handler keeps its message, and required in the schema.
//!
//! Two keys are read only in some cases, and the contract reads them in the same cases: `ascii`
//! only when `format` is `stl` (in any case), and `single_instance` only when `slicer` is text. A
//! value of the wrong type that the handler reads as if the key were left out (`ascii: "yes"` next
//! to `format: "stl"`, a number for `slicer`) is refused by the contract, as schemas.md has it: that
//! difference is what warn and strict report.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Value, json};
use super::check::{located, whole};
use super::types::{not_blank_pattern, whitespace_class};
/// A key that, once given (`null` included), must parse as `T`, as the handler reads it.
fn some<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Option<T>, D::Error> {
T::deserialize(d).map(Some)
}
/// The keys `file.save_mesh` reads. See [`SaveMeshParams`], which leaves `ascii` and
/// `single_instance` unread in the cases the handler does.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = conditional_keys)]
pub struct SaveMeshKeys {
/// The file to write: not blank, fewer than 4096 UTF-8 bytes.
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
#[schemars(with = "MeshPath")]
pub path: Option<MeshPath>,
/// The file format, in any case: `stl`, `3mf`, or `3mf-tagged` (3MF with a face tag on each
/// triangle, docs/formats/3mf-face-tags.md). Default: from the extension of `path` (`.3mf`
/// gives 3mf), else stl.
#[serde(default)]
pub format: Option<MeshFormat>,
/// The bodies to save, by name. Default: every body.
#[serde(default)]
pub bodies: Option<MeshBodies>,
/// STL only (read only when `format` is `stl`): true writes ASCII STL instead of binary.
/// Default: false.
#[serde(default)]
pub ascii: Option<bool>,
/// A slicer to send the saved file to, after it is written where `path` says, through the
/// same steps as `file.send_to_slicer`: an id or a name from `file.slicers`, the program path
/// it lists, or a program the user added under Custom…. A slicer that has to be started first
/// is waited for. Kept for scripts; may be deprecated later in favour of `file.send_to_slicer`
/// or a Send to App workflow. Default: none.
#[serde(default)]
pub slicer: Option<String>,
/// Read only when `slicer` is given: true opens the file in the window the slicer already has
/// open, for slicers that load files that way (see `file.send_to_slicer`). Default: true.
#[serde(default)]
pub single_instance: Option<bool>,
/// How closely the mesh follows curved faces (STL, OBJ and 3MF), in any case: `standard`, `fine`
/// or `finest`, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size. Default: standard.
#[serde(default)]
pub quality: Option<MeshQualityParam>,
/// 3MF only: `body` is one object per body; `component` is 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 (other bodies stay objects of their own). `component` is refused for
/// other formats. Default: body.
#[serde(default)]
pub objects: Option<MeshObjects>,
}
/// Leaves out the keys the handler never reads for this call: `ascii` unless `format` is `stl`,
/// and `single_instance` unless `slicer` is text.
fn leave_unread(value: &mut Value) {
let Some(keys) = value.as_object_mut() else { return };
let stl = keys.get("format").and_then(Value::as_str).is_some_and(|f| f.eq_ignore_ascii_case("stl"));
if !stl {
keys.remove("ascii");
}
if !keys.get("slicer").is_some_and(Value::is_string) {
keys.remove("single_instance");
}
}
fn read_keys<'de, D: Deserializer<'de>>(d: D) -> Result<SaveMeshKeys, D::Error> {
let mut value = Value::deserialize(d)?;
leave_unread(&mut value);
serde_path_to_error::deserialize(&value).map_err(|e| D::Error::custom(located(&e)))
}
/// Save bodies as a mesh file: binary or ASCII STL, 3MF, or 3MF with a face tag on each triangle.
/// The format is `format`, else the extension of `path`, else STL. With `slicer`, the saved file
/// is then sent to that program. Nothing is added to undo.
#[derive(Debug)]
pub struct SaveMeshParams(pub SaveMeshKeys);
impl<'de> Deserialize<'de> for SaveMeshParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
read_keys(d).map(SaveMeshParams)
}
}
impl JsonSchema for SaveMeshParams {
fn schema_name() -> Cow<'static, str> {
"SaveMeshParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = SaveMeshKeys::json_schema(generator);
schema.insert("description".into(), SAVE_MESH.into());
schema
}
}
const SAVE_MESH: &str = "Save bodies as a mesh file: binary or ASCII STL, 3MF, or 3MF with a face tag on each triangle. The format is `format`, else the extension of `path`, else STL. With `slicer`, the saved file is then sent to that program. Nothing is added to undo.";
/// The pattern for `format`: the three words, in any case.
const FORMAT_PATTERN: &str = "^([Ss][Tt][Ll]|3[Mm][Ff]|3[Mm][Ff]-[Tt][Aa][Gg][Gg][Ee][Dd])$";
/// The pattern for `quality`: the three words, in any case, with whitespace around them allowed
/// (the handler trims).
fn quality_pattern() -> String {
let ws = whitespace_class();
format!("^[{ws}]*([Ss][Tt][Aa][Nn][Dd][Aa][Rr][Dd]|[Ff][Ii][Nn][Ee]|[Ff][Ii][Nn][Ee][Ss][Tt])[{ws}]*$")
}
/// `path` is required (the handler says so when it is left out); `ascii` and `single_instance`
/// have their shapes only where the handler reads them, as [`leave_unread`] has it.
fn conditional_keys(schema: &mut Schema) {
super::motion::require(schema, "path");
let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return };
let mut read_only_when = |key: &str| {
let shape = props.get_mut(key).map(Value::take).unwrap_or_default();
props.insert(key.into(), json!({"description": shape.get("description").cloned().unwrap_or_default()}));
shape
};
let ascii = read_only_when("ascii");
let single_instance = read_only_when("single_instance");
// `format` is `stl` in any case: the eight spellings, which `enum` says and a pattern would only approximate.
let spellings: Vec<String> =
(0..8).map(|n| "stl".chars().enumerate().map(|(i, c)| if n >> i & 1 == 1 { c.to_ascii_uppercase() } else { c }).collect()).collect();
let stl = json!({"required": ["format"], "properties": {"format": {"enum": spellings}}});
let slicer = json!({"required": ["slicer"], "properties": {"slicer": {"type": "string"}}});
schema.insert(
"allOf".into(),
json!([
{"anyOf": [{"not": stl}, {"properties": {"ascii": ascii}}]},
{"anyOf": [{"not": slicer}, {"properties": {"single_instance": single_instance}}]},
]),
);
}
/// The file to write, as `path_arg` reads `path`: not blank and under 4096 bytes, or the
/// handler's "`path` is required" (anything but text reads as no `path`).
#[derive(Clone, Debug, PartialEq)]
pub struct MeshPath(pub String);
impl<'de> Deserialize<'de> for MeshPath {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if !s.trim().is_empty() && s.len() < 4096 => Ok(MeshPath(s)),
_ => Err(D::Error::custom(whole("`path` is required"))),
}
}
}
impl JsonSchema for MeshPath {
fn schema_name() -> Cow<'static, str> {
"MeshPath".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
// `maxLength` counts characters, so 4095 is as close as the schema gets to "under 4096 bytes".
json_schema!({
"description": "The file to write: not blank, fewer than 4096 UTF-8 bytes.",
"type": "string",
"maxLength": 4095,
"pattern": not_blank_pattern()
})
}
}
/// The bodies to save, read by the handler's own `string_list`: a name or a list of names, with its
/// messages for anything else.
#[derive(Clone, Debug, PartialEq)]
pub struct MeshBodies(pub Vec<String>);
impl<'de> Deserialize<'de> for MeshBodies {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let keys = json!({"bodies": Value::deserialize(d)?});
match crate::params::string_list("file.save_mesh", &keys, "bodies") {
Ok(names) => Ok(MeshBodies(names)),
Err(crate::EngineError::BadParams { msg, .. }) => Err(D::Error::custom(whole(msg))),
Err(e) => Err(D::Error::custom(whole(e))),
}
}
}
impl JsonSchema for MeshBodies {
fn schema_name() -> Cow<'static, str> {
"MeshBodies".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "A body name, or a list of body names (at most 10000).",
"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "maxItems": crate::params::MAX_STRING_LIST}]
})
}
}
/// A mesh format, as `file.save_mesh` reads `format` (the words in any case).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MeshFormat {
/// STL, binary unless `ascii` is true.
Stl,
/// 3MF.
ThreeMf,
/// 3MF with a face tag on each triangle.
ThreeMfTagged,
}
impl<'de> Deserialize<'de> for MeshFormat {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match String::deserialize(d)?.to_ascii_lowercase().as_str() {
"stl" => Ok(MeshFormat::Stl),
"3mf" => Ok(MeshFormat::ThreeMf),
"3mf-tagged" => Ok(MeshFormat::ThreeMfTagged),
other => Err(D::Error::custom(whole(format!("unknown mesh format `{other}` (stl, 3mf, 3mf-tagged)")))),
}
}
}
impl JsonSchema for MeshFormat {
fn schema_name() -> Cow<'static, str> {
"MeshFormat".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "stl, 3mf or 3mf-tagged, in any case.",
"type": "string",
"pattern": FORMAT_PATTERN
})
}
}
/// How closely the mesh follows curved faces, as `quality` reads it (the words in any case,
/// whitespace around them ignored).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MeshQualityParam {
/// A chord tolerance of 2e-4 of each body's size.
Standard,
/// 5e-5 of each body's size.
Fine,
/// 1e-5 of each body's size.
Finest,
}
impl<'de> Deserialize<'de> for MeshQualityParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let named = v.as_str().filter(|s| solidcraft_io::MeshQuality::from_name(s).is_some()).map(|s| s.trim().to_ascii_lowercase());
match named.as_deref() {
Some("standard") => Ok(MeshQualityParam::Standard),
Some("fine") => Ok(MeshQualityParam::Fine),
Some("finest") => Ok(MeshQualityParam::Finest),
_ => Err(D::Error::custom(whole(format!("`quality` must be standard, fine or finest, not {v}")))),
}
}
}
impl JsonSchema for MeshQualityParam {
fn schema_name() -> Cow<'static, str> {
"MeshQualityParam".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "standard (a chord tolerance of 2e-4 of each body's size), fine (5e-5) or finest (1e-5), in any case.",
"type": "string",
"pattern": quality_pattern()
})
}
}
/// What each 3MF object holds, as `objects` reads it (lower case only).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MeshObjects {
/// One object per body.
Body,
/// One object for each placed component with two or more bodies.
Component,
}
impl<'de> Deserialize<'de> for MeshObjects {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
match v.as_str() {
Some("body") => Ok(MeshObjects::Body),
Some("component") => Ok(MeshObjects::Component),
_ => Err(D::Error::custom(whole(format!("`objects` must be body or component, not {v}")))),
}
}
}
impl JsonSchema for MeshObjects {
fn schema_name() -> Cow<'static, str> {
"MeshObjects".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "body (one object per body) or component (one object for each placed component with two or more bodies, with those bodies as its parts). Lower case only.",
"type": "string",
"enum": ["body", "component"]
})
}
}
/// What happened when the saved file was handed to `slicer`. Only the one that applies is there.
#[derive(Debug, Default, Serialize, JsonSchema)]
pub struct SlicerDelivery {
/// The slicer's name: the file was opened there.
#[serde(skip_serializing_if = "Option::is_none")]
pub slicer: Option<String>,
/// Why the program would not open the file. The file is saved either way.
#[serde(skip_serializing_if = "Option::is_none")]
pub slicer_error: Option<String>,
/// The slicer's name: the slicer had to be started first and was not ready (never for
/// `file.save_mesh`, which waits for it).
#[serde(skip_serializing_if = "Option::is_none")]
pub starting: Option<String>,
/// The slicer's name: it was started but was not ready within 10 seconds, so the file was
/// not sent. The file is saved.
#[serde(skip_serializing_if = "Option::is_none")]
pub slicer_timeout: Option<String>,
}
/// What `file.save_mesh` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SaveMeshResult {
/// The file written, as given.
pub path: String,
/// Its size in bytes.
pub bytes: usize,
/// The format written: StlBinary, StlAscii, ThreeMf or ThreeMfTagged.
pub format: String,
/// 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` 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.
#[serde(flatten)]
pub delivery: SlicerDelivery,
/// 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>,
}
#[cfg(test)]
#[path = "save_mesh_tests.rs"]
mod tests;
-303
View File
@@ -1,303 +0,0 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use super::{SaveMeshParams, SaveMeshResult};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-13308-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn with_box(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
s.execute("solid.box", &json!({"length": 20, "width": 10, "height": 5, "body_name": "Block"})).unwrap();
s.execute("solid.cylinder", &json!({"radius": 3, "height": 8, "body_name": "Pin", "operation": "new"})).unwrap();
s
}
#[test]
fn schemas_and_the_key_check_agree() {
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"]));
assert!(serde_json::from_value::<SaveMeshParams>(json!({})).is_ok());
// The patterns are pinned (`conforms` doesn't read them).
let defs = &schema.as_value()["$defs"];
assert_eq!(defs["MeshFormat"]["pattern"], "^([Ss][Tt][Ll]|3[Mm][Ff]|3[Mm][Ff]-[Tt][Aa][Gg][Gg][Ee][Dd])$");
assert_eq!(defs["MeshPath"]["pattern"], json!(crate::schema::types::not_blank_pattern()));
assert_eq!(defs["MeshPath"]["maxLength"], 4095);
let quality = defs["MeshQualityParam"]["pattern"].as_str().unwrap();
assert!(quality.starts_with("^[\\u0009-\\u000d\\u0020") && quality.contains("[Ff][Ii][Nn][Ee][Ss][Tt]"), "{quality}");
assert_eq!(schema.as_value()["allOf"].as_array().map(Vec::len), Some(2));
}
/// Strict gives the handler's message for the calls it refuses for a key. Nothing is written: each
/// of these is refused before the file is.
#[test]
fn strict_keeps_todays_errors() {
let p = |extra: Value| {
let mut p = json!({"path": "/nonexistent-13308/out.stl"});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
};
let mut cases: Vec<(&str, Value)> = vec![
("file.save_mesh", json!({})),
("file.save_mesh", json!({"path": ""})),
("file.save_mesh", json!({"path": " "})),
("file.save_mesh", json!({"path": 5})),
("file.save_mesh", json!({"path": null})),
("file.save_mesh", json!({"path": ["a.stl"]})),
("file.save_mesh", json!({"path": "x".repeat(4096)})),
];
for extra in [
json!({"format": "obj"}),
json!({"format": "step"}),
json!({"format": ""}),
json!({"format": "3MF-Tagged "}),
json!({"quality": "ultra"}),
json!({"quality": 5}),
json!({"quality": true}),
json!({"quality": ""}),
json!({"objects": "Body"}),
json!({"objects": "parts"}),
json!({"objects": 5}),
json!({"objects": false}),
json!({"bodies": 5}),
json!({"bodies": [1, "a"]}),
json!({"bodies": {"a": 1}}),
json!({"bodies": true}),
json!({"format": "stl", "bodies": ["a", null]}),
] {
cases.push(("file.save_mesh", p(extra)));
}
assert_strict_keeps_todays_errors(&cases);
}
/// The refusals only the handler can make (they need the design or two keys together) are the same
/// in strict, and nothing is written.
#[test]
fn strict_keeps_the_handlers_own_refusals() {
let dir = temp_dir("own");
let at = |n: &str| dir.join(n).to_string_lossy().into_owned();
for p in [
json!({"path": at("a.stl"), "objects": "component"}),
json!({"path": at("b.stl"), "bodies": ["Nope"]}),
json!({"path": at("c.3mf"), "slicer": "no such slicer anywhere"}),
json!({"path": at("d.stl"), "slicer": ""}),
] {
let error = |level: ParamCheck| with_box(level).execute("file.save_mesh", &p).err().map(|e| e.to_string());
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{p}");
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
/// The types read each key as the handler does: keys it never reads in a call are not read by the
/// contract either, and the ones it reads fail with its words.
#[test]
fn the_types_read_like_the_handler() {
let reads = |p: Value| serde_json::from_value::<SaveMeshParams>(p).is_ok();
let issues = |p: Value| crate::schema::issues("file.save_mesh", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
// `ascii` is read only for `format: stl`, in any case; `single_instance` only with a text `slicer`.
assert!(reads(json!({"path": "a.stl", "ascii": "yes"})) && reads(json!({"path": "a.stl", "ascii": 1, "format": "3mf"})));
assert!(reads(json!({"path": "a.stl", "ascii": {}, "format": null})) && reads(json!({"path": "a.stl", "ascii": [], "format": null})));
assert!(reads(json!({"path": "a.stl", "single_instance": "no"})) && reads(json!({"path": "a.stl", "single_instance": 0, "slicer": null})));
assert!(reads(json!({"path": "a.stl", "format": "STL", "ascii": true})) && reads(json!({"path": "a.stl", "format": "Stl", "ascii": null})));
assert!(reads(json!({"path": "a.stl", "slicer": "prusa", "single_instance": false})));
assert!(!reads(json!({"path": "a.stl", "format": "stl", "ascii": "yes"})) && !reads(json!({"path": "a.stl", "format": "STL", "ascii": 1})));
assert!(!reads(json!({"path": "a.stl", "slicer": "prusa", "single_instance": "no"})));
// `null` reads as left out where the handler does.
assert!(reads(json!({"path": "a.stl", "format": null, "bodies": null, "slicer": null, "quality": null, "objects": null})));
// Stricter than the handler, which reads a wrong type as left out.
assert!(!reads(json!({"path": "a.stl", "format": 5})) && !reads(json!({"path": "a.stl", "slicer": 5})));
assert!(!reads(json!({"path": "a.stl", "slicer": true})));
// The words the handler takes: any case for the format and the quality (trimmed), lower case for `objects`.
for format in ["stl", "3MF", "3mf-tagged", "3Mf-TaGGed"] {
assert!(reads(json!({"path": "a", "format": format})), "{format}");
}
for quality in ["standard", "FINE", " Finest\n"] {
assert!(reads(json!({"path": "a", "quality": quality})), "{quality:?}");
}
assert!(reads(json!({"path": "a", "objects": "body"})) && reads(json!({"path": "a", "objects": "component"})));
assert!(reads(json!({"path": "a", "bodies": "A"})) && reads(json!({"path": "a", "bodies": ["A", "B"]})));
assert!(reads(json!({"path": "a", "bodies": []})));
// The handler's own messages.
assert_eq!(issues(json!({"path": ""})), ["`path` is required"]);
assert_eq!(issues(json!({"path": 5})), ["`path` is required"]);
assert_eq!(issues(json!({"path": null})), ["`path` is required"]);
assert_eq!(issues(json!({"path": "a", "format": "OBJ"})), ["unknown mesh format `obj` (stl, 3mf, 3mf-tagged)"]);
assert_eq!(issues(json!({"path": "a", "quality": "ultra"})), ["`quality` must be standard, fine or finest, not \"ultra\""]);
assert_eq!(issues(json!({"path": "a", "quality": 5})), ["`quality` must be standard, fine or finest, not 5"]);
assert_eq!(issues(json!({"path": "a", "objects": "Body"})), ["`objects` must be body or component, not \"Body\""]);
assert_eq!(issues(json!({"path": "a", "objects": null})), Vec::<String>::new());
assert_eq!(issues(json!({"path": "a", "bodies": 5})), ["`bodies` must be a list of names; it is 5"]);
assert_eq!(issues(json!({"path": "a", "pth": 1})), ["unknown key `pth`; did you mean `path`?"]);
// A key the handler doesn't read in this call is not checked, so no warning for it.
assert!(issues(json!({"path": "a", "format": "3mf", "ascii": "yes", "single_instance": 3})).is_empty());
assert_eq!(issues(json!({"path": "a", "format": "stl", "ascii": "yes"})), ["`ascii` must be true or false"]);
}
/// What the schema takes: the same calls as the type, including the keys read only in some cases.
#[test]
fn the_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<SaveMeshParams>();
let schema = schema.as_value();
for (p, takes) in [
(json!({"path": "a.stl"}), true),
(json!({"path": "a.3mf", "format": "3MF", "bodies": ["A"], "quality": "fine", "objects": "component"}), true),
(json!({"path": "a", "format": "stl", "ascii": true}), true),
(json!({"path": "a", "slicer": "prusa", "single_instance": false}), true),
(json!({"path": "a", "bodies": "A"}), true),
(json!({}), false),
(json!({"path": 5}), false),
(json!({"path": "a", "format": 5}), false),
(json!({"path": "a", "objects": "x"}), false),
(json!({"path": "a", "bodies": 5}), false),
(json!({"path": "a", "wibble": 1}), false),
// Read only in some cases: anything elsewhere, the shape where it is read.
(json!({"path": "a", "ascii": "yes"}), true),
(json!({"path": "a", "format": "3mf", "ascii": "yes"}), true),
(json!({"path": "a", "format": "stl", "ascii": "yes"}), false),
(json!({"path": "a", "single_instance": "no"}), true),
(json!({"path": "a", "slicer": 5, "single_instance": "no"}), false),
(json!({"path": "a", "slicer": "x", "single_instance": "no"}), false),
] {
// `conforms` doesn't read `pattern`: the cases a pattern alone refuses are in the type tests.
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
fn matches(r: &Value) {
let schema = output_schema_for::<SaveMeshResult>();
let schema = schema.as_value();
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
}
fn keys(r: &Value) -> BTreeSet<String> {
r.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default()
}
/// Every form of the command replies as its output schema says, in warn and without warnings, with
/// the keys it always had; a misspelt key gets its warning and the reply still matches.
#[test]
fn replies_match_the_output_schema() {
let dir = temp_dir("replies");
let at = |n: &str| dir.join(n).to_string_lossy().into_owned();
let mut s = with_box(ParamCheck::Warn);
let run = |s: &mut Session, p: Value| {
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"].map(String::from).into(), "{p}: {r}");
assert_eq!(r["bytes"].as_u64(), std::fs::metadata(r["path"].as_str().unwrap()).ok().map(|m| m.len()), "{p}: {r}");
r
};
for (p, format) in [
(json!({"path": at("a.stl")}), "StlBinary"),
(json!({"path": at("b.3mf")}), "ThreeMf"),
(json!({"path": at("c.dat")}), "StlBinary"),
(json!({"path": at("d.dat"), "format": "STL"}), "StlBinary"),
(json!({"path": at("e.stl"), "format": "stl", "ascii": true}), "StlAscii"),
(json!({"path": at("f.stl"), "format": "Stl", "ascii": null}), "StlBinary"),
(json!({"path": at("g.stl"), "format": "3mf"}), "ThreeMf"),
(json!({"path": at("h.3mf"), "format": "3MF-tagged"}), "ThreeMfTagged"),
(json!({"path": at("i.stl"), "bodies": "Block", "quality": "Fine"}), "StlBinary"),
(json!({"path": at("j.3mf"), "bodies": ["Block", "Pin"], "quality": " finest ", "objects": "component"}), "ThreeMf"),
(json!({"path": at("k.3mf"), "objects": "body", "bodies": []}), "ThreeMf"),
(json!({"path": at("l.stl"), "format": null, "bodies": null, "quality": null, "objects": null, "slicer": null}), "StlBinary"),
// Keys the handler doesn't read in these calls take anything, and get no warning.
(json!({"path": at("m.3mf"), "ascii": "yes"}), "ThreeMf"),
(json!({"path": at("n.stl"), "single_instance": [1], "ascii": {}}), "StlBinary"),
(json!({"path": at("o.stl"), "format": "3mf", "ascii": 7, "single_instance": "no"}), "ThreeMf"),
] {
let r = run(&mut s, p.clone());
assert_eq!(r["format"], format, "{p}");
}
assert!(std::fs::read_to_string(at("e.stl")).unwrap().starts_with("solid"));
assert!(std::fs::read(at("a.stl")).unwrap().len() > 84);
// 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"});
let r = s.execute("file.save_mesh", &p).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| w.iter().any(|m| m.as_str().is_some_and(|m| m.contains("`qualty`")))), "{r}");
matches(&r);
assert_eq!(r["format"], "StlBinary");
s.param_check = ParamCheck::Strict;
let error = s.execute("file.save_mesh", &json!({"path": at("q.stl"), "qualty": "fine"})).err().map(|e| e.to_string()).unwrap_or_default();
assert!(error.contains("`qualty`") && error.contains("did you mean `quality`"), "{error}");
assert!(!std::path::Path::new(&at("q.stl")).exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// With a `slicer`: the reply says where the file went; a body with a print face is laid on it and
/// listed. The program is a script that records what it is given.
#[cfg(unix)]
#[test]
fn replies_with_a_slicer_match_the_output_schema() {
use std::os::unix::fs::PermissionsExt;
let _turn = crate::tests::mesh_convert::CHOSEN_PROGRAMS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = temp_dir("slicer");
let (script, seen) = (dir.join("fake-slicer"), dir.join("seen"));
std::fs::write(&script, format!("#!/bin/sh\necho \"$@\" >> {}\n", seen.display())).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let spec = script.to_string_lossy().into_owned();
crate::slicers::set_chosen_programs(std::slice::from_ref(&spec));
let mut s = with_box(ParamCheck::Warn);
s.execute("body.print_face", &json!({"body": "Block", "point": [10, 5, 5]})).unwrap();
let out = dir.join("part.stl").to_string_lossy().into_owned();
let r = s.execute("file.save_mesh", &json!({"path": out, "slicer": spec, "single_instance": false}));
let again =
s.execute("file.save_mesh", &json!({"path": dir.join("x.3mf"), "slicer": spec, "format": "3mf", "objects": "component", "bodies": "Block"}));
crate::slicers::set_chosen_programs(&[]);
let (r, again) = (r.unwrap(), again.unwrap());
for r in [&r, &again] {
assert!(r.get("param_warnings").is_none(), "{r}");
matches(r);
assert!(r["slicer"].is_string() && r.get("slicer_error").is_none(), "{r}");
}
assert_eq!(keys(&r), ["bytes", "format", "path", "print_faces", "slicer"].map(String::from).into(), "{r}");
assert_eq!(r["print_faces"], json!(["Block"]));
assert_eq!(r["path"], json!(out));
assert_eq!(keys(&again), ["bytes", "format", "path", "print_faces", "slicer"].map(String::from).into(), "{again}");
let _ = std::fs::remove_dir_all(&dir);
}
/// The typed reply serializes to the keys the handler's JSON had, and nothing for what is unset.
#[test]
fn the_reply_type_serializes_as_before() {
use super::{SaveMeshResult, SlicerDelivery};
let reply = |delivery| SaveMeshResult {
path: "a.stl".into(),
bytes: 5,
format: "StlBinary".into(),
print_faces: Vec::new(),
warnings: Vec::new(),
delivery,
param_warnings: Vec::new(),
};
assert_eq!(serde_json::to_value(reply(SlicerDelivery::default())).unwrap(), json!({"path": "a.stl", "bytes": 5, "format": "StlBinary"}));
// Print faces and warnings are there when there are any, as `Laid::report` put them.
let laid = SaveMeshResult { print_faces: vec!["A".into()], warnings: vec!["w".into()], ..reply(SlicerDelivery::default()) };
let r = serde_json::to_value(laid).unwrap();
assert_eq!(r, json!({"path": "a.stl", "bytes": 5, "format": "StlBinary", "print_faces": ["A"], "warnings": ["w"]}));
matches(&r);
for (delivery, key) in [
(SlicerDelivery { slicer: Some("P".into()), ..Default::default() }, "slicer"),
(SlicerDelivery { slicer_error: Some("e".into()), ..Default::default() }, "slicer_error"),
(SlicerDelivery { starting: Some("P".into()), ..Default::default() }, "starting"),
(SlicerDelivery { slicer_timeout: Some("P".into()), ..Default::default() }, "slicer_timeout"),
] {
let r = serde_json::to_value(reply(delivery)).unwrap();
assert_eq!(keys(&r), ["bytes", "format", "path", key].map(String::from).into(), "{r}");
matches(&r);
}
}
@@ -1,278 +0,0 @@
//! Contract and result type for `sketch.draw.dimensioned` in `cmd/sketch_dimensioned.rs` (#13313).
//!
//! The handler reads its parameters from the raw JSON, so each key here reads as the handler
//! reads it and fails with the handler's message. `command` and `params` are optional in the
//! type and required in the schema, so that the handler's message, not the check's "is required",
//! is what a caller sees when they are left out. Which roles `dimensions` may name depends on the
//! creator, and whether a value can be evaluated or is in range depends on the design: both stay
//! the handler's to check. A call with several problems may get a different one first in strict
//! than with the check off (the check goes key by key, the handler in its own order).
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value, json};
use super::check::whole;
use super::motion::require;
use super::types::{AngleExpr, LengthExpr};
use crate::params;
/// The sketch creators `sketch.draw.dimensioned` can dimension: those `dimension_roles` lists.
pub const CREATORS: &[&str] = &[
"sketch.line",
"sketch.line.midpoint",
"sketch.rectangle.two_point",
"sketch.rectangle.center",
"sketch.rectangle.three_point",
"sketch.rectangle.rounded",
"sketch.circle.center",
"sketch.circle.two_point",
"sketch.circle.three_point",
"sketch.arc.center_point",
"sketch.ellipse",
"sketch.slot.center_to_center",
"sketch.slot.overall",
"sketch.slot.center_point",
"sketch.slot.arc_center",
"sketch.slot.arc_three_point",
"sketch.polygon.inscribed",
"sketch.polygon.circumscribed",
"sketch.polygon.edge",
];
/// A sketch creator as `command` names it: one `dimension_roles` knows.
#[derive(Clone, Debug, PartialEq)]
pub struct Creator(pub String);
impl<'de> Deserialize<'de> for Creator {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if crate::dimension_roles(&s).is_some() => Ok(Creator(s)),
Value::String(_) => Err(D::Error::custom(whole("unsupported sketch creator"))),
_ => Err(D::Error::custom(whole("`command` must name a supported sketch creator"))),
}
}
}
impl JsonSchema for Creator {
fn schema_name() -> Cow<'static, str> {
"Creator".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "A sketch creation command whose shape can be dimensioned.",
"type": "string",
"enum": CREATORS
})
}
}
/// The creator's own parameters: an object (`null` is refused).
#[derive(Clone, Debug, PartialEq)]
pub struct CreatorParams(pub Map<String, Value>);
impl<'de> Deserialize<'de> for CreatorParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::Object(o) => Ok(CreatorParams(o)),
_ => Err(D::Error::custom(whole("`params` must be an object"))),
}
}
}
impl JsonSchema for CreatorParams {
fn schema_name() -> Cow<'static, str> {
"CreatorParams".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "The parameters of the creator `command` names, as that command takes them. The creator checks them.",
"type": "object"
})
}
}
/// The sizes to place, by role. Each value is a number or an expression of at most 4096
/// characters, as `params::expr` reads it (`null` is refused).
#[derive(Clone, Debug, PartialEq)]
pub struct DimensionValues(pub Map<String, Value>);
impl<'de> Deserialize<'de> for DimensionValues {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let Value::Object(o) = Value::deserialize(d)? else {
return Err(D::Error::custom(whole("`dimensions` must be an object")));
};
for value in o.values() {
if params::expr(&json!({"value": value}), "value").is_none_or(|e| e.len() > 4096) {
return Err(D::Error::custom(whole("dimension values must be bounded expressions or finite numbers")));
}
}
Ok(DimensionValues(o))
}
}
impl JsonSchema for DimensionValues {
fn schema_name() -> Cow<'static, str> {
"DimensionValues".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let length = generator.subschema_for::<LengthExpr>();
let angle = generator.subschema_for::<AngleExpr>();
json_schema!({
"description": "The sizes to place, each by its role: a number (in the design's unit) or an expression, or `name = expression`, which also defines the user parameter `name`. The roles a creator takes are its own: a line or midpoint line takes `length` and `angle`; a rectangle `width` and `height`; a circle `diameter` or `radius` (not both); an arc by centre `radius` and `sweep`; an ellipse `major_radius` and `minor_radius`; a centre-to-centre, overall or centre-point slot `length` and `width`; a centre arc slot `radius`, `sweep` and `width`; a three-point arc slot `width`; an inscribed or circumscribed polygon `radius`; an edge polygon `radius` or `length` (not both). A role the creator does not take is refused.",
"type": "object",
"properties": {
"length": length,
"width": length,
"height": length,
"diameter": length,
"radius": length,
"major_radius": length,
"minor_radius": length,
"angle": angle,
"sweep": angle
},
"additionalProperties": length
})
}
}
/// Draw a shape and dimension it in one undo step. `command` names the sketch creator and
/// `params` its parameters; `dimensions` gives the sizes to place, by role, as driving
/// dimensions. With `as_drawn` every other size of the shape is placed at its size as drawn
/// (a size the sketch already determines is left out); with `locate` the shape is then placed
/// from the sketch origin with horizontal and vertical distances, only where each takes freedom
/// away. Either one makes `dimensions` optional. A size the sketch already has is placed as a
/// driven (reference) dimension rather than refused.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = draw_required)]
pub struct DrawDimensionedParams {
/// The sketch creation command to run: one of the listed creators.
#[serde(default, deserialize_with = "command")]
#[schemars(with = "Creator")]
pub command: Option<Creator>,
/// The creator's own parameters, as it takes them (`sketch` among them, to draw in a sketch
/// other than the active one). A `sweep` dimension replaces the creator's `sweep` and removes
/// its `end`.
#[serde(default, deserialize_with = "creator_params")]
#[schemars(with = "CreatorParams")]
pub params: Option<CreatorParams>,
/// The sizes to place, by role. Required unless `as_drawn` or `locate` is true.
#[serde(default, deserialize_with = "dimension_values")]
#[schemars(with = "DimensionValues")]
pub dimensions: Option<DimensionValues>,
/// Also dimension every other size of the shape at its size as drawn. A radius or diameter
/// given takes the place of the other (as an edge polygon's side length does its radius); a
/// circle otherwise takes its diameter. Default: false.
#[serde(default)]
pub as_drawn: Option<bool>,
/// Then place the shape from the sketch origin until nothing of it is left free: horizontal
/// and vertical distances from the origin to its points, centres first, each kept only where
/// it removes freedom, so a shape anchored to the origin or to placed geometry gets none. A
/// point on an axis goes onto that axis instead of getting a distance of 0. Default: false.
#[serde(default)]
pub locate: Option<bool>,
}
fn command<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Creator>, D::Error> {
Creator::deserialize(d).map(Some)
}
fn creator_params<'de, D: Deserializer<'de>>(d: D) -> Result<Option<CreatorParams>, D::Error> {
CreatorParams::deserialize(d).map(Some)
}
fn dimension_values<'de, D: Deserializer<'de>>(d: D) -> Result<Option<DimensionValues>, D::Error> {
DimensionValues::deserialize(d).map(Some)
}
/// `command` and `params` are always needed; `dimensions` unless `as_drawn` or `locate` is true.
fn draw_required(schema: &mut Schema) {
require(schema, "command");
require(schema, "params");
schema.insert(
"anyOf".into(),
json!([
{"required": ["dimensions"]},
{"required": ["as_drawn"], "properties": {"as_drawn": {"enum": [true]}}},
{"required": ["locate"], "properties": {"locate": {"enum": [true]}}}
]),
);
}
/// What `sketch.draw.dimensioned` returns: what the creator returned, then the dimensions placed.
#[derive(Debug, Serialize, JsonSchema)]
#[schemars(transform = creator_reply)]
pub struct DrawDimensionedResult {
/// What the creator returned, as that command returns it (`curves`, `constraints`, `sketch`,
/// `created`, and for some creators `sides`, `corners`, `centre_arc` or `support_circle`).
#[serde(flatten)]
pub drawn: Map<String, Value>,
/// The `sketch.dimension` reply of each dimension placed, by role.
pub dimensions: Map<String, Value>,
/// The names of the user parameters a typed `name = expression` defined. Only when there
/// are any.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub defined: Vec<String>,
/// With `locate`: the `sketch.dimension` or `sketch.constraint.coincident` replies of what
/// placed the shape, in order (empty when nothing was left to place).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub located: Option<Vec<Value>>,
/// In the warn parameter-check mode: what the call's parameters got wrong. When the call
/// itself is fine, the creator's own warnings about `params` are kept here.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
impl DrawDimensionedResult {
/// The creator's reply `drawn` with the dimensions placed. A key the creator's reply and this
/// result both have takes this result's value, as the handler always set them over it.
pub fn new(drawn: Value, dimensions: Map<String, Value>, defined: Vec<String>, located: Option<Vec<Value>>) -> Self {
let mut drawn = match drawn {
Value::Object(o) => o,
_ => Map::new(),
};
let mut param_warnings = Vec::new();
if let Some(Value::Array(warnings)) = drawn.get("param_warnings")
&& warnings.iter().all(Value::is_string)
{
param_warnings = warnings.iter().filter_map(|w| w.as_str().map(str::to_string)).collect();
drawn.remove("param_warnings");
}
drawn.remove("dimensions");
if !defined.is_empty() {
drawn.remove("defined");
}
if located.is_some() {
drawn.remove("located");
}
DrawDimensionedResult { drawn, dimensions, defined, located, param_warnings }
}
}
/// The keys most creators' replies have, which depend on the creator (none is always there).
fn creator_reply(schema: &mut Schema) {
let strings = json!({"type": "array", "items": {"type": "string"}});
let properties = json!({
"curves": strings,
"constraints": strings,
"sketch": {"type": "object", "description": "The sketch's solve: `{solved, dof}`."},
"created": {"type": "object", "description": "What the creator added: `{points, curves, constraints, dimensions}`."},
"sides": {"type": "object", "description": "A rectangle's sides {bottom, right, top, left}, line ids."},
"corners": {"type": "object", "description": "A rectangle's corners {bottom_left, bottom_right, top_right, top_left}, point ids."},
"centre_arc": {"type": "string", "description": "A centre arc slot's middle arc."},
"support_circle": {"type": "string", "description": "A polygon's construction circle."}
});
if let (Some(Value::Object(existing)), Value::Object(extra)) = (schema.get_mut("properties"), properties) {
for (k, v) in extra {
existing.entry(k).or_insert(v);
}
}
}
#[cfg(test)]
#[path = "sketch_dimensioned_tests.rs"]
mod tests;
@@ -1,234 +0,0 @@
use serde_json::{Value, json};
use super::{CREATORS, DrawDimensionedParams, DrawDimensionedResult};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
const ID: &str = "sketch.draw.dimensioned";
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn scene(level: ParamCheck) -> Session {
let mut s = Session { param_check: ParamCheck::Off, ..Session::default() };
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s.param_check = level;
s
}
fn matches(reply: &Value) {
let schema = output_schema_for::<DrawDimensionedResult>();
let schema = schema.as_value();
assert!(conforms(schema, schema, reply), "{reply} doesn't match {schema}");
assert!(reply.get("param_warnings").is_none(), "{reply}");
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<DrawDimensionedParams>(), &["as_drawn", "command", "dimensions", "locate", "params"]);
}
/// The creators in the schema are exactly the ones `dimension_roles` knows.
#[test]
fn the_creator_list_is_the_one_dimension_roles_has() {
for c in crate::cmd::command_specs() {
assert_eq!(crate::dimension_roles(c.id).is_some(), CREATORS.contains(&c.id), "{}", c.id);
}
for creator in CREATORS {
assert!(crate::cmd::find_command(creator).is_some(), "{creator}");
assert!(crate::dimension_roles(creator).is_some(), "{creator}");
}
}
#[test]
fn strict_keeps_todays_errors() {
let line = json!({"points": [[0, 0], [10, 0]]});
assert_strict_keeps_todays_errors(&[
(ID, json!({})),
(ID, json!({"command": null, "params": line, "dimensions": {"length": 5}})),
(ID, json!({"command": 5, "params": line, "dimensions": {"length": 5}})),
(ID, json!({"command": "sketch.point", "params": line, "dimensions": {"length": 5}})),
(ID, json!({"command": "", "params": line, "dimensions": {"length": 5}})),
(ID, json!({"command": "sketch.line", "dimensions": {"length": 5}})),
(ID, json!({"command": "sketch.line", "params": null, "dimensions": {"length": 5}})),
(ID, json!({"command": "sketch.line", "params": [1], "dimensions": {"length": 5}})),
(ID, json!({"command": "sketch.line", "params": line})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": null})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": null, "locate": true})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": [1]})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": null}})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": true}})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": ""}})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": "x".repeat(4097)}})),
(ID, json!({"command": "sketch.line", "params": line, "dimensions": {"length": [5]}})),
]);
}
#[test]
fn the_check_names_what_is_wrong_with_a_key() {
let said = |p: Value| issues(ID, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
let line = json!({"points": [[0, 0], [10, 0]]});
assert_eq!(said(json!({"command": "sketch.point", "params": line})), ["unsupported sketch creator"]);
assert_eq!(said(json!({"command": 3, "params": line})), ["`command` must name a supported sketch creator"]);
assert_eq!(said(json!({"command": "sketch.line", "params": 1})), ["`params` must be an object"]);
assert_eq!(said(json!({"command": "sketch.line", "params": line, "dimensions": 1})), ["`dimensions` must be an object"]);
assert_eq!(
said(json!({"command": "sketch.line", "params": line, "dimensions": {"length": []}})),
["dimension values must be bounded expressions or finite numbers"]
);
assert_eq!(said(json!({"command": "sketch.line", "params": line, "dimensions": {"length": 5}, "lenght": 1})).len(), 1);
// Left out, the handler's message is what a caller gets, not the check's.
assert!(said(json!({})).is_empty());
}
/// The types read each key as the handler does, and the schema takes what the handler takes.
#[test]
fn the_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<DrawDimensionedParams>();
let schema = schema.as_value();
let line = json!({"points": [[0, 0], [10, 0]]});
for (p, takes) in [
(json!({"command": "sketch.line", "params": line, "dimensions": {"length": 5, "angle": "30 deg"}}), true),
(json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 2}, "dimensions": {"diameter": "width = 20"}}), true),
(json!({"command": "sketch.line", "params": line, "as_drawn": true}), true),
(json!({"command": "sketch.line", "params": line, "locate": true}), true),
(json!({"command": "sketch.line", "params": line, "as_drawn": false, "locate": true}), true),
(json!({"command": "sketch.line", "params": line}), false),
(json!({"command": "sketch.line", "params": line, "as_drawn": false}), false),
(json!({"params": line, "as_drawn": true}), false),
(json!({"command": "sketch.line", "as_drawn": true}), false),
(json!({"command": "sketch.point", "params": line, "as_drawn": true}), false),
(json!({"command": "sketch.line", "params": line, "dimensions": {"length": []}}), false),
] {
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
// The type agrees with the schema about the keys it reads.
if takes {
assert!(issues(ID, &p).is_empty(), "{p}");
}
}
}
/// Every creator, in every form the tests use (typed, as drawn, located), in Warn: the reply
/// matches the output schema and carries no warning, so the contract accepts what works today.
#[test]
fn replies_match_the_output_schema() {
let calls = [
("sketch.line", json!({"points": [[0, 0], [10, 3]]}), json!({"length": 12, "angle": "20"})),
("sketch.line.midpoint", json!({"mid": [3, 4], "end": [9, 12]}), json!({"length": 12})),
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 5]}), json!({"width": 20, "height": "h = 8"})),
("sketch.rectangle.center", json!({"center": [0, 0], "corner": [5, 5]}), json!({"width": 20})),
("sketch.rectangle.rounded", json!({"p0": [0, 0], "p1": [20, 10], "radius": 2}), json!({"width": 30})),
("sketch.circle.center", json!({"center": [0, 0], "radius": 3}), json!({"diameter": 20})),
("sketch.circle.center", json!({"center": [0, 0], "radius": 3}), json!({"radius": "r = 4"})),
("sketch.ellipse", json!({"center": [0, 0], "major": [10, 6], "minor_radius": 4}), json!({"major_radius": 12})),
("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 5, "sides": 6}), json!({"radius": 8})),
("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [5, 0], "sides": 6}), json!({"length": 7})),
];
for (command, params, dimensions) in calls {
for extra in [json!({}), json!({"as_drawn": true}), json!({"as_drawn": true, "locate": true}), json!({"locate": true})] {
let mut s = scene(ParamCheck::Warn);
let mut p = json!({"command": command, "params": params, "dimensions": dimensions});
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
let reply = s.execute(ID, &p).unwrap_or_else(|e| panic!("{p}: {e}"));
matches(&reply);
assert!(reply["dimensions"].is_object(), "{reply}");
assert_eq!(reply.get("located").is_some(), p.get("locate") == Some(&json!(true)), "{reply}");
}
}
// Typed values alone (no flags), a definition and `locate`: `defined` and `located` appear.
let mut s = scene(ParamCheck::Warn);
let reply = run(
&mut s,
ID,
json!({"command": "sketch.circle.center", "params": {"center": [3, 4], "radius": 3}, "dimensions": {"diameter": "w = 20"}, "locate": true}),
);
matches(&reply);
assert_eq!(reply["defined"], json!(["w"]), "{reply}");
assert!(reply["located"].as_array().is_some_and(|l| !l.is_empty()), "{reply}");
}
/// A drawing for each creator, as the handler's own tests use.
fn sample(creator: &str) -> Value {
match creator {
"sketch.line" => json!({"points":[[0,0],[6,8]]}),
"sketch.line.midpoint" => json!({"mid":[3,4],"end":[9,12]}),
"sketch.rectangle.two_point" => json!({"p0":[0,0],"p1":[30,20]}),
"sketch.rectangle.center" => json!({"center":[0,0],"corner":[15,10]}),
"sketch.rectangle.three_point" => json!({"p0":[0,0],"p1":[30,10],"p2":[25,25]}),
"sketch.rectangle.rounded" => json!({"p0":[0,0],"p1":[30,20],"radius":3}),
"sketch.circle.center" => json!({"center":[0,0],"radius":5}),
"sketch.circle.two_point" => json!({"p0":[0,0],"p1":[10,0]}),
"sketch.circle.three_point" => json!({"p0":[0,0],"p1":[10,0],"p2":[5,5]}),
"sketch.arc.center_point" => json!({"center":[0,0],"start":[10,0],"end":[0,10]}),
"sketch.ellipse" => json!({"center":[0,0],"major":[10,6],"minor_radius":4}),
"sketch.slot.center_to_center" | "sketch.slot.overall" => json!({"p0":[2,3],"p1":[22,3],"width":4}),
"sketch.slot.center_point" => json!({"center":[12,3],"end":[22,3],"width":4}),
"sketch.slot.arc_center" => json!({"center":[0,0],"start":[20,0],"end":[0,20],"width":6}),
"sketch.slot.arc_three_point" => json!({"start":[20,0],"through":[14.142136,14.142136],"end":[0,20],"width":6}),
"sketch.polygon.inscribed" | "sketch.polygon.circumscribed" => json!({"center":[4,5],"radius":10,"sides":6}),
"sketch.polygon.edge" => json!({"p0":[0,0],"p1":[10,0],"sides":6}),
other => panic!("{other}: add a sample drawing for it"),
}
}
/// Every creator, as drawn and located, in Warn: a reply that matches, with no warning.
#[test]
fn every_creator_replies_as_drawn_and_located() {
for creator in CREATORS {
for flags in [json!({"as_drawn": true}), json!({"locate": true}), json!({"as_drawn": true, "locate": true})] {
let mut s = scene(ParamCheck::Warn);
let mut p = json!({"command": creator, "params": sample(creator)});
p.as_object_mut().into_iter().for_each(|o| o.extend(flags.as_object().cloned().unwrap_or_default()));
let reply = s.execute(ID, &p).unwrap_or_else(|e| panic!("{p}: {e}"));
matches(&reply);
assert!(reply["dimensions"].is_object() && reply["curves"].is_array(), "{reply}");
}
}
}
#[test]
fn every_creator_passes_the_check() {
for creator in CREATORS {
// The creator refuses empty parameters in its own words; the check says nothing.
assert!(issues(ID, &json!({"command": creator, "params": {}, "as_drawn": true})).is_empty(), "{creator}");
}
}
/// A misspelt key still runs in Warn and says so, with a reply that matches; strict refuses it.
#[test]
fn a_misspelt_key_is_reported_in_warn() {
let p = json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 3}, "dimensions": {"diameter": 10}, "as_draw": true});
let mut s = scene(ParamCheck::Warn);
let reply = run(&mut s, ID, p.clone());
assert!(reply["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{reply}");
let schema = output_schema_for::<DrawDimensionedResult>();
assert!(conforms(schema.as_value(), schema.as_value(), &reply), "{reply}");
assert!(scene(ParamCheck::Strict).execute(ID, &p).is_err());
}
/// A misspelt key of the creator and one of the call itself are both reported in Warn.
#[test]
fn warn_keeps_the_creators_warnings_beside_its_own() {
let p = json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 3, "constructon": true}, "dimensions": {"diameter": 10}, "as_draw": true});
let reply = run(&mut scene(ParamCheck::Warn), ID, p);
let warnings = reply["param_warnings"].as_array().unwrap_or_else(|| panic!("{reply}"));
assert_eq!(warnings.len(), 2, "{reply}");
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("constructon"))), "{reply}");
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("as_draw"))), "{reply}");
}
/// The reply is what the handler returned before: the creator's keys, `dimensions`, `defined`,
/// `located`, and the creator's own `param_warnings` kept.
#[test]
fn the_reply_keeps_the_creators_keys() {
let creator = json!({"curves": ["c1"], "sketch": {"solved": true, "dof": 1}, "param_warnings": ["a"], "dimensions": "old", "defined": ["x"]});
let none = DrawDimensionedResult::new(creator.clone(), serde_json::Map::new(), Vec::new(), None);
let json = serde_json::to_value(&none).unwrap_or_default();
assert_eq!(json, json!({"curves": ["c1"], "sketch": {"solved": true, "dof": 1}, "param_warnings": ["a"], "dimensions": {}, "defined": ["x"]}));
let all = DrawDimensionedResult::new(creator, serde_json::Map::new(), vec!["w".into()], Some(vec![json!(1)]));
let json = serde_json::to_value(&all).unwrap_or_default();
assert_eq!(json["defined"], json!(["w"]));
assert_eq!(json["located"], json!([1]));
}
-45
View File
@@ -1,45 +0,0 @@
//! Contracts and result types for the command in `cmd/file.rs` that lists the installed slicers:
//! `file.slicers` (#13306).
//!
//! The handler reads no parameter, so its contract has no key: a key in a call is reported in
//! warn and refused in strict, and a call without one runs as it always did.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// `file.slicers` takes no parameters.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SlicersParams {}
/// What `file.slicers` returns: the slicers found on this machine, known ones in a fixed order
/// (empty in the browser). Pass an entry's `id`, its `name` or its `program` as
/// `file.send_to_slicer`'s or `file.save_mesh`'s `slicer`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SlicersResult {
/// The slicers installed on this machine.
pub slicers: Vec<SlicerInfo>,
/// 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>,
}
/// One installed slicer.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SlicerInfo {
/// `prusa`, `bambu`, … for the known ones, `custom` for a program the user picked. When one
/// slicer is installed more than once, each install has its own id (`prusa#<where it was
/// found>`).
pub id: String,
/// The name to show, which says which install it is when there are several.
pub name: String,
/// The program that is started, as a path or a command name; null when there is none.
pub program: Option<String>,
/// The path of the application's icon file (a PNG, a macOS `.icns` file or a Windows
/// program); null when none was found.
pub icon: Option<String>,
}
#[cfg(test)]
#[path = "slicers_tests.rs"]
mod tests;
-108
View File
@@ -1,108 +0,0 @@
use serde_json::{Value, json};
use super::{SlicerInfo, SlicersParams, SlicersResult};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<SlicersParams>(), &[]);
}
/// The handler reads no key, so there is no value of one to refuse: strict refuses a key it does
/// not know, with the check's own message, and the handler never refuses anything.
#[test]
fn the_type_reads_no_key_as_the_handler_reads_none() {
assert!(serde_json::from_value::<SlicersParams>(json!({})).is_ok());
// An unknown key is the key check's to report, not the type's.
assert!(serde_json::from_value::<SlicersParams>(json!({"path": "x"})).is_ok());
let mut s = Session { param_check: ParamCheck::Off, ..Session::default() };
for p in [json!({}), json!({"anything": 1, "path": null})] {
assert!(s.execute("file.slicers", &p).is_ok(), "{p}");
}
let schema = input_schema_for::<SlicersParams>();
assert!(conforms(schema.as_value(), schema.as_value(), &json!({})));
assert!(!conforms(schema.as_value(), schema.as_value(), &json!({"slicer": "prusa"})));
}
/// Strict has nothing of today's to keep: no call of `file.slicers` is refused by the handler.
/// Called with no key it runs, as it always did, and a misspelt one is the check's to refuse.
#[test]
fn strict_keeps_todays_behaviour() {
assert_strict_keeps_todays_errors(&[]);
let mut s = Session { param_check: ParamCheck::Strict, ..Session::default() };
assert!(s.execute("file.slicers", &json!({})).is_ok());
assert!(s.execute("file.slicers", &json!({"slicer": "prusa"})).is_err());
}
/// A call with no key has nothing to warn about; one with a key is told it is unknown.
#[test]
fn the_check_names_an_unknown_key() {
assert!(issues("file.slicers", &json!({})).is_empty());
assert_eq!(issues("file.slicers", &json!({"slicer": "prusa"})).len(), 1);
}
fn matches(reply: &Value) {
let schema = output_schema_for::<SlicersResult>();
assert!(conforms(schema.as_value(), schema.as_value(), reply), "{reply} doesn't match {schema:?}");
assert!(reply.get("param_warnings").is_none(), "{reply}");
}
/// What the entries serialize to: every key is there, and a missing program or icon is null, as
/// the handler has always answered.
#[test]
fn an_entry_serializes_with_null_for_what_is_missing() {
let full = SlicerInfo { id: "prusa".into(), name: "PrusaSlicer".into(), program: Some("/usr/bin/prusa".into()), icon: Some("/i.png".into()) };
assert_eq!(
serde_json::to_value(full).unwrap_or_default(),
json!({"id": "prusa", "name": "PrusaSlicer", "program": "/usr/bin/prusa", "icon": "/i.png"})
);
let bare = SlicerInfo { id: "x".into(), name: "X".into(), program: None, icon: None };
assert_eq!(serde_json::to_value(bare).unwrap_or_default(), json!({"id": "x", "name": "X", "program": null, "icon": null}));
let none = SlicersResult { slicers: Vec::new(), param_warnings: Vec::new() };
assert_eq!(serde_json::to_value(none).unwrap_or_default(), json!({"slicers": []}));
}
/// The real command in Warn, on this machine's own list, then on made-up ones (one with an icon
/// and a program, one with neither): each reply matches the output schema and carries no
/// warning. A misspelt key still runs, says so, and the reply still matches.
#[cfg(unix)]
#[test]
fn replies_match_the_output_schema() {
use crate::slicers::{IconSource, Slicer, TEST_DETECTED};
use crate::tests::mesh_convert::{CHOSEN_PROGRAMS, MadeUp};
let _turn = CHOSEN_PROGRAMS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _reset = MadeUp;
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let own = s.execute("file.slicers", &json!({})).unwrap_or_else(|e| panic!("{e}"));
assert!(own["slicers"].is_array(), "{own}");
matches(&own);
let entry = |id: &str, command: Vec<String>, icon: Option<IconSource>| Slicer {
id: id.into(),
name: format!("{id} name"),
command,
icon,
prusa_major: None,
};
*TEST_DETECTED.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(vec![
entry("prusa", vec!["/opt/prusa".into(), "--arg".into()], Some(IconSource::Image("/icons/prusa.png".into()))),
entry("bare", Vec::new(), None),
]);
let listed = s.execute("file.slicers", &json!({})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(
listed,
json!({"slicers": [
{"id": "prusa", "name": "prusa name", "program": "/opt/prusa", "icon": "/icons/prusa.png"},
{"id": "bare", "name": "bare name", "program": null, "icon": null},
]})
);
matches(&listed);
let warned = s.execute("file.slicers", &json!({"slicr": true})).unwrap_or_else(|e| panic!("{e}"));
assert!(warned["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{warned}");
assert_eq!(warned["slicers"], listed["slicers"]);
let schema = output_schema_for::<SlicersResult>();
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
}
-116
View File
@@ -1,116 +0,0 @@
//! Contracts for Split Body and Split Face in `cmd/features.rs` (#13127). Both reply with the plain
//! feature reply, [`super::features::FeatureResult`].
//!
//! Each takes one cutting tool, and the handler reads the first it finds in this order: a face of
//! a body (`tool`), a sketch, then a plane. A key that is `null` counts as not given. The
//! contracts check the form of every tool key a call gives, so in strict a call that gives a good
//! `tool` and a malformed `plane` is refused, where today the plane is ignored. A call that gives
//! no tool is the handler's to refuse, with its message for a missing `plane`; whether a body,
//! sketch, line or plane exists is the handler's to check too.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use serde_json::Value;
use super::check::whole;
use super::types::{FaceRefs, FeatureCommon, PlaneParam, SketchRef, ToolFace, feature_common};
/// Cut a body into pieces along a plane, a face of another body (its surface extended), or a
/// straight sketch line (along the sketch's normal). Each piece becomes a body.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SplitBodyParams {
/// The body to split, by name.
#[serde(deserialize_with = "body")]
pub body: String,
/// A face of a body to split along, its surface extended. Read when neither `sketch` nor
/// `line` is given.
#[serde(default)]
pub tool: Option<ToolFace>,
/// With `line`: the sketch, by feature id (not its name), whose line splits the body.
#[serde(default)]
pub sketch: Option<SketchFeatureId>,
/// With `sketch`: a straight line's id; the body is split by the plane through it along the
/// sketch's normal, which follows the line when the sketch changes.
#[serde(default, deserialize_with = "line")]
pub line: Option<String>,
/// The plane to split across. Read when none of `tool`, `sketch` and `line` is given; then it
/// is required.
#[serde(default)]
pub plane: Option<PlaneParam>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// Divide faces of a body along a plane, a face of another body (its surface extended), or a
/// sketch's curves (along the sketch's normal), without cutting the body: each face becomes
/// several, and the body keeps its shape.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SplitFaceParams {
/// The faces to split.
pub faces: FaceRefs,
/// The body the faces are on, by name: where faces of several bodies meet at a point, whose
/// face it is. Default: the body each point or name finds.
#[serde(default)]
pub body: Option<String>,
/// A face of a body to split along, its surface extended.
#[serde(default)]
pub tool: Option<ToolFace>,
/// A sketch, by id or name, whose curves split the faces along its normal. Read when no
/// `tool` is given.
#[serde(default)]
pub sketch: Option<SketchRef>,
/// The plane to split along. Read when neither `tool` nor `sketch` is given; then it is
/// required.
#[serde(default)]
pub plane: Option<PlaneParam>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// Split Body's `body`, with the handler's message for anything that isn't text.
fn body<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(s),
_ => Err(D::Error::custom(whole("`body` is required"))),
}
}
/// Split Body's `line`, with the handler's message for anything that isn't text. `null` is no
/// line, as the handler reads it.
fn line<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Value::deserialize(d)? {
Value::Null => Ok(None),
Value::String(s) => Ok(Some(s)),
_ => Err(D::Error::custom(whole("`line` must name a line of the sketch"))),
}
}
/// Split Body's `sketch`: a sketch's feature id, a whole number from 0 (read with
/// `Value::as_u64`). Unlike Split Face's [`SketchRef`], a name is refused, with the handler's
/// message. Whether a sketch has that id is the handler's to check.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SketchFeatureId(pub u64);
impl<'de> Deserialize<'de> for SketchFeatureId {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?.as_u64().map(SketchFeatureId).ok_or_else(|| D::Error::custom(whole("`sketch` must be a sketch feature id")))
}
}
impl JsonSchema for SketchFeatureId {
fn schema_name() -> Cow<'static, str> {
"SketchFeatureId".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "A sketch's feature id.", "type": "integer", "minimum": 0, "maximum": u64::MAX})
}
}
#[cfg(test)]
#[path = "split_tests.rs"]
mod tests;
-210
View File
@@ -1,210 +0,0 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use super::{SplitBodyParams, SplitFaceParams};
use crate::Session;
use crate::schema::features::FeatureResult;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(
input_schema_for::<SplitBodyParams>(),
&["body", "body_name", "body_names", "line", "name", "participants", "plane", "sketch", "tool"],
);
assert_keys(
input_schema_for::<SplitFaceParams>(),
&["body", "body_name", "body_names", "faces", "name", "participants", "plane", "sketch", "tool"],
);
}
/// The design the split calls work on, at `level`, and the sketch ids `Cuts` and `Ring`:
/// - `B`, a 40 x 30 x 20 mm box from the origin (its top at z = 20);
/// - `Tool`, a box whose face at y = 10, extended, crosses `B`'s top (the point [20, 10, 27] is
/// on it);
/// - the sketch `Cuts` on XY, two lines across `B` at x = 10 (id `cut`) and x = 30;
/// - the sketch `Ring` on XY, a circle `c` beside `B`, so no straight line;
/// - the construction plane `Mid`, 20 mm along X from YZ, through the middle of `B`.
fn design(level: ParamCheck) -> (Session, Value, Value, Value) {
let mut s = Session { param_check: level, ..Session::default() };
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
run("solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "B"}));
run("solid.box", json!({"corner": [-5, 10, 25], "length": 50, "width": 5, "height": 5, "body_name": "Tool"}));
let cuts = run("sketch.create", json!({"plane": "XY", "name": "Cuts"}))["sketch"].clone();
run("sketch.line", json!({"points": [[10, -5], [10, 35]], "ids": ["cut"]}));
run("sketch.line", json!({"points": [[30, -5], [30, 35]]}));
run("sketch.finish", json!({}));
let ring = run("sketch.create", json!({"plane": "XY", "name": "Ring"}))["sketch"].clone();
run("sketch.circle.center", json!({"center": [60, 60], "radius": 2, "id": "c"}));
run("sketch.finish", json!({}));
let mid = run("construct.plane.offset", json!({"base": "YZ", "offset": 20, "name": "Mid"}))["feature"].clone();
assert!(cuts.is_u64() && ring.is_u64() && mid.is_u64(), "{cuts} {ring} {mid}");
(s, cuts, ring, mid)
}
/// Calls that fail today give the same message in strict, each on a fresh [`design`]: what the
/// contracts check (the body, the faces, each tool's form) and what they leave to the handler (a
/// body, sketch, line, plane or face the design lacks, a sketch line that isn't straight, a tool
/// point off the tool, and no tool at all). Split Face without `faces` is left out: today it says
/// "`faces` must be a list of points or face names", and strict "`faces` is required", as for
/// every missing key.
#[test]
fn strict_keeps_todays_errors() {
let (_, cuts, ring, _) = design(ParamCheck::Off);
let cases = [
("solid.split_body", json!({})),
("solid.split_body", json!({"plane": "XY"})),
("solid.split_body", json!({"body": 5, "plane": "XY"})),
("solid.split_body", json!({"body": null, "plane": "XY"})),
("solid.split_body", json!({"body": "Nope", "plane": "XY"})),
("solid.split_body", json!({"body": "B"})),
("solid.split_body", json!({"body": "B", "plane": null, "tool": null, "sketch": null, "line": null})),
("solid.split_body", json!({"body": "B", "plane": true})),
("solid.split_body", json!({"body": "B", "plane": 7.5})),
("solid.split_body", json!({"body": "B", "plane": -1})),
("solid.split_body", json!({"body": "B", "plane": "Nowhere"})),
("solid.split_body", json!({"body": "B", "plane": "Cuts"})),
("solid.split_body", json!({"body": "B", "plane": {"origin": [0, 0, 0]}})),
("solid.split_body", json!({"body": "B", "plane": {"origin": "here", "normal": [1, 0, 0]}})),
("solid.split_body", json!({"body": "B", "plane": {"origin": [0, 0, 0], "normal": [1, 0, 0]}})),
("solid.split_body", json!({"body": "B", "tool": "Tool"})),
("solid.split_body", json!({"body": "B", "tool": {"point": [20, 10, 27]}})),
("solid.split_body", json!({"body": "B", "tool": {"body": 5, "point": [20, 10, 27]}})),
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool"}})),
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool", "point": [20, 10]}})),
("solid.split_body", json!({"body": "B", "tool": {"body": "Nope", "point": [20, 10, 27]}})),
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool", "point": [500, 500, 500]}})),
("solid.split_body", json!({"body": "B", "sketch": "Cuts", "line": "cut"})),
("solid.split_body", json!({"body": "B", "sketch": -1, "line": "cut"})),
("solid.split_body", json!({"body": "B", "sketch": 999, "line": "cut"})),
("solid.split_body", json!({"body": "B", "sketch": cuts})),
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": null})),
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": 5})),
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": "nope"})),
("solid.split_body", json!({"body": "B", "sketch": ring, "line": "c"})),
("solid.split_body", json!({"body": "B", "line": "cut"})),
("solid.split_face", json!({"faces": 5, "plane": "XY"})),
("solid.split_face", json!({"faces": [], "plane": "XY"})),
("solid.split_face", json!({"faces": [[10, 15]], "plane": "XY"})),
("solid.split_face", json!({"faces": [[500, 500, 500]], "plane": "Mid"})),
("solid.split_face", json!({"faces": ["NoSuchFace"], "plane": "Mid"})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "body": "Nope", "plane": "Mid"})),
("solid.split_face", json!({"faces": [[10, 15, 20]]})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": null, "tool": null, "sketch": null})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": true})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": "Nowhere"})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"normal": [0, 0, 0]}})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "tool": "Tool"})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "tool": {"body": "Tool", "point": "here"}})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "tool": {"body": "Nope", "point": [20, 10, 27]}})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "sketch": true})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "sketch": "Nope"})),
];
for (id, p) in cases {
let error = |level: ParamCheck| design(level).0.execute(id, &p).err().map(|e| e.to_string());
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{id} {p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{id} {p}");
}
}
/// #13127: each contract takes what its input schema allows: each call's check finds no issue
/// exactly when the call matches the schema. A call that gives no tool matches it; the handler
/// refuses it.
#[test]
fn the_check_takes_what_the_schema_allows() {
let body = input_schema_for::<SplitBodyParams>();
let face = input_schema_for::<SplitFaceParams>();
for (id, schema, p, takes) in [
("solid.split_body", &body, json!({"body": "B", "plane": "XY"}), true),
("solid.split_body", &body, json!({"body": "B", "plane": 3}), true),
(
"solid.split_body",
&body,
json!({"body": "B", "plane": {"origin": [0, "w", 0], "normal": [1, 0, 0]}, "name": "S", "body_names": ["L", "R"]}),
true,
),
("solid.split_body", &body, json!({"body": "B", "tool": {"body": "T", "point": [0, 0, 0]}}), true),
("solid.split_body", &body, json!({"body": "B", "tool": {"body": "T", "point": {"x": 0, "y": 0, "z": 0}, "side": 1}}), true),
("solid.split_body", &body, json!({"body": "B", "sketch": 4, "line": "cut", "participants": ["*"]}), true),
("solid.split_body", &body, json!({"body": "B", "sketch": null, "line": null, "tool": null, "plane": null}), true),
("solid.split_body", &body, json!({"body": "B", "line": "cut"}), true),
("solid.split_body", &body, json!({"body": ""}), true),
("solid.split_body", &body, json!({}), false),
("solid.split_body", &body, json!({"body": 5, "plane": "XY"}), false),
("solid.split_body", &body, json!({"body": "B", "plane": true}), false),
("solid.split_body", &body, json!({"body": "B", "plane": -1}), false),
("solid.split_body", &body, json!({"body": "B", "plane": {"origin": [0, 0, 0]}}), false),
("solid.split_body", &body, json!({"body": "B", "tool": "T"}), false),
("solid.split_body", &body, json!({"body": "B", "tool": {"body": "T"}}), false),
("solid.split_body", &body, json!({"body": "B", "tool": {"body": 1, "point": [0, 0, 0]}}), false),
("solid.split_body", &body, json!({"body": "B", "sketch": "Cuts", "line": "cut"}), false),
("solid.split_body", &body, json!({"body": "B", "sketch": -2, "line": "cut"}), false),
("solid.split_body", &body, json!({"body": "B", "sketch": 4, "line": 7}), false),
("solid.split_body", &body, json!({"body": "B", "plane": "XY", "faces": [[0, 0, 0]]}), false),
("solid.split_body", &body, json!({"body": "B", "plane": "XY", "body_name": 3}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "plane": "XY"}), true),
(
"solid.split_face",
&face,
json!({"faces": ["F1", {"point": [1, 2, 3]}], "body": "B", "plane": {"x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}),
true,
),
("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]], "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),
("solid.split_face", &face, json!({"faces": [], "plane": "XY"}), false),
("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]], "tool": {"point": [1, 2, 3]}}), false),
("solid.split_face", &face, json!({"faces": [[1, 2, 3]], "plane": "XY", "line": "cut"}), false),
] {
let schema = schema.as_value();
let issues = crate::schema::issues(id, &p);
assert_eq!(issues.is_empty(), takes, "{id} {p}: {issues:?}");
assert_eq!(conforms(schema, schema, &p), takes, "{id} {p}");
}
}
/// #13127: both commands reply with `FeatureResult`, with the keys they gave before, for each
/// tool: in Warn, without warnings, so their contracts take each call. Split Face changes `B` and
/// makes nothing; Split Body cuts `B` in two, so two bodies are made or changed between them.
#[test]
fn split_body_and_split_face_reply_with_feature_result() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
let before = BTreeSet::from(["feature", "name", "bodies", "created_bodies", "changed_bodies", "removed_bodies", "warning", "ms"]);
let (_, cuts, _, mid) = design(ParamCheck::Warn);
let calls = [
("solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}})),
("solid.split_face", json!({"faces": [[10, 15, 20]], "body": "B", "plane": "Mid", "name": "Halves"})),
("solid.split_face", json!({"faces": [[20, 5, 20]], "body": "B", "tool": {"body": "Tool", "point": [20, 10, 27]}})),
("solid.split_face", json!({"faces": [[20, 15, 20]], "sketch": "Cuts", "tool": null, "plane": null})),
("solid.split_face", json!({"faces": [[20, 15, 20]], "sketch": cuts.clone(), "participants": ["B"]})),
("solid.split_body", json!({"body": "B", "plane": {"origin": [20, 0, "0 mm"], "normal": [1, 0, 0]}})),
("solid.split_body", json!({"body": "B", "plane": mid, "name": "Halves"})),
("solid.split_body", json!({"body": "B", "plane": "Mid", "tool": null, "sketch": null, "line": null})),
("solid.split_body", json!({"body": "B", "tool": {"body": "Tool", "point": [20, 10, 27]}})),
("solid.split_body", json!({"body": "B", "sketch": cuts, "line": "cut"})),
];
for (id, p) in calls {
let (mut s, ..) = design(ParamCheck::Warn);
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
let keys: BTreeSet<&str> = r.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, before, "{id} {p}: {r}");
let count = |key: &str| r[key].as_array().map_or(0, Vec::len);
if id == "solid.split_face" {
assert_eq!((count("created_bodies"), r["changed_bodies"].clone()), (0, json!(["B"])), "{id} {p}: {r}");
} else {
assert!(count("created_bodies") >= 1 && count("created_bodies") + count("changed_bodies") == 2, "{id} {p}: {r}");
}
}
}
-75
View File
@@ -7,7 +7,6 @@ use serde_json::{Value, json};
use super::check::schema_keys;
use super::features::{BoxParams, ExtrudeParams};
use super::types::{RevolveAxis, RevolveConventionParam, RevolveDirection, RevolveToPoint};
use super::*;
use crate::Session;
@@ -1137,80 +1136,6 @@ fn axis_lines_match_the_axis_reader() {
}
}
/// A revolve's `axis` is accepted as `features::axis_param` reads it before it looks anything up,
/// and its schema says the same, apart from what a schema can't say (below).
#[test]
fn revolve_axes_match_the_axis_reader() {
let schema = input_schema_for::<RevolveAxis>();
let schema = schema.as_value();
for (v, accepted) in [
(json!("y"), true),
(json!("Z"), true),
(json!("z"), true),
(json!("l1"), true),
(json!("Spindle"), true),
(json!(""), true),
(json!({"dir": [0, 0, 1]}), true),
(json!({"dir": [0, 0, 0]}), true),
(json!({"origin": [1, 2, 3], "dir": {"x": 0, "y": 1, "z": 0}}), true),
(json!({"origin": ["1 mm", 0, {"x": 1}], "dir": [0, 0, 1]}), false),
(json!({"origin": ["1 mm", "w / 2", 0], "dir": [0, 0, 1]}), true),
(json!({"origin": {"x": 1, "y": 2, "z": "3 mm"}, "dir": [0, 0, 1]}), true),
(json!({"origin": "o", "dir": [0, 0, 1]}), false),
(json!({"origin": null, "dir": [0, 0, 1]}), false),
(json!({"origin": [1, 2], "dir": [0, 0, 1]}), false),
(json!({"origin": [0, 0, 0]}), false),
(json!({"dir": [0, 1]}), false),
(json!({"dir": "y"}), false),
(json!({"dir": [0, 0, 1], "type": "sketch_line", "extra": 1}), true),
(json!({"type": "world", "axis": "X"}), true),
(json!({"type": "world", "axis": "X", "origin": 5, "dir": "q"}), true),
(json!({"type": "world", "axis": "x"}), false),
(json!({"type": "world", "axis": 5}), false),
(json!({"type": "world"}), false),
(json!({"type": "world", "dir": [0, 0, 1]}), false),
(json!({"edge": [1, 2, 3]}), true),
(json!({"face": [1, 2, 3], "dir": [0, 0, 1]}), true),
(json!({"face": "x", "dir": [0, 0, 1]}), false),
(json!({"type": "face", "at": [1, 2, 3]}), true),
(json!({"type": "vertex", "at": [1, 2, 3]}), false),
(json!({"face": "x", "vertex": [1, 2, 3]}), false),
(json!({}), false),
(json!(5), false),
(json!(true), false),
(json!(null), false),
(json!([0, 0, 1]), false),
] {
assert_eq!(parse::<RevolveAxis>(v.clone()).is_some(), accepted, "{v}");
assert_eq!(conforms(schema, schema, &v), accepted, "schema {v}");
}
// Where the schema can't follow the reader: a saved edge is read whole by serde.
let v = json!({"type": "edge", "at": "x"});
assert!(parse::<RevolveAxis>(v.clone()).is_none() && conforms(schema, schema, &v), "{v}");
}
/// Revolve's `direction` and `convention` take what the handler reads, and its `to` is a point.
#[test]
fn revolve_keywords_match_the_handler() {
for (v, accepted) in
[(json!("symmetric"), true), (json!("SYMMETRIC"), true), (json!(" symmetric"), false), (json!("negative"), false), (json!(true), false)]
{
assert_eq!(parse::<RevolveDirection>(v.clone()).is_some(), accepted, "{v}");
let schema = input_schema_for::<RevolveDirection>();
assert_eq!(conforms(schema.as_value(), schema.as_value(), &v), accepted && v == json!("symmetric"), "schema {v}");
}
for (v, accepted) in [
(json!("sketch_normal"), true),
(json!("legacy_axis_start"), true),
(json!("Sketch_Normal"), false),
(json!("legacy_per_side"), false),
(json!(null), false),
] {
assert_eq!(parse::<RevolveConventionParam>(v.clone()).is_some(), accepted, "{v}");
}
assert!(parse::<RevolveToPoint>(json!([1, 2, 3])).is_some() && parse::<RevolveToPoint>(json!([1, 2])).is_none());
}
/// A construction plane's name is refused when `features::check_plane_name` refuses it. (The
/// schema can't say which names: see `PlaneName`.)
#[test]
+6 -180
View File
@@ -210,12 +210,6 @@ target_point!(
"must be a point [x, y, z] on the face or point to start from"
);
target_point!(
/// Revolve's `to`: a [`Point3`], with the handler's message.
RevolveToPoint,
"must be a point [x, y, z] to turn to"
);
/// Extrude's `faces`: 1 to 1024 points on coplanar faces of one body, read as the handler reads
/// them, with its messages.
#[derive(Clone, Debug, PartialEq)]
@@ -980,42 +974,6 @@ pick_list!(
"The edges, 1 to 1000."
);
/// A face of a body whose surface, extended, cuts like a plane: the `tool` of `solid.split_body`,
/// `solid.split_face` and `surface.trim`, which read `tool.body` and then `tool.point` with these
/// messages. Other keys in it are ignored, as the handlers ignore them. Whether the body exists
/// and the point is on one of its faces is the handler's to check.
#[derive(Clone, Debug, PartialEq)]
pub struct ToolFace {
pub body: String,
pub point: Vec3,
}
impl<'de> Deserialize<'de> for ToolFace {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
// In the handlers' order: the body (anything but an object has none), then the point.
let body = v.get("body").and_then(Value::as_str).ok_or_else(|| D::Error::custom(whole("`tool.body` must name a body")))?;
let point =
v.get("point").and_then(params::vec3).ok_or_else(|| D::Error::custom(whole("`tool.point` must be [x, y, z] on the tool face")))?;
Ok(ToolFace { body: body.to_string(), point })
}
}
impl JsonSchema for ToolFace {
fn schema_name() -> Cow<'static, str> {
"ToolFace".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "A face of a body, its surface extended: the body's name and a point in mm on the face.",
"type": "object",
"properties": {"body": {"type": "string"}, "point": point},
"required": ["body", "point"]
})
}
}
/// A sketch referred to by its feature id (a number) or its name, as `features::sketch_id` reads
/// it, with its message for a top-level key (`path_sketch`, `solid.split_face`'s `sketch`…);
/// for a nested one (a section's `sketch`) the message names the key as `sections[0].sketch`
@@ -1366,17 +1324,6 @@ impl JsonSchema for PlaneOrFace {
}
}
/// What `construct::model_axis_param` finds wrong with an edge or face axis before it looks
/// anything up in the design: a reference that isn't a picked or saved face or edge.
fn model_axis_problem(v: &Value) -> Option<String> {
use crate::cmd::GeoForm;
use solidcraft_doc::construct::GeoRef;
geo_problem(v).or_else(|| match crate::cmd::geo_form(v) {
Ok(GeoForm::Face(_) | GeoForm::Edge(_) | GeoForm::Saved(GeoRef::Face { .. } | GeoRef::Edge { .. })) => None,
_ => Some(solidcraft_doc::construct::MODEL_AXIS_REFS.to_string()),
})
}
/// The line `construct.plane.at_angle` turns about, as `features::axis_line` reads it: an edge or
/// face of the model (as `construct::model_axis_param` reads it), else X, Y, Z, a construction
/// axis's name or `{origin?, dir}`. Kept as given, for the handler. Whether a picked edge or face
@@ -1386,9 +1333,14 @@ pub struct AxisLine(pub Value);
impl<'de> Deserialize<'de> for AxisLine {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use crate::cmd::GeoForm;
use solidcraft_doc::construct::GeoRef;
let v = Value::deserialize(d)?;
let problem = if crate::cmd::is_model_axis(&v) {
model_axis_problem(&v)
geo_problem(&v).or_else(|| match crate::cmd::geo_form(&v) {
Ok(GeoForm::Face(_) | GeoForm::Edge(_) | GeoForm::Saved(GeoRef::Face { .. } | GeoRef::Edge { .. })) => None,
_ => Some(solidcraft_doc::construct::MODEL_AXIS_REFS.to_string()),
})
} else {
match &v {
Value::String(_) => None,
@@ -1428,132 +1380,6 @@ impl JsonSchema for AxisLine {
}
}
/// What `features::axis_param` says to an `axis` that is none of its forms.
const REVOLVE_AXIS_MESSAGE: &str =
"`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}";
/// What `features::axis_param` finds wrong with an object axis that isn't an edge or face of the
/// model, before it looks anything up in the design.
fn revolve_axis_object_problem(o: &Value) -> Option<String> {
if o.get("type").and_then(Value::as_str) == Some("world") {
let ok = o.get("axis").and_then(Value::as_str).is_some_and(|a| matches!(a, "X" | "Y" | "Z"));
return (!ok).then(|| whole("world axis requires `axis` to be X, Y or Z"));
}
if let Some(origin) = o.get("origin")
&& serde_json::from_value::<PointExpr3>(origin.clone()).is_err()
{
return Some(whole("`origin` must be a point [x, y, z] of numbers or length expressions"));
}
match o.get("dir") {
None => Some(whole("axis needs `dir`")),
Some(dir) => params::vec3(dir).is_none().then(|| whole("axis `dir` must be a direction [x, y, z] of numbers")),
}
}
/// Revolve's `axis`, as `features::axis_param` reads it before it looks anything up: a sketch
/// line's id or name, `x` or `y`, X, Y or Z, a construction axis's name (any text), `{type: "world",
/// axis}`, `{origin?, dir}`, or an edge or face of the model (as `construct::model_axis_param`
/// reads it). Kept as given, for the handler. Whether a line or axis exists, and whether a picked
/// edge or face gives an axis, is checked by the handler, which has the design.
#[derive(Clone, Debug, PartialEq)]
pub struct RevolveAxis(pub Value);
impl<'de> Deserialize<'de> for RevolveAxis {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let problem = if crate::cmd::is_model_axis(&v) {
model_axis_problem(&v)
} else {
match &v {
Value::String(_) => None,
Value::Object(_) => revolve_axis_object_problem(&v),
_ => Some(whole(REVOLVE_AXIS_MESSAGE)),
}
};
match problem {
Some(why) => Err(D::Error::custom(why)),
None => Ok(RevolveAxis(v)),
}
}
}
impl JsonSchema for RevolveAxis {
fn schema_name() -> Cow<'static, str> {
"RevolveAxis".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
let origin = generator.subschema_for::<PointExpr3>();
let model = json!({"anyOf": [{"required": ["edge"]}, {"required": ["face"]}, {"required": ["type"], "properties": {"type": {"enum": ["edge", "face"]}}}]});
json_schema!({
"description": "The axis to turn about: a sketch line's id or name (a line of the profile's sketch wins over a construction axis of the same name); x or y, the sketch's own axes; X, Y or Z, the world axes (any other case is a name); a construction axis's name, which the revolve follows when it is edited; {\"type\": \"world\", \"axis\": X, Y or Z}, as timeline.inspect gives one back; {\"origin\"?: [x, y, z] (numbers or length expressions; default: the world origin), \"dir\": [x, y, z]}, which the revolve follows when parameters change; or an edge or face of a body ({\"edge\": [x, y, z]} or {\"face\": [x, y, z]}, a point on a straight or circular edge, or on a cylinder, cone or torus face, or as saved: {\"type\": \"edge\" or \"face\", \"at\", \"name\"?}), about whose axis it turns, followed as the model changes.",
"anyOf": [
{"type": "string"},
{"type": "object", "properties": {"type": {"enum": ["world"]}, "axis": {"enum": ["X", "Y", "Z"]}}, "required": ["type", "axis"]},
{
"type": "object",
"properties": {"origin": origin, "dir": point},
"required": ["dir"],
"not": {"anyOf": [model, {"required": ["type"], "properties": {"type": {"enum": ["world"]}}}]}
},
picked_at("face", &point),
picked_at("edge", &point),
{"type": "object", "properties": {"type": {"enum": ["edge", "face"]}}, "required": ["type"]}
]
})
}
}
/// Revolve's `direction`: only `symmetric` (any case, not trimmed) does anything; the handler
/// ignores every other value.
#[derive(Clone, Debug, PartialEq)]
pub struct RevolveDirection;
impl<'de> Deserialize<'de> for RevolveDirection {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if s.eq_ignore_ascii_case("symmetric") => Ok(RevolveDirection),
_ => Err(D::Error::custom("must be symmetric")),
}
}
}
impl JsonSchema for RevolveDirection {
fn schema_name() -> Cow<'static, str> {
"RevolveDirection".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
enum_schema(
&["symmetric"],
"symmetric: the angle is split either side of the sketch plane, whichever way the axis points. The same as `symmetric: true`. Matching ignores case.",
)
}
}
/// Revolve's angular start, as the handler reads `convention` (serde, case-sensitive).
#[derive(Clone, Debug, PartialEq)]
pub struct RevolveConventionParam(pub solidcraft_doc::RevolveConvention);
impl<'de> Deserialize<'de> for RevolveConventionParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
serde_json::from_value(Value::deserialize(d)?)
.map(RevolveConventionParam)
.map_err(|_| D::Error::custom("must be sketch_normal or legacy_axis_start"))
}
}
impl JsonSchema for RevolveConventionParam {
fn schema_name() -> Cow<'static, str> {
"RevolveConvention".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
enum_schema(
&["sketch_normal", "legacy_axis_start"],
"sketch_normal (the default): the angle is measured from the sketch plane, turning about the axis as the sketch's normal sweeps. legacy_axis_start keeps the angular start older designs had.",
)
}
}
/// A construction plane's name, which `features::check_plane_name` refuses when it names an
/// origin plane. Strict gives the same message but names the command that was called, where the
/// handler (`add_feature`, which isn't given it) says `construct.plane`.
+10
View File
@@ -7,7 +7,12 @@ doc.recover
doc.recovery_discard
doc.recovery_list
face.delete
file.export
file.insert_mesh
file.insert_step
file.new
file.save_mesh
file.slicers
motion.export_animation
motion.trace
motion.trace.edit
@@ -19,6 +24,8 @@ render.preflight
render.scene
render.start
sheet.export_dxf
sketch.draw.dimensioned
solid.align
solid.coil
solid.draft
solid.emboss
@@ -32,7 +39,10 @@ solid.pattern.path
solid.pattern.rectangular
solid.press_pull
solid.replace_face
solid.revolve
solid.shell
solid.split_body
solid.split_face
solid.thread
surface.blend
surface.extend

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