mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-10 18:55:14 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a50ad60ad0 | |||
| a531623d2d | |||
| 92af38e470 | |||
| 0c4f55b60f | |||
| 0d490ffb35 | |||
| b5716d0fb8 | |||
| dab1f296bb | |||
| d946550019 | |||
| 47121d5287 | |||
| be12f10eab | |||
| 39e4100773 | |||
| 6ca597b857 | |||
| 543314c191 | |||
| 8fb4a1d22a | |||
| 0fc1ef8ca7 | |||
| 5fe92e88ec | |||
| 1c8fdde4ba | |||
| 3ce8b875b1 | |||
| bf78850423 | |||
| 3af51db557 | |||
| 956fe0070b |
+17
-5
@@ -1,15 +1,27 @@
|
||||
# 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 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.
|
||||
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.
|
||||
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.
|
||||
@@ -177,5 +189,5 @@ reviews:
|
||||
- name: "Existing defaults unchanged"
|
||||
mode: "warning"
|
||||
instructions: |
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -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):
|
||||
<!-- 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'):
|
||||
Changes a default: yes — <what>, old behaviour kept behind preference <name>
|
||||
Docs-only and bot PRs are exempt. -->
|
||||
|
||||
|
||||
@@ -436,6 +436,10 @@ 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
|
||||
|
||||
@@ -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`; see `solidcore/docs/layers.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`.
|
||||
- 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 keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line: `pr-checks.yml`.
|
||||
- A changed default (existing UI, not kernel results) keeps the old behaviour behind a preference; fill the PR's `Changes a default:` line.
|
||||
- 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`.
|
||||
|
||||
+1
-1
@@ -448,7 +448,7 @@ 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).
|
||||
`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`.
|
||||
|
||||
## Labels and triage
|
||||
|
||||
|
||||
Generated
+178
-12
@@ -125,10 +125,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cipher 0.4.4",
|
||||
"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"
|
||||
@@ -143,6 +154,21 @@ 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"
|
||||
@@ -485,6 +511,15 @@ 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"
|
||||
@@ -516,6 +551,16 @@ 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"
|
||||
@@ -703,7 +748,16 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -768,7 +822,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"inout",
|
||||
"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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -887,6 +951,12 @@ 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"
|
||||
@@ -996,9 +1066,9 @@ version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"block-padding",
|
||||
"cbc",
|
||||
"aes 0.8.4",
|
||||
"block-padding 0.3.3",
|
||||
"cbc 0.1.2",
|
||||
"dbus",
|
||||
"fastrand",
|
||||
"hkdf",
|
||||
@@ -1095,6 +1165,15 @@ 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"
|
||||
@@ -1625,7 +1704,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
"weezl 0.1.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2068,10 +2147,20 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"block-padding 0.3.3",
|
||||
"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"
|
||||
@@ -2316,6 +2405,43 @@ 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"
|
||||
@@ -3311,6 +3437,7 @@ 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",
|
||||
]
|
||||
@@ -3355,6 +3482,12 @@ 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"
|
||||
@@ -3730,8 +3863,8 @@ version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
"aes 0.8.4",
|
||||
"cbc 0.1.2",
|
||||
"futures-util",
|
||||
"generic-array",
|
||||
"hkdf",
|
||||
@@ -4333,7 +4466,7 @@ dependencies = [
|
||||
"solidcraft-color",
|
||||
"solidcraft-geom",
|
||||
"web-time",
|
||||
"weezl",
|
||||
"weezl 0.1.12",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4402,6 +4535,7 @@ dependencies = [
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"log",
|
||||
"lopdf",
|
||||
"minisign-verify",
|
||||
"muda",
|
||||
"nix 0.31.3",
|
||||
@@ -4506,6 +4640,17 @@ 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"
|
||||
@@ -4629,7 +4774,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error",
|
||||
"weezl",
|
||||
"weezl 0.1.12",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
@@ -4668,6 +4813,12 @@ 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"
|
||||
@@ -4845,6 +4996,15 @@ 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"
|
||||
@@ -5226,6 +5386,12 @@ 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"
|
||||
|
||||
+570
-111
@@ -1,6 +1,6 @@
|
||||
Third-party software in SolidCraft
|
||||
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 540 crates below.
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 559 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,7 +14,10 @@ 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)
|
||||
@@ -47,9 +50,11 @@ 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)
|
||||
@@ -66,10 +71,13 @@ 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)
|
||||
@@ -83,6 +91,7 @@ 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)
|
||||
@@ -106,6 +115,7 @@ 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)
|
||||
@@ -192,6 +202,7 @@ 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)
|
||||
@@ -217,6 +228,8 @@ 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)
|
||||
@@ -311,11 +324,13 @@ 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)
|
||||
@@ -387,6 +402,7 @@ 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)
|
||||
@@ -402,6 +418,7 @@ 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)
|
||||
@@ -422,6 +439,7 @@ 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)
|
||||
@@ -455,6 +473,7 @@ 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)
|
||||
@@ -844,10 +863,11 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
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
|
||||
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
|
||||
================================================================================
|
||||
|
||||
Apache License
|
||||
@@ -1052,6 +1072,37 @@ 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
|
||||
================================================================================
|
||||
@@ -1105,11 +1156,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-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
|
||||
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
|
||||
================================================================================
|
||||
|
||||
Apache License
|
||||
@@ -1314,6 +1365,23 @@ 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,
|
||||
@@ -1933,8 +2001,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,
|
||||
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
|
||||
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
|
||||
================================================================================
|
||||
|
||||
Apache License
|
||||
@@ -2890,7 +2958,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
block-buffer 0.12.1
|
||||
block-buffer 0.12.1, block-padding 0.4.2
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2018-2025 The RustCrypto Project Developers
|
||||
@@ -3019,6 +3087,20 @@ 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.
|
||||
@@ -3031,20 +3113,6 @@ 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
|
||||
================================================================================
|
||||
@@ -3187,7 +3255,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
cbc 0.1.2
|
||||
cbc 0.1.2, cbc 0.2.1
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2018-2022 RustCrypto Developers
|
||||
@@ -3290,6 +3358,36 @@ 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
|
||||
================================================================================
|
||||
@@ -3364,6 +3462,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.
|
||||
|
||||
================================================================================
|
||||
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
|
||||
================================================================================
|
||||
@@ -3476,6 +3604,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.
|
||||
|
||||
================================================================================
|
||||
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
|
||||
================================================================================
|
||||
@@ -3534,8 +3692,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, 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, stringprep 0.1.5, toml_edit
|
||||
0.25.15+spec-1.1.0, unicode-general-category 1.1.0, winit 0.30.13
|
||||
================================================================================
|
||||
|
||||
Apache License
|
||||
@@ -5235,6 +5393,36 @@ 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
|
||||
================================================================================
|
||||
@@ -7769,6 +7957,37 @@ 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
|
||||
================================================================================
|
||||
@@ -8707,6 +8926,65 @@ 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
|
||||
================================================================================
|
||||
@@ -10079,7 +10357,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
rand 0.8.8
|
||||
rand 0.10.3, rand 0.8.8
|
||||
================================================================================
|
||||
|
||||
Apache License
|
||||
@@ -10260,7 +10538,7 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
================================================================================
|
||||
rand 0.8.8, rand_chacha 0.3.1, rand_core 0.6.4
|
||||
rand 0.10.3, rand 0.8.8, rand_chacha 0.3.1, rand_core 0.6.4
|
||||
================================================================================
|
||||
|
||||
Copyright 2018 Developers of the Rand project
|
||||
@@ -10291,7 +10569,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
rand 0.8.8, rand_chacha 0.3.1, rand_core 0.6.4
|
||||
rand 0.10.3, 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
|
||||
@@ -10569,6 +10847,213 @@ 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
|
||||
================================================================================
|
||||
@@ -12276,39 +12761,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.
|
||||
|
||||
================================================================================
|
||||
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
|
||||
================================================================================
|
||||
@@ -12814,6 +13266,30 @@ 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
|
||||
================================================================================
|
||||
@@ -12977,6 +13453,16 @@ 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
|
||||
================================================================================
|
||||
@@ -13881,7 +14367,8 @@ carrying such notice may not be copied, modified, or distributed except
|
||||
according to those terms.
|
||||
|
||||
================================================================================
|
||||
unicode-bidi 0.3.18, unicode-properties 0.1.5, unicode-segmentation 1.13.3, unicode-width 0.2.2
|
||||
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
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2015 The Rust Project Developers
|
||||
@@ -13911,7 +14398,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
unicode-ident 1.0.26
|
||||
unicode-ident 1.0.26, unicode-normalization 0.1.26
|
||||
================================================================================
|
||||
|
||||
UNICODE LICENSE V3
|
||||
@@ -13954,6 +14441,19 @@ 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
|
||||
================================================================================
|
||||
@@ -13998,18 +14498,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-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
|
||||
================================================================================
|
||||
@@ -14726,7 +15214,7 @@ of Data, including for example machine learning models and models'
|
||||
insights.
|
||||
|
||||
================================================================================
|
||||
weezl 0.1.12
|
||||
weezl 0.1.12, weezl 0.2.1
|
||||
================================================================================
|
||||
|
||||
The MIT License (MIT)
|
||||
@@ -15786,36 +16274,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.
|
||||
|
||||
================================================================================
|
||||
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
|
||||
================================================================================
|
||||
@@ -16135,6 +16593,7 @@ 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)
|
||||
|
||||
@@ -1509,6 +1509,7 @@
|
||||
"cmd.timeline_stop": "إيقاف السجل",
|
||||
"cmd.timeline_suppress": "تعطيل الميزة",
|
||||
"cmd.ui.assistant": "إظهار المساعد أو إخفاؤه",
|
||||
"cmd.ui.palette": "البحث في الأوامر",
|
||||
"cmd.view.navigator": "متصفح العرض المخصص",
|
||||
"cmd.view.render_browser": "مستعرض التصيير",
|
||||
"cmd.view.render_dock": "نقل لوحة التصيير",
|
||||
|
||||
@@ -1319,6 +1319,7 @@
|
||||
"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,6 +1509,7 @@
|
||||
"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,6 +1509,7 @@
|
||||
"cmd.timeline_stop": "עצירת ציר הזמן",
|
||||
"cmd.timeline_suppress": "השבתת פיצ'ר",
|
||||
"cmd.ui.assistant": "הצגה או הסתרה של העוזר",
|
||||
"cmd.ui.palette": "חיפוש פקודות",
|
||||
"cmd.view.navigator": "ניווט תצוגה מותאם אישית",
|
||||
"cmd.view.render_browser": "דפדפן רינדור",
|
||||
"cmd.view.render_dock": "הזז את עגינת הרינדור",
|
||||
|
||||
@@ -1509,6 +1509,7 @@
|
||||
"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,6 +1252,7 @@
|
||||
"cmd.timeline_stop": "기록 재생 중지",
|
||||
"cmd.timeline_suppress": "특성 비활성화",
|
||||
"cmd.ui.assistant": "도우미 표시 전환",
|
||||
"cmd.ui.palette": "명령 검색",
|
||||
"cmd.view.navigator": "사용자 지정 뷰 탐색기",
|
||||
"cmd.view.render_browser": "렌더 브라우저",
|
||||
"cmd.view.render_dock": "렌더 도크 이동",
|
||||
|
||||
@@ -1509,6 +1509,7 @@
|
||||
"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,6 +1509,7 @@
|
||||
"cmd.timeline_stop": "停止历史播放",
|
||||
"cmd.timeline_suppress": "抑制特征",
|
||||
"cmd.ui.assistant": "切换助手显示",
|
||||
"cmd.ui.palette": "搜索命令",
|
||||
"cmd.view.navigator": "自定义视图导航器",
|
||||
"cmd.view.render_browser": "渲染浏览器",
|
||||
"cmd.view.render_dock": "移动渲染停靠面板",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,89 @@
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
+81
-26
@@ -4866,9 +4866,13 @@ 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.
|
||||
fn parallel_tapped_wall(st: &ModelState, entry: Vec3, inward: Vec3, radius: f64) -> Option<Vec3> {
|
||||
//
|
||||
// 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)> {
|
||||
let tol = (radius * 1e-6).max(1e-6);
|
||||
let mut best: Option<(f64, Vec3)> = None;
|
||||
let mut walls: Vec<(f64, f64, Vec3)> = Vec::new();
|
||||
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
|
||||
@@ -4895,12 +4899,15 @@ 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);
|
||||
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));
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, wall)| wall)
|
||||
walls.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
walls
|
||||
}
|
||||
|
||||
/// The cutting tools of a hole feature at every spot, each with its exact geometry when it is a
|
||||
@@ -7652,7 +7659,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, .. } => {
|
||||
FeatureKind::Hole { thread, thread_depth, diameter, hand, modeled, ease_out, depth, to, .. } => {
|
||||
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()));
|
||||
@@ -7667,6 +7674,19 @@ 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();
|
||||
@@ -7702,42 +7722,77 @@ fn eval_feature(
|
||||
)
|
||||
});
|
||||
let ease = thread::runout(vals, ease_out)?;
|
||||
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))
|
||||
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,
|
||||
};
|
||||
// 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.
|
||||
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))?;
|
||||
// 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))?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut t = thread_on(st, f.id, wall, &ask)?.info;
|
||||
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);
|
||||
}
|
||||
if let Some(depth) = thread_depth {
|
||||
let available = t.end - t.start;
|
||||
if depth > available {
|
||||
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 {
|
||||
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)
|
||||
)));
|
||||
}
|
||||
// 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;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
st.threads.push(t);
|
||||
st.threads.extend(threads);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -22,6 +22,7 @@ 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;
|
||||
|
||||
@@ -50,8 +50,11 @@ 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`]). `Err` explains when there is none: no
|
||||
/// such edge, the named edge split into several, or a name saved before format 14.
|
||||
/// 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.
|
||||
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"));
|
||||
@@ -81,7 +84,13 @@ 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,9 +483,10 @@ 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`.
|
||||
/// 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`]).
|
||||
pub fn params_csv(&self) -> String {
|
||||
let q = |s: &str| if s.contains([',', '"', '\r', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };
|
||||
let q = crate::csv::field;
|
||||
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!(
|
||||
@@ -1137,13 +1138,14 @@ 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(field.to_string());
|
||||
return Ok(crate::csv::unguard(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(s.replace("\"\"", "\"")),
|
||||
Some(s) if s.split("\"\"").all(|part| !part.contains('"')) => Ok(crate::csv::unguard(&s.replace("\"\"", "\"")).to_string()),
|
||||
_ => Err(DocError::Invalid("invalid quoting in parameter CSV".into())),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -421,13 +421,10 @@ fn rows(s: &Session, ix: &Index, counts: &BTreeMap<u64, u64>, group: Group, kits
|
||||
out
|
||||
}
|
||||
|
||||
/// A CSV field, always quoted.
|
||||
fn quote(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
/// The bill of materials as CSV: fixed columns, every field quoted.
|
||||
/// The bill of materials as CSV: fixed columns, every field quoted, and a field a spreadsheet
|
||||
/// would run as a formula written as text.
|
||||
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,6 +201,20 @@ 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,6 +30,14 @@ 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")
|
||||
@@ -257,6 +265,14 @@ 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,6 +1687,12 @@ 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,8 +15,8 @@ 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, ScaleParams,
|
||||
SphereParams, TorusParams,
|
||||
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, RevolveParams,
|
||||
ScaleParams, SphereParams, TorusParams,
|
||||
};
|
||||
use crate::schema::split::{SplitBodyParams, SplitFaceParams};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
@@ -24,6 +24,7 @@ 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();
|
||||
@@ -48,9 +49,11 @@ 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", revolve)
|
||||
CommandSpec::new("solid.revolve", "Revolve", crate::checked!("solid.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")
|
||||
|
||||
@@ -13,6 +13,7 @@ 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};
|
||||
@@ -20,6 +21,7 @@ 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();
|
||||
|
||||
@@ -58,9 +60,11 @@ 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", align)
|
||||
CommandSpec::new("solid.align", "Align", crate::checked!("solid.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")
|
||||
@@ -421,21 +425,58 @@ 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 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"));
|
||||
}
|
||||
let mask = axes_mask(v).map_err(|why| bad(cmd, why))?;
|
||||
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")?;
|
||||
@@ -492,20 +533,13 @@ 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)));
|
||||
}
|
||||
// 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));
|
||||
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)),
|
||||
}
|
||||
// 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)?;
|
||||
@@ -568,7 +602,24 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// The longest sketch point or curve id an Align frame takes.
|
||||
const MAX_SKETCH_ID: usize = 256;
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -585,11 +636,10 @@ 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 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)"))),
|
||||
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)"))),
|
||||
};
|
||||
(frame, s.doc.feature(sketch).map_or(0, |f| f.component))
|
||||
}
|
||||
|
||||
@@ -23,10 +23,16 @@ 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)
|
||||
@@ -38,9 +44,11 @@ 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", insert_mesh)
|
||||
CommandSpec::new("file.insert_mesh", "Insert Mesh", crate::checked!("file.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")
|
||||
@@ -102,10 +110,12 @@ 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", save_mesh)
|
||||
CommandSpec::new("file.save_mesh", "Save As Mesh", crate::checked!("file.save_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)
|
||||
@@ -138,6 +148,7 @@ 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"))
|
||||
@@ -310,18 +321,24 @@ 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 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)
|
||||
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()))
|
||||
}
|
||||
|
||||
/// Turn an import's component a quarter turn about X (the file's Y up becomes Z up), contents
|
||||
@@ -980,7 +997,9 @@ 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}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")}))
|
||||
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()))
|
||||
}
|
||||
|
||||
/// [`export_to`] for a mesh file a slicer is sent (STL or 3MF): each body laid on its print face.
|
||||
@@ -998,9 +1017,16 @@ 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 mut out = json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")});
|
||||
laid.report(&mut out);
|
||||
Ok(out)
|
||||
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()))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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};
|
||||
|
||||
@@ -120,18 +121,24 @@ 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) {
|
||||
obj.insert("slicer_error".into(), json!(e));
|
||||
say(SlicerDelivery { slicer_error: Some(e), ..Default::default() });
|
||||
return;
|
||||
}
|
||||
if wait == Wait::Return {
|
||||
obj.insert("starting".into(), json!(target.name));
|
||||
say(SlicerDelivery { starting: Some(target.name.clone()), ..Default::default() });
|
||||
return;
|
||||
}
|
||||
if !slicers::wait_for_ready_via(target, busctl, limit, poll) {
|
||||
obj.insert("slicer_timeout".into(), json!(target.name));
|
||||
say(SlicerDelivery { slicer_timeout: Some(target.name.clone()), ..Default::default() });
|
||||
return;
|
||||
}
|
||||
// After a start the file has to reach the instance that is now running, so the flag goes
|
||||
@@ -140,8 +147,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(()) => obj.insert("slicer".into(), json!(target.name)),
|
||||
Err(e) => obj.insert("slicer_error".into(), json!(e)),
|
||||
Ok(()) => say(SlicerDelivery { slicer: Some(target.name.clone()), ..Default::default() }),
|
||||
Err(e) => say(SlicerDelivery { slicer_error: Some(e), ..Default::default() }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ 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),
|
||||
at.occurrence.replace(',', " "),
|
||||
solidcraft_doc::csv::field(&at.occurrence),
|
||||
o[0],
|
||||
o[1],
|
||||
o[2],
|
||||
|
||||
@@ -687,6 +687,25 @@ 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,6 +400,27 @@ 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"] {
|
||||
|
||||
@@ -1205,8 +1205,9 @@ 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.
|
||||
let quote = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
|
||||
// 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;
|
||||
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,6 +2069,17 @@ 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();
|
||||
|
||||
@@ -696,7 +696,10 @@ 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();
|
||||
if let Some(origin) = axis.get("origin").and_then(|o| m.make(o)) {
|
||||
// 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)) {
|
||||
axis.insert("origin".into(), origin);
|
||||
}
|
||||
if let Some(dir) = axis.get("dir").and_then(vec3)
|
||||
|
||||
@@ -456,6 +456,38 @@ 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,6 +125,47 @@ 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)] {
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,329 @@
|
||||
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"));
|
||||
}
|
||||
@@ -19,6 +19,15 @@ 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)]
|
||||
@@ -1519,7 +1528,7 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
AssistantParams, CheckpointParams, CheckpointResult, FeatureKeys, FeatureRef, FeatureSelection, FolderId, FolderIds, MarkerPosition, NewName,
|
||||
RedoParams, RedoResult, RepeatParams, RestoreCheckpointParams, SetFields, TimelineComputeAllParams, TimelineComputeAllResult,
|
||||
PaletteParams, RedoParams, RedoResult, RepeatParams, RestoreCheckpointParams, SetFields, TimelineComputeAllParams, TimelineComputeAllResult,
|
||||
TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams, TimelineEditResult,
|
||||
TimelineInspectParams, TimelineInspectResult, TimelineMarkerResult, TimelineRenameParams, TimelineRenameResult, TimelineRollToParams,
|
||||
TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult, UndoParams, UndoResult,
|
||||
@@ -1537,6 +1546,7 @@ 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,6 +18,7 @@ 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.
|
||||
@@ -181,6 +182,78 @@ 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, ScaleParams, SphereParams, TorusParams,
|
||||
FilletParams, FilletResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, RevolveParams, ScaleParams, SphereParams, TorusParams,
|
||||
};
|
||||
use super::{SurfaceSweepParams, SweepParams};
|
||||
use crate::Session;
|
||||
@@ -52,6 +52,31 @@ 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"],
|
||||
@@ -165,6 +190,41 @@ 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]})),
|
||||
@@ -866,3 +926,109 @@ 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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,8 +235,8 @@ 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` also StlAscii,
|
||||
/// ThreeMfTagged or Design).
|
||||
/// 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).
|
||||
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")]
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,290 @@
|
||||
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,6 +54,7 @@ mod coverage_tests;
|
||||
mod tests;
|
||||
|
||||
// The contracts, by command file.
|
||||
pub mod align;
|
||||
pub mod appearance;
|
||||
pub mod bom;
|
||||
pub mod browser;
|
||||
@@ -71,6 +72,7 @@ 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;
|
||||
@@ -105,6 +107,7 @@ 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;
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,303 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
|
||||
@@ -1136,6 +1137,80 @@ 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,6 +210,12 @@ 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)]
|
||||
@@ -1360,6 +1366,17 @@ 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
|
||||
@@ -1369,14 +1386,9 @@ 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) {
|
||||
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()),
|
||||
})
|
||||
model_axis_problem(&v)
|
||||
} else {
|
||||
match &v {
|
||||
Value::String(_) => None,
|
||||
@@ -1416,6 +1428,132 @@ 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,10 +7,8 @@ doc.recover
|
||||
doc.recovery_discard
|
||||
doc.recovery_list
|
||||
face.delete
|
||||
file.insert_mesh
|
||||
file.insert_step
|
||||
file.new
|
||||
file.save_mesh
|
||||
file.slicers
|
||||
motion.export_animation
|
||||
motion.trace
|
||||
@@ -23,7 +21,6 @@ render.preflight
|
||||
render.scene
|
||||
render.start
|
||||
sheet.export_dxf
|
||||
solid.align
|
||||
solid.coil
|
||||
solid.draft
|
||||
solid.emboss
|
||||
@@ -37,7 +34,6 @@ solid.pattern.path
|
||||
solid.pattern.rectangular
|
||||
solid.press_pull
|
||||
solid.replace_face
|
||||
solid.revolve
|
||||
solid.shell
|
||||
solid.thread
|
||||
surface.blend
|
||||
|
||||
@@ -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)]
|
||||
static CHOSEN_PROGRAMS: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
pub(crate) 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`.
|
||||
|
||||
@@ -1528,3 +1528,60 @@ 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,11 +432,12 @@ 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, 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).
|
||||
/// 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).
|
||||
#[test]
|
||||
fn a_seam_split_in_two_edges_is_drawn_whole() {
|
||||
fn a_g2_corner_s_side_to_its_round_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();
|
||||
@@ -452,7 +453,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(), 2, "SMLib splits the side in two: {side:?}");
|
||||
assert_eq!(side.len(), 1, "the Setback's section is one edge (tangency-labs/solidcore#2988): {side:?}");
|
||||
assert!(side.iter().all(|e| mesh.shaded_edge_visible(*e)), "the side to round {round} is drawn whole: {side:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2487,6 +2487,26 @@ 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() {
|
||||
|
||||
+12
-2
@@ -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 (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. When a sketch command creates more points than its new curves have ends (spline poles), `created.points` becomes `created.point_count` (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` (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`, and `created.points` becomes `created.point_count` when there are more points than the new curves have ends (default true)"},
|
||||
"expect": expect_schema(),
|
||||
}),
|
||||
&[],
|
||||
@@ -1829,6 +1829,16 @@ 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,9 +606,10 @@ 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, 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.
|
||||
/// 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).
|
||||
#[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]];
|
||||
@@ -616,17 +617,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, 30, 22]);
|
||||
assert_eq!(counts(&open).unwrap(), [9, 27, 19]);
|
||||
assert!(!super::is_manifold_solid(&open).unwrap());
|
||||
let [hole] = holes.as_slice() else { panic!("one corner: {holes:?}") };
|
||||
assert_eq!(hole.edges.len(), 12);
|
||||
assert_eq!(hole.edges.len(), 9);
|
||||
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) % 12];
|
||||
let next = &hole.edges[(k + 1) % 9];
|
||||
assert_eq!(end(h, true), end(next, false), "edge {k} ends where the next starts");
|
||||
assert!(h.face < 9);
|
||||
}
|
||||
|
||||
@@ -389,24 +389,26 @@ 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, 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.
|
||||
/// 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).
|
||||
#[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, 30, 22]);
|
||||
assert_eq!(open.counts().unwrap(), [9, 27, 19]);
|
||||
assert!(!open.is_manifold_solid().unwrap());
|
||||
let [hole] = holes.as_slice() else { panic!("one corner: {holes:?}") };
|
||||
assert_eq!(hole.edges.len(), 12);
|
||||
assert_eq!(hole.edges.len(), 9);
|
||||
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) % 12];
|
||||
let next = &hole.edges[(k + 1) % 9];
|
||||
assert_eq!(end(h, true), end(next, false), "edge {k} ends where the next starts");
|
||||
assert!(h.face < 9);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ 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,6 +30,11 @@ 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,
|
||||
@@ -88,10 +93,11 @@ pub enum Content {
|
||||
size: [u32; 2],
|
||||
},
|
||||
Text(Arc<String>),
|
||||
/// A PDF, sent whole as a document to an agent that takes them.
|
||||
/// A PDF, sent whole as a document to an agent that takes them; to the others, its text.
|
||||
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 {
|
||||
@@ -103,6 +109,60 @@ 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
|
||||
@@ -222,7 +282,9 @@ 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))?;
|
||||
Ok(Attachment { id: 0, name, content: Content::Pdf { bytes: Arc::new(bytes), path }, thumb: None })
|
||||
// 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 })
|
||||
}
|
||||
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, &[]))
|
||||
@@ -334,6 +396,24 @@ 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
|
||||
@@ -353,21 +433,8 @@ 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) => {
|
||||
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 => {
|
||||
Content::Text(t) => fit(&mut out, &mut left, &format!("Attached file `{}`", f.name), t),
|
||||
Content::Pdf { path, bytes, .. } if documents_sent => {
|
||||
out += &format!(
|
||||
"\n\nAttached PDF `{}` ({}, at {}): sent with this message as a document.",
|
||||
f.name,
|
||||
@@ -375,14 +442,29 @@ pub fn note(files: &[Attachment], pictures_sent: bool, documents_sent: bool, bud
|
||||
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()
|
||||
);
|
||||
// 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Content::Reference { path, size, mime } => {
|
||||
out += &format!(
|
||||
@@ -405,7 +487,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(),
|
||||
@@ -680,4 +762,107 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +330,20 @@ 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 } => body_items(app, std::slice::from_ref(name), true),
|
||||
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::Sketch { id } => sketch_items(app, *id),
|
||||
Target::Feature { id } => feature_items(app, *id),
|
||||
Target::Plane { id } => plane_items(app, *id),
|
||||
|
||||
+218
-274
@@ -4,13 +4,11 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use egui::vec2;
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::render::{Camera, StandardView};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::ui_kit::{self, FileView, RecoveryMessage};
|
||||
use solidcraft_theme::Tokens;
|
||||
use crate::start_page;
|
||||
|
||||
/// Recent designs kept.
|
||||
pub const MAX_RECENT: usize = 12;
|
||||
@@ -622,7 +620,13 @@ enum FileAction {
|
||||
Shortcuts,
|
||||
Crashes,
|
||||
About,
|
||||
Recovery(solidcraft_engine::recovery::Entry, bool),
|
||||
/// 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),
|
||||
}
|
||||
|
||||
/// File view replaces the workspace while keeping the open design and its history intact.
|
||||
@@ -672,7 +676,6 @@ 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}");
|
||||
@@ -695,246 +698,174 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
want(&mut app.home, path);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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) {
|
||||
dispatch(app, ui.ctx(), action);
|
||||
}
|
||||
}
|
||||
|
||||
fn navigation(app: &SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>) {
|
||||
/// SolidCraft's rail: Start, New, Open, Recover; the design's own entries; the app's.
|
||||
fn rail(app: &SolveApp) -> Vec<start_page::RailItem<'_, 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 = if app.recoverable.is_empty() {
|
||||
app.tr_label("Recover").to_string()
|
||||
let recover: start_page::Label<'_> = if app.recoverable.is_empty() {
|
||||
app.tr_label("Recover").into()
|
||||
} else {
|
||||
format!("{} ({})", app.tr_label("Recover"), app.recoverable.len())
|
||||
format!("{} ({})", app.tr_label("Recover"), app.recoverable.len()).into()
|
||||
};
|
||||
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 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),
|
||||
];
|
||||
for (index, (id, label, key, section, enabled, picked)) in nav.into_iter().enumerate() {
|
||||
let mut rail = Vec::with_capacity(nav.len() + 2);
|
||||
for (index, (id, label, shortcut, section, enabled, chosen)) in nav.into_iter().enumerate() {
|
||||
if index == 4 || index == 8 {
|
||||
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::Separator);
|
||||
}
|
||||
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 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())]);
|
||||
fn start_body(app: &SolveApp) -> start_page::Start<'_, FileAction> {
|
||||
let banner = app.recoverable.first().map(|entry| {
|
||||
let filename = entry
|
||||
.path
|
||||
.as_deref()
|
||||
.and_then(|path| std::path::Path::new(path).file_name())
|
||||
.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) });
|
||||
.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))),
|
||||
}
|
||||
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"), 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
("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,
|
||||
}
|
||||
}
|
||||
|
||||
fn texture(texture: Option<&egui::TextureHandle>) -> Option<(egui::TextureId, egui::Vec2)> {
|
||||
@@ -1007,62 +938,55 @@ fn recent_time_at(app: &SolveApp, time: u64, now: u64) -> String {
|
||||
solidcraft_ui_text::format(key, &[("count", &count.to_string())])
|
||||
}
|
||||
|
||||
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 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 export_content(app: &SolveApp, ui: &mut egui::Ui, action: &mut Option<FileAction>) {
|
||||
fn export_tiles(app: &SolveApp) -> start_page::Tiles<'_, FileAction> {
|
||||
let formats = [
|
||||
("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")),
|
||||
("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")),
|
||||
];
|
||||
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"),
|
||||
});
|
||||
}
|
||||
});
|
||||
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 }
|
||||
}
|
||||
|
||||
fn dispatch(app: &mut SolveApp, ctx: &egui::Context, action: FileAction) {
|
||||
@@ -1126,7 +1050,27 @@ 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(entry, recover) => crate::recovery_ui::apply(app, entry, recover),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ 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",
|
||||
@@ -1884,6 +1883,43 @@ 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)]
|
||||
|
||||
@@ -293,6 +293,7 @@ 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;
|
||||
|
||||
@@ -1821,6 +1821,32 @@ 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};
|
||||
|
||||
@@ -7326,6 +7326,62 @@ 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,7 +304,17 @@ 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");
|
||||
let changed = ui.data(|d| d.get_temp::<String>(reveal_key)).as_ref() != Some(&identity);
|
||||
// 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 mut chosen = None;
|
||||
let mut card = |ui: &mut egui::Ui, index: usize, width: f32| {
|
||||
let Some(e) = visible.get(index) else { return };
|
||||
|
||||
@@ -455,6 +455,7 @@ 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,
|
||||
@@ -466,6 +467,7 @@ 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 {
|
||||
@@ -1096,7 +1098,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), 22.0));
|
||||
assert_eq!(closed.size(), vec2(ui_kit::CommandSearch::closed_width(&h.ctx, Some("S")), 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);
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
//! 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(ctx: &egui::Context, bar: Rect, right: f32, tabs_room: f32) -> Rect {
|
||||
fn header_search_rect(app: &SolveApp, ctx: &egui::Context, bar: Rect, right: f32, tabs_room: f32) -> Rect {
|
||||
let height = 26.0;
|
||||
let full = crate::ui_kit::CommandSearch::closed_width(ctx);
|
||||
let full = crate::ui_kit::CommandSearch::closed_width(ctx, crate::keymap::display_for(app, "ui.palette").as_deref());
|
||||
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(ui.ctx(), r, gear_left - HEADER_SEARCH_GAP, report.left() - CLUSTER_BUTTONS - tabs_start));
|
||||
.then(|| header_search_rect(app, 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,7 +1385,8 @@ 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()) } else { 0.0 };
|
||||
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 };
|
||||
// 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();
|
||||
@@ -3742,9 +3743,6 @@ 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") {
|
||||
@@ -4474,7 +4472,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));
|
||||
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx, Some("S")));
|
||||
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.
|
||||
@@ -4507,7 +4505,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), "the whole field where there is room");
|
||||
assert_eq!(search.width(), crate::ui_kit::CommandSearch::closed_width(&h.ctx, Some("S")), "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,11 +25,43 @@ 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: &'a str,
|
||||
pub icon: Icon<'a>,
|
||||
pub thumbnail: Option<(TextureId, Vec2)>,
|
||||
}
|
||||
|
||||
@@ -245,14 +277,22 @@ impl FileView {
|
||||
ui.add_space(t.space_10);
|
||||
}
|
||||
|
||||
pub(crate) fn action_tile(ui: &mut Ui, id: &str, width: f32, icon: &str, label: &str, subtitle: &str, primary: bool) -> Response {
|
||||
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 {
|
||||
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));
|
||||
crate::icons::paint_accented(ui.painter(), icon_rect, icon, None, if ui.is_enabled() { 1.0 } else { 0.45 });
|
||||
icon.into().paint(ui.painter(), icon_rect, 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());
|
||||
@@ -562,13 +602,7 @@ 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() {
|
||||
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,
|
||||
);
|
||||
sample.icon.paint(ui.painter(), Rect::from_center_size(image.center(), Vec2::splat(t.file_tile_icon.min(image.width()))), 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", thumbnail: None }],
|
||||
&[SampleCard { title: "Sample Plate", description: "Parametric plate with holes and a bored boss", icon: "new".into(), thumbnail: None }],
|
||||
);
|
||||
}),
|
||||
element!("file_recovery", |ui, _| {
|
||||
@@ -1145,6 +1145,7 @@ 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", 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 },
|
||||
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 },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -83,6 +83,7 @@ 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::*;
|
||||
|
||||
@@ -51,20 +51,23 @@ impl CommandSearch {
|
||||
}
|
||||
|
||||
/// Reserve the translated hint at the exact editor font, plus compact controls.
|
||||
/// The small text allowance covers pixel rounding and the editor's caret.
|
||||
pub(crate) fn closed_width(ctx: &egui::Context) -> f32 {
|
||||
/// The small text allowance covers pixel rounding and the editor's caret. `key` is command
|
||||
/// search's own key (#12190): its cap, or none when the user took it away.
|
||||
pub(crate) fn closed_width(ctx: &egui::Context, key: Option<&str>) -> f32 {
|
||||
let t = Tokens::get();
|
||||
let spacing = FieldSpacing::at(0.0);
|
||||
let (hint, key) = ctx.fonts_mut(|fonts| {
|
||||
let (hint, cap) = ctx.fonts_mut(|fonts| {
|
||||
(
|
||||
fonts
|
||||
.layout_no_wrap(solidcraft_ui_text::tr_label("Search commands").to_owned(), FontId::proportional(t.font_button), t.form_dim)
|
||||
.size()
|
||||
.x,
|
||||
fonts.layout_no_wrap("S".to_owned(), FontId::monospace(t.font_small), t.form_dim).size().x,
|
||||
key.map_or(0.0, |key| {
|
||||
fonts.layout_no_wrap(key.to_owned(), FontId::monospace(t.font_small), t.form_dim).size().x + spacing.key_gap + spacing.key_padding
|
||||
}),
|
||||
)
|
||||
});
|
||||
(spacing.left + t.space_16 + spacing.icon_gap + hint + spacing.key_gap + key + spacing.key_padding + spacing.right + t.space_2).ceil()
|
||||
(spacing.left + t.space_16 + spacing.icon_gap + hint + cap + spacing.right + t.space_2).ceil()
|
||||
}
|
||||
|
||||
/// The closed field as just its magnifier, for a bar short of room: a square button.
|
||||
@@ -92,6 +95,7 @@ impl CommandSearch {
|
||||
focus: bool,
|
||||
open: bool,
|
||||
geometry: SearchFieldGeometry,
|
||||
key: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Response {
|
||||
let t = Tokens::get();
|
||||
@@ -124,7 +128,7 @@ impl CommandSearch {
|
||||
bevel.paint(ui.painter(), r.shrink(if focused || invalid { 1.0 } else { 0.0 }), radius, true, ui.is_enabled());
|
||||
}
|
||||
// Closed with less room than its hint takes (a narrow bar): just the magnifier.
|
||||
if !open && progress == 0.0 && r.width() + 0.5 < Self::closed_width(ui.ctx()) {
|
||||
if !open && progress == 0.0 && r.width() + 0.5 < Self::closed_width(ui.ctx(), key) {
|
||||
icons::paint_icon(ui.painter(), Rect::from_center_size(r.center(), vec2(t.space_16, t.space_16)), "zoom", 1.0);
|
||||
solidcraft_ui_registry::publish_target("ribbon:search", r);
|
||||
solidcraft_ui_registry::publish_target("shortcut:search", r);
|
||||
@@ -139,13 +143,11 @@ impl CommandSearch {
|
||||
"zoom",
|
||||
1.0,
|
||||
);
|
||||
let cap = if open { "Esc" } else { "S" };
|
||||
let key_width = Self::key_with_padding(ui, r.right_center() - vec2(spacing.right, 0.0), cap, spacing.key_padding);
|
||||
let cap = if open { Some("Esc") } else { key };
|
||||
let key_width = cap
|
||||
.map_or(0.0, |cap| Self::key_with_padding(ui, r.right_center() - vec2(spacing.right, 0.0), cap, spacing.key_padding) + spacing.key_gap);
|
||||
let editor_left = (r.left() + spacing.left + t.space_16 + spacing.icon_gap).min(r.right());
|
||||
let editor = Rect::from_min_max(
|
||||
pos2(editor_left, r.top()),
|
||||
pos2((r.right() - spacing.right - key_width - spacing.key_gap).max(editor_left), r.bottom()),
|
||||
);
|
||||
let editor = Rect::from_min_max(pos2(editor_left, r.top()), pos2((r.right() - spacing.right - key_width).max(editor_left), r.bottom()));
|
||||
let mono = value.contains('{');
|
||||
let response = ui
|
||||
.scope_builder(egui::UiBuilder::new().max_rect(editor), |ui| {
|
||||
@@ -389,7 +391,7 @@ mod tests {
|
||||
let ctx = crate::fonts::test_context();
|
||||
let mut query = String::new();
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
CommandSearch::field(ui, &mut query, false, true, true, SearchFieldGeometry { height: 40.0, radius, progress }, 4096);
|
||||
CommandSearch::field(ui, &mut query, false, true, true, SearchFieldGeometry { height: 40.0, radius, progress }, Some("S"), 4096);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let shapes: Vec<_> = output
|
||||
@@ -423,7 +425,7 @@ mod tests {
|
||||
let ctx = crate::fonts::test_context();
|
||||
let mut query = String::new();
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
CommandSearch::field(ui, &mut query, false, true, true, SearchFieldGeometry { height: 40.0, radius, progress }, 4096);
|
||||
CommandSearch::field(ui, &mut query, false, true, true, SearchFieldGeometry { height: 40.0, radius, progress }, Some("S"), 4096);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
for shape in output.shapes {
|
||||
|
||||
@@ -237,7 +237,7 @@ impl StageBar {
|
||||
result.stage = Some(index);
|
||||
}
|
||||
}
|
||||
let search_width = super::CommandSearch::closed_width(ui.ctx()).max(188.0);
|
||||
let search_width = super::CommandSearch::closed_width(ui.ctx(), Some("S")).max(188.0);
|
||||
let action_width = actions.iter().map(|a| super::actions::icon_label_width(ui, a.label, a.icon, a.primary)).sum::<f32>();
|
||||
ui.add_space((ui.available_width() - search_width - action_width - t.space_4 * (actions.len() + 2) as f32).max(t.space_8));
|
||||
for (index, item) in actions.iter().enumerate() {
|
||||
|
||||
@@ -557,7 +557,16 @@ Rows are sorted by part number (rows without one come last) and hold `item`, `pa
|
||||
`description`, `revision`, `bom_type`, `count`, `definition` (the component names), `components` (their ids)
|
||||
and `properties`, with `total_occurrences` beside them. `path` writes them to a `.csv` file
|
||||
(the columns item, part_number, description, revision, count and definition, every field
|
||||
quoted; children follow their row) or a `.json` file. The window lists the same rows, switches
|
||||
quoted, and a field that a spreadsheet would run as a formula written with a `'` before it so the
|
||||
spreadsheet shows it as text; children follow their row) or a `.json` file. A formula field is one that
|
||||
starts with `=`, `+`, `-` or `@`, their full-width forms, a tab or a line break, after any spaces and
|
||||
`'`; a plain number such as `-5` is not one. A script that reads the CSV sees the `'` too: a part number
|
||||
`=1+1` is `'=1+1` in the file, and `'=1+1` is `''=1+1`. To get the value back, take off one leading `'`
|
||||
when what follows starts a formula, as `solidcraft_doc::csv::unguard` does, or read the `.json` file,
|
||||
which holds the values unchanged. The `'` holds only while the file is as SolidCraft wrote it: a
|
||||
spreadsheet that saves the file again as CSV can drop it (Excel does), and the formula then runs the next
|
||||
time that file opens. Export again from SolidCraft instead of passing on a CSV a spreadsheet has saved.
|
||||
The window lists the same rows, switches
|
||||
between the levels and groupings and shows children under their part, edits a picked row's part
|
||||
number, description and revision, and exports them. Changing part properties changes no
|
||||
geometry, so it doesn't evaluate the model or re-seat joints again.
|
||||
|
||||
@@ -102,6 +102,13 @@ an unchanged field cannot reinterpret it. Computed sizes and tolerances keep the
|
||||
precision when written back into editable fields, independently of display precision.
|
||||
|
||||
Parameters export to and import from CSV or JSON (`parameters.export`, `parameters.import`).
|
||||
In a CSV, a value that a spreadsheet would run as a formula is written with a `'` before it, so the
|
||||
spreadsheet shows it as text. That is a value that starts with `=`, `+`, `-` or `@`, their full-width
|
||||
forms, a tab or a line break, after any spaces and `'`; a plain number such as `-5` is left as it is.
|
||||
The import takes the `'` off again. A spreadsheet that saves the file again as CSV can drop the `'`
|
||||
(Excel does), so export again from SolidCraft instead of passing that file on. Earlier versions of
|
||||
SolidCraft wrote every value unchanged, so in a CSV from one of them, a value whose first character
|
||||
really was a `'` typed before a formula character (`'=x`) now imports without it (`=x`).
|
||||
**Configurations** (`config.*`) keep a table of rows, each with its own parameter values and
|
||||
suppressed features, physical materials and body appearances, switched with `config.activate`.
|
||||
The table's Library picker selects material or appearance values. Choose **Follow material**
|
||||
|
||||
@@ -791,6 +791,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
| Toggle Assistant (Ctrl+Shift+A) | `ui.assistant` | – |
|
||||
| Search Commands (S) | `ui.palette` | – |
|
||||
|
||||
## `view.*`
|
||||
|
||||
|
||||
+3
-1
@@ -474,7 +474,9 @@ Feature replies omit the full `bodies` list while keeping `feature`, `name`,
|
||||
`created_bodies`, `changed_bodies` and `removed_bodies`. A cut can therefore report a
|
||||
changed body even when no body name was added or removed. Sketch replies keep new
|
||||
IDs under `created` and omit only identical top-level `points`, `curves`,
|
||||
`constraints` and `dimensions` lists. Other fields and errors are unchanged;
|
||||
`constraints` and `dimensions` lists. When a sketch command creates more points than
|
||||
its new curves have ends (the poles of an offset's splines), `created.points` becomes
|
||||
`created.point_count`: refer to those curves by their ids. Other fields and errors are unchanged;
|
||||
inspection commands still return their complete results. Compaction uses the
|
||||
engine's reply directly, without an additional design-inspection request.
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@ function parseDefaults(body) {
|
||||
|
||||
const DEFAULTS_HELP =
|
||||
'Add one line to the description: `Changes a default: no`, or ' +
|
||||
'`Changes a default: yes — <what>, old behaviour kept behind preference <name>`.';
|
||||
'`Changes a default: yes — <what>, old behaviour kept behind preference <name>`. ' +
|
||||
'(UX defaults only; a kernel result change is \'no\'.)';
|
||||
|
||||
function isBot(user) {
|
||||
return !!user && (user.type === 'Bot' || BOT_LOGINS.has(user.login) || /\[bot\]$/.test(user.login || ''));
|
||||
|
||||
@@ -18,6 +18,7 @@ The authority is `solidcore/docs/layers.md`; this is its short form. Every job l
|
||||
|
||||
## Lists that only shrink
|
||||
- `xtask/mesh-decisions.txt`: model decisions made from a mesh (`cargo xtask mesh-decisions`).
|
||||
- `xtask/app-geometry.txt`: functions in the app crates that compute geometry (`cargo xtask app-geometry`). A new one fails CI; when you move one into `solidcore/`, delete its line.
|
||||
- `solidcore/plan/panic-sites.tsv`: panic-prone calls in solidcore (SolidCore checks in CI).
|
||||
|
||||
## Working in solidcore/
|
||||
|
||||
@@ -22,4 +22,4 @@ Settings live in the `preference_settings!` table in `crates/ui-egui/src/prefs.r
|
||||
- Test it in a UI scenario or a `*_tests.rs` file next to the setting (`skills/solidcraft-tests/SKILL.md`).
|
||||
|
||||
## What counts as a default
|
||||
Anything a user sees or does differently on first launch after the update: a changed shortcut, camera or navigation behaviour, unit, naming, theme or autosave behaviour, or a tool that appears unasked. A fix that makes the app do what it already claimed is not a default change; say so on the line.
|
||||
Anything a user sees or does differently on first launch after the update: a changed shortcut, camera or navigation behaviour, unit, naming, theme or autosave behaviour, or a tool that appears unasked. 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. Not defaults: bug fixes and more correct results, kernel or geometry result changes (write `Changes a default: no`), new options that are off by default, and CI or tooling config. A fix that makes the app do what it already claimed is not a default change; say so on the line.
|
||||
|
||||
@@ -33,7 +33,5 @@ solidcraft-kernel::corner_g2::tests::set_back_8_the_g2_corner_lies_between_the_h
|
||||
solidcraft-kernel::corner_g2::tests::the_box_corner_g2_patch_is_a_closed_solid_curvature_continuous_to_its_neighbours the fitted G2 corner patch bulges 1.7e-5 past the box, as on C++
|
||||
solidcraft-kernel::fillet_setback_tests::a_setback_not_past_the_radius_is_refused_naming_the_least solidcore builds setback 5.5 (15 faces) where SMLib f916a8d's setback corner (smlib#104), which solidcore ports in solidcore#2310 refuses it naming the least that builds; nothing names the least setback on solidcore until then
|
||||
solidcraft-kernel::native::tests::box_and_fillet_have_stable_wire_records the box-edge round's wire record differs from the fixture at solidcore 8ddce9016 (a value 3 is 5): the fixture is re-blessed (NATIVE_BREP_BLESS=1) once the change is shown right
|
||||
solidcraft-kernel::smooth_edges::tests::a_seam_split_in_two_edges_is_drawn_whole fails since solidcore c0d3cc0124 (passed at a445bdaac6), vendoring cutover (#13534)
|
||||
solidcraft-smlib::core::blend_tests::a_setback_corner_left_open_returns_its_hole fails since solidcore c0d3cc0124 (passed at a445bdaac6), vendoring cutover (#13534)
|
||||
solidcraft-smlib::ops::tests::a_setback_corner_left_open_returns_its_hole fails since solidcore c0d3cc0124 (passed at a445bdaac6), vendoring cutover (#13534)
|
||||
solidcraft-kernel::smooth_edges::tests::a_g2_corner_s_side_to_its_round_is_drawn_whole a G2 corner's side to its round is one edge since solidcore #2988, but isn't drawn whole: shaded_edge_visible false (#13542)
|
||||
solidcraft-smlib::ops::tests::a_variable_round_with_a_corner_in_its_law_is_its_balls_union solidcore refuses a variable fillet whose law has a corner (solidcore#430)
|
||||
|
||||
@@ -330,6 +330,7 @@ timeline.play plays the history back in the view: no new geo
|
||||
timeline.rename a feature name
|
||||
timeline.rename_group a group name
|
||||
ui.assistant the view: camera, layout, display
|
||||
ui.palette the view: the command search; tested in crates/ui-egui/src/keymap.rs
|
||||
view.ambient_occlusion the view: camera, layout, display
|
||||
view.angle_readout the view: camera, layout, display
|
||||
view.apply the view: camera, layout, display
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,369 @@
|
||||
//! `cargo xtask app-geometry [--print]`: apps compute no geometry (`solidcore/docs/layers.md` §4,
|
||||
//! RFC 0003 row 0.1). Vector maths on coordinates, solves and mesh queries belong in the kernel
|
||||
//! (solidcore); the crates here hold state and call it. The check keeps a ledger of the functions
|
||||
//! that still compute geometry, [`LIST`], and the ledger only shrinks: a refactor PR deletes its
|
||||
//! lines and no PR adds one.
|
||||
//!
|
||||
//! The heuristic, deliberately plain. The check reads the non-test Rust of the crates in
|
||||
//! [`CRATES`], following each crate's module tree from its `lib.rs` (a `#[cfg(test)]` or
|
||||
//! `#[test]` item is left out), and flags every function whose body (closures, nested blocks and
|
||||
//! expression macros such as `format!` included) uses at least one geometry operation, named by
|
||||
//! [`geometry_use`]:
|
||||
//! - vector maths, as a method call: `.dot(v)`, `.cross(v)`, `.normalize()`, `.normalized()`,
|
||||
//! `.magnitude()`, `.magnitude2()`, `.distance(p)`, `.distance2(p)`, `.hypot(y)`, `.atan2(x)`,
|
||||
//! `.angle(v)`;
|
||||
//! - solves: `.solve(..)`;
|
||||
//! - mesh queries: `.raycast(..)`, `.tangent_chain(..)`, the `.triangles` field and
|
||||
//! `closest_on_triangle(..)`.
|
||||
//!
|
||||
//! It is by name, not by type: a method with one of those names counts whoever defines it. It
|
||||
//! does not grade `sqrt`, `sin`, `cos`, `length`, `lerp` or plain arithmetic (too common in
|
||||
//! layout, colour and timing code), so the ledger is a floor of the sites, not a complete count.
|
||||
//! A function is `<path>:<scope>`, the enclosing inline modules, impl type and function joined
|
||||
//! by `::`. Nested functions and closures count toward the function they sit in; two functions
|
||||
//! with one name in one impl type (trait impls) are one site.
|
||||
//!
|
||||
//! [`LIST`] holds one site a line, `<path>:<function> <the operations it uses>`, sorted (the
|
||||
//! kernel rig's known-failing format; the operations are for the reader and are not graded). The
|
||||
//! check fails on a site the list does not name, and on a listed site that no longer computes
|
||||
//! geometry or no longer exists: its refactor removes its line, as with
|
||||
//! `xtask/mesh-decisions.txt`. `--print` writes the current sites in the list's format, to see
|
||||
//! what a change did; it is not a way to refresh the list.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use syn::visit::{self, Visit};
|
||||
|
||||
use crate::mesh_decisions::{module_files, relative, test_only, type_name};
|
||||
|
||||
pub const LIST: &str = "xtask/app-geometry.txt";
|
||||
|
||||
/// The crates that are apps or sit between an app and the kernel: the document, the engine, the
|
||||
/// UI, file formats, and SolidCraft's own kernel and geometry layers, whose geometry moves into
|
||||
/// solidcore.
|
||||
pub const CRATES: &[&str] = &["crates/doc", "crates/engine", "crates/ui-egui", "crates/io", "crates/kernel", "crates/geom"];
|
||||
|
||||
/// The geometry operation `call` is, if it is one.
|
||||
fn geometry_use(call: &Call) -> Option<&'static str> {
|
||||
match (call, call.name()) {
|
||||
(Call::Method { args: 1, .. }, "dot") => Some("dot"),
|
||||
(Call::Method { args: 1, .. }, "cross") => Some("cross"),
|
||||
(Call::Method { args: 0, .. }, "normalize") => Some("normalize"),
|
||||
(Call::Method { args: 0, .. }, "normalized") => Some("normalized"),
|
||||
(Call::Method { args: 0, .. }, "magnitude") => Some("magnitude"),
|
||||
(Call::Method { args: 0, .. }, "magnitude2") => Some("magnitude2"),
|
||||
(Call::Method { args: 1, .. }, "distance") => Some("distance"),
|
||||
(Call::Method { args: 1, .. }, "distance2") => Some("distance2"),
|
||||
(Call::Method { args: 1, .. }, "hypot") => Some("hypot"),
|
||||
(Call::Method { args: 1, .. }, "atan2") => Some("atan2"),
|
||||
(Call::Method { args: 1, .. }, "angle") => Some("angle"),
|
||||
(Call::Method { .. }, "solve") => Some("solve"),
|
||||
(Call::Method { .. }, "raycast") => Some("raycast"),
|
||||
(Call::Method { .. }, "tangent_chain") => Some("tangent_chain"),
|
||||
(Call::Function { .. }, "closest_on_triangle") => Some("closest_on_triangle"),
|
||||
(Call::Field { .. }, "triangles") => Some("triangles"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
enum Call {
|
||||
Method { name: String, args: usize },
|
||||
Function { name: String },
|
||||
Field { name: String },
|
||||
}
|
||||
|
||||
impl Call {
|
||||
fn name(&self) -> &str {
|
||||
match self {
|
||||
Call::Method { name, .. } | Call::Function { name } | Call::Field { name } => name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(root: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let found = scan(root)?;
|
||||
if args.contains(&"--print") {
|
||||
for (site, uses) in &found {
|
||||
println!("{site} {}", uses.join(","));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let text = std::fs::read_to_string(root.join(LIST)).map_err(|e| format!("{LIST}: {e}"))?;
|
||||
println!("{}", grade(&text, &found).map_err(|e| format!("{LIST}: {e}"))?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every site under `root`: `<path>:<function>` with the operations it uses, sorted.
|
||||
fn scan(root: &Path) -> Result<BTreeMap<String, Vec<&'static str>>, String> {
|
||||
let mut found = BTreeMap::new();
|
||||
for krate in CRATES {
|
||||
let mut files = Vec::new();
|
||||
module_files(&root.join(krate).join("src/lib.rs"), true, &mut files)?;
|
||||
files.sort();
|
||||
files.dedup();
|
||||
for path in files {
|
||||
let rel = relative(root, &path);
|
||||
let source = std::fs::read_to_string(&path).map_err(|e| format!("{rel}: {e}"))?;
|
||||
for (function, uses) in sites(&source).map_err(|e| format!("{rel}: {e}"))? {
|
||||
found.insert(format!("{rel}:{function}"), uses);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// The functions of `source` that compute geometry, with the operations each uses (outside test
|
||||
/// code), sorted.
|
||||
pub fn sites(source: &str) -> Result<BTreeMap<String, Vec<&'static str>>, String> {
|
||||
let ast = syn::parse_file(source).map_err(|e| format!("cannot parse Rust source: {e}"))?;
|
||||
let mut finder = Finder { scope: Vec::new(), fns: 0, found: BTreeMap::new() };
|
||||
finder.visit_file(&ast);
|
||||
for uses in finder.found.values_mut() {
|
||||
uses.sort_unstable();
|
||||
}
|
||||
Ok(finder.found)
|
||||
}
|
||||
|
||||
struct Finder {
|
||||
/// The enclosing inline modules, impl type and function, outermost first.
|
||||
scope: Vec<String>,
|
||||
/// How many functions enclose the code being visited: a use belongs to the outermost one.
|
||||
fns: usize,
|
||||
found: BTreeMap<String, Vec<&'static str>>,
|
||||
}
|
||||
|
||||
impl Finder {
|
||||
fn note(&mut self, call: Call) {
|
||||
let Some(what) = geometry_use(&call) else { return };
|
||||
// Outside any function (a const or static initialiser) the use belongs to that item.
|
||||
let site = if self.scope.is_empty() { "<module>".to_owned() } else { self.scope.join("::") };
|
||||
let uses = self.found.entry(site).or_default();
|
||||
if !uses.contains(&what) {
|
||||
uses.push(what);
|
||||
}
|
||||
}
|
||||
|
||||
fn scoped(&mut self, name: String, f: impl FnOnce(&mut Self)) {
|
||||
self.scope.push(name);
|
||||
f(self);
|
||||
self.scope.pop();
|
||||
}
|
||||
|
||||
/// A function: inside another function it joins that one (a nested `fn` is part of its
|
||||
/// parent's site), otherwise it is a site of its own.
|
||||
fn function(&mut self, name: String, f: impl FnOnce(&mut Self)) {
|
||||
if self.fns > 0 {
|
||||
f(self);
|
||||
} else {
|
||||
self.fns += 1;
|
||||
self.scoped(name, f);
|
||||
self.fns -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// A macro's arguments, when they are expressions (`format!`, `vec!`, `assert!`).
|
||||
fn macro_args(&mut self, mac: &syn::Macro) {
|
||||
let parser = syn::punctuated::Punctuated::<syn::Expr, syn::Token![,]>::parse_terminated;
|
||||
if let Ok(args) = mac.parse_body_with(parser) {
|
||||
for e in &args {
|
||||
self.visit_expr(e);
|
||||
}
|
||||
} else if let Ok(block) = mac.parse_body_with(syn::Block::parse_within) {
|
||||
for s in &block {
|
||||
self.visit_stmt(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ast> Visit<'ast> for Finder {
|
||||
fn visit_item_mod(&mut self, m: &'ast syn::ItemMod) {
|
||||
if !test_only(&m.attrs) && m.content.is_some() {
|
||||
self.scoped(m.ident.to_string(), |f| visit::visit_item_mod(f, m));
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_item_fn(&mut self, i: &'ast syn::ItemFn) {
|
||||
if !test_only(&i.attrs) {
|
||||
self.function(i.sig.ident.to_string(), |f| visit::visit_item_fn(f, i));
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_item_impl(&mut self, i: &'ast syn::ItemImpl) {
|
||||
if !test_only(&i.attrs) {
|
||||
self.scoped(type_name(&i.self_ty), |f| visit::visit_item_impl(f, i));
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_impl_item_fn(&mut self, i: &'ast syn::ImplItemFn) {
|
||||
if !test_only(&i.attrs) {
|
||||
self.function(i.sig.ident.to_string(), |f| visit::visit_impl_item_fn(f, i));
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_item_trait(&mut self, i: &'ast syn::ItemTrait) {
|
||||
if !test_only(&i.attrs) {
|
||||
self.scoped(i.ident.to_string(), |f| visit::visit_item_trait(f, i));
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_trait_item_fn(&mut self, i: &'ast syn::TraitItemFn) {
|
||||
if !test_only(&i.attrs) {
|
||||
self.function(i.sig.ident.to_string(), |f| visit::visit_trait_item_fn(f, i));
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_item_const(&mut self, i: &'ast syn::ItemConst) {
|
||||
if !test_only(&i.attrs) {
|
||||
self.scoped(i.ident.to_string(), |f| visit::visit_item_const(f, i));
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_item_static(&mut self, i: &'ast syn::ItemStatic) {
|
||||
if !test_only(&i.attrs) {
|
||||
self.scoped(i.ident.to_string(), |f| visit::visit_item_static(f, i));
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_stmt(&mut self, s: &'ast syn::Stmt) {
|
||||
if let syn::Stmt::Local(l) = s
|
||||
&& test_only(&l.attrs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
visit::visit_stmt(self, s);
|
||||
}
|
||||
|
||||
fn visit_expr_method_call(&mut self, e: &'ast syn::ExprMethodCall) {
|
||||
self.note(Call::Method { name: e.method.to_string(), args: e.args.len() });
|
||||
visit::visit_expr_method_call(self, e);
|
||||
}
|
||||
|
||||
fn visit_expr_call(&mut self, e: &'ast syn::ExprCall) {
|
||||
if let syn::Expr::Path(p) = e.func.as_ref()
|
||||
&& let Some(last) = p.path.segments.last()
|
||||
{
|
||||
self.note(Call::Function { name: last.ident.to_string() });
|
||||
}
|
||||
visit::visit_expr_call(self, e);
|
||||
}
|
||||
|
||||
fn visit_expr_field(&mut self, e: &'ast syn::ExprField) {
|
||||
if let syn::Member::Named(name) = &e.member {
|
||||
self.note(Call::Field { name: name.to_string() });
|
||||
}
|
||||
visit::visit_expr_field(self, e);
|
||||
}
|
||||
|
||||
fn visit_macro(&mut self, mac: &'ast syn::Macro) {
|
||||
self.macro_args(mac);
|
||||
visit::visit_macro(self, mac);
|
||||
}
|
||||
}
|
||||
|
||||
/// The crate of a site (`crates/<name>/...`).
|
||||
fn crate_of(site: &str) -> &str {
|
||||
site.strip_prefix("crates/").and_then(|s| s.split('/').next()).unwrap_or("?")
|
||||
}
|
||||
|
||||
/// The per-crate counts of `sites`, in [`CRATES`] order: `doc 12, engine 3, ...`.
|
||||
fn counts<'a>(sites: impl Iterator<Item = &'a str>) -> String {
|
||||
let mut n: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
for s in sites {
|
||||
*n.entry(crate_of(s)).or_default() += 1;
|
||||
}
|
||||
CRATES.iter().map(|c| c.trim_start_matches("crates/")).map(|c| format!("{c} {}", n.get(c).unwrap_or(&0))).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// The gate: every site in `found` is listed in `list`, and every listed site is still found.
|
||||
fn grade(list: &str, found: &BTreeMap<String, Vec<&'static str>>) -> Result<String, String> {
|
||||
let listed = crate::kernel_cases::known_failing(list)?;
|
||||
let mut problems = Vec::new();
|
||||
for (site, uses) in found {
|
||||
if !listed.iter().any(|(s, _)| s == site) {
|
||||
problems.push(format!(
|
||||
"NEW: {site} computes geometry ({}): call the kernel (solidcore ops) for it instead; apps hold state and compute no geometry",
|
||||
uses.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
for (site, _) in &listed {
|
||||
if !found.contains_key(*site) {
|
||||
problems.push(format!("FIXED: {site} no longer computes geometry (or is gone): remove its line"));
|
||||
}
|
||||
}
|
||||
let by_crate = counts(listed.iter().map(|(s, _)| *s));
|
||||
if problems.is_empty() {
|
||||
Ok(format!("app-geometry: {} sites compute geometry ({by_crate}); no new ones", listed.len()))
|
||||
} else {
|
||||
Err(format!("{} problem(s) (listed: {by_crate}):\n {}", problems.len(), problems.join("\n ")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn geometry_is_found_per_function_outside_tests() {
|
||||
let s = sites(
|
||||
r#"
|
||||
fn tilt(n: V, u: V) -> f64 { n.dot(u).acos() }
|
||||
struct Rig;
|
||||
impl Rig {
|
||||
fn frame(&self, a: V, b: V) -> V { let f = |x: V| x.cross(b); f(a).normalized() }
|
||||
fn nested(&self) { fn inner(a: V) -> f64 { a.magnitude() } let _ = inner; }
|
||||
fn plain(&self, a: f64) -> f64 { a.sqrt() + a.sin() }
|
||||
fn arity(&self, a: V) -> f64 { a.dot() }
|
||||
}
|
||||
fn in_macro(a: V, b: V) -> String { format!("{}", a.distance(b)) }
|
||||
fn mesh(m: &Mesh) -> usize { m.triangles.len() }
|
||||
#[cfg(test)] mod tests { fn fixture(a: V, b: V) { a.dot(b); } }
|
||||
#[test] fn t(a: V, b: V) { a.dot(b); }
|
||||
mod inner { fn nearest(p: V) -> V { closest_on_triangle(p, p, p, p) } }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let keys: Vec<&str> = s.keys().map(String::as_str).collect();
|
||||
assert_eq!(keys, ["Rig::frame", "Rig::nested", "in_macro", "inner::nearest", "mesh", "tilt"]);
|
||||
assert_eq!(s["Rig::frame"], ["cross", "normalized"]);
|
||||
assert_eq!(s["Rig::nested"], ["magnitude"], "a nested fn counts toward its parent");
|
||||
assert!(sites("invalid Rust {{").is_err(), "unparseable source fails closed");
|
||||
}
|
||||
|
||||
const LISTED: &str = "# comment\ncrates/doc/src/a.rs:tilt dot\ncrates/ui-egui/src/b.rs:near distance\n";
|
||||
|
||||
fn found(names: &[&str]) -> BTreeMap<String, Vec<&'static str>> {
|
||||
names.iter().map(|n| (n.to_string(), vec!["dot"])).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listed_sites_pass_and_counts_are_per_crate() {
|
||||
let ok = grade(LISTED, &found(&["crates/doc/src/a.rs:tilt", "crates/ui-egui/src/b.rs:near"])).unwrap();
|
||||
assert!(ok.contains("doc 1, engine 0, ui-egui 1, io 0, kernel 0, geom 0"), "{ok}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_site_fails() {
|
||||
let e = grade(LISTED, &found(&["crates/doc/src/a.rs:tilt", "crates/ui-egui/src/b.rs:near", "crates/doc/src/a.rs:pick"])).unwrap_err();
|
||||
assert!(e.contains("NEW: crates/doc/src/a.rs:pick"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gone_site_still_listed_fails() {
|
||||
let e = grade(LISTED, &found(&["crates/doc/src/a.rs:tilt"])).unwrap_err();
|
||||
assert!(e.contains("FIXED: crates/ui-egui/src/b.rs:near"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_is_sorted_with_a_reason_each() {
|
||||
assert!(grade("b.rs:near x\na.rs:tilt x\n", &found(&["a.rs:tilt", "b.rs:near"])).is_err());
|
||||
assert!(grade("a.rs:tilt\n", &found(&["a.rs:tilt"])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_workspace_has_no_new_app_geometry() {
|
||||
run(Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(), &[]).unwrap();
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -3,6 +3,7 @@
|
||||
//! Rust workspace tooling. External tools (`cargo`, and Python 3.11+ for the
|
||||
//! original icon collection) are invoked through `std::process::Command`.
|
||||
|
||||
mod app_geometry;
|
||||
mod app_icons;
|
||||
mod assets;
|
||||
mod book;
|
||||
@@ -121,6 +122,12 @@ commands:
|
||||
geometry: fails on a function outside display, export, previews and mesh bodies
|
||||
that uses a mesh and xtask/mesh-decisions.txt does not list, and on a listed one
|
||||
that no longer does (the list only shrinks)
|
||||
app-geometry [--print]
|
||||
check that apps compute no geometry: fails on a function in crates/doc, engine,
|
||||
ui-egui, io, kernel or geom that does vector maths, solves or mesh queries and
|
||||
xtask/app-geometry.txt does not list, and on a listed one that no longer does
|
||||
(the list only shrinks); prints the listed count per crate. --print writes the
|
||||
sites found, in the list's format
|
||||
test [CARGO ARGS] [-- TEST ARGS]
|
||||
build the tests with the ci profile, then run every test binary at once
|
||||
(and the doc tests alongside); SOLIDCRAFT_TEST_JOBS=N bounds concurrent test
|
||||
@@ -136,7 +143,7 @@ commands:
|
||||
check in headless Chrome that the web app works offline and updates to a new
|
||||
build (a stand-in app, or the web build in DIR such as dist/web)
|
||||
ci fmt --check, assets, icons, licences --check, lists, skills, layers, shared,
|
||||
ui-containers, mesh-decisions, clippy -D warnings, then the tests
|
||||
ui-containers, mesh-decisions, app-geometry, clippy -D warnings, then the tests
|
||||
with the wasm check alongside, then web-offline-test when Chrome is installed
|
||||
(stops at the first failing step)
|
||||
";
|
||||
@@ -166,6 +173,7 @@ fn main() -> ExitCode {
|
||||
Some("fold-pending") => pending::run(&root()),
|
||||
Some("skills") => skills::run(&root()),
|
||||
Some("mesh-decisions") => mesh_decisions::run(&root()),
|
||||
Some("app-geometry") => app_geometry::run(&root(), &rest),
|
||||
Some("lists") => lists::run(&root(), rest.contains(&"--fix")),
|
||||
Some("test") => {
|
||||
let (cargo_args, test_args) = rest.iter().position(|a| *a == "--").map_or((&rest[..], &[][..]), |i| (&rest[..i], &rest[i + 1..]));
|
||||
@@ -477,6 +485,7 @@ fn cmd_ci() -> Result<(), String> {
|
||||
shared::run(&root())?;
|
||||
ui_containers::run(&root())?;
|
||||
mesh_decisions::run(&root())?;
|
||||
app_geometry::run(&root(), &[])?;
|
||||
let mut c = cargo();
|
||||
c.args(["clippy", "--workspace", "--all-targets", "--profile", "ci", "--", "-D", "warnings"]);
|
||||
run(c, "cargo clippy -D warnings")?;
|
||||
|
||||
@@ -178,14 +178,14 @@ fn scan_skipping(root: &Path, modules: &[(&str, &str)], functions: &[(&str, &str
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn relative(root: &Path, path: &Path) -> String {
|
||||
pub(crate) fn relative(root: &Path, path: &Path) -> String {
|
||||
path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
/// The source files of the module at `file` and of every non-test module below it, into `out`.
|
||||
/// `owns_dir` is whether `file` is a `lib.rs` or `mod.rs` (its submodules sit beside it) rather
|
||||
/// than `name.rs` (its submodules sit in `name/`).
|
||||
fn module_files(file: &Path, owns_dir: bool, out: &mut Vec<PathBuf>) -> Result<(), String> {
|
||||
pub(crate) fn module_files(file: &Path, owns_dir: bool, out: &mut Vec<PathBuf>) -> Result<(), String> {
|
||||
let source = std::fs::read_to_string(file).map_err(|e| format!("{}: {e}", file.display()))?;
|
||||
let ast = syn::parse_file(&source).map_err(|e| format!("{}: cannot parse Rust source: {e}", file.display()))?;
|
||||
out.push(file.to_path_buf());
|
||||
@@ -244,7 +244,7 @@ fn path_attribute(attrs: &[syn::Attribute]) -> Option<String> {
|
||||
|
||||
/// Whether `attrs` keep an item to test builds: `#[test]`, `#[cfg(test)]` or
|
||||
/// `#[cfg(all(.., test, ..))]`.
|
||||
fn test_only(attrs: &[syn::Attribute]) -> bool {
|
||||
pub(crate) fn test_only(attrs: &[syn::Attribute]) -> bool {
|
||||
attrs.iter().any(|a| {
|
||||
if a.path().is_ident("test") {
|
||||
return true;
|
||||
@@ -310,7 +310,7 @@ impl Finder {
|
||||
}
|
||||
}
|
||||
|
||||
fn type_name(ty: &syn::Type) -> String {
|
||||
pub(crate) fn type_name(ty: &syn::Type) -> String {
|
||||
match ty {
|
||||
syn::Type::Path(p) => p.path.segments.last().map_or_else(|| "<type>".to_owned(), |s| s.ident.to_string()),
|
||||
syn::Type::Reference(r) => type_name(&r.elem),
|
||||
|
||||
Reference in New Issue
Block a user