mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-10 18:55:14 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d01515911 | |||
| 8ffb97d541 | |||
| 8f13e948a9 | |||
| 59a85a1646 | |||
| 4890cebd36 | |||
| 27d5a264f3 | |||
| 3b82c40ff7 | |||
| a05b47339f | |||
| b53fa7a569 | |||
| 4579e1ad50 | |||
| bc63b3670b | |||
| dfb240a982 | |||
| a22d42ad98 | |||
| 7c03f57fd9 | |||
| 91a08fc63a | |||
| 193a13d7fc | |||
| 2e8e68c64d | |||
| 11d408d8c2 | |||
| 1b4547f2b3 | |||
| ae4f93a7cf | |||
| 59b78db068 | |||
| 9210343c9d | |||
| cce1c9dcf5 | |||
| 11bb98683f | |||
| 55297e5b30 |
+5
-17
@@ -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.
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
|
||||
@@ -436,10 +436,6 @@ jobs:
|
||||
run: |
|
||||
cargo xtask mesh-decisions
|
||||
cargo test -p xtask mesh_decisions --locked
|
||||
- name: Apps compute no geometry
|
||||
run: |
|
||||
cargo xtask app-geometry
|
||||
cargo test -p xtask app_geometry --locked
|
||||
- name: Agent router and skills cite real files
|
||||
run: |
|
||||
cargo xtask skills
|
||||
@@ -452,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:
|
||||
|
||||
@@ -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);
|
||||
@@ -12,7 +12,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
|
||||
- Never crash: no unwrap/expect/panic/unsafe in non-test code, errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
|
||||
- Model decisions (what an operation does, which bodies it touches, which face or edge a point or name means, depths, nearness) use exact geometry; a mesh is for display, export, previews and mesh bodies (`xtask/mesh-decisions.txt` only shrinks): `cargo xtask mesh-decisions`.
|
||||
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
|
||||
- Layering; only `crates/kernel` uses truck; solidcore only via `solidcore-ops`/`-tess`/`-record` (`xtask/solidcore-seam.txt`): `cargo xtask layers`/`app-geometry`; `solidcore/docs/layers.md`.
|
||||
- Layering; only `crates/kernel` uses truck; solidcore only via `solidcore-ops`/`-tess`/`-record` (`xtask/solidcore-seam.txt`): `cargo xtask layers`; see `solidcore/docs/layers.md`.
|
||||
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
|
||||
- Application code is Rust only and builds for wasm: `cargo xtask wasm`, `cargo xtask web-offline-test`.
|
||||
- The product name is SolidCraft; the old name lives only in `docs/compatibility.md`.
|
||||
@@ -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
-26
@@ -17,28 +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-only pull request targets the `kernel` branch. A change that touches the kernel and the
|
||||
app goes to `main` as one pull request, with a code owner approving the `solidcore/` part.
|
||||
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)
|
||||
|
||||
@@ -442,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
-178
@@ -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"
|
||||
@@ -3437,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",
|
||||
]
|
||||
@@ -3482,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"
|
||||
@@ -3863,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",
|
||||
@@ -4466,7 +4333,7 @@ dependencies = [
|
||||
"solidcraft-color",
|
||||
"solidcraft-geom",
|
||||
"web-time",
|
||||
"weezl 0.1.12",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4535,7 +4402,6 @@ dependencies = [
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"log",
|
||||
"lopdf",
|
||||
"minisign-verify",
|
||||
"muda",
|
||||
"nix 0.31.3",
|
||||
@@ -4640,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"
|
||||
@@ -4774,7 +4629,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error",
|
||||
"weezl 0.1.12",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
@@ -4813,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"
|
||||
@@ -4996,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"
|
||||
@@ -5386,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"
|
||||
|
||||
+111
-570
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "نقل لوحة التصيير",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "הזז את עגינת הרינדור",
|
||||
|
||||
@@ -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": "レンダードックを移動",
|
||||
|
||||
@@ -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": "렌더 도크 이동",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "移动渲染停靠面板",
|
||||
|
||||
@@ -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;
|
||||
@@ -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
-225
@@ -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
|
||||
@@ -1482,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();
|
||||
@@ -1799,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.
|
||||
@@ -2263,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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2335,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() {
|
||||
@@ -2367,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);
|
||||
@@ -2383,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 {
|
||||
@@ -2480,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(),
|
||||
@@ -2509,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;
|
||||
@@ -2652,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(),
|
||||
@@ -2677,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;
|
||||
@@ -4866,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
|
||||
@@ -4899,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
|
||||
@@ -7078,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, ©, 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).
|
||||
@@ -7659,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()));
|
||||
@@ -7674,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();
|
||||
@@ -7722,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(())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
|
||||
@@ -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())),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -23,16 +23,10 @@ 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::save::{SaveAsParams, SaveParams, SaveResult};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::schema::save_mesh::{SaveMeshResult, SlicerDelivery};
|
||||
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", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"),
|
||||
CommandSpec::new("doc.open", "Open", open)
|
||||
@@ -44,11 +38,9 @@ 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)"),
|
||||
CommandSpec::new("file.save", "Save", crate::checked!("file.save", save))
|
||||
.icon("save")
|
||||
@@ -71,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
|
||||
@@ -110,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)
|
||||
@@ -140,7 +128,6 @@ fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"name": name}))
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -148,7 +135,6 @@ const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::Exp
|
||||
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 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"))
|
||||
@@ -321,24 +307,18 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>,
|
||||
// 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
|
||||
@@ -997,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.
|
||||
@@ -1017,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> {
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +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 use file::open_steps::OpenSteps;
|
||||
pub(crate) use inspect::color_param;
|
||||
pub use join_pieces::{Addition, addition};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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°.
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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, ¶ms)?;
|
||||
let mut out = scratch.execute(create, ¶ms)?;
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)] {
|
||||
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>(), &[]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,362 +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)
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// 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"] {
|
||||
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,290 +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>(), &["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"})),
|
||||
// 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})),
|
||||
// `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})),
|
||||
// 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));
|
||||
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}),
|
||||
] {
|
||||
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` 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` 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),
|
||||
// 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),
|
||||
] {
|
||||
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}));
|
||||
// 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": 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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -107,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;
|
||||
@@ -118,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;
|
||||
@@ -127,7 +123,6 @@ pub mod sketch_modify;
|
||||
pub mod sketch_project;
|
||||
pub mod sketch_session;
|
||||
pub mod sketch_shapes;
|
||||
pub mod split;
|
||||
pub mod surface;
|
||||
pub mod surface_analysis;
|
||||
pub mod surface_boundary;
|
||||
|
||||
@@ -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;
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -7,8 +7,11 @@ 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
|
||||
@@ -21,6 +24,8 @@ render.preflight
|
||||
render.scene
|
||||
render.start
|
||||
sheet.export_dxf
|
||||
sketch.draw.dimensioned
|
||||
solid.align
|
||||
solid.coil
|
||||
solid.draft
|
||||
solid.emboss
|
||||
@@ -34,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
|
||||
|
||||
@@ -235,7 +235,7 @@ fn send_to_slicer_needs_a_slicer_and_bodies() {
|
||||
|
||||
/// The chosen-programs list is process-wide: tests that set it take turns.
|
||||
#[cfg(unix)]
|
||||
pub(crate) static CHOSEN_PROGRAMS: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
static CHOSEN_PROGRAMS: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// `file.save_mesh` with a `slicer` writes where the caller said and hands that file over, by the
|
||||
/// same steps as `file.send_to_slicer`.
|
||||
|
||||
@@ -400,124 +400,3 @@ fn an_insert_after_a_mirror_keeps_its_meshes_through_a_drag() {
|
||||
}
|
||||
assert_as_evaluated_afresh(&s);
|
||||
}
|
||||
|
||||
/// The model of `s` against a fresh evaluation of its design: each body's name, volume, bounds
|
||||
/// and reference mesh. Not the shown meshes: a copy's display triangles can tie differently by
|
||||
/// the pose they were made at, with or without reuse (as in the drag test above).
|
||||
fn assert_model_as_evaluated_afresh(s: &Session) {
|
||||
let mut fresh = solidcraft_doc::Model::new();
|
||||
fresh.no_cache = true;
|
||||
fresh.evaluate(&s.doc);
|
||||
let errors: Vec<_> = fresh.results.iter().filter_map(|r| r.error.as_ref()).collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
let (got, want) = (s.model.state(), fresh.state());
|
||||
assert_eq!(got.bodies.len(), want.bodies.len());
|
||||
for (a, b) in got.bodies.iter().zip(&want.bodies) {
|
||||
assert_eq!(a.name, b.name);
|
||||
let (va, vb) = (a.measure().unwrap().volume, b.measure().unwrap().volume);
|
||||
assert!((va - vb).abs() < 1e-6, "{}: {va} != {vb}", a.name);
|
||||
assert_eq!(a.mesh().triangles, b.mesh().triangles, "{}", a.name);
|
||||
let (ma, mb) = (a.mesh().bounds(), b.mesh().bounds());
|
||||
assert!(ma.min.dist(mb.min) < 1e-6 && ma.max.dist(mb.max) < 1e-6, "{}: {ma:?} != {mb:?}", a.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Driving a joint evaluates a mirror of the part it moves again, and the features after it
|
||||
/// only when they read its copy: a box, a sketch and its extrusion, and a cut into another
|
||||
/// body are kept (#12139).
|
||||
#[test]
|
||||
fn a_joint_drive_evaluates_only_what_reads_the_mirrored_copy() {
|
||||
for dependent in [false, true] {
|
||||
let mut s = Session::default();
|
||||
moved_arm(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"joint.as_built",
|
||||
json!({"type": "revolute", "name": "Pan", "a": 0, "b": "Arm", "at": {"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 1]}}),
|
||||
);
|
||||
let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"})));
|
||||
run(&mut s, "solid.box", json!({"corner": [0, -100, 0], "length": 10, "width": 10, "height": 10, "body_name": "Later"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-100, -100], "p1": [-90, -90]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 4, "operation": "new"}));
|
||||
run(&mut s, "solid.box", json!({"corner": [2, -98, 5], "length": 2, "width": 2, "height": 10, "operation": "cut", "targets": ["Later"]}));
|
||||
if dependent {
|
||||
run(&mut s, "solid.move", json!({"bodies": [copy.clone()], "translate": [0, 0, 5]}));
|
||||
}
|
||||
for angle in [20, -35, 60] {
|
||||
run(&mut s, "joint.drive", json!({"joint": "Pan", "value": angle, "quiet": true}));
|
||||
let recomputed = s.model.last_recomputed + s.model.last_restored;
|
||||
if dependent {
|
||||
assert_eq!(recomputed, 2, "the mirror and the move of its copy at {angle}°");
|
||||
} else {
|
||||
assert_eq!(recomputed, 1, "only the mirror at {angle}°");
|
||||
}
|
||||
assert_box(world_box(&s, ©), {
|
||||
let (lo, hi) = mirrored_yz(&s, "ArmBody");
|
||||
if dependent { (lo + Vec3::new(0., 0., 5.), hi + Vec3::new(0., 0., 5.)) } else { (lo, hi) }
|
||||
});
|
||||
assert_model_as_evaluated_afresh(&s);
|
||||
}
|
||||
for command in ["edit.undo", "edit.redo"] {
|
||||
run(&mut s, command, json!({}));
|
||||
assert_model_as_evaluated_afresh(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved model's pose-only mirror was made at the pose the design had when it was saved: one
|
||||
/// adopted for a design posed otherwise is evaluated again, not taken from the cache (#12139).
|
||||
#[test]
|
||||
fn an_adopted_model_mirrors_at_the_designs_own_pose() {
|
||||
let mut s = Session::default();
|
||||
moved_arm(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"joint.as_built",
|
||||
json!({"type": "revolute", "name": "Pan", "a": 0, "b": "Arm", "at": {"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 1]}}),
|
||||
);
|
||||
run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"}));
|
||||
run(&mut s, "solid.box", json!({"corner": [0, -100, 0], "length": 10, "width": 10, "height": 10, "body_name": "Later"}));
|
||||
let saved = s.model.saved(&s.doc).unwrap();
|
||||
run(&mut s, "joint.drive", json!({"joint": "Pan", "value": 40, "quiet": true}));
|
||||
// As opening installs it, before any evaluation; and once evaluated.
|
||||
s.model = solidcraft_doc::Model::adopt(&s.doc, &saved).unwrap();
|
||||
assert_model_as_evaluated_afresh(&s);
|
||||
let before = s.model.recomputed;
|
||||
s.model.evaluate(&s.doc);
|
||||
assert_eq!(s.model.recomputed, before, "the adopted model is current");
|
||||
assert_model_as_evaluated_afresh(&s);
|
||||
}
|
||||
|
||||
/// A cut revolved about an edge of a mirrored copy turns about it where the copy now is, when
|
||||
/// a joint drive moves the copy: the axis is a pick of the model, not a body by name (#12139).
|
||||
/// (Not checked against a fresh evaluation: one without the cache finds no edge at the pick.)
|
||||
#[test]
|
||||
fn a_revolve_about_a_mirrored_copys_edge_follows_a_joint_drive() {
|
||||
let mut s = Session::default();
|
||||
moved_arm(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"joint.as_built",
|
||||
json!({"type": "revolute", "name": "Pan", "a": 0, "b": "Arm", "at": {"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 1]}}),
|
||||
);
|
||||
let copy = created(&run(&mut s, "solid.mirror", json!({"bodies": ["ArmBody"], "plane": "YZ"})));
|
||||
// An edge of the copy along X, on its lowest Y and Z.
|
||||
let (lo, hi) = world_box(&s, ©);
|
||||
let edge = [(lo.x + hi.x) / 2., lo.y, lo.z];
|
||||
let (x, y) = (edge[0], lo.y - 30.);
|
||||
run(&mut s, "solid.box", json!({"corner": [x - 50., y - 30., -40], "length": 100, "width": 60, "height": 80, "body_name": "Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x - 5., y], "p1": [x + 5., y + 10.]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.revolve", json!({"axis": {"edge": edge}, "angle": "360 deg", "operation": "cut", "targets": ["Block"]}));
|
||||
let block = |s: &Session| s.model.state().body("Block").unwrap().mesh().positions.clone();
|
||||
let before = block(&s);
|
||||
for angle in [5, -5] {
|
||||
run(&mut s, "joint.drive", json!({"joint": "Pan", "value": angle, "quiet": true}));
|
||||
assert_eq!(s.model.last_recomputed + s.model.last_restored, 2, "the mirror and the revolve at {angle}°");
|
||||
let after = block(&s);
|
||||
assert_ne!(after, before, "the cut turned about the old axis at {angle}°");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1528,60 +1528,3 @@ fn a_modelled_thread_leaves_the_next_hole_a_cylinder_to_thread() {
|
||||
assert!(t3.is_ok(), "gap {gap}: the second hole takes a modelled thread: {t3:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #13505: a tapped hole drilled across two slots has its wall in three pieces (z 30..40, 15..25
|
||||
/// and 5..10). The thread runs along every piece the hole reaches, cosmetic or modelled, and a
|
||||
/// thread depth runs on through the pieces from the first one's start.
|
||||
#[test]
|
||||
fn a_tapped_hole_across_slots_is_threaded_along_every_piece_of_its_wall() {
|
||||
let slotted = || {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 30, "width": 30, "height": 40, "body_name": "Part"}));
|
||||
for z in [25, 10] {
|
||||
run(&mut s, "solid.box", json!({"corner": [-1, -1, z], "length": 32, "width": 21, "height": 5, "operation": "cut", "targets": ["Part"]}));
|
||||
}
|
||||
s
|
||||
};
|
||||
let hole = |s: &mut Session, extra: Value| {
|
||||
let mut ask = json!({"position": [15, 10, 40], "direction": [0, 0, -1], "thread": "M4", "depth": 35});
|
||||
ask.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
run(s, "solid.hole", ask);
|
||||
};
|
||||
let spans = |s: &Session| {
|
||||
let mut v: Vec<(f64, f64)> = s
|
||||
.model
|
||||
.state()
|
||||
.threads
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let (a, b) = ((t.axis_point + t.axis * t.start).z, (t.axis_point + t.axis * t.end).z);
|
||||
(a.min(b), a.max(b))
|
||||
})
|
||||
.collect();
|
||||
v.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
v
|
||||
};
|
||||
let near = |got: Vec<(f64, f64)>, want: &[(f64, f64)]| {
|
||||
assert_eq!(got.len(), want.len(), "{got:?} vs {want:?}");
|
||||
for (g, w) in got.iter().zip(want) {
|
||||
assert!((g.0 - w.0).abs() < 1e-6 && (g.1 - w.1).abs() < 1e-6, "{got:?} vs {want:?}");
|
||||
}
|
||||
};
|
||||
let mut s = slotted();
|
||||
hole(&mut s, json!({}));
|
||||
near(spans(&s), &[(30.0, 40.0), (15.0, 25.0), (5.0, 10.0)]);
|
||||
// 22 mm from the entry: on through the first slot, stopping in the second piece.
|
||||
let mut s = slotted();
|
||||
hole(&mut s, json!({"thread_depth": 22}));
|
||||
near(spans(&s), &[(30.0, 40.0), (18.0, 25.0)]);
|
||||
// Modelled, every piece of the wall is grooved: each loses material the cosmetic one keeps.
|
||||
let mut s = slotted();
|
||||
hole(&mut s, json!({"modeled": true}));
|
||||
assert!(s.model.state().body("Part").unwrap().body.validity().is_empty());
|
||||
let mut plain = slotted();
|
||||
hole(&mut plain, json!({}));
|
||||
for (z0, z1) in [(30.0, 40.0), (15.0, 25.0), (5.0, 10.0)] {
|
||||
let (cut, drilled) = (volume_between(&s, "Part", (z0, z1), None), volume_between(&plain, "Part", (z0, z1), None));
|
||||
assert!(cut < drilled - 1.0, "z {z0}..{z1}: {cut} vs {drilled}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,12 +432,11 @@ mod tests {
|
||||
assert!(classify(&body, &mesh, 0).0.iter().all(|f| !*f));
|
||||
}
|
||||
|
||||
/// A G2 corner patch's sides are G2 to their rounds except near the faces they end on: each
|
||||
/// side is one join, drawn whole (the 20 mm box's r3 corner). Since tangency-labs/solidcore#2988 the
|
||||
/// corner SMLib leaves open is the blend corner's Setback, whose sections are not split, so
|
||||
/// each side is one edge (the N x N patch before it held each side as two).
|
||||
/// A G2 corner patch's sides are G2 to their rounds except near the faces they end on, and
|
||||
/// SMLib holds each side as two edges: the side is one join, drawn whole, not up to the
|
||||
/// vertex in its middle where the G2 half begins (the 20 mm box's r3 corner).
|
||||
#[test]
|
||||
fn a_g2_corner_s_side_to_its_round_is_drawn_whole() {
|
||||
fn a_seam_split_in_two_edges_is_drawn_whole() {
|
||||
let body = crate::box_solid(Vec3::ZERO, Vec3::new(20., 20., 20.)).unwrap();
|
||||
let picks = [Vec3::new(20., 20., 10.), Vec3::new(20., 10., 20.), Vec3::new(10., 20., 20.)];
|
||||
let g2 = crate::fillet_g2_corners(&body, &picks, 3., &[]).unwrap();
|
||||
@@ -453,7 +452,7 @@ mod tests {
|
||||
for round in rounds {
|
||||
let side: Vec<usize> =
|
||||
(0..mesh.edges.len()).filter(|e| mesh.edge_faces[*e].contains(&patch) && mesh.edge_faces[*e].contains(&round)).collect();
|
||||
assert_eq!(side.len(), 1, "the Setback's section is one edge (tangency-labs/solidcore#2988): {side:?}");
|
||||
assert_eq!(side.len(), 2, "SMLib splits the side in two: {side:?}");
|
||||
assert!(side.iter().all(|e| mesh.shaded_edge_visible(*e)), "the side to round {round} is drawn whole: {side:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2487,26 +2487,6 @@ fn compact_execute_keeps_changes_ids_and_default_replies() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #9180: a compact sketch reply counts the points of a spline's poles instead of listing them,
|
||||
/// and keeps the point ids of curves with only their ends.
|
||||
#[test]
|
||||
fn compact_sketch_replies_count_spline_poles() {
|
||||
let mut s = server();
|
||||
tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}));
|
||||
let points = json!([[0, 0], [10, 5], [20, 0], [30, 5], [40, 0], [50, 5]]);
|
||||
let full = tool(&mut s, "execute", json!({"command": "sketch.spline.fit_point", "params": {"points": points}}));
|
||||
let listed = full["created"]["points"].as_array().unwrap().len();
|
||||
let curves = full["created"]["curves"].as_array().unwrap().len();
|
||||
assert!(listed > 2 * curves, "{full}");
|
||||
let compact = tool(&mut s, "execute", json!({"command": "sketch.spline.fit_point", "params": {"points": points}, "verbose": false}));
|
||||
assert_eq!(compact["created"]["point_count"], json!(listed), "{compact}");
|
||||
assert!(compact["created"].get("points").is_none(), "{compact}");
|
||||
assert_eq!(compact["created"]["curves"].as_array().map(Vec::len), Some(curves));
|
||||
let rectangle =
|
||||
tool(&mut s, "execute", json!({"command": "sketch.rectangle.two_point", "params": {"p0": [0, 20], "p1": [10, 30]}, "verbose": false}));
|
||||
assert_eq!(rectangle["created"]["points"].as_array().map(Vec::len), Some(4), "{rectangle}");
|
||||
}
|
||||
|
||||
/// A connected app sees exactly one engine call; no inventory inspection is needed.
|
||||
#[test]
|
||||
fn compact_execute_uses_one_backend_call_and_keeps_removed_bodies() {
|
||||
|
||||
+2
-12
@@ -122,7 +122,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
json!({
|
||||
"command": string("Command id (see list_commands)"),
|
||||
"params": {"type": "object", "description": "The command's parameters"},
|
||||
"verbose": {"type": "boolean", "description": "false: omit the full feature body list and sketch ids repeated outside `created`; keep created_bodies, changed_bodies and removed_bodies. When a sketch command creates more points than its new curves have ends (spline poles), `created.points` becomes `created.point_count` (default true)"},
|
||||
"verbose": {"type": "boolean", "description": "false: omit the full feature body list and sketch ids repeated outside `created`; keep created_bodies, changed_bodies and removed_bodies (default true)"},
|
||||
"expect": expect_schema(),
|
||||
}),
|
||||
&["command"],
|
||||
@@ -160,7 +160,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
"coalesce_parameters": {"type": "boolean", "description": "Set each run of consecutive plain parameter edits (parameters.change or set_parameter with a name and a value, or changes) together, rebuilding the design once for the run instead of once per edit; a run that is refused goes step by step, naming the refused step. Other steps still see the design up to date. Each step keeps its own result; the run is one undo step (default false)"},
|
||||
"timings": {"type": "boolean", "description": "Add `timings`: [{index, command, ms}] for every step run (the failing one included) and `total_ms` (default false)"},
|
||||
"profile": {"type": "boolean", "description": "Like timings, and each step's `profile`: {boolean_ms, tessellate_ms, other_ms}, its time in kernel booleans, in tessellation and the rest (default false)"},
|
||||
"verbose": {"type": "boolean", "description": "false: a compact reply. Feature steps leave out the full body list (created_bodies, changed_bodies and removed_bodies stay), sketch steps the ids they repeat outside `created`, and `created.points` becomes `created.point_count` when there are more points than the new curves have ends (default true)"},
|
||||
"verbose": {"type": "boolean", "description": "false: a compact reply. Feature steps leave out the full body list (created_bodies, changed_bodies and removed_bodies stay), sketch steps the ids they repeat outside `created` (default true)"},
|
||||
"expect": expect_schema(),
|
||||
}),
|
||||
&[],
|
||||
@@ -1829,16 +1829,6 @@ fn compact(mut v: Value) -> Value {
|
||||
}
|
||||
}
|
||||
}
|
||||
// More new points than the new curves have ends (an offset's spline poles, #9180): agents
|
||||
// refer to those curves by id, so the points only fill the context. Keep how many there were.
|
||||
if let Some(created) = o.get_mut("created").and_then(Value::as_object_mut) {
|
||||
let count = |k: &str| created.get(k).and_then(Value::as_array).map_or(0, Vec::len);
|
||||
let (points, curves) = (count("points"), count("curves"));
|
||||
if curves > 0 && points > 2 * curves {
|
||||
created.remove("points");
|
||||
created.insert("point_count".into(), json!(points));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
@@ -606,10 +606,9 @@ fn pocket_chamfer_on_a_boolean_body() {
|
||||
}
|
||||
|
||||
/// The corner of r5 rounds at (60, 40, 20) on a 60 x 40 x 20 box, set back 8 and left open: the
|
||||
/// box's six faces and the three rounds (9 faces, 27 edges, 19 vertices), a sheet, and one hole
|
||||
/// of nine edges, chained end to start, each beside a face of the sheet. The marked corner is the
|
||||
/// blend corner's Setback (tangency-labs/solidcore#2988), whose sections and face curves are one
|
||||
/// edge each; the N x N patch it replaced split each in two (12 edges, 30 and 22).
|
||||
/// box's six faces and the three rounds (9 faces, 30 edges, 22 vertices), a sheet, and one hole
|
||||
/// of twelve edges (each section and face curve in two), chained end to start, each beside a face
|
||||
/// of the sheet.
|
||||
#[test]
|
||||
fn a_setback_corner_left_open_returns_its_hole() {
|
||||
let edges = [[30.0, 40.0, 20.0], [60.0, 20.0, 20.0], [60.0, 40.0, 10.0]];
|
||||
@@ -617,17 +616,17 @@ fn a_setback_corner_left_open_returns_its_hole() {
|
||||
let b = Shape::Box([0.0; 3], [60.0, 40.0, 20.0]).core();
|
||||
blend_holes(&b, Blend::Round, &edges, &[5.0; 3], 0.1, &[[60.0, 40.0, 20.0]], &[8.0], Corners::Open).unwrap()
|
||||
});
|
||||
assert_eq!(counts(&open).unwrap(), [9, 27, 19]);
|
||||
assert_eq!(counts(&open).unwrap(), [9, 30, 22]);
|
||||
assert!(!super::is_manifold_solid(&open).unwrap());
|
||||
let [hole] = holes.as_slice() else { panic!("one corner: {holes:?}") };
|
||||
assert_eq!(hole.edges.len(), 9);
|
||||
assert_eq!(hole.edges.len(), 12);
|
||||
let flat = to_arrays(&open).unwrap();
|
||||
let (rec, _) = native(&flat).unwrap();
|
||||
solidcraft_geom::brep::check::validate(&rec).unwrap();
|
||||
let ends = &flat.ints[&crate::flat::EDGE_VERTICES];
|
||||
let end = |h: &crate::HoleEdge, last: bool| ends[2 * h.edge + usize::from(h.forward == last)];
|
||||
for (k, h) in hole.edges.iter().enumerate() {
|
||||
let next = &hole.edges[(k + 1) % 9];
|
||||
let next = &hole.edges[(k + 1) % 12];
|
||||
assert_eq!(end(h, true), end(next, false), "edge {k} ends where the next starts");
|
||||
assert!(h.face < 9);
|
||||
}
|
||||
|
||||
@@ -389,26 +389,24 @@ fn draft_tilts_a_side_about_its_line_on_the_neutral_plane() {
|
||||
}
|
||||
|
||||
/// The corner of r5 rounds at (60, 40, 20) on a 60 x 40 x 20 box, set back 8 and left open: the
|
||||
/// box's six faces and the three rounds (9 faces, 27 edges, 19 vertices), a sheet SMLib's check
|
||||
/// passes, and one hole of nine edges, chained end to start, each bounding a different face than
|
||||
/// the patch's neighbours across it. The marked corner is the blend corner's Setback
|
||||
/// (tangency-labs/solidcore#2988), whose sections and face curves are one edge each; the N x N
|
||||
/// patch it replaced split each in two (12 edges, 30 and 22).
|
||||
/// box's six faces and the three rounds (9 faces, 30 edges, 22 vertices), a sheet SMLib's check
|
||||
/// passes, and one hole of twelve edges (each section and face curve in two), chained end to
|
||||
/// start, each bounding a different face than the patch's neighbours across it.
|
||||
#[test]
|
||||
fn a_setback_corner_left_open_returns_its_hole() {
|
||||
let b = Brep::make_box([0.0; 3], [60.0, 40.0, 20.0]).unwrap();
|
||||
let edges = [[30.0, 40.0, 20.0], [60.0, 20.0, 20.0], [60.0, 40.0, 10.0]];
|
||||
let (open, holes) = b.blend_setback_open(Blend::Round, &edges, &[5.0; 3], &[[60.0, 40.0, 20.0]], &[8.0], 0.1).unwrap();
|
||||
shot(&open, "setback_open");
|
||||
assert_eq!(open.counts().unwrap(), [9, 27, 19]);
|
||||
assert_eq!(open.counts().unwrap(), [9, 30, 22]);
|
||||
assert!(!open.is_manifold_solid().unwrap());
|
||||
let [hole] = holes.as_slice() else { panic!("one corner: {holes:?}") };
|
||||
assert_eq!(hole.edges.len(), 9);
|
||||
assert_eq!(hole.edges.len(), 12);
|
||||
let arrays = open.arrays().unwrap();
|
||||
let ends = &arrays.ints[&crate::flat::EDGE_VERTICES];
|
||||
let end = |h: &HoleEdge, last: bool| ends[2 * h.edge + usize::from(h.forward == last)];
|
||||
for (k, h) in hole.edges.iter().enumerate() {
|
||||
let next = &hole.edges[(k + 1) % 9];
|
||||
let next = &hole.edges[(k + 1) % 12];
|
||||
assert_eq!(end(h, true), end(next, false), "edge {k} ends where the next starts");
|
||||
assert!(h.face < 9);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ proc-macro2 = { workspace = true }
|
||||
# egui-winit's clipboard.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
arboard = { version = "3.6", default-features = false, features = ["image-data"] }
|
||||
lopdf = { version = "0.45", default-features = false }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
|
||||
# The offscreen viewport render test opens a device without a window.
|
||||
|
||||
@@ -30,11 +30,6 @@ const MAX_PICTURE_BYTES: u64 = 40 * 1024 * 1024;
|
||||
const MAX_TEXT_BYTES: u64 = 256 * 1024;
|
||||
/// Largest PDF sent as a document; a bigger one goes by reference.
|
||||
const MAX_PDF_BYTES: u64 = 12 * 1024 * 1024;
|
||||
/// Pages whose text is taken from a PDF, and the most characters of it kept.
|
||||
const MAX_PDF_PAGES: usize = 300;
|
||||
const MAX_PDF_CHARS: usize = 200_000;
|
||||
/// A PDF page's content may inflate to at most this (a compressed bomb is refused by the reader).
|
||||
const MAX_PDF_PAGE_BYTES: usize = 32 * 1024 * 1024;
|
||||
/// A picture's longer side is scaled down to this before it is sent.
|
||||
pub const MAX_SIDE: u32 = 2048;
|
||||
/// Largest picture sent, as it came or re-encoded: its base64 (a third larger) stays under 5 MB,
|
||||
@@ -93,11 +88,10 @@ pub enum Content {
|
||||
size: [u32; 2],
|
||||
},
|
||||
Text(Arc<String>),
|
||||
/// A PDF, sent whole as a document to an agent that takes them; to the others, its text.
|
||||
/// A PDF, sent whole as a document to an agent that takes them.
|
||||
Pdf {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
path: PathBuf,
|
||||
text: Option<Arc<PdfText>>,
|
||||
},
|
||||
/// A file the agent is pointed to, not given: what it is, its size and where it is.
|
||||
Reference {
|
||||
@@ -109,60 +103,6 @@ pub enum Content {
|
||||
Imported,
|
||||
}
|
||||
|
||||
/// What was taken out of a PDF: its page count and its text, without layout, pictures or fonts.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PdfText {
|
||||
pub pages: usize,
|
||||
pub text: String,
|
||||
/// The text stops short: of the pages or of the characters kept.
|
||||
pub cut: bool,
|
||||
}
|
||||
|
||||
/// The text of a PDF, from its pages in order (up to [`MAX_PDF_PAGES`] pages and
|
||||
/// [`MAX_PDF_CHARS`] characters). `None` when it can't be read at all (damaged, or a reader
|
||||
/// panic, which is caught); an encrypted or scanned one gives its page count and no text.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn pdf_text(bytes: &[u8]) -> Option<PdfText> {
|
||||
std::panic::catch_unwind(|| {
|
||||
let doc = lopdf::Document::load_mem(bytes).ok()?;
|
||||
let pages: Vec<u32> = doc.get_pages().keys().copied().collect();
|
||||
let mut text = String::new();
|
||||
let mut chars = 0usize;
|
||||
let mut cut = pages.len() > MAX_PDF_PAGES;
|
||||
if !doc.is_encrypted() {
|
||||
for page in pages.iter().take(MAX_PDF_PAGES) {
|
||||
// A page that can't be read is skipped; the others still give their text.
|
||||
let chunks = doc.extract_text_chunks_with_limit(&[*page], MAX_PDF_PAGE_BYTES);
|
||||
let page_text: String = chunks.into_iter().filter_map(Result::ok).collect();
|
||||
let page_text = page_text.trim();
|
||||
if page_text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !text.is_empty() {
|
||||
text.push_str("\n\n");
|
||||
}
|
||||
let room = MAX_PDF_CHARS.saturating_sub(chars);
|
||||
let n = page_text.chars().count();
|
||||
if n > room {
|
||||
text.extend(page_text.chars().take(room));
|
||||
cut = true;
|
||||
break;
|
||||
}
|
||||
text.push_str(page_text);
|
||||
chars += n;
|
||||
}
|
||||
}
|
||||
Some(PdfText { pages: pages.len(), text, cut })
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn pdf_text(_bytes: &[u8]) -> Option<PdfText> {
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Attachment {
|
||||
/// Its own on the message, never reused (set when it is added there; 0 until then): the
|
||||
@@ -282,9 +222,7 @@ pub fn read(path: &Path) -> Result<Attachment, String> {
|
||||
return Ok(Attachment { id: 0, name, content: Content::Reference { path, size: meta.len(), mime: "application/pdf" }, thumb: None });
|
||||
}
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(&name, e))?;
|
||||
// Read here, off the window thread: for an agent that takes no documents.
|
||||
let text = pdf_text(&bytes).map(Arc::new);
|
||||
Ok(Attachment { id: 0, name, content: Content::Pdf { bytes: Arc::new(bytes), path, text }, thumb: None })
|
||||
Ok(Attachment { id: 0, name, content: Content::Pdf { bytes: Arc::new(bytes), path }, thumb: None })
|
||||
}
|
||||
Kind::Other if matches!(path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref(), Some("heic" | "heif")) => {
|
||||
Err(refused("ui.agent_chat_file_heic", &name, &[]))
|
||||
@@ -396,24 +334,6 @@ fn thumbnail(img: &image::DynamicImage) -> Option<Arc<egui::ColorImage>> {
|
||||
Some(Arc::new(egui::ColorImage::from_rgba_unmultiplied(size, t.as_raw())))
|
||||
}
|
||||
|
||||
/// Add a file's text to the message under `head`, within the `left` characters shared by the
|
||||
/// files: whole when it fits; cut, saying how much is missing, when it doesn't; named only when
|
||||
/// nothing is left.
|
||||
fn fit(out: &mut String, left: &mut usize, head: &str, text: &str) {
|
||||
let t = text.trim_end();
|
||||
let len = t.chars().count();
|
||||
if *left == 0 {
|
||||
*out += &format!("\n\n{head}: left out, the message is at its size limit.");
|
||||
} else if len <= *left {
|
||||
*out += &format!("\n\n{head}:\n```\n{t}\n```");
|
||||
*left -= len;
|
||||
} else {
|
||||
let kept: String = t.chars().take(*left).collect();
|
||||
*out += &format!("\n\n{head} (cut: {} more characters left out):\n```\n{kept}\n```", len - *left);
|
||||
*left = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// What the message says about its files, after the person's words: the text files' text, and the
|
||||
/// pictures and imported parts by name (the agent sees the pictures themselves as images).
|
||||
/// `pictures_sent` and `documents_sent`: whether this agent takes pictures and PDFs. The text files share `budget` characters
|
||||
@@ -433,8 +353,21 @@ pub fn note(files: &[Attachment], pictures_sent: bool, documents_sent: bool, bud
|
||||
let mut left = budget;
|
||||
for f in files {
|
||||
match &f.content {
|
||||
Content::Text(t) => fit(&mut out, &mut left, &format!("Attached file `{}`", f.name), t),
|
||||
Content::Pdf { path, bytes, .. } if documents_sent => {
|
||||
Content::Text(t) => {
|
||||
let t = t.trim_end();
|
||||
let len = t.chars().count();
|
||||
if left == 0 {
|
||||
out += &format!("\n\nAttached file `{}`: left out, the message is at its size limit.", f.name);
|
||||
} else if len <= left {
|
||||
out += &format!("\n\nAttached file `{}`:\n```\n{t}\n```", f.name);
|
||||
left -= len;
|
||||
} else {
|
||||
let kept: String = t.chars().take(left).collect();
|
||||
out += &format!("\n\nAttached file `{}` (cut: {} more characters left out):\n```\n{kept}\n```", f.name, len - left);
|
||||
left = 0;
|
||||
}
|
||||
}
|
||||
Content::Pdf { path, bytes } if documents_sent => {
|
||||
out += &format!(
|
||||
"\n\nAttached PDF `{}` ({}, at {}): sent with this message as a document.",
|
||||
f.name,
|
||||
@@ -442,29 +375,14 @@ pub fn note(files: &[Attachment], pictures_sent: bool, documents_sent: bool, bud
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
// An agent that takes no documents gets the text, or is pointed to the file.
|
||||
Content::Pdf { path, bytes, text } => {
|
||||
let size = size_text(bytes.len() as u64);
|
||||
match text.as_deref().filter(|t| !t.text.trim().is_empty()) {
|
||||
Some(t) => {
|
||||
let cut = if t.cut { "; cut, the rest is in the file" } else { "" };
|
||||
let head = format!(
|
||||
"Attached PDF `{}` ({} pages, {size}, at {}): its text only, layout, pictures and fonts are dropped{cut}",
|
||||
f.name,
|
||||
t.pages,
|
||||
path.display()
|
||||
);
|
||||
fit(&mut out, &mut left, &head, &t.text);
|
||||
}
|
||||
None => {
|
||||
let why = if text.is_some() { "no text could be taken from it (scanned or protected)" } else { "its text could not be read" };
|
||||
out += &format!(
|
||||
"\n\nAttached file `{}` (PDF, {size}, at {}): {why}; read it with your own tools, it is not in this message.",
|
||||
f.name,
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
// An agent that takes no documents is pointed to the file, as for any other file.
|
||||
Content::Pdf { path, bytes } => {
|
||||
out += &format!(
|
||||
"\n\nAttached file `{}` (PDF, {}, at {}): read it with your own tools; it is not in this message.",
|
||||
f.name,
|
||||
size_text(bytes.len() as u64),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Content::Reference { path, size, mime } => {
|
||||
out += &format!(
|
||||
@@ -487,7 +405,7 @@ pub fn parts(files: &[Attachment], pictures: bool, documents: bool) -> Vec<Promp
|
||||
.iter()
|
||||
.filter_map(|f| match &f.content {
|
||||
Content::Picture { mime, bytes, .. } if pictures => Some(PromptPart::Image { mime: (*mime).to_string(), base64: backend::base64(bytes) }),
|
||||
Content::Pdf { bytes, path, .. } if documents => Some(PromptPart::Document {
|
||||
Content::Pdf { bytes, path } if documents => Some(PromptPart::Document {
|
||||
name: f.name.clone(),
|
||||
uri: file_uri(path),
|
||||
mime: "application/pdf".to_string(),
|
||||
@@ -762,107 +680,4 @@ mod tests {
|
||||
assert_eq!(size_text(1536), "2 KB");
|
||||
assert_eq!(size_text(5 * 1024 * 1024 + 512 * 1024), "5.5 MB");
|
||||
}
|
||||
|
||||
/// A PDF with one line of text on each page.
|
||||
fn sample_pdf(pages: &[&str]) -> Vec<u8> {
|
||||
use lopdf::content::{Content, Operation};
|
||||
use lopdf::{Document, Object, Stream, dictionary};
|
||||
let mut doc = Document::with_version("1.5");
|
||||
let pages_id = doc.new_object_id();
|
||||
let font = doc.add_object(dictionary! {"Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica"});
|
||||
let resources = doc.add_object(dictionary! {"Font" => dictionary! {"F1" => font}});
|
||||
let mut kids = Vec::new();
|
||||
for line in pages {
|
||||
let mut ops = vec![
|
||||
Operation::new("BT", vec![]),
|
||||
Operation::new("Tf", vec!["F1".into(), 12.into()]),
|
||||
Operation::new("Td", vec![72.into(), 700.into()]),
|
||||
];
|
||||
if !line.is_empty() {
|
||||
ops.push(Operation::new("Tj", vec![Object::string_literal(*line)]));
|
||||
}
|
||||
ops.push(Operation::new("ET", vec![]));
|
||||
let content = doc.add_object(Stream::new(dictionary! {}, Content { operations: ops }.encode().unwrap()));
|
||||
kids.push(Object::Reference(doc.add_object(dictionary! {"Type" => "Page", "Parent" => pages_id, "Contents" => content})));
|
||||
}
|
||||
let tree = dictionary! {
|
||||
"Type" => "Pages", "Kids" => kids, "Count" => pages.len() as i64, "Resources" => resources,
|
||||
"MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
|
||||
};
|
||||
doc.objects.insert(pages_id, Object::Dictionary(tree));
|
||||
let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages_id});
|
||||
doc.trailer.set("Root", catalog);
|
||||
let mut out = Vec::new();
|
||||
doc.save_to(&mut out).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// An agent that takes no documents gets the PDF's text, with its page count and a note that
|
||||
/// the layout was dropped; one that takes documents gets the PDF and not the text.
|
||||
#[test]
|
||||
fn a_pdf_gives_its_text_to_agents_without_documents() {
|
||||
let d = dir("pdftext");
|
||||
let path = d.join("brief.pdf");
|
||||
std::fs::write(&path, sample_pdf(&["Wall 2 mm", "Hole 5 mm"])).unwrap();
|
||||
let a = read(&path).unwrap();
|
||||
let Content::Pdf { text: Some(t), .. } = &a.content else { panic!("a PDF with its text: {:?}", a.content) };
|
||||
assert_eq!(t.pages, 2);
|
||||
assert!(!t.cut && t.text.contains("Wall 2 mm") && t.text.contains("Hole 5 mm"), "{:?}", t.text);
|
||||
assert!(t.text.find("Wall").unwrap() < t.text.find("Hole").unwrap(), "pages in order");
|
||||
let files = vec![a];
|
||||
let n = note(&files, false, false, usize::MAX);
|
||||
assert!(n.contains("Attached PDF `brief.pdf` (2 pages"), "{n}");
|
||||
assert!(n.contains("layout, pictures and fonts are dropped") && n.contains("Wall 2 mm"), "{n}");
|
||||
let short = note(&files, false, false, 5);
|
||||
assert!(short.contains("more characters left out"), "the text shares the message's budget: {short}");
|
||||
let with = note(&files, false, true, usize::MAX);
|
||||
assert!(with.contains("as a document") && !with.contains("Wall 2 mm"), "{with}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A PDF that is all pictures (a scan), or can't be read, says so and points to the file; the
|
||||
/// text kept is capped.
|
||||
#[test]
|
||||
fn pdfs_without_text_or_too_long_are_said_so() {
|
||||
let blank = pdf_text(&sample_pdf(&[""])).unwrap();
|
||||
assert_eq!((blank.pages, blank.text.as_str()), (1, ""));
|
||||
let a = Attachment {
|
||||
id: 0,
|
||||
name: "scan.pdf".into(),
|
||||
content: Content::Pdf { bytes: Arc::new(vec![1; 10]), path: "/a/scan.pdf".into(), text: Some(Arc::new(blank)) },
|
||||
thumb: None,
|
||||
};
|
||||
assert!(note(&[a], false, false, usize::MAX).contains("no text could be taken from it"));
|
||||
let broken = Attachment {
|
||||
id: 0,
|
||||
name: "x.pdf".into(),
|
||||
content: Content::Pdf { bytes: Arc::new(vec![1; 10]), path: "/a/x.pdf".into(), text: None },
|
||||
thumb: None,
|
||||
};
|
||||
assert!(note(&[broken], false, false, usize::MAX).contains("its text could not be read"));
|
||||
let long = "a".repeat(MAX_PDF_CHARS + 5000);
|
||||
let t = pdf_text(&sample_pdf(&[&long])).unwrap();
|
||||
assert!(t.cut && t.text.chars().count() == MAX_PDF_CHARS, "{} characters", t.text.chars().count());
|
||||
}
|
||||
|
||||
/// Hostile PDFs never panic: truncated and damaged copies of a real one are read or refused.
|
||||
#[test]
|
||||
fn damaged_pdfs_never_panic() {
|
||||
let good = sample_pdf(&["Wall 2 mm", "Hole 5 mm"]);
|
||||
assert!(pdf_text(&good).is_some());
|
||||
assert!(pdf_text(b"").is_none() && pdf_text(b"%PDF-1.7\n").is_none() && pdf_text(b"not a pdf").is_none());
|
||||
for cut in (0..good.len()).step_by(37) {
|
||||
let _ = pdf_text(&good[..cut]);
|
||||
}
|
||||
let mut seed = 0x9e37_79b9_u32;
|
||||
for _ in 0..200 {
|
||||
let mut bad = good.clone();
|
||||
for _ in 0..4 {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
let at = usize::try_from(seed >> 8).unwrap() % bad.len();
|
||||
bad[at] = (seed & 0xff) as u8;
|
||||
}
|
||||
let _ = pdf_text(&bad);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ impl State {
|
||||
State::Failed => "failed",
|
||||
}
|
||||
}
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
fn label(self) -> &'static str {
|
||||
tr(match self {
|
||||
State::Off => "ui.agent_chat_state_off",
|
||||
State::Starting => "ui.agent_chat_state_starting",
|
||||
|
||||
@@ -6,7 +6,6 @@ use crate::fonts::RichTextExt as _;
|
||||
use crate::ui_kit::BackingTooltipExt as _;
|
||||
use crate::ui_kit::{Container, Edge, Surface};
|
||||
use egui::{Align, Align2, Color32, Id, Layout, Rect, RichText, Sense, Stroke, pos2, vec2};
|
||||
use solidcraft_engine::panels::Flow;
|
||||
|
||||
use super::*;
|
||||
use crate::ui_kit;
|
||||
@@ -241,7 +240,7 @@ pub(crate) fn finish_shared(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
|
||||
/// Existing Assistant controls and conversation, inside the shared host's body.
|
||||
pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
|
||||
pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let rect = ui.max_rect().intersect(ui.clip_rect());
|
||||
if !shown(app) || !app.agent_chat.open || !rect.is_finite() || !rect.is_positive() {
|
||||
return;
|
||||
@@ -249,7 +248,7 @@ pub(crate) fn shared_content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow)
|
||||
app.agent_chat.panel_rect = Some(rect);
|
||||
app.agent_chat.drop_rect = Some(rect);
|
||||
crate::scenario::publish_rect("agent_chat:panel", rect);
|
||||
ui_kit::scope(ui, |ui| content(app, ui, flow));
|
||||
ui_kit::scope(ui, |ui| content(app, ui));
|
||||
}
|
||||
|
||||
/// Horizontal room reserved before the history and browser when Assistant is on the left.
|
||||
@@ -905,12 +904,10 @@ fn dock_drag(app: &mut SolveApp, ui: &egui::Ui, drag: &egui::Response, left: boo
|
||||
|
||||
fn body(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
|
||||
header(app, ui, left);
|
||||
content(app, ui, Flow::Tall);
|
||||
content(app, ui);
|
||||
}
|
||||
|
||||
fn content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = flow;
|
||||
fn content(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !crate::panel_runtime::shared_content_measurement(ui.ctx()) {
|
||||
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
|
||||
@@ -920,61 +917,17 @@ fn content(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
|
||||
ui.label(tr("ui.agent_chat_desktop_only"));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
desktop_body(app, ui, flow);
|
||||
desktop_body(app, ui);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui, flow: Flow) {
|
||||
fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let top = ui.cursor().top();
|
||||
let shared = crate::panel_runtime::shared_content_measurement(ui.ctx());
|
||||
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
|
||||
refresh(app, ui.ctx());
|
||||
}
|
||||
let state = app.agent_chat.state();
|
||||
let bounds = ui.available_rect_before_wrap();
|
||||
let gap = Tokens::get().space_8;
|
||||
// Compact wide cards keep the existing stack until both regions fit.
|
||||
let wide = shared && flow == Flow::Wide && bounds.width() >= MIN_WIDTH * 2.0 + gap;
|
||||
let (interaction_bounds, transcript_bounds) = if wide {
|
||||
let width = (bounds.width() - gap) / 2.0;
|
||||
let left = Rect::from_min_size(bounds.min, vec2(width, bounds.height()));
|
||||
let right = Rect::from_min_max(pos2(bounds.right() - width, bounds.top()), bounds.max);
|
||||
if crate::i18n::panel_direction(&app.preferences.language) == solidcraft_engine::panels::Direction::Rtl {
|
||||
(left, Some(right))
|
||||
} else {
|
||||
(right, Some(left))
|
||||
}
|
||||
} else {
|
||||
(bounds, None)
|
||||
};
|
||||
let root = ui.scope_id();
|
||||
// Orientation changes rectangles, never the retained region identities.
|
||||
let mut interaction = shared.then(|| {
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().scope_id(root.with("assistant-interaction")).max_rect(interaction_bounds));
|
||||
child.set_clip_rect(ui.clip_rect().intersect(interaction_bounds));
|
||||
child
|
||||
});
|
||||
let (top_height, outcome, remaining) = {
|
||||
let column = interaction.as_mut().unwrap_or(&mut *ui);
|
||||
let top_height = top_controls(app, column, state, shared);
|
||||
let outcome = composer(app, column, state);
|
||||
(top_height, outcome, column.available_rect_before_wrap())
|
||||
};
|
||||
let mut transcript = shared.then(|| {
|
||||
let bounds = transcript_bounds.unwrap_or(remaining);
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().scope_id(root.with("assistant-transcript")).max_rect(bounds));
|
||||
child.set_clip_rect(ui.clip_rect().intersect(bounds));
|
||||
child
|
||||
});
|
||||
let transcript_height = transcript_body(app, transcript.as_mut().unwrap_or(&mut *ui));
|
||||
let interaction_height = top_height + outcome.height;
|
||||
let height = if wide { interaction_height.max(transcript_height) } else { interaction_height + transcript_height };
|
||||
crate::panel_runtime::report_content_extent(ui, top, height, false);
|
||||
finish_composer(app, ui.ctx(), outcome);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn new_chat_confirmation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if app.agent_chat.confirm_new {
|
||||
ui_kit::scope(ui, |ui| {
|
||||
inset(ui, |ui| {
|
||||
@@ -982,7 +935,7 @@ fn new_chat_confirmation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui_kit::text(ui, tr("ui.agent_chat_new_confirm"), ui_kit::Text::Report("new_chat", false));
|
||||
ui.horizontal(|ui| {
|
||||
let yes = ui_kit::button(ui, tr("ui.agent_chat_new"), ui_kit::Button::Normal);
|
||||
crate::scenario::publish_target("agent_chat:new_confirm", yes.rect);
|
||||
crate::scenario::publish_handle("agent_chat:new_confirm", yes.rect.center());
|
||||
if yes.clicked() {
|
||||
new_chat(app);
|
||||
}
|
||||
@@ -993,23 +946,6 @@ fn new_chat_confirmation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn top_controls(app: &mut SolveApp, ui: &mut egui::Ui, state: State, shared: bool) -> f32 {
|
||||
let top = ui.cursor().top();
|
||||
let reveal_confirmation = shared && {
|
||||
let id = ui.scope_id().with("assistant-confirm-visible");
|
||||
let visible = app.agent_chat.confirm_new;
|
||||
ui.data_mut(|data| {
|
||||
let previous = data.get_temp::<bool>(id).unwrap_or(false);
|
||||
data.insert_temp(id, visible);
|
||||
visible && !previous
|
||||
})
|
||||
};
|
||||
if !shared {
|
||||
new_chat_confirmation(app, ui);
|
||||
}
|
||||
// Shared top controls must yield scroll space to the pinned composer, including
|
||||
// when discovery lists several agents or a long message grows the editor.
|
||||
let picker_max = if shared {
|
||||
@@ -1020,41 +956,24 @@ fn top_controls(app: &mut SolveApp, ui: &mut egui::Ui, state: State, shared: boo
|
||||
} else {
|
||||
ui.available_height() / 3.0
|
||||
};
|
||||
let provider_scroll = egui::ScrollArea::vertical()
|
||||
let picker = egui::ScrollArea::vertical()
|
||||
.id_salt("sc_agent_chat_picker_scroll")
|
||||
.min_scrolled_height(0.0)
|
||||
.max_height(picker_max)
|
||||
.auto_shrink([false, true]);
|
||||
let provider_scroll = if reveal_confirmation { provider_scroll.vertical_scroll_offset(0.0) } else { provider_scroll };
|
||||
let picker = provider_scroll.show(ui, |ui| {
|
||||
crate::scenario::publish_rect("agent_chat:providers", ui.clip_rect());
|
||||
if shared {
|
||||
new_chat_confirmation(app, ui);
|
||||
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
|
||||
}
|
||||
picker(app, ui);
|
||||
if shared && state == State::NeedsAuth {
|
||||
inset(ui, |ui| auth_box(app, ui));
|
||||
}
|
||||
});
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
if shared {
|
||||
ui_kit::assistant_controls(ui, |ui| switcher(app, ui));
|
||||
}
|
||||
picker(app, ui);
|
||||
if shared && state == State::NeedsAuth {
|
||||
inset(ui, |ui| auth_box(app, ui));
|
||||
}
|
||||
});
|
||||
if !shared && state == State::NeedsAuth {
|
||||
inset(ui, |ui| auth_box(app, ui));
|
||||
}
|
||||
ui.cursor().top() - top - picker.inner_rect.height().min(picker.content_size.y) + picker.content_size.y
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct ComposerOutcome {
|
||||
height: f32,
|
||||
send_clicked: bool,
|
||||
stop_clicked: bool,
|
||||
enter: bool,
|
||||
steer: bool,
|
||||
esc: bool,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn composer(app: &mut SolveApp, ui: &mut egui::Ui, state: State) -> ComposerOutcome {
|
||||
let top_height = ui.cursor().top() - top - picker.inner_rect.height().min(picker.content_size.y) + picker.content_size.y;
|
||||
// The input sits at the bottom of the panel and grows upwards: a long message makes its box
|
||||
// taller up to `edit_max`, then it scrolls, and the conversation takes what is left above. It
|
||||
// is a panel of its own at the bottom, as high as its content this frame, so the buttons under
|
||||
@@ -1229,11 +1148,6 @@ fn composer(app: &mut SolveApp, ui: &mut egui::Ui, state: State) -> ComposerOutc
|
||||
if pick_clicked {
|
||||
start_pick(app);
|
||||
}
|
||||
ComposerOutcome { height: composer.inner, send_clicked, stop_clicked, enter, steer, esc }
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn transcript_body(app: &mut SolveApp, ui: &mut egui::Ui) -> f32 {
|
||||
let conversation = egui::ScrollArea::vertical()
|
||||
.id_salt("sc_agent_chat_scroll")
|
||||
.min_scrolled_height(0.0)
|
||||
@@ -1249,12 +1163,7 @@ fn transcript_body(app: &mut SolveApp, ui: &mut egui::Ui) -> f32 {
|
||||
scroll_while_selecting(ui);
|
||||
});
|
||||
crate::scenario::publish_rect("agent_chat:conversation", conversation.inner_rect);
|
||||
conversation.content_size.y
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn finish_composer(app: &mut SolveApp, ctx: &egui::Context, outcome: ComposerOutcome) {
|
||||
let ComposerOutcome { send_clicked, stop_clicked, enter, steer, esc, .. } = outcome;
|
||||
crate::panel_runtime::report_content_extent(ui, top, top_height + composer.inner + conversation.content_size.y, false);
|
||||
if esc || stop_clicked {
|
||||
cancel(app);
|
||||
}
|
||||
@@ -1265,13 +1174,13 @@ fn finish_composer(app: &mut SolveApp, ctx: &egui::Context, outcome: ComposerOut
|
||||
// Enter waits with the button.
|
||||
} else if (send_clicked || enter || steer) && something {
|
||||
let text = std::mem::take(&mut app.agent_chat.input).trim_end_matches('\n').to_string();
|
||||
send(app, ctx, text);
|
||||
send(app, ui.ctx(), text);
|
||||
if steer {
|
||||
send_now(app, ctx);
|
||||
send_now(app, ui.ctx());
|
||||
}
|
||||
} else if steer {
|
||||
// Nothing typed: Ctrl+Enter sends what is already queued.
|
||||
send_now(app, ctx);
|
||||
send_now(app, ui.ctx());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2641,7 +2550,7 @@ mod tests {
|
||||
ui.set_width(width);
|
||||
ui_kit::scope(ui, |ui| {
|
||||
header(&mut app, ui, false);
|
||||
desktop_body(&mut app, ui, Flow::Tall);
|
||||
desktop_body(&mut app, ui);
|
||||
});
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
|
||||
@@ -4762,249 +4762,3 @@ fn a_picture_only_prompt_and_a_thought_have_no_copy_message_action() {
|
||||
assert!(crate::scenario::handle_at("agent_chat:copy:1").is_none(), "a thought has no copy action");
|
||||
assert!(h.widget("Copy message").is_none(), "nothing to click");
|
||||
}
|
||||
|
||||
fn shared_assistant_harness() -> (crate::scenario::Harness, Script) {
|
||||
let mut h = narrow_chat();
|
||||
h.app.home.open = false;
|
||||
h.app.ui.show_browser = false;
|
||||
h.app.ui.show_timeline = false;
|
||||
h.app.preferences.ai_features = true;
|
||||
h.app.preferences.agent_chat_popped = false;
|
||||
h.app.preferences.language = "en".into();
|
||||
let script = Script::default();
|
||||
h.app.agent_chat.backend = Some(Box::new(script.clone()));
|
||||
h.app.agent_chat.state = Some(State::Ready);
|
||||
h.app.agent_chat.input = "Retained responsive composer draft".into();
|
||||
h.app.agent_chat.items.push(Item::Agent { thought: false, text: "A retained conversation".into() });
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Retained profile"})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
let feature = h.app.session.doc.features[0].id;
|
||||
h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Feature { id: feature }]})).unwrap();
|
||||
h.app.preferences.shared_panel_layout = true;
|
||||
h.frames(6);
|
||||
assert!(h.app.panel_runtime().is_some());
|
||||
assert!(h.app.agent_chat.detecting.is_none(), "the deterministic discovery fixture starts no worker");
|
||||
assert!(script.calls().is_empty());
|
||||
(h, script)
|
||||
}
|
||||
|
||||
fn shared_assistant_float(h: &mut crate::scenario::Harness, width: f32, height: f32) {
|
||||
use solidcraft_engine::panels::{Destination, InstanceId, LayoutAction, Mutation, Point, SectionId, SectionLocation, Size};
|
||||
let instance = InstanceId::new("assistant").unwrap();
|
||||
let source = h.app.panel_runtime().unwrap().layout().layout().containing(&instance).unwrap();
|
||||
let origin = Point { x: 300.0, y: 200.0 };
|
||||
let size = Size { width, height };
|
||||
let mutation = if matches!(source.location, SectionLocation::Floating { .. }) {
|
||||
Mutation::SetFloatingRect { section: source.id.clone(), origin, size }
|
||||
} else {
|
||||
Mutation::Move {
|
||||
instance,
|
||||
destination: Destination::NewSection {
|
||||
section: SectionId::new("responsive-assistant").unwrap(),
|
||||
index: 0,
|
||||
location: SectionLocation::Floating { origin },
|
||||
size,
|
||||
},
|
||||
}
|
||||
};
|
||||
h.app.dispatch_panel_layout(LayoutAction::Apply { expected_revision: None, mutations: vec![mutation] }).unwrap();
|
||||
h.frames(6);
|
||||
}
|
||||
|
||||
fn shared_assistant_body(h: &crate::scenario::Harness) -> egui::Rect {
|
||||
let body = h.app.universal_panel_frame().unwrap().layout.sections.iter().find(|panel| panel.instance.0 == "assistant").unwrap().body.unwrap();
|
||||
egui::Rect::from_min_size(egui::pos2(body.origin.x, body.origin.y), egui::vec2(body.size.width, body.size.height))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_assistant_wide_and_compact_present_real_regions_without_losing_the_composer_selection() {
|
||||
use solidcraft_engine::panels::{Destination, InstanceId, LayoutAction, Mutation, SectionId, SectionLocation, Size, Zone};
|
||||
let (mut h, script) = shared_assistant_harness();
|
||||
let input = egui::Id::unique("sc_agent_chat_input");
|
||||
h.ctx.memory_mut(|memory| memory.request_focus(input));
|
||||
h.frames(2);
|
||||
let mut editor = egui::TextEdit::load_state(&h.ctx, input).unwrap();
|
||||
editor.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(2), egui::text::CCursor::new(10))));
|
||||
editor.store(&h.ctx, input);
|
||||
h.frame();
|
||||
let caret = egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range();
|
||||
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input), "the docked composer starts focused");
|
||||
let response = h.ctx.read_response(input).unwrap();
|
||||
assert!(response.enabled() && response.interact_rect.is_positive(), "the focused composer is live and interactable");
|
||||
assert!(!caret.unwrap().is_empty(), "exercise a real selection before moving the panel");
|
||||
let draft = h.app.agent_chat.input.clone();
|
||||
let document = h.app.session.doc.clone();
|
||||
let model = h.app.session.model.state();
|
||||
let selection = h.app.session.selection.clone();
|
||||
let camera = h.app.cam;
|
||||
let session = h.app.session.uid;
|
||||
let revision = h.app.session.revision;
|
||||
let undo = h.app.session.undo.len();
|
||||
let dirty = h.app.session.is_dirty();
|
||||
let controllers = h.app.panel_runtime().unwrap().controller_count();
|
||||
for (width, height, wide) in [(900.0, 420.0, true), (500.0, 340.0, false)] {
|
||||
shared_assistant_float(&mut h, width, height);
|
||||
let body = shared_assistant_body(&h);
|
||||
assert!(body.width() > body.height(), "both cases exercise the Wide host flow");
|
||||
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
|
||||
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
|
||||
let switcher = crate::scenario::rect_of("agent_chat:switcher").unwrap();
|
||||
assert!(body.expand(1.0).contains_rect(conversation));
|
||||
assert!(body.expand(1.0).contains_rect(composer));
|
||||
assert!(body.expand(1.0).contains_rect(switcher));
|
||||
if wide {
|
||||
assert!(conversation.right() <= composer.left(), "a viable Wide body puts the real transcript beside its composer");
|
||||
assert!(conversation.right() <= switcher.left(), "provider controls share the composer's column");
|
||||
assert!(conversation.height() > composer.height(), "the transcript can use the full height of its own column");
|
||||
} else {
|
||||
assert!(conversation.bottom() <= composer.top() + 1.0, "a compact Wide body falls back to the existing vertical composition");
|
||||
assert!(conversation.left() < composer.right() && composer.left() < conversation.right());
|
||||
}
|
||||
assert_eq!(h.app.agent_chat.input, draft);
|
||||
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input));
|
||||
assert_eq!(egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range(), caret);
|
||||
}
|
||||
h.app.preferences.language = "ar".into();
|
||||
shared_assistant_float(&mut h, 900.0, 420.0);
|
||||
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
|
||||
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
|
||||
assert!(composer.right() <= conversation.left(), "Wide mirrors the two real regions in RTL");
|
||||
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input), "RTL keeps the composer focused");
|
||||
assert_eq!(egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range(), caret);
|
||||
h.app.preferences.language = "en".into();
|
||||
h.app
|
||||
.dispatch_panel_layout(LayoutAction::Apply {
|
||||
expected_revision: None,
|
||||
mutations: vec![Mutation::Move {
|
||||
instance: InstanceId::new("assistant").unwrap(),
|
||||
destination: Destination::NewSection {
|
||||
section: SectionId::new("responsive-assistant-side").unwrap(),
|
||||
index: 0,
|
||||
location: SectionLocation::Dock { zone: Zone::Right },
|
||||
size: Size { width: 360.0, height: 420.0 },
|
||||
},
|
||||
}],
|
||||
})
|
||||
.unwrap();
|
||||
h.frames(6);
|
||||
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
|
||||
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
|
||||
assert!(conversation.bottom() <= composer.top() + 1.0);
|
||||
assert_eq!(h.app.agent_chat.input, draft);
|
||||
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(input));
|
||||
assert_eq!(egui::TextEdit::load_state(&h.ctx, input).unwrap().cursor.char_range(), caret);
|
||||
assert_eq!(h.app.session.uid, session);
|
||||
assert!(Arc::ptr_eq(&h.app.session.doc, &document));
|
||||
assert!(Arc::ptr_eq(&h.app.session.model.state(), &model));
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
assert_eq!(h.app.cam, camera);
|
||||
assert_eq!(h.app.session.revision, revision);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.is_dirty(), dirty);
|
||||
assert_eq!(h.app.panel_runtime().unwrap().controller_count(), controllers);
|
||||
assert!(script.calls().is_empty(), "changing presentation must not submit, cancel or restart the retained service");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_assistant_transcript_keeps_its_scroll_position_through_responsive_width_changes() {
|
||||
use solidcraft_engine::panels::{Destination, InstanceId, LayoutAction, Mutation, SectionId, SectionLocation, Size, Zone};
|
||||
let (mut h, script) = shared_assistant_harness();
|
||||
h.app.agent_chat.items = (0..80).map(|n| Item::Agent { thought: false, text: format!("Parts list row {n}") }).collect();
|
||||
shared_assistant_float(&mut h, 900.0, 420.0);
|
||||
assert_eq!(rows_in_view(&h).last().unwrap().0, 79);
|
||||
let conversation = crate::scenario::rect_of("agent_chat:conversation").unwrap();
|
||||
h.call("ui.scroll", json!({"x":conversation.center().x,"y":conversation.center().y,"delta":1200}));
|
||||
h.frames(30);
|
||||
let before = rows_in_view(&h).first().unwrap().0;
|
||||
assert!(before > 0 && before < 70, "exercise a middle position, not an automatic endpoint");
|
||||
for width in [1040.0, 900.0] {
|
||||
shared_assistant_float(&mut h, width, 420.0);
|
||||
let after = rows_in_view(&h).first().unwrap().0;
|
||||
assert!(before.abs_diff(after) <= 1, "fixed-height short messages keep their scroll anchor across a width change: {before} → {after}");
|
||||
assert!(!rows_in_view(&h).iter().any(|row| row.0 == 79), "a reparent must not reset stick-to-bottom");
|
||||
}
|
||||
h.app
|
||||
.dispatch_panel_layout(LayoutAction::Apply {
|
||||
expected_revision: None,
|
||||
mutations: vec![Mutation::Move {
|
||||
instance: InstanceId::new("assistant").unwrap(),
|
||||
destination: Destination::NewSection {
|
||||
section: SectionId::new("scrolled-assistant-side").unwrap(),
|
||||
index: 0,
|
||||
location: SectionLocation::Dock { zone: Zone::Right },
|
||||
size: Size { width: 360.0, height: 420.0 },
|
||||
},
|
||||
}],
|
||||
})
|
||||
.unwrap();
|
||||
h.frames(6);
|
||||
let after = rows_in_view(&h).first().unwrap().0;
|
||||
assert!(before.abs_diff(after) <= 1, "returning to Tall retains the same transcript scroll anchor: {before} → {after}");
|
||||
assert!(script.calls().is_empty());
|
||||
assert!(h.app.agent_chat.backend.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_assistant_header_uses_live_status_and_one_click_starts_one_new_chat() {
|
||||
use crate::panel_runtime::{HostControlTarget, HostPointerTarget};
|
||||
use solidcraft_engine::panels::{ControlId, InstanceId};
|
||||
let (mut h, script) = shared_assistant_harness();
|
||||
shared_assistant_float(&mut h, 900.0, 420.0);
|
||||
let assistant = InstanceId::new("assistant").unwrap();
|
||||
for state in [State::Ready, State::Busy, State::NeedsAuth, State::Exited, State::Failed] {
|
||||
h.app.agent_chat.state = Some(state);
|
||||
h.frames(2);
|
||||
let presentation = h.app.panel_runtime().unwrap().instance_presentation(&assistant).unwrap();
|
||||
assert_eq!(presentation.status.as_deref(), Some(state.label()));
|
||||
assert_eq!(presentation.attention, matches!(state, State::NeedsAuth | State::Exited | State::Failed));
|
||||
}
|
||||
h.app.agent_chat.state = Some(State::Ready);
|
||||
h.app.agent_chat.items.push(Item::Permission { key: 42, title: "Review this local request".into(), options: Vec::new(), answer: None });
|
||||
h.frames(2);
|
||||
let presentation = h.app.panel_runtime().unwrap().instance_presentation(&assistant).unwrap();
|
||||
assert_eq!(presentation.status.as_deref(), Some(trf("ui.assistant_waiting", &[("count", "1")]).as_str()));
|
||||
assert!(presentation.attention);
|
||||
assert_eq!(h.app.agent_chat.waiting(), 1, "rendering status never answers the pending request");
|
||||
h.app.agent_chat.items.pop();
|
||||
h.app.agent_chat.pending.push(Pending {
|
||||
text: "Keep this queued request until confirmation".into(),
|
||||
image: false,
|
||||
context: Context::default(),
|
||||
files: Vec::new(),
|
||||
});
|
||||
h.app.preferences.agents =
|
||||
(0..24).map(|n| agents::AgentConfig { id: format!("review-{n}"), name: format!("Review provider {n}"), ..Default::default() }).collect();
|
||||
h.app.agent_chat.picking = Some(true);
|
||||
h.frames(4);
|
||||
let providers = crate::scenario::rect_of("agent_chat:providers").unwrap();
|
||||
assert!(providers.contains(crate::scenario::handle_at("agent_chat:pick:review-0").unwrap()));
|
||||
h.call("ui.scroll", json!({"x":providers.center().x,"y":providers.center().y,"delta":-900}));
|
||||
h.frames(30);
|
||||
let providers = crate::scenario::rect_of("agent_chat:providers").unwrap();
|
||||
let switcher = crate::scenario::rect_of("agent_chat:switcher").unwrap();
|
||||
assert!(
|
||||
switcher.bottom() < providers.top() - solidcraft_theme::Tokens::get().control_height * 4.0,
|
||||
"the real provider scroller is far from its top before New Chat"
|
||||
);
|
||||
assert!(script.calls().is_empty(), "scrolling the deterministic provider list starts no agent");
|
||||
let target = HostControlTarget::Pointer(HostPointerTarget::HeaderAction { instance: assistant, action: ControlId::new("new-chat").unwrap() });
|
||||
let control = h.app.universal_panel_frame().unwrap().controls.iter().find(|control| control.target == target).unwrap();
|
||||
let at = egui::pos2(control.bounds.origin.x + control.bounds.size.width / 2.0, control.bounds.origin.y + control.bounds.size.height / 2.0);
|
||||
click_at(&mut h, at, egui::PointerButton::Primary);
|
||||
h.frames(4);
|
||||
assert!(h.app.agent_chat.confirm_new);
|
||||
assert_eq!(h.app.agent_chat.pending.len(), 1);
|
||||
assert!(script.calls().is_empty(), "opening confirmation cannot invoke New Chat early");
|
||||
let confirm = crate::scenario::rect_of("agent_chat:new_confirm").unwrap();
|
||||
let providers = crate::scenario::rect_of("agent_chat:providers").unwrap();
|
||||
let composer = crate::scenario::rect_of("agent_chat:composer_footer").unwrap();
|
||||
assert!(providers.expand(1.0).contains_rect(confirm), "a newly requested confirmation is fully visible inside the actual provider clip");
|
||||
assert!(shared_assistant_body(&h).contains_rect(confirm));
|
||||
assert!(confirm.left() >= composer.left(), "the real confirmation remains reachable in the interaction column");
|
||||
click_at(&mut h, confirm.center(), egui::PointerButton::Primary);
|
||||
h.frames(4);
|
||||
assert_eq!(script.calls().iter().filter(|call| call.as_str() == "new_session").count(), 1);
|
||||
assert!(!script.calls().iter().any(|call| call.starts_with("prompt:") || call == "cancel"));
|
||||
assert_eq!(h.app.agent_chat.input, "Retained responsive composer draft");
|
||||
assert!(h.app.agent_chat.items.is_empty());
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ fn every_axis_is_drawn_in_one_set_of_colours() {
|
||||
for (name, d, want) in [("x", Vec3::X, t.axis_x), ("y", Vec3::Y, t.axis_y), ("z", Vec3::Z, t.axis_z)] {
|
||||
let at = c + vec2(32.0, 0.0);
|
||||
let mut out =
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| crate::icons::navigation::axis_tip(ui.painter(), c, name, d.into(), at, 0.5, false, t));
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| crate::icons::navigation::axis_tip(ui.painter(), c, name, &d, at, 0.5, false, t));
|
||||
out.textures_delta.clear();
|
||||
let end = out.shapes.iter().find_map(|s| match &s.shape {
|
||||
egui::Shape::Circle(e) if e.center == at => Some(e.fill),
|
||||
|
||||
@@ -330,20 +330,7 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
|
||||
act(if app.session.visibility.hidden_bodies.contains(name) { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye")
|
||||
.with(json!({"bodies":[name]})),
|
||||
],
|
||||
Target::Body { name } => {
|
||||
let mut v = body_items(app, std::slice::from_ref(name), true);
|
||||
// A selected browser body hands every selected body to the mesh dialogs (#12334).
|
||||
// A row outside the selection still opens them with only the body right-clicked.
|
||||
let selected = sel_bodies(app);
|
||||
if selected.len() > 1 && selected.contains(name) {
|
||||
for item in &mut v {
|
||||
if item.id == "ui.saveMesh" || item.id == "ui.sendToSlicer" {
|
||||
item.params = Some(json!({ "bodies": selected }));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
Target::Body { name } => body_items(app, std::slice::from_ref(name), true),
|
||||
Target::Sketch { id } => sketch_items(app, *id),
|
||||
Target::Feature { id } => feature_items(app, *id),
|
||||
Target::Plane { id } => plane_items(app, *id),
|
||||
|
||||
+274
-218
@@ -4,11 +4,13 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use egui::vec2;
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::render::{Camera, StandardView};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::start_page;
|
||||
use crate::ui_kit::{self, FileView, RecoveryMessage};
|
||||
use solidcraft_theme::Tokens;
|
||||
|
||||
/// Recent designs kept.
|
||||
pub const MAX_RECENT: usize = 12;
|
||||
@@ -620,13 +622,7 @@ enum FileAction {
|
||||
Shortcuts,
|
||||
Crashes,
|
||||
About,
|
||||
/// Recover (true) or discard the recoverable design at this index.
|
||||
Recovery(usize, bool),
|
||||
/// The recent design at this index in `HomeState::recent`.
|
||||
OpenRecent(usize),
|
||||
ForgetRecent(usize),
|
||||
ShowInFolder(usize),
|
||||
Sample(usize),
|
||||
Recovery(solidcraft_engine::recovery::Entry, bool),
|
||||
}
|
||||
|
||||
/// File view replaces the workspace while keeping the open design and its history intact.
|
||||
@@ -676,6 +672,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let samples = solidcraft_engine::sample::samples();
|
||||
for i in 0..samples.len() {
|
||||
let key = format!("sample:{i}");
|
||||
@@ -698,174 +695,246 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
want(&mut app.home, path);
|
||||
}
|
||||
}
|
||||
let page = start_page::Page {
|
||||
id: "home",
|
||||
name: app.tr("ui.start_page").into(),
|
||||
interactive,
|
||||
back: start_page::Back {
|
||||
template: app.tr("home.back").into(),
|
||||
document: app.session.doc.name.as_str().into(),
|
||||
shortcut: "Esc".into(),
|
||||
chosen: FileAction::Back,
|
||||
},
|
||||
rail: rail(app),
|
||||
title: match app.home.section {
|
||||
Section::Start => app.tr_label("Start").into(),
|
||||
Section::Recovery => app.tr_label("Recover").into(),
|
||||
Section::Export => app.tr("home.export").into(),
|
||||
},
|
||||
hint: app
|
||||
.tr(match app.home.section {
|
||||
Section::Start => "home.start_hint",
|
||||
Section::Recovery => "home.recovery_hint",
|
||||
Section::Export => "home.export_hint",
|
||||
})
|
||||
.into(),
|
||||
error: app.status.as_ref().filter(|(_, _, error)| *error).map(|(message, _, _)| message.as_str().into()),
|
||||
body: match app.home.section {
|
||||
Section::Start => start_page::Body::Start(Box::new(start_body(app))),
|
||||
Section::Recovery => start_page::Body::Reviews(recovery_body(app)),
|
||||
Section::Export => start_page::Body::Tiles(export_tiles(app)),
|
||||
},
|
||||
};
|
||||
if let Some(action) = start_page::show(ui, page) {
|
||||
let mut action = None;
|
||||
FileView::page(ui, |ui| {
|
||||
ui.add_enabled_ui(interactive, |ui| {
|
||||
let full = ui.available_rect_before_wrap();
|
||||
crate::scenario::publish_rect("home:page", full);
|
||||
let narrow = full.width() < t.file_nav_width + FileView::tile_min_width() + t.file_content_inset_x * 2.0;
|
||||
if narrow {
|
||||
FileView::navigation(ui, |ui| {
|
||||
ui_kit::dropdown_menu(ui, "home_navigation", app.tr("ui.start_page"), |ui| {
|
||||
ui.set_min_width(t.file_nav_width);
|
||||
FileView::navigation(ui, |ui| {
|
||||
navigation(app, ui, &mut action);
|
||||
if action.is_some() {
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
content(app, ui, &mut action, true);
|
||||
} else {
|
||||
let rail = egui::Rect::from_min_size(full.min, vec2(t.file_nav_width, full.height()));
|
||||
FileView::rail(ui, rail, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("home_navigation").auto_shrink([false, false]).show(ui, |ui| {
|
||||
navigation(app, ui, &mut action);
|
||||
});
|
||||
});
|
||||
let body = egui::Rect::from_min_max(egui::pos2(rail.right(), full.top()), full.max);
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(body), |ui| content(app, ui, &mut action, false));
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(action) = action {
|
||||
dispatch(app, ui.ctx(), action);
|
||||
}
|
||||
}
|
||||
|
||||
/// SolidCraft's rail: Start, New, Open, Recover; the design's own entries; the app's.
|
||||
fn rail(app: &SolveApp) -> Vec<start_page::RailItem<'_, FileAction>> {
|
||||
fn navigation(app: &SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>) {
|
||||
let busy = app.file_picker_busy();
|
||||
let design = !crate::documents::active_is_blank(app);
|
||||
let response = FileView::back_to(ui, "home:back", app.tr("home.back"), &app.session.doc.name, "Esc");
|
||||
publish("home:back", &response);
|
||||
if response.clicked() {
|
||||
*action = Some(FileAction::Back);
|
||||
}
|
||||
let primary = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl+" };
|
||||
let shift = if cfg!(target_os = "macos") { "⇧" } else { "Shift+" };
|
||||
let recover: start_page::Label<'_> = if app.recoverable.is_empty() {
|
||||
app.tr_label("Recover").into()
|
||||
let recover = if app.recoverable.is_empty() {
|
||||
app.tr_label("Recover").to_string()
|
||||
} else {
|
||||
format!("{} ({})", app.tr_label("Recover"), app.recoverable.len()).into()
|
||||
format!("{} ({})", app.tr_label("Recover"), app.recoverable.len())
|
||||
};
|
||||
let nav: [(&str, start_page::Label<'_>, String, Option<Section>, bool, FileAction); 12] = [
|
||||
("home:start", app.tr_label("Start").into(), String::new(), Some(Section::Start), true, FileAction::Section(Section::Start)),
|
||||
("home:new", app.tr_label("New").into(), format!("{primary}N"), None, true, FileAction::New("preferred")),
|
||||
("home:open", app.tr("ui.open").into(), format!("{primary}O"), None, true, FileAction::Open),
|
||||
("home:recover", recover, String::new(), Some(Section::Recovery), true, FileAction::Section(Section::Recovery)),
|
||||
("home:save", app.tr("ui.save").into(), format!("{primary}S"), None, design, FileAction::Save),
|
||||
("home:save_as", app.tr("ui.save_as").into(), format!("{primary}{shift}S"), None, design, FileAction::SaveAs),
|
||||
("home:export", app.tr("home.export").into(), String::new(), Some(Section::Export), design, FileAction::Section(Section::Export)),
|
||||
("home:properties", app.tr("home.properties").into(), String::new(), None, design, FileAction::Properties),
|
||||
("home:settings", app.tr("home.settings").into(), format!("{primary},"), None, true, FileAction::Settings),
|
||||
("home:shortcuts", app.tr("home.shortcuts").into(), String::new(), None, true, FileAction::Shortcuts),
|
||||
("home:crashes", app.tr("home.crashes").into(), String::new(), None, true, FileAction::Crashes),
|
||||
("home:about", app.tr_label("About SolidCraft").into(), String::new(), None, true, FileAction::About),
|
||||
let nav = [
|
||||
("home:start", app.tr_label("Start"), String::new(), Some(Section::Start), true, FileAction::Section(Section::Start)),
|
||||
("home:new", app.tr_label("New"), format!("{primary}N"), None, true, FileAction::New("preferred")),
|
||||
("home:open", app.tr("ui.open"), format!("{primary}O"), None, true, FileAction::Open),
|
||||
("home:recover", recover.as_str(), String::new(), Some(Section::Recovery), true, FileAction::Section(Section::Recovery)),
|
||||
("home:save", app.tr("ui.save"), format!("{primary}S"), None, design, FileAction::Save),
|
||||
("home:save_as", app.tr("ui.save_as"), format!("{primary}{shift}S"), None, design, FileAction::SaveAs),
|
||||
("home:export", app.tr("home.export"), String::new(), Some(Section::Export), design, FileAction::Section(Section::Export)),
|
||||
("home:properties", app.tr("home.properties"), String::new(), None, design, FileAction::Properties),
|
||||
("home:settings", app.tr("home.settings"), format!("{primary},"), None, true, FileAction::Settings),
|
||||
("home:shortcuts", app.tr("home.shortcuts"), String::new(), None, true, FileAction::Shortcuts),
|
||||
("home:crashes", app.tr("home.crashes"), String::new(), None, true, FileAction::Crashes),
|
||||
("home:about", app.tr_label("About SolidCraft"), String::new(), None, true, FileAction::About),
|
||||
];
|
||||
let mut rail = Vec::with_capacity(nav.len() + 2);
|
||||
for (index, (id, label, shortcut, section, enabled, chosen)) in nav.into_iter().enumerate() {
|
||||
for (index, (id, label, key, section, enabled, picked)) in nav.into_iter().enumerate() {
|
||||
if index == 4 || index == 8 {
|
||||
rail.push(start_page::RailItem::Separator);
|
||||
FileView::separator(ui);
|
||||
}
|
||||
let response = FileView::nav_row(ui, id, label, &key, section == Some(app.home.section), enabled && !busy);
|
||||
publish(id, &response);
|
||||
if response.clicked() {
|
||||
*action = Some(picked);
|
||||
}
|
||||
rail.push(start_page::RailItem::Entry(start_page::RailEntry {
|
||||
id: id.into(),
|
||||
label,
|
||||
shortcut: shortcut.into(),
|
||||
selected: section == Some(app.home.section),
|
||||
enabled: enabled && !busy,
|
||||
chosen,
|
||||
}));
|
||||
}
|
||||
rail
|
||||
}
|
||||
|
||||
fn start_body(app: &SolveApp) -> start_page::Start<'_, FileAction> {
|
||||
let banner = app.recoverable.first().map(|entry| {
|
||||
fn publish(id: &str, response: &egui::Response) {
|
||||
crate::scenario::publish_handle(id, response.rect.center());
|
||||
crate::scenario::publish_rect(id, response.rect);
|
||||
}
|
||||
|
||||
fn content(app: &mut SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>, narrow: bool) {
|
||||
let t = Tokens::get();
|
||||
FileView::content(ui, narrow, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("home_content").auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let (title, hint) = match app.home.section {
|
||||
Section::Start => (app.tr_label("Start"), app.tr("home.start_hint")),
|
||||
Section::Recovery => (app.tr_label("Recover"), app.tr("home.recovery_hint")),
|
||||
Section::Export => (app.tr("home.export"), app.tr("home.export_hint")),
|
||||
};
|
||||
FileView::title(ui, title);
|
||||
ui.add_space(t.space_6);
|
||||
FileView::subtitle(ui, hint);
|
||||
ui.add_space(t.file_section_gap);
|
||||
if let Some((message, _, true)) = &app.status {
|
||||
ui_kit::text(ui, message, ui_kit::Text::Error("home_status"));
|
||||
}
|
||||
match app.home.section {
|
||||
Section::Start => start_content(app, ui, action),
|
||||
Section::Recovery => recovery_content(app, ui, action),
|
||||
Section::Export => export_content(app, ui, action),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn start_content(app: &mut SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>) {
|
||||
let t = Tokens::get();
|
||||
if let Some(entry) = app.recoverable.first().cloned() {
|
||||
let title = solidcraft_ui_text::format("home.recovery_count", &[("count", &app.recoverable.len().to_string())]);
|
||||
let filename = entry
|
||||
.path
|
||||
.as_deref()
|
||||
.and_then(|path| std::path::Path::new(path).file_name())
|
||||
.map(|name| name.to_string_lossy())
|
||||
.unwrap_or_else(|| entry.name.as_str().into());
|
||||
let single = app.recoverable.len() == 1;
|
||||
start_page::Banner {
|
||||
title: solidcraft_ui_text::format("home.recovery_count", &[("count", &app.recoverable.len().to_string())]).into(),
|
||||
before_filename: " · ".into(),
|
||||
filename,
|
||||
after_filename: format!(" · {}", crate::recovery_ui::ago(entry.saved_at)).into(),
|
||||
recover: (app.tr_label("Recover").into(), if single { FileAction::Recovery(0, true) } else { FileAction::Section(Section::Recovery) }),
|
||||
discard: single.then(|| (app.tr_label("Discard").into(), FileAction::Recovery(0, false))),
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| entry.name.clone());
|
||||
let metadata = format!(" · {}", crate::recovery_ui::ago(entry.saved_at));
|
||||
let (recover, discard) = FileView::recovery_banner(
|
||||
ui,
|
||||
"home:recovery",
|
||||
RecoveryMessage { title: &title, before_filename: " · ", filename: &filename, after_filename: &metadata },
|
||||
app.tr_label("Recover"),
|
||||
(app.recoverable.len() == 1).then(|| app.tr_label("Discard")),
|
||||
);
|
||||
publish("home:recovery:recover", &recover);
|
||||
if recover.clicked() {
|
||||
*action =
|
||||
Some(if app.recoverable.len() == 1 { FileAction::Recovery(entry.clone(), true) } else { FileAction::Section(Section::Recovery) });
|
||||
}
|
||||
});
|
||||
if let Some(discard) = discard {
|
||||
publish("home:recovery:discard", &discard);
|
||||
if discard.clicked() {
|
||||
*action = Some(FileAction::Recovery(entry, false));
|
||||
}
|
||||
}
|
||||
ui.add_space(t.file_section_gap);
|
||||
}
|
||||
let shortcut = if cfg!(target_os = "macos") { "⌘O" } else { "Ctrl+O" };
|
||||
let open_hint = solidcraft_ui_text::format("home.open_hint", &[("shortcut", shortcut)]);
|
||||
let tiles = [
|
||||
("home:new_mm", "new", app.tr("home.new_mm"), start_page::Label::from(app.tr("home.new_mm_hint")), true, FileAction::New("mm")),
|
||||
("home:new_inches", "new", app.tr("home.new_inches"), app.tr("home.new_inches_hint").into(), false, FileAction::New("in")),
|
||||
("home:open_tile", "open", app.tr("home.open"), open_hint.into(), false, FileAction::Open),
|
||||
("home:import", "import", app.tr("home.import"), app.tr("home.import_hint").into(), false, FileAction::Import),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(id, icon, title, hint, primary, chosen)| start_page::Tile {
|
||||
id: id.into(),
|
||||
icon: start_page::Icon::Kit(icon),
|
||||
title: title.into(),
|
||||
hint,
|
||||
primary,
|
||||
chosen,
|
||||
})
|
||||
.collect();
|
||||
let recent = app.home.recent.iter().enumerate().map(|(index, path)| recent_entry(app, index, path)).collect();
|
||||
let samples = solidcraft_engine::sample::samples()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, sample)| start_page::Sample {
|
||||
title: app.tr_label(sample.name).into(),
|
||||
description: (if app.home.wanted == Some(index) { app.tr("home.opening") } else { app.tr_label(sample.about) }).into(),
|
||||
icon: start_page::Icon::Kit("box"),
|
||||
thumbnail: texture(app.home.thumbs.get(&format!("sample:{index}")).and_then(Option::as_ref)),
|
||||
chosen: FileAction::Sample(index),
|
||||
})
|
||||
.collect();
|
||||
start_page::Start {
|
||||
banner,
|
||||
actions_title: app.tr_label("New").into(),
|
||||
actions: start_page::Tiles { grid: "home_new", tiles },
|
||||
recent_title: app.tr("home.recent").into(),
|
||||
recent_empty: app.tr("home.recent_empty").into(),
|
||||
recent,
|
||||
samples_title: app.tr("home.samples").into(),
|
||||
samples,
|
||||
}
|
||||
}
|
||||
|
||||
fn recent_entry<'a>(app: &'a SolveApp, index: usize, path: &'a str) -> start_page::Recent<'a, FileAction> {
|
||||
// Keep the extension so plate.step and plate.stl are not both "plate" (#9081).
|
||||
let title = std::path::Path::new(path).file_name().map_or_else(|| path.into(), |name| name.to_string_lossy());
|
||||
let missing = !available(app, path);
|
||||
let when = if app.session.path.as_deref() == Some(path) { app.tr("home.open_now").to_string() } else { recent_time(app, path) };
|
||||
let shown_path: start_page::Label<'a> = if missing {
|
||||
format!("{} · {}", app.tr("home.missing"), friendly_path(path)).into()
|
||||
} else if path.starts_with("/disk/") {
|
||||
app.tr_label("On your computer").into()
|
||||
} else if path.starts_with("/opfs/") {
|
||||
app.tr("home.in_this_browser").into()
|
||||
} else {
|
||||
friendly_path(path).into()
|
||||
};
|
||||
let mut menu =
|
||||
vec![start_page::MenuEntry { label: app.tr_label("Remove from list").into(), icon: "delete", chosen: FileAction::ForgetRecent(index) }];
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
menu.push(start_page::MenuEntry { label: app.tr_label("Show in folder").into(), icon: "folder", chosen: FileAction::ShowInFolder(index) });
|
||||
}
|
||||
start_page::Recent {
|
||||
title,
|
||||
path: shown_path,
|
||||
when: when.into(),
|
||||
kind: recent_kind(app, path).into(),
|
||||
thumbnail: texture(app.home.thumbs.get(path).and_then(Option::as_ref)),
|
||||
// A design whose picture could not be made shows a dimmed placeholder, distinct from the
|
||||
// one shown while a picture is still loading (#4655).
|
||||
thumbnail_failed: app.home.thumb_failed(path),
|
||||
chosen: FileAction::OpenRecent(index),
|
||||
menu,
|
||||
}
|
||||
("home:new_mm", "new", app.tr("home.new_mm"), app.tr("home.new_mm_hint"), true),
|
||||
("home:new_inches", "new", app.tr("home.new_inches"), app.tr("home.new_inches_hint"), false),
|
||||
("home:open_tile", "open", app.tr("home.open"), open_hint.as_str(), false),
|
||||
("home:import", "import", app.tr("home.import"), app.tr("home.import_hint"), false),
|
||||
];
|
||||
FileView::section(ui, app.tr_label("New"), t.space_12, |ui| {
|
||||
FileView::grid(ui, "home_new", FileView::tile_min_width(), tiles.len(), |ui, index, width| {
|
||||
let Some((id, icon, title, hint, primary)) = tiles.get(index) else { return };
|
||||
let response = FileView::action_tile(ui, id, width, icon, title, hint, *primary);
|
||||
publish(id, &response);
|
||||
if response.clicked() {
|
||||
*action = Some(match index {
|
||||
0 => FileAction::New("mm"),
|
||||
1 => FileAction::New("in"),
|
||||
2 => FileAction::Open,
|
||||
_ => FileAction::Import,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(t.file_section_gap);
|
||||
FileView::section(ui, app.tr("home.recent"), t.space_10, |ui| {
|
||||
if app.home.recent.is_empty() {
|
||||
FileView::detail(ui, app.tr("home.recent_empty"));
|
||||
}
|
||||
if !app.home.recent.is_empty() {
|
||||
FileView::recent_list(ui, |ui| {
|
||||
for (index, path) in app.home.recent.clone().iter().enumerate() {
|
||||
// Keep the extension so plate.step and plate.stl are not both "plate" (#9081).
|
||||
let name = std::path::Path::new(path).file_name().map(|name| name.to_string_lossy()).unwrap_or_else(|| path.as_str().into());
|
||||
let missing = !available(app, path);
|
||||
let when = if app.session.path.as_ref() == Some(path) { app.tr("home.open_now").to_string() } else { recent_time(app, path) };
|
||||
let shown_path = if missing {
|
||||
format!("{} · {}", app.tr("home.missing"), friendly_path(path))
|
||||
} else if path.starts_with("/disk/") {
|
||||
app.tr_label("On your computer").to_string()
|
||||
} else if path.starts_with("/opfs/") {
|
||||
app.tr("home.in_this_browser").to_string()
|
||||
} else {
|
||||
friendly_path(path)
|
||||
};
|
||||
let kind = recent_kind(app, path);
|
||||
let response = FileView::recent_row(
|
||||
ui,
|
||||
&format!("home:recent:{index}"),
|
||||
ui_kit::RecentRow {
|
||||
title: &name,
|
||||
path: &shown_path,
|
||||
when: &when,
|
||||
workspace: &kind,
|
||||
thumbnail: texture(app.home.thumbs.get(path).and_then(Option::as_ref)),
|
||||
// A design whose picture could not be made shows a dimmed placeholder,
|
||||
// distinct from the one shown while a picture is still loading (#4655).
|
||||
thumbnail_failed: app.home.thumb_failed(path),
|
||||
},
|
||||
);
|
||||
publish(&format!("home:recent:{index}"), &response);
|
||||
ui_kit::Menu::context(&response, |ui| {
|
||||
if crate::context_menu::button(ui, app.tr_label("Remove from list"), "delete").clicked() {
|
||||
app.home.forget(path);
|
||||
ui.close();
|
||||
}
|
||||
if !cfg!(target_arch = "wasm32") && crate::context_menu::button(ui, app.tr_label("Show in folder"), "folder").clicked() {
|
||||
if let Some(show) = &app.services.show_in_folder {
|
||||
show(path);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if response.clicked() {
|
||||
open_recent(app, path);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(t.file_section_gap);
|
||||
FileView::section(ui, app.tr("home.samples"), t.space_10, |ui| {
|
||||
let samples: Vec<_> = solidcraft_engine::sample::samples()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, sample)| {
|
||||
let hint = if app.home.wanted == Some(index) { app.tr("home.opening") } else { app.tr_label(sample.about) };
|
||||
ui_kit::SampleCard {
|
||||
title: app.tr_label(sample.name),
|
||||
description: hint,
|
||||
icon: "box",
|
||||
thumbnail: texture(app.home.thumbs.get(&format!("sample:{index}")).and_then(Option::as_ref)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for (index, response) in FileView::sample_grid(ui, "home:sample", &samples).into_iter().enumerate() {
|
||||
publish(&format!("home:sample:{index}"), &response);
|
||||
if response.clicked() {
|
||||
open_sample(app, index);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn texture(texture: Option<&egui::TextureHandle>) -> Option<(egui::TextureId, egui::Vec2)> {
|
||||
@@ -938,55 +1007,62 @@ fn recent_time_at(app: &SolveApp, time: u64, now: u64) -> String {
|
||||
solidcraft_ui_text::format(key, &[("count", &count.to_string())])
|
||||
}
|
||||
|
||||
fn recovery_body(app: &SolveApp) -> start_page::Reviews<'_, FileAction> {
|
||||
let items = app
|
||||
.recoverable
|
||||
.iter()
|
||||
.take(20)
|
||||
.enumerate()
|
||||
.map(|(index, entry)| start_page::Review {
|
||||
title: entry.name.as_str().into(),
|
||||
detail: solidcraft_ui_text::format(
|
||||
"recovery.detail",
|
||||
&[
|
||||
("file", entry.path.as_deref().unwrap_or(app.tr("recovery.never_saved"))),
|
||||
("features", &entry.features.to_string()),
|
||||
("when", &crate::recovery_ui::ago(entry.saved_at)),
|
||||
],
|
||||
)
|
||||
.into(),
|
||||
recover: (app.tr_label("Recover").into(), FileAction::Recovery(index, true)),
|
||||
discard: (app.tr_label("Discard").into(), FileAction::Recovery(index, false)),
|
||||
})
|
||||
.collect();
|
||||
start_page::Reviews { empty: app.tr("home.recovery_empty").into(), items }
|
||||
fn recovery_content(app: &SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>) {
|
||||
if app.recoverable.is_empty() {
|
||||
ui_kit::text(ui, app.tr("home.recovery_empty"), ui_kit::Text::Inline);
|
||||
}
|
||||
for (index, entry) in app.recoverable.iter().take(20).enumerate() {
|
||||
let detail = solidcraft_ui_text::format(
|
||||
"recovery.detail",
|
||||
&[
|
||||
("file", entry.path.as_deref().unwrap_or(app.tr("recovery.never_saved"))),
|
||||
("features", &entry.features.to_string()),
|
||||
("when", &crate::recovery_ui::ago(entry.saved_at)),
|
||||
],
|
||||
);
|
||||
let (recover, discard) =
|
||||
FileView::recovery_review(ui, &format!("home:recovery:{index}"), &entry.name, &detail, app.tr_label("Recover"), app.tr_label("Discard"));
|
||||
for (id, response, recover) in [("recover", Some(recover), true), ("discard", discard, false)] {
|
||||
let Some(response) = response else { continue };
|
||||
publish(&format!("home:recovery:{index}:{id}"), &response);
|
||||
if response.clicked() {
|
||||
*action = Some(FileAction::Recovery(entry.clone(), recover));
|
||||
}
|
||||
}
|
||||
ui.add_space(Tokens::get().space_12);
|
||||
}
|
||||
}
|
||||
|
||||
fn export_tiles(app: &SolveApp) -> start_page::Tiles<'_, FileAction> {
|
||||
fn export_content(app: &SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>) {
|
||||
let formats = [
|
||||
("step", "STEP", Some("home.export_step_hint"), FileAction::Export("step")),
|
||||
("mesh", "Mesh", Some("home.export_mesh_hint"), FileAction::Mesh),
|
||||
("view_dxf", "DXF", None, FileAction::ViewDxf),
|
||||
("obj", "OBJ", None, FileAction::Export("obj")),
|
||||
("urdf", "URDF", None, FileAction::Export("urdf")),
|
||||
("json", "JSON", Some("home.export_json_hint"), FileAction::Export("json")),
|
||||
("step", "STEP", Some("home.export_step_hint")),
|
||||
("mesh", "Mesh", Some("home.export_mesh_hint")),
|
||||
("view_dxf", "DXF", None),
|
||||
("obj", "OBJ", None),
|
||||
("urdf", "URDF", None),
|
||||
("json", "JSON", Some("home.export_json_hint")),
|
||||
];
|
||||
let tiles = formats
|
||||
.into_iter()
|
||||
.map(|(id, format, hint, chosen)| start_page::Tile {
|
||||
id: format!("home:export:{id}").into(),
|
||||
icon: start_page::Icon::Kit("export"),
|
||||
title: if id == "view_dxf" {
|
||||
app.tr_label("Export Current View as DXF…").into()
|
||||
} else {
|
||||
format!("{} {}…", app.tr("home.export"), app.tr_label(format)).into()
|
||||
},
|
||||
hint: hint.map_or(format, |key| app.tr(key)).into(),
|
||||
primary: false,
|
||||
chosen,
|
||||
})
|
||||
.collect();
|
||||
start_page::Tiles { grid: "home_exports", tiles }
|
||||
FileView::grid(ui, "home_exports", FileView::tile_min_width(), formats.len(), |ui, index, width| {
|
||||
let Some((id, format, hint)) = formats.get(index) else { return };
|
||||
let label = if *id == "view_dxf" {
|
||||
app.tr_label("Export Current View as DXF…").to_string()
|
||||
} else {
|
||||
format!("{} {}…", app.tr("home.export"), app.tr_label(format))
|
||||
};
|
||||
let subtitle = hint.map(|key| app.tr(key)).unwrap_or(format);
|
||||
let response = FileView::action_tile(ui, &format!("home:export:{id}"), width, "export", &label, subtitle, false);
|
||||
publish(&format!("home:export:{id}"), &response);
|
||||
if response.clicked() {
|
||||
*action = Some(match *id {
|
||||
"step" => FileAction::Export("step"),
|
||||
"mesh" => FileAction::Mesh,
|
||||
"view_dxf" => FileAction::ViewDxf,
|
||||
"obj" => FileAction::Export("obj"),
|
||||
"urdf" => FileAction::Export("urdf"),
|
||||
_ => FileAction::Export("json"),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn dispatch(app: &mut SolveApp, ctx: &egui::Context, action: FileAction) {
|
||||
@@ -1050,27 +1126,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, action: FileAction) {
|
||||
}
|
||||
FileAction::Crashes => crate::help::run(app, ctx, "crashes"),
|
||||
FileAction::About => crate::help::run(app, ctx, "about"),
|
||||
FileAction::Recovery(index, recover) => {
|
||||
if let Some(entry) = app.recoverable.get(index).cloned() {
|
||||
crate::recovery_ui::apply(app, entry, recover);
|
||||
}
|
||||
}
|
||||
FileAction::OpenRecent(index) => {
|
||||
if let Some(path) = app.home.recent.get(index).cloned() {
|
||||
open_recent(app, &path);
|
||||
}
|
||||
}
|
||||
FileAction::ForgetRecent(index) => {
|
||||
if let Some(path) = app.home.recent.get(index).cloned() {
|
||||
app.home.forget(&path);
|
||||
}
|
||||
}
|
||||
FileAction::ShowInFolder(index) => {
|
||||
if let (Some(show), Some(path)) = (&app.services.show_in_folder, app.home.recent.get(index)) {
|
||||
show(path);
|
||||
}
|
||||
}
|
||||
FileAction::Sample(index) => open_sample(app, index),
|
||||
FileAction::Recovery(entry, recover) => crate::recovery_ui::apply(app, entry, recover),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ pub(crate) const DRAG_ZOOM: &str = "view.tool.zoom";
|
||||
/// Keys the application handles before any command shortcut.
|
||||
pub const RESERVED: &[&str] = &[
|
||||
"Escape",
|
||||
"S",
|
||||
"V",
|
||||
"F2",
|
||||
"F6",
|
||||
@@ -1883,43 +1884,6 @@ mod tests {
|
||||
h.frames(1);
|
||||
assert!(h.app.ui.palette_open && h.app.dialog.is_none(), "typing in a field never repeats");
|
||||
}
|
||||
|
||||
/// Command search is an ordinary command (#12190): S opens it out of the box, Preferences ›
|
||||
/// Shortcuts can move it, and S is then free for another command.
|
||||
#[test]
|
||||
fn command_search_can_move_off_s_and_s_can_start_another_command() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.frames(2);
|
||||
assert_eq!(effective(&h.app, "ui.palette").as_deref(), Some("S"));
|
||||
h.step(&json!({"key": "S"})).unwrap();
|
||||
assert!(h.app.ui.palette_open, "S opens command search by default");
|
||||
h.call("ui.palette", json!({"open": false}));
|
||||
h.frames(2);
|
||||
bind(&mut h.app, "ui.palette", "Shift+J", true).unwrap();
|
||||
h.step(&json!({"key": "S"})).unwrap();
|
||||
assert!(!h.app.ui.palette_open, "S no longer opens command search");
|
||||
h.step(&json!({"key": "J", "shift": true})).unwrap();
|
||||
assert!(h.app.ui.palette_open, "its new key does");
|
||||
h.call("ui.palette", json!({"open": false}));
|
||||
h.frames(2);
|
||||
// The closed field shows the key command search has now, and no key once it has none.
|
||||
let width = || crate::scenario::rect_of("ribbon:search").map(|r| r.width());
|
||||
let shown = display_for(&h.app, "ui.palette");
|
||||
assert_eq!(shown.as_deref(), Some("Shift+J"));
|
||||
assert_eq!(width(), Some(crate::ui_kit::CommandSearch::closed_width(&h.ctx, shown.as_deref())));
|
||||
assert!(width() > Some(crate::ui_kit::CommandSearch::closed_width(&h.ctx, Some("S"))), "not the old S");
|
||||
bind(&mut h.app, "ui.palette", "", false).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(width(), Some(crate::ui_kit::CommandSearch::closed_width(&h.ctx, None)));
|
||||
bind(&mut h.app, "ui.palette", "Shift+J", true).unwrap();
|
||||
h.frames(2);
|
||||
bind(&mut h.app, "solid.box", "S", true).unwrap();
|
||||
h.step(&json!({"key": "S"})).unwrap();
|
||||
h.frames(1);
|
||||
assert_eq!(h.app.dialog.as_ref().and_then(|d| d.command()), Some("solid.box"), "S starts the command given it");
|
||||
assert!(!h.app.ui.palette_open);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -192,7 +192,6 @@ mod file_load_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod fillet_spline_handle_tests;
|
||||
pub mod mouse_gesture;
|
||||
mod nav_bar;
|
||||
pub(crate) mod navigation;
|
||||
pub mod navigation_bar;
|
||||
mod navigation_bar_view;
|
||||
@@ -293,7 +292,6 @@ pub mod space_mouse_calibration;
|
||||
pub mod space_mouse_model;
|
||||
pub mod space_mouse_offer;
|
||||
pub mod stand_up;
|
||||
pub mod start_page;
|
||||
mod status_bar;
|
||||
#[cfg(test)]
|
||||
mod sweep_dialog_tests;
|
||||
@@ -320,7 +318,6 @@ mod ui_kit_guard_tests;
|
||||
pub mod update;
|
||||
mod value_step;
|
||||
pub(crate) mod view_angles;
|
||||
pub mod view_compass;
|
||||
mod view_confirm;
|
||||
pub mod view_gizmo;
|
||||
pub mod view_pick;
|
||||
|
||||
@@ -1821,32 +1821,6 @@ fn appearance_browser_selection_reaches_the_editor_without_other_bodies() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #12334: Save As Mesh and Send to Slicer on a selected browser row open their dialog with every
|
||||
/// selected body; a row outside the selection, or a lone selected one, opens it with that row only.
|
||||
#[test]
|
||||
fn mesh_export_from_a_selected_browser_body_takes_the_selected_bodies() {
|
||||
let cases = [
|
||||
("One", vec!["One", "Two"], vec!["One", "Two"]),
|
||||
("Two", vec!["One", "Two"], vec!["One", "Two"]),
|
||||
("Other", vec!["One", "Two"], vec!["Other"]),
|
||||
("One", vec!["One"], vec!["One"]),
|
||||
];
|
||||
for label in ["Send to Slicer", "Save As Mesh"] {
|
||||
for (clicked, selected, expected) in &cases {
|
||||
let mut app = appearance_selection_app();
|
||||
// Send to Slicer needs a way to start a program.
|
||||
app.services.pick_program = Some(Box::new(|| Box::pin(async { None })));
|
||||
let items: Vec<_> = selected.iter().map(|name| json!({"type":"body","name":name})).collect();
|
||||
app.run("select.set", json!({ "items": items })).unwrap();
|
||||
let item = find(&context_menu::items(&app, &Target::Body { name: (*clicked).into() }), label);
|
||||
context_menu::run_item(&mut app, &item, pos2(0.0, 0.0));
|
||||
let dialog = app.dialog.as_ref().unwrap_or_else(|| panic!("{label} opens its dialog"));
|
||||
let bodies: Vec<_> = expected.iter().map(|name| solidcraft_engine::Sel::Body { name: (*name).into() }).collect();
|
||||
assert_eq!(dialog.inputs[0].items, bodies, "{label}: clicked {clicked}, selected {selected:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_parent_click_opens_editor_after_hovering_quick_choices() {
|
||||
use crate::scenario::{Harness, rect_of};
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
//! The navigation bar as a component. The app measures the row, describes each slot and each
|
||||
//! menu as plain data ([`Bar`], [`SlotView`], [`Menu`]) and runs the [`NavAction`] the bar
|
||||
//! returns; the bar keeps which menu is open and reads nothing of the app. Every slot, menu row
|
||||
//! and context item publishes its rect to the widget registry while it is drawn.
|
||||
use crate::ui_kit::{self, BackingTooltipExt as _, Container, NavigationEdge, NavigationIdle, NavigationKind, NavigationSize, Surface};
|
||||
use egui::{Align2, Rect, pos2, vec2};
|
||||
|
||||
/// One slot as the bar draws it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SlotView<'a> {
|
||||
/// Where the slot is in the stored list (what Remove removes).
|
||||
pub index: usize,
|
||||
pub id: &'a str,
|
||||
pub kind: NavigationKind,
|
||||
pub icon: &'a str,
|
||||
/// The text on the button (or beside it, for a menu showing its value).
|
||||
pub text: String,
|
||||
/// The slot's name for assistive technology.
|
||||
pub name: String,
|
||||
/// Hover text, and the shortcut shown with it.
|
||||
pub tip: String,
|
||||
pub key: Option<String>,
|
||||
/// Its tool or toggle is on.
|
||||
pub selected: bool,
|
||||
/// Inert for now (drawn faded, still clickable).
|
||||
pub enabled: bool,
|
||||
/// Editing only: the slot is not shown on this canvas.
|
||||
pub hidden: bool,
|
||||
/// The button shows its text.
|
||||
pub show_text: bool,
|
||||
/// Where it is drawn (unused for a slot in the More menu).
|
||||
pub rect: Rect,
|
||||
/// A second registry name its handle is published under, wherever it is drawn.
|
||||
pub alias: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Labels of the bar's own controls, in the app's language.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BarLabels {
|
||||
pub more: String,
|
||||
pub customise: String,
|
||||
pub hide: String,
|
||||
pub reset: String,
|
||||
pub remove: String,
|
||||
}
|
||||
|
||||
/// A measured bar and its slots.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Bar<'a> {
|
||||
pub rect: Rect,
|
||||
pub size: NavigationSize,
|
||||
pub edge: NavigationEdge,
|
||||
pub idle: NavigationIdle,
|
||||
/// The app's reasons to show the bar at full strength (the view is being turned, the pointer
|
||||
/// is over the view compass); the pointer over the bar and an open menu count on their own.
|
||||
pub active: bool,
|
||||
pub slots: Vec<SlotView<'a>>,
|
||||
/// The slots folded into More, in order.
|
||||
pub overflow: Vec<SlotView<'a>>,
|
||||
/// The More button (`Rect::NOTHING`: nothing folded).
|
||||
pub more: Rect,
|
||||
pub labels: BarLabels,
|
||||
}
|
||||
|
||||
/// A menu a slot opens.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Menu {
|
||||
pub entries: Vec<MenuEntry>,
|
||||
/// A pick closes the menu (otherwise it stays open for the next).
|
||||
pub close_on_pick: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MenuEntry {
|
||||
Heading(String),
|
||||
Separator,
|
||||
Row(MenuRow),
|
||||
}
|
||||
|
||||
/// One row of a slot's menu.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MenuRow {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub icon: String,
|
||||
pub selected: bool,
|
||||
pub enabled: bool,
|
||||
/// Text at the row's end (a current value).
|
||||
pub detail: Option<String>,
|
||||
pub tip: Option<String>,
|
||||
/// The registry name its rect is published under.
|
||||
pub target: String,
|
||||
/// Rows of the menu this row opens (empty: the row is picked itself).
|
||||
pub submenu: Vec<MenuRow>,
|
||||
}
|
||||
|
||||
impl MenuRow {
|
||||
pub fn new(id: impl Into<String>, label: impl Into<String>, icon: impl Into<String>, target: impl Into<String>) -> MenuRow {
|
||||
MenuRow { id: id.into(), label: label.into(), icon: icon.into(), enabled: true, target: target.into(), ..MenuRow::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// An item of the bar's context menu.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ContextItem {
|
||||
Customise,
|
||||
Hide,
|
||||
Reset,
|
||||
/// Remove the slot at this index of the stored list.
|
||||
Remove(usize),
|
||||
}
|
||||
|
||||
/// What the user chose on the bar this frame.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum NavAction {
|
||||
/// A slot that is not a menu was clicked (on the bar or in More).
|
||||
Slot(String),
|
||||
/// Row `item` of the menu of slot `menu` (or of its submenu) was picked.
|
||||
Item {
|
||||
menu: String,
|
||||
item: String,
|
||||
},
|
||||
Context(ContextItem),
|
||||
}
|
||||
|
||||
/// Paint the bar's surface and its slots; the slots' responses in order. The live bar and the
|
||||
/// Preferences editor (`editing`: the selected slot's index, if any) share it.
|
||||
pub fn paint_slots(
|
||||
ui: &mut egui::Ui,
|
||||
rect: Rect,
|
||||
size: NavigationSize,
|
||||
edge: NavigationEdge,
|
||||
slots: &[SlotView<'_>],
|
||||
editing: Option<Option<usize>>,
|
||||
) -> Vec<egui::Response> {
|
||||
ui_kit::navigation_surface(ui, rect, size, editing.is_some());
|
||||
let mut responses = Vec::new();
|
||||
for slot in slots {
|
||||
let response = ui
|
||||
.push_id(slot.index, |ui| {
|
||||
ui.scope(|ui| {
|
||||
if slot.hidden {
|
||||
ui.set_opacity(ui_kit::NAV_HIDDEN_OPACITY);
|
||||
} else if !slot.enabled {
|
||||
ui.set_opacity(ui.visuals().disabled_alpha);
|
||||
}
|
||||
if slot.kind == NavigationKind::Separator {
|
||||
ui_kit::navigation_separator(ui, slot.rect);
|
||||
ui.interact(
|
||||
slot.rect,
|
||||
ui.scope_id().with("separator"),
|
||||
if editing.is_some() { egui::Sense::click_and_drag() } else { egui::Sense::click() },
|
||||
)
|
||||
} else {
|
||||
let button = ui_kit::NavigationButton {
|
||||
id: slot.id,
|
||||
kind: slot.kind,
|
||||
icon: slot.icon,
|
||||
label: &slot.text,
|
||||
selected: slot.selected,
|
||||
labels: slot.show_text,
|
||||
menu: slot.kind == NavigationKind::Menu,
|
||||
};
|
||||
if editing.is_some() {
|
||||
ui_kit::navigation_button_editable(ui, slot.rect, button, true)
|
||||
} else {
|
||||
ui_kit::navigation_button(ui, slot.rect, button)
|
||||
}
|
||||
}
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
let chosen = editing.map_or(slot.selected, |selected| selected == Some(slot.index));
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), chosen, &slot.name));
|
||||
if editing.is_some() {
|
||||
ui_kit::navigation_edit_outline(ui, slot.rect, slot.hidden, chosen);
|
||||
}
|
||||
ui_kit::navigation_tooltip(&response, rect, edge, &slot.tip, slot.key.as_deref());
|
||||
responses.push(response);
|
||||
}
|
||||
responses
|
||||
}
|
||||
|
||||
/// Draw the live bar, its More button, its context menu and the open menu (`menu` gives a menu
|
||||
/// slot's rows by its id); what the user chose, for the app to run.
|
||||
pub fn show(ui: &mut egui::Ui, bar: &Bar<'_>, menu: impl FnOnce(&str) -> Menu) -> Option<NavAction> {
|
||||
if !bar.rect.is_positive() {
|
||||
return None;
|
||||
}
|
||||
solidcraft_ui_registry::publish_rect("nav:outer", bar.rect);
|
||||
let menu_id = ui.scope_id().with("navigation_bar_menu");
|
||||
let mut open = ui.data(|d| d.get_temp::<String>(menu_id)).unwrap_or_default();
|
||||
let mut action = None;
|
||||
let active = bar.active || ui.rect_contains_pointer(bar.rect) || !open.is_empty();
|
||||
let opacity = ui_kit::navigation_opacity(bar.idle, active);
|
||||
let background = ui.interact(bar.rect, ui.scope_id().with("navigation_bar_context"), egui::Sense::click());
|
||||
let opacity = if bar.idle == NavigationIdle::Hide {
|
||||
ui.ctx().animate_value_with_time(menu_id.with("opacity"), opacity, if active { ui_kit::NAV_FADE_SECONDS } else { 0.0 })
|
||||
} else {
|
||||
opacity
|
||||
};
|
||||
let mut anchor = bar.more;
|
||||
ui.scope(|ui| {
|
||||
ui.set_opacity(opacity);
|
||||
let shown: Vec<_> = bar.slots.iter().map(|s| SlotView { selected: s.selected || open == s.id, ..s.clone() }).collect();
|
||||
for (slot, response) in shown.iter().zip(paint_slots(ui, bar.rect, bar.size, bar.edge, &shown, None)) {
|
||||
solidcraft_ui_registry::publish_target(&format!("nav:slot:{}", slot.id), response.rect);
|
||||
if let Some(alias) = slot.alias {
|
||||
solidcraft_ui_registry::publish_handle(alias, response.rect.center());
|
||||
}
|
||||
context_menu(&response, Some(slot.index), &bar.labels, &mut action);
|
||||
if response.clicked() && slot.kind != NavigationKind::Separator {
|
||||
if slot.kind == NavigationKind::Menu {
|
||||
open = if open == slot.id { String::new() } else { slot.id.to_owned() };
|
||||
} else {
|
||||
action = Some(NavAction::Slot(slot.id.to_owned()));
|
||||
}
|
||||
}
|
||||
if open == slot.id {
|
||||
anchor = response.rect;
|
||||
}
|
||||
}
|
||||
if bar.more.is_positive() {
|
||||
let response = ui_kit::navigation_button(
|
||||
ui,
|
||||
bar.more,
|
||||
ui_kit::NavigationButton {
|
||||
id: "more",
|
||||
kind: NavigationKind::Menu,
|
||||
icon: "dots",
|
||||
label: &bar.labels.more,
|
||||
selected: open == "more" || bar.overflow.iter().any(|slot| slot.id == open),
|
||||
labels: false,
|
||||
menu: false,
|
||||
},
|
||||
);
|
||||
ui_kit::navigation_tooltip(&response, bar.rect, bar.edge, &bar.labels.more, None);
|
||||
solidcraft_ui_registry::publish_target("nav:more", response.rect);
|
||||
context_menu(&response, None, &bar.labels, &mut action);
|
||||
if response.clicked() {
|
||||
open = if open == "more" { String::new() } else { "more".to_owned() };
|
||||
}
|
||||
}
|
||||
});
|
||||
context_menu(&background, None, &bar.labels, &mut action);
|
||||
if !open.is_empty() && anchor.is_positive() {
|
||||
let child_popup_open = egui::Popup::is_any_open(ui.ctx());
|
||||
let ctx = ui.ctx().clone();
|
||||
// Menus open into the view: up from a bar at the bottom, down from one at the top.
|
||||
let (pivot, at) = match bar.edge {
|
||||
NavigationEdge::Bottom => (Align2::RIGHT_BOTTOM, anchor.right_top() - vec2(0.0, ui_kit::NAV_POPUP_GAP)),
|
||||
NavigationEdge::Top => (Align2::RIGHT_TOP, anchor.right_bottom() + vec2(0.0, ui_kit::NAV_POPUP_GAP)),
|
||||
};
|
||||
let contents = if open == "more" { None } else { Some(menu(&open)) };
|
||||
let popup = Container::new(&ctx, Surface::Popup).area(menu_id.with("popup")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(at).show(
|
||||
&ctx,
|
||||
|ui| {
|
||||
ui_kit::navigation_menu(ui, if open == "more" { ui_kit::NAV_MORE_WIDTH } else { ui_kit::NAV_POPUP_WIDTH }, |ui| match &contents {
|
||||
None => overflow_menu(ui, bar, &mut open, &mut action),
|
||||
Some(m) if m.entries.is_empty() => open.clear(),
|
||||
Some(m) => {
|
||||
if let Some(item) = menu_rows(ui, &m.entries) {
|
||||
action = Some(NavAction::Item { menu: open.clone(), item });
|
||||
if m.close_on_pick {
|
||||
open.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
let outside = !child_popup_open
|
||||
&& ui.input(|i| {
|
||||
i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !popup.response.rect.contains(p) && !bar.rect.contains(p))
|
||||
});
|
||||
if outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
open.clear();
|
||||
}
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(menu_id, open));
|
||||
action
|
||||
}
|
||||
|
||||
/// The context menu of the bar (`index`: `None`) or of a slot: Customise, Hide and Reset on the
|
||||
/// bar, Remove on a slot.
|
||||
fn context_menu(response: &egui::Response, index: Option<usize>, labels: &BarLabels, action: &mut Option<NavAction>) {
|
||||
ui_kit::navigation_context(response, |ui| {
|
||||
let items: Vec<(&str, &str, ContextItem)> = match index {
|
||||
None => vec![
|
||||
("customise", &labels.customise, ContextItem::Customise),
|
||||
("hide", &labels.hide, ContextItem::Hide),
|
||||
("reset", &labels.reset, ContextItem::Reset),
|
||||
],
|
||||
Some(index) => vec![("remove", &labels.remove, ContextItem::Remove(index))],
|
||||
};
|
||||
for (id, label, item) in items {
|
||||
let row = ui_kit::NavigationMenuRow::new(id, label, "").show(ui);
|
||||
solidcraft_ui_registry::publish_target(&format!("nav:context:{id}"), row.rect);
|
||||
if row.clicked() {
|
||||
*action = Some(NavAction::Context(item));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// More: the folded slots as rows. A menu slot opens its menu in place of More.
|
||||
fn overflow_menu(ui: &mut egui::Ui, bar: &Bar<'_>, open: &mut String, action: &mut Option<NavAction>) {
|
||||
for slot in &bar.overflow {
|
||||
if slot.kind == NavigationKind::Separator {
|
||||
let start = ui.next_widget_position();
|
||||
ui_kit::navigation_menu_separator(ui);
|
||||
let bounds = Rect::from_min_max(start, pos2(start.x + ui.available_width(), ui.next_widget_position().y));
|
||||
solidcraft_ui_registry::publish_target(&format!("nav:slot:{}", slot.id), bounds);
|
||||
continue;
|
||||
}
|
||||
let mut item = ui_kit::NavigationMenuRow::new(slot.id, &slot.name, slot.icon);
|
||||
if matches!(slot.kind, NavigationKind::Tool | NavigationKind::Toggle) {
|
||||
item.selected = slot.selected;
|
||||
}
|
||||
let response = item.show(ui);
|
||||
context_menu(&response, Some(slot.index), &bar.labels, action);
|
||||
solidcraft_ui_registry::publish_target(&format!("nav:slot:{}", slot.id), response.rect);
|
||||
if let Some(alias) = slot.alias {
|
||||
solidcraft_ui_registry::publish_handle(alias, response.rect.center());
|
||||
}
|
||||
ui_kit::navigation_tooltip(&response, response.rect, NavigationEdge::Bottom, &slot.tip, slot.key.as_deref());
|
||||
if response.clicked() {
|
||||
if slot.kind == NavigationKind::Menu {
|
||||
*open = slot.id.to_owned();
|
||||
} else {
|
||||
*action = Some(NavAction::Slot(slot.id.to_owned()));
|
||||
open.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A menu's rows; the id of the row picked, if any.
|
||||
fn menu_rows(ui: &mut egui::Ui, entries: &[MenuEntry]) -> Option<String> {
|
||||
let mut picked = None;
|
||||
for entry in entries {
|
||||
match entry {
|
||||
MenuEntry::Heading(title) => ui_kit::navigation_menu_heading(ui, title),
|
||||
MenuEntry::Separator => ui_kit::navigation_menu_separator(ui),
|
||||
MenuEntry::Row(row) => {
|
||||
let response = menu_row(ui, row);
|
||||
if row.submenu.is_empty() {
|
||||
if response.clicked() {
|
||||
picked = Some(row.id.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Container::new(ui.ctx(), Surface::Popup).menu(&response).frame(ui_kit::navigation_popup_frame()).show(|ui| {
|
||||
ui.set_width(ui_kit::NAV_POPUP_WIDTH - 2.0 * (f32::from(ui_kit::NAV_POPUP_PADDING) + ui_kit::NAV_BORDER));
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for sub in &row.submenu {
|
||||
if menu_row(ui, sub).clicked() {
|
||||
picked = Some(sub.id.clone());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
picked
|
||||
}
|
||||
|
||||
fn menu_row(ui: &mut egui::Ui, row: &MenuRow) -> egui::Response {
|
||||
let mut item = ui_kit::NavigationMenuRow::new(&row.id, &row.label, &row.icon);
|
||||
item.selected = row.selected;
|
||||
item.enabled = row.enabled;
|
||||
item.detail = row.detail.as_deref();
|
||||
item.submenu = !row.submenu.is_empty();
|
||||
let response = item.show(ui);
|
||||
let response = match &row.tip {
|
||||
Some(tip) => response.backing_on_hover_text(tip),
|
||||
None => response,
|
||||
};
|
||||
if !row.target.is_empty() {
|
||||
solidcraft_ui_registry::publish_target(&row.target, response.rect);
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -622,186 +622,3 @@ fn navigation_board_drops_menu_value_text_before_folding_slots() {
|
||||
assert!(!folded.values && !folded.overflow.is_empty());
|
||||
assert_eq!(folded.more.size(), vec2(32.0, 32.0));
|
||||
}
|
||||
|
||||
/// The bar component driven with plain data, no app: what each click returns.
|
||||
mod component {
|
||||
use super::*;
|
||||
use crate::nav_bar::{Bar, ContextItem, Menu, MenuEntry, MenuRow, NavAction, SlotView, show};
|
||||
use egui::{Event, PointerButton, RawInput};
|
||||
|
||||
fn slot(index: usize, id: &'static str, kind: NavigationKind, x: f32) -> SlotView<'static> {
|
||||
SlotView {
|
||||
index,
|
||||
id,
|
||||
kind,
|
||||
icon: id,
|
||||
text: id.to_owned(),
|
||||
name: id.to_owned(),
|
||||
tip: id.to_owned(),
|
||||
key: None,
|
||||
selected: false,
|
||||
enabled: true,
|
||||
hidden: false,
|
||||
show_text: false,
|
||||
rect: Rect::from_min_size(pos2(x, 304.0), vec2(26.0, 26.0)),
|
||||
alias: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fit and Styles on the bar; Zoom and View folded into More.
|
||||
fn bar() -> Bar<'static> {
|
||||
Bar {
|
||||
rect: Rect::from_min_size(pos2(90.0, 300.0), vec2(110.0, 34.0)),
|
||||
size: Default::default(),
|
||||
edge: NavigationEdge::Bottom,
|
||||
idle: NavigationIdle::Always,
|
||||
active: false,
|
||||
slots: vec![
|
||||
slot(0, "fit", NavigationKind::Action, 100.0),
|
||||
SlotView { alias: Some("test:styles"), ..slot(1, "styles", NavigationKind::Menu, 134.0) },
|
||||
],
|
||||
overflow: vec![slot(2, "zoom", NavigationKind::Action, 0.0), slot(3, "view", NavigationKind::Menu, 0.0)],
|
||||
more: Rect::from_min_size(pos2(168.0, 304.0), vec2(26.0, 26.0)),
|
||||
labels: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Styles closes on a pick; View stays open and has a submenu.
|
||||
fn menu(id: &str) -> Menu {
|
||||
match id {
|
||||
"styles" => Menu { entries: vec![MenuEntry::Row(MenuRow::new("wire", "Wire", "", "test:wire"))], close_on_pick: true },
|
||||
"view" => {
|
||||
let mut preset = MenuRow::new("preset", "Preset", "", "test:preset");
|
||||
preset.submenu = vec![MenuRow::new("fast", "Fast", "", "test:fast")];
|
||||
Menu {
|
||||
entries: vec![
|
||||
MenuEntry::Heading("Show".into()),
|
||||
MenuEntry::Row(MenuRow::new("grid", "Grid", "", "test:grid")),
|
||||
MenuEntry::Separator,
|
||||
MenuEntry::Row(preset),
|
||||
],
|
||||
close_on_pick: false,
|
||||
}
|
||||
}
|
||||
_ => Menu::default(),
|
||||
}
|
||||
}
|
||||
|
||||
struct Rig {
|
||||
ctx: egui::Context,
|
||||
bar: Bar<'static>,
|
||||
}
|
||||
|
||||
impl Rig {
|
||||
fn new() -> Rig {
|
||||
let rig = Rig { ctx: crate::fonts::test_context(), bar: bar() };
|
||||
rig.frame(Vec::new());
|
||||
rig
|
||||
}
|
||||
|
||||
fn frame(&self, events: Vec<Event>) -> Option<NavAction> {
|
||||
solidcraft_ui_registry::clear_handles();
|
||||
let mut action = None;
|
||||
crate::test_support::frame(
|
||||
&self.ctx,
|
||||
RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0))),
|
||||
events,
|
||||
focused: true,
|
||||
..Default::default()
|
||||
},
|
||||
|ui| action = show(ui, &self.bar, menu),
|
||||
);
|
||||
action
|
||||
}
|
||||
|
||||
/// A press and release of `button` at `at`, then two quiet frames so a popup it opened is
|
||||
/// measured and placed: what the release returned.
|
||||
fn click_with(&self, at: egui::Pos2, button: PointerButton) -> Option<NavAction> {
|
||||
let press = |pressed| vec![Event::PointerMoved(at), Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() }];
|
||||
self.frame(vec![Event::PointerMoved(at)]);
|
||||
let action = self.frame(press(true)).or(self.frame(press(false)));
|
||||
self.frame(Vec::new());
|
||||
self.frame(Vec::new());
|
||||
action
|
||||
}
|
||||
|
||||
fn click(&self, at: egui::Pos2) -> Option<NavAction> {
|
||||
self.click_with(at, PointerButton::Primary)
|
||||
}
|
||||
|
||||
fn click_target(&self, name: &str) -> Option<NavAction> {
|
||||
let at = solidcraft_ui_registry::rect_of(name).expect("the target is drawn").center();
|
||||
self.click(at)
|
||||
}
|
||||
|
||||
fn drawn(&self, name: &str) -> bool {
|
||||
solidcraft_ui_registry::rect_of(name).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slot_or_a_row_of_its_menu_is_returned_and_a_closing_menu_closes() {
|
||||
let rig = Rig::new();
|
||||
assert_eq!(rig.click(pos2(113.0, 317.0)), Some(NavAction::Slot("fit".into())), "an action slot is returned, not run");
|
||||
assert_eq!(rig.click(pos2(147.0, 317.0)), None, "a menu slot opens its menu");
|
||||
assert_eq!(solidcraft_ui_registry::handle_at("test:styles"), Some(pos2(147.0, 317.0)), "a slot's alias names it too");
|
||||
assert_eq!(rig.click_target("test:wire"), Some(NavAction::Item { menu: "styles".into(), item: "wire".into() }));
|
||||
assert!(!rig.drawn("test:wire"), "a pick closes a menu that asks for it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_returns_a_folded_slot_and_opens_a_folded_menu_that_stays_open() {
|
||||
let rig = Rig::new();
|
||||
assert_eq!(rig.click_target("nav:more"), None);
|
||||
assert_eq!(rig.click_target("nav:slot:zoom"), Some(NavAction::Slot("zoom".into())), "a folded slot is returned");
|
||||
assert!(!rig.drawn("nav:slot:zoom"), "and More closes");
|
||||
rig.click_target("nav:more");
|
||||
assert_eq!(rig.click_target("nav:slot:view"), None, "a folded menu slot opens its menu");
|
||||
assert!(rig.drawn("test:grid") && !rig.drawn("nav:slot:zoom"), "in place of More");
|
||||
assert_eq!(rig.click_target("test:grid"), Some(NavAction::Item { menu: "view".into(), item: "grid".into() }));
|
||||
assert!(rig.drawn("test:grid"), "a menu that does not close on a pick stays open");
|
||||
assert_eq!(rig.click_target("test:preset"), None, "a row with a submenu opens it");
|
||||
assert_eq!(rig.click_target("test:fast"), Some(NavAction::Item { menu: "view".into(), item: "fast".into() }), "a submenu row by its own id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_context_menus_return_customise_hide_reset_and_remove_with_the_slot_index() {
|
||||
let rig = Rig::new();
|
||||
for (id, item) in [("customise", ContextItem::Customise), ("hide", ContextItem::Hide), ("reset", ContextItem::Reset)] {
|
||||
rig.click_with(pos2(94.0, 317.0), PointerButton::Secondary);
|
||||
assert!(!rig.drawn("nav:context:remove"), "the bar's own menu");
|
||||
assert_eq!(rig.click_target(&format!("nav:context:{id}")), Some(NavAction::Context(item)));
|
||||
}
|
||||
rig.click_with(pos2(147.0, 317.0), PointerButton::Secondary);
|
||||
assert!(!rig.drawn("nav:context:hide"), "a slot's menu");
|
||||
assert_eq!(rig.click_target("nav:context:remove"), Some(NavAction::Context(ContextItem::Remove(1))), "removes the slot at its stored index");
|
||||
rig.click_target("nav:more");
|
||||
rig.frame(vec![Event::PointerMoved(pos2(0.0, 0.0))]);
|
||||
let folded = solidcraft_ui_registry::rect_of("nav:slot:view").expect("More is open").center();
|
||||
rig.click_with(folded, PointerButton::Secondary);
|
||||
assert_eq!(rig.click_target("nav:context:remove"), Some(NavAction::Context(ContextItem::Remove(3))), "a folded slot too");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_view_menu_slot_is_named_nav_view_on_the_bar_and_in_more() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.ui.show_browser = false;
|
||||
h.app.ui.show_timeline = false;
|
||||
h.call("ui.prefs", json!({"navigation_bar":{"slots":["display","fit","home"]}}));
|
||||
h.settle();
|
||||
let on_bar = solidcraft_ui_registry::handle_at("nav:slot:display").expect("the View slot is on the bar");
|
||||
assert_eq!(solidcraft_ui_registry::handle_at("nav:view"), Some(on_bar));
|
||||
// Many slots before it in a narrow view fold the View slot into More.
|
||||
h.call(
|
||||
"ui.prefs",
|
||||
json!({"navigation_bar":{"slots":["pan","fit","home","visual_style","grid","ground_shadow","ambient_occlusion","projection","spin","orbit","zoom","display"]}}),
|
||||
);
|
||||
h.call("ui.resize", json!({"width":300,"height":600}));
|
||||
h.settle();
|
||||
assert!(solidcraft_ui_registry::handle_at("nav:slot:display").is_none() && solidcraft_ui_registry::handle_at("nav:view").is_none(), "folded");
|
||||
h.step(&json!({"click":{"handle":"nav:more"}})).unwrap();
|
||||
let in_more = solidcraft_ui_registry::handle_at("nav:slot:display").expect("More lists the View slot");
|
||||
assert_eq!(solidcraft_ui_registry::handle_at("nav:view"), Some(in_more));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Measured single-row navigation bar. Commands own all view changes.
|
||||
use crate::nav_bar::{self, Bar, BarLabels, ContextItem, Menu, MenuEntry, MenuRow, NavAction, SlotView};
|
||||
use crate::navigation::NavCaps;
|
||||
use crate::navigation_bar::{self, Labels, MenuDisplay, NavigationBar, Position, Slot};
|
||||
use crate::ui_kit::{self, NavigationEdge, NavigationKind};
|
||||
use crate::ui_kit::BackingTooltipExt as _;
|
||||
use crate::ui_kit::{self, Container, NavigationEdge, NavigationIdle, NavigationKind, Surface};
|
||||
use crate::{SolveApp, viewport::NavMode};
|
||||
use egui::{Rect, pos2, vec2};
|
||||
use egui::{Align2, Rect, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use solidcraft_theme::Tokens;
|
||||
|
||||
@@ -259,62 +259,21 @@ fn activate(app: &mut SolveApp, slot: &Slot) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot's hover text: a hidden slot's reason, the current style, the sketch's lock, or its name.
|
||||
fn tip(app: &SolveApp, slot: &Slot, hidden: Option<&str>, key: bool) -> String {
|
||||
if let Some(hidden) = hidden {
|
||||
fn tooltip(app: &SolveApp, response: egui::Response, slot: &Slot, hidden: Option<&str>, along: Rect, edge: NavigationEdge) -> egui::Response {
|
||||
let key = slot.command.and_then(|id| crate::keymap::display_for(app, id));
|
||||
let label = if let Some(hidden) = hidden {
|
||||
app.tr(hidden).to_owned()
|
||||
} else if slot.id == "visual_style" {
|
||||
solidcraft_ui_text::format("navbar.value_tip", &[("name", app.tr(slot.label)), ("value", current_style(app))])
|
||||
} else if slot.id == "projection" && projection_locked(app) {
|
||||
app.tr("nav.perspective.sketch").to_owned()
|
||||
} else if slot.id == "zoom" && key {
|
||||
} else if slot.id == "zoom" && key.is_some() {
|
||||
app.tr("navbar.zoom_hold_tip").to_owned()
|
||||
} else {
|
||||
app.tr(slot.label).to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Slot `slot`, at `index` of the stored list, as the bar component draws it.
|
||||
fn slot_view(app: &SolveApp, bar: &BarLayout, slot: &'static Slot, index: usize, rect: Rect, editing: Option<(NavCaps, bool)>) -> SlotView<'static> {
|
||||
let hidden = editing
|
||||
.filter(|(caps, _)| !slot.available(*caps))
|
||||
.map(|(_, render)| if render { "navbar.custom.hidden_render" } else { "navbar.custom.hidden_design" });
|
||||
let key = slot.command.and_then(|id| crate::keymap::display_for(app, id));
|
||||
let value = slot.kind == NavigationKind::Menu
|
||||
&& slot.id == "visual_style"
|
||||
&& app.preferences.navigation_bar.menu_display(slot.id) == MenuDisplay::IconAndValue;
|
||||
SlotView {
|
||||
index,
|
||||
id: slot.id,
|
||||
kind: slot.kind,
|
||||
icon: slot.current_icon(app.ui.visual_style),
|
||||
text: slot_text(app, slot).to_owned(),
|
||||
name: app.tr(slot.label).to_owned(),
|
||||
tip: tip(app, slot, hidden, key.is_some()),
|
||||
key,
|
||||
selected: editing.is_none() && selected(app, slot.id),
|
||||
enabled: editing.is_some() || !(slot.id == "projection" && projection_locked(app)),
|
||||
hidden: hidden.is_some(),
|
||||
show_text: if slot.kind == NavigationKind::Menu { bar.values && value } else { bar.labels },
|
||||
rect,
|
||||
// The View menu's slot, by the name older scenarios use.
|
||||
alias: (slot.id == "display").then_some("nav:view"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The measured row's slots, each with its index in the stored list (a slot the list no longer
|
||||
/// holds is left out).
|
||||
fn slot_views(app: &SolveApp, bar: &BarLayout, editing: Option<(NavCaps, bool)>) -> Vec<SlotView<'static>> {
|
||||
let mut cursor = 0;
|
||||
let mut views = Vec::new();
|
||||
for (slot, rect) in &bar.slots {
|
||||
let Some((index, _)) = app.preferences.navigation_bar.slots.iter().enumerate().skip(cursor).find(|(_, id)| id.as_str() == slot.id) else {
|
||||
continue;
|
||||
};
|
||||
cursor = index + 1;
|
||||
views.push(slot_view(app, bar, slot, index, *rect, editing));
|
||||
}
|
||||
views
|
||||
};
|
||||
ui_kit::navigation_tooltip(&response, along, edge, &label, key.as_deref());
|
||||
response
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
@@ -322,73 +281,107 @@ pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
return;
|
||||
}
|
||||
let Some(layout) = app.viewport.layout.as_ref().filter(|l| l.navigation.is_positive()) else { return };
|
||||
let measured = measured(app, ui.ctx(), layout.content);
|
||||
if !measured.rect.is_positive() {
|
||||
let bar = measured(app, ui.ctx(), layout.content);
|
||||
if !bar.rect.is_positive() {
|
||||
return;
|
||||
}
|
||||
let active = ui.rect_contains_pointer(crate::viewport::axis_rect(app, rect))
|
||||
crate::scenario::publish_rect("nav:outer", bar.rect);
|
||||
let menu_id = ui.scope_id().with("navigation_bar_menu");
|
||||
let mut open = ui.data(|d| d.get_temp::<String>(menu_id)).unwrap_or_default();
|
||||
let active = ui.rect_contains_pointer(bar.rect)
|
||||
|| ui.rect_contains_pointer(crate::viewport::axis_rect(app, rect))
|
||||
|| app.viewport.axis_drag.is_some()
|
||||
|| app.viewport.primary_nav.is_some()
|
||||
|| app.viewport.orbit_from.is_some()
|
||||
|| (app.viewport.mouse.is_some() && ui.input(|i| i.pointer.middle_down()))
|
||||
|| app.viewport.modifier_nav.is_some()
|
||||
|| app.viewport.spin;
|
||||
let prefs = &app.preferences.navigation_bar;
|
||||
let bar = Bar {
|
||||
rect: measured.rect,
|
||||
size: prefs.size,
|
||||
edge: prefs.edge,
|
||||
idle: prefs.idle,
|
||||
active,
|
||||
slots: slot_views(app, &measured, None),
|
||||
overflow: measured
|
||||
.overflow
|
||||
.iter()
|
||||
.filter_map(|slot| Some(slot_view(app, &measured, slot, prefs.slots.iter().position(|id| id == slot.id)?, Rect::NOTHING, None)))
|
||||
.collect(),
|
||||
more: measured.more,
|
||||
labels: BarLabels {
|
||||
more: app.tr("navbar.more").to_owned(),
|
||||
customise: app.tr("navbar.custom.customise").to_owned(),
|
||||
hide: app.tr("navbar.custom.hide").to_owned(),
|
||||
reset: app.tr("navbar.custom.reset").to_owned(),
|
||||
remove: app.tr("navbar.custom.remove_bar").to_owned(),
|
||||
},
|
||||
|| app.viewport.spin
|
||||
|| !open.is_empty();
|
||||
let opacity = ui_kit::navigation_opacity(app.preferences.navigation_bar.idle, active);
|
||||
let background = ui.interact(bar.rect, ui.scope_id().with("navigation_bar_context"), egui::Sense::click());
|
||||
let opacity = if app.preferences.navigation_bar.idle == NavigationIdle::Hide {
|
||||
ui.ctx().animate_value_with_time(menu_id.with("opacity"), opacity, if active { ui_kit::NAV_FADE_SECONDS } else { 0.0 })
|
||||
} else {
|
||||
opacity
|
||||
};
|
||||
let action = nav_bar::show(ui, &bar, |id| menu(app, id));
|
||||
if let Some(action) = action {
|
||||
run(app, action);
|
||||
// Rows of a menu that stays open show the new state on the next frame, without new input.
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run what the bar returned.
|
||||
fn run(app: &mut SolveApp, action: NavAction) {
|
||||
match action {
|
||||
NavAction::Slot(id) => {
|
||||
if let Some(slot) = navigation_bar::find(&id) {
|
||||
activate(app, slot);
|
||||
let mut anchor = bar.more;
|
||||
ui.scope(|ui| {
|
||||
ui.set_opacity(opacity);
|
||||
for (index, slot, response) in paint_row(app, ui, &bar, &open, None) {
|
||||
let bounds = response.rect;
|
||||
crate::scenario::publish_target(&format!("nav:slot:{}", slot.id), bounds);
|
||||
context_menu(app, &response, Some(index));
|
||||
if response.clicked() && slot.kind != NavigationKind::Separator {
|
||||
if slot.kind == NavigationKind::Menu {
|
||||
open = if open == slot.id { String::new() } else { slot.id.to_owned() };
|
||||
} else {
|
||||
activate(app, slot);
|
||||
}
|
||||
}
|
||||
if slot.id == "display" {
|
||||
crate::scenario::publish_handle("nav:view", bounds.center());
|
||||
}
|
||||
if open == slot.id {
|
||||
anchor = bounds;
|
||||
}
|
||||
}
|
||||
NavAction::Context(ContextItem::Customise) => {
|
||||
let _ = crate::prefs::control(app, &json!({"open":"Interface/Navigation bar"}));
|
||||
if bar.more.is_positive() {
|
||||
let response = ui_kit::navigation_button(
|
||||
ui,
|
||||
bar.more,
|
||||
ui_kit::NavigationButton {
|
||||
id: "more",
|
||||
kind: NavigationKind::Menu,
|
||||
icon: "dots",
|
||||
label: app.tr("navbar.more"),
|
||||
selected: open == "more" || bar.overflow.iter().any(|slot| slot.id == open),
|
||||
labels: false,
|
||||
menu: false,
|
||||
},
|
||||
);
|
||||
ui_kit::navigation_tooltip(&response, bar.rect, app.preferences.navigation_bar.edge, app.tr("navbar.more"), None);
|
||||
crate::scenario::publish_target("nav:more", response.rect);
|
||||
context_menu(app, &response, None);
|
||||
if response.clicked() {
|
||||
open = if open == "more" { String::new() } else { "more".to_owned() };
|
||||
}
|
||||
}
|
||||
NavAction::Context(ContextItem::Hide) => {
|
||||
let _ = app.run("view.navigation_bar", json!({"visible":false}));
|
||||
});
|
||||
context_menu(app, &background, None);
|
||||
if !open.is_empty() && anchor.is_positive() {
|
||||
let child_popup_open = egui::Popup::is_any_open(ui.ctx());
|
||||
let ctx = ui.ctx().clone();
|
||||
// Menus open into the view: up from a bar at the bottom, down from one at the top.
|
||||
let (pivot, at) = match app.preferences.navigation_bar.edge {
|
||||
NavigationEdge::Bottom => (Align2::RIGHT_BOTTOM, anchor.right_top() - vec2(0.0, ui_kit::NAV_POPUP_GAP)),
|
||||
NavigationEdge::Top => (Align2::RIGHT_TOP, anchor.right_bottom() + vec2(0.0, ui_kit::NAV_POPUP_GAP)),
|
||||
};
|
||||
let menu = Container::new(&ctx, Surface::Popup).area(menu_id.with("popup")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(at).show(
|
||||
&ctx,
|
||||
|ui| {
|
||||
ui_kit::navigation_menu(ui, if open == "more" { ui_kit::NAV_MORE_WIDTH } else { ui_kit::NAV_POPUP_WIDTH }, |ui| {
|
||||
match open.as_str() {
|
||||
"visual_style" => {
|
||||
if style_menu(app, ui) {
|
||||
open.clear();
|
||||
}
|
||||
}
|
||||
"display" => display_menu(app, ui),
|
||||
"more" => overflow_menu(app, ui, &bar.overflow, &mut open),
|
||||
_ => open.clear(),
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
let outside = !child_popup_open
|
||||
&& ui.input(|i| {
|
||||
i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !menu.response.rect.contains(p) && !bar.rect.contains(p))
|
||||
});
|
||||
if outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
open.clear();
|
||||
}
|
||||
NavAction::Context(ContextItem::Reset) => {
|
||||
app.preferences.navigation_bar = NavigationBar::default();
|
||||
let _ = app.run("view.navigation_bar", json!({"visible":true}));
|
||||
}
|
||||
NavAction::Context(ContextItem::Remove(index)) => {
|
||||
let _ = app.preferences.navigation_bar.remove(index);
|
||||
}
|
||||
NavAction::Item { menu, item } if menu == "visual_style" => {
|
||||
let _ = app.run("view.visual_style", json!({"style":item}));
|
||||
}
|
||||
NavAction::Item { item, .. } => display_pick(app, &item),
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(menu_id, open));
|
||||
}
|
||||
|
||||
pub(crate) fn current_style(app: &SolveApp) -> &str {
|
||||
@@ -416,131 +409,250 @@ pub(crate) fn paint_row(
|
||||
open: &str,
|
||||
editing: Option<(Option<usize>, NavCaps, bool)>,
|
||||
) -> Vec<(usize, &'static Slot, egui::Response)> {
|
||||
let mut views = slot_views(app, bar, editing.map(|(_, caps, render)| (caps, render)));
|
||||
for view in &mut views {
|
||||
view.selected |= editing.is_none() && open == view.id;
|
||||
ui_kit::navigation_surface(ui, bar.rect, app.preferences.navigation_bar.size, editing.is_some());
|
||||
let mut responses = Vec::new();
|
||||
let mut cursor = 0;
|
||||
for (slot, bounds) in &bar.slots {
|
||||
let Some((index, _)) = app.preferences.navigation_bar.slots.iter().enumerate().skip(cursor).find(|(_, id)| id.as_str() == slot.id) else {
|
||||
continue;
|
||||
};
|
||||
cursor = index + 1;
|
||||
let response = ui
|
||||
.push_id(index, |ui| {
|
||||
let hidden = editing.is_some_and(|(_, caps, _)| !slot.available(caps));
|
||||
ui.scope(|ui| {
|
||||
if hidden {
|
||||
ui.set_opacity(ui_kit::NAV_HIDDEN_OPACITY);
|
||||
} else if editing.is_none() && slot.id == "projection" && projection_locked(app) {
|
||||
ui.set_opacity(ui.visuals().disabled_alpha);
|
||||
}
|
||||
if slot.kind == NavigationKind::Separator {
|
||||
ui_kit::navigation_separator(ui, *bounds);
|
||||
ui.interact(
|
||||
*bounds,
|
||||
ui.scope_id().with("separator"),
|
||||
if editing.is_some() { egui::Sense::click_and_drag() } else { egui::Sense::click() },
|
||||
)
|
||||
} else {
|
||||
let button = ui_kit::NavigationButton {
|
||||
id: slot.id,
|
||||
kind: slot.kind,
|
||||
icon: slot.current_icon(app.ui.visual_style),
|
||||
label: slot_text(app, slot),
|
||||
selected: if editing.is_some() { false } else { selected(app, slot.id) || open == slot.id },
|
||||
labels: if slot.kind == NavigationKind::Menu {
|
||||
bar.values
|
||||
&& slot.id == "visual_style"
|
||||
&& app.preferences.navigation_bar.menu_display(slot.id) == MenuDisplay::IconAndValue
|
||||
} else {
|
||||
bar.labels
|
||||
},
|
||||
menu: slot.kind == NavigationKind::Menu,
|
||||
};
|
||||
if editing.is_some() {
|
||||
ui_kit::navigation_button_editable(ui, *bounds, button, true)
|
||||
} else {
|
||||
ui_kit::navigation_button(ui, *bounds, button)
|
||||
}
|
||||
}
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
let mut hidden = None;
|
||||
if let Some((selected, caps, render)) = editing {
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected == Some(index), app.tr(slot.label)));
|
||||
ui_kit::navigation_edit_outline(ui, *bounds, !slot.available(caps), selected == Some(index));
|
||||
if !slot.available(caps) {
|
||||
hidden = Some(if render { "navbar.custom.hidden_render" } else { "navbar.custom.hidden_design" });
|
||||
}
|
||||
} else {
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected(app, slot.id) || open == slot.id, app.tr(slot.label))
|
||||
});
|
||||
}
|
||||
// The settings' preview keeps its tips above, wherever the bar sits in the view.
|
||||
let edge = if editing.is_some() { NavigationEdge::Bottom } else { app.preferences.navigation_bar.edge };
|
||||
responses.push((index, *slot, tooltip(app, response, slot, hidden, bar.rect, edge)));
|
||||
}
|
||||
// The settings' preview keeps its tips above, wherever the bar sits in the view.
|
||||
let edge = if editing.is_some() { NavigationEdge::Bottom } else { app.preferences.navigation_bar.edge };
|
||||
let responses = nav_bar::paint_slots(ui, bar.rect, app.preferences.navigation_bar.size, edge, &views, editing.map(|(selected, _, _)| selected));
|
||||
views.iter().zip(responses).filter_map(|(view, response)| Some((view.index, navigation_bar::find(view.id)?, response))).collect()
|
||||
responses
|
||||
}
|
||||
|
||||
/// The rows of the menu slot `id` opens (none for an unknown one, which closes it).
|
||||
fn menu(app: &SolveApp, id: &str) -> Menu {
|
||||
match id {
|
||||
"visual_style" => style_menu(app),
|
||||
"display" => display_menu(app),
|
||||
_ => Menu::default(),
|
||||
}
|
||||
pub(crate) fn context_menu(app: &mut SolveApp, response: &egui::Response, index: Option<usize>) {
|
||||
ui_kit::navigation_context(response, |ui| {
|
||||
for id in if index.is_none() { &["customise", "hide", "reset"][..] } else { &[][..] } {
|
||||
let key = format!("navbar.custom.{id}");
|
||||
let item = ui_kit::NavigationMenuRow::new(id, app.tr(&key), "").show(ui);
|
||||
crate::scenario::publish_target(&format!("nav:context:{id}"), item.rect);
|
||||
if item.clicked() {
|
||||
match *id {
|
||||
"customise" => {
|
||||
let _ = crate::prefs::control(app, &json!({"open":"Interface/Navigation bar"}));
|
||||
}
|
||||
"hide" => {
|
||||
let _ = app.run("view.navigation_bar", json!({"visible":false}));
|
||||
}
|
||||
_ => {
|
||||
app.preferences.navigation_bar = NavigationBar::default();
|
||||
let _ = app.run("view.navigation_bar", json!({"visible":true}));
|
||||
}
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if let Some(index) = index {
|
||||
let item = ui_kit::NavigationMenuRow::new("remove", app.tr("navbar.custom.remove_bar"), "").show(ui);
|
||||
crate::scenario::publish_target("nav:context:remove", item.rect);
|
||||
if item.clicked() {
|
||||
let _ = app.preferences.navigation_bar.remove(index);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Visual style: the four styles, then Translucent (later); a pick closes the menu.
|
||||
fn style_menu(app: &SolveApp) -> Menu {
|
||||
let mut entries: Vec<MenuEntry> = navigation_bar::VISUAL_STYLES
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (style, _))| {
|
||||
let mut row = MenuRow::new(
|
||||
*style,
|
||||
app.tr(&format!("navbar.style.{style}")),
|
||||
navigation_bar::visual_style_icon(index as u8),
|
||||
format!("nav:style:{style}"),
|
||||
);
|
||||
row.selected = app.ui.visual_style == index as u8;
|
||||
MenuEntry::Row(row)
|
||||
})
|
||||
.collect();
|
||||
entries.push(MenuEntry::Separator);
|
||||
let mut later = MenuRow::new("translucent", app.tr("navbar.translucent"), "style_translucent", "");
|
||||
fn style_menu(app: &mut SolveApp, ui: &mut egui::Ui) -> bool {
|
||||
let mut chosen = false;
|
||||
for (index, style) in navigation_bar::VISUAL_STYLES.iter().map(|(style, _)| *style).enumerate() {
|
||||
let label = format!("navbar.style.{style}");
|
||||
let mut item = ui_kit::NavigationMenuRow::new(style, app.tr(&label), navigation_bar::visual_style_icon(index as u8));
|
||||
item.selected = app.ui.visual_style == index as u8;
|
||||
let response = item.show(ui);
|
||||
crate::scenario::publish_target(&format!("nav:style:{style}"), response.rect);
|
||||
if response.clicked() {
|
||||
let _ = app.run("view.visual_style", json!({"style":style}));
|
||||
chosen = true;
|
||||
}
|
||||
}
|
||||
ui_kit::navigation_menu_separator(ui);
|
||||
let mut later = ui_kit::NavigationMenuRow::new("translucent", app.tr("navbar.translucent"), "style_translucent");
|
||||
later.enabled = false;
|
||||
later.detail = Some(app.tr("navbar.later").to_owned());
|
||||
entries.push(MenuEntry::Row(later));
|
||||
Menu { entries, close_on_pick: true }
|
||||
later.detail = Some(app.tr("navbar.later"));
|
||||
later.show(ui);
|
||||
chosen
|
||||
}
|
||||
|
||||
/// The View menu: graphics preset, what the view shows, projection, cursor and the assistant.
|
||||
fn display_menu(app: &SolveApp) -> Menu {
|
||||
let row = |id: &str, label: &str, icon: &str, target: &str, selected: bool| {
|
||||
let mut row = MenuRow::new(id, label, icon, target);
|
||||
row.selected = selected;
|
||||
row
|
||||
};
|
||||
let mut entries = vec![MenuEntry::Heading(app.tr("navbar.graphics").to_owned())];
|
||||
fn overflow_menu(app: &mut SolveApp, ui: &mut egui::Ui, slots: &[&Slot], open: &mut String) {
|
||||
for slot in slots {
|
||||
if slot.kind == NavigationKind::Separator {
|
||||
let start = ui.next_widget_position();
|
||||
ui_kit::navigation_menu_separator(ui);
|
||||
let bounds = Rect::from_min_max(start, pos2(start.x + ui.available_width(), ui.next_widget_position().y));
|
||||
crate::scenario::publish_target(&format!("nav:slot:{}", slot.id), bounds);
|
||||
continue;
|
||||
}
|
||||
let mut item = ui_kit::NavigationMenuRow::new(slot.id, app.tr(slot.label), slot.current_icon(app.ui.visual_style));
|
||||
if matches!(slot.kind, NavigationKind::Tool | NavigationKind::Toggle) {
|
||||
item.selected = selected(app, slot.id);
|
||||
}
|
||||
let response = item.show(ui);
|
||||
let index = app.preferences.navigation_bar.slots.iter().position(|id| id == slot.id);
|
||||
context_menu(app, &response, index);
|
||||
crate::scenario::publish_target(&format!("nav:slot:{}", slot.id), response.rect);
|
||||
if slot.id == "display" {
|
||||
crate::scenario::publish_handle("nav:view", response.rect.center());
|
||||
}
|
||||
if tooltip(app, response.clone(), slot, None, response.rect, NavigationEdge::Bottom).clicked() {
|
||||
if slot.kind == NavigationKind::Menu {
|
||||
*open = slot.id.to_owned();
|
||||
} else {
|
||||
activate(app, slot);
|
||||
open.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_menu(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
use ui_kit::{NavigationMenuRow, navigation_menu_heading as heading, navigation_menu_separator as separator};
|
||||
heading(ui, app.tr("navbar.graphics"));
|
||||
let current = crate::prefs::graphics_preset(&app.preferences);
|
||||
let mut preset = MenuRow::new("preset", app.tr("navbar.preset"), "", "view:graphics_preset");
|
||||
preset.detail = Some(app.tr(&format!("ui.graphics_{current}")).to_owned());
|
||||
preset.submenu = [("performance", "ui.graphics_performance"), ("quality", "ui.graphics_quality")]
|
||||
.into_iter()
|
||||
.map(|(id, label)| row(id, app.tr(label), "", &format!("view:graphics_preset:{id}"), current == id))
|
||||
.collect();
|
||||
entries.extend([MenuEntry::Row(preset), MenuEntry::Separator, MenuEntry::Heading(app.tr("navbar.show").to_owned())]);
|
||||
let detail = format!("ui.graphics_{current}");
|
||||
let mut preset = NavigationMenuRow::new("preset", app.tr("navbar.preset"), "");
|
||||
preset.detail = Some(app.tr(&detail));
|
||||
preset.submenu = true;
|
||||
let response = preset.show(ui);
|
||||
crate::scenario::publish_target("view:graphics_preset", response.rect);
|
||||
Container::new(ui.ctx(), Surface::Popup).menu(&response).frame(ui_kit::navigation_popup_frame()).show(|ui| {
|
||||
ui.set_width(ui_kit::NAV_POPUP_WIDTH - 2.0 * (f32::from(ui_kit::NAV_POPUP_PADDING) + ui_kit::NAV_BORDER));
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for (id, label) in [("performance", "ui.graphics_performance"), ("quality", "ui.graphics_quality")] {
|
||||
let mut item = NavigationMenuRow::new(id, app.tr(label), "");
|
||||
item.selected = current == id;
|
||||
let response = item.show(ui);
|
||||
crate::scenario::publish_target(&format!("view:graphics_preset:{id}"), response.rect);
|
||||
if response.clicked() {
|
||||
let _ = app.run("view.graphics_preset", json!({"preset":id}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
separator(ui);
|
||||
heading(ui, app.tr("navbar.show"));
|
||||
for id in ["grid", "ground_shadow", "ambient_occlusion"] {
|
||||
let Some(slot) = navigation_bar::find(id) else { continue };
|
||||
entries.push(MenuEntry::Row(row(
|
||||
slot.id,
|
||||
app.tr(slot.label),
|
||||
slot.current_icon(app.ui.visual_style),
|
||||
&format!("view:{id}"),
|
||||
selected(app, slot.id),
|
||||
)));
|
||||
let mut item = NavigationMenuRow::new(slot.id, app.tr(slot.label), slot.current_icon(app.ui.visual_style));
|
||||
item.selected = selected(app, slot.id);
|
||||
let response = item.show(ui);
|
||||
crate::scenario::publish_target(&format!("view:{id}"), response.rect);
|
||||
if response.clicked() {
|
||||
activate(app, slot);
|
||||
}
|
||||
}
|
||||
entries.extend([MenuEntry::Separator, MenuEntry::Heading(app.tr("navbar.projection_heading").to_owned())]);
|
||||
separator(ui);
|
||||
heading(ui, app.tr("navbar.projection_heading"));
|
||||
for (perspective, key) in [(false, "navbar.orthographic"), (true, "navbar.perspective")] {
|
||||
let mut item = row(
|
||||
key,
|
||||
app.tr(key),
|
||||
"perspective",
|
||||
if perspective { "view:projection" } else { "view:orthographic" },
|
||||
app.ui.perspective == perspective,
|
||||
);
|
||||
let mut item = NavigationMenuRow::new(key, app.tr(key), "perspective");
|
||||
item.selected = app.ui.perspective == perspective;
|
||||
item.enabled = !projection_locked(app);
|
||||
item.tip = projection_locked(app).then(|| app.tr("nav.perspective.sketch").to_owned());
|
||||
entries.push(MenuEntry::Row(item));
|
||||
let response = item.show(ui);
|
||||
let response = if projection_locked(app) { response.backing_on_hover_text(app.tr("nav.perspective.sketch")) } else { response };
|
||||
crate::scenario::publish_target(if perspective { "view:projection" } else { "view:orthographic" }, response.rect);
|
||||
if response.clicked() && app.ui.perspective != perspective {
|
||||
toggle_projection(app);
|
||||
}
|
||||
}
|
||||
entries.push(MenuEntry::Separator);
|
||||
separator(ui);
|
||||
for (id, label, on) in [
|
||||
("auto_ortho_faces", "Perspective while orbiting, ortho on faces", app.ui.auto_ortho_faces),
|
||||
("tool_cursor", "Show tool at cursor", app.ui.tool_cursor),
|
||||
] {
|
||||
entries.push(MenuEntry::Row(row(id, solidcraft_ui_text::tr_label(label), "select", &format!("view:{id}"), on)));
|
||||
}
|
||||
// The View angles readout under the orientation gizmo (#6032).
|
||||
let mut angles = row("view_angles", app.tr("ui.view_angles"), "select", "view:view_angles", app.preferences.show_view_angles);
|
||||
angles.tip = Some(app.tr("ui.view_angles_help").to_owned());
|
||||
entries.extend([MenuEntry::Row(angles), MenuEntry::Separator, MenuEntry::Heading(app.tr("navbar.assistant").to_owned())]);
|
||||
entries.push(MenuEntry::Row(row("agent_cursor", app.tr("navbar.agent_cursor"), "select", "view:agent_cursor", app.ui.agent_cursor.show)));
|
||||
if crate::agent_chat::shown(app) {
|
||||
entries.push(MenuEntry::Row(row("agent_chat", app.tr("navbar.agent_chat"), "agent_chat", "view:agent_chat", app.agent_chat.open)));
|
||||
}
|
||||
Menu { entries, close_on_pick: false }
|
||||
}
|
||||
|
||||
/// A View menu pick.
|
||||
fn display_pick(app: &mut SolveApp, item: &str) {
|
||||
match item {
|
||||
"performance" | "quality" => {
|
||||
let _ = app.run("view.graphics_preset", json!({"preset":item}));
|
||||
}
|
||||
"navbar.orthographic" | "navbar.perspective" => {
|
||||
if app.ui.perspective != (item == "navbar.perspective") {
|
||||
toggle_projection(app);
|
||||
let mut row = NavigationMenuRow::new(id, solidcraft_ui_text::tr_label(label), "select");
|
||||
row.selected = on;
|
||||
let response = row.show(ui);
|
||||
crate::scenario::publish_target(&format!("view:{id}"), response.rect);
|
||||
if response.clicked() {
|
||||
match id {
|
||||
"auto_ortho_faces" => app.ui.auto_ortho_faces = !on,
|
||||
_ => app.ui.tool_cursor = !on,
|
||||
}
|
||||
}
|
||||
"auto_ortho_faces" => app.ui.auto_ortho_faces = !app.ui.auto_ortho_faces,
|
||||
"tool_cursor" => app.ui.tool_cursor = !app.ui.tool_cursor,
|
||||
"view_angles" => {
|
||||
let _ = app.run("view.angle_readout", json!({"enabled": !app.preferences.show_view_angles}));
|
||||
}
|
||||
"agent_cursor" => app.ui.agent_cursor.show = !app.ui.agent_cursor.show,
|
||||
"agent_chat" => {
|
||||
}
|
||||
// The View angles readout under the orientation gizmo (#6032).
|
||||
let mut angles = NavigationMenuRow::new("view_angles", app.tr("ui.view_angles"), "select");
|
||||
angles.selected = app.preferences.show_view_angles;
|
||||
let response = angles.show(ui).backing_on_hover_text(app.tr("ui.view_angles_help"));
|
||||
crate::scenario::publish_target("view:view_angles", response.rect);
|
||||
if response.clicked() {
|
||||
let _ = app.run("view.angle_readout", json!({"enabled": !app.preferences.show_view_angles}));
|
||||
}
|
||||
separator(ui);
|
||||
heading(ui, app.tr("navbar.assistant"));
|
||||
let mut cursor = NavigationMenuRow::new("agent_cursor", app.tr("navbar.agent_cursor"), "select");
|
||||
cursor.selected = app.ui.agent_cursor.show;
|
||||
let response = cursor.show(ui);
|
||||
crate::scenario::publish_target("view:agent_cursor", response.rect);
|
||||
if response.clicked() {
|
||||
app.ui.agent_cursor.show = !app.ui.agent_cursor.show;
|
||||
}
|
||||
if crate::agent_chat::shown(app) {
|
||||
let mut chat = NavigationMenuRow::new("agent_chat", app.tr("navbar.agent_chat"), "agent_chat");
|
||||
chat.selected = app.agent_chat.open;
|
||||
let response = chat.show(ui);
|
||||
crate::scenario::publish_target("view:agent_chat", response.rect);
|
||||
if response.clicked() {
|
||||
app.agent_chat.open = !app.agent_chat.open;
|
||||
app.preferences.agent_chat_minimized &= !app.agent_chat.open;
|
||||
}
|
||||
id => {
|
||||
if let Some(slot) = navigation_bar::find(id) {
|
||||
activate(app, slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,9 +682,7 @@ impl AppPanelRuntime {
|
||||
if (self.runtime.environment() != &before || self.runtime.layout().inspect().revision != revision)
|
||||
&& let Some(host) = &mut self.host
|
||||
{
|
||||
// Presentation metadata can revise an unrelated panel.
|
||||
// Scope and focused-owner checks decide input ownership.
|
||||
host.invalidate_geometry();
|
||||
host.invalidate_context();
|
||||
host.reconcile_scope(&self.runtime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,17 +110,6 @@ impl Product {
|
||||
}
|
||||
fn presentation(self, app: &crate::SolveApp, play_button: bool) -> PanelPresentation {
|
||||
let mut presentation = self.empty_presentation();
|
||||
if self == Self::Assistant {
|
||||
let waiting = app.agent_chat.waiting();
|
||||
let state = app.agent_chat.state();
|
||||
presentation.status = Some(if waiting > 0 {
|
||||
solidcraft_ui_text::format("ui.assistant_waiting", &[("count", &waiting.to_string())])
|
||||
} else {
|
||||
state.label().into()
|
||||
});
|
||||
presentation.attention = waiting > 0
|
||||
|| matches!(state, crate::agent_chat::State::NeedsAuth | crate::agent_chat::State::Exited | crate::agent_chat::State::Failed);
|
||||
}
|
||||
presentation.actions = match self {
|
||||
Self::History => crate::ui_kit::HistoryTransport::shown(play_button)
|
||||
.iter()
|
||||
@@ -865,7 +854,7 @@ impl FramePanelContent for ProductionFrame<'_> {
|
||||
Product::Browser | Product::RenderBrowser => crate::browser::panel_contents(self.app, ui),
|
||||
Product::History if context.flow == Flow::Wide => crate::timeline::panel_inline(self.app, ui),
|
||||
Product::History => crate::timeline_left::panel_contents(self.app, ui),
|
||||
Product::Assistant => crate::agent_chat::shared_content(self.app, ui, context.flow),
|
||||
Product::Assistant => crate::agent_chat::shared_content(self.app, ui),
|
||||
}
|
||||
let capture = ui.ctx().data_mut(|data| data.remove_temp::<Option<FocusCapture>>(focus_capture_id()).flatten());
|
||||
if context.document.as_ref().map(|document| document.0.as_str()) != Some(self.app.session.document_id.as_str()) {
|
||||
|
||||
@@ -12893,92 +12893,6 @@ fn content_focus_bookmark_restores_caret_after_explicit_collapse_and_focus() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_focus_survives_floating_sizing_unless_a_live_foreign_control_takes_it() {
|
||||
for scale in [1.0, 2.0] {
|
||||
for foreign_focus in [false, true] {
|
||||
let (mut content, trace) = review_cohort_host(|_| {});
|
||||
content.dispatch(vec![fixture_open("fixture.editor", "wrapping-editor", "wrapping", Zone::Left, 0).unwrap()]).unwrap();
|
||||
let mut raw = RawPanelHost::new(content, scale);
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
if foreign_focus {
|
||||
raw.prime_foreign_window();
|
||||
}
|
||||
let editor = trace.borrow().editors.last().unwrap().0;
|
||||
raw.ctx.memory_mut(|memory| memory.request_focus(editor));
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame_late(vec![egui::Event::Text(" retained reparent selection".into())], false, |_, _| {});
|
||||
raw.frame(Vec::new(), false);
|
||||
let mut state = egui::TextEdit::load_state(&raw.ctx, editor).unwrap();
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(1), egui::text::CCursor::new(4))));
|
||||
state.store(&raw.ctx, editor);
|
||||
raw.frame(Vec::new(), false);
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
|
||||
let caret = egui::TextEdit::load_state(&raw.ctx, editor).unwrap().cursor.char_range();
|
||||
assert!(!caret.unwrap().is_empty());
|
||||
let draft = trace.borrow().editors.last().unwrap().3.clone();
|
||||
let key = ControllerKey { instance: id("wrapping-editor"), owner: raw.content.runtime.instance_owner(&id("wrapping-editor")).unwrap() };
|
||||
let renders = raw.content.probe.state(&key).unwrap().renders;
|
||||
if foreign_focus {
|
||||
raw.foreign_cover = Some(egui::Rect::from_min_size(egui::pos2(1000.0, 600.0), egui::vec2(180.0, 80.0)));
|
||||
raw.foreign_takes_focus = true;
|
||||
}
|
||||
raw.content
|
||||
.dispatch(vec![Mutation::Move {
|
||||
instance: id("wrapping-editor"),
|
||||
destination: Destination::NewSection {
|
||||
section: section("reparented-editor"),
|
||||
index: 0,
|
||||
location: SectionLocation::Floating { origin: Point { x: 400.0, y: 180.0 } },
|
||||
size: Size { width: 400.0, height: 320.0 },
|
||||
},
|
||||
}])
|
||||
.unwrap();
|
||||
raw.frame(Vec::new(), false);
|
||||
assert_eq!(raw.content.probe.state(&key).unwrap().renders, renders, "the new Area sizing pass must not run a hidden body");
|
||||
let foreign = foreign_focus.then(|| raw.foreign_responses.last().unwrap().id);
|
||||
assert_eq!(
|
||||
raw.ctx.memory(|memory| memory.focused()),
|
||||
foreign,
|
||||
"sizing removes the editor; only the real late foreign widget may take focus"
|
||||
);
|
||||
// Stop requesting focus: subsequent passes must retain the person's
|
||||
// choice without repeatedly forcing it after host restoration.
|
||||
raw.foreign_takes_focus = false;
|
||||
for _ in 0..2 {
|
||||
raw.frame(Vec::new(), false);
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), foreign.or(Some(editor)));
|
||||
}
|
||||
assert!(raw.content.probe.state(&key).unwrap().renders > renders);
|
||||
assert_eq!(trace.borrow().editors.last().unwrap().3, draft);
|
||||
if foreign_focus {
|
||||
// A later deliberate focus command may return to the retained
|
||||
// selection. It is separate from the Move tested above.
|
||||
let reply = raw.content.dispatch(vec![Mutation::Focus { instance: id("wrapping-editor") }]).unwrap();
|
||||
raw.host.note_reply(&reply);
|
||||
raw.frame(Vec::new(), false);
|
||||
raw.frame(Vec::new(), false);
|
||||
}
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
|
||||
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor).unwrap().cursor.char_range(), caret);
|
||||
assert_eq!(trace.borrow().editors.last().unwrap().3, draft);
|
||||
assert_eq!(raw.content.commands.calls, 0);
|
||||
|
||||
// A retained bookmark must not keep its editor focused after the
|
||||
// owning content becomes unavailable, even before another paint.
|
||||
trace.borrow_mut().unavailable.insert(key.clone());
|
||||
raw.content.tick().unwrap();
|
||||
assert_ne!(raw.content.runtime.layout().availability(&key.instance), Availability::Available);
|
||||
assert_eq!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
|
||||
raw.host.reconcile_scope(&raw.content.runtime);
|
||||
assert_ne!(raw.ctx.memory(|memory| memory.focused()), Some(editor));
|
||||
assert_eq!(egui::TextEdit::load_state(&raw.ctx, editor).unwrap().cursor.char_range(), caret);
|
||||
assert_eq!(trace.borrow().editors.last().unwrap().3, draft);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_header_action_reports_enabled_and_pressed_state_from_retained_presentation() {
|
||||
let (mut content, trace) = review_cohort_host(|_| {});
|
||||
|
||||
@@ -226,17 +226,6 @@ struct ContentFocus {
|
||||
selection: Option<egui::text::CCursorRange>,
|
||||
}
|
||||
impl ContentFocus {
|
||||
fn logically_presented(&self, runtime: &PanelRuntime) -> bool {
|
||||
let service = runtime.layout();
|
||||
self.scope == runtime_scope(runtime)
|
||||
&& runtime.instance_owner(&self.key.instance).is_ok_and(|owner| owner == self.key.owner)
|
||||
&& service.availability(&self.key.instance) == Availability::Available
|
||||
&& service.layout().instance(&self.key.instance).is_ok_and(|instance| instance.requested_open)
|
||||
&& service.layout().containing(&self.key.instance).is_ok_and(|section| {
|
||||
let availability: AvailabilityMap = section.tabs.iter().map(|instance| (instance.clone(), service.availability(instance))).collect();
|
||||
!section.collapsed && presented_tab(section, &availability) == Some(&self.key.instance)
|
||||
})
|
||||
}
|
||||
fn restore_selection(&self, ctx: &egui::Context) {
|
||||
if let Some(selection) = self.selection
|
||||
&& let Some(mut state) = egui::TextEdit::load_state(ctx, self.id)
|
||||
@@ -611,16 +600,6 @@ impl WorkspacePanelHost {
|
||||
}
|
||||
self.scope = Some(identity);
|
||||
}
|
||||
if let Some(bookmark) = &self.content_focus
|
||||
&& self.owned_focus == Some(bookmark.id)
|
||||
&& !bookmark.logically_presented(runtime)
|
||||
{
|
||||
self.restore_content_focus = false;
|
||||
self.owned_focus = None;
|
||||
if let Some(ctx) = &self.egui_context {
|
||||
ctx.memory_mut(|memory| memory.surrender_focus(bookmark.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn note_reply(&mut self, reply: &LayoutReply) {
|
||||
for effect in &reply.effects {
|
||||
@@ -956,7 +935,7 @@ impl WorkspacePanelHost {
|
||||
// the intended resolved geometry, but never publish it as actual paint
|
||||
// or an input/occlusion shield, even if no further egui pass is allowed.
|
||||
let mut unpainted_instances = Vec::new();
|
||||
for (layer, bounds, sizing) in &unpainted {
|
||||
for (layer, bounds) in &unpainted {
|
||||
let hidden_sections: Vec<_> = frame
|
||||
.layout
|
||||
.sections
|
||||
@@ -967,16 +946,6 @@ impl WorkspacePanelHost {
|
||||
.iter()
|
||||
.flat_map(|section| std::iter::once(section.instance.clone()).chain(section.tabs.iter().map(|tab| tab.instance.clone())))
|
||||
.collect();
|
||||
// A new Area measures before it paints. Preserve only the editor
|
||||
// that still owns focus; restore it after a fresh response returns.
|
||||
if *sizing && ui.is_enabled() && ui.is_visible() && ui.input(|input| input.focused) && !ui_kit::Container::modal_input_blocked(ui.ctx()) {
|
||||
self.restore_content_focus |= self.content_focus.as_ref().is_some_and(|bookmark| {
|
||||
self.owned_focus == Some(bookmark.id)
|
||||
&& ui.memory(|memory| memory.focused()) == Some(bookmark.id)
|
||||
&& bookmark.logically_presented(runtime)
|
||||
&& hidden_sections.iter().any(|section| section.instance == bookmark.key.instance && !section.collapsed)
|
||||
});
|
||||
}
|
||||
unpainted_instances.extend(hidden_instances.iter().cloned());
|
||||
let clipped = logical_rect(egui_rect(*bounds).intersect(egui_rect(frame.layout.capacity)));
|
||||
// Remove one occurrence: a visible card can occupy the same rect.
|
||||
@@ -1063,12 +1032,6 @@ impl WorkspacePanelHost {
|
||||
}
|
||||
}
|
||||
}
|
||||
let logical_focus_eligible = self.restore_content_focus
|
||||
&& ui.input(|input| input.focused)
|
||||
&& self.content_focus.as_ref().is_some_and(|bookmark| bookmark.logically_presented(runtime));
|
||||
// A passive return cannot outlive deliberate hiding or owner/focus loss.
|
||||
// A modal may still wait while that logical presentation remains valid.
|
||||
self.restore_content_focus &= logical_focus_eligible;
|
||||
if self.restore_content_focus && !ui_kit::Container::modal_input_blocked(ui.ctx()) {
|
||||
let current = ui.memory(|memory| memory.focused());
|
||||
let deliberate = current.is_some_and(|id| ui.ctx().read_response(id).is_some_and(|response| response.gained_focus()));
|
||||
@@ -1077,8 +1040,11 @@ impl WorkspacePanelHost {
|
||||
} else if current.is_none()
|
||||
&& let Some(bookmark) = &self.content_focus
|
||||
{
|
||||
let eligible =
|
||||
self.content_reports.get(&bookmark.id).is_some_and(|report| report.key == bookmark.key && report.scope == bookmark.scope);
|
||||
let eligible = bookmark.scope == scope
|
||||
&& runtime.instance_owner(&bookmark.key.instance).is_ok_and(|owner| owner == bookmark.key.owner)
|
||||
&& runtime.layout().availability(&bookmark.key.instance) == Availability::Available
|
||||
&& frame.layout.sections.iter().any(|section| section.instance == bookmark.key.instance && !section.collapsed)
|
||||
&& self.content_reports.get(&bookmark.id).is_some_and(|report| report.key == bookmark.key && report.scope == bookmark.scope);
|
||||
if eligible {
|
||||
bookmark.restore_selection(ui.ctx());
|
||||
ui.memory_mut(|memory| memory.request_focus(bookmark.id));
|
||||
@@ -2230,7 +2196,7 @@ struct WorkspaceContentHost<'a> {
|
||||
motion: Motion,
|
||||
commands: &'a mut Vec<HostCommand>,
|
||||
allow_actions: bool,
|
||||
unpainted: &'a mut Vec<(egui::LayerId, LogicalRect, bool)>,
|
||||
unpainted: &'a mut Vec<(egui::LayerId, LogicalRect)>,
|
||||
}
|
||||
|
||||
impl PanelHost for WorkspaceContentHost<'_> {
|
||||
@@ -2281,7 +2247,7 @@ impl PanelHost for WorkspaceContentHost<'_> {
|
||||
matches!(appearance.contrast, ContrastOutcome::RetainReadableFallback { .. }).then(|| style.palette(appearance)),
|
||||
|ui, content_clip| {
|
||||
if (section.peek || section.zone == Zone::Floating) && (!ui.is_visible() || ui.is_sizing_pass()) {
|
||||
self.unpainted.push((ui.layer_id(), section.outer, ui.is_sizing_pass()));
|
||||
self.unpainted.push((ui.layer_id(), section.outer));
|
||||
return;
|
||||
}
|
||||
// A surface response provides semantic focus and owns only the actual
|
||||
|
||||
@@ -7326,62 +7326,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #11445: the theme row opens on the theme in use each time Preferences or its page shows it,
|
||||
/// and the arrows still move it freely while it stays in view.
|
||||
#[test]
|
||||
fn theme_row_opens_on_the_theme_in_use_each_time_it_comes_into_view() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.call("ui.resize", json!({"width":1600,"height":1100}));
|
||||
h.call("ui.prefs", json!({"theme":"dark","language":"en","open":"Appearance"}));
|
||||
h.frames(4);
|
||||
let count = theme_choices(&h.app).len();
|
||||
let page_back = |h: &mut crate::scenario::Harness| {
|
||||
for _ in 0..=count {
|
||||
if crate::scenario::rect_of("prefs:theme_previous").is_none() {
|
||||
return;
|
||||
}
|
||||
h.step(&json!({"click":{"handle":"prefs:theme_previous"}})).unwrap();
|
||||
h.frames(3);
|
||||
}
|
||||
panic!("the previous arrow never reached the start: the row is held in place");
|
||||
};
|
||||
page_back(&mut h);
|
||||
// A theme the row does not show at its start.
|
||||
let far =
|
||||
theme_choices(&h.app).into_iter().map(|(id, _)| id).find(|id| crate::scenario::rect_of(&format!("prefs:theme:{id}")).is_none()).unwrap();
|
||||
let shows = |id: &str| {
|
||||
let viewport = crate::scenario::rect_of("prefs:theme_viewport").unwrap();
|
||||
crate::scenario::rect_of(&format!("prefs:theme:{id}")).is_some_and(|rect| viewport.expand(1.0).contains_rect(rect))
|
||||
};
|
||||
h.call("ui.prefs", json!({"theme":far}));
|
||||
h.frames(4);
|
||||
assert!(shows(&far), "a theme chosen elsewhere comes into view: {far}");
|
||||
page_back(&mut h);
|
||||
assert!(!shows(&far), "the arrows move the row away from the theme in use");
|
||||
h.call("ui.prefs", json!({"button":"ok"}));
|
||||
h.frames(3);
|
||||
h.call("ui.prefs", json!({"open":"Appearance"}));
|
||||
h.frames(4);
|
||||
assert!(shows(&far), "reopening Preferences shows the theme in use: {far}");
|
||||
page_back(&mut h);
|
||||
h.frames(4);
|
||||
assert!(!shows(&far), "the row stays where the arrows leave it");
|
||||
h.call("ui.prefs", json!({"open":"General"}));
|
||||
h.frames(3);
|
||||
h.call("ui.prefs", json!({"open":"Appearance"}));
|
||||
h.frames(4);
|
||||
assert!(shows(&far), "coming back to the page shows the theme in use: {far}");
|
||||
page_back(&mut h);
|
||||
h.app.prefs_window.filter = "Onyx".to_owned();
|
||||
h.frames(5);
|
||||
h.app.prefs_window.filter.clear();
|
||||
h.frames(5);
|
||||
assert!(shows(&far), "clearing a search shows the theme in use: {far}");
|
||||
assert_eq!(h.app.preferences.theme, far, "showing a theme never chooses one");
|
||||
h.call("ui.prefs", json!({"button":"cancel"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_controls_edit_real_fields_and_cancel_restores_them() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
|
||||
@@ -304,17 +304,7 @@ fn row_contents(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let selected_index = visible.iter().position(|e| e.id == reveal);
|
||||
let identity = format!("{reveal}:{:?}:{:?}:{}:{}", state.tone, state.high, state.imported, state.grid);
|
||||
let reveal_key = ui.make_persistent_id("theme_picker_reveal");
|
||||
// The theme in use also comes into view each time the picker does (#11445): Preferences
|
||||
// opens, or its page or a cleared search shows it again. Only then: the arrows, the wheel and
|
||||
// a drag move the row freely while it stays in view.
|
||||
let shown_key = ui.make_persistent_id("theme_picker_shown");
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
let returning = ui.data_mut(|d| {
|
||||
let returning = d.get_temp::<u64>(shown_key).is_none_or(|last| last.saturating_add(1) < pass);
|
||||
d.insert_temp(shown_key, pass);
|
||||
returning
|
||||
});
|
||||
let changed = returning || ui.data(|d| d.get_temp::<String>(reveal_key)).as_ref() != Some(&identity);
|
||||
let changed = ui.data(|d| d.get_temp::<String>(reveal_key)).as_ref() != Some(&identity);
|
||||
let mut chosen = None;
|
||||
let mut card = |ui: &mut egui::Ui, index: usize, width: f32| {
|
||||
let Some(e) = visible.get(index) else { return };
|
||||
|
||||
@@ -455,7 +455,6 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui_kit::scope(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let invalid = typed(&query).is_some_and(|(_, result)| result.is_err());
|
||||
let key = crate::keymap::display_for(app, "ui.palette");
|
||||
let field = ui_kit::CommandSearch::field(
|
||||
ui,
|
||||
&mut query,
|
||||
@@ -467,7 +466,6 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
progress,
|
||||
radius: ui_kit::CommandSearch::field_radius(progress, ui_kit::Container::new(ctx, ui_kit::Surface::Chrome).floating()),
|
||||
},
|
||||
key.as_deref(),
|
||||
MAX_QUERY,
|
||||
);
|
||||
if !open && progress == 0.0 {
|
||||
@@ -1098,7 +1096,7 @@ mod tests {
|
||||
let toolbar = crate::scenario::rect_of("toolbar").unwrap();
|
||||
assert_eq!(crate::toolbar::TAB_ROW_HEIGHT, 30.0);
|
||||
assert_eq!(crate::toolbar::RIBBON_HEIGHT, 117.0);
|
||||
assert_eq!(closed.size(), vec2(ui_kit::CommandSearch::closed_width(&h.ctx, Some("S")), 22.0));
|
||||
assert_eq!(closed.size(), vec2(ui_kit::CommandSearch::closed_width(&h.ctx), 22.0));
|
||||
assert_eq!(closed.right(), toolbar.right() - 4.0);
|
||||
assert_eq!(closed.top(), toolbar.top() + 4.0);
|
||||
assert_eq!(closed.center().y, toolbar.top() + 15.0);
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
//! The start page as a component: a rail of entries beside a titled body (action tiles, the
|
||||
//! recent files and the samples, or a section of the host's tiles or reviews). The host gives
|
||||
//! every label, icon, thumbnail and what each entry stands for (`A`); [`show`] draws the page and
|
||||
//! returns the entry chosen this frame, which the host runs. Nothing here knows the app.
|
||||
//!
|
||||
//! Tiles and samples take a host [`Icon`] (a kit icon name, a texture or the host's painter);
|
||||
//! menu entries take a kit icon name. Thumbnails are textures the host made.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use egui::vec2;
|
||||
|
||||
pub use crate::ui_kit::Icon;
|
||||
use crate::ui_kit::{self, FileView, RecoveryMessage};
|
||||
use solidcraft_theme::Tokens;
|
||||
|
||||
/// Text the host gives: borrowed when it has it, owned when it formatted it this frame.
|
||||
pub type Label<'a> = Cow<'a, str>;
|
||||
|
||||
/// The whole page. `id` names it (`{id}:page`, `{id}:back`, `{id}:recent:N`, `{id}:sample:N`…).
|
||||
pub struct Page<'a, A> {
|
||||
pub id: &'a str,
|
||||
/// The narrow layout's menu title, standing for the rail.
|
||||
pub name: Label<'a>,
|
||||
/// Whether the page takes input (not while a file picker or a load is up).
|
||||
pub interactive: bool,
|
||||
pub back: Back<'a, A>,
|
||||
pub rail: Vec<RailItem<'a, A>>,
|
||||
pub title: Label<'a>,
|
||||
pub hint: Label<'a>,
|
||||
/// An error shown under the hint.
|
||||
pub error: Option<Label<'a>>,
|
||||
pub body: Body<'a, A>,
|
||||
}
|
||||
|
||||
/// The rail's first row, back to the open document.
|
||||
pub struct Back<'a, A> {
|
||||
/// The label, with `{name}` for the document's name.
|
||||
pub template: Label<'a>,
|
||||
pub document: Label<'a>,
|
||||
pub shortcut: Label<'a>,
|
||||
pub chosen: A,
|
||||
}
|
||||
|
||||
pub enum RailItem<'a, A> {
|
||||
Entry(RailEntry<'a, A>),
|
||||
Separator,
|
||||
}
|
||||
|
||||
pub struct RailEntry<'a, A> {
|
||||
pub id: Label<'a>,
|
||||
pub label: Label<'a>,
|
||||
/// The key hint drawn at the row's end (empty for none).
|
||||
pub shortcut: Label<'a>,
|
||||
pub selected: bool,
|
||||
pub enabled: bool,
|
||||
pub chosen: A,
|
||||
}
|
||||
|
||||
pub enum Body<'a, A> {
|
||||
/// Boxed: the start section is far larger than the other bodies.
|
||||
Start(Box<Start<'a, A>>),
|
||||
Tiles(Tiles<'a, A>),
|
||||
Reviews(Reviews<'a, A>),
|
||||
}
|
||||
|
||||
/// The start section: an optional recovery banner, the action tiles, the recent files and the
|
||||
/// samples.
|
||||
pub struct Start<'a, A> {
|
||||
pub banner: Option<Banner<'a, A>>,
|
||||
pub actions_title: Label<'a>,
|
||||
pub actions: Tiles<'a, A>,
|
||||
pub recent_title: Label<'a>,
|
||||
/// Shown when there are no recent files.
|
||||
pub recent_empty: Label<'a>,
|
||||
pub recent: Vec<Recent<'a, A>>,
|
||||
pub samples_title: Label<'a>,
|
||||
pub samples: Vec<Sample<'a, A>>,
|
||||
}
|
||||
|
||||
/// A grid of action tiles; `grid` keeps its widgets' state apart from other grids.
|
||||
pub struct Tiles<'a, A> {
|
||||
pub grid: &'a str,
|
||||
pub tiles: Vec<Tile<'a, A>>,
|
||||
}
|
||||
|
||||
pub struct Tile<'a, A> {
|
||||
pub id: Label<'a>,
|
||||
pub icon: Icon<'a>,
|
||||
pub title: Label<'a>,
|
||||
pub hint: Label<'a>,
|
||||
/// Drawn as the page's main action.
|
||||
pub primary: bool,
|
||||
pub chosen: A,
|
||||
}
|
||||
|
||||
pub struct Banner<'a, A> {
|
||||
pub title: Label<'a>,
|
||||
pub before_filename: Label<'a>,
|
||||
pub filename: Label<'a>,
|
||||
pub after_filename: Label<'a>,
|
||||
pub recover: (Label<'a>, A),
|
||||
pub discard: Option<(Label<'a>, A)>,
|
||||
}
|
||||
|
||||
pub struct Recent<'a, A> {
|
||||
pub title: Label<'a>,
|
||||
pub path: Label<'a>,
|
||||
pub when: Label<'a>,
|
||||
pub kind: Label<'a>,
|
||||
pub thumbnail: Option<(egui::TextureId, egui::Vec2)>,
|
||||
/// No picture could be made: a dimmed placeholder rather than a loading one.
|
||||
pub thumbnail_failed: bool,
|
||||
pub chosen: A,
|
||||
/// The row's context menu.
|
||||
pub menu: Vec<MenuEntry<'a, A>>,
|
||||
}
|
||||
|
||||
pub struct MenuEntry<'a, A> {
|
||||
pub label: Label<'a>,
|
||||
/// A kit icon name: the menu row is a `ui_kit::MenuItem`, which takes a host `Icon` with #13379.
|
||||
pub icon: &'a str,
|
||||
pub chosen: A,
|
||||
}
|
||||
|
||||
pub struct Sample<'a, A> {
|
||||
pub title: Label<'a>,
|
||||
pub description: Label<'a>,
|
||||
pub icon: Icon<'a>,
|
||||
pub thumbnail: Option<(egui::TextureId, egui::Vec2)>,
|
||||
pub chosen: A,
|
||||
}
|
||||
|
||||
/// A list of entries to review (the recoverable designs), each with a keep and a drop action.
|
||||
pub struct Reviews<'a, A> {
|
||||
/// Shown when the list is empty.
|
||||
pub empty: Label<'a>,
|
||||
pub items: Vec<Review<'a, A>>,
|
||||
}
|
||||
|
||||
pub struct Review<'a, A> {
|
||||
pub title: Label<'a>,
|
||||
pub detail: Label<'a>,
|
||||
pub recover: (Label<'a>, A),
|
||||
pub discard: (Label<'a>, A),
|
||||
}
|
||||
|
||||
/// Draw the page; the entry chosen this frame, if any.
|
||||
pub fn show<A>(ui: &mut egui::Ui, page: Page<'_, A>) -> Option<A> {
|
||||
let t = Tokens::get();
|
||||
let Page { id, name, interactive, back, rail, title, hint, error, body } = page;
|
||||
let mut chosen = None;
|
||||
let mut rail = Some((back, rail));
|
||||
let mut body = Some(body);
|
||||
FileView::page(ui, |ui| {
|
||||
ui.add_enabled_ui(interactive, |ui| {
|
||||
let full = ui.available_rect_before_wrap();
|
||||
solidcraft_ui_registry::publish_rect(&format!("{id}:page"), full);
|
||||
let narrow = full.width() < t.file_nav_width + FileView::tile_min_width() + t.file_content_inset_x * 2.0;
|
||||
let navigation_id = format!("{id}_navigation");
|
||||
if narrow {
|
||||
FileView::navigation(ui, |ui| {
|
||||
ui_kit::dropdown_menu(ui, navigation_id.as_str(), &name, |ui| {
|
||||
ui.set_min_width(t.file_nav_width);
|
||||
FileView::navigation(ui, |ui| {
|
||||
if let Some((back, rail)) = rail.take() {
|
||||
navigation(ui, id, back, rail, &mut chosen);
|
||||
}
|
||||
if chosen.is_some() {
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(body) = body.take() {
|
||||
content(ui, id, (title.as_ref(), hint.as_ref(), error.as_deref()), body, true, &mut chosen);
|
||||
}
|
||||
} else {
|
||||
let rail_rect = egui::Rect::from_min_size(full.min, vec2(t.file_nav_width, full.height()));
|
||||
FileView::rail(ui, rail_rect, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt(navigation_id.as_str()).auto_shrink([false, false]).show(ui, |ui| {
|
||||
if let Some((back, rail)) = rail.take() {
|
||||
navigation(ui, id, back, rail, &mut chosen);
|
||||
}
|
||||
});
|
||||
});
|
||||
let body_rect = egui::Rect::from_min_max(egui::pos2(rail_rect.right(), full.top()), full.max);
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(body_rect), |ui| {
|
||||
if let Some(body) = body.take() {
|
||||
content(ui, id, (title.as_ref(), hint.as_ref(), error.as_deref()), body, false, &mut chosen);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
chosen
|
||||
}
|
||||
|
||||
fn navigation<A>(ui: &mut egui::Ui, id: &str, back: Back<'_, A>, rail: Vec<RailItem<'_, A>>, chosen: &mut Option<A>) {
|
||||
let back_id = format!("{id}:back");
|
||||
let response = FileView::back_to(ui, &back_id, &back.template, &back.document, &back.shortcut);
|
||||
publish(&back_id, &response);
|
||||
if response.clicked() {
|
||||
*chosen = Some(back.chosen);
|
||||
}
|
||||
for item in rail {
|
||||
match item {
|
||||
RailItem::Separator => FileView::separator(ui),
|
||||
RailItem::Entry(entry) => {
|
||||
let response = FileView::nav_row(ui, &entry.id, &entry.label, &entry.shortcut, entry.selected, entry.enabled);
|
||||
publish(&entry.id, &response);
|
||||
if response.clicked() {
|
||||
*chosen = Some(entry.chosen);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn publish(id: &str, response: &egui::Response) {
|
||||
solidcraft_ui_registry::publish_handle(id, response.rect.center());
|
||||
solidcraft_ui_registry::publish_rect(id, response.rect);
|
||||
}
|
||||
|
||||
fn content<A>(ui: &mut egui::Ui, id: &str, heading: (&str, &str, Option<&str>), body: Body<'_, A>, narrow: bool, chosen: &mut Option<A>) {
|
||||
let t = Tokens::get();
|
||||
let (title, hint, error) = heading;
|
||||
let mut body = Some(body);
|
||||
FileView::content(ui, narrow, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt(format!("{id}_content")).auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
FileView::title(ui, title);
|
||||
ui.add_space(t.space_6);
|
||||
FileView::subtitle(ui, hint);
|
||||
ui.add_space(t.file_section_gap);
|
||||
if let Some(message) = error {
|
||||
ui_kit::text(ui, message, ui_kit::Text::Error(&format!("{id}_status")));
|
||||
}
|
||||
match body.take() {
|
||||
Some(Body::Start(start)) => start_content(ui, id, *start, chosen),
|
||||
Some(Body::Tiles(tiles)) => tile_grid(ui, tiles, chosen),
|
||||
Some(Body::Reviews(reviews)) => review_list(ui, id, reviews, chosen),
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn tile_grid<A>(ui: &mut egui::Ui, tiles: Tiles<'_, A>, chosen: &mut Option<A>) {
|
||||
let Tiles { grid, tiles } = tiles;
|
||||
let mut tiles: Vec<Option<Tile<'_, A>>> = tiles.into_iter().map(Some).collect();
|
||||
FileView::grid(ui, grid, FileView::tile_min_width(), tiles.len(), |ui, index, width| {
|
||||
let Some(tile) = tiles.get_mut(index).and_then(Option::take) else { return };
|
||||
let response = FileView::action_tile(ui, &tile.id, width, tile.icon, &tile.title, &tile.hint, tile.primary);
|
||||
publish(&tile.id, &response);
|
||||
if response.clicked() {
|
||||
*chosen = Some(tile.chosen);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn start_content<A>(ui: &mut egui::Ui, id: &str, start: Start<'_, A>, chosen: &mut Option<A>) {
|
||||
let t = Tokens::get();
|
||||
let Start { banner, actions_title, actions, recent_title, recent_empty, recent, samples_title, samples } = start;
|
||||
if let Some(banner) = banner {
|
||||
let banner_id = format!("{id}:recovery");
|
||||
let message = RecoveryMessage {
|
||||
title: &banner.title,
|
||||
before_filename: &banner.before_filename,
|
||||
filename: &banner.filename,
|
||||
after_filename: &banner.after_filename,
|
||||
};
|
||||
let (recover_label, recover_chosen) = banner.recover;
|
||||
let (discard_label, discard_chosen) = banner.discard.map_or((None, None), |(label, chosen)| (Some(label), Some(chosen)));
|
||||
let (recover, discard) = FileView::recovery_banner(ui, &banner_id, message, &recover_label, discard_label.as_deref());
|
||||
publish(&format!("{banner_id}:recover"), &recover);
|
||||
if recover.clicked() {
|
||||
*chosen = Some(recover_chosen);
|
||||
}
|
||||
if let (Some(discard), Some(discard_chosen)) = (discard, discard_chosen) {
|
||||
publish(&format!("{banner_id}:discard"), &discard);
|
||||
if discard.clicked() {
|
||||
*chosen = Some(discard_chosen);
|
||||
}
|
||||
}
|
||||
ui.add_space(t.file_section_gap);
|
||||
}
|
||||
FileView::section(ui, &actions_title, t.space_12, |ui| tile_grid(ui, actions, chosen));
|
||||
ui.add_space(t.file_section_gap);
|
||||
FileView::section(ui, &recent_title, t.space_10, |ui| {
|
||||
if recent.is_empty() {
|
||||
FileView::detail(ui, &recent_empty);
|
||||
return;
|
||||
}
|
||||
FileView::recent_list(ui, |ui| {
|
||||
for (index, row) in recent.into_iter().enumerate() {
|
||||
let row_id = format!("{id}:recent:{index}");
|
||||
let response = FileView::recent_row(
|
||||
ui,
|
||||
&row_id,
|
||||
ui_kit::RecentRow {
|
||||
title: &row.title,
|
||||
path: &row.path,
|
||||
when: &row.when,
|
||||
workspace: &row.kind,
|
||||
thumbnail: row.thumbnail,
|
||||
thumbnail_failed: row.thumbnail_failed,
|
||||
},
|
||||
);
|
||||
publish(&row_id, &response);
|
||||
let mut menu = row.menu.into_iter().map(Some).collect::<Vec<_>>();
|
||||
ui_kit::Menu::context(&response, |ui| {
|
||||
for entry in &mut menu {
|
||||
let picked =
|
||||
entry.as_ref().is_some_and(|entry| ui_kit::MenuItem::new(&entry.label, &entry.label, entry.icon).show(ui).clicked());
|
||||
if picked && let Some(entry) = entry.take() {
|
||||
*chosen = Some(entry.chosen);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
if response.clicked() {
|
||||
*chosen = Some(row.chosen);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(t.file_section_gap);
|
||||
FileView::section(ui, &samples_title, t.space_10, |ui| {
|
||||
let (shown, picks): (Vec<_>, Vec<_>) =
|
||||
samples.into_iter().map(|sample| ((sample.title, sample.description, sample.icon, sample.thumbnail), sample.chosen)).unzip();
|
||||
let cards: Vec<_> = shown
|
||||
.iter()
|
||||
.map(|(title, description, icon, thumbnail)| ui_kit::SampleCard { title, description, icon: *icon, thumbnail: *thumbnail })
|
||||
.collect();
|
||||
let sample_id = format!("{id}:sample");
|
||||
let responses = FileView::sample_grid(ui, &sample_id, &cards);
|
||||
for ((index, response), pick) in responses.into_iter().enumerate().zip(picks) {
|
||||
publish(&format!("{sample_id}:{index}"), &response);
|
||||
if response.clicked() {
|
||||
*chosen = Some(pick);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn review_list<A>(ui: &mut egui::Ui, id: &str, reviews: Reviews<'_, A>, chosen: &mut Option<A>) {
|
||||
if reviews.items.is_empty() {
|
||||
ui_kit::text(ui, &reviews.empty, ui_kit::Text::Inline);
|
||||
}
|
||||
for (index, review) in reviews.items.into_iter().enumerate() {
|
||||
let review_id = format!("{id}:recovery:{index}");
|
||||
let (recover_label, recover_chosen) = review.recover;
|
||||
let (discard_label, discard_chosen) = review.discard;
|
||||
let (recover, discard) = FileView::recovery_review(ui, &review_id, &review.title, &review.detail, &recover_label, &discard_label);
|
||||
publish(&format!("{review_id}:recover"), &recover);
|
||||
if recover.clicked() {
|
||||
*chosen = Some(recover_chosen);
|
||||
}
|
||||
if let Some(discard) = discard {
|
||||
publish(&format!("{review_id}:discard"), &discard);
|
||||
if discard.clicked() {
|
||||
*chosen = Some(discard_chosen);
|
||||
}
|
||||
}
|
||||
ui.add_space(Tokens::get().space_12);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Picked {
|
||||
Back,
|
||||
Rail(&'static str),
|
||||
Tile(&'static str),
|
||||
Recent(usize),
|
||||
Forget(usize),
|
||||
ShowInFolder(usize),
|
||||
Sample(usize),
|
||||
}
|
||||
|
||||
/// The texture the second tile's icon is drawn from.
|
||||
const HOST_ICON: egui::TextureId = egui::TextureId::User(7);
|
||||
|
||||
/// A start page of plain data: no app, no design, no engine.
|
||||
fn page(recent: usize) -> Page<'static, Picked> {
|
||||
let tile = |id: &'static str, icon, primary| Tile {
|
||||
id: id.into(),
|
||||
icon,
|
||||
title: id.into(),
|
||||
hint: "a hint".into(),
|
||||
primary,
|
||||
chosen: Picked::Tile(id),
|
||||
};
|
||||
let entry = |id: &'static str, selected, enabled| RailEntry {
|
||||
id: id.into(),
|
||||
label: id.into(),
|
||||
shortcut: "".into(),
|
||||
selected,
|
||||
enabled,
|
||||
chosen: Picked::Rail(id),
|
||||
};
|
||||
Page {
|
||||
id: "start",
|
||||
name: "Start page".into(),
|
||||
interactive: true,
|
||||
back: Back { template: "Back to {name}".into(), document: "Plate".into(), shortcut: "Esc".into(), chosen: Picked::Back },
|
||||
rail: vec![
|
||||
RailItem::Entry(entry("start:home", true, true)),
|
||||
RailItem::Entry(entry("start:save", false, false)),
|
||||
RailItem::Separator,
|
||||
RailItem::Entry(entry("start:about", false, true)),
|
||||
],
|
||||
title: "Start".into(),
|
||||
hint: "Pick something".into(),
|
||||
error: None,
|
||||
body: Body::Start(Box::new(Start {
|
||||
banner: None,
|
||||
actions_title: "New".into(),
|
||||
actions: Tiles {
|
||||
grid: "start_new",
|
||||
tiles: vec![tile("start:first", Icon::Kit("new"), true), tile("start:second", Icon::Texture(HOST_ICON), false)],
|
||||
},
|
||||
recent_title: "Recent".into(),
|
||||
recent_empty: "Nothing yet".into(),
|
||||
recent: (0..recent)
|
||||
.map(|i| Recent {
|
||||
title: format!("part {i}").into(),
|
||||
path: "~/part".into(),
|
||||
when: "Just now".into(),
|
||||
kind: "Surface".into(),
|
||||
thumbnail: None,
|
||||
thumbnail_failed: false,
|
||||
chosen: Picked::Recent(i),
|
||||
menu: vec![
|
||||
MenuEntry { label: "Remove from list".into(), icon: "delete", chosen: Picked::Forget(i) },
|
||||
MenuEntry { label: "Show in folder".into(), icon: "folder", chosen: Picked::ShowInFolder(i) },
|
||||
],
|
||||
})
|
||||
.collect(),
|
||||
samples_title: "Samples".into(),
|
||||
samples: (0..3)
|
||||
.map(|i| Sample {
|
||||
title: format!("sample {i}").into(),
|
||||
description: "a sample".into(),
|
||||
icon: Icon::Kit("box"),
|
||||
thumbnail: None,
|
||||
chosen: Picked::Sample(i),
|
||||
})
|
||||
.collect(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// The page drawn frame by frame, with what it chose so far.
|
||||
struct Drive {
|
||||
ctx: egui::Context,
|
||||
screen: egui::Rect,
|
||||
recent: usize,
|
||||
chosen: Vec<Picked>,
|
||||
}
|
||||
|
||||
impl Drive {
|
||||
fn new(size: egui::Vec2, recent: usize) -> Drive {
|
||||
let mut drive =
|
||||
Drive { ctx: crate::fonts::test_context(), screen: egui::Rect::from_min_size(egui::Pos2::ZERO, size), recent, chosen: Vec::new() };
|
||||
drive.frame(Vec::new());
|
||||
drive
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<egui::Event>) -> egui::FullOutput {
|
||||
solidcraft_ui_registry::clear_handles();
|
||||
let input = egui::RawInput { screen_rect: Some(self.screen), focused: true, events, ..Default::default() };
|
||||
let recent = self.recent;
|
||||
let chosen = &mut self.chosen;
|
||||
crate::test_support::frame(&self.ctx, input, |ui| chosen.extend(show(ui, page(recent))))
|
||||
}
|
||||
|
||||
fn at(name: &str) -> egui::Pos2 {
|
||||
solidcraft_ui_registry::handle_at(name).unwrap_or_else(|| panic!("{name} was not drawn"))
|
||||
}
|
||||
|
||||
/// Press and release at what `name` published last frame.
|
||||
fn click_with(&mut self, name: &str, button: egui::PointerButton) {
|
||||
let pos = Self::at(name);
|
||||
for pressed in [true, false] {
|
||||
self.frame(vec![egui::Event::PointerMoved(pos), egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() }]);
|
||||
}
|
||||
}
|
||||
|
||||
fn click(&mut self, name: &str) {
|
||||
self.click_with(name, egui::PointerButton::Primary);
|
||||
}
|
||||
}
|
||||
|
||||
fn click(size: egui::Vec2, name: &str, recent: usize) -> Vec<Picked> {
|
||||
let mut drive = Drive::new(size, recent);
|
||||
drive.click(name);
|
||||
drive.chosen
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_page_from_plain_data_returns_the_tile_clicked() {
|
||||
let wide = egui::vec2(1400.0, 900.0);
|
||||
assert_eq!(click(wide, "start:second", 0), [Picked::Tile("start:second")]);
|
||||
assert_eq!(click(wide, "start:first", 0), [Picked::Tile("start:first")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_page_returns_the_rail_entry_recent_and_sample_clicked() {
|
||||
let wide = egui::vec2(1400.0, 900.0);
|
||||
assert_eq!(click(wide, "start:back", 2), [Picked::Back]);
|
||||
assert_eq!(click(wide, "start:about", 2), [Picked::Rail("start:about")]);
|
||||
assert_eq!(click(wide, "start:recent:1", 2), [Picked::Recent(1)]);
|
||||
assert_eq!(click(wide, "start:sample:2", 2), [Picked::Sample(2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_page_disabled_rail_entry_returns_nothing() {
|
||||
assert_eq!(click(egui::vec2(1400.0, 900.0), "start:save", 0), []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_page_recent_context_menu_returns_the_entry_picked() {
|
||||
for (entry, picked) in [("menu:Remove from list", Picked::Forget(1)), ("menu:Show in folder", Picked::ShowInFolder(1))] {
|
||||
let mut drive = Drive::new(egui::vec2(1400.0, 900.0), 2);
|
||||
drive.click_with("start:recent:1", egui::PointerButton::Secondary);
|
||||
drive.frame(Vec::new());
|
||||
drive.click(entry);
|
||||
assert_eq!(drive.chosen, [picked], "{entry}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_page_tile_draws_the_hosts_texture_icon() {
|
||||
let mut drive = Drive::new(egui::vec2(1400.0, 900.0), 0);
|
||||
let output = drive.frame(Vec::new());
|
||||
let tile = solidcraft_ui_registry::rect_of("start:second").unwrap();
|
||||
let mut drawn = false;
|
||||
for clipped in &output.shapes {
|
||||
crate::test_support::each_shape(&clipped.shape, &mut |shape| {
|
||||
if let egui::Shape::Mesh(mesh) = shape {
|
||||
drawn |= mesh.texture_id == HOST_ICON && tile.contains_rect(mesh.calc_bounds());
|
||||
}
|
||||
});
|
||||
}
|
||||
assert!(drawn, "the second tile draws its icon from the host's texture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_page_lays_out_inside_a_narrow_window_and_keeps_its_tiles() {
|
||||
assert_eq!(click(egui::vec2(420.0, 800.0), "start:first", 0), [Picked::Tile("start:first")]);
|
||||
let page = solidcraft_ui_registry::rect_of("start:page").unwrap();
|
||||
assert!(page.contains_rect(solidcraft_ui_registry::rect_of("start:second").unwrap()));
|
||||
assert!(solidcraft_ui_registry::handle_at("start:back").is_none(), "the rail folds into the page's menu");
|
||||
}
|
||||
}
|
||||
@@ -275,9 +275,9 @@ fn report_rect(bar: Rect, captions: f32, tabs_start: f32, label_width: f32, vers
|
||||
/// The closed search field in the application bar, ending `right`: whole when the document tabs
|
||||
/// keep the room Report a bug's label leaves them (`tabs_room`: theirs without the field), else
|
||||
/// just its magnifier.
|
||||
fn header_search_rect(app: &SolveApp, ctx: &egui::Context, bar: Rect, right: f32, tabs_room: f32) -> Rect {
|
||||
fn header_search_rect(ctx: &egui::Context, bar: Rect, right: f32, tabs_room: f32) -> Rect {
|
||||
let height = 26.0;
|
||||
let full = crate::ui_kit::CommandSearch::closed_width(ctx, crate::keymap::display_for(app, "ui.palette").as_deref());
|
||||
let full = crate::ui_kit::CommandSearch::closed_width(ctx);
|
||||
let whole = bar.width() >= 640.0 && tabs_room - full - HEADER_SEARCH_GAP >= 216.0;
|
||||
let width = if whole { full } else { crate::ui_kit::CommandSearch::compact_width(height) };
|
||||
Rect::from_min_size(pos2(right - width, bar.center().y - height / 2.0), vec2(width, height))
|
||||
@@ -536,7 +536,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
// moves the assistant button and the end of the document tabs left by its width.
|
||||
let gear_left = report.left() - 68.0;
|
||||
let search = (search_in_header(app) && design_shown(app))
|
||||
.then(|| header_search_rect(app, ui.ctx(), r, gear_left - HEADER_SEARCH_GAP, report.left() - CLUSTER_BUTTONS - tabs_start));
|
||||
.then(|| header_search_rect(ui.ctx(), r, gear_left - HEADER_SEARCH_GAP, report.left() - CLUSTER_BUTTONS - tabs_start));
|
||||
let search_shift = search.map_or(0.0, |s| s.width() + HEADER_SEARCH_GAP);
|
||||
if let Some(search) = search {
|
||||
// shortcut_box::show draws the one search editor wherever this rectangle is.
|
||||
@@ -1385,8 +1385,7 @@ fn ribbon_tabs(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect) -> Option<bool>
|
||||
// The application bar may hold the search field instead ([`HeaderLayout`]): the tabs then
|
||||
// run to the row's end.
|
||||
let in_ribbon = !search_in_header(app);
|
||||
let search_width =
|
||||
if in_ribbon { crate::ui_kit::CommandSearch::closed_width(ui.ctx(), crate::keymap::display_for(app, "ui.palette").as_deref()) } else { 0.0 };
|
||||
let search_width = if in_ribbon { crate::ui_kit::CommandSearch::closed_width(ui.ctx()) } else { 0.0 };
|
||||
// The first tab and the search field sit the same distance from the card's top, left and right
|
||||
// edges, so their outer corners are concentric with the card's corners.
|
||||
let floating = Container::for_layout(app.ui.layout, Surface::Panel).floating();
|
||||
@@ -3743,6 +3742,9 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
// A single Esc with nothing left to cancel keeps the sketch open (#1804); a quick
|
||||
// second press finishes it (above).
|
||||
}
|
||||
Key::S if !mods.shift && !mods.alt => {
|
||||
app.start("ui.palette");
|
||||
}
|
||||
// With a menu open, V runs that menu's Show/Hide on what the menu is for.
|
||||
Key::V => {
|
||||
if !crate::context_menu::press_shortcut(app, "V") {
|
||||
@@ -4472,7 +4474,7 @@ mod header_report_tests {
|
||||
let search = rect("ribbon:search");
|
||||
assert!(search.top() >= APP_BAR_HEIGHT, "search is in the ribbon: {search:?}");
|
||||
assert_eq!(search.height(), TAB_ROW_HEIGHT - 2.0 * RIBBON_TAB_INSET);
|
||||
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx, Some("S")));
|
||||
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx));
|
||||
assert!(crate::scenario::rect_of("titlebar:search").is_none());
|
||||
let report = rect("titlebar:report");
|
||||
// A pre-release's longer version takes more room than RIGHT_GROUP and moves the button left.
|
||||
@@ -4505,7 +4507,7 @@ mod header_report_tests {
|
||||
let (search, prefs) = (rect("titlebar:search"), rect("titlebar:preferences"));
|
||||
assert_eq!(search.right(), prefs.left() - HEADER_SEARCH_GAP, "search sits immediately left of Settings");
|
||||
assert!(search.bottom() <= APP_BAR_HEIGHT && (search.center().y - prefs.center().y).abs() < 0.5);
|
||||
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx, Some("S")), "the whole field where there is room");
|
||||
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx), "the whole field where there is room");
|
||||
assert_eq!(rect("ribbon:search"), search, "the one search field is drawn in the bar");
|
||||
// Report a bug: an icon the size of Settings and Help, still named for assistive tools.
|
||||
let report = rect("titlebar:report");
|
||||
|
||||
@@ -25,43 +25,11 @@ pub(crate) struct RecentRow<'a> {
|
||||
pub thumbnail_failed: bool,
|
||||
}
|
||||
|
||||
/// A picture the host gives a kit surface: a name in the kit's icon set, a texture the host made
|
||||
/// (its own app icon, say), or the host's own painter, called with the icon's rect and opacity.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Icon<'a> {
|
||||
Kit(&'a str),
|
||||
Texture(TextureId),
|
||||
Paint(&'a dyn Fn(&egui::Painter, Rect, f32)),
|
||||
}
|
||||
|
||||
impl<'a> From<&'a str> for Icon<'a> {
|
||||
fn from(name: &'a str) -> Self {
|
||||
Icon::Kit(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Icon<'_> {
|
||||
pub(crate) fn paint(&self, painter: &egui::Painter, rect: Rect, opacity: f32) {
|
||||
match self {
|
||||
Icon::Kit(name) => crate::icons::paint_accented(painter, rect, name, None, opacity),
|
||||
Icon::Texture(texture) => {
|
||||
painter.image(
|
||||
*texture,
|
||||
rect,
|
||||
Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
|
||||
Tokens::get().file_image_tint.gamma_multiply(opacity),
|
||||
);
|
||||
}
|
||||
Icon::Paint(paint) => paint(painter, rect, opacity),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct SampleCard<'a> {
|
||||
pub title: &'a str,
|
||||
pub description: &'a str,
|
||||
pub icon: Icon<'a>,
|
||||
pub icon: &'a str,
|
||||
pub thumbnail: Option<(TextureId, Vec2)>,
|
||||
}
|
||||
|
||||
@@ -277,22 +245,14 @@ impl FileView {
|
||||
ui.add_space(t.space_10);
|
||||
}
|
||||
|
||||
pub(crate) fn action_tile<'a>(
|
||||
ui: &mut Ui,
|
||||
id: &str,
|
||||
width: f32,
|
||||
icon: impl Into<Icon<'a>>,
|
||||
label: &str,
|
||||
subtitle: &str,
|
||||
primary: bool,
|
||||
) -> Response {
|
||||
pub(crate) fn action_tile(ui: &mut Ui, id: &str, width: f32, icon: &str, label: &str, subtitle: &str, primary: bool) -> Response {
|
||||
let t = Tokens::get();
|
||||
let (rect, response) = allocate(ui, id, width, t.file_tile_height, label);
|
||||
card(ui, rect, &response, primary);
|
||||
let inner = rect.shrink(t.space_16);
|
||||
let icon_size = t.file_tile_icon.min(inner.height()).min(inner.width());
|
||||
let icon_rect = Rect::from_center_size(pos2(inner.left() + icon_size / 2.0, inner.center().y), Vec2::splat(icon_size));
|
||||
icon.into().paint(ui.painter(), icon_rect, if ui.is_enabled() { 1.0 } else { 0.45 });
|
||||
crate::icons::paint_accented(ui.painter(), icon_rect, icon, None, if ui.is_enabled() { 1.0 } else { 0.45 });
|
||||
let text_rect = Rect::from_min_max(pos2((icon_rect.right() + t.space_14).min(inner.right()), inner.top()), inner.max);
|
||||
let ink = if primary { t.file_primary_ink } else { t.form_ink };
|
||||
let mut title_job = egui::text::LayoutJob::simple(label.to_owned(), crate::fonts::semibold(t.font_heading), ink, text_rect.width());
|
||||
@@ -602,7 +562,13 @@ fn sample_card_with_height(ui: &mut Ui, id: &str, width: f32, sample: &SampleCar
|
||||
let image = Rect::from_min_size(inner.min, vec2(inner.width(), t.file_sample_image_height));
|
||||
thumbnail(ui, image, sample.thumbnail);
|
||||
if sample.thumbnail.is_none() {
|
||||
sample.icon.paint(ui.painter(), Rect::from_center_size(image.center(), Vec2::splat(t.file_tile_icon.min(image.width()))), 1.0);
|
||||
crate::icons::paint_accented(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(image.center(), Vec2::splat(t.file_tile_icon.min(image.width()))),
|
||||
sample.icon,
|
||||
None,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(inner));
|
||||
let title_top = image.bottom() + t.space_8;
|
||||
|
||||
@@ -1120,7 +1120,7 @@ const ELEMENTS: &[Element] = &[
|
||||
FileView::sample_grid(
|
||||
ui,
|
||||
"gallery:file:sample",
|
||||
&[SampleCard { title: "Sample Plate", description: "Parametric plate with holes and a bored boss", icon: "new".into(), thumbnail: None }],
|
||||
&[SampleCard { title: "Sample Plate", description: "Parametric plate with holes and a bored boss", icon: "new", thumbnail: None }],
|
||||
);
|
||||
}),
|
||||
element!("file_recovery", |ui, _| {
|
||||
@@ -1145,7 +1145,6 @@ const ELEMENTS: &[Element] = &[
|
||||
radius: CommandSearch::field_radius(1.0, true),
|
||||
progress: 1.0,
|
||||
},
|
||||
Some("S"),
|
||||
4096,
|
||||
);
|
||||
ui.add_space(Tokens::get().space_6);
|
||||
|
||||
@@ -137,8 +137,8 @@ fn sample_grid_stretches_each_row_without_clipping_long_descriptions() {
|
||||
|ui| {
|
||||
ui.set_width(680.0);
|
||||
cards = FileView::sample_grid(ui, "samples", &[
|
||||
SampleCard { title: "Plate", description: "Short description", icon: "new".into(), thumbnail: None },
|
||||
SampleCard { title: "Enclosure", description: "Long descriptive sample text that wraps across several lines while preserving every word through the final marker.", icon: "new".into(), thumbnail: None },
|
||||
SampleCard { title: "Plate", description: "Short description", icon: "new", thumbnail: None },
|
||||
SampleCard { title: "Enclosure", description: "Long descriptive sample text that wraps across several lines while preserving every word through the final marker.", icon: "new", thumbnail: None },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -83,7 +83,6 @@ pub(crate) use disclosure::*;
|
||||
pub(crate) use drag::*;
|
||||
pub(crate) use fields::*;
|
||||
pub use fields::{Expressions, cell_editor, install_expressions, wide_cell_editor};
|
||||
pub use file_view::Icon;
|
||||
pub(crate) use file_view::{FileView, RecentRow, RecoveryMessage, SampleCard};
|
||||
pub(crate) use framing::*;
|
||||
pub(crate) use history::*;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user