mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 21:11:56 +00:00
Compare commits
162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8b4c36b9a | |||
| 5f1167f2ce | |||
| 93638960e2 | |||
| 769e57654e | |||
| fda573a6a4 | |||
| dc24446027 | |||
| 5153f78557 | |||
| e39f269aa5 | |||
| be66e894d9 | |||
| 2e43cce857 | |||
| 71f5b6989d | |||
| 789d628e43 | |||
| f292f6bfaf | |||
| be6158f82f | |||
| 0397be0f7c | |||
| 4384c52448 | |||
| b2d5044fbd | |||
| c2436a98f1 | |||
| bfe3da9c3d | |||
| ec50f99608 | |||
| 95176d0326 | |||
| f0e2c95dd0 | |||
| 5dbfa589bf | |||
| d3ba5d414b | |||
| 72254ce90d | |||
| c2eeb2c5ed | |||
| 2724151d04 | |||
| d83cc2e48b | |||
| 4b6efddb3c | |||
| de146e349b | |||
| d83d6b3c1e | |||
| 50c78b30dd | |||
| 26db4c7777 | |||
| 363ec793dc | |||
| bd4f6e857d | |||
| 31c6f39e98 | |||
| 731749224d | |||
| a54ff93c59 | |||
| d2b9daeba8 | |||
| 223ccd0004 | |||
| 331218f23b | |||
| c361491d57 | |||
| 24ba6f29ca | |||
| 3edfed7219 | |||
| 0383f87e57 | |||
| bb91986df3 | |||
| af73d7bd90 | |||
| 7d86e00ca7 | |||
| e894f4a12f | |||
| 7b9e66929b | |||
| bd3a94e946 | |||
| b0d7219115 | |||
| a7598a92c6 | |||
| 5c4932ba42 | |||
| ee72a98948 | |||
| 2fba25f564 | |||
| 7b90cdf3fb | |||
| 8b7586ba87 | |||
| b23d1ada5d | |||
| 9a0d9ce243 | |||
| be226abee7 | |||
| 414b8bb139 | |||
| 8706d623fb | |||
| ae1d85e5d4 | |||
| 0d350e7b38 | |||
| e69dfe126c | |||
| 3c4b336080 | |||
| 1ec5eda955 | |||
| 7bb016e5e4 | |||
| 98fdb6ef3c | |||
| 95fece4c7b | |||
| f959c0168d | |||
| a2d42fdaec | |||
| eaf8f27a7f | |||
| 8f1143b940 | |||
| 778e7465e9 | |||
| fa127b3e29 | |||
| 970eb715f3 | |||
| a74e78fba8 | |||
| 2171393816 | |||
| 3eacb3722e | |||
| 2c82e702f5 | |||
| f256bef34b | |||
| 83cb509f81 | |||
| c18aaf7cc3 | |||
| 1e6b1ec20f | |||
| 4d96c780e1 | |||
| 52afe212a8 | |||
| 0bb09d0b13 | |||
| 11abdba1ba | |||
| 7c9c1699de | |||
| cf9ae4d25e | |||
| 1f29f432ee | |||
| dc679cae34 | |||
| dbbfa437a5 | |||
| ba43d2798f | |||
| 6a1fe74683 | |||
| a9ff55ef27 | |||
| 3081630f92 | |||
| 323defa525 | |||
| 5eaf8c4181 | |||
| b464c48506 | |||
| 7a2f2949a7 | |||
| fcd4ccd09a | |||
| beaa43cb40 | |||
| 6085423d0d | |||
| 4425b87d2b | |||
| 59d032659f | |||
| 395679015f | |||
| 30be21a224 | |||
| cd1685035a | |||
| f472ee1a92 | |||
| 0160e67ea1 | |||
| c29f396f72 | |||
| 46edecca67 | |||
| a36c1743dc | |||
| 7b3ca00054 | |||
| 91a06a546e | |||
| fd02123b3e | |||
| 13ab728d07 | |||
| 4a07865288 | |||
| b73d6e6e6f | |||
| 3b2ca62c86 | |||
| 24c7bb7d70 | |||
| 670636ecd6 | |||
| 3623466acf | |||
| ecbd05ef5e | |||
| c1bd94e937 | |||
| 28fed4d6dd | |||
| be648ef742 | |||
| a8d775c12a | |||
| e02e1d40b5 | |||
| 136b62fa47 | |||
| a2c4a1267b | |||
| 735688dbc5 | |||
| 0debd9759f | |||
| 2c6230eafc | |||
| 850509120b | |||
| 72d2beff71 | |||
| 5395387dad | |||
| 0e83a0e494 | |||
| 9387a1b93c | |||
| 304aba2a49 | |||
| b9270a6884 | |||
| ea46e07300 | |||
| eaf014eb54 | |||
| 1c03f2be06 | |||
| 3dc258c203 | |||
| 565ba4a37e | |||
| 1c1018aa6e | |||
| 1bd05a2c51 | |||
| 5d8cd60f70 | |||
| 046eba5ba3 | |||
| 96a6629ed1 | |||
| 203ce2672b | |||
| 43c14bdbdf | |||
| 6b5b49b285 | |||
| 3d49d0cb0b | |||
| 9ecf28834f | |||
| 2e4b728fca | |||
| 5524224222 | |||
| 2c4e3fe4d6 |
@@ -1,6 +1,9 @@
|
||||
# Preserve canonical source receipts and the runtime icon-discovery snapshot.
|
||||
.gitattributes text eol=lf
|
||||
/assets/icons/ui/design/requested_icons.txt text eol=lf
|
||||
# Shaders reach the code through include_str!, and tests match multi-line WGSL excerpts
|
||||
# written as Rust strings (always LF), so a CRLF checkout must not change them (#13138).
|
||||
*.wgsl text eol=lf
|
||||
# SolidCraft designs (.sc): a JSON document or a ZIP container, never Scala source, so they
|
||||
# stay out of GitHub's language statistics. Git's own text/binary detection handles both kinds.
|
||||
*.sc -linguist-detectable
|
||||
|
||||
@@ -725,10 +725,16 @@ jobs:
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
sha256sum --check SHA256SUMS.txt
|
||||
# Generated notes start at the newest published release, pre-releases included. Left
|
||||
# to GitHub they start at the Latest stable one, and the list outgrows the 125,000
|
||||
# characters a release body may have.
|
||||
start=()
|
||||
prev="$(gh release list --exclude-drafts --limit 30 --json tagName,publishedAt --jq 'sort_by(.publishedAt) | last | .tagName // empty')"
|
||||
if [ -n "$prev" ]; then start=(--notes-start-tag "$prev"); fi
|
||||
# Stage all packages before publishing. Published tags are never replaced.
|
||||
gh release create "$tag" --draft --prerelease --latest=false \
|
||||
--title "SolidCraft v$VERSION (nightly)" --target "$GITHUB_SHA" \
|
||||
--notes-file ../notes.md --generate-notes -- *
|
||||
--notes-file ../notes.md --generate-notes "${start[@]}" -- *
|
||||
gh release edit "$tag" --draft=false --prerelease --latest=false
|
||||
echo "### Published nightly: $tag (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
- name: Create or update the draft release
|
||||
@@ -760,8 +766,13 @@ jobs:
|
||||
--target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
# As for nightlies: start the generated notes at the newest published release.
|
||||
start=()
|
||||
prev="$(gh release list --exclude-drafts --limit 30 --json tagName,publishedAt --jq 'sort_by(.publishedAt) | last | .tagName // empty')"
|
||||
if [ -n "$prev" ]; then start=(--notes-start-tag "$prev"); fi
|
||||
gh release create "$tag" --draft --title "SolidCraft v$VERSION" \
|
||||
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
|
||||
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${start[@]}" \
|
||||
"${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft **SolidCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
|
||||
|
||||
+2
-1
@@ -201,7 +201,6 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/platforms/windows/solidcraft.en-us.wxl | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
|
||||
| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-dark.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/gruvbox-light.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/themes/solidcraft-95.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
|
||||
@@ -842,6 +841,8 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
|
||||
| crates/sketch/assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
|
||||
| crates/sketch/assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/locales/pending/drag-to-zoom.json | SolidCraft contributors | Original drag-to-zoom labels and translations | MIT OR Apache-2.0 |
|
||||
| `assets/locales/pending/hole-group-folder.json` | SolidCraft contributors | Original Hole grouping preference labels and translations | MIT OR Apache-2.0 |
|
||||
| assets/locales/pending/press-pull-dialog-title.json | SolidCraft contributors | Original Press Pull dialog title translations for SolidCraft's English catalog; no external text used | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
|
||||
Generated
+102
-27
@@ -4051,15 +4051,16 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "solidcore"
|
||||
version = "1.0.1"
|
||||
source = "git+https://github.com/tangency-labs/solidcore?rev=8ddce901671f1cebd9214a31d21895d725e886c8#8ddce901671f1cebd9214a31d21895d725e886c8"
|
||||
source = "git+https://github.com/tangency-labs/solidcore?rev=a445bdaac60a980b0e9d1dba3ee28eb7ada44f50#a445bdaac60a980b0e9d1dba3ee28eb7ada44f50"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcore-record"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/tangency-labs/solidcore?rev=f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f#f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f"
|
||||
source = "git+https://github.com/tangency-labs/solidcore?rev=a445bdaac60a980b0e9d1dba3ee28eb7ada44f50#a445bdaac60a980b0e9d1dba3ee28eb7ada44f50"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4068,13 +4069,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
"egui",
|
||||
"embed-resource",
|
||||
"getrandom 0.3.4",
|
||||
"hidapi",
|
||||
"keyring",
|
||||
"libc",
|
||||
@@ -4092,10 +4092,13 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-3dx-client",
|
||||
"solidcraft-control",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
"solidcraft-space-mouse",
|
||||
"solidcraft-theme",
|
||||
"solidcraft-ui-egui",
|
||||
"solidcraft-ui-text",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
@@ -4106,11 +4109,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4122,9 +4125,32 @@ dependencies = [
|
||||
"solidcraft-mcp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-color"
|
||||
version = "0.40.0-rc.3"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-control"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-control-egui"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"image",
|
||||
"serde_json",
|
||||
"solidcraft-control",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4133,13 +4159,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-derive",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
@@ -4152,7 +4179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-dynamics"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4163,7 +4190,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4172,6 +4199,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-control",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-io",
|
||||
@@ -4186,7 +4215,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4196,7 +4225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4206,6 +4235,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-io",
|
||||
@@ -4217,7 +4247,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4233,6 +4263,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
@@ -4244,7 +4275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4263,17 +4294,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
"solidcraft-control",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
"solidcraft-mcp-server",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp-server"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-profile"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"web-time",
|
||||
@@ -4281,7 +4321,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4292,6 +4332,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-geom",
|
||||
"web-time",
|
||||
"weezl",
|
||||
@@ -4299,7 +4340,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"serde",
|
||||
@@ -4313,7 +4354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-smlib"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"log",
|
||||
@@ -4326,14 +4367,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-step"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"solidcore-record",
|
||||
"solidcraft-geom",
|
||||
@@ -4342,11 +4383,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-testkit"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-theme"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-color",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4354,6 +4405,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"muda",
|
||||
"nix 0.31.3",
|
||||
"pollster 0.4.0",
|
||||
"proc-macro2",
|
||||
@@ -4362,23 +4414,44 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-color",
|
||||
"solidcraft-control",
|
||||
"solidcraft-control-egui",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
"solidcraft-space-mouse",
|
||||
"solidcraft-theme",
|
||||
"solidcraft-ui-registry",
|
||||
"solidcraft-ui-text",
|
||||
"syn 2.0.119",
|
||||
"sys-locale",
|
||||
"tiny-skia",
|
||||
"toml",
|
||||
"ttf-parser",
|
||||
"unicode-bidi",
|
||||
"web-time",
|
||||
"winreg",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-registry"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-text"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"unicode-bidi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4386,8 +4459,10 @@ dependencies = [
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"serde_json",
|
||||
"solidcraft-control",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-ui-egui",
|
||||
"solidcraft-ui-text",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
@@ -5793,7 +5868,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proc-macro2",
|
||||
|
||||
+10
-3
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.40.0-rc.2"
|
||||
version = "0.40.0-rc.3"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -28,18 +28,25 @@ solidcraft-doc = { path = "crates/doc" }
|
||||
solidcraft-io = { path = "crates/io" }
|
||||
solidcraft-engine = { path = "crates/engine" }
|
||||
solidcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
solidcraft-ui-registry = { path = "crates/ui-registry" }
|
||||
solidcraft-color = { path = "crates/color" }
|
||||
solidcraft-ui-text = { path = "crates/ui-text" }
|
||||
solidcraft-theme = { path = "crates/theme" }
|
||||
solidcraft-space-mouse = { path = "crates/space-mouse" }
|
||||
solidcraft-mcp = { path = "crates/mcp" }
|
||||
solidcraft-mcp-server = { path = "crates/mcp-server" }
|
||||
solidcraft-control = { path = "crates/control" }
|
||||
solidcraft-control-egui = { path = "crates/control-egui" }
|
||||
solidcraft-derive = { path = "crates/derive" }
|
||||
solidcraft-3dx-client = { path = "crates/threedx-client" }
|
||||
# The Rust port of NVIDIA's SMLib (Apache-2.0), the B-rep kernel behind crates/smlib. Pinned;
|
||||
# move the rev on its own commit. For a
|
||||
# local checkout, add `[patch."https://github.com/tangency-labs/solidcore"] solidcore = { path =
|
||||
# "../solidcore/crates/solidcore" }` to a `.cargo/config.toml` outside the repository.
|
||||
solidcore = { git = "https://github.com/tangency-labs/solidcore", rev = "8ddce901671f1cebd9214a31d21895d725e886c8" }
|
||||
solidcore = { git = "https://github.com/tangency-labs/solidcore", rev = "a445bdaac60a980b0e9d1dba3ee28eb7ada44f50" }
|
||||
# Geometry as data (solidcore's layers §1, "Record"): the B-rep record, its structural validation
|
||||
# and the native body file. Pinned like solidcore; for a local checkout, patch it as above.
|
||||
solidcore-record = { git = "https://github.com/tangency-labs/solidcore", rev = "f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f" }
|
||||
solidcore-record = { git = "https://github.com/tangency-labs/solidcore", rev = "a445bdaac60a980b0e9d1dba3ee28eb7ada44f50" }
|
||||
# test tooling: a dev-dependency only (xtask/src/layers.rs)
|
||||
solidcraft-testkit = { path = "crates/testkit" }
|
||||
|
||||
|
||||
@@ -68,7 +68,8 @@ impl Control {
|
||||
if !addr.ip().is_loopback() || addr.port() == 0 {
|
||||
return Err("--connect requires a loopback address and a nonzero port (the app listens on 127.0.0.1)".into());
|
||||
}
|
||||
let token = solidcraft_engine::control_auth::client_token(addr.port(), token)
|
||||
let token = solidcraft_engine::control_auth::APP
|
||||
.client_token(addr.port(), token)
|
||||
.map(|t| t.token)
|
||||
.ok_or("no control token: pass --token, set SOLIDCRAFT_CONTROL_TOKEN, or run on the app's computer (its token file)")?;
|
||||
if token.len() > 4096 {
|
||||
|
||||
@@ -916,7 +916,7 @@ fn serve_mcp(
|
||||
/// The port of the running desktop app: the one its discovery file names, if something listens
|
||||
/// there (a file left by an app that crashed names a closed port).
|
||||
fn discover_app() -> Option<u16> {
|
||||
let port = solidcraft_engine::control_auth::read_current_port()?;
|
||||
let port = solidcraft_engine::control_auth::APP.read_current_port()?;
|
||||
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
|
||||
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).ok().map(|_| port)
|
||||
}
|
||||
|
||||
@@ -1308,7 +1308,7 @@ fn edge_round_actions_keep_the_picked_body() {
|
||||
let mut unscoped = p.clone();
|
||||
unscoped.as_object_mut().unwrap().remove("body");
|
||||
let err = s.scratch().execute(id, &unscoped).unwrap_err().to_string();
|
||||
assert!(err.contains("edges meet"), "{err}");
|
||||
assert!(err.contains("[10, 0, 20] lies on edges of 2 bodies, Plate and Block"), "{err}");
|
||||
perform(&mut s.scratch(), &a.act, &errors(&s)).unwrap();
|
||||
tried += 1;
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ fn a_model_saved_under_another_check_mode_is_evaluated_again() {
|
||||
/// opened from its model or evaluated anew. Two edges of a 20 mm box rounded r2 (a mitre at the
|
||||
/// corner (20, 0, 20)), saved; opened, the upright edge there rounded r3: the corner all three
|
||||
/// rounded at once make (SMLib's setback corner, kernel `three_rounds_at_a_corner`), 15 faces and
|
||||
/// 7928.142563938 mm³, the same body as the design evaluated anew.
|
||||
/// 7927.72428251348 mm³, the same body as the design evaluated anew.
|
||||
#[test]
|
||||
fn a_later_round_after_opening_blends_the_saved_rounds_again() {
|
||||
let mut built = Session::default();
|
||||
@@ -336,7 +336,7 @@ fn a_later_round_after_opening_blends_the_saved_rounds_again() {
|
||||
let b = &st.body("Box").unwrap().body;
|
||||
let v = solidcraft_engine::kernel::measure(b).unwrap().volume;
|
||||
assert_eq!(b.face_count(), 15);
|
||||
assert!((v - 7928.142563938).abs() < 1e-6, "{v}");
|
||||
assert!((v - 7927.72428251348).abs() < 1e-6, "{v}"); // solidcore#2382's setback corner
|
||||
}
|
||||
assert_eq!(model("later round", &cached), model("later round", &rebuilt));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
@@ -22,7 +22,9 @@ profile = ["solidcraft-engine/profile"]
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-control = { workspace = true }
|
||||
solidcraft-ui-egui = { workspace = true }
|
||||
solidcraft-ui-text = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_ui_egui::ControlRequest;
|
||||
use solidcraft_control::ControlRequest;
|
||||
use wasm_bindgen::{JsValue, closure::Closure};
|
||||
|
||||
const MAX_PENDING: usize = 256;
|
||||
|
||||
@@ -173,6 +173,35 @@ pub fn is_open(open: Option<&str>, wanted: &str) -> bool {
|
||||
open == Some(wanted)
|
||||
}
|
||||
|
||||
/// What follows once a design restored from a kept copy, or recovered, is open: it opens in
|
||||
/// stages, a few features a frame with progress and Cancel (#10198), so this waits for the answer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AfterOpen {
|
||||
/// A kept copy (#10220): it is its own design again (saving goes to `path`, it is named
|
||||
/// `name`), with its changes unsaved; then the copy `id` goes when `drop` says so.
|
||||
KeptCopy { opened: String, id: String, path: Option<String>, name: String, drop: bool },
|
||||
/// The saved design offered beside a kept copy: the copy `id` goes when `drop` says so.
|
||||
KeptSaved { opened: String, id: String, drop: bool },
|
||||
/// Unsaved work from the page's last visit: it belongs to `path` again, named `name`, unsaved.
|
||||
Recovery { opened: String, path: Option<String>, name: String },
|
||||
}
|
||||
|
||||
impl AfterOpen {
|
||||
/// The file the open reads.
|
||||
pub fn opened(&self) -> &str {
|
||||
match self {
|
||||
AfterOpen::KeptCopy { opened, .. } | AfterOpen::KeptSaved { opened, .. } | AfterOpen::Recovery { opened, .. } => opened,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the open of `wanted` answered with `reply` put it in: the answer says so, and it is
|
||||
/// the design now open (`open`: the open design's path). A refusal, a Cancel or another file
|
||||
/// leaves the copy or the recovered work offered.
|
||||
pub fn opened(reply: &Value, open: Option<&str>, wanted: &str) -> bool {
|
||||
reply["ok"].as_bool() == Some(true) && is_open(open, wanted)
|
||||
}
|
||||
|
||||
/// Whether leaving the page asks through the browser's "leave site?" box: there is unsaved work,
|
||||
/// and either the user still wants to be asked or it isn't all kept in the browser yet (with the
|
||||
/// keep preference off it never is, so there the box always stays).
|
||||
@@ -224,6 +253,20 @@ pub fn answers(entry: &Entry, saved_exists: bool) -> Vec<Answer> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A restored or recovered design opens in stages (#10198): only an answer that it is in, for
|
||||
/// the file asked for, counts; a refusal, a Cancel or another design open leaves it offered.
|
||||
#[test]
|
||||
fn a_staged_restore_counts_only_when_its_file_is_in() {
|
||||
let copy = "/opfs/.kept/a.sc";
|
||||
assert!(opened(&json!({"ok": true}), Some(copy), copy));
|
||||
assert!(!opened(&json!({"ok": false, "error": "cancelled"}), Some(copy), copy));
|
||||
assert!(!opened(&json!({"ok": false, "error": "not a design"}), None, copy));
|
||||
assert!(!opened(&json!({"ok": true}), Some("/opfs/other.sc"), copy));
|
||||
assert!(!opened(&json!({}), Some(copy), copy));
|
||||
let after = AfterOpen::KeptCopy { opened: copy.into(), id: "a".into(), path: None, name: "Bracket".into(), drop: true };
|
||||
assert_eq!(after.opened(), copy);
|
||||
}
|
||||
|
||||
fn entry(id: &str, changed_at: f64, path: Option<&str>) -> Entry {
|
||||
Entry { id: id.into(), name: "Bracket".into(), path: path.map(str::to_string), changed_at }
|
||||
}
|
||||
|
||||
@@ -48,15 +48,15 @@ impl Download {
|
||||
}
|
||||
if solidcraft_ui_egui::documents::any_dirty(app) {
|
||||
let mut answer = None;
|
||||
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_egui::i18n::tr("web.open_link"))
|
||||
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_text::tr("web.open_link"))
|
||||
.required()
|
||||
.show_body(ctx, |ui| {
|
||||
ui.label(solidcraft_ui_egui::i18n::tr("web.open_link_confirm"));
|
||||
ui.label(solidcraft_ui_text::tr("web.open_link_confirm"));
|
||||
ui.horizontal(|ui| {
|
||||
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.open"), ui_kit::Button::Normal).clicked() {
|
||||
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.open"), ui_kit::Button::Normal).clicked() {
|
||||
answer = Some(true);
|
||||
}
|
||||
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
|
||||
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
|
||||
answer = Some(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -360,7 +360,7 @@ mod web {
|
||||
/// The start page's recent designs were filled from the stored ones.
|
||||
recents_synced: bool,
|
||||
/// On-demand script fonts: fetched only when a CJK language is selected.
|
||||
font: solidcraft_ui_egui::fonts::CjkFontLoader,
|
||||
font: solidcraft_ui_egui::ui_kit::type_face::CjkFontLoader,
|
||||
font_result: FontLoadResult,
|
||||
/// The fetched Japanese font's bytes: kept so a language change can re-order the fallbacks.
|
||||
cjk_bytes: Option<Vec<u8>>,
|
||||
@@ -382,6 +382,9 @@ mod web {
|
||||
keep_track: std::collections::HashMap<u64, Track>,
|
||||
/// Every unsaved design's kept copy is current (and the preference is on).
|
||||
all_kept: bool,
|
||||
/// A kept copy or recovered work being opened, in stages with progress and Cancel
|
||||
/// (#10198): the answer to come and what follows it ([`Shell::restored`]).
|
||||
restoring: Option<(std::sync::mpsc::Receiver<serde_json::Value>, keep::AfterOpen)>,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
@@ -633,7 +636,7 @@ mod web {
|
||||
/// (when it has a file) and Discard.
|
||||
fn keep_prompt(&mut self, ctx: &egui::Context) {
|
||||
let offered = self.files.keep_offered.borrow().clone();
|
||||
if offered.is_empty() || !self.files.ready.get() {
|
||||
if offered.is_empty() || !self.files.ready.get() || self.restoring.is_some() {
|
||||
return;
|
||||
}
|
||||
let now = (js_sys::Date::now() / 1000.0) as u64;
|
||||
@@ -673,24 +676,21 @@ mod web {
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.app.open_path(©);
|
||||
if self.app.session.path.as_deref() != Some(copy.as_str()) {
|
||||
// Not opened (the status says why): it stays offered.
|
||||
return;
|
||||
}
|
||||
// It is its own design again (saving goes to its file), with its changes unsaved.
|
||||
self.app.session.path = entry.path.clone();
|
||||
self.app.session.doc_mut().name = entry.name.clone();
|
||||
self.app.session.mark_unsaved();
|
||||
self.app.home.open = false;
|
||||
// Finished in `restored` once it is in.
|
||||
let after = keep::AfterOpen::KeptCopy {
|
||||
opened: copy.clone(),
|
||||
id: entry.id.clone(),
|
||||
path: entry.path.clone(),
|
||||
name: entry.name.clone(),
|
||||
drop: plan.drop_copy,
|
||||
};
|
||||
self.restore(©, after);
|
||||
return;
|
||||
}
|
||||
if let Some(path) = &plan.open_saved {
|
||||
if vfs::exists(path) {
|
||||
self.app.open_path(path);
|
||||
if !keep::is_open(self.app.session.path.as_deref(), path) {
|
||||
// Not opened (the status says why): the copy stays offered.
|
||||
return;
|
||||
}
|
||||
self.restore(path, keep::AfterOpen::KeptSaved { opened: path.clone(), id: entry.id.clone(), drop: plan.drop_copy });
|
||||
return;
|
||||
} else {
|
||||
// A file on the computer: the browser asks for access again, in this click.
|
||||
// The copy goes once the file has opened, not before: if access is refused,
|
||||
@@ -708,8 +708,62 @@ mod web {
|
||||
}
|
||||
}
|
||||
if plan.drop_copy {
|
||||
self.files.keep_offered.borrow_mut().retain(|e| e.id != entry.id);
|
||||
files::remove_kept(&self.files, &entry.id);
|
||||
self.drop_kept(&entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
fn drop_kept(&self, id: &str) {
|
||||
self.files.keep_offered.borrow_mut().retain(|e| e.id != id);
|
||||
files::remove_kept(&self.files, id);
|
||||
}
|
||||
|
||||
/// Open a kept copy, its saved design or recovered work as any design opens, a few
|
||||
/// features a frame with progress and Cancel (#10198), so a large design doesn't freeze the
|
||||
/// page; `after` follows once it is in ([`Self::restored`]).
|
||||
fn restore(&mut self, path: &str, after: keep::AfterOpen) {
|
||||
self.restoring = Some((self.app.open_path_answered(path), after));
|
||||
self.restored();
|
||||
}
|
||||
|
||||
/// What follows a restore that is in: the design gets back its file and name, and the
|
||||
/// copy or the recovered work it came from goes. One that didn't open (refused or
|
||||
/// cancelled; the status says why) stays offered.
|
||||
fn restored(&mut self) {
|
||||
let Some((rx, _)) = &self.restoring else { return };
|
||||
let reply = match rx.try_recv() {
|
||||
Ok(reply) => reply,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => serde_json::json!({"ok": false}),
|
||||
};
|
||||
let Some((_, after)) = self.restoring.take() else { return };
|
||||
if !keep::opened(&reply, self.app.session.path.as_deref(), after.opened()) {
|
||||
return;
|
||||
}
|
||||
match after {
|
||||
keep::AfterOpen::KeptCopy { id, path, name, drop, .. } => {
|
||||
// It is its own design again (saving goes to its file), with its changes unsaved.
|
||||
self.app.session.path = path;
|
||||
self.app.session.doc_mut().name = name;
|
||||
self.app.session.mark_unsaved();
|
||||
self.app.home.open = false;
|
||||
if drop {
|
||||
self.drop_kept(&id);
|
||||
}
|
||||
}
|
||||
keep::AfterOpen::KeptSaved { id, drop, .. } => {
|
||||
if drop {
|
||||
self.drop_kept(&id);
|
||||
}
|
||||
}
|
||||
keep::AfterOpen::Recovery { path, name, .. } => {
|
||||
// It belongs to its own file again (saving goes there); it is unsaved.
|
||||
self.app.session.path = path;
|
||||
self.app.session.doc_mut().name = name;
|
||||
self.app.session.mark_unsaved();
|
||||
self.app.home.open = false;
|
||||
*self.files.recovery.borrow_mut() = None;
|
||||
files::autosave(&self.files, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,7 +810,7 @@ mod web {
|
||||
files::pick_from_disk(&self.files, Intent::Open);
|
||||
}
|
||||
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
|
||||
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
|
||||
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_drawings_hint"))
|
||||
.clicked()
|
||||
{
|
||||
files::pick_from_disk(&self.files, Intent::Insert);
|
||||
@@ -810,7 +864,7 @@ mod web {
|
||||
let design = solidcraft_engine::io::is_design_path(&e.name);
|
||||
if !design
|
||||
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
|
||||
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
|
||||
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_stored_drawings_hint"))
|
||||
.clicked()
|
||||
{
|
||||
act = Some(("insert", e.name.clone()));
|
||||
@@ -917,6 +971,9 @@ mod web {
|
||||
|
||||
/// Offer the autosave an earlier session left.
|
||||
fn recovery_prompt(&mut self, ctx: &egui::Context) {
|
||||
if self.restoring.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some((name, path)) = self.files.recovery.borrow().clone() else { return };
|
||||
let mut answer = None;
|
||||
let _ = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::unique("sc_browser_recovery"), "restore", "Recover unsaved work?")
|
||||
@@ -941,15 +998,10 @@ mod web {
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.app.open_path(&recovery);
|
||||
if self.app.session.path.as_deref() != Some(recovery.as_str()) {
|
||||
return;
|
||||
}
|
||||
// It belongs to its own file again (saving goes there); it is unsaved.
|
||||
self.app.session.path = path;
|
||||
self.app.session.doc_mut().name = name;
|
||||
self.app.session.mark_unsaved();
|
||||
self.app.home.open = false;
|
||||
// Finished in `restored` once it is in; until then the prompt stays away.
|
||||
let after = keep::AfterOpen::Recovery { opened: recovery.clone(), path, name };
|
||||
self.restore(&recovery, after);
|
||||
return;
|
||||
}
|
||||
*self.files.recovery.borrow_mut() = None;
|
||||
files::autosave(&self.files, None);
|
||||
@@ -964,7 +1016,7 @@ mod web {
|
||||
|
||||
/// Whether the shell already has the on-demand face the live language needs.
|
||||
fn script_font_ready(&self) -> bool {
|
||||
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language)) {
|
||||
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_text::effective_language(&self.app.preferences.language)) {
|
||||
Some("zh") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh"),
|
||||
Some("zh-TW") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh-TW"),
|
||||
Some("ja") => self.cjk_bytes.is_some(),
|
||||
@@ -1006,8 +1058,8 @@ mod web {
|
||||
false
|
||||
}
|
||||
};
|
||||
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
|
||||
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(effective) {
|
||||
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
|
||||
if !installed && solidcraft_ui_egui::ui_kit::type_face::needs_cjk_font(effective) {
|
||||
log::warn!("script font unavailable; showing English for this session");
|
||||
}
|
||||
self.font.finished(installed, &mut self.app.preferences.language);
|
||||
@@ -1015,13 +1067,13 @@ mod web {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let ready = self.script_font_ready();
|
||||
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
|
||||
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
|
||||
if self.font.tick(&mut self.app.preferences.language, ready) {
|
||||
self.fetch_font(ctx, solidcraft_ui_egui::fonts::web_script_font_kind(effective).unwrap_or("ja"));
|
||||
}
|
||||
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
|
||||
// to Ukrainian (or English) must put the Latin face back in front.
|
||||
if solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language) != self.font_language {
|
||||
if solidcraft_ui_text::effective_language(&self.app.preferences.language) != self.font_language {
|
||||
self.apply_fonts(ctx);
|
||||
}
|
||||
}
|
||||
@@ -1029,7 +1081,7 @@ mod web {
|
||||
/// Set up the UI fonts for the live language, keeping fetched script fonts as fallbacks
|
||||
/// (Chinese leads a Chinese UI; see [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
|
||||
fn apply_fonts(&mut self, ctx: &egui::Context) {
|
||||
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
|
||||
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
|
||||
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() {
|
||||
solidcraft_ui_egui::fonts::setup_fonts(ctx);
|
||||
} else {
|
||||
@@ -1041,7 +1093,7 @@ mod web {
|
||||
/// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name):
|
||||
/// fetch the Japanese font, so the text does not stay tofu (#720).
|
||||
fn watch_text_for_font(&mut self, ctx: &egui::Context) {
|
||||
if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() {
|
||||
if self.font.watching_text() && solidcraft_ui_egui::ui_kit::type_face::painted_cjk(ctx) && self.font.text_needs_font() {
|
||||
self.fetch_font(ctx, "ja");
|
||||
}
|
||||
}
|
||||
@@ -1093,10 +1145,11 @@ mod web {
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.linked.tick(&mut self.app, ui.ctx());
|
||||
self.restored();
|
||||
UNSAVED.with(|u| u.set(self.asks_before_unload()));
|
||||
if self.launch.embed {
|
||||
ui_kit::Container::new(ui.ctx(), ui_kit::Surface::Chrome).edge("embed_header", ui_kit::Edge::Top).show(ui, |ui| {
|
||||
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("web.open_full"), ui_kit::Button::Normal).clicked()
|
||||
if ui_kit::button(ui, solidcraft_ui_text::tr("web.open_full"), ui_kit::Button::Normal).clicked()
|
||||
&& let (Some(window), Some(url)) = (web_sys::window(), &self.full_url)
|
||||
{
|
||||
let _ = window.open_with_url_and_target_and_features(url, "_blank", "noopener");
|
||||
@@ -1172,6 +1225,7 @@ mod web {
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
solidcraft_ui_egui::i18n::install();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let launch = crate::Launch::parse(&query());
|
||||
@@ -1306,6 +1360,7 @@ mod web {
|
||||
other_tab_prefs,
|
||||
keep_track: Default::default(),
|
||||
all_kept: false,
|
||||
restoring: None,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -31,9 +31,12 @@ neg_cmp_op_on_partial_ord = "allow"
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-control = { workspace = true }
|
||||
# The D3D12 shader compiler the window starts with (#6756).
|
||||
solidcraft-gpu = { workspace = true }
|
||||
solidcraft-ui-egui = { workspace = true }
|
||||
solidcraft-ui-text = { workspace = true }
|
||||
solidcraft-theme = { workspace = true }
|
||||
solidcraft-space-mouse = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
|
||||
egui = { workspace = true }
|
||||
@@ -45,7 +48,6 @@ rfd = { workspace = true }
|
||||
ron = "0.12"
|
||||
# Waits for wgpu's adapter list when choosing the graphics backend before the window opens.
|
||||
pollster = { workspace = true }
|
||||
getrandom = "0.3"
|
||||
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
|
||||
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,7 @@ mod native_crash;
|
||||
mod native_crash_macos;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod native_frame;
|
||||
#[cfg(target_os = "macos")]
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
mod native_menu;
|
||||
mod nav_bridge;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -468,7 +468,7 @@ fn case_variants(exts: &[&str]) -> Vec<String> {
|
||||
fn content_ui(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let content = ui.ctx().content_rect();
|
||||
let above = egui::Rect::from_min_max(ui.max_rect().min, egui::pos2(ui.max_rect().right(), content.top()));
|
||||
ui.painter().rect_filled(above, 0.0, solidcraft_ui_egui::theme::Tokens::get().app_bar);
|
||||
ui.painter().rect_filled(above, 0.0, solidcraft_theme::Tokens::get().app_bar);
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(content), |ui| app.ui(ui));
|
||||
}
|
||||
|
||||
@@ -518,7 +518,7 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services
|
||||
// The groups come from the format registry, so a new format joins this dialog by itself.
|
||||
let mut everything = io::formats::open_extensions(true);
|
||||
everything.extend(["dxf", "svg"]);
|
||||
dialog = dialog.add_filter(solidcraft_ui_egui::i18n::tr("file.open.drawing_filter"), &case_variants(&everything));
|
||||
dialog = dialog.add_filter(solidcraft_ui_text::tr("file.open.drawing_filter"), &case_variants(&everything));
|
||||
for (label, exts) in io::formats::import_filters(true) {
|
||||
dialog = dialog.add_filter(label, &case_variants(&exts));
|
||||
}
|
||||
@@ -913,17 +913,7 @@ fn app_viewport(custom_titlebar: bool) -> Result<egui::ViewportBuilder, eframe::
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let icon =
|
||||
egui::IconData { rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?, width: 64, height: 64 };
|
||||
Ok(egui::ViewportBuilder::default()
|
||||
.with_title("SolidCraft")
|
||||
.with_icon(icon)
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!custom_titlebar)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID))
|
||||
Ok(solidcraft_ui_egui::window_chrome::viewport("SolidCraft", APP_ID, custom_titlebar).with_icon(icon))
|
||||
}
|
||||
|
||||
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
|
||||
@@ -945,6 +935,7 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
|
||||
mark("main");
|
||||
solidcraft_ui_egui::i18n::install();
|
||||
// On OCLP-patched Macs, wgpu picks a Metal Shading Language standard that the system's
|
||||
// compiler refuses (`invalid value 'metal3.2'`), because the patched OS reports a newer
|
||||
// macOS version than the GPU's Metal compiler supports. Re-launching with
|
||||
@@ -1221,7 +1212,7 @@ fn main() -> eframe::Result {
|
||||
mark("viewport pipelines");
|
||||
// `--control`/the environment name a port; otherwise the preference (on by default)
|
||||
// opens the default one (docs/control-protocol.md).
|
||||
if let Some(listen_on) = solidcraft_engine::control_auth::plan(control_port, app.preferences.agent_control) {
|
||||
if let Some(listen_on) = solidcraft_control::auth::plan(control_port, app.preferences.agent_control) {
|
||||
let rx = control_server::start(listen_on, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
|
||||
+347
-162
@@ -1,137 +1,221 @@
|
||||
//! macOS system menus, owned and updated on eframe's main thread.
|
||||
//! SolidCraft's macOS menu bar: its entries as kit menu items
|
||||
//! (`solidcraft_ui_egui::window_chrome::native_menu`), each with a command id or a `ui.*` id, run
|
||||
//! through the same actions as the application bar's File menu (`toolbar::FileAction`).
|
||||
//!
|
||||
//! Entries that are control actions use their ids and do the same there: `app.quit`,
|
||||
//! `ui.prefs {open}` (Settings and Keyboard Shortcuts), `ui.agent_chat {toggle}`, `ui.help {item}`. Undo and Redo are the commands
|
||||
//! `edit.undo` and `edit.redo`. The other entries' ids are defined here: `ui.file.new`,
|
||||
//! `ui.file.open`, `ui.file.open_recent {path}`, `ui.file.clear_recent`, `ui.file.save`,
|
||||
//! `ui.file.save_as`, `ui.file.save_json`, `ui.file.export {format}`,
|
||||
//! `ui.file.export_mesh {format}`. Menus are `menu.file`, `menu.file.recent`, `menu.edit`,
|
||||
//! `menu.window` and `menu.help`.
|
||||
//!
|
||||
//! The menu bar itself is macOS's; the entries and their ids build everywhere, so their tests run
|
||||
//! on every system.
|
||||
|
||||
use std::sync::mpsc::{Receiver, sync_channel};
|
||||
|
||||
use muda::{
|
||||
AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
|
||||
accelerator::{Accelerator, Code, Modifiers},
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_ui_egui::{
|
||||
SolveApp,
|
||||
toolbar::FileAction,
|
||||
ui_kit::Item,
|
||||
window_chrome::native_menu::{self, MenuBar},
|
||||
};
|
||||
use solidcraft_ui_egui::{SolveApp, toolbar::FileAction};
|
||||
|
||||
/// The Open Recent submenu, refilled when the recent designs change.
|
||||
const RECENT: &str = "menu.file.recent";
|
||||
const OPEN_RECENT: &str = "ui.file.open_recent";
|
||||
/// Recent designs listed.
|
||||
#[cfg(target_os = "macos")]
|
||||
const RECENT_COUNT: usize = 32;
|
||||
|
||||
/// File > Insert entries.
|
||||
const INSERTS: [(&str, FileAction); 3] = [
|
||||
("Insert STEP\u{2026}", FileAction::Command("file.insert_step")),
|
||||
("Insert Mesh\u{2026}", FileAction::Command("file.insert_mesh")),
|
||||
("Insert Design", FileAction::Command("component.insert_design")),
|
||||
];
|
||||
|
||||
/// File > Export entries.
|
||||
const EXPORTS: [(&str, FileAction); 7] = [
|
||||
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
|
||||
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
|
||||
("Export STEP\u{2026}", FileAction::Export("step")),
|
||||
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
|
||||
("Export OBJ\u{2026}", FileAction::Export("obj")),
|
||||
("Export URDF\u{2026}", FileAction::Export("urdf")),
|
||||
("Export Design as JSON\u{2026}", FileAction::Export("json")),
|
||||
];
|
||||
|
||||
const DESIGN_PROPERTIES: FileAction = FileAction::Command("document.metadata");
|
||||
|
||||
/// The other actions with a fixed id (all but Open Recent), to map an id back to its action.
|
||||
const FIXED: &[FileAction] = &[
|
||||
FileAction::Settings,
|
||||
FileAction::Shortcuts,
|
||||
FileAction::Quit,
|
||||
FileAction::New,
|
||||
FileAction::Open,
|
||||
FileAction::ClearRecent,
|
||||
FileAction::Save,
|
||||
FileAction::SaveAs,
|
||||
FileAction::SaveJson,
|
||||
DESIGN_PROPERTIES,
|
||||
FileAction::Undo,
|
||||
FileAction::Redo,
|
||||
FileAction::AgentChat,
|
||||
];
|
||||
|
||||
/// The Help menu's entries: the control channel's `ui.help {item}`.
|
||||
const HELP_ID: &str = "ui.help";
|
||||
/// The Help menu's items (`help::run`), top to bottom.
|
||||
const HELP: [&str; 7] = ["docs", "updates", "shortcuts", "messages", "report", "crashes", "install_cli"];
|
||||
|
||||
fn help_label<'a>(app: &'a SolveApp, item: &str) -> &'a str {
|
||||
match item {
|
||||
"docs" => app.tr_label("Documentation"),
|
||||
"updates" => app.tr("update.check_for_updates"),
|
||||
"shortcuts" => app.tr_label("Keyboard Shortcuts"),
|
||||
"messages" => app.tr_label("Messages\u{2026}"),
|
||||
"report" => app.tr_label("Report an Issue\u{2026}"),
|
||||
"crashes" => app.tr_label("Crash Reports\u{2026}"),
|
||||
_ => app.tr_label("Install solidcraft-cli command"),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a menu entry runs.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Entry {
|
||||
File(FileAction),
|
||||
Help(&'static str),
|
||||
}
|
||||
|
||||
/// The entry's id and parameters for `action`.
|
||||
fn id_of(action: &FileAction) -> (String, Option<Value>) {
|
||||
let fixed = |id: &str| (id.to_string(), None);
|
||||
match action {
|
||||
FileAction::Settings => ("ui.prefs".into(), Some(json!({ "open": "General" }))),
|
||||
FileAction::Shortcuts => ("ui.prefs".into(), Some(json!({ "open": "Shortcuts" }))),
|
||||
FileAction::Quit => fixed("app.quit"),
|
||||
FileAction::New => fixed("ui.file.new"),
|
||||
FileAction::Open => fixed("ui.file.open"),
|
||||
FileAction::OpenRecent(path) => (OPEN_RECENT.into(), Some(json!({ "path": path }))),
|
||||
FileAction::ClearRecent => fixed("ui.file.clear_recent"),
|
||||
FileAction::Save => fixed("ui.file.save"),
|
||||
FileAction::SaveAs => fixed("ui.file.save_as"),
|
||||
FileAction::SaveJson => fixed("ui.file.save_json"),
|
||||
FileAction::Export(format) => ("ui.file.export".into(), Some(json!({ "format": format }))),
|
||||
FileAction::ExportMesh(format) => ("ui.file.export_mesh".into(), Some(json!({ "format": format }))),
|
||||
FileAction::Command(command) => fixed(command),
|
||||
FileAction::Undo => fixed("edit.undo"),
|
||||
FileAction::Redo => fixed("edit.redo"),
|
||||
FileAction::AgentChat => ("ui.agent_chat".into(), Some(json!({ "toggle": true }))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The entry with `id` and `params`, `None` for one this menu doesn't have.
|
||||
fn entry_of(id: &str, params: Option<&Value>) -> Option<Entry> {
|
||||
if id == OPEN_RECENT {
|
||||
return params.and_then(|p| p.get("path")).and_then(Value::as_str).map(|path| Entry::File(FileAction::OpenRecent(path.to_string())));
|
||||
}
|
||||
if id == HELP_ID {
|
||||
let item = params.and_then(|p| p.get("item")).and_then(Value::as_str);
|
||||
return HELP.into_iter().find(|h| Some(*h) == item).map(Entry::Help);
|
||||
}
|
||||
let wanted = (id.to_string(), params.cloned());
|
||||
FIXED.iter().cloned().chain(INSERTS.map(|(_, a)| a)).chain(EXPORTS.map(|(_, a)| a)).find(|a| id_of(a) == wanted).map(Entry::File)
|
||||
}
|
||||
|
||||
/// The entries with a key, as menu accelerators.
|
||||
const SHORTCUTS: [(FileAction, &str); 8] = [
|
||||
(FileAction::Settings, "Cmd+,"),
|
||||
(FileAction::Quit, "Cmd+Q"),
|
||||
(FileAction::New, "Cmd+N"),
|
||||
(FileAction::Open, "Cmd+O"),
|
||||
(FileAction::Save, "Cmd+S"),
|
||||
(FileAction::SaveAs, "Cmd+Shift+S"),
|
||||
(FileAction::Undo, "Cmd+Z"),
|
||||
(FileAction::Redo, "Cmd+Shift+Z"),
|
||||
];
|
||||
|
||||
fn item(label: &str, action: &FileAction) -> Item {
|
||||
let (id, params) = id_of(action);
|
||||
let shortcut = SHORTCUTS.iter().find(|(a, _)| a == action).map_or("", |(_, k)| *k);
|
||||
let mut item = Item::action(&id, label, "").key(shortcut);
|
||||
item.params = params;
|
||||
item
|
||||
}
|
||||
|
||||
fn submenu(id: &str, label: &str, children: Vec<Item>) -> Item {
|
||||
let mut menu = Item::action(id, label, "");
|
||||
menu.children = children;
|
||||
menu
|
||||
}
|
||||
|
||||
fn recent_items(app: &SolveApp, paths: &[String]) -> Vec<Item> {
|
||||
let mut items: Vec<Item> = paths.iter().map(|path| item(path, &FileAction::OpenRecent(path.clone()))).collect();
|
||||
items.push(Item::sep());
|
||||
items.push(item(app.tr("ui.clear_recent"), &FileAction::ClearRecent));
|
||||
items
|
||||
}
|
||||
|
||||
fn menu_bar(app: &SolveApp, recent: &[String]) -> MenuBar {
|
||||
let more = |key: &str| format!("{}\u{2026}", app.tr(key));
|
||||
let mut file = vec![
|
||||
item(app.tr("ui.new_design"), &FileAction::New),
|
||||
item(&more("ui.open"), &FileAction::Open),
|
||||
submenu(RECENT, app.tr("ui.open_recent"), recent_items(app, recent)),
|
||||
Item::sep(),
|
||||
];
|
||||
file.extend(INSERTS.iter().map(|(label, action)| item(app.tr_label(label), action)));
|
||||
file.extend([
|
||||
Item::sep(),
|
||||
item(app.tr("ui.save"), &FileAction::Save),
|
||||
item(&more("ui.save_as"), &FileAction::SaveAs),
|
||||
item(app.tr_label("Save As JSON\u{2026}"), &FileAction::SaveJson),
|
||||
Item::sep(),
|
||||
]);
|
||||
file.extend(EXPORTS.iter().map(|(label, action)| item(app.tr_label(label), action)));
|
||||
file.extend([Item::sep(), item(app.tr_label("Design Properties"), &DESIGN_PROPERTIES)]);
|
||||
let edit = vec![item(app.tr("ui.undo"), &FileAction::Undo), item(app.tr("ui.redo"), &FileAction::Redo)];
|
||||
let window = vec![item(app.tr_label("Agent Chat"), &FileAction::AgentChat)];
|
||||
let help = HELP.iter().map(|h| Item::action(HELP_ID, help_label(app, h), "").with(json!({ "item": h }))).collect();
|
||||
MenuBar {
|
||||
title: "SolidCraft".into(),
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
app: vec![item(&more("ui.preferences"), &FileAction::Settings), item(&more("ui.keyboard_shortcuts"), &FileAction::Shortcuts)],
|
||||
quit: item(&format!("{} SolidCraft", app.tr("ui.quit")), &FileAction::Quit),
|
||||
menus: vec![
|
||||
submenu("menu.file", app.tr("ui.file"), file),
|
||||
submenu("menu.edit", app.tr_label("Edit"), edit),
|
||||
submenu(native_menu::WINDOW, "Window", window),
|
||||
submenu("menu.help", app.tr("ui.help"), help),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu bar, owned and updated on eframe's main thread.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub struct NativeMenu {
|
||||
menu: Menu,
|
||||
actions: Vec<(MenuItem, FileAction)>,
|
||||
help_actions: Vec<(MenuItem, &'static str)>,
|
||||
events: Receiver<MenuEvent>,
|
||||
recent: Submenu,
|
||||
menu: native_menu::NativeMenu,
|
||||
recent_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl NativeMenu {
|
||||
pub fn new(app: &SolveApp, ctx: &egui::Context) -> muda::Result<Self> {
|
||||
let menu = Menu::new();
|
||||
let application = Submenu::new("SolidCraft", true);
|
||||
let file = Submenu::new(app.tr("ui.file"), true);
|
||||
let edit = Submenu::new(app.tr_label("Edit"), true);
|
||||
let recent = Submenu::new(app.tr("ui.open_recent"), true);
|
||||
let window = Submenu::new("Window", true);
|
||||
let help = Submenu::new(app.tr("ui.help"), true);
|
||||
let mut help_actions = Vec::new();
|
||||
let mut actions = Vec::new();
|
||||
let mut add = |parent: &Submenu, label: &str, action: FileAction, shortcut: Option<Accelerator>| -> muda::Result<()> {
|
||||
let item = MenuItem::new(label, Self::action_enabled(&action, app, ctx), shortcut);
|
||||
parent.append(&item)?;
|
||||
actions.push((item, action));
|
||||
Ok(())
|
||||
};
|
||||
application.append(&PredefinedMenuItem::about(
|
||||
Some("About SolidCraft"),
|
||||
Some(AboutMetadata { name: Some("SolidCraft".into()), version: Some(env!("CARGO_PKG_VERSION").into()), ..Default::default() }),
|
||||
))?;
|
||||
application.append(&PredefinedMenuItem::separator())?;
|
||||
add(
|
||||
&application,
|
||||
&format!("{}\u{2026}", app.tr("ui.preferences")),
|
||||
FileAction::Settings,
|
||||
Some(Accelerator::new(Modifiers::META, Code::Comma)),
|
||||
)?;
|
||||
add(&application, &format!("{}\u{2026}", app.tr("ui.keyboard_shortcuts")), FileAction::Shortcuts, None)?;
|
||||
application.append(&PredefinedMenuItem::separator())?;
|
||||
application.append(&PredefinedMenuItem::services(None))?;
|
||||
application.append(&PredefinedMenuItem::separator())?;
|
||||
application.append_items(&[
|
||||
&PredefinedMenuItem::hide(Some("Hide SolidCraft")),
|
||||
&PredefinedMenuItem::hide_others(None),
|
||||
&PredefinedMenuItem::show_all(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
])?;
|
||||
add(&application, &format!("{} SolidCraft", app.tr("ui.quit")), FileAction::Quit, Some(Accelerator::new(Modifiers::META, Code::KeyQ)))?;
|
||||
add(&file, app.tr("ui.new_design"), FileAction::New, Some(Accelerator::new(Modifiers::META, Code::KeyN)))?;
|
||||
add(&file, &format!("{}\u{2026}", app.tr("ui.open")), FileAction::Open, Some(Accelerator::new(Modifiers::META, Code::KeyO)))?;
|
||||
file.append(&recent)?;
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
for (label, command) in
|
||||
[("Insert STEP\u{2026}", "file.insert_step"), ("Insert Mesh\u{2026}", "file.insert_mesh"), ("Insert Design", "component.insert_design")]
|
||||
{
|
||||
add(&file, app.tr_label(label), FileAction::Command(command), None)?;
|
||||
}
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
add(&file, app.tr("ui.save"), FileAction::Save, Some(Accelerator::new(Modifiers::META, Code::KeyS)))?;
|
||||
add(
|
||||
&file,
|
||||
&format!("{}\u{2026}", app.tr("ui.save_as")),
|
||||
FileAction::SaveAs,
|
||||
Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyS)),
|
||||
)?;
|
||||
add(&file, app.tr_label("Save As JSON\u{2026}"), FileAction::SaveJson, None)?;
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
for (label, action) in [
|
||||
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
|
||||
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
|
||||
("Export STEP\u{2026}", FileAction::Export("step")),
|
||||
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
|
||||
("Export OBJ\u{2026}", FileAction::Export("obj")),
|
||||
("Export URDF\u{2026}", FileAction::Export("urdf")),
|
||||
("Export Design as JSON\u{2026}", FileAction::Export("json")),
|
||||
] {
|
||||
add(&file, app.tr_label(label), action, None)?;
|
||||
}
|
||||
file.append(&PredefinedMenuItem::separator())?;
|
||||
add(&file, app.tr_label("Design Properties"), FileAction::Command("document.metadata"), None)?;
|
||||
add(&edit, app.tr("ui.undo"), FileAction::Undo, Some(Accelerator::new(Modifiers::META, Code::KeyZ)))?;
|
||||
add(&edit, app.tr("ui.redo"), FileAction::Redo, Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)))?;
|
||||
add(&window, app.tr_label("Agent Chat"), FileAction::AgentChat, None)?;
|
||||
window.append(&PredefinedMenuItem::separator())?;
|
||||
window.append_items(&[
|
||||
&PredefinedMenuItem::minimize(None),
|
||||
&PredefinedMenuItem::maximize(None),
|
||||
&PredefinedMenuItem::fullscreen(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::bring_all_to_front(None),
|
||||
])?;
|
||||
for (action, label) in [
|
||||
("docs", "Documentation"),
|
||||
("updates", app.tr("update.check_for_updates")),
|
||||
("shortcuts", "Keyboard Shortcuts"),
|
||||
("messages", "Messages\u{2026}"),
|
||||
("report", "Report an Issue\u{2026}"),
|
||||
("crashes", "Crash Reports\u{2026}"),
|
||||
("install_cli", "Install solidcraft-cli command"),
|
||||
] {
|
||||
let item = MenuItem::new(app.tr_label(label), true, None);
|
||||
help.append(&item)?;
|
||||
help_actions.push((item, action));
|
||||
}
|
||||
menu.append_items(&[&application, &file, &edit, &window, &help])?;
|
||||
let (sender, events) = sync_channel(64);
|
||||
let ctx = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |event| {
|
||||
let _ = sender.try_send(event);
|
||||
ctx.request_repaint();
|
||||
}));
|
||||
menu.init_for_nsapp();
|
||||
let mut native = Self { menu, actions, help_actions, events, recent, recent_paths: Vec::new() };
|
||||
native.update_recent(app)?;
|
||||
pub fn new(app: &SolveApp, ctx: &egui::Context) -> Result<Self, String> {
|
||||
let recent_paths: Vec<String> = app.home.recent.iter().take(RECENT_COUNT).cloned().collect();
|
||||
let menu = native_menu::NativeMenu::new(&menu_bar(app, &recent_paths), ctx)?;
|
||||
let native = Self { menu, recent_paths };
|
||||
native.update(app, ctx);
|
||||
Ok(native)
|
||||
}
|
||||
|
||||
pub fn raw_input_hook(&mut self, app: &mut SolveApp, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
for event in self.events.try_iter().take(64) {
|
||||
if let Some((_, action)) = self.help_actions.iter().find(|(item, _)| item.id() == &event.id) {
|
||||
solidcraft_ui_egui::help::run(app, ctx, action);
|
||||
}
|
||||
if let Some((_, action)) = self.actions.iter().find(|(item, _)| item.id() == &event.id) {
|
||||
if matches!(action, FileAction::Undo | FileAction::Redo) && ctx.egui_wants_keyboard_input() {
|
||||
for (id, params) in self.menu.events() {
|
||||
match entry_of(&id, params.as_ref()) {
|
||||
Some(Entry::Help(item)) => solidcraft_ui_egui::help::run(app, ctx, item),
|
||||
// A focused text field takes its own Undo and Redo.
|
||||
Some(Entry::File(action @ (FileAction::Undo | FileAction::Redo))) if ctx.egui_wants_keyboard_input() => {
|
||||
raw.events.push(egui::Event::Key {
|
||||
key: egui::Key::Z,
|
||||
physical_key: None,
|
||||
@@ -139,58 +223,159 @@ impl NativeMenu {
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers { command: true, mac_cmd: true, shift: matches!(action, FileAction::Redo), ..Default::default() },
|
||||
});
|
||||
} else {
|
||||
action.run(app);
|
||||
}
|
||||
Some(Entry::File(action)) => action.run(app),
|
||||
None => log::warn!("native menu: no entry `{id}`"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_enabled(action: &FileAction, app: &SolveApp, ctx: &egui::Context) -> bool {
|
||||
action.menu_enabled(app, ctx.egui_wants_keyboard_input())
|
||||
fn update(&self, app: &SolveApp, ctx: &egui::Context) {
|
||||
let focus = ctx.egui_wants_keyboard_input();
|
||||
self.menu.update(|id, params| match entry_of(id, params) {
|
||||
Some(Entry::File(action)) => action.menu_enabled(app, focus),
|
||||
Some(Entry::Help(item)) => solidcraft_ui_egui::help::available(app, item),
|
||||
None => false,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &egui::Context) {
|
||||
for (item, action) in &self.actions {
|
||||
let enabled = Self::action_enabled(action, app, ctx);
|
||||
if item.is_enabled() != enabled {
|
||||
item.set_enabled(enabled);
|
||||
if !self.recent_paths.iter().eq(app.home.recent.iter().take(RECENT_COUNT)) {
|
||||
self.recent_paths = app.home.recent.iter().take(RECENT_COUNT).cloned().collect();
|
||||
if let Err(error) = self.menu.replace(RECENT, &recent_items(app, &self.recent_paths)) {
|
||||
app.set_status(format!("Recent designs menu: {error}"), true);
|
||||
}
|
||||
}
|
||||
for (item, action) in &self.help_actions {
|
||||
let enabled = solidcraft_ui_egui::help::available(app, action);
|
||||
if item.is_enabled() != enabled {
|
||||
item.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
if !self.recent_paths.iter().eq(app.home.recent.iter().take(32))
|
||||
&& let Err(error) = self.update_recent(app)
|
||||
{
|
||||
app.set_status(format!("Recent designs menu: {error}"), true);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_recent(&mut self, app: &SolveApp) -> muda::Result<()> {
|
||||
while self.recent.remove_at(0).is_some() {}
|
||||
self.actions.retain(|(_, action)| !matches!(action, FileAction::OpenRecent(_) | FileAction::ClearRecent));
|
||||
self.recent_paths = app.home.recent.iter().take(32).cloned().collect();
|
||||
for path in &self.recent_paths {
|
||||
let action = FileAction::OpenRecent(path.clone());
|
||||
let item = MenuItem::new(path, action.enabled(app), None);
|
||||
self.recent.append(&item)?;
|
||||
self.actions.push((item, action));
|
||||
}
|
||||
self.recent.append(&PredefinedMenuItem::separator())?;
|
||||
let clear = MenuItem::new(app.tr("ui.clear_recent"), !self.recent_paths.is_empty(), None);
|
||||
self.recent.append(&clear)?;
|
||||
self.actions.push((clear, FileAction::ClearRecent));
|
||||
Ok(())
|
||||
self.update(app, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NativeMenu {
|
||||
fn drop(&mut self) {
|
||||
MenuEvent::set_event_handler(None::<fn(MenuEvent)>);
|
||||
self.menu.remove_for_nsapp();
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every action the menu offers, Open Recent with a path.
|
||||
fn actions() -> Vec<FileAction> {
|
||||
let mut all: Vec<FileAction> = FIXED.iter().cloned().chain(INSERTS.map(|(_, a)| a)).chain(EXPORTS.map(|(_, a)| a)).collect();
|
||||
all.push(FileAction::OpenRecent("/work/a.solidcraft".into()));
|
||||
all
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_action_and_help_item_round_trips_through_its_id() {
|
||||
let mut seen = vec![];
|
||||
for action in actions() {
|
||||
let (id, params) = id_of(&action);
|
||||
assert!(!seen.contains(&(id.clone(), params.clone())), "two actions share `{id}` {params:?}");
|
||||
assert_eq!(entry_of(&id, params.as_ref()), Some(Entry::File(action)), "`{id}` {params:?}");
|
||||
seen.push((id, params));
|
||||
}
|
||||
for h in HELP {
|
||||
assert_eq!(entry_of(HELP_ID, Some(&json!({ "item": h }))), Some(Entry::Help(h)));
|
||||
}
|
||||
// The control channel's ids where it has one.
|
||||
assert_eq!(id_of(&FileAction::Quit), ("app.quit".to_string(), None));
|
||||
assert_eq!(id_of(&FileAction::Settings), ("ui.prefs".to_string(), Some(json!({"open": "General"}))));
|
||||
assert_eq!(id_of(&FileAction::Shortcuts), ("ui.prefs".to_string(), Some(json!({"open": "Shortcuts"}))));
|
||||
assert_eq!(id_of(&FileAction::AgentChat), ("ui.agent_chat".to_string(), Some(json!({"toggle": true}))));
|
||||
assert_eq!(entry_of("ui.file.export", Some(&json!({"format": "step"}))), Some(Entry::File(FileAction::Export("step"))));
|
||||
for (id, params) in [
|
||||
("ui.file.export", None),
|
||||
("ui.file.export", Some(json!({"format": "dwg"}))),
|
||||
(HELP_ID, Some(json!({"item": "nothing"}))),
|
||||
(HELP_ID, None),
|
||||
("ui.prefs", None),
|
||||
("solid.nothing", None),
|
||||
(OPEN_RECENT, None),
|
||||
(RECENT, None),
|
||||
(native_menu::WINDOW, None),
|
||||
] {
|
||||
assert_eq!(entry_of(id, params.as_ref()), None, "{id} {params:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_shortcut_is_one_actions_and_the_menu_shows_it() {
|
||||
for (i, (action, key)) in SHORTCUTS.iter().enumerate() {
|
||||
assert!(SHORTCUTS.iter().skip(i + 1).all(|(a, k)| a != action && k != key), "{action:?} {key}");
|
||||
assert_eq!(item("label", action).shortcut, *key);
|
||||
}
|
||||
assert_eq!(item("label", &FileAction::SaveJson).shortcut, "");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn menu_shortcuts_parse_as_the_keys_they_were() {
|
||||
use muda::accelerator::{Accelerator, Code, Modifiers};
|
||||
let (cmd, shift) = (Modifiers::META, Modifiers::META | Modifiers::SHIFT);
|
||||
let want = [
|
||||
Accelerator::new(cmd, Code::Comma),
|
||||
Accelerator::new(cmd, Code::KeyQ),
|
||||
Accelerator::new(cmd, Code::KeyN),
|
||||
Accelerator::new(cmd, Code::KeyO),
|
||||
Accelerator::new(cmd, Code::KeyS),
|
||||
Accelerator::new(shift, Code::KeyS),
|
||||
Accelerator::new(cmd, Code::KeyZ),
|
||||
Accelerator::new(shift, Code::KeyZ),
|
||||
];
|
||||
for ((action, key), want) in SHORTCUTS.iter().zip(want) {
|
||||
assert_eq!(key.parse::<Accelerator>().ok(), Some(want), "{action:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_entry_of_the_menu_bar_runs_its_action() {
|
||||
fn leaves(items: &[Item], out: &mut Vec<Item>) {
|
||||
for item in items {
|
||||
if item.children.is_empty() {
|
||||
out.push(item.clone());
|
||||
} else {
|
||||
leaves(&item.children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
let bar = menu_bar(&app, &["/work/a.solidcraft".to_string()]);
|
||||
let mut entries = vec![];
|
||||
leaves(&bar.app, &mut entries);
|
||||
leaves(std::slice::from_ref(&bar.quit), &mut entries);
|
||||
leaves(&bar.menus, &mut entries);
|
||||
entries.retain(|i| i.id != "-");
|
||||
// App menu 2 + Quit; File: New, Open, 1 recent, Clear, 3 inserts, Save, Save As, JSON,
|
||||
// 7 exports, Properties; Edit 2; Window 1; Help 7.
|
||||
assert_eq!(entries.len(), 3 + 4 + 3 + 3 + 7 + 1 + 2 + 1 + 7, "{:?}", entries.iter().map(|i| &i.id).collect::<Vec<_>>());
|
||||
for item in &entries {
|
||||
assert!(entry_of(&item.id, item.params.as_ref()).is_some(), "`{}` {:?} runs nothing", item.id, item.params);
|
||||
}
|
||||
assert!(bar.menus.iter().all(|m| m.id.starts_with("menu.")), "menu ids are never actions");
|
||||
}
|
||||
|
||||
/// The entries whose ids are control actions do over the control channel what they do in
|
||||
/// the menu.
|
||||
#[test]
|
||||
fn shared_ids_mean_the_same_over_the_control_channel() {
|
||||
use solidcraft_ui_egui::scenario::Harness;
|
||||
let mut h = Harness::new();
|
||||
h.app.preferences.ai_features = true;
|
||||
h.app.agent_chat.open = false;
|
||||
let (id, params) = id_of(&FileAction::AgentChat);
|
||||
for open in [true, false] {
|
||||
let out = h.call(&id, params.clone().unwrap_or_default());
|
||||
assert_eq!(out["ok"], true, "{out}");
|
||||
assert_eq!(h.app.agent_chat.open, open, "Agent Chat toggles as in the menu");
|
||||
}
|
||||
for action in [FileAction::Settings, FileAction::Shortcuts] {
|
||||
let (id, params) = id_of(&action);
|
||||
let out = h.call(&id, params.unwrap_or_default());
|
||||
assert_eq!(out["ok"], true, "{action:?}: {out}");
|
||||
}
|
||||
// install_cli links into the system; its menu and control paths are the same `help::run`.
|
||||
for item in HELP.into_iter().filter(|h| *h != "install_cli") {
|
||||
let out = h.call(HELP_ID, json!({ "item": item }));
|
||||
assert_eq!(out["ok"], true, "{item}: {out}");
|
||||
}
|
||||
assert_eq!(h.call(HELP_ID, json!({"item": "nothing"}))["ok"], false, "an unknown help item is an error");
|
||||
let out = h.call(&id_of(&FileAction::Quit).0, json!({}));
|
||||
assert_eq!((out["ok"].clone(), h.app.quit_requested), (json!(true), true), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ assets/
|
||||
ui/source/ # Rust viewport glyphs, markers and navigation
|
||||
branding/web/source/og.png # shared social preview card
|
||||
branding/logo/ # print/web logo files; guide in branding/README.md
|
||||
styles/ui/palettes.rs # light/dark defaults
|
||||
styles/web/loading.css # web loading/canvas styles
|
||||
appearances/library.rs # built-in visual presets
|
||||
materials/library.rs # physical defaults and colours
|
||||
|
||||
@@ -38,7 +38,7 @@ Other colours, all already used in the project:
|
||||
| Colour | Hex | Where it comes from |
|
||||
|---|---|---|
|
||||
| Wordmark blue | `#15499e` | loading logo `--word` |
|
||||
| Interface accent | `#2678da` | light theme `accent` token (`assets/styles/ui/palettes.rs`) |
|
||||
| Interface accent | `#2678da` | light theme `accent` token (`crates/theme/src/palettes.rs`) |
|
||||
| Light background | `#e8ebf0` | web loading screen (`assets/styles/web/loading.css`) |
|
||||
| Dark background | `#1f232b` | dark theme `file_background` token |
|
||||
| Black / white | `#000000` / `#ffffff` | one-colour versions only |
|
||||
|
||||
@@ -81,7 +81,7 @@ for message in icon_palette.validation_errors(pal, names | set(al)):
|
||||
err(d + '/palette.toml', message)
|
||||
# the review page lies its icons on the real app surfaces: its colours must be the app's tokens
|
||||
def app_tokens():
|
||||
src = open(os.path.join(d, '..', '..', '..', 'styles', 'ui', 'palettes.rs'), encoding='utf-8').read()
|
||||
src = open(os.path.join(d, '..', '..', '..', '..', 'crates', 'theme', 'src', 'palettes.rs'), encoding='utf-8').read()
|
||||
lo, hi = src.index('pub const fn light'), src.index('pub const fn dark')
|
||||
light_src, dark_src = src[lo:hi], src[hi:]
|
||||
def pick(block, name, dark):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* App surfaces (keep in sync with assets/styles/ui/palettes.rs; lint_icons.py checks): --card is the ribbon surface `toolbar` the icons lie on, --bg the page (`panel` in light, `viewport_top` in dark), --line `border`, --fg `text`, --mut `text_dim`. */
|
||||
/* App surfaces (keep in sync with crates/theme/src/palettes.rs; lint_icons.py checks): --card is the ribbon surface `toolbar` the icons lie on, --bg the page (`panel` in light, `viewport_top` in dark), --line `border`, --fg `text`, --mut `text_dim`. */
|
||||
:root{--bg:#fafbfc;--fg:#262a32;--card:#f6f7f9;--line:#d0d4dc;--mut:#6e7480;--sz:48px}
|
||||
body.dark{--bg:#2e3440;--fg:#dee2e8;--card:#272b34;--line:#3f4552;--mut:#949ba8}
|
||||
body{font:12px system-ui,sans-serif;margin:0;background:var(--bg);color:var(--fg)}
|
||||
|
||||
@@ -110,7 +110,7 @@ pub(crate) fn breadcrumb(p: &egui::Painter, c: egui::Pos2, col: Color32) {
|
||||
}
|
||||
|
||||
pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
|
||||
brand_mark_styled(p, c, 20.0, crate::theme::Tokens::get().logo, true);
|
||||
brand_mark_styled(p, c, 20.0, solidcraft_theme::Tokens::get().logo, true);
|
||||
}
|
||||
|
||||
/// The same original mark as a single quiet silhouette, without face seams or outlines.
|
||||
@@ -127,7 +127,7 @@ fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32;
|
||||
};
|
||||
// The faces are separated by gaps that show the application bar, not by white lines.
|
||||
let [light, mid, dark] = colours;
|
||||
let edge_col = crate::theme::Tokens::get().app_bar;
|
||||
let edge_col = solidcraft_theme::Tokens::get().app_bar;
|
||||
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
|
||||
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
|
||||
face(mid, &[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Constraint, assembly snap, hole placement and dimension marker artwork.
|
||||
use crate::dialogs_assembly::SnapKind;
|
||||
use crate::theme::Tokens;
|
||||
use egui::{Color32, Pos2, Stroke, vec2};
|
||||
use solidcraft_theme::Tokens;
|
||||
|
||||
pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: SnapKind, col: Color32, tag: Option<&str>) {
|
||||
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
|
||||
@@ -49,7 +49,7 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
|
||||
// What it snaps to (hovered), or which component it is (picked): a small label.
|
||||
if let Some(tag) = tag {
|
||||
let t = Tokens::get();
|
||||
let font = egui::FontId::proportional(crate::theme::Tokens::get().font_caption);
|
||||
let font = egui::FontId::proportional(solidcraft_theme::Tokens::get().font_caption);
|
||||
let g = painter.layout_no_wrap(tag.to_string(), font, t.text);
|
||||
let at = c + vec2(12.0, 10.0);
|
||||
let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Navigation drawing definitions. Camera projection and picking remain in the UI widgets.
|
||||
use crate::theme::Tokens;
|
||||
use egui::{Color32, FontId, Pos2, Rect, Shape, Stroke, pos2, vec2};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_engine::geom::Ring;
|
||||
use solidcraft_theme::Tokens;
|
||||
pub(crate) struct Facet {
|
||||
pub(crate) direction: Vec3,
|
||||
pub(crate) points: Ring<Pos2>,
|
||||
@@ -150,16 +150,16 @@ pub(crate) fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, h
|
||||
}
|
||||
/// The pointer target around a compass end: larger than the drawn disc, so it is easy to hit.
|
||||
pub(crate) const AXIS_RADIUS: f32 = 15.0;
|
||||
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) {
|
||||
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens) {
|
||||
axis_tip_scaled(painter, c, name, d, at, depth, hovered, t, 1.0);
|
||||
}
|
||||
|
||||
/// The same compass artwork at a uniform scale, including its lettering and strokes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
|
||||
let col = if d.x != 0.0 {
|
||||
pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: [f64; 3], at: Pos2, depth: f64, hovered: bool, t: Tokens, scale: f32) {
|
||||
let col = if d[0] != 0.0 {
|
||||
t.axis_x
|
||||
} else if d.y != 0.0 {
|
||||
} else if d[1] != 0.0 {
|
||||
t.axis_y
|
||||
} else {
|
||||
t.axis_z
|
||||
@@ -196,7 +196,7 @@ pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &
|
||||
// On a hovered (white) disc the letter takes the axis colour.
|
||||
let letter = if hovered { col } else { t.on_accent };
|
||||
let label = name.to_uppercase();
|
||||
let galley = painter.layout_no_wrap(label, crate::fonts::bold(crate::theme::Tokens::get().font_compass * scale), letter);
|
||||
let galley = painter.layout_no_wrap(label, crate::ui_kit::type_face::bold(solidcraft_theme::Tokens::get().font_compass * scale), letter);
|
||||
let origin = at - galley.size() / 2.0;
|
||||
let ppp = painter.ctx().pixels_per_point().max(0.5);
|
||||
let origin = pos2((origin.x * ppp).round() / ppp, (origin.y * ppp).round() / ppp);
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
use egui::{Color32, FontId, Pos2, Shape, Stroke, vec2};
|
||||
/// The agent pointer's colour (a theme token, so a colour vision mode can reach it).
|
||||
pub(crate) fn agent() -> Color32 {
|
||||
crate::theme::Tokens::get().icons.agent
|
||||
solidcraft_theme::Tokens::get().icons.agent
|
||||
}
|
||||
pub(crate) fn paint(painter: &egui::Painter, p: Pos2) {
|
||||
let ic = crate::theme::Tokens::get().icons;
|
||||
let ic = solidcraft_theme::Tokens::get().icons;
|
||||
// An arrow pointer, tip at `p`.
|
||||
let pts: Vec<Pos2> =
|
||||
[(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
|
||||
painter.add(Shape::convex_polygon(pts.clone(), ic.agent, Stroke::NONE));
|
||||
painter.add(Shape::closed_line(pts, Stroke::new(1.5, ic.highlight)));
|
||||
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(crate::theme::Tokens::get().font_small), ic.highlight);
|
||||
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(solidcraft_theme::Tokens::get().font_small), ic.highlight);
|
||||
let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
|
||||
painter.rect_filled(r, 4.0, ic.agent);
|
||||
painter.galley(r.min + vec2(5.0, 2.0), label, ic.highlight);
|
||||
|
||||
+43
-15
@@ -265,6 +265,7 @@
|
||||
"Edge": "حافة",
|
||||
"Edges": "الحواف",
|
||||
"Edit": "تعديل",
|
||||
"Edit Holes": "تحرير الثقوب",
|
||||
"Edit Profile Sketch": "تعديل رسم المقطع",
|
||||
"Edit shortcuts": "تعديل الاختصارات",
|
||||
"Enable rule": "تفعيل القاعدة",
|
||||
@@ -976,7 +977,7 @@
|
||||
"browser.calibrate_click_a": "انقر على الصورة لتحديد النقطة A، أو اكتب إحداثياتها.",
|
||||
"browser.calibrate_click_b": "انقر على الصورة لتحديد النقطة B، أو اكتب إحداثياتها.",
|
||||
"browser.calibrate_title": "معايرة: {name}",
|
||||
"browser.canvas_locked": "اللوحة مقفلة. ألغِ قفلها أولاً.",
|
||||
"browser.canvas_locked": "اللوحة مقفلة. ألغِ قفلها أولًا.",
|
||||
"browser.capture_many": "تم تحريك {count} مكوّنات دون تثبيت مواضعها.",
|
||||
"browser.capture_note": "التثبيت يحتفظ بالموضع الجديد، والإرجاع يعيده كما كان.",
|
||||
"browser.capture_one": "تم تحريك مكوّن دون تثبيت موضعه.",
|
||||
@@ -1206,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "معامل المرونة الاتجاهي",
|
||||
"cmd.material_properties_validate": "التحقق من بيانات المادة الفيزيائية",
|
||||
"cmd.mesh_convert": "تحويل الشبكة",
|
||||
"cmd.model_body_features": "سرد ميزات الجسم",
|
||||
"cmd.model_edges": "قائمة الحواف",
|
||||
"cmd.model_faces": "قائمة الأوجه",
|
||||
"cmd.model_threads": "قائمة الأسنان اللولبية",
|
||||
@@ -1234,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "إغلاق العرض المؤقت للوحة",
|
||||
"cmd.panel.float": "تعويم اللوحة",
|
||||
"cmd.panel.focus": "تركيز اللوحة",
|
||||
"cmd.panel.focus.next": "التركيز على اللوحة التالية",
|
||||
"cmd.panel.focus.previous": "التركيز على اللوحة السابقة",
|
||||
"cmd.panel.list": "سرد اللوحات",
|
||||
"cmd.panel.move": "نقل اللوحة",
|
||||
"cmd.panel.move.next_zone": "نقل اللوحة إلى المنطقة التالية",
|
||||
"cmd.panel.native_close": "إعادة إرساء قسم اللوحة من نافذته المستقلة",
|
||||
"cmd.panel.open": "فتح اللوحة",
|
||||
"cmd.panel.peek": "عرض اللوحة المطوية مؤقتًا",
|
||||
@@ -1243,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "إعادة ضبط فاصل اللوحة",
|
||||
"cmd.panel.resize": "تغيير حجم قسم اللوحة",
|
||||
"cmd.panel.resize_pair": "تحريك فاصل اللوحة",
|
||||
"cmd.panel.sash.down": "تحريك فاصل اللوحة لأسفل",
|
||||
"cmd.panel.sash.left": "تحريك فاصل اللوحة لليسار",
|
||||
"cmd.panel.sash.reset": "إعادة تعيين فاصل اللوحة المحدد",
|
||||
"cmd.panel.sash.right": "تحريك فاصل اللوحة لليمين",
|
||||
"cmd.panel.sash.up": "تحريك فاصل اللوحة لأعلى",
|
||||
"cmd.panel.toggle": "تبديل طي اللوحة المحددة",
|
||||
"cmd.parameters_add": "إضافة معامل مستخدم",
|
||||
"cmd.parameters_change": "تغيير المعاملات",
|
||||
"cmd.parameters_comment": "التعليق على المعامل",
|
||||
@@ -1366,6 +1377,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "مماس يمر بنقطة خارجية",
|
||||
"cmd.sketch.construction_line.tangent_two": "مماس مشترك",
|
||||
"cmd.sketch.construction_line.vertical": "خط إنشائي رأسي",
|
||||
"cmd.sketch.replace": "استبدال الرسم",
|
||||
"cmd.sketch_arc3d": "قوس ثلاثي الأبعاد",
|
||||
"cmd.sketch_arc_center_point": "قوس بنقطة مركز",
|
||||
"cmd.sketch_arc_tangent": "قوس مماسي",
|
||||
@@ -1862,7 +1874,7 @@
|
||||
"help.sketch.constrainer": "يطبق القيد الذي يكاد منحنى أو منحنيان محددان يحققانه، مثل الأفقي أو التوازي أو التماس أو التساوي. استخدمه لتهذيب رسم يدوي علاقة بعد أخرى.",
|
||||
"help.sketch.constraint.coincident": "يجعل نقطة تقع على نقطة أخرى أو على منحنى أو على محور الرسم. استخدمه لسد الفجوات في المقطع أو لوضع نقطة على خط.",
|
||||
"help.sketch.constraint.collinear": "يجعل خطين يقعان على خط مستقيم واحد. استخدمه لمحاذاة الحواف المتقطعة، مثل جانبي حز.",
|
||||
"help.sketch.constraint.concentric": "يجعل دائرتين أو قوسين يشتركان في المركز نفسه، أو يضع مركز دائرة أو قوس على نقطة في الرسم التخطيطي، بما في ذلك نقطة الأصل. اختر العنصرين بأي ترتيب.",
|
||||
"help.sketch.constraint.concentric": "يجعل دائرتين أو قوسين يشتركان في المركز نفسه، أو يضع مركز دائرة أو قوس على نقطة في الرسم، بما في ذلك نقطة الأصل. اختر العنصرين بأي ترتيب.",
|
||||
"help.sketch.constraint.curvature": "يجعل منحنيين يلتقيان عند نقطة نهاية يتابعان بالانحناء نفسه (G2). استخدمه حيث لا يكفي الوصل المماسي نعومة، مثلًا على الأسطح الظاهرة.",
|
||||
"help.sketch.constraint.equal": "يجعل الخطوط بالطول نفسه، أو الدوائر والأقواس بنصف القطر نفسه. استخدمه ليتحكم بُعد واحد في عدة ميزات متماثلة.",
|
||||
"help.sketch.constraint.fix": "يثبّت النقاط أو المنحنيات في مكانها، أو يفك تثبيتها. استخدمه للهندسة التي يجب ألا تتحرك عند سحب الرسم أو حله.",
|
||||
@@ -2016,6 +2028,7 @@
|
||||
"help.view.visual_style": "يبدّل طريقة رسم النموذج: مظلَّل مع الحواف أو بدونها، أو إطار سلكي، أو مع الحواف المخفية. استخدم الإطار السلكي لرؤية الداخل، والمظلَّل لمظهر نظيف.",
|
||||
"history.sketch_under_constrained": "قيود ناقصة ({count} درجات حرية)",
|
||||
"history.sketch_under_constrained_one": "قيود ناقصة ({count} درجة حرية)",
|
||||
"hole_folder.applies_to": "تنطبق التغييرات على الثقوب الـ {count} في {folder}؛ ويحتفظ كل ثقب بموضعه.",
|
||||
"hole_presets.confirm_delete": "تأكيد الحذف",
|
||||
"hole_presets.delete": "حذف",
|
||||
"hole_presets.delete_prompt": "هل تحذف هذا الإعداد المسبق الشخصي؟ الثقوب الموجودة لا تتغير.",
|
||||
@@ -2515,6 +2528,8 @@
|
||||
"prefs.bottom_centre": "أسفل الوسط",
|
||||
"prefs.bottom_right": "أسفل اليمين",
|
||||
"prefs.browser": "المستعرض",
|
||||
"prefs.browser_body_features": "الميزات تحت الأجسام",
|
||||
"prefs.browser_body_features_note": "يمكن فتح كل جسم في المستعرض لعرض ميزات الخط الزمني التي بنته، بترتيب الخط الزمني.",
|
||||
"prefs.browser_components_open": "المكوّنات مفتوحة من البداية",
|
||||
"prefs.browser_components_open_note": "يبدأ كل مكوّن في المستعرض مفتوحًا كما كان من قبل، بدلًا من أن يبقى مغلقًا حتى تفتحه.",
|
||||
"prefs.browser_eye_left": "أيقونة الإظهار بجانب سهم الطي",
|
||||
@@ -2555,7 +2570,11 @@
|
||||
"prefs.design_tools.degrees": "درجات",
|
||||
"prefs.design_tools.double_click_help": "أو لتحرير مكوّن مرتبط في موضعه.",
|
||||
"prefs.design_tools.ghost": "إظهار الموضع القديم للأجزاء المنقولة كشبح",
|
||||
"prefs.design_tools.hole_group": "تجميع الثقوب المُنشأة معًا",
|
||||
"prefs.design_tools.hole_group_help": "الثقوب المُنشأة دفعة واحدة تشترك في مجلد واحد في السجل يبدأ مطويًا؛ افتحه لتحرير كل ثقب على حدة.",
|
||||
"prefs.design_tools.hole_moves_help": "اختيار الوجه مجددًا ينقل الثقب بدلًا من إزالته.",
|
||||
"prefs.design_tools.hole_several": "عدة ثقوب على وجه واحد",
|
||||
"prefs.design_tools.hole_several_help": "الاختيار في موضع آخر من الوجه يضيف ثقبًا آخر، واختيار ثقب يزيله.",
|
||||
"prefs.design_tools.hole_snaps_help": "منتصف الوجه ومراكز حوافه الدائرية.",
|
||||
"prefs.design_tools.imported_files": "الملفات المستوردة",
|
||||
"prefs.design_tools.note": "ملاحظة",
|
||||
@@ -2637,6 +2656,8 @@
|
||||
"prefs.history": "السجل",
|
||||
"prefs.history_bar_groups": "طي شريط السجل في مجموعات",
|
||||
"prefs.history_bar_groups_note": "في سجل طويل، تُطوى سلسلة ميزات المكوّن الواحد في شريحة واحدة، وتبدأ مجلدات الميزات مطوية. تدخل مجموعة STEP المُدرجة في مجلد Group. عند الإيقاف تظهر كل ميزة، وتبدأ المجلدات مفتوحة، ولا يُنشأ أي مجلد.",
|
||||
"prefs.history_folder_kind_icon": "أيقونة المجلد من ميزاته",
|
||||
"prefs.history_folder_kind_icon_note": "مجلد السجل الذي تكون ميزاته كلها من نوع واحد يعرض أيقونتها.",
|
||||
"prefs.history_group_joints": "تجميع المفاصل في السجل",
|
||||
"prefs.history_group_joints_note": "يظهر مفصلان أو أكثر كزر واحد مع عددها، والنقر عليه يُظهر كل مفصل. عند الإيقاف يظهر كل مفصل على حدة.",
|
||||
"prefs.history_origin_bodies": "إظهار الأصل والأجسام في الأعلى",
|
||||
@@ -2828,8 +2849,10 @@
|
||||
"prefs.select_hidden_edges_help": "يتيح تحديد الحواف عبر الأوجه عند تفعيل نمط «مظلل مع الحواف المخفية»، بما في ذلك عند استخدام التقويس. عند الإيقاف يبقى سلوك التحديد كما في v0.30.1. تظل الأجسام المخفية غير قابلة للتحديد.",
|
||||
"prefs.select_one_item": "تحديد عنصر واحد",
|
||||
"prefs.select_other_hidden": "تحديد العناصر الهندسية المخفية باستخدام «تحديد عنصر آخر»",
|
||||
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والمخططات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
|
||||
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والرسومات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
|
||||
"prefs.selecting": "التحديد",
|
||||
"prefs.shared_panel_layout": "استخدام تخطيط اللوحات المشترك",
|
||||
"prefs.shared_panel_layout_help": "استخدم المتصفح والسجل والمساعد داخل لوحات قابلة للنقل. يسري التغيير بعد تطبيق التفضيلات.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
|
||||
"prefs.shift_add_note": "عند التشغيل: Shift يضيف، وCtrl / Cmd يبدّل. عند الإيقاف (الافتراضي): كلاهما يبدّل.",
|
||||
@@ -2852,8 +2875,8 @@
|
||||
"prefs.sketch.drawing": "الرسم",
|
||||
"prefs.sketch.ellipse_axes": "رسم القطوع الناقصة مع محاورها",
|
||||
"prefs.sketch.ellipse_axes_note": "المحوران الأكبر والأصغر كخطوط مساعدة، لتحديد أبعاد أنصاف المحاور والمحاور. عند الإيقاف: القطع الناقص وحده، ويعطي D عليه نصف قطره الأصغر أينما سُحبت القيمة، مرسومًا ومُعلَّمًا بـ R كما في السابق.",
|
||||
"prefs.sketch.escape_twice_finishes": "الضغط المزدوج على Esc ينهي الرسم التخطيطي",
|
||||
"prefs.sketch.escape_twice_finishes_note": "ضغطتان سريعتان على Esc تنهيان الرسم التخطيطي. عند الإيقاف: يلغي Esc الأداة أو التحديد فقط، وينهي أمر «إنهاء الرسم» الرسم التخطيطي.",
|
||||
"prefs.sketch.escape_twice_finishes": "الضغط على Esc مرتين ينهي الرسم",
|
||||
"prefs.sketch.escape_twice_finishes_note": "ضغطتان سريعتان على Esc تنهيان الرسم. عند الإيقاف: يلغي Esc الأداة أو التحديد فقط، وينهي أمر «إنهاء الرسم» الرسم.",
|
||||
"prefs.sketch.exit_keep": "إبقاء",
|
||||
"prefs.sketch.exit_previous": "العودة إلى العرض السابق",
|
||||
"prefs.sketch.exit_unless_rotated": "إلا إذا تم التدوير",
|
||||
@@ -3074,7 +3097,7 @@
|
||||
"render.aging_metallic": "النسبة المعدنية للطبقة",
|
||||
"render.aging_origin": "أصل الحقل · mm العالم",
|
||||
"render.aging_patina": "زنجار نحاسي أخضر",
|
||||
"render.aging_raytrace_help": "يُطبّق التعتيق في تتبع الأشعة على المعالج وتتبع المسارات على المعالج الرسومي. يبقى التصيير النقطي تخطيطياً.",
|
||||
"render.aging_raytrace_help": "يُطبّق التعتيق في تتبع الأشعة على المعالج وتتبع المسارات على المعالج الرسومي. يبقى التصيير النقطي تخطيطيًا.",
|
||||
"render.aging_refresh_targets": "فحص الأجسام الحالية",
|
||||
"render.aging_roughness": "خشونة الطبقة",
|
||||
"render.aging_rust": "حديد مؤكسد / صدأ",
|
||||
@@ -3374,7 +3397,7 @@
|
||||
"render.cpu_opaque_required": "يتطلب تتبع الأشعة على CPU خلفية معتمة. ألغِ تحديد «خلفية شفافة» للمتابعة.",
|
||||
"render.cpu_options_pending": "لا تستطيع لوحة CPU هذه تحرير الانبعاث ولا تأثيرات السطح ولا الزجاج ولا تباين الخواص بعد. امسح خيارات API هذه قبل استخدامها.",
|
||||
"render.cpu_path_depth": "عمق المسار",
|
||||
"render.cpu_path_help": "تحسب الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءاً من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتاً أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_path_help": "تضيف الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءًا من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتًا أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_path_lighting": "إضاءة غير مباشرة",
|
||||
"render.cpu_path_optics_reason": "تبقى إعداداتك كما هي. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_point": "ضوء نقطي",
|
||||
@@ -3409,8 +3432,8 @@
|
||||
"render.cpu_sky_bounds": "يجب أن يكون دوران السماء محدودًا ضمن ±1,000,000 راديان، وأن تكون الشدة بين 0 و100.",
|
||||
"render.cpu_studio_enable": "تفعيل الإضاءة غير المباشرة لهذا التوزيع",
|
||||
"render.cpu_studio_enable_apply": "تفعيل الإضاءة غير المباشرة وتطبيقها",
|
||||
"render.cpu_studio_indirect_help": "يحتاج توزيع الشمس هذا إلى إضاءة غير مباشرة بعمق 8 وظلال متتبعة وعينة كاميرا واحدة لكل مرور. إذا كان التصيير التدريجي معطلاً، يبدأ بـ 16 عينة لكل بكسل وبذرة 0. تُحفظ الإعدادات التدريجية الحالية وملف الموارد. تمنع الإعدادات غير المتوافقة التطبيق دون تغييرها.",
|
||||
"render.cpu_studio_radiance_limit": "سطوع المصدر المساحي في هذا التوزيع هو {radiance}، ويتجاوز حد CPU البالغ 64. تُحفظ الإضاءة المؤلَّفة؛ اختر توزيعاً آخر أو GPU.",
|
||||
"render.cpu_studio_indirect_help": "يحتاج توزيع الشمس هذا إلى إضاءة غير مباشرة بعمق 8 وظلال متتبعة وعينة كاميرا واحدة لكل مرور. إذا كان التصيير التدريجي معطلًا، يبدأ بـ 16 عينة لكل بكسل وبذرة 0. تُحفظ الإعدادات التدريجية الحالية وملف الموارد. تمنع الإعدادات غير المتوافقة التطبيق دون تغييرها.",
|
||||
"render.cpu_studio_radiance_limit": "سطوع المصدر المساحي في هذا التوزيع هو {radiance}، ويتجاوز حد CPU البالغ 64. تُحفظ الإضاءة المؤلَّفة؛ اختر توزيعًا آخر أو GPU.",
|
||||
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة الضجيج. تبقى إعداداتك محفوظة.",
|
||||
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة الضجيج. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
|
||||
"render.cpu_summary": "تتبع الأشعة على CPU · انعكاسات · HDR · أضواء اتجاهية ونقطية",
|
||||
@@ -3430,7 +3453,7 @@
|
||||
"render.diag_preflight_details": "تفاصيل الفحص المسبق",
|
||||
"render.diag_request": "الطلب الحالي بالضبط",
|
||||
"render.diag_triangles": "المثلثات",
|
||||
"render.directional_sun_limit": "تدعم إضاءة GPU ثمانية مصادر ضوء اتجاهية إجمالاً، من بينها شمس واحدة بظلال ناعمة كحد أقصى. انسخ ضوءاً اتجاهياً بظلال حادة لإضافة مصدر بعيد آخر.",
|
||||
"render.directional_sun_limit": "تدعم إضاءة GPU ثمانية مصادر ضوء اتجاهية إجمالًا، من بينها شمس واحدة بظلال ناعمة كحد أقصى. انسخ ضوءًا اتجاهيًا بظلال حادة لإضافة مصدر بعيد آخر.",
|
||||
"render.discard_continue": "تجاهل المسودة والمتابعة",
|
||||
"render.draft_confirm_help": "في مسودة التصيير الحالية تعديلات غير مطبّقة. احفظها قبل التبديل، أو تجاهلها صراحةً، أو تابع التحرير.",
|
||||
"render.draft_confirm_title": "الحفاظ على إعدادات التصيير المعدّلة",
|
||||
@@ -4014,7 +4037,7 @@
|
||||
"render.output_lock_ratio": "قفل نسبة الأبعاد",
|
||||
"render.output_master_unavailable": "لتصدير EXR، اختر صورة مكتملة تحتوي على بيانات EXR خام. فعّل EXR الخام قبل تصيير صور جديدة.",
|
||||
"render.output_over_budget": "الصورة المطلوبة تتجاوز نطاق الموارد المختار. اختر حجمًا أو ملفًا متوافقًا صراحةً؛ لا يحدث تخفيض تلقائي.",
|
||||
"render.output_presentation_limits": "تدعم مؤثرات الصور على CPU وإخراج EXR صوراً بعرض وارتفاع لا يتجاوزان 8192 بكسل، وبإجمالي لا يتجاوز 16,777,216 بكسل.",
|
||||
"render.output_presentation_limits": "تدعم مؤثرات الصور على CPU وإخراج EXR صورًا بعرض وارتفاع لا يتجاوزان 8192 بكسل، وبإجمالي لا يتجاوز 16,777,216 بكسل.",
|
||||
"render.output_presets_help": "الصيغة والجودة وقائمة الفحص في محرر المخرجات.",
|
||||
"render.output_quality": "الجودة",
|
||||
"render.output_quality_waiting": "بانتظار عقد الجودة",
|
||||
@@ -4265,7 +4288,7 @@
|
||||
"render.soft_focus": "تركيز ناعم",
|
||||
"render.spot_axis": "اتجاه انبعاث الضوء",
|
||||
"render.spot_cone": "مخروط الكشّاف",
|
||||
"render.spot_gpu_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية. تتطلب نطاقات التلاشي الضيقة جداً التصيير باستخدام CPU.",
|
||||
"render.spot_gpu_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية. تتطلب نطاقات التلاشي الضيقة جدًا التصيير باستخدام CPU.",
|
||||
"render.spot_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية.",
|
||||
"render.spot_inner": "نصف الزاوية الداخلية",
|
||||
"render.spot_outer": "نصف الزاوية الخارجية",
|
||||
@@ -5446,7 +5469,7 @@
|
||||
"ui.finest": "الأدق",
|
||||
"ui.finish_sketch": "إنهاء الرسم",
|
||||
"ui.first_dimension_scales": "البُعد الأول يغيّر مقياس الرسم",
|
||||
"ui.first_dimension_scales_tip": "تشغيل: أول بُعد طولي في المخطط يغيّر مقياس الرسم الذي يقيسه، فيبقى شكله كما هو. إيقاف: يغيّر البُعد حجم ما يقيسه فقط.",
|
||||
"ui.first_dimension_scales_tip": "تشغيل: أول بُعد طولي في الرسم يغيّر مقياس الشكل الذي يقيسه، فيبقى شكله كما هو. إيقاف: يغيّر البُعد حجم ما يقيسه فقط.",
|
||||
"ui.fit": "ملاءمة",
|
||||
"ui.fix_unfix": "تثبيت/فك التثبيت",
|
||||
"ui.flat": "مسطّح",
|
||||
@@ -5520,6 +5543,7 @@
|
||||
"ui.heading_plastic": "البلاستيك",
|
||||
"ui.heading_position": "الموضع",
|
||||
"ui.heading_prepare": "تحضير",
|
||||
"ui.heading_press_pull": "ضغط وسحب",
|
||||
"ui.heading_rectangular_pattern": "نمط مستطيلي",
|
||||
"ui.heading_revolve": "دوران",
|
||||
"ui.heading_right": "يمين",
|
||||
@@ -5771,7 +5795,7 @@
|
||||
"ui.panel_layout.custom_hint": "تم نقل اللوحات منذ {name}.",
|
||||
"ui.panel_layout.details": "التفاصيل",
|
||||
"ui.panel_layout.layout_workspace": "مساحة عمل التخطيط",
|
||||
"ui.panel_layout.page_defaults_hint": "إعادة ضبط إعدادات هذه الصفحة. تُحفظ ترتيبات اللوحات والتخطيطات المحفوظة.",
|
||||
"ui.panel_layout.page_defaults_hint": "يعيد ضبط إعدادات هذه الصفحة. تُحفظ ترتيبات اللوحات والتخطيطات المحفوظة.",
|
||||
"ui.panel_layout.panel_layout": "تخطيط اللوحات",
|
||||
"ui.panel_layout.panel_style": "نمط اللوحات",
|
||||
"ui.panel_layout.replace": "استبدال التخطيط",
|
||||
@@ -5796,7 +5820,7 @@
|
||||
"ui.panel_layout.workspace.modelling": "النمذجة",
|
||||
"ui.panel_layout.workspace.render": "التصيير",
|
||||
"ui.panel_layout.workspace.review": "المراجعة",
|
||||
"ui.panel_layout.workspace.sketch": "الرسم التخطيطي",
|
||||
"ui.panel_layout.workspace.sketch": "الرسم",
|
||||
"ui.panel_layout.your_layout": "تخطيطك",
|
||||
"ui.parallel": "متوازي",
|
||||
"ui.parameters": "المعاملات",
|
||||
@@ -5863,6 +5887,8 @@
|
||||
"ui.profiles": "المقاطع",
|
||||
"ui.project": "إسقاط",
|
||||
"ui.project_face_edges_sketch": "إسقاط حواف الوجه في رسومات جديدة",
|
||||
"ui.project_references_construction": "إسقاط المراجع كهندسة مساعدة",
|
||||
"ui.project_references_construction_note": "ينشئ الإسقاط والتقاطع وتضمين الهندسة ثلاثية الأبعاد وحواف الوجه لرسم جديد على وجه هندسةً مساعدة: مراجع للرسم عليها لا تدخل في المقاطع. وتبقى مرتبطة بالنموذج.",
|
||||
"ui.properties": "الخصائص",
|
||||
"ui.properties.copy_matrix": "نسخ المصفوفة",
|
||||
"ui.properties.first_moments": "العزوم الأولى",
|
||||
@@ -5906,6 +5932,8 @@
|
||||
"ui.renderer": "محرك التصيير",
|
||||
"ui.repeat": "تكرار",
|
||||
"ui.repeat_nothing": "لا يوجد ما يُكرَّر بعد: ابدأ أداة أو مربع حوار أولًا.",
|
||||
"ui.replace_sketch.from_file": "من ملف تصميم…",
|
||||
"ui.replace_sketch.no_sketches": "لا يحتوي هذا التصميم على رسم يمكن أخذه",
|
||||
"ui.replace_with_link_done": "أصبحت النسخ الآن مواضع للتصميم المرتبط.",
|
||||
"ui.report_a_bug": "الإبلاغ عن خطأ",
|
||||
"ui.report_characters_left_one": "الأحرف المتبقية: {count}",
|
||||
|
||||
+448
-16
File diff suppressed because it is too large
Load Diff
+751
-3
File diff suppressed because it is too large
Load Diff
@@ -206,6 +206,7 @@
|
||||
"Edge": "Kante",
|
||||
"Edges": "Kanten",
|
||||
"Edit": "Bearbeiten",
|
||||
"Edit Holes": "Bohrungen bearbeiten",
|
||||
"Edit Profile Sketch": "Profile-Skizze bearbeiten",
|
||||
"Edit shortcuts": "Tastenkürzel bearbeiten",
|
||||
"Enable rule": "Regel aktivieren",
|
||||
@@ -1017,6 +1018,7 @@
|
||||
"cmd.material_properties_directional": "Richtungsabhängiger Elastizitätsmodul",
|
||||
"cmd.material_properties_validate": "Physikalische Materialdaten prüfen",
|
||||
"cmd.mesh_convert": "Netz konvertieren",
|
||||
"cmd.model_body_features": "Körper-Features auflisten",
|
||||
"cmd.model_edges": "Kanten auflisten",
|
||||
"cmd.model_faces": "Flächen auflisten",
|
||||
"cmd.model_threads": "Gewinde auflisten",
|
||||
@@ -1045,8 +1047,11 @@
|
||||
"cmd.panel.dismiss": "Panel-Vorschau schließen",
|
||||
"cmd.panel.float": "Panel frei platzieren",
|
||||
"cmd.panel.focus": "Panel fokussieren",
|
||||
"cmd.panel.focus.next": "Nächstes Panel fokussieren",
|
||||
"cmd.panel.focus.previous": "Vorheriges Panel fokussieren",
|
||||
"cmd.panel.list": "Panels auflisten",
|
||||
"cmd.panel.move": "Panel verschieben",
|
||||
"cmd.panel.move.next_zone": "Panel in nächste Zone verschieben",
|
||||
"cmd.panel.native_close": "Nativen Panel-Bereich wieder andocken",
|
||||
"cmd.panel.open": "Panel öffnen",
|
||||
"cmd.panel.peek": "Eingeklapptes Panel anzeigen",
|
||||
@@ -1054,6 +1059,12 @@
|
||||
"cmd.panel.reset_pair": "Panel-Trennlinie zurücksetzen",
|
||||
"cmd.panel.resize": "Panel-Bereich skalieren",
|
||||
"cmd.panel.resize_pair": "Panel-Trennlinie verschieben",
|
||||
"cmd.panel.sash.down": "Panel-Trennlinie nach unten verschieben",
|
||||
"cmd.panel.sash.left": "Panel-Trennlinie nach links verschieben",
|
||||
"cmd.panel.sash.reset": "Trennlinie des fokussierten Panels zurücksetzen",
|
||||
"cmd.panel.sash.right": "Panel-Trennlinie nach rechts verschieben",
|
||||
"cmd.panel.sash.up": "Panel-Trennlinie nach oben verschieben",
|
||||
"cmd.panel.toggle": "Fokussiertes Panel ein- oder ausklappen",
|
||||
"cmd.parameters_add": "Benutzerparameter hinzufügen",
|
||||
"cmd.parameters_change": "Parameter ändern",
|
||||
"cmd.parameters_comment": "Parameter kommentieren",
|
||||
@@ -1177,6 +1188,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "Tangente durch äußeren Punkt",
|
||||
"cmd.sketch.construction_line.tangent_two": "Gemeinsame Tangente",
|
||||
"cmd.sketch.construction_line.vertical": "Vertikale Konstruktionslinie",
|
||||
"cmd.sketch.replace": "Skizze ersetzen",
|
||||
"cmd.sketch_arc3d": "3D-Bogen",
|
||||
"cmd.sketch_arc_center_point": "Mittelpunkt-Kreisbogen",
|
||||
"cmd.sketch_arc_tangent": "Tangentialbogen",
|
||||
@@ -1819,6 +1831,7 @@
|
||||
"help.view.visual_style": "Schaltet um, wie das Modell gezeichnet wird: schattiert mit oder ohne Kanten, Drahtmodell oder mit verdeckten Kanten. Drahtmodell zum Hineinschauen, schattiert für ein sauberes Bild.",
|
||||
"history.sketch_under_constrained": "Unterbestimmt ({count} Freiheitsgrade)",
|
||||
"history.sketch_under_constrained_one": "Unterbestimmt ({count} Freiheitsgrad)",
|
||||
"hole_folder.applies_to": "Änderungen gelten für die {count} Bohrungen in {folder}; jede behält ihre Position.",
|
||||
"hole_presets.confirm_delete": "Löschen bestätigen",
|
||||
"hole_presets.delete": "Löschen",
|
||||
"hole_presets.delete_prompt": "Möchten Sie diese persönliche Vorlage wirklich löschen? Bereits vorhandene Bohrungen bleiben unverändert.",
|
||||
@@ -2302,6 +2315,8 @@
|
||||
"prefs.bottom_centre": "Unten mittig",
|
||||
"prefs.bottom_right": "Unten rechts",
|
||||
"prefs.browser": "Browser",
|
||||
"prefs.browser_body_features": "Features unter Körpern",
|
||||
"prefs.browser_body_features_note": "Jeder Körper im Browser lässt sich aufklappen und zeigt die Features der Zeitleiste, die ihn erzeugt haben, in deren Reihenfolge.",
|
||||
"prefs.browser_components_open": "Komponenten zu Beginn aufgeklappt",
|
||||
"prefs.browser_components_open_note": "Jede Komponente im Browser ist wie bisher von Anfang an aufgeklappt, statt zugeklappt zu bleiben, bis du sie aufklappst.",
|
||||
"prefs.browser_eye_left": "Auge neben dem Aufklapppfeil",
|
||||
@@ -2342,7 +2357,11 @@
|
||||
"prefs.design_tools.degrees": "Grad",
|
||||
"prefs.design_tools.double_click_help": "Oder zum Bearbeiten einer verknüpften Komponente an Ort und Stelle.",
|
||||
"prefs.design_tools.ghost": "Alte Position verschobener Teile als Geisterbild zeigen",
|
||||
"prefs.design_tools.hole_group": "Gemeinsam erstellte Bohrungen gruppieren",
|
||||
"prefs.design_tools.hole_group_help": "In einem Schritt erstellte Bohrungen teilen sich einen Ordner im Verlauf, zunächst geschlossen; öffnen Sie ihn, um eine Bohrung einzeln zu bearbeiten.",
|
||||
"prefs.design_tools.hole_moves_help": "Erneutes Auswählen der Fläche verschiebt die Bohrung, statt sie zu entfernen.",
|
||||
"prefs.design_tools.hole_several": "Mehrere Bohrungen auf einer Fläche",
|
||||
"prefs.design_tools.hole_several_help": "Eine Auswahl an anderer Stelle der Fläche fügt eine weitere Bohrung hinzu; die Auswahl einer Bohrung entfernt sie.",
|
||||
"prefs.design_tools.hole_snaps_help": "Die Mitte einer Fläche und die Mittelpunkte ihrer kreisförmigen Kanten.",
|
||||
"prefs.design_tools.imported_files": "Importierte Dateien",
|
||||
"prefs.design_tools.note": "Hinweis",
|
||||
@@ -2424,6 +2443,8 @@
|
||||
"prefs.history": "Verlauf",
|
||||
"prefs.history_bar_groups": "Verlaufsleiste in Gruppen falten",
|
||||
"prefs.history_bar_groups_note": "In einem langen Verlauf wird eine Folge von Features einer Komponente zu einem Chip gefaltet, und Merkmalsordner beginnen gefaltet. Eine eingefügte STEP-Baugruppe kommt in einen Group-Ordner. Aus zeigt jedes Feature, öffnet Ordner und legt keinen Ordner an.",
|
||||
"prefs.history_folder_kind_icon": "Ordnersymbol aus seinen Features",
|
||||
"prefs.history_folder_kind_icon_note": "Ein Ordner im Verlauf, dessen Features alle von einer Art sind, zeigt deren Symbol.",
|
||||
"prefs.history_group_joints": "Gelenke im Verlauf gruppieren",
|
||||
"prefs.history_group_joints_note": "Zwei oder mehr Gelenke erscheinen als eine Schaltfläche mit ihrer Anzahl; ein Klick zeigt jedes einzeln. Aus zeigt jedes Gelenk einzeln.",
|
||||
"prefs.history_origin_bodies": "Ursprung und Körper oben zeigen",
|
||||
@@ -2590,6 +2611,8 @@
|
||||
"prefs.select_other_hidden": "Verborgene Geometrie mit „Andere auswählen“ auswählen",
|
||||
"prefs.select_other_hidden_help": "Ein: Verborgene Körper, Skizzen und verdeckte Kanten nahe dem Zeiger einbeziehen. Aus: Die ursprüngliche Liste sichtbarer Geometrie direkt unter dem Zeiger verwenden.",
|
||||
"prefs.selecting": "Auswahl",
|
||||
"prefs.shared_panel_layout": "Gemeinsames Panellayout verwenden",
|
||||
"prefs.shared_panel_layout_help": "Browser, Verlauf und Assistent in verschiebbaren Panels anzeigen. Die Änderung wird beim Anwenden der Einstellungen wirksam.",
|
||||
"prefs.shift": "Umschalt",
|
||||
"prefs.shift_add_ctrl_toggle": "Umschalt fügt nur hinzu; Strg / Cmd schaltet die Auswahl um",
|
||||
"prefs.shift_add_note": "Aktiviert: Umschalt fügt Elemente zur Auswahl hinzu, Strg / Cmd schaltet ihre Auswahl um. Deaktiviert (Standard): Beide Tasten schalten die Auswahl um.",
|
||||
@@ -5240,6 +5263,7 @@
|
||||
"ui.heading_plastic": "PLASTIK",
|
||||
"ui.heading_position": "POSITION",
|
||||
"ui.heading_prepare": "VORBEREITEN",
|
||||
"ui.heading_press_pull": "DRÜCKEN/ZIEHEN",
|
||||
"ui.heading_rectangular_pattern": "RECHTECKMUSTER",
|
||||
"ui.heading_revolve": "DREHEN",
|
||||
"ui.heading_right": "RECHTS",
|
||||
@@ -5579,6 +5603,8 @@
|
||||
"ui.profiles": "Profile",
|
||||
"ui.project": "Projizieren",
|
||||
"ui.project_face_edges_sketch": "Flächenkanten in neue Skizzen projizieren",
|
||||
"ui.project_references_construction": "Referenzen als Konstruktionsgeometrie projizieren",
|
||||
"ui.project_references_construction_note": "Projizieren, Schneiden, 3D-Geometrie einbeziehen und die Flächenkanten einer neuen Skizze auf einer Fläche erzeugen Konstruktionsgeometrie: Bezüge zum Zeichnen, die nicht zu Profilen gehören. Sie bleiben mit dem Modell verknüpft.",
|
||||
"ui.properties": "Eigenschaften",
|
||||
"ui.properties.copy_matrix": "Matrix kopieren",
|
||||
"ui.properties.first_moments": "Statische Momente",
|
||||
@@ -5620,6 +5646,8 @@
|
||||
"ui.renderer": "Renderer",
|
||||
"ui.repeat": "Wiederholen",
|
||||
"ui.repeat_nothing": "Noch nichts zu wiederholen: Starten Sie zuerst ein Werkzeug oder einen Dialog.",
|
||||
"ui.replace_sketch.from_file": "Aus einer Konstruktionsdatei…",
|
||||
"ui.replace_sketch.no_sketches": "Diese Konstruktion enthält keine Skizze",
|
||||
"ui.replace_with_link_done": "Die Kopien sind jetzt Platzierungen des verknüpften Designs.",
|
||||
"ui.report_a_bug": "Fehler melden",
|
||||
"ui.report_characters_left_one": "{count} Zeichen übrig",
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"Edge": "Edge",
|
||||
"Edges": "Edges",
|
||||
"Edit": "Edit",
|
||||
"Edit Holes": "Edit Holes",
|
||||
"Edit Profile Sketch": "Edit Profile Sketch",
|
||||
"Edit shortcuts": "Edit shortcuts",
|
||||
"Enable rule": "Enable rule",
|
||||
@@ -1206,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "Directional Elastic Modulus",
|
||||
"cmd.material_properties_validate": "Validate Physical Material Data",
|
||||
"cmd.mesh_convert": "Convert Mesh",
|
||||
"cmd.model_body_features": "List Body Features",
|
||||
"cmd.model_edges": "List Edges",
|
||||
"cmd.model_faces": "List Faces",
|
||||
"cmd.model_threads": "List Threads",
|
||||
@@ -1234,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "Dismiss Panel Peek",
|
||||
"cmd.panel.float": "Float Panel",
|
||||
"cmd.panel.focus": "Focus Panel",
|
||||
"cmd.panel.focus.next": "Focus Next Panel",
|
||||
"cmd.panel.focus.previous": "Focus Previous Panel",
|
||||
"cmd.panel.list": "List Panels",
|
||||
"cmd.panel.move": "Move Panel",
|
||||
"cmd.panel.move.next_zone": "Move Panel to Next Zone",
|
||||
"cmd.panel.native_close": "Redock Native Panel Section",
|
||||
"cmd.panel.open": "Open Panel",
|
||||
"cmd.panel.peek": "Peek Collapsed Panel",
|
||||
@@ -1243,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "Reset Panel Sash",
|
||||
"cmd.panel.resize": "Resize Panel Section",
|
||||
"cmd.panel.resize_pair": "Resize Panel Sash",
|
||||
"cmd.panel.sash.down": "Resize Panel Sash Down",
|
||||
"cmd.panel.sash.left": "Resize Panel Sash Left",
|
||||
"cmd.panel.sash.reset": "Reset Focused Panel Sash",
|
||||
"cmd.panel.sash.right": "Resize Panel Sash Right",
|
||||
"cmd.panel.sash.up": "Resize Panel Sash Up",
|
||||
"cmd.panel.toggle": "Toggle Focused Panel",
|
||||
"cmd.parameters_add": "Add User Parameter",
|
||||
"cmd.parameters_change": "Change Parameters",
|
||||
"cmd.parameters_comment": "Comment Parameter",
|
||||
@@ -1366,6 +1377,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "Tangent through External Point",
|
||||
"cmd.sketch.construction_line.tangent_two": "Common Tangent",
|
||||
"cmd.sketch.construction_line.vertical": "Vertical Construction Line",
|
||||
"cmd.sketch.replace": "Replace Sketch",
|
||||
"cmd.sketch_arc3d": "3D Arc",
|
||||
"cmd.sketch_arc_center_point": "Center Point Arc",
|
||||
"cmd.sketch_arc_tangent": "Tangent Arc",
|
||||
@@ -2016,6 +2028,7 @@
|
||||
"help.view.visual_style": "Switches how the model is drawn: shaded with or without edges, wireframe, or with hidden edges. Use wireframe to see inside, shaded for a clean look.",
|
||||
"history.sketch_under_constrained": "Under constrained ({count} degrees of freedom)",
|
||||
"history.sketch_under_constrained_one": "Under constrained ({count} degree of freedom)",
|
||||
"hole_folder.applies_to": "Changes apply to the {count} holes in {folder}; each keeps its place.",
|
||||
"hole_presets.confirm_delete": "Confirm delete",
|
||||
"hole_presets.delete": "Delete",
|
||||
"hole_presets.delete_prompt": "Delete this personal preset? Existing holes are unchanged.",
|
||||
@@ -2515,6 +2528,8 @@
|
||||
"prefs.bottom_centre": "Bottom center",
|
||||
"prefs.bottom_right": "Bottom right",
|
||||
"prefs.browser": "Browser",
|
||||
"prefs.browser_body_features": "Features under bodies",
|
||||
"prefs.browser_body_features_note": "Each body in the browser folds open on the timeline features that built it, in timeline order.",
|
||||
"prefs.browser_components_open": "Components start open",
|
||||
"prefs.browser_components_open_note": "Every component in the browser starts open, as before, instead of closed until you open it.",
|
||||
"prefs.browser_eye_left": "Eye beside the fold arrow",
|
||||
@@ -2555,7 +2570,11 @@
|
||||
"prefs.design_tools.degrees": "Degrees",
|
||||
"prefs.design_tools.double_click_help": "Or to edit a linked one in place.",
|
||||
"prefs.design_tools.ghost": "Show moved parts' old place as a ghost",
|
||||
"prefs.design_tools.hole_group": "Group holes made together",
|
||||
"prefs.design_tools.hole_group_help": "Holes made in one go share a folder in History, closed at first; open it to edit a hole on its own.",
|
||||
"prefs.design_tools.hole_moves_help": "Picking the face again moves the hole instead of removing it.",
|
||||
"prefs.design_tools.hole_several": "Several holes on one face",
|
||||
"prefs.design_tools.hole_several_help": "Picking elsewhere on a face adds another hole; picking a hole removes it.",
|
||||
"prefs.design_tools.hole_snaps_help": "The middle of a face and the centers of its round edges.",
|
||||
"prefs.design_tools.imported_files": "Imported files",
|
||||
"prefs.design_tools.note": "Note",
|
||||
@@ -2637,6 +2656,8 @@
|
||||
"prefs.history": "History",
|
||||
"prefs.history_bar_groups": "Fold the History bar into groups",
|
||||
"prefs.history_bar_groups_note": "In a long history, a run of one component's features folds into one chip, and feature folders start folded. An inserted STEP assembly goes into a Group folder. Off shows each feature, starts folders open and makes no folder.",
|
||||
"prefs.history_folder_kind_icon": "Folder icon from its features",
|
||||
"prefs.history_folder_kind_icon_note": "A History folder whose features are all of one kind shows their icon.",
|
||||
"prefs.history_group_joints": "Group joints in the History",
|
||||
"prefs.history_group_joints_note": "Two or more joints show as one button with their count, and a click shows each one. Off shows each joint.",
|
||||
"prefs.history_origin_bodies": "Show origin and bodies at the top",
|
||||
@@ -2830,6 +2851,8 @@
|
||||
"prefs.select_other_hidden": "Select hidden geometry with Select Other",
|
||||
"prefs.select_other_hidden_help": "On: include hidden bodies, sketches and occluded edges near the pointer. Off: use the original list of visible geometry under the pointer.",
|
||||
"prefs.selecting": "Selecting",
|
||||
"prefs.shared_panel_layout": "Use shared panel layout",
|
||||
"prefs.shared_panel_layout_help": "Show the existing Browser, History and Assistant in movable panels. The change takes effect when preferences are applied.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift adds only; Ctrl / Cmd toggles",
|
||||
"prefs.shift_add_note": "On: Shift adds, Ctrl / Cmd toggles. Off (default): both toggle.",
|
||||
@@ -5520,6 +5543,7 @@
|
||||
"ui.heading_plastic": "PLASTIC",
|
||||
"ui.heading_position": "POSITION",
|
||||
"ui.heading_prepare": "PREPARE",
|
||||
"ui.heading_press_pull": "PRESS PULL",
|
||||
"ui.heading_rectangular_pattern": "RECTANGULAR PATTERN",
|
||||
"ui.heading_revolve": "REVOLVE",
|
||||
"ui.heading_right": "RIGHT",
|
||||
@@ -5863,6 +5887,8 @@
|
||||
"ui.profiles": "Profiles",
|
||||
"ui.project": "Project",
|
||||
"ui.project_face_edges_sketch": "Project face edges into new sketches",
|
||||
"ui.project_references_construction": "Project references as construction geometry",
|
||||
"ui.project_references_construction_note": "Project, Intersect, Include 3D Geometry and the face edges of a new sketch on a face make construction geometry: references to draw against, left out of profiles. They stay linked to the model.",
|
||||
"ui.properties": "Properties",
|
||||
"ui.properties.copy_matrix": "Copy matrix",
|
||||
"ui.properties.first_moments": "First moments",
|
||||
@@ -5906,6 +5932,8 @@
|
||||
"ui.renderer": "Renderer",
|
||||
"ui.repeat": "Repeat",
|
||||
"ui.repeat_nothing": "Nothing to repeat yet: start a tool or dialog first.",
|
||||
"ui.replace_sketch.from_file": "From a Design File…",
|
||||
"ui.replace_sketch.no_sketches": "That design has no sketch to take",
|
||||
"ui.replace_with_link_done": "The copies are now placements of the linked design.",
|
||||
"ui.report_a_bug": "Report a bug",
|
||||
"ui.report_characters_left_one": "{count} character left",
|
||||
|
||||
@@ -1000,6 +1000,7 @@
|
||||
"cmd.material_properties_directional": "Module d'élasticité directionnel",
|
||||
"cmd.material_properties_validate": "Valider les données du matériau physique",
|
||||
"cmd.mesh_convert": "Convertir le maillage",
|
||||
"cmd.model_body_features": "Lister les fonctions du corps",
|
||||
"cmd.model_edges": "Lister les arêtes",
|
||||
"cmd.model_faces": "Lister les faces",
|
||||
"cmd.model_threads": "Lister les filetages",
|
||||
@@ -1139,6 +1140,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "Tangente passant par un point extérieur",
|
||||
"cmd.sketch.construction_line.tangent_two": "Tangente commune",
|
||||
"cmd.sketch.construction_line.vertical": "Droite de construction verticale",
|
||||
"cmd.sketch.replace": "Remplacer l’esquisse",
|
||||
"cmd.sketch_arc3d": "Arc 3D",
|
||||
"cmd.sketch_arc_center_point": "Arc par centre",
|
||||
"cmd.sketch_arc_tangent": "Arc tangent",
|
||||
@@ -2163,6 +2165,8 @@
|
||||
"prefs.bottom_centre": "Bas centre",
|
||||
"prefs.bottom_right": "Bas droite",
|
||||
"prefs.browser": "Navigateur",
|
||||
"prefs.browser_body_features": "Fonctions sous les corps",
|
||||
"prefs.browser_body_features_note": "Chaque corps du navigateur se déplie sur les fonctions de l'historique qui l'ont construit, dans l'ordre de l'historique.",
|
||||
"prefs.browser_components_open": "Composants dépliés au départ",
|
||||
"prefs.browser_components_open_note": "Chaque composant du navigateur commence déplié, comme avant, au lieu de rester replié jusqu'à ce que vous le dépliiez.",
|
||||
"prefs.browser_eye_left": "Œil à côté de la flèche de dépliage",
|
||||
@@ -5015,6 +5019,8 @@
|
||||
"ui.renderer": "Moteur de rendu",
|
||||
"ui.repeat": "Répéter",
|
||||
"ui.repeat_nothing": "Rien à répéter pour l'instant : lancer d'abord un outil ou une boîte de dialogue.",
|
||||
"ui.replace_sketch.from_file": "Depuis un fichier de conception…",
|
||||
"ui.replace_sketch.no_sketches": "Cette conception ne contient aucune esquisse",
|
||||
"ui.replace_with_link_done": "Les copies sont maintenant des placements de la conception liée.",
|
||||
"ui.report_a_bug": "Signaler un bug",
|
||||
"ui.reset": "Réinitialiser",
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"Edge": "מקצוע",
|
||||
"Edges": "מקצועות",
|
||||
"Edit": "עריכה",
|
||||
"Edit Holes": "עריכת חורים",
|
||||
"Edit Profile Sketch": "עריכת סקיצת הפרופיל",
|
||||
"Edit shortcuts": "עריכת קיצורי דרך",
|
||||
"Enable rule": "הפעלת הכלל",
|
||||
@@ -1206,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "מודול אלסטיות כיווני",
|
||||
"cmd.material_properties_validate": "אימות נתוני חומר פיזיקלי",
|
||||
"cmd.mesh_convert": "המרת רשת משולשים",
|
||||
"cmd.model_body_features": "הצגת פיצ'רים של גוף",
|
||||
"cmd.model_edges": "רשימת מקצועות",
|
||||
"cmd.model_faces": "רשימת פאות",
|
||||
"cmd.model_threads": "רשימת הברגות",
|
||||
@@ -1234,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "סגירת תצוגה זמנית של חלונית",
|
||||
"cmd.panel.float": "הצפת חלונית",
|
||||
"cmd.panel.focus": "מיקוד חלונית",
|
||||
"cmd.panel.focus.next": "מיקוד בלוח הבא",
|
||||
"cmd.panel.focus.previous": "מיקוד בלוח הקודם",
|
||||
"cmd.panel.list": "הצגת רשימת חלוניות",
|
||||
"cmd.panel.move": "העברת חלונית",
|
||||
"cmd.panel.move.next_zone": "העברת הלוח לאזור הבא",
|
||||
"cmd.panel.native_close": "עיגון מחדש של מקטע חלונית מחלון נפרד",
|
||||
"cmd.panel.open": "פתיחת חלונית",
|
||||
"cmd.panel.peek": "הצגה זמנית של חלונית מכווצת",
|
||||
@@ -1243,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "איפוס מפריד חלוניות",
|
||||
"cmd.panel.resize": "שינוי גודל מקטע חלונית",
|
||||
"cmd.panel.resize_pair": "הזזת מפריד חלוניות",
|
||||
"cmd.panel.sash.down": "הזזת מפריד הלוח למטה",
|
||||
"cmd.panel.sash.left": "הזזת מפריד הלוח שמאלה",
|
||||
"cmd.panel.sash.reset": "איפוס מפריד הלוח שבמיקוד",
|
||||
"cmd.panel.sash.right": "הזזת מפריד הלוח ימינה",
|
||||
"cmd.panel.sash.up": "הזזת מפריד הלוח למעלה",
|
||||
"cmd.panel.toggle": "שינוי מצב הכיווץ של הלוח שבמיקוד",
|
||||
"cmd.parameters_add": "הוספת פרמטר משתמש",
|
||||
"cmd.parameters_change": "שינוי פרמטרים",
|
||||
"cmd.parameters_comment": "הערה לפרמטר",
|
||||
@@ -1366,6 +1377,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "משיק דרך נקודה חיצונית",
|
||||
"cmd.sketch.construction_line.tangent_two": "משיק משותף",
|
||||
"cmd.sketch.construction_line.vertical": "ישר עזר אנכי",
|
||||
"cmd.sketch.replace": "החלפת סקיצה",
|
||||
"cmd.sketch_arc3d": "קשת תלת־ממדית",
|
||||
"cmd.sketch_arc_center_point": "קשת מרכז ונקודה",
|
||||
"cmd.sketch_arc_tangent": "קשת משיקה",
|
||||
@@ -2016,6 +2028,7 @@
|
||||
"help.view.visual_style": "החלפת אופן הציור של המודל: מוצלל עם מקצועות או בלעדיהם, מסגרת תיל, או עם מקצועות מוסתרים. מסגרת תיל מאפשרת לראות פנימה, ומוצלל נותן מראה נקי.",
|
||||
"history.sketch_under_constrained": "אילוצים חסרים ({count} דרגות חופש)",
|
||||
"history.sketch_under_constrained_one": "אילוצים חסרים (דרגת חופש {count})",
|
||||
"hole_folder.applies_to": "השינויים חלים על {count} החורים ב-{folder}; כל חור שומר על מיקומו.",
|
||||
"hole_presets.confirm_delete": "אישור מחיקה",
|
||||
"hole_presets.delete": "מחיקה",
|
||||
"hole_presets.delete_prompt": "למחוק את ההגדרה המוכנה האישית הזו? חורים קיימים לא ישתנו.",
|
||||
@@ -2515,6 +2528,8 @@
|
||||
"prefs.bottom_centre": "למטה במרכז",
|
||||
"prefs.bottom_right": "למטה מימין",
|
||||
"prefs.browser": "עץ התכן",
|
||||
"prefs.browser_body_features": "פיצ'רים מתחת לגופים",
|
||||
"prefs.browser_body_features_note": "כל גוף בדפדפן נפתח ומציג את הפיצ'רים מציר הזמן שבנו אותו, לפי סדר ציר הזמן.",
|
||||
"prefs.browser_components_open": "רכיבים פתוחים מההתחלה",
|
||||
"prefs.browser_components_open_note": "כל רכיב בעץ התכן מתחיל פתוח, כמו קודם, במקום סגור עד שתפתח אותו.",
|
||||
"prefs.browser_eye_left": "סמל העין ליד חץ הקיפול",
|
||||
@@ -2555,7 +2570,11 @@
|
||||
"prefs.design_tools.degrees": "מעלות",
|
||||
"prefs.design_tools.double_click_help": "או לעריכת רכיב מקושר במקומו.",
|
||||
"prefs.design_tools.ghost": "הצגת המיקום הקודם של חלקים שהוזזו כרפאים",
|
||||
"prefs.design_tools.hole_group": "קיבוץ חורים שנוצרו יחד",
|
||||
"prefs.design_tools.hole_group_help": "חורים שנוצרו בפעולה אחת חולקים תיקייה בהיסטוריה, סגורה בהתחלה; פתחו אותה כדי לערוך חור בנפרד.",
|
||||
"prefs.design_tools.hole_moves_help": "בחירה חוזרת בפאה מזיזה את החור במקום להסירו.",
|
||||
"prefs.design_tools.hole_several": "כמה חורים בפאה אחת",
|
||||
"prefs.design_tools.hole_several_help": "בחירה במקום אחר בפאה מוסיפה חור נוסף; בחירה בחור מסירה אותו.",
|
||||
"prefs.design_tools.hole_snaps_help": "אמצע הפאה ומרכזי הקצוות העגולים שלה.",
|
||||
"prefs.design_tools.imported_files": "קבצים מיובאים",
|
||||
"prefs.design_tools.note": "הערה",
|
||||
@@ -2637,6 +2656,8 @@
|
||||
"prefs.history": "ציר הזמן",
|
||||
"prefs.history_bar_groups": "קיפול סרגל ציר הזמן לקבוצות",
|
||||
"prefs.history_bar_groups_note": "בציר זמן ארוך, רצף פיצ'רים של רכיב אחד מתקפל לשבב אחד, ותיקיות פיצ'רים מתחילות מקופלות. מכלול STEP שהוכנס נכנס לתיקיית Group. כשהאפשרות כבויה, כל פיצ'ר מוצג, התיקיות מתחילות פתוחות ולא נוצרת תיקייה.",
|
||||
"prefs.history_folder_kind_icon": "סמל התיקייה לפי התכונות שבה",
|
||||
"prefs.history_folder_kind_icon_note": "תיקייה בהיסטוריה שכל התכונות בה מאותו סוג מציגה את הסמל שלהן.",
|
||||
"prefs.history_group_joints": "קיבוץ מפרקים בציר הזמן",
|
||||
"prefs.history_group_joints_note": "שני מפרקים או יותר מוצגים ככפתור אחד עם מספרם, ולחיצה מציגה כל אחד מהם. כשהאפשרות כבויה, כל מפרק מוצג בנפרד.",
|
||||
"prefs.history_origin_bodies": "הצגת הראשית והגופים למעלה",
|
||||
@@ -2830,6 +2851,8 @@
|
||||
"prefs.select_other_hidden": "בחירת גאומטריה מוסתרת באמצעות בחירה אחרת",
|
||||
"prefs.select_other_hidden_help": "מופעל: כולל גופים, סקיצות וקצוות מוסתרים בקרבת הסמן. כבוי: שימוש ברשימה המקורית של גאומטריה גלויה מתחת לסמן.",
|
||||
"prefs.selecting": "בחירה",
|
||||
"prefs.shared_panel_layout": "שימוש בפריסת הלוחות המשותפת",
|
||||
"prefs.shared_panel_layout_help": "הצגת הדפדפן, ההיסטוריה והעוזר בלוחות ניתנים להזזה. השינוי ייכנס לתוקף לאחר החלת ההעדפות.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift מוסיף בלבד; Ctrl / Cmd מחליף מצב",
|
||||
"prefs.shift_add_note": "כשפועל: Shift מוסיף, Ctrl / Cmd מחליף מצב. כשכבוי (ברירת מחדל): שניהם מחליפים מצב.",
|
||||
@@ -5520,6 +5543,7 @@
|
||||
"ui.heading_plastic": "פלסטיק",
|
||||
"ui.heading_position": "מיקום",
|
||||
"ui.heading_prepare": "הכנה",
|
||||
"ui.heading_press_pull": "דחיפה/משיכה",
|
||||
"ui.heading_rectangular_pattern": "מערך מלבני",
|
||||
"ui.heading_revolve": "סיבוב",
|
||||
"ui.heading_right": "ימין",
|
||||
@@ -5863,6 +5887,8 @@
|
||||
"ui.profiles": "פרופילים",
|
||||
"ui.project": "הטלה",
|
||||
"ui.project_face_edges_sketch": "הטלת מקצועות הפאה לסקיצות חדשות",
|
||||
"ui.project_references_construction": "הטלת הפניות כגאומטריית עזר",
|
||||
"ui.project_references_construction_note": "הטלה, חיתוך, הכללת גאומטריה תלת־ממדית ומקצועות הפאה של סקיצה חדשה על פאה יוצרים גאומטריית עזר: הפניות לשרטוט שאינן נכללות בפרופילים. הן נשארות מקושרות למודל.",
|
||||
"ui.properties": "מאפיינים",
|
||||
"ui.properties.copy_matrix": "העתקת המטריצה",
|
||||
"ui.properties.first_moments": "מומנטים ראשונים",
|
||||
@@ -5906,6 +5932,8 @@
|
||||
"ui.renderer": "מנוע גרפי",
|
||||
"ui.repeat": "חזרה",
|
||||
"ui.repeat_nothing": "אין עדיין מה לחזור עליו: התחל קודם כלי או תיבת דו־שיח.",
|
||||
"ui.replace_sketch.from_file": "מקובץ תכנון…",
|
||||
"ui.replace_sketch.no_sketches": "בתכנון הזה אין סקיצה לקחת",
|
||||
"ui.replace_with_link_done": "העותקים הם עכשיו מיקומים של התכן המקושר.",
|
||||
"ui.report_a_bug": "דיווח על באג",
|
||||
"ui.report_characters_left_one": "תווים שנותרו: {count}",
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"Edge": "エッジ",
|
||||
"Edges": "エッジ",
|
||||
"Edit": "編集",
|
||||
"Edit Holes": "穴を編集",
|
||||
"Edit Profile Sketch": "プロファイルスケッチを編集",
|
||||
"Edit shortcuts": "ショートカットを編集",
|
||||
"Enable rule": "ルールを有効化",
|
||||
@@ -1206,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "方向別弾性率",
|
||||
"cmd.material_properties_validate": "物理材料データを検証",
|
||||
"cmd.mesh_convert": "メッシュを変換",
|
||||
"cmd.model_body_features": "ボディのフィーチャーを一覧表示",
|
||||
"cmd.model_edges": "エッジを一覧表示",
|
||||
"cmd.model_faces": "面を一覧表示",
|
||||
"cmd.model_threads": "ねじを一覧表示",
|
||||
@@ -1234,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "パネルの一時表示を閉じる",
|
||||
"cmd.panel.float": "パネルをフロート表示",
|
||||
"cmd.panel.focus": "パネルにフォーカス",
|
||||
"cmd.panel.focus.next": "次のパネルにフォーカス",
|
||||
"cmd.panel.focus.previous": "前のパネルにフォーカス",
|
||||
"cmd.panel.list": "パネルを一覧表示",
|
||||
"cmd.panel.move": "パネルを移動",
|
||||
"cmd.panel.move.next_zone": "パネルを次の領域に移動",
|
||||
"cmd.panel.native_close": "別ウィンドウのパネルを再ドッキング",
|
||||
"cmd.panel.open": "パネルを開く",
|
||||
"cmd.panel.peek": "折りたたんだパネルを一時表示",
|
||||
@@ -1243,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "パネルの区切りをリセット",
|
||||
"cmd.panel.resize": "パネルのセクションのサイズを変更",
|
||||
"cmd.panel.resize_pair": "パネルの区切りを移動",
|
||||
"cmd.panel.sash.down": "パネルの区切りを下に移動",
|
||||
"cmd.panel.sash.left": "パネルの区切りを左に移動",
|
||||
"cmd.panel.sash.reset": "フォーカス中のパネルの区切りをリセット",
|
||||
"cmd.panel.sash.right": "パネルの区切りを右に移動",
|
||||
"cmd.panel.sash.up": "パネルの区切りを上に移動",
|
||||
"cmd.panel.toggle": "フォーカス中のパネルの折りたたみを切り替え",
|
||||
"cmd.parameters_add": "ユーザー パラメータを追加",
|
||||
"cmd.parameters_change": "パラメータを変更",
|
||||
"cmd.parameters_comment": "パラメータにコメントを付与",
|
||||
@@ -1366,6 +1377,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "外部点を通る接線",
|
||||
"cmd.sketch.construction_line.tangent_two": "共通接線",
|
||||
"cmd.sketch.construction_line.vertical": "垂直補助直線",
|
||||
"cmd.sketch.replace": "スケッチを置き換え",
|
||||
"cmd.sketch_arc3d": "3D 円弧",
|
||||
"cmd.sketch_arc_center_point": "中心点円弧",
|
||||
"cmd.sketch_arc_tangent": "正接円弧",
|
||||
@@ -2016,6 +2028,7 @@
|
||||
"help.view.visual_style": "モデルの描画方法(エッジ付きまたはなしのシェード、ワイヤーフレーム、隠線付き)を切り替えます。内部を見るにはワイヤーフレーム、すっきり見せるにはシェードを使います。",
|
||||
"history.sketch_under_constrained": "拘束不足(自由度 {count})",
|
||||
"history.sketch_under_constrained_one": "拘束不足(自由度 {count})",
|
||||
"hole_folder.applies_to": "変更は {folder} の {count} 個の穴に適用されます。各穴の位置はそのままです。",
|
||||
"hole_presets.confirm_delete": "削除を確認",
|
||||
"hole_presets.delete": "削除",
|
||||
"hole_presets.delete_prompt": "この個人用プリセットを削除しますか?既存の穴は変更されません。",
|
||||
@@ -2515,6 +2528,8 @@
|
||||
"prefs.bottom_centre": "下中央",
|
||||
"prefs.bottom_right": "右下",
|
||||
"prefs.browser": "ブラウザー",
|
||||
"prefs.browser_body_features": "ボディの下にフィーチャー",
|
||||
"prefs.browser_body_features_note": "ブラウザーの各ボディを展開すると、そのボディを作ったタイムラインのフィーチャーがタイムライン順に表示されます。",
|
||||
"prefs.browser_components_open": "コンポーネントを最初から展開",
|
||||
"prefs.browser_components_open_note": "ブラウザーのすべてのコンポーネントを以前と同じく最初から展開して表示します。オフの場合は展開するまで折りたたまれています。",
|
||||
"prefs.browser_eye_left": "折りたたみ矢印の隣の目のアイコン",
|
||||
@@ -2555,7 +2570,11 @@
|
||||
"prefs.design_tools.degrees": "度",
|
||||
"prefs.design_tools.double_click_help": "リンクされたコンポーネントをその場で編集することもできます。",
|
||||
"prefs.design_tools.ghost": "移動したパーツの元の位置をゴースト表示",
|
||||
"prefs.design_tools.hole_group": "一緒に作成した穴をまとめる",
|
||||
"prefs.design_tools.hole_group_help": "一度に作成した穴は履歴で1つのフォルダーにまとめられ、最初は閉じています。開くと穴を個別に編集できます。",
|
||||
"prefs.design_tools.hole_moves_help": "面をもう一度選ぶと穴を削除せずに移動します。",
|
||||
"prefs.design_tools.hole_several": "1つの面に複数の穴",
|
||||
"prefs.design_tools.hole_several_help": "面の別の場所を選ぶと穴を追加し、穴を選ぶとその穴を削除します。",
|
||||
"prefs.design_tools.hole_snaps_help": "面の中央と円形エッジの中心。",
|
||||
"prefs.design_tools.imported_files": "インポートしたファイル",
|
||||
"prefs.design_tools.note": "通知",
|
||||
@@ -2637,6 +2656,8 @@
|
||||
"prefs.history": "履歴",
|
||||
"prefs.history_bar_groups": "履歴バーをグループにまとめる",
|
||||
"prefs.history_bar_groups_note": "長い履歴では、1つのコンポーネントの連続したフィーチャーを1つのチップにまとめ、フィーチャーフォルダは閉じた状態で始まります。挿入したSTEPアセンブリはGroupフォルダに入ります。オフにすると、各フィーチャーを表示し、フォルダは開いた状態で始まり、フォルダは作成されません。",
|
||||
"prefs.history_folder_kind_icon": "中のフィーチャーのアイコンをフォルダーに使う",
|
||||
"prefs.history_folder_kind_icon_note": "中のフィーチャーがすべて同じ種類の履歴フォルダーは、そのアイコンで表示されます。",
|
||||
"prefs.history_group_joints": "履歴でジョイントをまとめる",
|
||||
"prefs.history_group_joints_note": "2つ以上のジョイントを、数を添えた1つのボタンにまとめます。クリックすると各ジョイントを表示します。オフにすると各ジョイントを個別に表示します。",
|
||||
"prefs.history_origin_bodies": "原点とボディを先頭に表示",
|
||||
@@ -2830,6 +2851,8 @@
|
||||
"prefs.select_other_hidden": "「他を選択」で非表示のジオメトリを選択",
|
||||
"prefs.select_other_hidden_help": "オン: ポインター付近の非表示のボディやスケッチ、遮られたエッジを含めます。オフ: ポインター直下の表示中のジオメトリだけを元のリストに表示します。",
|
||||
"prefs.selecting": "選択",
|
||||
"prefs.shared_panel_layout": "共通のパネルレイアウトを使用",
|
||||
"prefs.shared_panel_layout_help": "既存のブラウザー、履歴、アシスタントを移動可能なパネルに表示します。設定を適用すると変更が有効になります。",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift は追加のみ、Ctrl / Cmd は選択を切り替え",
|
||||
"prefs.shift_add_note": "オン:Shift は追加、Ctrl / Cmd は選択を切り替えます。オフ(既定):どちらも選択を切り替えます。",
|
||||
@@ -5520,6 +5543,7 @@
|
||||
"ui.heading_plastic": "プラスチック",
|
||||
"ui.heading_position": "配置",
|
||||
"ui.heading_prepare": "準備",
|
||||
"ui.heading_press_pull": "プレス プル",
|
||||
"ui.heading_rectangular_pattern": "矩形パターン",
|
||||
"ui.heading_revolve": "回転",
|
||||
"ui.heading_right": "右側面",
|
||||
@@ -5863,6 +5887,8 @@
|
||||
"ui.profiles": "プロファイル",
|
||||
"ui.project": "投影",
|
||||
"ui.project_face_edges_sketch": "新規スケッチに面のエッジを自動投影",
|
||||
"ui.project_references_construction": "参照をコンストラクションジオメトリとして投影",
|
||||
"ui.project_references_construction_note": "投影、交差、3D ジオメトリを含める、および面上の新規スケッチの面のエッジは、コンストラクションジオメトリになります。描画の参照用で、プロファイルには含まれません。モデルとのリンクは保たれます。",
|
||||
"ui.properties": "プロパティ",
|
||||
"ui.properties.copy_matrix": "行列をコピー",
|
||||
"ui.properties.first_moments": "一次モーメント",
|
||||
@@ -5906,6 +5932,8 @@
|
||||
"ui.renderer": "レンダラー",
|
||||
"ui.repeat": "繰り返し",
|
||||
"ui.repeat_nothing": "まだ繰り返すものがありません。先にツールかダイアログを開始してください。",
|
||||
"ui.replace_sketch.from_file": "デザインファイルから…",
|
||||
"ui.replace_sketch.no_sketches": "そのデザインには取り込めるスケッチがありません",
|
||||
"ui.replace_with_link_done": "コピーはリンクされたデザインの配置になりました。",
|
||||
"ui.report_a_bug": "不具合を報告",
|
||||
"ui.report_characters_left_one": "残り {count} 文字",
|
||||
|
||||
@@ -992,8 +992,11 @@
|
||||
"cmd.panel.dismiss": "패널 임시 보기 닫기",
|
||||
"cmd.panel.float": "패널 띄우기",
|
||||
"cmd.panel.focus": "패널에 초점 맞추기",
|
||||
"cmd.panel.focus.next": "다음 패널에 초점 맞추기",
|
||||
"cmd.panel.focus.previous": "이전 패널에 초점 맞추기",
|
||||
"cmd.panel.list": "패널 목록",
|
||||
"cmd.panel.move": "패널 이동",
|
||||
"cmd.panel.move.next_zone": "패널을 다음 영역으로 이동",
|
||||
"cmd.panel.native_close": "별도 창의 패널 구역 다시 도킹",
|
||||
"cmd.panel.open": "패널 열기",
|
||||
"cmd.panel.peek": "접힌 패널 임시 보기",
|
||||
@@ -1001,6 +1004,12 @@
|
||||
"cmd.panel.reset_pair": "패널 구분선 초기화",
|
||||
"cmd.panel.resize": "패널 구역 크기 조정",
|
||||
"cmd.panel.resize_pair": "패널 구분선 이동",
|
||||
"cmd.panel.sash.down": "패널 구분선을 아래로 이동",
|
||||
"cmd.panel.sash.left": "패널 구분선을 왼쪽으로 이동",
|
||||
"cmd.panel.sash.reset": "초점이 있는 패널 구분선 초기화",
|
||||
"cmd.panel.sash.right": "패널 구분선을 오른쪽으로 이동",
|
||||
"cmd.panel.sash.up": "패널 구분선을 위로 이동",
|
||||
"cmd.panel.toggle": "초점이 있는 패널 접기 전환",
|
||||
"cmd.parameters_add": "사용자 매개 변수 추가",
|
||||
"cmd.parameters_change": "매개 변수 변경",
|
||||
"cmd.parameters_comment": "매개 변수에 설명 추가",
|
||||
@@ -1117,6 +1126,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "외부 점을 지나는 접선",
|
||||
"cmd.sketch.construction_line.tangent_two": "공통 접선",
|
||||
"cmd.sketch.construction_line.vertical": "수직 보조 직선",
|
||||
"cmd.sketch.replace": "스케치 바꾸기",
|
||||
"cmd.sketch_arc_center_point": "중심점 호",
|
||||
"cmd.sketch_arc_tangent": "접선 호",
|
||||
"cmd.sketch_arc_three_point": "3점 호",
|
||||
@@ -1928,6 +1938,8 @@
|
||||
"prefs.design_tools.degrees": "도",
|
||||
"prefs.design_tools.double_click_help": "연결된 구성요소를 제자리에서 편집할 수도 있습니다.",
|
||||
"prefs.design_tools.ghost": "이동한 부품의 이전 위치를 고스트로 표시",
|
||||
"prefs.design_tools.hole_group": "함께 만든 구멍 그룹화",
|
||||
"prefs.design_tools.hole_group_help": "한 번에 만든 구멍은 기록에서 하나의 폴더를 공유하며, 처음에는 닫혀 있습니다. 폴더를 열어 각 구멍을 개별적으로 편집하세요.",
|
||||
"prefs.design_tools.hole_moves_help": "면을 다시 선택하면 구멍을 제거하지 않고 이동합니다.",
|
||||
"prefs.design_tools.hole_snaps_help": "면의 가운데와 원형 모서리의 중심입니다.",
|
||||
"prefs.design_tools.imported_files": "가져온 파일",
|
||||
@@ -2005,6 +2017,8 @@
|
||||
"prefs.history": "이력",
|
||||
"prefs.history_bar_groups": "이력 막대를 그룹으로 접기",
|
||||
"prefs.history_bar_groups_note": "긴 이력에서는 한 컴포넌트의 연속된 특성을 하나의 칩으로 접고, 특성 폴더는 접힌 상태로 시작합니다. 삽입한 STEP 어셈블리는 Group 폴더에 들어갑니다. 끄면 각 특성을 표시하고, 폴더를 펼친 상태로 시작하며, 폴더를 만들지 않습니다.",
|
||||
"prefs.history_folder_kind_icon": "폴더 아이콘을 안의 피처에서 가져오기",
|
||||
"prefs.history_folder_kind_icon_note": "피처가 모두 같은 종류인 기록 폴더는 그 피처의 아이콘을 표시합니다.",
|
||||
"prefs.history_group_joints": "이력에서 조인트 묶기",
|
||||
"prefs.history_group_joints_note": "조인트가 두 개 이상이면 개수가 표시된 버튼 하나로 묶고, 클릭하면 각 조인트를 표시합니다. 끄면 각 조인트를 하나씩 표시합니다.",
|
||||
"prefs.history_origin_bodies": "원점과 바디를 맨 위에 표시",
|
||||
@@ -2132,6 +2146,8 @@
|
||||
"prefs.select_other_hidden": "다른 항목 선택으로 숨겨진 형상 선택",
|
||||
"prefs.select_other_hidden_help": "켜짐: 포인터 근처의 숨겨진 바디, 스케치 및 가려진 모서리를 포함합니다. 꺼짐: 포인터 바로 아래에 보이는 형상만 포함하는 기존 목록을 사용합니다.",
|
||||
"prefs.selecting": "선택",
|
||||
"prefs.shared_panel_layout": "공유 패널 레이아웃 사용",
|
||||
"prefs.shared_panel_layout_help": "기존 브라우저, 기록 및 도우미를 이동 가능한 패널로 표시합니다. 환경설정을 적용하면 변경 사항이 반영됩니다.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift는 추가만, Ctrl / Cmd는 토글",
|
||||
"prefs.shift_add_note": "켬: Shift는 추가, Ctrl / Cmd는 토글. 끔(기본값): 둘 다 토글.",
|
||||
@@ -4716,6 +4732,7 @@
|
||||
"ui.heading_plastic": "플라스틱",
|
||||
"ui.heading_position": "배치",
|
||||
"ui.heading_prepare": "준비",
|
||||
"ui.heading_press_pull": "밀기/당기기",
|
||||
"ui.heading_rectangular_pattern": "직사각형 패턴",
|
||||
"ui.heading_revolve": "회전",
|
||||
"ui.heading_right": "우측면",
|
||||
@@ -5037,6 +5054,8 @@
|
||||
"ui.profiles": "프로파일",
|
||||
"ui.project": "투영",
|
||||
"ui.project_face_edges_sketch": "면 모서리를 새 스케치로 투영",
|
||||
"ui.project_references_construction": "참조를 구성선으로 투영",
|
||||
"ui.project_references_construction_note": "투영, 교차, 3D 형상 포함과 면 위 새 스케치의 면 모서리가 구성선으로 만들어집니다. 프로파일에 포함되지 않는 그리기용 참조이며, 모델과의 연결은 유지됩니다.",
|
||||
"ui.properties": "속성",
|
||||
"ui.properties.copy_matrix": "행렬 복사",
|
||||
"ui.properties.first_moments": "1차 모멘트",
|
||||
@@ -5078,6 +5097,8 @@
|
||||
"ui.renderer": "렌더러",
|
||||
"ui.repeat": "반복",
|
||||
"ui.repeat_nothing": "아직 반복할 것이 없습니다. 먼저 도구나 대화 상자를 시작하세요.",
|
||||
"ui.replace_sketch.from_file": "디자인 파일에서…",
|
||||
"ui.replace_sketch.no_sketches": "그 디자인에는 가져올 스케치가 없습니다",
|
||||
"ui.replace_with_link_done": "복사본이 이제 링크된 디자인의 배치가 되었습니다.",
|
||||
"ui.report_a_bug": "버그 신고",
|
||||
"ui.reset": "재설정",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"ar": {
|
||||
"material.editor.remove": "إزالة"
|
||||
},
|
||||
"bg": {
|
||||
"material.editor.remove": "Remove"
|
||||
},
|
||||
"bs": {
|
||||
"material.editor.remove": "Ukloni"
|
||||
},
|
||||
"ca": {
|
||||
"material.editor.remove": "Eliminar"
|
||||
},
|
||||
"cs": {
|
||||
"material.editor.remove": "Odebrat"
|
||||
},
|
||||
"da": {
|
||||
"material.editor.remove": "Fjern"
|
||||
},
|
||||
"de": {
|
||||
"material.editor.remove": "Entfernen"
|
||||
},
|
||||
"en": {
|
||||
"material.editor.remove": "Remove"
|
||||
},
|
||||
"es": {
|
||||
"material.editor.remove": "Eliminar"
|
||||
},
|
||||
"fi": {
|
||||
"material.editor.remove": "Poista"
|
||||
},
|
||||
"fr": {
|
||||
"material.editor.remove": "Retirer"
|
||||
},
|
||||
"gl": {
|
||||
"material.editor.remove": "Quitar"
|
||||
},
|
||||
"he": {
|
||||
"material.editor.remove": "הסרה"
|
||||
},
|
||||
"hr": {
|
||||
"material.editor.remove": "Ukloni"
|
||||
},
|
||||
"hu": {
|
||||
"material.editor.remove": "Eltávolítás"
|
||||
},
|
||||
"id": {
|
||||
"material.editor.remove": "Hapus"
|
||||
},
|
||||
"it": {
|
||||
"material.editor.remove": "Rimuovi"
|
||||
},
|
||||
"ja": {
|
||||
"material.editor.remove": "削除"
|
||||
},
|
||||
"ka": {
|
||||
"material.editor.remove": "წაშლა"
|
||||
},
|
||||
"ms": {
|
||||
"material.editor.remove": "Buang"
|
||||
},
|
||||
"nb": {
|
||||
"material.editor.remove": "Fjern"
|
||||
},
|
||||
"nl": {
|
||||
"material.editor.remove": "Verwijderen"
|
||||
},
|
||||
"pl": {
|
||||
"material.editor.remove": "Usuń"
|
||||
},
|
||||
"pt-BR": {
|
||||
"material.editor.remove": "Remover"
|
||||
},
|
||||
"ro": {
|
||||
"material.editor.remove": "Elimină"
|
||||
},
|
||||
"ru": {
|
||||
"material.editor.remove": "Убрать"
|
||||
},
|
||||
"sr": {
|
||||
"material.editor.remove": "Уклони"
|
||||
},
|
||||
"sv": {
|
||||
"material.editor.remove": "Ta bort"
|
||||
},
|
||||
"tr": {
|
||||
"material.editor.remove": "Kaldır"
|
||||
},
|
||||
"uk": {
|
||||
"material.editor.remove": "Вилучити"
|
||||
},
|
||||
"vi": {
|
||||
"material.editor.remove": "Xóa"
|
||||
},
|
||||
"zh-TW": {
|
||||
"material.editor.remove": "刪除"
|
||||
},
|
||||
"zh": {
|
||||
"material.editor.remove": "删除"
|
||||
}
|
||||
}
|
||||
+2703
-1
File diff suppressed because it is too large
Load Diff
+3721
-1
File diff suppressed because it is too large
Load Diff
+527
-4
File diff suppressed because it is too large
Load Diff
@@ -265,6 +265,7 @@
|
||||
"Edge": "Cạnh",
|
||||
"Edges": "Các cạnh",
|
||||
"Edit": "Sửa",
|
||||
"Edit Holes": "Sửa các lỗ",
|
||||
"Edit Profile Sketch": "Chỉnh sửa phác thảo biên dạng",
|
||||
"Edit shortcuts": "Sửa phím tắt",
|
||||
"Enable rule": "Bật quy tắc",
|
||||
@@ -1206,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "Mô đun đàn hồi theo hướng",
|
||||
"cmd.material_properties_validate": "Xác thực dữ liệu thuộc tính vật liệu",
|
||||
"cmd.mesh_convert": "Chuyển đổi lưới",
|
||||
"cmd.model_body_features": "Liệt kê tính năng của thân",
|
||||
"cmd.model_edges": "Danh sách các cạnh",
|
||||
"cmd.model_faces": "Danh sách các bề mặt",
|
||||
"cmd.model_threads": "Danh sách các đoạn ren",
|
||||
@@ -1234,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "Đóng chế độ xem tạm của bảng",
|
||||
"cmd.panel.float": "Thả nổi bảng",
|
||||
"cmd.panel.focus": "Đặt tiêu điểm vào bảng",
|
||||
"cmd.panel.focus.next": "Đặt tiêu điểm vào bảng tiếp theo",
|
||||
"cmd.panel.focus.previous": "Đặt tiêu điểm vào bảng trước",
|
||||
"cmd.panel.list": "Liệt kê các bảng",
|
||||
"cmd.panel.move": "Di chuyển bảng",
|
||||
"cmd.panel.move.next_zone": "Di chuyển bảng đến vùng tiếp theo",
|
||||
"cmd.panel.native_close": "Gắn lại vùng bảng từ cửa sổ riêng",
|
||||
"cmd.panel.open": "Mở bảng",
|
||||
"cmd.panel.peek": "Xem tạm bảng đã thu gọn",
|
||||
@@ -1243,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "Đặt lại thanh chia bảng",
|
||||
"cmd.panel.resize": "Đổi kích thước vùng bảng",
|
||||
"cmd.panel.resize_pair": "Di chuyển thanh chia bảng",
|
||||
"cmd.panel.sash.down": "Di chuyển thanh chia bảng xuống",
|
||||
"cmd.panel.sash.left": "Di chuyển thanh chia bảng sang trái",
|
||||
"cmd.panel.sash.reset": "Đặt lại thanh chia bảng đang có tiêu điểm",
|
||||
"cmd.panel.sash.right": "Di chuyển thanh chia bảng sang phải",
|
||||
"cmd.panel.sash.up": "Di chuyển thanh chia bảng lên",
|
||||
"cmd.panel.toggle": "Bật/tắt thu gọn bảng đang có tiêu điểm",
|
||||
"cmd.parameters_add": "Thêm tham số người dùng",
|
||||
"cmd.parameters_change": "Thay đổi tham số",
|
||||
"cmd.parameters_comment": "Thêm chú thích tham số",
|
||||
@@ -1366,6 +1377,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "Tiếp tuyến qua điểm ngoài",
|
||||
"cmd.sketch.construction_line.tangent_two": "Tiếp tuyến chung",
|
||||
"cmd.sketch.construction_line.vertical": "Đường dựng hình thẳng đứng",
|
||||
"cmd.sketch.replace": "Thay thế phác thảo",
|
||||
"cmd.sketch_arc3d": "Cung tròn 3D",
|
||||
"cmd.sketch_arc_center_point": "Cung tròn xác định bởi tâm và điểm",
|
||||
"cmd.sketch_arc_tangent": "Cung tròn tiếp tuyến",
|
||||
@@ -2016,6 +2028,7 @@
|
||||
"help.view.visual_style": "Đổi cách vẽ mô hình: tô bóng có hoặc không có cạnh, khung dây, hoặc hiện cạnh khuất. Dùng khung dây để nhìn vào bên trong, tô bóng để có hình ảnh gọn gàng.",
|
||||
"history.sketch_under_constrained": "Thiếu ràng buộc ({count} bậc tự do)",
|
||||
"history.sketch_under_constrained_one": "Thiếu ràng buộc ({count} bậc tự do)",
|
||||
"hole_folder.applies_to": "Thay đổi áp dụng cho {count} lỗ trong {folder}; mỗi lỗ giữ nguyên vị trí.",
|
||||
"hole_presets.confirm_delete": "Xác nhận xóa",
|
||||
"hole_presets.delete": "Xóa",
|
||||
"hole_presets.delete_prompt": "Xóa mẫu thiết lập sẵn cá nhân này? Các lỗ ren/khoan hiện có sẽ không thay đổi.",
|
||||
@@ -2515,6 +2528,8 @@
|
||||
"prefs.bottom_centre": "Giữa phía dưới",
|
||||
"prefs.bottom_right": "Góc dưới bên phải",
|
||||
"prefs.browser": "Cây thư mục",
|
||||
"prefs.browser_body_features": "Tính năng dưới thân",
|
||||
"prefs.browser_body_features_note": "Mỗi thân trong trình duyệt có thể mở ra để hiện các tính năng trên dòng thời gian đã tạo nên nó, theo thứ tự dòng thời gian.",
|
||||
"prefs.browser_components_open": "Thành phần mở sẵn",
|
||||
"prefs.browser_components_open_note": "Mọi thành phần trong trình duyệt đều mở sẵn như trước, thay vì đóng cho đến khi bạn mở.",
|
||||
"prefs.browser_eye_left": "Biểu tượng mắt cạnh mũi tên thu gọn",
|
||||
@@ -2555,7 +2570,11 @@
|
||||
"prefs.design_tools.degrees": "Độ",
|
||||
"prefs.design_tools.double_click_help": "Hoặc để chỉnh sửa thành phần được liên kết tại chỗ.",
|
||||
"prefs.design_tools.ghost": "Hiện vị trí cũ của chi tiết đã di chuyển dưới dạng bóng mờ",
|
||||
"prefs.design_tools.hole_group": "Nhóm các lỗ được tạo cùng lúc",
|
||||
"prefs.design_tools.hole_group_help": "Các lỗ được tạo trong một lần dùng chung một thư mục trong Lịch sử, ban đầu được đóng; mở thư mục để sửa từng lỗ.",
|
||||
"prefs.design_tools.hole_moves_help": "Chọn lại mặt sẽ di chuyển lỗ thay vì xóa lỗ.",
|
||||
"prefs.design_tools.hole_several": "Nhiều lỗ trên một mặt",
|
||||
"prefs.design_tools.hole_several_help": "Chọn chỗ khác trên mặt sẽ thêm một lỗ; chọn một lỗ sẽ xóa lỗ đó.",
|
||||
"prefs.design_tools.hole_snaps_help": "Điểm giữa của mặt và tâm các cạnh tròn của mặt đó.",
|
||||
"prefs.design_tools.imported_files": "Tệp đã nhập",
|
||||
"prefs.design_tools.note": "Thông báo",
|
||||
@@ -2637,6 +2656,8 @@
|
||||
"prefs.history": "Lịch sử",
|
||||
"prefs.history_bar_groups": "Thu gọn thanh Lịch sử thành nhóm",
|
||||
"prefs.history_bar_groups_note": "Trong lịch sử dài, một chuỗi tính năng của cùng một thành phần được thu gọn thành một thẻ, và thư mục tính năng bắt đầu ở dạng thu gọn. Cụm lắp ráp STEP được chèn vào sẽ nằm trong thư mục Group. Khi tắt, mỗi tính năng đều hiện, thư mục bắt đầu ở dạng mở và không tạo thư mục nào.",
|
||||
"prefs.history_folder_kind_icon": "Biểu tượng thư mục theo các tính năng bên trong",
|
||||
"prefs.history_folder_kind_icon_note": "Thư mục Lịch sử có các tính năng cùng một loại sẽ hiện biểu tượng của chúng.",
|
||||
"prefs.history_group_joints": "Nhóm các khớp nối trong Lịch sử",
|
||||
"prefs.history_group_joints_note": "Từ hai khớp nối trở lên hiện thành một nút kèm số lượng, nhấp vào để hiện từng khớp nối. Khi tắt, mỗi khớp nối hiện riêng.",
|
||||
"prefs.history_origin_bodies": "Hiển thị gốc tọa độ và thân khối ở trên cùng",
|
||||
@@ -2830,6 +2851,8 @@
|
||||
"prefs.select_other_hidden": "Chọn hình học ẩn bằng Chọn đối tượng khác",
|
||||
"prefs.select_other_hidden_help": "Bật: bao gồm các khối, bản phác thảo ẩn và cạnh bị che gần con trỏ. Tắt: dùng danh sách ban đầu chỉ gồm hình học đang hiển thị ngay dưới con trỏ.",
|
||||
"prefs.selecting": "Đang chọn",
|
||||
"prefs.shared_panel_layout": "Dùng bố cục bảng dùng chung",
|
||||
"prefs.shared_panel_layout_help": "Hiển thị Trình duyệt, Lịch sử và Trợ lý hiện có trong các bảng có thể di chuyển. Thay đổi có hiệu lực khi áp dụng tùy chọn.",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift chỉ thêm; Ctrl / Cmd đảo trạng thái chọn",
|
||||
"prefs.shift_add_note": "Bật: Shift để thêm vào vùng chọn, Ctrl / Cmd để đảo chọn. Tắt (mặc định): cả hai đều đảo trạng thái chọn.",
|
||||
@@ -5520,6 +5543,7 @@
|
||||
"ui.heading_plastic": "CHI TIẾT NHỰA",
|
||||
"ui.heading_position": "VỊ TRÍ",
|
||||
"ui.heading_prepare": "CHUẨN BỊ",
|
||||
"ui.heading_press_pull": "KÉO/ĐẨY",
|
||||
"ui.heading_rectangular_pattern": "SAO CHÉP HÌNH CHỮ NHẬT",
|
||||
"ui.heading_revolve": "XOAY TRÒN TẠO KHỐI",
|
||||
"ui.heading_right": "HÌNH CHIẾU CẠNH",
|
||||
@@ -5863,6 +5887,8 @@
|
||||
"ui.profiles": "Các biên dạng",
|
||||
"ui.project": "Chiếu hình học",
|
||||
"ui.project_face_edges_sketch": "Chiếu các cạnh bề mặt vào phác thảo mới",
|
||||
"ui.project_references_construction": "Chiếu tham chiếu thành đường dựng hình",
|
||||
"ui.project_references_construction_note": "Chiếu, Giao, Đưa vào hình học 3D và các cạnh bề mặt của phác thảo mới trên một bề mặt tạo ra đường dựng hình: tham chiếu để vẽ theo, không thuộc biên dạng. Chúng vẫn liên kết với mô hình.",
|
||||
"ui.properties": "Thuộc tính cơ lý",
|
||||
"ui.properties.copy_matrix": "Sao chép ma trận",
|
||||
"ui.properties.first_moments": "Mômen bậc nhất",
|
||||
@@ -5906,6 +5932,8 @@
|
||||
"ui.renderer": "Trình kết xuất hình ảnh (Renderer)",
|
||||
"ui.repeat": "Lặp lại",
|
||||
"ui.repeat_nothing": "Chưa có gì để lặp lại: hãy bắt đầu một công cụ hoặc hộp thoại trước.",
|
||||
"ui.replace_sketch.from_file": "Từ một tệp thiết kế…",
|
||||
"ui.replace_sketch.no_sketches": "Thiết kế đó không có phác thảo nào để lấy",
|
||||
"ui.replace_with_link_done": "Các bản sao hiện đã trở thành các vị trí đặt của thiết kế liên kết.",
|
||||
"ui.report_a_bug": "Báo cáo lỗi",
|
||||
"ui.report_characters_left_one": "Còn {count} ký tự",
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"Edge": "边",
|
||||
"Edges": "边",
|
||||
"Edit": "编辑",
|
||||
"Edit Holes": "编辑孔",
|
||||
"Edit Profile Sketch": "编辑轮廓草图",
|
||||
"Edit shortcuts": "编辑快捷键",
|
||||
"Enable rule": "启用规则",
|
||||
@@ -1206,6 +1207,7 @@
|
||||
"cmd.material_properties_directional": "方向弹性模量",
|
||||
"cmd.material_properties_validate": "验证物理材料数据",
|
||||
"cmd.mesh_convert": "转换网格",
|
||||
"cmd.model_body_features": "列出实体特征",
|
||||
"cmd.model_edges": "列出边",
|
||||
"cmd.model_faces": "列出面",
|
||||
"cmd.model_threads": "列出螺纹",
|
||||
@@ -1234,8 +1236,11 @@
|
||||
"cmd.panel.dismiss": "关闭面板临时预览",
|
||||
"cmd.panel.float": "浮动面板",
|
||||
"cmd.panel.focus": "聚焦面板",
|
||||
"cmd.panel.focus.next": "聚焦下一个面板",
|
||||
"cmd.panel.focus.previous": "聚焦上一个面板",
|
||||
"cmd.panel.list": "列出面板",
|
||||
"cmd.panel.move": "移动面板",
|
||||
"cmd.panel.move.next_zone": "将面板移至下一个区域",
|
||||
"cmd.panel.native_close": "重新停靠独立窗口中的面板分区",
|
||||
"cmd.panel.open": "打开面板",
|
||||
"cmd.panel.peek": "临时预览折叠面板",
|
||||
@@ -1243,6 +1248,12 @@
|
||||
"cmd.panel.reset_pair": "重置面板分隔条",
|
||||
"cmd.panel.resize": "调整面板分区大小",
|
||||
"cmd.panel.resize_pair": "移动面板分隔条",
|
||||
"cmd.panel.sash.down": "向下移动面板分隔条",
|
||||
"cmd.panel.sash.left": "向左移动面板分隔条",
|
||||
"cmd.panel.sash.reset": "重置当前焦点面板的分隔条",
|
||||
"cmd.panel.sash.right": "向右移动面板分隔条",
|
||||
"cmd.panel.sash.up": "向上移动面板分隔条",
|
||||
"cmd.panel.toggle": "切换当前焦点面板的折叠状态",
|
||||
"cmd.parameters_add": "添加用户参数",
|
||||
"cmd.parameters_change": "更改参数",
|
||||
"cmd.parameters_comment": "注释参数",
|
||||
@@ -1366,6 +1377,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "过外部点的切线",
|
||||
"cmd.sketch.construction_line.tangent_two": "公切线",
|
||||
"cmd.sketch.construction_line.vertical": "竖直构造直线",
|
||||
"cmd.sketch.replace": "替换草图",
|
||||
"cmd.sketch_arc3d": "三维圆弧",
|
||||
"cmd.sketch_arc_center_point": "中心点圆弧",
|
||||
"cmd.sketch_arc_tangent": "相切圆弧",
|
||||
@@ -2016,6 +2028,7 @@
|
||||
"help.view.visual_style": "切换模型的绘制方式:带边或不带边的着色、线框,或带隐藏边。用线框看内部,用着色得到干净的外观。",
|
||||
"history.sketch_under_constrained": "欠约束({count} 个自由度)",
|
||||
"history.sketch_under_constrained_one": "欠约束({count} 个自由度)",
|
||||
"hole_folder.applies_to": "更改将应用于 {folder} 中的 {count} 个孔;每个孔保持原位置。",
|
||||
"hole_presets.confirm_delete": "确认删除",
|
||||
"hole_presets.delete": "删除",
|
||||
"hole_presets.delete_prompt": "删除此个人预设?现有孔不会改变。",
|
||||
@@ -2515,6 +2528,8 @@
|
||||
"prefs.bottom_centre": "底部居中",
|
||||
"prefs.bottom_right": "右下角",
|
||||
"prefs.browser": "浏览器",
|
||||
"prefs.browser_body_features": "实体下显示特征",
|
||||
"prefs.browser_body_features_note": "浏览器中的每个实体都可展开,按时间线顺序显示构建它的时间线特征。",
|
||||
"prefs.browser_components_open": "组件初始展开",
|
||||
"prefs.browser_components_open_note": "浏览器中的每个组件都像以前一样初始展开,而不是在您展开前保持折叠。",
|
||||
"prefs.browser_eye_left": "折叠箭头旁的眼睛图标",
|
||||
@@ -2555,7 +2570,11 @@
|
||||
"prefs.design_tools.degrees": "度",
|
||||
"prefs.design_tools.double_click_help": "或就地编辑链接的组件。",
|
||||
"prefs.design_tools.ghost": "以虚影显示已移动零件的原位置",
|
||||
"prefs.design_tools.hole_group": "将一起创建的孔分组",
|
||||
"prefs.design_tools.hole_group_help": "一次创建的孔在历史中共用一个文件夹,初始为折叠;展开后可单独编辑每个孔。",
|
||||
"prefs.design_tools.hole_moves_help": "再次选择该面会移动孔而非移除孔。",
|
||||
"prefs.design_tools.hole_several": "一个面上多个孔",
|
||||
"prefs.design_tools.hole_several_help": "在面的其他位置选择会再添加一个孔;选择孔会移除该孔。",
|
||||
"prefs.design_tools.hole_snaps_help": "面的中点及其圆形边的圆心。",
|
||||
"prefs.design_tools.imported_files": "导入的文件",
|
||||
"prefs.design_tools.note": "提示",
|
||||
@@ -2637,6 +2656,8 @@
|
||||
"prefs.history": "历史",
|
||||
"prefs.history_bar_groups": "将历史栏折叠为分组",
|
||||
"prefs.history_bar_groups_note": "在较长的历史中,同一组件的连续特征折叠为一个标签,特征文件夹开始时为折叠状态。插入的 STEP 装配体放入 Group 文件夹。关闭后显示每个特征,文件夹开始时为展开状态,且不创建文件夹。",
|
||||
"prefs.history_folder_kind_icon": "文件夹使用其中特征的图标",
|
||||
"prefs.history_folder_kind_icon_note": "历史中特征全部属于同一类型的文件夹显示该类型的图标。",
|
||||
"prefs.history_group_joints": "在历史中将关节分组",
|
||||
"prefs.history_group_joints_note": "两个或更多关节显示为一个带数量的按钮,单击即可显示每个关节。关闭后逐个显示每个关节。",
|
||||
"prefs.history_origin_bodies": "在顶部显示原点和实体",
|
||||
@@ -2830,6 +2851,8 @@
|
||||
"prefs.select_other_hidden": "使用“选择其他”选择隐藏几何体",
|
||||
"prefs.select_other_hidden_help": "开启:包括指针附近的隐藏实体、草图和被遮挡的边。关闭:恢复原来的列表,仅包含指针正下方的可见几何体。",
|
||||
"prefs.selecting": "选择",
|
||||
"prefs.shared_panel_layout": "使用共享面板布局",
|
||||
"prefs.shared_panel_layout_help": "在可移动面板中显示现有的浏览器、历史记录和助手。应用首选项后更改生效。",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift 仅添加;Ctrl / Cmd 切换选择",
|
||||
"prefs.shift_add_note": "开启:Shift 添加,Ctrl / Cmd 切换选择。关闭(默认):两者均切换选择。",
|
||||
@@ -5520,6 +5543,7 @@
|
||||
"ui.heading_plastic": "塑料",
|
||||
"ui.heading_position": "位置",
|
||||
"ui.heading_prepare": "准备",
|
||||
"ui.heading_press_pull": "按压拖动",
|
||||
"ui.heading_rectangular_pattern": "矩形阵列",
|
||||
"ui.heading_revolve": "旋转",
|
||||
"ui.heading_right": "右视",
|
||||
@@ -5863,6 +5887,8 @@
|
||||
"ui.profiles": "轮廓",
|
||||
"ui.project": "投影",
|
||||
"ui.project_face_edges_sketch": "将面的边投影到新草图",
|
||||
"ui.project_references_construction": "将参考投影为构造几何",
|
||||
"ui.project_references_construction_note": "投影、相交、包含三维几何以及在面上新建草图时投影的面边,都会生成构造几何:用于绘图参考,不计入轮廓。它们仍与模型保持关联。",
|
||||
"ui.properties": "属性",
|
||||
"ui.properties.copy_matrix": "复制矩阵",
|
||||
"ui.properties.first_moments": "一阶矩",
|
||||
@@ -5906,6 +5932,8 @@
|
||||
"ui.renderer": "渲染器",
|
||||
"ui.repeat": "重复",
|
||||
"ui.repeat_nothing": "还没有可重复的操作:请先启动一个工具或对话框。",
|
||||
"ui.replace_sketch.from_file": "从设计文件…",
|
||||
"ui.replace_sketch.no_sketches": "该设计中没有可用的草图",
|
||||
"ui.replace_with_link_done": "这些副本现在是链接设计的放置。",
|
||||
"ui.report_a_bug": "报告缺陷",
|
||||
"ui.report_characters_left_one": "剩余 {count} 个字符",
|
||||
|
||||
@@ -716,14 +716,20 @@ fn lit_color(i: TOut, visibility: f32, base: vec4<f32>) -> vec4<f32> {
|
||||
if (dot(n, v) < 0.0) { n = -n; }
|
||||
if u.product_lights_meta.x>0.0 {
|
||||
var radiance=vec3<f32>(0.0);
|
||||
let albedo=srgb_decode(base.rgb);
|
||||
for (var k=0u;k<min(u32(u.product_lights_meta.y),21u);k+=1u) {
|
||||
if u.product_lights[k*4u].w == 4.0 {radiance+=to_linear(base.rgb)*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
|
||||
if u.product_lights[k*4u].w == 4.0 {radiance+=albedo*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
|
||||
let sample=product_light_sample(k,i.wp);
|
||||
radiance+=to_linear(base.rgb)*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
|
||||
radiance+=albedo*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
|
||||
}
|
||||
let mapped=radiance/(vec3<f32>(1.0)+radiance);
|
||||
// A linear attachment takes linear light as it is; any other is encoded once, as the CPU
|
||||
// image is (decoding the encoded value again through `out_color` would flatten the dark end).
|
||||
if (u.back.w > 0.5) {
|
||||
return vec4<f32>(mapped,base.a);
|
||||
}
|
||||
let encoded=select(mapped*12.92,1.055*pow(mapped,vec3<f32>(1.0/2.4))-vec3<f32>(0.055),mapped>vec3<f32>(0.0031308));
|
||||
return out_color(vec4<f32>(encoded,base.a));
|
||||
return vec4<f32>(encoded,base.a);
|
||||
}
|
||||
let key = normalize(v * 0.8 + vec3<f32>(-0.3, 0.2, 0.6));
|
||||
let diff = max(dot(n, key), 0.0);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "solidcraft-color"
|
||||
description = "Colour rules and data any crate may share: the sRGB transfer function and the surface analysis palette"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,20 @@
|
||||
# solidcraft-color
|
||||
|
||||
Colour rules and data that any crate may share. It has no dependencies and holds no app types,
|
||||
so the renderer, the document, the importers and the UI theme all use the same rule and the same
|
||||
colours instead of keeping copies.
|
||||
|
||||
The sRGB transfer function (IEC 61966-2-1) lives here once: every encode and decode in Rust goes
|
||||
through `srgb`, so a change to the rule is one change. Shaders can't call Rust and keep their own
|
||||
copies.
|
||||
|
||||
## API
|
||||
|
||||
| Item | What it is |
|
||||
|---|---|
|
||||
| `srgb::encode(linear)` | Linear light (0..1) to an sRGB value (0..1) |
|
||||
| `srgb::decode(encoded)` | An sRGB value (0..1) to linear light |
|
||||
| `srgb::encode8(linear)` | Linear light to the nearest 8-bit sRGB code (out of range clamps, NaN reads as 0) |
|
||||
| `srgb::decode8(code)` | An 8-bit sRGB code to linear light |
|
||||
| `AnalysisColors` | The surface analysis palette (draft, accessibility, curvature, hatch), straight sRGB 0..1 as the shader takes it; the theme fills it and the renderer reads it |
|
||||
| `AnalysisColors::DEFAULT` | The light theme's colours with no colour vision mode, as headless pictures use |
|
||||
@@ -0,0 +1,32 @@
|
||||
//! The colours a surface analysis is shaded in.
|
||||
|
||||
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AnalysisColors {
|
||||
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
|
||||
pub draft: [[f32; 3]; 3],
|
||||
/// Accessibility Analysis: reachable, blocked.
|
||||
pub access: [[f32; 3]; 2],
|
||||
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius. Dark blue
|
||||
/// to light yellow in one straight blend, so the lightness rises the whole way and the scale
|
||||
/// reads with hue lost too (#975).
|
||||
pub curvature: [[f32; 3]; 3],
|
||||
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
|
||||
pub hatch: bool,
|
||||
}
|
||||
|
||||
impl AnalysisColors {
|
||||
/// The light theme's colours (no colour vision mode): what headless pictures use.
|
||||
pub const DEFAULT: AnalysisColors = AnalysisColors {
|
||||
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
|
||||
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
|
||||
curvature: [[0.08, 0.12, 0.45], [0.53, 0.51, 0.425], [0.98, 0.90, 0.40]],
|
||||
hatch: false,
|
||||
};
|
||||
}
|
||||
|
||||
impl Default for AnalysisColors {
|
||||
fn default() -> Self {
|
||||
AnalysisColors::DEFAULT
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Colour rules and data shared by every crate: the sRGB transfer function ([`srgb`]) and the surface analysis palette
|
||||
//! ([`AnalysisColors`]). No app types and no dependencies, so the renderer, the document and the theme all read the same
|
||||
//! rule and the same colours.
|
||||
|
||||
mod analysis;
|
||||
pub mod srgb;
|
||||
|
||||
pub use analysis::AnalysisColors;
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "solidcraft-control-egui"
|
||||
description = "The window's own control requests any egui app answers alike: ui.screenshot and ui.resize"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solidcraft-control = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# The window's own requests (`ui.screenshot`, `ui.resize`) go through egui.
|
||||
egui = { workspace = true }
|
||||
# Screenshots are saved in the format their path names.
|
||||
image = { workspace = true }
|
||||
@@ -0,0 +1,14 @@
|
||||
# solidcraft-control-egui
|
||||
|
||||
The window's own control requests (docs/control-protocol.md), which any egui app answers the same
|
||||
way: `ui.screenshot` and `ui.resize`. The token files, the loopback server and the request types
|
||||
are `solidcraft-control`, which has no UI toolkit, so a headless engine, CLI or MCP bridge uses the
|
||||
control channel without compiling egui. No app types and no engine.
|
||||
|
||||
## API
|
||||
|
||||
| Item | What it does |
|
||||
|---|---|
|
||||
| `screenshot::Shots` | `ui.screenshot`: frames asked for, taken when the view settles, saved and answered, or refused when hidden, cancelled or late; the app gives the reply for no frame |
|
||||
| `screenshot::encode` | A frame in the format its path names |
|
||||
| `window::resize`, `window::hidden` | `ui.resize` over `egui::Context`; whether the window presents frames |
|
||||
@@ -0,0 +1,8 @@
|
||||
//! The window's own control requests (docs/control-protocol.md) any app answers alike over egui:
|
||||
//! `ui.screenshot` ([`screenshot::Shots`]) and `ui.resize` ([`window::resize`]). The token, the
|
||||
//! server and the requests themselves are `solidcraft-control`, which needs no UI toolkit.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod screenshot;
|
||||
pub mod window;
|
||||
@@ -0,0 +1,288 @@
|
||||
//! `ui.screenshot`: a picture of the window as it is presented, for a control request. The
|
||||
//! window asks egui for a frame a few frames after the request (a view that just moved settles
|
||||
//! first), saves it where the request says and answers; a hidden window, a request whose agent
|
||||
//! chat turn ended, or no frame within 8 s is answered at once with why.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
|
||||
use egui::ColorImage;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_control::{cancelled_chat_response, chat_turn_cancelled};
|
||||
|
||||
use crate::window::hidden;
|
||||
|
||||
/// A frame asked for: its token, the time (ms) from which it may be taken, and the frames drawn
|
||||
/// since it was asked for.
|
||||
pub type Queued = (u64, f64, u32);
|
||||
|
||||
/// A request waiting for its frame: its token, where to save it, where to answer, and the time
|
||||
/// (ms) after which no frame is coming.
|
||||
pub type Pending = (u64, Option<String>, Sender<Value>, f64);
|
||||
|
||||
/// A frame egui handed back, by its token.
|
||||
pub type Shot = (u64, Arc<ColorImage>);
|
||||
|
||||
/// The window's screenshots in flight. The app may queue frames of its own (an issue report's)
|
||||
/// with tokens from [`Shots::token`]; [`Shots::collect`] hands those back.
|
||||
pub struct Shots {
|
||||
/// The error a request gets when the window presents no frame: the app's words, which may
|
||||
/// name its other ways to a picture.
|
||||
no_frame: String,
|
||||
/// The last token given out.
|
||||
pub token: u64,
|
||||
/// Frames asked for and not yet requested from egui.
|
||||
pub queued: Vec<Queued>,
|
||||
/// Control requests waiting for their frame.
|
||||
pub pending: Vec<Pending>,
|
||||
/// The agent chat turn of each pending request that belongs to one.
|
||||
turns: HashMap<u64, Arc<AtomicBool>>,
|
||||
/// Frames egui hands back; egui calls back, [`Shots::collect`] reads them.
|
||||
tx: Sender<Shot>,
|
||||
rx: Receiver<Shot>,
|
||||
}
|
||||
|
||||
impl Shots {
|
||||
/// No screenshots in flight; a request the window presents no frame for is answered with the
|
||||
/// error `no_frame`.
|
||||
pub fn new(no_frame: impl Into<String>) -> Self {
|
||||
let (tx, rx) = channel();
|
||||
Shots { no_frame: no_frame.into(), token: 0, queued: Vec::new(), pending: Vec::new(), turns: HashMap::new(), tx, rx }
|
||||
}
|
||||
|
||||
/// Take the window's picture for a request that answers on `reply`, saved at `path`; `turn`:
|
||||
/// the agent chat turn it belongs to. `now` is in ms. A window that is minimised or covered
|
||||
/// (another macOS Space) presents no frame: that is said at once rather than after the
|
||||
/// deadline (#1677).
|
||||
pub fn request(&mut self, ctx: &egui::Context, path: Option<String>, reply: Sender<Value>, turn: Option<Arc<AtomicBool>>, now: f64) {
|
||||
if hidden(ctx) {
|
||||
let _ = reply.send(json!({"ok": false, "hidden": true, "error": self.no_frame}));
|
||||
return;
|
||||
}
|
||||
self.token += 1;
|
||||
let token = self.token;
|
||||
if let Some(turn) = turn {
|
||||
self.turns.insert(token, turn);
|
||||
}
|
||||
self.queued.push((token, now + 150.0, 0));
|
||||
self.pending.push((token, path, reply, now + 8000.0));
|
||||
}
|
||||
|
||||
/// Each frame: ask egui for the frames that are due, three frames after their request at the
|
||||
/// earliest. `settled`: the view is drawn at full quality; a view that keeps moving is taken
|
||||
/// as it is half a second later (#7165).
|
||||
pub fn issue(&mut self, ctx: &egui::Context, settled: bool, now: f64) {
|
||||
let shot_tx = &self.tx;
|
||||
self.queued.retain_mut(|(token, at, frames)| {
|
||||
*frames += 1;
|
||||
if now >= *at && *frames >= 3 && (settled || now >= *at + 500.0) {
|
||||
let (token, tx, ctx2) = (*token, shot_tx.clone(), ctx.clone());
|
||||
ctx.request_screenshot(move |image| {
|
||||
let _ = tx.send((token, image));
|
||||
ctx2.request_repaint();
|
||||
});
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
if !self.queued.is_empty() || !self.pending.is_empty() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
/// Each frame: answer the requests whose frame came, saving it with `save` (the reply), and
|
||||
/// those whose turn ended or that wait in vain. `others`: the app waits for frames of its own;
|
||||
/// then every frame that came this frame is handed back, for the app to take its own from.
|
||||
pub fn collect(&mut self, ctx: &egui::Context, now: f64, others: bool, save: impl Fn(&ColorImage, Option<&str>) -> Value) -> Vec<Shot> {
|
||||
// A screenshot requested in one tab must never return or save a frame of another.
|
||||
let turns = &mut self.turns;
|
||||
let queued = &mut self.queued;
|
||||
self.pending.retain(|(token, _, reply, _)| {
|
||||
if chat_turn_cancelled(turns.get(token).map(Arc::as_ref)) {
|
||||
turns.remove(token);
|
||||
queued.retain(|(waiting, ..)| waiting != token);
|
||||
let _ = reply.send(cancelled_chat_response());
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
// Drained every frame: a shot that comes after its request was cancelled or timed out
|
||||
// is dropped here instead of waiting in the channel.
|
||||
let shots: Vec<Shot> = self.rx.try_iter().collect();
|
||||
if self.pending.is_empty() && !others {
|
||||
return Vec::new();
|
||||
}
|
||||
for (token, image) in &shots {
|
||||
if let Some(i) = self.pending.iter().position(|(t, ..)| t == token) {
|
||||
let (_, path, reply, _) = self.pending.remove(i);
|
||||
self.turns.remove(token);
|
||||
let _ = reply.send(save(image, path.as_deref()));
|
||||
}
|
||||
}
|
||||
// Hidden while waiting: no frame is coming.
|
||||
let hidden = hidden(ctx);
|
||||
let (turns, no_frame) = (&mut self.turns, &self.no_frame);
|
||||
self.pending.retain(|(token, _, reply, deadline)| {
|
||||
if now < *deadline && !hidden {
|
||||
return true;
|
||||
}
|
||||
let _ = reply.send(json!({"ok": false, "error": no_frame}));
|
||||
turns.remove(token);
|
||||
false
|
||||
});
|
||||
shots
|
||||
}
|
||||
}
|
||||
|
||||
/// `image` in the format `path`'s extension names (a PNG for `.png`).
|
||||
pub fn encode(image: &ColorImage, path: &str) -> Result<Vec<u8>, String> {
|
||||
let [w, h] = image.size;
|
||||
let mut buf = Vec::with_capacity(w * h * 4);
|
||||
for c in &image.pixels {
|
||||
buf.extend_from_slice(&c.to_array());
|
||||
}
|
||||
let img = image::RgbaImage::from_raw(w as u32, h as u32, buf).ok_or("bad image")?;
|
||||
let format = image::ImageFormat::from_path(path).map_err(|e| e.to_string())?;
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut bytes, format).map_err(|e| e.to_string())?;
|
||||
Ok(bytes.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The app's reply for no frame.
|
||||
const NO_FRAME: &str = "no frame here";
|
||||
|
||||
fn pixel_image() -> Arc<ColorImage> {
|
||||
Arc::new(ColorImage::new([4, 3], vec![egui::Color32::from_rgb(10, 20, 30); 12]))
|
||||
}
|
||||
|
||||
/// A request is answered with its frame once egui hands it back, saved as the app saves it;
|
||||
/// a frame of the app's own is handed back to it; a request whose frame never comes is
|
||||
/// answered after its deadline.
|
||||
#[test]
|
||||
fn a_request_is_answered_with_its_frame_or_its_deadline() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut shots = Shots::new(NO_FRAME);
|
||||
let (reply, answer) = channel();
|
||||
shots.request(&ctx, Some("a.png".into()), reply, None, 0.0);
|
||||
assert_eq!((shots.token, shots.queued.len(), shots.pending.len()), (1, 1, 1));
|
||||
// The frame egui hands back, and one the app asked for itself.
|
||||
shots.queued.clear();
|
||||
let _ = shots.tx.send((1, pixel_image()));
|
||||
let _ = shots.tx.send((2, pixel_image()));
|
||||
let save = |image: &ColorImage, path: Option<&str>| json!({"ok": true, "result": {"path": path, "width": image.size[0]}});
|
||||
let back = shots.collect(&ctx, 1.0, true, save);
|
||||
assert_eq!(back.iter().map(|(t, _)| *t).collect::<Vec<_>>(), [1, 2]);
|
||||
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": true, "result": {"path": "a.png", "width": 4}})));
|
||||
assert!(shots.pending.is_empty());
|
||||
|
||||
let (reply, late) = channel();
|
||||
shots.request(&ctx, None, reply, None, 0.0);
|
||||
assert!(shots.collect(&ctx, 7999.0, false, save).is_empty());
|
||||
assert!(late.try_recv().is_err(), "still waiting");
|
||||
shots.collect(&ctx, 8000.0, false, save);
|
||||
assert_eq!(late.try_recv().ok(), Some(json!({"ok": false, "error": NO_FRAME})));
|
||||
}
|
||||
|
||||
/// A request whose agent chat turn ended is answered so, and its frame is never taken.
|
||||
#[test]
|
||||
fn a_request_whose_turn_ended_is_refused() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut shots = Shots::new(NO_FRAME);
|
||||
let (reply, answer) = channel();
|
||||
let turn = Arc::new(AtomicBool::new(true));
|
||||
shots.request(&ctx, None, reply, Some(Arc::clone(&turn)), 0.0);
|
||||
turn.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
shots.collect(&ctx, 1.0, false, |_, _| Value::Null);
|
||||
assert_eq!(answer.try_recv().ok(), Some(cancelled_chat_response()));
|
||||
assert!(shots.queued.is_empty() && shots.pending.is_empty());
|
||||
}
|
||||
|
||||
/// One frame of a window that is minimised (`hidden`) or shown, running `f`.
|
||||
fn frame(ctx: &egui::Context, hidden: bool, f: impl FnMut(&egui::Context)) {
|
||||
let mut raw = egui::RawInput::default();
|
||||
let info = egui::ViewportInfo { minimized: Some(hidden), occluded: Some(false), ..Default::default() };
|
||||
raw.viewports.insert(egui::ViewportId::ROOT, info);
|
||||
// No painter takes the frame's textures here.
|
||||
ctx.run_pass(raw, f).textures_delta.clear();
|
||||
}
|
||||
|
||||
/// A frame is asked of egui 150 ms after its request at the earliest and on its third frame;
|
||||
/// a view that keeps moving is taken as it is 500 ms after that.
|
||||
#[test]
|
||||
fn a_frame_is_taken_once_the_view_settles() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut shots = Shots::new(NO_FRAME);
|
||||
let (reply, _answer) = channel();
|
||||
shots.request(&ctx, None, reply.clone(), None, 0.0);
|
||||
shots.issue(&ctx, true, 200.0);
|
||||
shots.issue(&ctx, true, 200.0);
|
||||
assert_eq!(shots.queued.len(), 1, "two frames drawn");
|
||||
shots.issue(&ctx, true, 200.0);
|
||||
assert!(shots.queued.is_empty(), "the third frame is taken");
|
||||
|
||||
shots.request(&ctx, None, reply.clone(), None, 0.0);
|
||||
for _ in 0..3 {
|
||||
shots.issue(&ctx, true, 149.0);
|
||||
}
|
||||
assert_eq!(shots.queued.len(), 1, "too soon");
|
||||
shots.issue(&ctx, true, 150.0);
|
||||
assert!(shots.queued.is_empty());
|
||||
|
||||
shots.request(&ctx, None, reply, None, 0.0);
|
||||
for _ in 0..3 {
|
||||
shots.issue(&ctx, false, 649.0);
|
||||
}
|
||||
assert_eq!(shots.queued.len(), 1, "still moving");
|
||||
shots.issue(&ctx, false, 650.0);
|
||||
assert!(shots.queued.is_empty(), "taken as it is");
|
||||
assert_eq!(shots.pending.len(), 3, "every request still waits for its frame");
|
||||
}
|
||||
|
||||
/// A hidden window refuses a request at once, and answers one that waits when it hides.
|
||||
#[test]
|
||||
fn a_hidden_window_answers_at_once() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut shots = Shots::new(NO_FRAME);
|
||||
let (reply, answer) = channel();
|
||||
frame(&ctx, true, |ctx| shots.request(ctx, None, reply.clone(), None, 0.0));
|
||||
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": false, "hidden": true, "error": NO_FRAME})));
|
||||
assert_eq!((shots.token, shots.queued.len(), shots.pending.len()), (0, 0, 0));
|
||||
|
||||
frame(&ctx, false, |ctx| shots.request(ctx, None, reply.clone(), None, 0.0));
|
||||
frame(&ctx, false, |ctx| {
|
||||
shots.collect(ctx, 1.0, false, |_, _| Value::Null);
|
||||
});
|
||||
assert!(answer.try_recv().is_err(), "shown: still waiting");
|
||||
frame(&ctx, true, |ctx| {
|
||||
shots.collect(ctx, 2.0, false, |_, _| Value::Null);
|
||||
});
|
||||
assert_eq!(answer.try_recv().ok(), Some(json!({"ok": false, "error": NO_FRAME})));
|
||||
assert!(shots.pending.is_empty());
|
||||
}
|
||||
|
||||
/// With nothing waiting, the frames that came are dropped, not kept for a later request.
|
||||
#[test]
|
||||
fn frames_nobody_waits_for_are_dropped() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut shots = Shots::new(NO_FRAME);
|
||||
let _ = shots.tx.send((5, pixel_image()));
|
||||
assert!(shots.collect(&ctx, 0.0, false, |_, _| Value::Null).is_empty());
|
||||
assert!(shots.collect(&ctx, 1.0, true, |_, _| Value::Null).is_empty(), "the frame was dropped");
|
||||
}
|
||||
|
||||
/// The saved bytes are the format the path names.
|
||||
#[test]
|
||||
fn a_png_path_gets_a_png() {
|
||||
let bytes = encode(&pixel_image(), "shot.png").unwrap();
|
||||
assert!(bytes.starts_with(b"\x89PNG"), "{:?}", bytes.get(..8));
|
||||
assert!(encode(&pixel_image(), "shot.nope").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! The window's own control requests over `egui::Context`, which any app answers the same way.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// The window is minimised or fully covered (another macOS Space), so it presents no frames
|
||||
/// (#1677). Unknown counts as shown: the screenshot deadline still ends a wait for a frame that
|
||||
/// never comes.
|
||||
pub fn hidden(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|i| i.viewport().visible()) == Some(false)
|
||||
}
|
||||
|
||||
/// `ui.resize {width, height}`: the window's inner size in points, kept within 320×240 and
|
||||
/// 8192×8192.
|
||||
pub fn resize(ctx: &egui::Context, params: &Value) -> Result<Value, String> {
|
||||
let f = |k: &str| params.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
|
||||
let (Some(w), Some(h)) = (f("width"), f("height")) else { return Err("missing width/height".into()) };
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32)));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "solidcraft-control"
|
||||
description = "The control channel any app may share: token files, the loopback server, and its requests; no UI toolkit"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# The loopback server is for desktop apps; the web app is driven through the page instead.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# The per-launch control token: 32 random bytes from the operating system (MIT OR Apache-2.0).
|
||||
getrandom = "0.3"
|
||||
@@ -0,0 +1,23 @@
|
||||
# solidcraft-control
|
||||
|
||||
The control channel any app may share (docs/control-protocol.md). An app that listens for
|
||||
control makes a random token per launch, writes it to a file only the user can read and refuses
|
||||
every connection whose first request doesn't carry it; a client finds the token the same way.
|
||||
Which app the files belong to, its environment, its name in messages and its default port come
|
||||
from an `App` value the app defines. No app types and no engine.
|
||||
|
||||
## API
|
||||
|
||||
| Item | What it does |
|
||||
|---|---|
|
||||
| `auth::App` | An app's identity: `name`, `data_dir`, `env` (its prefixed variables), `utc`, `default_port` |
|
||||
| `App::token_path(port)`, `App::current_port_path()` | `<data folder>/control/<port>.token` and the discovery file (`CONTROL_DIR` replaces the folder) |
|
||||
| `App::write_token_file(path, token)` | Write a token readable only by the user, with its `<port>.pid` file beside it |
|
||||
| `App::write_current_port`, `App::read_current_port`, `App::remove_current_port` | The discovery file of the most recently started app |
|
||||
| `App::env_token()`, `App::client_token(port, explicit)` | The token a client sends: explicit, `CONTROL_TOKEN`, else the token file |
|
||||
| `auth::ClientToken::explain_error(error)` | A refusal with the token file named and whether it is stale; never the token |
|
||||
| `auth::plan`, `auth::Listen`, `auth::bind_with_fallback`, `auth::PORT_TRIES` | Where a launch listens; a loopback listener on the first free port |
|
||||
| `auth::tokens_match`, `auth::remove_token_file`, `auth::TokenFile`, `auth::pid_path`, `auth::MIN_TOKEN_LEN` | Constant-time compare and token file housekeeping |
|
||||
| `Effect` | What a request still running does when it finishes: reads only, writes a file, or changes; `when_done(document)` says it |
|
||||
| `server::Hub`, `server::Protocol`, `server::Summary`, `server::Waits`, `server::new_token` | The loopback JSON-lines server behind the token (desktop only); the app says what its requests mean |
|
||||
| `ControlRequest`, `ControlResponse`, `DocumentWatch`, `DOCUMENT_WATCH` | A request as the window gets it; the document id and revision subscribed connections wait on |
|
||||
@@ -0,0 +1,665 @@
|
||||
//! The control channel's per-launch token (docs/control-protocol.md). An app listening for
|
||||
//! control makes a random token, writes it to a file only the user can read
|
||||
//! ([`App::token_path`]) and refuses every connection whose first request doesn't carry it, so
|
||||
//! other users on the same machine (loopback is shared, on Windows across remote-desktop sessions
|
||||
//! too) can't drive the app. Clients find it with [`App::client_token`]. Next to it, `<port>.pid`
|
||||
//! names the process that wrote it, so a client refused with a leftover token can say the file is
|
||||
//! stale.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io::{Error, ErrorKind, Read, Result, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Shortest token accepted from the app's `CONTROL_TOKEN` variable (hex characters).
|
||||
pub const MIN_TOKEN_LEN: usize = 32;
|
||||
|
||||
/// Ports tried from the app's default port upward when it is taken (a second window, another
|
||||
/// program).
|
||||
pub const PORT_TRIES: u16 = 16;
|
||||
|
||||
/// Which app a control channel belongs to: where its token files live, its environment and its
|
||||
/// name in messages.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct App {
|
||||
/// The app's name in messages ("restart SolidCraft").
|
||||
pub name: &'static str,
|
||||
/// The per-user data folder; the token files go in its `control` folder.
|
||||
pub data_dir: fn() -> Option<PathBuf>,
|
||||
/// The app's environment variable `name` (`CONTROL_DIR`, `CONTROL_TOKEN`), with the app's prefix.
|
||||
pub env: fn(&str) -> Option<OsString>,
|
||||
/// A time in seconds since the Unix epoch, as the app writes UTC times in messages.
|
||||
pub utc: fn(u64) -> String,
|
||||
/// The port the control channel tries first when the launch gives none. Below the ephemeral
|
||||
/// ranges of Linux (32768+) and Windows/macOS (49152+), so the system never hands it to an
|
||||
/// outgoing connection.
|
||||
pub default_port: u16,
|
||||
}
|
||||
|
||||
/// Where the control channel listens, from the launch's `--control`/environment port and the app's
|
||||
/// allow-agents setting.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Listen {
|
||||
/// Exactly this port (`--control PORT`, the app's `CONTROL_PORT`); no fallback.
|
||||
Exact(u16),
|
||||
/// The app's [`App::default_port`], or the next free one of [`PORT_TRIES`].
|
||||
Default,
|
||||
}
|
||||
|
||||
/// What a launch listens on. An explicit port wins over the app's allow-agents setting
|
||||
/// (`enabled`), so a scripted launch works with the setting off; with the setting off and no port,
|
||||
/// nothing listens.
|
||||
pub fn plan(explicit: Option<u16>, enabled: bool) -> Option<Listen> {
|
||||
match explicit {
|
||||
Some(port) => Some(Listen::Exact(port)),
|
||||
None if enabled => Some(Listen::Default),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind a loopback-only listener on `first`, or on the next free port when it is taken, up to
|
||||
/// `tries` ports in all.
|
||||
pub fn bind_with_fallback(first: u16, tries: u16) -> Result<std::net::TcpListener> {
|
||||
let mut last = Error::new(ErrorKind::AddrInUse, "no port to try");
|
||||
for port in (first..=u16::MAX).take(usize::from(tries.max(1))) {
|
||||
match std::net::TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => return Ok(l),
|
||||
// In use; Windows reports a port another program holds exclusively as denied.
|
||||
Err(e) if matches!(e.kind(), ErrorKind::AddrInUse | ErrorKind::PermissionDenied) => last = e,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// The folder of the token files: `<data folder>/control` (the app's `CONTROL_DIR` replaces it).
|
||||
fn control_dir(&self) -> Option<PathBuf> {
|
||||
match self.custom_control_dir() {
|
||||
Some(d) => Some(PathBuf::from(d)),
|
||||
None => Some((self.data_dir)()?.join("control")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder the app's `CONTROL_DIR` names in place of the default one.
|
||||
fn custom_control_dir(&self) -> Option<OsString> {
|
||||
(self.env)("CONTROL_DIR").filter(|d| !d.is_empty())
|
||||
}
|
||||
|
||||
/// The token file of the app listening on `port`: `<data folder>/control/<port>.token`
|
||||
/// (the app's `CONTROL_DIR` replaces the folder).
|
||||
pub fn token_path(&self, port: u16) -> Option<PathBuf> {
|
||||
Some(self.control_dir()?.join(format!("{port}.token")))
|
||||
}
|
||||
|
||||
/// The discovery file: the port of the most recently started app, so a client with no port
|
||||
/// finds it. It holds only the port; the token stays in its own file.
|
||||
pub fn current_port_path(&self) -> Option<PathBuf> {
|
||||
Some(self.control_dir()?.join("current.port"))
|
||||
}
|
||||
|
||||
/// Record `port` as the running app's, readable only by the user like the token files.
|
||||
pub fn write_current_port(&self, port: u16) -> Result<PathBuf> {
|
||||
let path = self.current_port_path().ok_or_else(|| Error::new(ErrorKind::NotFound, "no data folder"))?;
|
||||
self.write_token_file(&path, &port.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// The port the discovery file names, if there is one.
|
||||
pub fn read_current_port(&self) -> Option<u16> {
|
||||
parse_port(&std::fs::read_to_string(self.current_port_path()?).ok().filter(|t| t.len() <= 64)?)
|
||||
}
|
||||
|
||||
/// Remove the discovery file (and its process file) at exit, unless a later app has taken it
|
||||
/// over: it names another port, or another launch now holds this port's token file. Under
|
||||
/// Flatpak every instance has its own loopback, so two windows can listen on the same port
|
||||
/// number and write the same file names; the one closing first must leave the other's (#8794).
|
||||
/// Run it after [`remove_token_file`], which takes this launch's own token file away first.
|
||||
pub fn remove_current_port(&self, port: u16, token: &str) {
|
||||
if let (Some(current), Some(file)) = (self.current_port_path(), self.token_path(port)) {
|
||||
remove_discovery(¤t, &file, port, token);
|
||||
}
|
||||
}
|
||||
|
||||
/// A token set on purpose with the app's `CONTROL_TOKEN` (test harnesses, sandboxed packages
|
||||
/// whose data folder the client can't see), if it is long enough.
|
||||
pub fn env_token(&self) -> Option<String> {
|
||||
(self.env)("CONTROL_TOKEN").and_then(|v| v.into_string().ok()).map(|t| t.trim().to_string()).filter(|t| t.len() >= MIN_TOKEN_LEN)
|
||||
}
|
||||
|
||||
/// The token a client sends to the app on `port`: `explicit` (`--token`), else the app's
|
||||
/// `CONTROL_TOKEN`, else the app's token file.
|
||||
pub fn client_token(&self, port: u16, explicit: Option<&str>) -> Option<ClientToken> {
|
||||
if let Some(t) = explicit.map(str::trim).filter(|t| !t.is_empty()) {
|
||||
return Some(ClientToken { token: t.to_string(), file: None, app: *self });
|
||||
}
|
||||
if let Some(t) = self.env_token() {
|
||||
return Some(ClientToken { token: t, file: None, app: *self });
|
||||
}
|
||||
// Read directly, not through any agent's file scope: the app's own file.
|
||||
read_token_file(&self.token_path(port)?, *self)
|
||||
}
|
||||
|
||||
/// Write `token` to `path`, readable and writable only by the user: the folder is made `0700`
|
||||
/// and the file `0600` on Unix; on Windows the file's inherited permissions are replaced by one
|
||||
/// entry for the user (`icacls`) before the token goes in; when that fails, the still empty file
|
||||
/// is removed and no token is written. A file left by an earlier launch is replaced. The process
|
||||
/// file beside it ([`pid_path`]) records this process, best effort.
|
||||
pub fn write_token_file(&self, path: &Path, token: &str) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
let restrict = |p: &Path| restrict_to_user(p, self.custom_control_dir().is_some());
|
||||
#[cfg(not(windows))]
|
||||
let restrict = |_: &Path| Ok(());
|
||||
write_token_file_with(path, token, restrict)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_port(text: &str) -> Option<u16> {
|
||||
text.trim().parse::<u16>().ok().filter(|p| *p != 0)
|
||||
}
|
||||
|
||||
/// [`App::remove_current_port`] on given files: `current` is the discovery file, `file` the token
|
||||
/// file of `port`.
|
||||
fn remove_discovery(current: &Path, file: &Path, port: u16, token: &str) {
|
||||
let named = std::fs::read_to_string(current).ok().filter(|t| t.len() <= 64).and_then(|t| parse_port(&t));
|
||||
if named != Some(port) || held_by_another(file, token) {
|
||||
return;
|
||||
}
|
||||
let _ = std::fs::remove_file(current);
|
||||
let _ = std::fs::remove_file(pid_path(current));
|
||||
}
|
||||
|
||||
/// A client token and, when it came from a token file, where that file is.
|
||||
pub struct ClientToken {
|
||||
pub token: String,
|
||||
pub file: Option<TokenSource>,
|
||||
/// The app it is for, named in an explanation.
|
||||
pub app: App,
|
||||
}
|
||||
|
||||
/// The token file a client read: its path, when it was written and the process that wrote it.
|
||||
pub struct TokenSource {
|
||||
pub path: PathBuf,
|
||||
pub modified: Option<SystemTime>,
|
||||
/// From `<port>.pid`: the writer's process id and, on Linux, its start time.
|
||||
pub writer: Option<(u32, Option<u64>)>,
|
||||
}
|
||||
|
||||
impl ClientToken {
|
||||
/// The app's `error`, and for a refused token read from a file, which file it was, when it
|
||||
/// was written and whether the instance that wrote it has exited. Never the token itself.
|
||||
pub fn explain_error(&self, error: &str) -> String {
|
||||
let Some(src) = &self.file else { return error.to_string() };
|
||||
if !error.starts_with("unauthorized") {
|
||||
return error.to_string();
|
||||
}
|
||||
let written = src
|
||||
.modified
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|t| (self.app.utc)(t.as_secs()))
|
||||
.unwrap_or_else(|| "at an unknown time".into());
|
||||
let age = src.modified.and_then(|t| t.elapsed().ok()).map(|d| format!(" ({} s ago)", d.as_secs())).unwrap_or_default();
|
||||
let path = src.path.display();
|
||||
let name = self.app.name;
|
||||
match src.writer.filter(|(pid, start)| writer_exited(*pid, *start)) {
|
||||
Some((pid, _)) => format!(
|
||||
"{error}; stale token file from an instance that exited (process {pid}): {path}, written {written}{age}; delete that file or restart {name}"
|
||||
),
|
||||
None => format!("{error}; token from {path}, written {written}{age}; if {name} was force-quit, restart it or delete that file"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Files bigger than this are not token files.
|
||||
const MAX_TOKEN_FILE: u64 = 4096;
|
||||
|
||||
fn read_token_file(path: &Path, app: App) -> Option<ClientToken> {
|
||||
let f = std::fs::File::open(path).ok()?;
|
||||
let modified = f.metadata().ok().and_then(|m| m.modified().ok());
|
||||
let mut text = String::new();
|
||||
f.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
|
||||
if text.len() as u64 > MAX_TOKEN_FILE {
|
||||
return None;
|
||||
}
|
||||
let token = text.trim();
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
let writer = read_pid_file(&pid_path(&path));
|
||||
Some(ClientToken { token: token.to_string(), file: Some(TokenSource { path, modified, writer }), app })
|
||||
}
|
||||
|
||||
/// The process file beside the token file `path`: `7878.token` → `7878.pid`.
|
||||
pub fn pid_path(path: &Path) -> PathBuf {
|
||||
path.with_extension("pid")
|
||||
}
|
||||
|
||||
/// `<pid> [<start time>]`; anything else (or no file) is no information.
|
||||
fn read_pid_file(path: &Path) -> Option<(u32, Option<u64>)> {
|
||||
let mut text = String::new();
|
||||
std::fs::File::open(path).ok()?.take(256).read_to_string(&mut text).ok()?;
|
||||
let mut fields = text.split_whitespace();
|
||||
let pid = fields.next()?.parse::<u32>().ok().filter(|&pid| pid > 0)?;
|
||||
Some((pid, fields.next().and_then(|s| s.parse().ok())))
|
||||
}
|
||||
|
||||
/// Whether the process that wrote a token file is known to be gone: not running, or its id now
|
||||
/// belongs to a process that started at another time. Unknown counts as running.
|
||||
fn writer_exited(pid: u32, start: Option<u64>) -> bool {
|
||||
process_running(pid) == Some(false) || start.zip(process_start(pid)).is_some_and(|(saved, now)| saved != now)
|
||||
}
|
||||
|
||||
/// Whether process `pid` runs; `None` when that can't be found out.
|
||||
fn process_running(pid: u32) -> Option<bool> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if !Path::new("/proc/self").exists() {
|
||||
return None;
|
||||
}
|
||||
match std::fs::metadata(format!("/proc/{pid}")) {
|
||||
Ok(_) => Some(true),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Some(false),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
{
|
||||
let output = std::process::Command::new("ps").args(["-p", &pid.to_string(), "-o", "pid="]).output().ok()?;
|
||||
if output.status.success() {
|
||||
return Some(true);
|
||||
}
|
||||
(output.status.code() == Some(1) && output.stdout.iter().all(u8::is_ascii_whitespace)).then_some(false)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
let output = std::process::Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
|
||||
.creation_flags(0x0800_0000)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() || output.stdout.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// `"image","pid",…` per process; with no match, a message line without a pid.
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
Some(text.lines().any(|line| line.split("\",\"").nth(1).and_then(|s| s.trim_matches('"').parse::<u32>().ok()) == Some(pid)))
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = pid;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux: the process's start time (clock ticks after boot), which tells a reused id apart.
|
||||
fn process_start(pid: u32) -> Option<u64> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mut stat = String::new();
|
||||
std::fs::File::open(format!("/proc/{pid}/stat")).ok()?.take(4096).read_to_string(&mut stat).ok()?;
|
||||
stat.rsplit_once(')')?.1.split_whitespace().nth(19)?.parse().ok()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = pid;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `a == b`, taking the same time wherever they differ.
|
||||
pub fn tokens_match(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// [`App::write_token_file`], with `restrict` limiting the new file to the user (Windows).
|
||||
fn write_token_file_with(path: &Path, token: &str, restrict: impl FnOnce(&Path) -> Result<()>) -> Result<()> {
|
||||
let dir = path.parent().ok_or_else(|| Error::new(ErrorKind::InvalidInput, "no folder"))?;
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let _ = std::fs::remove_file(path);
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let opened = opts.open(path)?;
|
||||
// On a refusal the file goes with the closure, so it is closed before it is removed.
|
||||
let mut f = match restrict(path).map(|()| opened) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(path);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
f.write_all(token.as_bytes())?;
|
||||
f.write_all(b"\n")?;
|
||||
f.sync_all()?;
|
||||
let pid = std::process::id();
|
||||
let line = process_start(pid).map_or_else(|| format!("{pid}\n"), |start| format!("{pid} {start}\n"));
|
||||
let _ = std::fs::write(pid_path(path), line);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a token file this launch wrote, and its process file, while it still holds `token`.
|
||||
/// Another launch may have written the same file since (a second window on the same port,
|
||||
/// #8794); its file stays. The process id can't tell the two apart, as every Flatpak instance
|
||||
/// has the same one, but each launch's token is its own.
|
||||
pub fn remove_token_file(path: &Path, token: &str) {
|
||||
if read_token(path).is_some_and(|held| tokens_match(&held, token.trim())) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_file(pid_path(path));
|
||||
}
|
||||
}
|
||||
|
||||
/// The token a token file holds, trimmed; `None` when there is no readable token file.
|
||||
fn read_token(path: &Path) -> Option<String> {
|
||||
let mut text = String::new();
|
||||
std::fs::File::open(path).ok()?.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
|
||||
let token = text.trim();
|
||||
(text.len() as u64 <= MAX_TOKEN_FILE && !token.is_empty()).then(|| token.to_string())
|
||||
}
|
||||
|
||||
/// Whether the token file at `path` holds a token other than `token`: another launch's.
|
||||
fn held_by_another(path: &Path, token: &str) -> bool {
|
||||
read_token(path).is_some_and(|held| !tokens_match(&held, token.trim()))
|
||||
}
|
||||
|
||||
/// How long `icacls` may take (#6145). It finishes at once; on some computers the new process
|
||||
/// is left suspended (security software holding it), and waiting on it without a limit froze
|
||||
/// the app.
|
||||
#[cfg(windows)]
|
||||
const ICACLS_LIMIT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// How a run of `icacls` ended.
|
||||
#[cfg(any(windows, test))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum IcaclsEnd {
|
||||
Succeeded,
|
||||
Failed,
|
||||
/// It didn't finish within [`ICACLS_LIMIT`] and was ended.
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// Whether a token may go into a file after `icacls` ended this way. `custom_folder`: the file is
|
||||
/// in the folder the app's `CONTROL_DIR` names. A timeout is accepted only in the default folder,
|
||||
/// the user's own local app data, which other users can't read by default; nothing is known about
|
||||
/// the permissions of any other folder.
|
||||
#[cfg(any(windows, test))]
|
||||
fn restriction_result(end: IcaclsEnd, custom_folder: bool, path: &Path) -> Result<()> {
|
||||
match end {
|
||||
IcaclsEnd::Succeeded => Ok(()),
|
||||
IcaclsEnd::TimedOut if !custom_folder => Ok(()),
|
||||
IcaclsEnd::TimedOut => Err(Error::new(ErrorKind::TimedOut, format!("icacls did not finish in time to restrict {}", path.display()))),
|
||||
IcaclsEnd::Failed => Err(Error::other(format!("icacls failed to restrict {}", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: drop inherited entries and grant only the current user full access. An `icacls`
|
||||
/// that doesn't finish in time is ended, and [`restriction_result`] decides what that means.
|
||||
#[cfg(windows)]
|
||||
fn restrict_to_user(path: &Path, custom_folder: bool) -> Result<()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let user = std::env::var("USERNAME").map_err(|_| Error::other("USERNAME is not set"))?;
|
||||
let domain = std::env::var("USERDOMAIN").ok().filter(|d| !d.is_empty());
|
||||
let who = domain.map_or(user.clone(), |d| format!("{d}\\{user}"));
|
||||
let child = std::process::Command::new("icacls")
|
||||
.arg(path)
|
||||
.args(["/inheritance:r", "/grant:r", &format!("{who}:F")])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.creation_flags(0x0800_0000)
|
||||
.spawn()?;
|
||||
let end = match wait_within(child, ICACLS_LIMIT)? {
|
||||
Some(status) if status.success() => IcaclsEnd::Succeeded,
|
||||
Some(_) => IcaclsEnd::Failed,
|
||||
None => IcaclsEnd::TimedOut,
|
||||
};
|
||||
restriction_result(end, custom_folder, path)
|
||||
}
|
||||
|
||||
/// Wait for `child` at most `limit`: its exit status, or `None` (and the child ended) when it
|
||||
/// hasn't finished by then.
|
||||
#[cfg(any(windows, test))]
|
||||
fn wait_within(mut child: std::process::Child, limit: std::time::Duration) -> Result<Option<std::process::ExitStatus>> {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
return Ok(Some(status));
|
||||
}
|
||||
if start.elapsed() >= limit {
|
||||
// Not waited for afterwards: a suspended process may not end at once.
|
||||
let _ = child.kill();
|
||||
return Ok(None);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the token file (path, token) when the app ends, while it still holds that token.
|
||||
pub struct TokenFile(pub PathBuf, pub String);
|
||||
|
||||
impl Drop for TokenFile {
|
||||
fn drop(&mut self) {
|
||||
remove_token_file(&self.0, &self.1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An app with no data folder and no environment, whose times read `t<seconds>`.
|
||||
const TEST: App = App { name: "TestCraft", data_dir: || None, env: |_| None, utc: |secs| format!("t{secs}"), default_port: 27878 };
|
||||
|
||||
/// #6145: a child that doesn't finish in time is given up on, not waited for forever.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_slow_child_is_waited_for_only_so_long() {
|
||||
use std::time::{Duration, Instant};
|
||||
let start = Instant::now();
|
||||
let slow = std::process::Command::new("sleep").arg("30").spawn().unwrap();
|
||||
assert!(wait_within(slow, Duration::from_millis(200)).unwrap().is_none());
|
||||
assert!(start.elapsed() < Duration::from_secs(5), "{:?}", start.elapsed());
|
||||
let quick = std::process::Command::new("true").spawn().unwrap();
|
||||
assert!(wait_within(quick, Duration::from_secs(5)).unwrap().is_some_and(|s| s.success()));
|
||||
let failing = std::process::Command::new("false").spawn().unwrap();
|
||||
assert!(wait_within(failing, Duration::from_secs(5)).unwrap().is_some_and(|s| !s.success()));
|
||||
}
|
||||
|
||||
/// #6145's freeze fix stays in the default folder, the user's own; a folder picked with
|
||||
/// the app's `CONTROL_DIR` gets no token unless the file was really restricted.
|
||||
#[test]
|
||||
fn only_the_default_folder_accepts_an_icacls_that_timed_out() {
|
||||
let path = Path::new("control/7878.token");
|
||||
for custom in [false, true] {
|
||||
assert!(restriction_result(IcaclsEnd::Succeeded, custom, path).is_ok());
|
||||
assert!(restriction_result(IcaclsEnd::Failed, custom, path).is_err());
|
||||
}
|
||||
assert!(restriction_result(IcaclsEnd::TimedOut, false, path).is_ok());
|
||||
let e = restriction_result(IcaclsEnd::TimedOut, true, path).unwrap_err();
|
||||
assert_eq!(e.kind(), ErrorKind::TimedOut);
|
||||
assert!(e.to_string().contains("7878.token"), "{e}");
|
||||
}
|
||||
|
||||
/// A file that couldn't be restricted is removed, empty and with no process file beside it, and
|
||||
/// the error goes to the caller. A restricted one gets its token.
|
||||
#[test]
|
||||
fn a_token_file_that_could_not_be_restricted_is_removed() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-restrict-{}", std::process::id()));
|
||||
let path = dir.join("7879.token");
|
||||
let e = write_token_file_with(&path, "secret-token", |p| {
|
||||
assert_eq!(std::fs::read(p).unwrap(), b"", "restricted before the token goes in");
|
||||
restriction_result(IcaclsEnd::TimedOut, true, p)
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(e.kind(), ErrorKind::TimedOut);
|
||||
assert!(!path.exists() && !pid_path(&path).exists());
|
||||
write_token_file_with(&path, "secret-token", |p| restriction_result(IcaclsEnd::TimedOut, false, p)).unwrap();
|
||||
assert_eq!(read_token(&path).as_deref(), Some("secret-token"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_compare_whole() {
|
||||
assert!(tokens_match("abc123", "abc123"));
|
||||
assert!(!tokens_match("abc123", "abc124"));
|
||||
assert!(!tokens_match("abc123", "abc12"));
|
||||
assert!(!tokens_match("", "a"));
|
||||
}
|
||||
|
||||
/// The preference starts the listener by default, off it doesn't, and a port given on the
|
||||
/// command line or in the environment wins over it either way.
|
||||
#[test]
|
||||
fn the_preference_decides_unless_a_port_is_given() {
|
||||
assert_eq!(plan(None, true), Some(Listen::Default));
|
||||
assert_eq!(plan(None, false), None);
|
||||
assert_eq!(plan(Some(7878), false), Some(Listen::Exact(7878)));
|
||||
assert_eq!(plan(Some(7878), true), Some(Listen::Exact(7878)));
|
||||
}
|
||||
|
||||
/// A taken port falls through to the next free one, on loopback only; a run of taken ports
|
||||
/// is an error, not a hang.
|
||||
#[test]
|
||||
fn a_taken_port_falls_back_to_the_next_free_one() {
|
||||
let first = bind_with_fallback(TEST.default_port, PORT_TRIES).unwrap();
|
||||
let a = first.local_addr().unwrap();
|
||||
assert!(a.ip().is_loopback() && (TEST.default_port..TEST.default_port + PORT_TRIES).contains(&a.port()), "{a}");
|
||||
let second = bind_with_fallback(a.port(), PORT_TRIES).unwrap();
|
||||
assert!(second.local_addr().unwrap().port() > a.port());
|
||||
assert!(bind_with_fallback(a.port(), 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_discovery_file_holds_a_port() {
|
||||
assert_eq!(parse_port("27879\n"), Some(27879));
|
||||
assert_eq!(parse_port("0"), None);
|
||||
assert_eq!(parse_port("70000"), None);
|
||||
assert_eq!(parse_port("nope"), None);
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-current-port-{}", std::process::id()));
|
||||
let path = dir.join("current.port");
|
||||
TEST.write_token_file(&path, "27881").unwrap();
|
||||
assert_eq!(parse_port(&std::fs::read_to_string(&path).unwrap()), Some(27881));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A one-line token file (and one with no process file) still reads.
|
||||
#[test]
|
||||
fn a_token_file_reads_with_and_without_its_process_file() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-read-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("7879.token");
|
||||
std::fs::write(&path, "legacy-token\n").unwrap();
|
||||
let client = read_token_file(&path, TEST).unwrap();
|
||||
assert_eq!(client.token, "legacy-token");
|
||||
let src = client.file.as_ref().unwrap();
|
||||
assert_eq!(src.path, std::fs::canonicalize(&path).unwrap());
|
||||
assert!(src.modified.is_some() && src.writer.is_none());
|
||||
std::fs::write(pid_path(&path), "not a pid\n").unwrap();
|
||||
assert!(read_token_file(&path, TEST).unwrap().file.unwrap().writer.is_none());
|
||||
std::fs::write(&path, "x".repeat(4097)).unwrap();
|
||||
assert!(read_token_file(&path, TEST).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A refused token's explanation names the app and writes the time as the app does.
|
||||
#[test]
|
||||
fn an_explanation_names_the_app() {
|
||||
let file = Some(TokenSource { path: PathBuf::from("7878.token"), modified: Some(UNIX_EPOCH), writer: None });
|
||||
let client = ClientToken { token: "secret-token".into(), file, app: TEST };
|
||||
let error = client.explain_error("unauthorized");
|
||||
assert!(error.contains("written t0 (") && error.contains("if TestCraft was force-quit"), "{error}");
|
||||
assert!(!error.contains("secret-token"), "{error}");
|
||||
let explicit = TEST.client_token(0, Some("explicit-token")).unwrap();
|
||||
assert!(explicit.token == "explicit-token" && explicit.file.is_none());
|
||||
assert_eq!(explicit.explain_error("unauthorized"), "unauthorized");
|
||||
}
|
||||
|
||||
/// A process file naming a process that has exited marks the token file stale.
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn a_token_file_from_an_exited_process_is_stale() {
|
||||
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "auth::tests::tokens_compare_whole"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id();
|
||||
assert!(child.wait().unwrap().success());
|
||||
let source = |writer| Some(TokenSource { path: PathBuf::from("7878.token"), modified: None, writer });
|
||||
let stale = ClientToken { token: "secret-token".into(), file: source(Some((pid, None))), app: TEST };
|
||||
let error = stale.explain_error("unauthorized");
|
||||
assert!(error.contains(&format!("stale token file from an instance that exited (process {pid}): 7878.token")), "{error}");
|
||||
assert!(!error.contains("secret-token"));
|
||||
let live = ClientToken { token: "t".into(), file: source(Some((std::process::id(), process_start(std::process::id())))), app: TEST };
|
||||
assert!(!live.explain_error("unauthorized").contains("stale"));
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let reused = process_start(std::process::id()).map(|s| s + 1);
|
||||
let reused = ClientToken { token: "t".into(), file: source(Some((std::process::id(), reused))), app: TEST };
|
||||
assert!(reused.explain_error("unauthorized").contains("stale token file"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The token file is the user's alone, and a client reads it back.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_token_file_is_private_to_the_user() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-auth-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("control/7878.token");
|
||||
std::fs::create_dir_all(dir.join("control")).unwrap();
|
||||
std::fs::write(&path, "stale").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
TEST.write_token_file(&path, "0123456789abcdef0123456789abcdef").unwrap();
|
||||
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&path), 0o600);
|
||||
assert_eq!(mode(path.parent().unwrap()), 0o700);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "0123456789abcdef0123456789abcdef");
|
||||
let writer = read_token_file(&path, TEST).unwrap().file.unwrap().writer;
|
||||
assert_eq!(writer.map(|(pid, _)| pid), Some(std::process::id()));
|
||||
drop(TokenFile(path.clone(), "0123456789abcdef0123456789abcdef".into()));
|
||||
assert!(!path.exists() && !pid_path(&path).exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #8794: under Flatpak two windows can listen on the same port number (each has its own
|
||||
/// loopback) and write the same files. The one closing first leaves the other's token, process
|
||||
/// and discovery files; the last one takes them all away, `current.pid` too.
|
||||
#[test]
|
||||
fn a_closing_launch_leaves_another_launchs_files() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-control-two-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (file, current) = (dir.join("27878.token"), dir.join("current.port"));
|
||||
let (first, second) = ("a".repeat(64), "b".repeat(64));
|
||||
// The first window, then the second on the same port, each writing both files.
|
||||
for token in [&first, &second] {
|
||||
TEST.write_token_file(&file, token).unwrap();
|
||||
TEST.write_token_file(¤t, "27878").unwrap();
|
||||
}
|
||||
// The first closes: the second's files stay.
|
||||
remove_token_file(&file, &first);
|
||||
remove_discovery(¤t, &file, 27878, &first);
|
||||
assert_eq!(read_token(&file).as_deref(), Some(second.as_str()));
|
||||
assert!(pid_path(&file).exists() && current.exists() && pid_path(¤t).exists());
|
||||
// Another port named in the discovery file is another app's too.
|
||||
remove_discovery(¤t, &file, 27879, &second);
|
||||
assert!(current.exists());
|
||||
// The second closes: nothing of either is left.
|
||||
remove_token_file(&file, &format!(" {second}\n"));
|
||||
remove_discovery(¤t, &file, 27878, &second);
|
||||
assert!(!file.exists() && !pid_path(&file).exists() && !current.exists() && !pid_path(¤t).exists());
|
||||
// With no token file left (removed by hand, or never written), the discovery file of the
|
||||
// port still goes; a missing file is no error.
|
||||
TEST.write_token_file(¤t, "27878").unwrap();
|
||||
remove_token_file(&file, &second);
|
||||
remove_discovery(¤t, &file, 27878, &second);
|
||||
assert!(!current.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! What a client is told about a control request that outlasts its wait (docs/control-protocol.md,
|
||||
//! #1618): the app's control server and a bridged client both say what the request still does when
|
||||
//! it finishes. Which requests do what is the app's to say.
|
||||
|
||||
/// What a request still running does when it finishes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Effect {
|
||||
/// It only reads (the document, the window or the command list): nothing changes.
|
||||
ReadsOnly,
|
||||
/// It writes a file (an export, a save, a render to a path); the document stays as it is.
|
||||
WritesFile,
|
||||
/// It changes the document (or may: anything not known to read or only write a file).
|
||||
Changes,
|
||||
}
|
||||
|
||||
impl Effect {
|
||||
/// What happens when the request finishes, for a message: "it only reads: nothing changes",
|
||||
/// "it was not cancelled and will still write the file when it finishes; the design does not
|
||||
/// change", … `document` is what the app calls its document ("design").
|
||||
pub fn when_done(self, document: &str) -> String {
|
||||
match self {
|
||||
Effect::ReadsOnly => "it only reads: nothing changes".into(),
|
||||
Effect::WritesFile => format!("it was not cancelled and will still write the file when it finishes; the {document} does not change"),
|
||||
Effect::Changes => "it was not cancelled and will still apply when it finishes".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! The control channel any app may share (docs/control-protocol.md): the per-launch token and its
|
||||
//! files ([`auth`]), the loopback server that admits a connection by the token and hands each
|
||||
//! request to the window ([`server`]), the request the window answers ([`ControlRequest`]), the
|
||||
//! document watch subscribed connections wait on ([`DocumentWatch`]), and what a request still running
|
||||
//! does when it finishes ([`Effect`]). It needs no UI toolkit; the window's own requests over egui
|
||||
//! (`ui.screenshot`, `ui.resize`) are `solidcraft-control-egui`.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod auth;
|
||||
mod effect;
|
||||
mod request;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod server;
|
||||
|
||||
pub use effect::Effect;
|
||||
pub use request::{ControlRequest, ControlResponse, DOCUMENT_WATCH, DocumentWatch, Subscriber, cancelled_chat_response, chat_turn_cancelled};
|
||||
@@ -0,0 +1,118 @@
|
||||
//! A control request as the window gets it, and the design watch subscribed connections wait on.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Condvar, Mutex, MutexGuard, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub type ControlResponse = Value;
|
||||
|
||||
pub struct ControlRequest {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
pub reply: Sender<ControlResponse>,
|
||||
/// Set once the window starts on it; until then it waits its turn (`control.running`, #3654).
|
||||
pub started: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
/// Chat requests already admitted by the gate may outlive a tab switch. This turn's
|
||||
/// validity follows them through queues, computation and cursor animation.
|
||||
pub chat_turn: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
impl ControlRequest {
|
||||
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
(Self { method: method.into(), params, reply: tx, started: Default::default(), chat_turn: None }, rx)
|
||||
}
|
||||
|
||||
/// Answer a request whose agent chat turn has ended, and say so: such a request is not run.
|
||||
pub fn refuse_cancelled_chat(&self) -> bool {
|
||||
if chat_turn_cancelled(self.chat_turn.as_deref()) {
|
||||
let _ = self.reply.send(cancelled_chat_response());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the agent chat turn a request belongs to has ended (`None`: it belongs to none).
|
||||
pub fn chat_turn_cancelled(turn: Option<&std::sync::atomic::AtomicBool>) -> bool {
|
||||
turn.is_some_and(|active| !active.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// The reply to a request whose agent chat turn has ended.
|
||||
pub fn cancelled_chat_response() -> Value {
|
||||
json!({"ok": false, "error": "not run: the agent chat turn ended or its document tab was left"})
|
||||
}
|
||||
|
||||
/// The active design's id and revision for control connections that sent `control.subscribe`
|
||||
/// (#6985): the window publishes them each frame while one is subscribed, and each such
|
||||
/// connection waits for them to move, then sends the app's change event (`Protocol::change_event`).
|
||||
#[derive(Default)]
|
||||
pub struct DocumentWatch {
|
||||
subscribers: AtomicUsize,
|
||||
now: Mutex<Option<(String, u64)>>,
|
||||
moved: Condvar,
|
||||
}
|
||||
|
||||
/// The app's [`DocumentWatch`].
|
||||
pub static DOCUMENT_WATCH: DocumentWatch = DocumentWatch::new();
|
||||
|
||||
impl DocumentWatch {
|
||||
pub const fn new() -> Self {
|
||||
DocumentWatch { subscribers: AtomicUsize::new(0), now: Mutex::new(None), moved: Condvar::new() }
|
||||
}
|
||||
|
||||
fn now(&self) -> MutexGuard<'_, Option<(String, u64)>> {
|
||||
self.now.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The design as last published (`None` before anything was).
|
||||
pub fn current(&self) -> Option<(String, u64)> {
|
||||
self.now().clone()
|
||||
}
|
||||
|
||||
/// The design as it is now; waiters wake only when it moved. Only the latest is kept, so
|
||||
/// changes made faster than a connection sends them go out as one.
|
||||
pub fn publish(&self, document_id: &str, revision: u64) {
|
||||
let mut now = self.now();
|
||||
if now.as_ref().is_some_and(|(id, r)| id == document_id && *r == revision) {
|
||||
return;
|
||||
}
|
||||
*now = Some((document_id.to_string(), revision));
|
||||
self.moved.notify_all();
|
||||
}
|
||||
|
||||
/// Whether a connection is subscribed: the window publishes only then.
|
||||
pub fn subscribed(&self) -> bool {
|
||||
self.subscribers.load(Ordering::Relaxed) > 0
|
||||
}
|
||||
|
||||
/// Count a subscribed connection until the returned guard is dropped.
|
||||
pub fn join(&'static self) -> Subscriber {
|
||||
self.subscribers.fetch_add(1, Ordering::Relaxed);
|
||||
Subscriber(self)
|
||||
}
|
||||
|
||||
/// Wait up to `wait` for the design to differ from `seen`, or for `stop`; the design then
|
||||
/// (`None` before anything was published).
|
||||
pub fn wait(&self, seen: &(String, u64), stop: &AtomicBool, wait: Duration) -> Option<(String, u64)> {
|
||||
let unmoved = |now: &mut Option<(String, u64)>| now.as_ref() == Some(seen) && !stop.load(Ordering::Relaxed);
|
||||
let (now, _) = self.moved.wait_timeout_while(self.now(), wait, unmoved).unwrap_or_else(PoisonError::into_inner);
|
||||
now.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A subscribed connection, counted by its [`DocumentWatch`] while this lives.
|
||||
pub struct Subscriber(&'static DocumentWatch);
|
||||
|
||||
impl Drop for Subscriber {
|
||||
fn drop(&mut self) {
|
||||
self.0.subscribers.fetch_sub(1, Ordering::Relaxed);
|
||||
// Under the lock, so a connection about to wait sees its `stop` set before it does.
|
||||
let _now = self.0.now();
|
||||
self.0.moved.notify_all();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
solidcore-record = { workspace = true }
|
||||
solidcraft-color = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-sketch = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
|
||||
@@ -154,7 +154,7 @@ impl VisualPreset {
|
||||
match self.recipe {
|
||||
Recipe::Opaque(r) => r.color,
|
||||
// A UI swatch of the transmission values, not rendered glass or absorption.
|
||||
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_geom::srgb::encode8),
|
||||
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_color::srgb::encode8),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -196,6 +196,45 @@ fn edge_pick<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<
|
||||
}
|
||||
}
|
||||
|
||||
/// The closest point on a polyline to `target`, projecting onto each segment rather than
|
||||
/// choosing only its vertices. This keeps an edge pick on the edge between tessellation points.
|
||||
fn closest_point_on_polyline(pts: &[Vec3], target: Vec3) -> Option<Vec3> {
|
||||
let mut best: Option<(f64, Vec3)> = None;
|
||||
for w in pts.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let ab = b - a;
|
||||
let len_sq = ab.dot(ab);
|
||||
let q = if len_sq < 1e-18 {
|
||||
a
|
||||
} else {
|
||||
let t = ((target - a).dot(ab) / len_sq).clamp(0.0, 1.0);
|
||||
a + ab * t
|
||||
};
|
||||
let d = target.dist(q);
|
||||
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
|
||||
best = Some((d, q));
|
||||
}
|
||||
}
|
||||
best.map(|(_, q)| q)
|
||||
}
|
||||
|
||||
/// The point on an edge nearest a pick location, projecting onto exact edge geometry
|
||||
/// (such as `kernel::edge_circle`) when available, else onto its drawn polyline segments.
|
||||
fn edge_point_at_pick(st: &ModelState, pick: Vec3, name: &Option<String>) -> Option<Vec3> {
|
||||
let (b, i, pts) = edge_pick(st, pick, name).ok()?;
|
||||
if let Some(c) = kernel::edge_circle(&b.body, i) {
|
||||
let v = pick - c.center;
|
||||
let v_perp = v - *c.axis * v.dot(*c.axis);
|
||||
if let Some(dir) = v_perp.normalized() {
|
||||
return Some(c.center + dir * c.radius);
|
||||
}
|
||||
}
|
||||
// Until the kernel can project onto any edge geometry (circles are exact above via
|
||||
// kernel::edge_circle), the fallback projects onto the drawn polyline. This is exact for
|
||||
// straight edges and approximates splines within mesh display tolerance.
|
||||
closest_point_on_polyline(&pts, pick)
|
||||
}
|
||||
|
||||
/// The circle a picked edge lies on, from its exact curve (`kernel::edge_circle`) when the drawn
|
||||
/// edge lies on it, else fitted to the drawn points: (centre, unit normal).
|
||||
fn edge_circle(b: &ModelBody, index: usize, pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
|
||||
@@ -743,7 +782,29 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
|
||||
PlaneRef::AxisPoint { axis, point: at } => {
|
||||
let (origin, dir) = line(ctx, axis)?;
|
||||
let dir = dir.normalized().ok_or_else(|| bad("the axis has no direction"))?;
|
||||
let delta = point(ctx, at)? - origin;
|
||||
let p = match point(ctx, at) {
|
||||
Ok(p) => {
|
||||
let delta = p - origin;
|
||||
let across = delta - dir * delta.dot(dir);
|
||||
if across.len().is_finite() && across.len() >= 1e-9 {
|
||||
p
|
||||
} else if let GeoRef::Edge { at: pick, name } = at.as_ref() {
|
||||
// When the centre of a circular edge lies on the axis, resolve to the picked point on the edge.
|
||||
edge_point_at_pick(ctx.st, *pick, name).unwrap_or(p)
|
||||
} else {
|
||||
p
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Non-circular edges are not converted to centres by `point`; resolve to the picked edge point.
|
||||
if let GeoRef::Edge { at: pick, name } = at.as_ref() {
|
||||
edge_point_at_pick(ctx.st, *pick, name).ok_or(e)?
|
||||
} else {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
let delta = p - origin;
|
||||
let across = delta - dir * delta.dot(dir);
|
||||
if !across.len().is_finite() || across.len() < 1e-9 {
|
||||
return Err(bad("the point is on the axis: pick a point off the axis to define a unique plane"));
|
||||
|
||||
+194
-71
@@ -143,7 +143,7 @@ impl ModelBody {
|
||||
|
||||
/// Face measurements for joint origins, at their original chord tolerance. Pose changes
|
||||
/// and scratch copies share them; a geometry edit makes a new body and cache.
|
||||
pub(crate) fn joint_faces(&self) -> Result<Arc<[kernel::FaceInfo]>> {
|
||||
pub fn joint_faces(&self) -> Result<Arc<[kernel::FaceInfo]>> {
|
||||
let measure = || {
|
||||
#[cfg(test)]
|
||||
cost_tests::count(|c| c.joint_faces += 1);
|
||||
@@ -1161,6 +1161,18 @@ pub struct Model {
|
||||
placement_inputs: Option<placement_reuse::Inputs>,
|
||||
/// All evaluated features use supported local inputs; computed with the existing owner index.
|
||||
placement_local: bool,
|
||||
/// A failed sketch shown while it is edited ([`Model::hold_failed_sketch`]).
|
||||
held: Option<HeldSketch>,
|
||||
}
|
||||
|
||||
/// A failed sketch built for its editor (#12085): `shown` is the final state `base` with that
|
||||
/// sketch in it. [`Model::state`] gives it only while the results still end in `base`, so the
|
||||
/// next evaluation, or anything else that changes the results, lets it go.
|
||||
#[derive(Clone, Debug)]
|
||||
struct HeldSketch {
|
||||
feature: u64,
|
||||
base: Arc<ModelState>,
|
||||
shown: Arc<ModelState>,
|
||||
}
|
||||
|
||||
/// Parameters a feature uses directly (inputs, sketch dimensions and the expressions of a
|
||||
@@ -1647,6 +1659,21 @@ impl PlacementReads {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bodies To / From Object's stored face names find, each where it is placed: evaluation
|
||||
/// finds a named face by its name ([`with_named_face_points`] fails a feature whose name finds
|
||||
/// none). Everything when a name is missing or empty, finds no one body, or `st` is `None`:
|
||||
/// the face is then found by its point, on whatever body lies there.
|
||||
fn object_faces(&mut self, st: Option<&ModelState>, names: Option<&[String]>) {
|
||||
let bodies: Option<Vec<&str>> = st.zip(names).and_then(|(st, names)| {
|
||||
let first_of_its_name = |b: &&ModelBody| st.body(&b.name).is_some_and(|first| std::ptr::eq(first, *b));
|
||||
names.iter().map(|n| crate::name_lookup::face_body(st, n).filter(first_of_its_name).map(|b| b.name.as_str())).collect()
|
||||
});
|
||||
match bodies {
|
||||
Some(bodies) => bodies.into_iter().for_each(|b| self.placed_body(st, b)),
|
||||
None => self.all = true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same target selection as `apply_traced`, expanded only over the timeline input.
|
||||
fn drawn_targets(&mut self, st: Option<&ModelState>, f: &Feature) {
|
||||
let Some(st) = st else {
|
||||
@@ -1826,11 +1853,22 @@ fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64
|
||||
}
|
||||
}
|
||||
}
|
||||
// The faces To / From Object names, in `face_points` order.
|
||||
let object_names = match &f.kind {
|
||||
FeatureKind::Extrude { extent, .. } => usize::from(extent.to.is_some()) + usize::from(extent.from.is_some()),
|
||||
FeatureKind::Revolve { to, .. } => usize::from(to.is_some()),
|
||||
_ => 0,
|
||||
};
|
||||
match &f.kind {
|
||||
// To / From Object finds its target where that target's component is placed (#928),
|
||||
// over every body.
|
||||
FeatureKind::Extrude { extent, .. } if extent.to.is_some() || extent.from.is_some() => reads.all = true,
|
||||
FeatureKind::Revolve { to: Some(_), .. } => reads.all = true,
|
||||
// To / From Object finds its target where the target's body is placed (#928): only that
|
||||
// body's placement, not every part's (#6325). Its tools meet their targets as other
|
||||
// joins and cuts do.
|
||||
kind if object_names > 0 => {
|
||||
reads.object_faces(st, f.face_names.get(..object_names));
|
||||
if drawn_tools_change_bodies(kind) {
|
||||
reads.drawn_targets(st, f);
|
||||
}
|
||||
}
|
||||
// A mirror of placed bodies follows the placements it was picked at and its own component's
|
||||
// frame, not every occurrence: moving an unrelated one leaves it (and its cache) alone.
|
||||
FeatureKind::Mirror { placements, bodies, .. } if !placements.is_empty() => return mirror_placements_digest(doc, f, placements, bodies),
|
||||
@@ -1978,9 +2016,56 @@ impl Model {
|
||||
|
||||
/// The final model state.
|
||||
pub fn state(&self) -> Arc<ModelState> {
|
||||
let last = self.evaluated_state();
|
||||
match &self.held {
|
||||
Some(held) if Arc::ptr_eq(&held.base, &last) => held.shown.clone(),
|
||||
_ => last,
|
||||
}
|
||||
}
|
||||
|
||||
/// The state after the last feature evaluated, without a failed sketch held for its editor.
|
||||
fn evaluated_state(&self) -> Arc<ModelState> {
|
||||
self.results.iter().rev().find(|r| !r.skipped).map(|r| r.state.clone()).unwrap_or_else(|| self.empty.clone())
|
||||
}
|
||||
|
||||
/// Show the sketch `feature` while it is edited although it failed (#12085): a link whose
|
||||
/// named face or edge is gone fails the sketch, so the evaluation keeps no sketch for it, and
|
||||
/// its editor had nothing to show or repair. Here the sketch is built as it is evaluated, but
|
||||
/// with such a link marked lost and kept where it was (no other face or edge is taken for
|
||||
/// it), and added to the final state only: the sketch's result keeps its error and the
|
||||
/// features after it still fail. `None`, a sketch that did not fail, or one that fails for
|
||||
/// another reason too, shows nothing more. Returns whether a held sketch is shown.
|
||||
pub fn hold_failed_sketch(&mut self, doc: &Document, feature: Option<u64>) -> bool {
|
||||
let Some(id) = feature else {
|
||||
self.held = None;
|
||||
return false;
|
||||
};
|
||||
let base = self.evaluated_state();
|
||||
if self.held.as_ref().is_some_and(|held| held.feature == id && Arc::ptr_eq(&held.base, &base)) {
|
||||
return true;
|
||||
}
|
||||
self.held = None;
|
||||
if base.sketch(id).is_some() || !self.result(id).is_some_and(|r| r.error.is_some() && !r.skipped && !r.stale) {
|
||||
return false;
|
||||
}
|
||||
let Some(f) = doc.feature(id) else { return false };
|
||||
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return false };
|
||||
let mut next = (*self.state_before(id)).clone();
|
||||
let vals = evaluation_values(doc).0;
|
||||
let mut warning = None;
|
||||
let built = kernel::guard(&f.name, || {
|
||||
let _frames = ToolFrames::install(doc, f, &next);
|
||||
eval_sketch(doc, &vals, f, plane, sketch, &mut next, &mut warning, crate::project::LostNames::Mark)
|
||||
.map_err(|e| kernel::KernelError::Failed(e.to_string()))
|
||||
});
|
||||
let Some(solved) = built.ok().and_then(|()| next.sketches.iter().find(|s| s.feature == id).cloned()) else { return false };
|
||||
let mut shown = (*base).clone();
|
||||
shown.sketches.retain(|s| s.feature != id);
|
||||
shown.sketches.push(solved);
|
||||
self.held = Some(HeldSketch { feature: id, base, shown: Arc::new(shown) });
|
||||
true
|
||||
}
|
||||
|
||||
/// State just before the feature with this id (for editing a sketch in context).
|
||||
pub fn state_before(&self, id: u64) -> Arc<ModelState> {
|
||||
let Some(i) = self.results.iter().position(|r| r.id == id) else { return self.state() };
|
||||
@@ -2144,6 +2229,10 @@ impl Model {
|
||||
|
||||
/// Re-evaluate the document, reusing results up to the first changed feature.
|
||||
pub fn evaluate(&mut self, doc: &Document) {
|
||||
// A failed sketch held for its editor is built again from the evaluated design: when the
|
||||
// sketch and every feature after it fail, the results end in the state they ended in
|
||||
// before, which would keep showing the sketch as it was before the edit.
|
||||
self.held = None;
|
||||
let _span = solidcraft_profile::span("doc", "evaluate");
|
||||
self.placement_inputs = None;
|
||||
self.fingerprinted = None;
|
||||
@@ -6932,6 +7021,22 @@ fn replay(
|
||||
instances.push(start..copies.len());
|
||||
}
|
||||
}
|
||||
// A cut or intersect copy with no named targets, made where the pattern's own component
|
||||
// has no body for it, works on the bodies of its source's component, as the source did
|
||||
// (#12996). A pattern whose component has bodies cuts them, as before. The same scope
|
||||
// counts misses, takes sheet cuts and applies the copies.
|
||||
let scoped;
|
||||
let f = if matches!(op, Operation::Cut | Operation::Intersect)
|
||||
&& targets.is_empty()
|
||||
&& f.participants.is_empty()
|
||||
&& src.component != f.component
|
||||
&& !st.bodies.iter().any(|b| b.component == f.component || b.feature == f.id)
|
||||
{
|
||||
scoped = Feature { component: src.component, ..f.clone() };
|
||||
&scoped
|
||||
} else {
|
||||
f
|
||||
};
|
||||
if op == Operation::Cut {
|
||||
let (m, t) = count_copy_misses(st, f, &targets, &copies, &instances, &axes);
|
||||
missed += m;
|
||||
@@ -7380,72 +7485,7 @@ fn eval_feature(
|
||||
| FeatureKind::ReplaceFace { .. }
|
||||
| FeatureKind::Align { .. }
|
||||
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st, warning),
|
||||
FeatureKind::Sketch { plane, sketch } => {
|
||||
let anchor = crate::face_anchor::plane_face_anchor(plane);
|
||||
let fst = match anchor {
|
||||
Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?,
|
||||
// A sketch that reads no geometry (a fixed plane, no projections) needs no other
|
||||
// component's bodies moved into its frame: placing every body of a large
|
||||
// assembly again is what made each sketch edit as slow as the design is big.
|
||||
None if !plane_reads_geometry(plane) && sketch.links.is_empty() => std::borrow::Cow::Borrowed(st),
|
||||
None => state_in_frame(doc, st, f.component)?,
|
||||
};
|
||||
// A further placement a link reads is placed in its frame too (#9957).
|
||||
let named: Vec<&str> = sketch.links.iter().flat_map(|l| l.source.bodies()).collect();
|
||||
let fst = match anchor.map(|anchor| anchor.author_frame(doc, f.component)) {
|
||||
_ if named.is_empty() => fst,
|
||||
Some(Ok(frame)) => with_placed_copies(doc, st, fst, &frame, &named),
|
||||
Some(Err(_)) => fst,
|
||||
None => with_placed_copies(doc, st, fst, &doc.component_transform(f.component), &named),
|
||||
};
|
||||
let (plane, face_warning) = match (anchor, plane) {
|
||||
(Some(_), plane) => doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?,
|
||||
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
|
||||
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
|
||||
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
|
||||
}
|
||||
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref())?,
|
||||
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
|
||||
};
|
||||
let mut sk = sketch.clone();
|
||||
// A text's outlines are rebuilt from its numbers when the links are refreshed: the
|
||||
// ones its expressions give come first.
|
||||
doc.apply_text_values(vals, &mut sk)?;
|
||||
let mut warns = match anchor {
|
||||
Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor)?,
|
||||
None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk)?,
|
||||
};
|
||||
drop(fst);
|
||||
sk.refresh_offsets()?;
|
||||
if let Some(w) = face_warning {
|
||||
warns.insert(0, w);
|
||||
}
|
||||
// The stored sketch is solved for its stored values, with its links where they
|
||||
// were stored: a link that moved must not change the side a dimension from it is on.
|
||||
let stored = sk.driving_values();
|
||||
let report = doc.solve_sketch(vals, &mut sk, &stored, Some(sketch))?;
|
||||
if !report.keeps_orientation() {
|
||||
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
|
||||
} else {
|
||||
// The solve may have moved the curve a text runs along.
|
||||
warns.extend(crate::project::relay_path_texts(&mut sk));
|
||||
}
|
||||
if !warns.is_empty() {
|
||||
*warning = Some(warns.join("; "));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
// A group of touching curves too large to search whole says so, rather than
|
||||
// leaving profiles out silently (#6896).
|
||||
let search = solidcraft_sketch::find_profiles_checked(&sk);
|
||||
if search.incomplete {
|
||||
append_warning(warning, profiles_cut_short_note());
|
||||
}
|
||||
let profiles = search.profiles;
|
||||
let ends = solidcraft_sketch::loose_ends(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends }));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Sketch { plane, sketch } => eval_sketch(doc, vals, f, plane, sketch, st, warning, crate::project::LostNames::Fail),
|
||||
FeatureKind::Extrude { operation, targets, .. }
|
||||
| FeatureKind::Pipe { operation, targets, .. }
|
||||
| FeatureKind::Revolve { operation, targets, .. }
|
||||
@@ -8264,6 +8304,88 @@ fn eval_feature(
|
||||
}
|
||||
}
|
||||
|
||||
/// A sketch feature: its links refreshed against the model before it, solved, its profiles
|
||||
/// found. `lost_names` says what a link whose named face or edge is gone does: the evaluation
|
||||
/// fails the sketch ([`crate::project::LostNames::Fail`]); a failed sketch shown for editing
|
||||
/// marks the link lost instead ([`Model::hold_failed_sketch`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn eval_sketch(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
f: &Feature,
|
||||
plane: &PlaneRef,
|
||||
sketch: &solidcraft_sketch::Sketch,
|
||||
st: &mut ModelState,
|
||||
warning: &mut Option<String>,
|
||||
lost_names: crate::project::LostNames,
|
||||
) -> Result<()> {
|
||||
let anchor = crate::face_anchor::plane_face_anchor(plane);
|
||||
let fst = match anchor {
|
||||
Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?,
|
||||
// A sketch that reads no geometry (a fixed plane, no projections) needs no other
|
||||
// component's bodies moved into its frame: placing every body of a large
|
||||
// assembly again is what made each sketch edit as slow as the design is big.
|
||||
None if !plane_reads_geometry(plane) && sketch.links.is_empty() => std::borrow::Cow::Borrowed(st),
|
||||
None => state_in_frame(doc, st, f.component)?,
|
||||
};
|
||||
// A further placement a link reads is placed in its frame too (#9957).
|
||||
let named: Vec<&str> = sketch.links.iter().flat_map(|l| l.source.bodies()).collect();
|
||||
let fst = match anchor.map(|anchor| anchor.author_frame(doc, f.component)) {
|
||||
_ if named.is_empty() => fst,
|
||||
Some(Ok(frame)) => with_placed_copies(doc, st, fst, &frame, &named),
|
||||
Some(Err(_)) => fst,
|
||||
None => with_placed_copies(doc, st, fst, &doc.component_transform(f.component), &named),
|
||||
};
|
||||
let (plane, face_warning) = match (anchor, plane) {
|
||||
(Some(_), plane) => doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?,
|
||||
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
|
||||
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
|
||||
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
|
||||
}
|
||||
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref())?,
|
||||
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
|
||||
};
|
||||
let mut sk = sketch.clone();
|
||||
// A text's outlines are rebuilt from its numbers when the links are refreshed: the
|
||||
// ones its expressions give come first.
|
||||
doc.apply_text_values(vals, &mut sk)?;
|
||||
let mut warns = match (anchor, lost_names) {
|
||||
(Some(anchor), crate::project::LostNames::Fail) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor)?,
|
||||
(None, crate::project::LostNames::Fail) => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk)?,
|
||||
(anchor, crate::project::LostNames::Mark) => crate::project::refresh_links_marking_lost(doc, vals, &fst, &plane, &mut sk, anchor)?,
|
||||
};
|
||||
drop(fst);
|
||||
sk.refresh_offsets()?;
|
||||
if let Some(w) = face_warning {
|
||||
warns.insert(0, w);
|
||||
}
|
||||
// The stored sketch is solved for its stored values, with its links where they
|
||||
// were stored: a link that moved must not change the side a dimension from it is on.
|
||||
let stored = sk.driving_values();
|
||||
let report = doc.solve_sketch(vals, &mut sk, &stored, Some(sketch))?;
|
||||
if !report.keeps_orientation() {
|
||||
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
|
||||
} else {
|
||||
// The solve may have moved the curve a text runs along.
|
||||
warns.extend(crate::project::relay_path_texts(&mut sk));
|
||||
}
|
||||
if !warns.is_empty() {
|
||||
*warning = Some(warns.join("; "));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
// A group of touching curves too large to search whole says so, rather than
|
||||
// leaving profiles out silently (#6896).
|
||||
let search = solidcraft_sketch::find_profiles_checked(&sk);
|
||||
if search.incomplete {
|
||||
append_warning(warning, profiles_cut_short_note());
|
||||
}
|
||||
let profiles = search.profiles;
|
||||
let ends = solidcraft_sketch::loose_ends(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `copy`, a moved copy of `source` (instance `instance` of a pattern or mirror): its faces are
|
||||
/// named after the source's.
|
||||
fn moved_copy(source: &ModelBody, copy: Body, instance: usize) -> Traced {
|
||||
@@ -8739,6 +8861,7 @@ impl PlacedBodies {
|
||||
let mut fresh = ModelBody::new(name.to_string(), placed.body.clone(), b.feature);
|
||||
fresh.mesh = placed.mesh.clone();
|
||||
fresh.measured = placed.measured.clone();
|
||||
fresh.joint_faces = placed.joint_faces.clone();
|
||||
fresh.mesh_placement = placed.mesh_placement.clone();
|
||||
fresh.plan = placed.plan.clone();
|
||||
fresh.plan_mesh = placed.plan_mesh.clone();
|
||||
|
||||
@@ -656,6 +656,126 @@ fn a_tool_cutting_another_part_cuts_again_when_that_part_moves() {
|
||||
assert!((volume(&model, &body) - (400.0 - 16.0)).abs() < 1e-6, "{}", volume(&model, &body));
|
||||
}
|
||||
|
||||
/// A root design with two 10 x 10 x 4 mm plates, "Plate" at the origin and "Other" 30 mm along Y,
|
||||
/// and a sketch on origin plane `plane` of the rectangle `profile` spans (opposite corners, in mm):
|
||||
/// (the plate part, the other part, the sketch).
|
||||
fn two_plates_and_a_sketch(doc: &mut Document, plane: &str, profile: [Vec2; 2]) -> (u64, u64, u64) {
|
||||
let plate = doc.add_component(Some("Plate"), 0).unwrap();
|
||||
let other = doc.add_component(Some("Other"), 0).unwrap();
|
||||
place(doc, other, rigid(Vec3::new(0.0, 30.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0));
|
||||
plate_with_holes(doc, other, other, 0);
|
||||
plate_with_holes(doc, plate, plate, 0);
|
||||
let mut sk = Sketch::new();
|
||||
let [a, c] = profile;
|
||||
let p = [a, Vec2::new(c.x, a.y), c, Vec2::new(a.x, c.y)].map(|q| sk.add_point(q, None).unwrap());
|
||||
for k in 0..4 {
|
||||
sk.add_line_pts(p[k], p[(k + 1) % 4], None).unwrap();
|
||||
}
|
||||
let sketch = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: plane.into() }, sketch: sk }, None).unwrap();
|
||||
(plate, other, sketch)
|
||||
}
|
||||
|
||||
/// `kind` added to the root with its face points named after the faces under them, as the
|
||||
/// command that makes it names them.
|
||||
fn add_named(doc: &mut Document, kind: FeatureKind) -> u64 {
|
||||
let mut model = Model::new();
|
||||
model.evaluate(doc);
|
||||
let names = face_point_names(doc, &model.state(), &kind, 0).unwrap();
|
||||
assert!(names.iter().all(|n| !n.is_empty()), "{names:?}");
|
||||
let id = doc.add_feature(kind, None).unwrap();
|
||||
doc.feature_mut(id).unwrap().face_names = names;
|
||||
id
|
||||
}
|
||||
|
||||
/// An extrude in the root from the top of the plate (z 4) 6 mm up, over 2 to 8 mm on X and Y.
|
||||
fn extrude_from_the_plate(doc: &mut Document) -> (u64, u64, u64) {
|
||||
let (plate, other, sketch) = two_plates_and_a_sketch(doc, "XY", [Vec2::new(2.0, 2.0), Vec2::new(8.0, 8.0)]);
|
||||
let kind = FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: SketchId(sketch),
|
||||
profiles: ProfileSel::All,
|
||||
extent: serde_json::from_value(serde_json::json!({"distance": "6", "from": [5.0, 5.0, 4.0]})).unwrap(),
|
||||
operation: Operation::NewBody,
|
||||
targets: vec![],
|
||||
};
|
||||
(plate, other, add_named(doc, kind))
|
||||
}
|
||||
|
||||
/// Where the body feature `id` made has its centre.
|
||||
fn centroid_of(model: &Model, id: u64) -> Vec3 {
|
||||
let st = model.state();
|
||||
kernel::measure(&st.bodies.iter().find(|b| b.feature == id).unwrap().body).unwrap().centroid
|
||||
}
|
||||
|
||||
/// An extrude from a face of the plate reads where the plate is, and nothing else (#6325): a
|
||||
/// joint moving another part leaves its result, and every result after it, as it was.
|
||||
#[test]
|
||||
fn an_extrude_from_its_own_part_keeps_its_result_when_another_part_moves() {
|
||||
let mut doc = Document::new("from object");
|
||||
let (plate, other, extrude) = extrude_from_the_plate(&mut doc);
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some()));
|
||||
assert!((centroid_of(&model, extrude).z - 7.0).abs() < 1e-6, "{:?}", centroid_of(&model, extrude));
|
||||
place(&mut doc, other, rigid(Vec3::new(0.0, 40.0, 3.0), Vec3::ZERO, Vec3::Z, 0.0));
|
||||
model.evaluate(&doc);
|
||||
assert_eq!(model.last_recomputed, 0);
|
||||
// The plate lifted 5 mm: the extrude starts on its top where it is now (#928).
|
||||
place(&mut doc, plate, rigid(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Z, 0.0));
|
||||
model.evaluate(&doc);
|
||||
assert_eq!(model.last_recomputed, 1);
|
||||
assert!((centroid_of(&model, extrude).z - 12.0).abs() < 1e-6, "{:?}", centroid_of(&model, extrude));
|
||||
}
|
||||
|
||||
/// A revolve To a face of the plate, as the extrude from it: it turns again only when the plate
|
||||
/// moves.
|
||||
#[test]
|
||||
fn a_revolve_to_its_own_part_keeps_its_result_when_another_part_moves() {
|
||||
let mut doc = Document::new("revolve to object");
|
||||
let (plate, other, sketch) = two_plates_and_a_sketch(&mut doc, "XZ", [Vec2::new(12.0, 0.0), Vec2::new(15.0, 2.0)]);
|
||||
let kind = FeatureKind::Revolve {
|
||||
sketch: SketchId(sketch),
|
||||
profiles: ProfileSel::All,
|
||||
axis: AxisRef::SketchAxis { axis: "y".into() },
|
||||
angle: "90 deg".into(),
|
||||
operation: Operation::NewBody,
|
||||
targets: vec![],
|
||||
angle2: None,
|
||||
symmetric: false,
|
||||
convention: Default::default(),
|
||||
to: Some(serde_json::from_value(serde_json::json!([5.0, 5.0, 4.0])).unwrap()),
|
||||
};
|
||||
let revolve = add_named(&mut doc, kind);
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some()));
|
||||
let turned = volume(&model, &model.state().bodies.iter().find(|b| b.feature == revolve).unwrap().name.clone());
|
||||
place(&mut doc, other, rigid(Vec3::new(0.0, 40.0, 3.0), Vec3::ZERO, Vec3::Z, 0.0));
|
||||
model.evaluate(&doc);
|
||||
assert_eq!(model.last_recomputed, 0);
|
||||
// The plate turned a quarter about Z: the revolve turns to its top where it is now.
|
||||
place(&mut doc, plate, rigid(Vec3::ZERO, Vec3::ZERO, Vec3::Z, std::f64::consts::FRAC_PI_2));
|
||||
model.evaluate(&doc);
|
||||
assert_eq!(model.last_recomputed, 1);
|
||||
let now = volume(&model, &model.state().bodies.iter().find(|b| b.feature == revolve).unwrap().name.clone());
|
||||
assert!((now - turned).abs() > 1.0, "{turned} then {now}");
|
||||
}
|
||||
|
||||
/// A From face without a name is found by its point, on whatever body lies there: moving any
|
||||
/// part extrudes again.
|
||||
#[test]
|
||||
fn an_extrude_from_a_face_without_a_name_extrudes_again_when_any_part_moves() {
|
||||
let mut doc = Document::new("from a point");
|
||||
let (_, other, extrude) = extrude_from_the_plate(&mut doc);
|
||||
doc.feature_mut(extrude).unwrap().face_names.clear();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!((centroid_of(&model, extrude).z - 7.0).abs() < 1e-6, "{:?}", centroid_of(&model, extrude));
|
||||
place(&mut doc, other, rigid(Vec3::new(0.0, 40.0, 3.0), Vec3::ZERO, Vec3::Z, 0.0));
|
||||
model.evaluate(&doc);
|
||||
assert_eq!(model.last_recomputed, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_is_measured_once_while_it_stays_the_same() {
|
||||
let mut doc = Document::new("measured once");
|
||||
|
||||
@@ -1122,4 +1122,31 @@ mod tests {
|
||||
// The square stop leaves about half a pitch's worth of groove uncut: well over a mm³.
|
||||
assert!(square > trimmed + 1.0, "{square} vs {trimmed}");
|
||||
}
|
||||
|
||||
/// A modelled thread on one hole leaves the other hole's wall a cylinder to thread: the
|
||||
/// second hole still takes its thread (#11367).
|
||||
///
|
||||
/// The modelled thread cuts the body, and the cut must not take the body's other cylinders
|
||||
/// with it: the second hole's wall is still a cylinder, so it still takes a thread, modelled
|
||||
/// or cosmetic.
|
||||
#[test]
|
||||
fn a_modelled_thread_leaves_the_next_hole_a_cylinder_to_thread() {
|
||||
// An 80 x 80 x 20 mm block with two 5 mm holes.
|
||||
let block = kernel::box_solid(Vec3::new(-40.0, -40.0, 0.0), Vec3::new(40.0, 40.0, 20.0)).unwrap();
|
||||
let hole = |x: f64| kernel::cylinder(Vec3::new(x, 0.0, -1.0), Vec3::new(0.0, 0.0, 1.0), 2.5, 22.0).unwrap();
|
||||
let mut body = kernel::boolean(&block, &hole(-20.0), BoolOp::Cut).unwrap().unwrap();
|
||||
body = kernel::boolean(&body, &hole(20.0), BoolOp::Cut).unwrap().unwrap();
|
||||
let mut st = ModelState::default();
|
||||
st.bodies.push(ModelBody::new("Part".into(), body, 1));
|
||||
let ask = ThreadAsk { designation: Some("M6"), ..ask() };
|
||||
// The first hole takes a modelled M6 thread.
|
||||
let on = thread_on(&st, 1, Vec3::new(-17.5, 0.0, 10.0), &ask).unwrap();
|
||||
cut(&mut st, on, (ThreadTip::None, None, None)).unwrap();
|
||||
// The second hole's wall is still a cylinder: it takes a thread too.
|
||||
let on = thread_on(&st, 1, Vec3::new(22.5, 0.0, 10.0), &ask);
|
||||
assert!(on.is_ok(), "the second hole still has a cylinder to thread: {:?}", on.err());
|
||||
// And a modelled one cuts there as well.
|
||||
let on = thread_on(&st, 1, Vec3::new(22.5, 0.0, 10.0), &ask).unwrap();
|
||||
assert!(cut(&mut st, on, (ThreadTip::None, None, None)).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,6 +982,12 @@ fn call(f: &str, args: &[Value], d: Defaults) -> Result<Value, DocError> {
|
||||
// Round in the value's own default unit (round(2.4 in) = 2 in for an inch parameter).
|
||||
let scale = scale_bare(Value::num(1.0), a.len, a.ang, d);
|
||||
let x = if scale != 0.0 { a.v / scale } else { a.v };
|
||||
// Unit conversions leave a rounding error of an ulp or two (`3 in / 1 in` is
|
||||
// 2.9999999999999996): a value that close to a whole number is that number, so `floor`
|
||||
// does not drop to 2. The tolerance is relative to the whole number, so a tiny value
|
||||
// never snaps to 0, and it stays under 1/8 so no real fraction is absorbed.
|
||||
let nearest = x.round();
|
||||
let x = if (x - nearest).abs() <= (4.0 * f64::EPSILON * nearest.abs()).min(0.125) { nearest } else { x };
|
||||
let r = match f {
|
||||
"floor" => x.floor(),
|
||||
"ceil" => x.ceil(),
|
||||
@@ -1444,6 +1450,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `floor`, `ceil`, `trunc` and `round` work on the number a person typed, not on float noise:
|
||||
/// `3 in / 1 in` is 2.9999999999999996 in floats, and `floor` of it must still be 3.
|
||||
#[test]
|
||||
fn rounding_functions_ignore_float_noise() {
|
||||
for (e, want) in [("floor(3 in / 1 in)", 3.0), ("ceil(3 in / 1 in)", 3.0), ("trunc(3 in / 1 in)", 3.0), ("floor(-3 in / 1 in)", -3.0)] {
|
||||
assert_eq!(ev(e, Kind::Unitless).unwrap(), want, "{e}");
|
||||
}
|
||||
let d = Defaults { len: 25.4, ang: std::f64::consts::PI / 180.0 };
|
||||
let look = |_: &str| Err(DocError::Expr("no parameters".into()));
|
||||
for e in ["floor(3 in)", "ceil(3 in)", "trunc(3 in)", "round(3 in)"] {
|
||||
let got = eval_with_defaults(e, &look, d).unwrap().to_kind_in(Kind::Length, d).unwrap();
|
||||
assert!((got - 76.2).abs() < 1e-9, "{e} gave {got}");
|
||||
}
|
||||
let got = eval_with_defaults("floor(30 deg)", &look, Defaults::default()).unwrap().to_kind(Kind::Angle).unwrap();
|
||||
assert!((got - 30f64.to_radians()).abs() < 1e-9, "floor(30 deg) gave {got}");
|
||||
// A real fraction still rounds the way its name says, however small, and large values keep working.
|
||||
for (e, want) in [
|
||||
("floor(2.9999)", 2.0),
|
||||
("ceil(3.0001)", 4.0),
|
||||
("floor(100.99999995)", 100.0),
|
||||
("ceil(100.00000005)", 101.0),
|
||||
("floor(1000000000.7)", 1_000_000_000.0),
|
||||
("ceil(1000000000.7)", 1_000_000_001.0),
|
||||
("trunc(1000000000.7)", 1_000_000_000.0),
|
||||
("round(1000000000.7)", 1_000_000_001.0),
|
||||
("ceil(1e-16)", 1.0),
|
||||
("floor(-1e-16)", -1.0),
|
||||
("trunc(1e-16)", 0.0),
|
||||
("trunc(-1e-16)", 0.0),
|
||||
("round(1e-16)", 0.0),
|
||||
("round(-1e-16)", 0.0),
|
||||
("floor(1000000000000000.5)", 1_000_000_000_000_000.0),
|
||||
] {
|
||||
assert_eq!(ev(e, Kind::Unitless).unwrap(), want, "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A count from a length (`(L - 20) / 210`, the 210 read as unit-less) names the fix (#5901).
|
||||
#[test]
|
||||
fn a_length_where_a_count_is_expected_says_to_divide_by_a_length() {
|
||||
|
||||
@@ -571,9 +571,12 @@ pub(crate) fn matches(names: &Names, index: usize, name: &str) -> Option<Match>
|
||||
if descends(current, name) {
|
||||
return Some(Match::Piece);
|
||||
}
|
||||
// The named piece, told apart by the faces around it: they still bound this piece.
|
||||
// The named piece, told apart by the faces around it: they still bound this piece. Or the
|
||||
// face it was a piece of, whole again since, which they still bound: the piece is all of it
|
||||
// (#11731). Only a piece told apart by faces: a numbered one (`#2`, nothing told it apart)
|
||||
// says nothing about which part of the whole face it was.
|
||||
if let Some((stem, around)) = crate::piece_names::piece_parts(name)
|
||||
&& crate::naming::parent_piece(current) == Some(stem)
|
||||
&& (crate::naming::parent_piece(current) == Some(stem) || (current == stem && !around.is_empty() && name.ends_with(']')))
|
||||
&& info.is_some_and(|i| around.iter().all(|f| i.bounds.contains(*f)))
|
||||
{
|
||||
return Some(Match::Piece);
|
||||
|
||||
@@ -171,3 +171,21 @@ fn a_linked_designs_face_names_keep_their_pieces_and_nothing_else() {
|
||||
let _ = role(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// A face that was split when a name was stored and is whole again (#11731): the stored piece
|
||||
/// is all of it, when the face still touches the faces that told the piece apart.
|
||||
#[test]
|
||||
fn a_piece_name_finds_its_face_once_it_is_whole_again() {
|
||||
let faces = named(&["top", "left", "right", "start"]);
|
||||
let names = qualify(faces, &[(0, 1), (0, 3)]);
|
||||
assert_eq!(names.faces[0], "top");
|
||||
assert_eq!(matches(&names, 0, "top#[left]"), Some(Match::Piece));
|
||||
assert_eq!(matches(&names, 0, "top#[left,start]"), Some(Match::Piece));
|
||||
// It no longer touches a face the qualifier names: some other piece, gone since.
|
||||
assert_eq!(matches(&names, 0, "top#[right]"), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 0, "top#[left]@F9.1"), None, "a copy's piece");
|
||||
// A numbered piece (nothing told it apart, or a tie) says nothing about which part it was.
|
||||
assert_eq!(matches(&names, 0, "top#1"), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 0, "top#[left]#2"), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 0, "top#[]"), Some(Match::Stem));
|
||||
}
|
||||
|
||||
@@ -339,6 +339,27 @@ pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option<FaceHit> {
|
||||
locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p).ok()
|
||||
}
|
||||
|
||||
/// The one body of `st` with a face the stored face name finds, as [`locate_face`] looks for it.
|
||||
/// `None` when no body has one, several do, or the name is one saved before format 14.
|
||||
pub(crate) fn face_body<'a>(st: &'a ModelState, name: &str) -> Option<&'a ModelBody> {
|
||||
if let Some((body, _)) = crate::sheet::split_sheet_face_name(name) {
|
||||
return st.bodies.iter().find(|b| b.name == body && !b.body.is_mesh());
|
||||
}
|
||||
if name.is_empty() || legacy_name(name).is_some() {
|
||||
return None;
|
||||
}
|
||||
let solids = || st.bodies.iter().filter(|b| !b.body.is_mesh());
|
||||
naming::warm_names(solids());
|
||||
let mut holders = solids().filter(|b| {
|
||||
let names = naming::names(b);
|
||||
(0..names.faces.len()).any(|face| crate::history_names::matches(&names, face, name).is_some_and(exactly))
|
||||
});
|
||||
match (holders.next(), holders.next()) {
|
||||
(Some(b), None) => Some(b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`locate_face`] for a feature that works on the faces of the body called `body` (a shell, a
|
||||
/// draft…): the face of that body the name finds, found without working out the names of every
|
||||
/// other body (a body named from geometry is meshed for it: seconds in a large model). When
|
||||
|
||||
@@ -243,3 +243,57 @@ fn numbered_projection_refuses_a_pick_inside_a_hole_in_the_named_face() {
|
||||
assert_eq!(pick(5.).map(|(index, _)| index), Some(top));
|
||||
assert!(pick(0.).is_none(), "the pick is on the plane but inside the hole");
|
||||
}
|
||||
|
||||
/// An edge picked when its side face was split in two keeps the piece in its name
|
||||
/// (`F2:side:l1#[F2:start]`); once the side is one face again the edge is still found (#11731).
|
||||
#[test]
|
||||
fn an_edge_named_by_a_piece_of_a_face_that_is_whole_again_is_found() {
|
||||
let doc = Document {
|
||||
features: serde_json::from_value(serde_json::json!([
|
||||
{
|
||||
"id": 1, "name": "Sketch1", "type": "sketch", "plane": {"name": "XY", "type": "origin"},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{"fixed": true, "id": "origin", "pos": [0., 0.]},
|
||||
{"fixed": false, "id": "p1", "pos": [0., 0.]},
|
||||
{"fixed": false, "id": "p2", "pos": [10., 0.]},
|
||||
{"fixed": false, "id": "p3", "pos": [10., 16.1]},
|
||||
{"fixed": false, "id": "p4", "pos": [0., 16.1]}
|
||||
],
|
||||
"curves": [
|
||||
{"a": 1, "b": 2, "construction": false, "id": "l1", "type": "line"},
|
||||
{"a": 2, "b": 3, "construction": false, "id": "l2", "type": "line"},
|
||||
{"a": 3, "b": 4, "construction": false, "id": "l3", "type": "line"},
|
||||
{"a": 4, "b": 1, "construction": false, "id": "l4", "type": "line"}
|
||||
],
|
||||
"constraints": [],
|
||||
"counters": {"k": 0, "l": 4, "p": 4}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2, "name": "Extrude1", "type": "extrude", "body_names": ["Body1"],
|
||||
"extent": {"convention": "total_distance", "direction": "positive", "distance": "5"},
|
||||
"operation": "new_body", "profiles": {"loops": [["l1", "l2", "l3", "l4"]], "type": "curves"}, "sketch": 1
|
||||
}
|
||||
]))
|
||||
.unwrap(),
|
||||
..Document::default()
|
||||
};
|
||||
let doc = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut model = crate::Model::new();
|
||||
model.evaluate(&doc);
|
||||
let state = model.state();
|
||||
let body = state.body("Body1").unwrap();
|
||||
// The side along x = 0 meets the start cap in the edge under the pick.
|
||||
let near = Vec3::new(0., 8.05, 0.);
|
||||
let names = crate::naming::edge_names(body);
|
||||
let whole = names.iter().find(|n| n.contains("F2:start") && n.contains("F2:side:l4")).expect("the side's edge on the start cap");
|
||||
let piece = whole.replace("F2:side:l4", "F2:side:l4#[F2:start]");
|
||||
let hit = crate::name_lookup::locate_edge(body, &piece, near).unwrap_or_else(|e| panic!("{piece}: {e}"));
|
||||
assert!(hit.on_point, "the edge under the saved pick");
|
||||
assert_eq!(hit.point, near);
|
||||
assert_eq!(names.get(hit.edge), Some(whole));
|
||||
// A piece told apart by a face the side never touches (the opposite side) is still refused.
|
||||
let gone = whole.replace("F2:side:l4", "F2:side:l4#[F2:side:l2]");
|
||||
assert!(crate::name_lookup::locate_edge(body, &gone, near).is_err(), "{gone}");
|
||||
}
|
||||
|
||||
@@ -120,6 +120,22 @@ pub fn profile_fallback_notes(ss: &SolvedSketch, selection: &ProfileSel) -> Vec<
|
||||
profile_selection_indices_lenient(ss, selection).map(|(_, notes)| notes).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A curve that is a line, an arc or a circle, and not the output of a free-form offset.
|
||||
fn is_analytic(sketch: &solidcraft_sketch::Sketch, id: &str) -> bool {
|
||||
sketch.curves.iter().any(|curve| {
|
||||
curve.id == id
|
||||
&& matches!(
|
||||
curve.kind,
|
||||
solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Arc { .. } | solidcraft_sketch::CurveKind::Circle { .. }
|
||||
)
|
||||
}) && !sketch.freeform_offsets.iter().any(|offset| offset.output == id)
|
||||
}
|
||||
|
||||
/// A loop made only of analytic curves: how many pieces each is cut into does not matter.
|
||||
fn is_analytic_loop(sketch: &solidcraft_sketch::Sketch, curves: &[String]) -> bool {
|
||||
!curves.is_empty() && curves.iter().all(|id| is_analytic(sketch, id))
|
||||
}
|
||||
|
||||
fn quoted(ids: &[String]) -> String {
|
||||
ids.iter().map(|id| format!("`{id}`")).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
@@ -135,17 +151,32 @@ fn quoted(ids: &[String]) -> String {
|
||||
/// them; a tie goes to the one with the fewest curves the loop did not have, and a tie left
|
||||
/// after that is not guessed.
|
||||
///
|
||||
/// Loops of offset curves and legacy graph-span multisets (a curve listed once per piece) keep
|
||||
/// their exact meaning and never fall back.
|
||||
/// Analytic loops (lines, arcs and circles only) may list a curve more than once, as the pieces a
|
||||
/// circle was cut into when the loop was saved; the repeats are one curve here, since a sketch
|
||||
/// edit changes how many pieces a curve has but not the region it bounds. The outputs of an
|
||||
/// analytic offset (`sketch.offsets`) take step 1 only: their loop closes on its own or the pick
|
||||
/// is lost, a partial match by identifiers never stands for it. Loops with free-form offset
|
||||
/// outputs, free curves (splines, ellipses, conics) and legacy graph-span multisets keep their
|
||||
/// exact meaning and never fall back.
|
||||
fn fallback_loop(ss: &SolvedSketch, curves: &[String]) -> Option<(Vec<usize>, String)> {
|
||||
let sketch = &ss.sketch;
|
||||
let analytic_loop = is_analytic_loop(sketch, curves);
|
||||
let deduped: Vec<String> = if analytic_loop {
|
||||
let mut seen = BTreeSet::new();
|
||||
curves.iter().filter(|id| seen.insert(id.as_str())).cloned().collect()
|
||||
} else {
|
||||
curves.to_vec()
|
||||
};
|
||||
let curves = deduped.as_slice();
|
||||
let stored: BTreeSet<&str> = curves.iter().map(String::as_str).collect();
|
||||
if stored.is_empty() || stored.len() != curves.len() || curves.len() > 512 || ss.profiles.len() > 10_000 {
|
||||
return None;
|
||||
}
|
||||
let sketch = &ss.sketch;
|
||||
let offset =
|
||||
|id: &str| sketch.offsets.iter().any(|offset| offset.owns_output(id)) || sketch.freeform_offsets.iter().any(|offset| offset.output == id);
|
||||
if stored.iter().any(|id| offset(id)) {
|
||||
if stored.iter().any(|id| sketch.freeform_offsets.iter().any(|offset| offset.output == *id)) {
|
||||
return None;
|
||||
}
|
||||
let from_offset = stored.iter().any(|id| sketch.offsets.iter().any(|offset| offset.owns_output(id)));
|
||||
if from_offset && !analytic_loop {
|
||||
return None;
|
||||
}
|
||||
let drawn = |id: &str| sketch.curves.iter().any(|curve| curve.id == id && !curve.construction && !curve.centerline);
|
||||
@@ -180,6 +211,9 @@ fn fallback_loop(ss: &SolvedSketch, curves: &[String]) -> Option<(Vec<usize>, St
|
||||
}
|
||||
}
|
||||
}
|
||||
if from_offset {
|
||||
return None;
|
||||
}
|
||||
// 2. The closest loop by the curves it shares.
|
||||
let count = |profile: &solidcraft_sketch::Profile, inside: bool| {
|
||||
profile.outer_curves.iter().map(String::as_str).filter(|id| stored.contains(id) == inside).collect::<BTreeSet<_>>().len()
|
||||
@@ -231,9 +265,12 @@ fn selection_indices(ss: &SolvedSketch, selection: &ProfileSel, mut notes: Optio
|
||||
}
|
||||
indices.push(unique_profile(ss, &ss.sketch.offset_boundary(id)?)?);
|
||||
} else {
|
||||
// Repeated saved IDs identify exact graph spans. Their multiplicity
|
||||
// must not silently turn into a whole-source certificate on resize.
|
||||
// Repeated saved IDs of free curves identify exact graph spans. Their
|
||||
// multiplicity must not silently turn into a whole-source certificate on
|
||||
// resize. Lines, arcs and circles are exempt: how many pieces such a curve is
|
||||
// cut into changes with every line drawn across it (#9368).
|
||||
if curves.iter().collect::<BTreeSet<_>>().len() != curves.len()
|
||||
&& !is_analytic_loop(&ss.sketch, curves)
|
||||
&& !ss.profiles.iter().any(|profile| same_ids(curves, &profile.outer_curves))
|
||||
{
|
||||
return Err(crate::curve_pick_error(solidcraft_sketch::CurvePick::Missing, curves, &ss.name));
|
||||
|
||||
+192
-16
@@ -912,7 +912,31 @@ pub fn refresh_face_anchor_links(
|
||||
sk: &mut Sketch,
|
||||
anchor: &crate::FaceAnchor,
|
||||
) -> Result<Vec<String>> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, Some(anchor))
|
||||
refresh_links_in(doc, vals, st, plane, sk, Some(anchor), LostNames::Fail)
|
||||
}
|
||||
|
||||
/// What a link kept by the name of a face or edge does when the name finds nothing any more.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum LostNames {
|
||||
/// The sketch fails, asking for the reference to be picked again ([`DocError::Reselect`]).
|
||||
Fail,
|
||||
/// The link is marked lost and keeps its last geometry, with a warning naming the reference.
|
||||
/// No other face or edge is taken for it. Only for showing a failed sketch while it is edited
|
||||
/// (#12085), never for the evaluation the features after it build from.
|
||||
Mark,
|
||||
}
|
||||
|
||||
/// [`refresh_links`] (or [`refresh_face_anchor_links`] with `anchor`) that marks a link whose
|
||||
/// named face or edge is gone lost instead of failing ([`LostNames::Mark`]).
|
||||
pub(crate) fn refresh_links_marking_lost(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
st: &ModelState,
|
||||
plane: &Plane,
|
||||
sk: &mut Sketch,
|
||||
anchor: Option<&crate::FaceAnchor>,
|
||||
) -> Result<Vec<String>> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, anchor, LostNames::Mark)
|
||||
}
|
||||
|
||||
/// Remove degree-two straight stations introduced by coplanar face subdivision.
|
||||
@@ -989,7 +1013,7 @@ fn merge_family_lines(geom: Vec<LinkGeom>) -> Result<Vec<LinkGeom>> {
|
||||
/// longer finds exactly that face or edge is not lost: the sketch fails, asking for the
|
||||
/// reference to be picked again ([`DocError::Reselect`]).
|
||||
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Result<Vec<String>> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, None)
|
||||
refresh_links_in(doc, vals, st, plane, sk, None, LostNames::Fail)
|
||||
}
|
||||
|
||||
/// Lay every text that runs along a curve of its sketch along that curve as the solve left it.
|
||||
@@ -1025,6 +1049,7 @@ fn refresh_links_in(
|
||||
plane: &Plane,
|
||||
sk: &mut Sketch,
|
||||
anchor: Option<&crate::FaceAnchor>,
|
||||
lost_names: LostNames,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut warn = Vec::new();
|
||||
let links = sk.links.clone();
|
||||
@@ -1034,16 +1059,17 @@ fn refresh_links_in(
|
||||
if !l.auto_face
|
||||
&& let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source)
|
||||
{
|
||||
let (index, p) = st
|
||||
.body(body)
|
||||
.and_then(|target| {
|
||||
if l.kind == LinkKind::Project {
|
||||
crate::name_lookup::locate_projected_face(target, name, *at)
|
||||
} else {
|
||||
crate::naming::face_on_body(target, name, *at)
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| DocError::reselect(name.as_str()))?;
|
||||
let found = st.body(body).and_then(|target| {
|
||||
if l.kind == LinkKind::Project {
|
||||
crate::name_lookup::locate_projected_face(target, name, *at)
|
||||
} else {
|
||||
crate::naming::face_on_body(target, name, *at)
|
||||
}
|
||||
});
|
||||
let Some((index, p)) = found else {
|
||||
lose_named(sk, &l.id, name, lost_names, &mut warn)?;
|
||||
continue;
|
||||
};
|
||||
*at = p;
|
||||
named_face = Some((body.clone(), index, p));
|
||||
}
|
||||
@@ -1051,7 +1077,10 @@ fn refresh_links_in(
|
||||
// edge has the name any more no other edge is taken for it: the sketch fails.
|
||||
let mut named_edge = None;
|
||||
if let (Some(name), LinkSource::Edge { body, at }) = (&l.edge_name, &source) {
|
||||
let (body, hit) = locate_named_edge(st, body, name, *at).ok_or_else(|| DocError::reselect(name.as_str()))?;
|
||||
let Some((body, hit)) = locate_named_edge(st, body, name, *at) else {
|
||||
lose_named(sk, &l.id, name, lost_names, &mut warn)?;
|
||||
continue;
|
||||
};
|
||||
warn.extend(hit.note(name).map(|note| format!("projected {note}")));
|
||||
named_edge = Some((body, hit.edge, *at));
|
||||
}
|
||||
@@ -1119,6 +1148,19 @@ fn refresh_links_in(
|
||||
Ok(warn)
|
||||
}
|
||||
|
||||
/// A link whose face or edge `name` finds nothing: the sketch fails asking for it again, or (only
|
||||
/// while a failed sketch is shown for editing) the link is marked lost, with a warning naming it.
|
||||
fn lose_named(sk: &mut Sketch, id: &str, name: &str, lost_names: LostNames, warn: &mut Vec<String>) -> Result<()> {
|
||||
match lost_names {
|
||||
LostNames::Fail => Err(DocError::reselect(name)),
|
||||
LostNames::Mark => {
|
||||
sk.set_link_lost(id, true);
|
||||
warn.push(format!("projected reference lost: `{name}` no longer exists as it was picked; reselect it"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Model lookups
|
||||
|
||||
@@ -1612,8 +1654,7 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
|
||||
let size = m.bounds().diagonal().max(1e-9);
|
||||
let (segs, exact) = section_segments(plane, m, face);
|
||||
let segs: Vec<(Vec2, Vec2)> = segs.into_iter().map(|(a, b, _)| (a, b)).collect();
|
||||
let loose = size * 2e-3;
|
||||
let refine = |v: Vec<LinkGeom>| -> Vec<LinkGeom> { v.into_iter().map(|g| refine_round(g, &exact, loose)).collect() };
|
||||
let refine = |v: Vec<LinkGeom>| -> Vec<LinkGeom> { refine_rounds(v, &exact, size) };
|
||||
let lossy = refine(fit_segments(&segs, size, false));
|
||||
// Lines and arcs that follow the section closely (the corners of a rounded profile stay
|
||||
// where they are) are kept when they take no more curves than the loose fit.
|
||||
@@ -1627,7 +1668,57 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
|
||||
let tight = size * 1e-4;
|
||||
let checkable = exact.len().saturating_mul(close.len()) <= 4_000_000;
|
||||
let strays = |v: &[LinkGeom]| exact.iter().any(|p| !v.iter().any(|g| plain_distance(g, *p).is_none_or(|d| d <= tight)));
|
||||
if plain(&close) && (close.len() <= lossy.len() || (checkable && strays(&lossy) && !strays(&close))) { close } else { lossy }
|
||||
let fitted = if plain(&close) && (close.len() <= lossy.len() || (checkable && strays(&lossy) && !strays(&close))) { close } else { lossy };
|
||||
join_rounds(fitted, size * 1e-5)
|
||||
}
|
||||
|
||||
/// Where the fit left a line and an arc ends apart by at most `tol`, both end at one point. A
|
||||
/// line can swallow an arc's first chord (a mesh crossing just past the tangent line), and
|
||||
/// [`refine_round`] moves an arc's ends but not the lines': the outline would not close.
|
||||
fn join_rounds(mut fitted: Vec<LinkGeom>, tol: f64) -> Vec<LinkGeom> {
|
||||
for i in 0..fitted.len() {
|
||||
let Some(&LinkGeom::Arc { c, mut a, mut b }) = fitted.get(i) else { continue };
|
||||
let r = c.dist(a);
|
||||
for end in [&mut a, &mut b] {
|
||||
for curve in &mut fitted {
|
||||
let LinkGeom::Line(p, q) = curve else { continue };
|
||||
let (near, far) = if p.dist(*end) <= q.dist(*end) { (p, q) } else { (q, p) };
|
||||
if near.dist(*end) <= tol {
|
||||
let joint = line_meets_round(*far, *near, c, r, tol);
|
||||
*near = joint;
|
||||
*end = joint;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(arc) = fitted.get_mut(i) {
|
||||
*arc = LinkGeom::Arc { c, a, b };
|
||||
}
|
||||
}
|
||||
fitted
|
||||
}
|
||||
|
||||
/// Where a line from `far` ending near `near` meets the circle (`c`, `r`): the tangent point when
|
||||
/// the line runs into the circle there, within `tol` of it over the arc's first chord, else its
|
||||
/// crossing nearest `near`, else the circle's point nearest `near`.
|
||||
fn line_meets_round(far: Vec2, near: Vec2, c: Vec2, r: f64, tol: f64) -> Vec2 {
|
||||
let nearest = |[x, y]: [Vec2; 2]| if x.dist(near) <= y.dist(near) { x } else { y };
|
||||
let to_centre = c - far;
|
||||
let span = to_centre.len();
|
||||
if let Some(unit) = to_centre.normalized().filter(|_| span > r) {
|
||||
let reach = (span * span - r * r).sqrt();
|
||||
let (along, side) = (unit * (reach * reach / span), unit.perp() * (reach * r / span));
|
||||
let tangent = nearest([far + along + side, far + along - side]);
|
||||
if tangent.dist(near) <= tol + (2.0 * r * tol).sqrt() {
|
||||
return tangent;
|
||||
}
|
||||
}
|
||||
let Some(dir) = (near - far).normalized() else { return near };
|
||||
let foot = far + dir * (c - far).dot(dir);
|
||||
let half = r * r - (foot - c).len2();
|
||||
if half >= 0.0 {
|
||||
return nearest([foot + dir * half.sqrt(), foot - dir * half.sqrt()]);
|
||||
}
|
||||
(near - c).normalized().map_or(near, |unit| c + unit * r)
|
||||
}
|
||||
|
||||
/// How far `p` lies from a line, arc or circle; None for any other curve.
|
||||
@@ -1785,6 +1876,91 @@ fn section_segments(plane: &Plane, m: &Mesh, face: Option<u32>) -> (Vec<(Vec2, V
|
||||
(segs, exact.into_inner())
|
||||
}
|
||||
|
||||
/// Re-fit each circle and arc of a fitted section through the exact section points near it.
|
||||
/// The outline stays closed: a line meeting an arc's end moves with it. Where two arcs meet,
|
||||
/// their refits stand only while their ends stay within the distance a profile joins ends
|
||||
/// across; else both keep their fits, since moving an arc's end would take it off its circle.
|
||||
fn refine_rounds(mut v: Vec<LinkGeom>, exact: &[Vec2], size: f64) -> Vec<LinkGeom> {
|
||||
// Ends this close meet: the fit joins crossings this close into one point.
|
||||
let meet = size * 1e-7;
|
||||
let ends = |h: &LinkGeom| match *h {
|
||||
LinkGeom::Arc { a, b, .. } => Some([a, b]),
|
||||
_ => None,
|
||||
};
|
||||
let fitted = v.clone();
|
||||
// Each arc's refit, and whether it stands.
|
||||
let refits: Vec<Option<LinkGeom>> =
|
||||
fitted.iter().map(|g| matches!(g, LinkGeom::Arc { .. }).then(|| refine_round(g.clone(), exact, size * 2e-3))).collect();
|
||||
let mut stands: Vec<bool> = refits.iter().map(Option::is_some).collect();
|
||||
// Where two arcs met as fitted: (arc, its end, other arc, its end).
|
||||
let mut joints = Vec::new();
|
||||
for (i, gi) in fitted.iter().enumerate() {
|
||||
for (j, gj) in fitted.iter().enumerate().skip(i + 1) {
|
||||
let (Some(ei), Some(ej)) = (ends(gi), ends(gj)) else { continue };
|
||||
for (ki, pi) in ei.iter().enumerate() {
|
||||
for (kj, pj) in ej.iter().enumerate() {
|
||||
if pi.dist(*pj) <= meet {
|
||||
joints.push((i, ki, j, kj));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Take back both refits at a joint they would tear, until no joint tears.
|
||||
let end_now = |stands: &[bool], i: usize, k: usize| {
|
||||
let g = if stands.get(i).copied().unwrap_or(false) { refits.get(i).and_then(Option::as_ref) } else { fitted.get(i) };
|
||||
g.and_then(ends).and_then(|e| e.get(k).copied())
|
||||
};
|
||||
let mut changed = true;
|
||||
while changed {
|
||||
changed = false;
|
||||
for &(i, ki, j, kj) in &joints {
|
||||
let (Some(p), Some(q)) = (end_now(&stands, i, ki), end_now(&stands, j, kj)) else { continue };
|
||||
if p.dist(q) > solidcraft_sketch::ENDPOINT_JOIN_TOL {
|
||||
for k in [i, j] {
|
||||
if let Some(s) = stands.get_mut(k).filter(|s| **s) {
|
||||
*s = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, g) in fitted.iter().enumerate() {
|
||||
match g {
|
||||
LinkGeom::Arc { a: a0, b: b0, .. } => {
|
||||
let (true, Some(refined @ LinkGeom::Arc { a: a1, b: b1, .. })) =
|
||||
(stands.get(i).copied().unwrap_or(false), refits.get(i).and_then(Option::as_ref))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for h in v.iter_mut() {
|
||||
if let LinkGeom::Line(s, t) = h {
|
||||
for end in [s, t] {
|
||||
if end.dist(*a0) <= meet {
|
||||
*end = *a1;
|
||||
} else if end.dist(*b0) <= meet {
|
||||
*end = *b1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(slot) = v.get_mut(i) {
|
||||
*slot = refined.clone();
|
||||
}
|
||||
}
|
||||
LinkGeom::Circle(..) => {
|
||||
if let Some(slot) = v.get_mut(i) {
|
||||
*slot = refine_round(g.clone(), exact, size * 2e-3);
|
||||
}
|
||||
}
|
||||
// Lines have moved with the arcs they meet; nothing else is refitted.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Re-fit a circle or arc through the exact section points near it, when there are enough.
|
||||
fn refine_round(g: LinkGeom, exact: &[Vec2], tol: f64) -> LinkGeom {
|
||||
let (c, r) = match g {
|
||||
|
||||
@@ -368,6 +368,133 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_round_refitted_to_the_exact_section_still_meets_its_neighbours() {
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_sketch::LinkGeom;
|
||||
let v = Vec2::new;
|
||||
// A 10 x 10 square with an R2 round at its top right corner, fitted from mesh crossings:
|
||||
// the arc misses the round's circle (centre (8, 8)) by 5e-4 but meets the sides exactly.
|
||||
// The exact crossings lie on the circle, and the refit puts the arc on it. The outline has
|
||||
// to stay closed: a profile joins ends only within 1e-4, so a wider gap leaves none (the
|
||||
// R2 rounds of a section at y = 20 through grooved walls, tangency-labs/solidcore#2908).
|
||||
let c = v(7.9995, 7.9995);
|
||||
let (p, q) = (v(10., c.y), v(c.x, 10.));
|
||||
let exact: Vec<Vec2> =
|
||||
(0..=8).map(|k| f64::from(k) * std::f64::consts::FRAC_PI_8 / 2.).map(|t| v(8. + 2. * t.cos(), 8. + 2. * t.sin())).collect();
|
||||
let square = |top: Vec<LinkGeom>| {
|
||||
let mut out = vec![LinkGeom::Line(v(0., 0.), v(10., 0.)), LinkGeom::Line(v(10., 0.), p), LinkGeom::Arc { c, a: p, b: q }];
|
||||
out.extend(top);
|
||||
out.push(LinkGeom::Line(v(0., 10.), v(0., 0.)));
|
||||
out
|
||||
};
|
||||
// The top side a line, or an arc of radius 10 (a shallow round on it) and then a line.
|
||||
let bulge = v(c.x, 20.);
|
||||
let from = bulge + v(-(10f64.to_radians().sin()), -(10f64.to_radians().cos())) * 10.;
|
||||
let cases =
|
||||
[square(vec![LinkGeom::Line(q, v(0., 10.))]), square(vec![LinkGeom::Arc { c: bulge, a: from, b: q }, LinkGeom::Line(from, v(0., 10.))])];
|
||||
// Ends as the sketch takes them: an arc ends on the circle through its start.
|
||||
let ends = |g: &LinkGeom| match *g {
|
||||
LinkGeom::Line(s, t) => vec![s, t],
|
||||
LinkGeom::Arc { c, a, b } => vec![a, c + (b - c).normalized().unwrap_or(Vec2::X) * c.dist(a)],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for (n, fitted) in cases.into_iter().enumerate() {
|
||||
// The size of the section: the square's diagonal.
|
||||
let out = super::super::refine_rounds(fitted, &exact, 200f64.sqrt());
|
||||
for (i, g) in out.iter().enumerate() {
|
||||
for e in ends(g) {
|
||||
let gap =
|
||||
out.iter().enumerate().filter(|(j, _)| *j != i).flat_map(|(_, h)| ends(h)).map(|x| x.dist(e)).fold(f64::INFINITY, f64::min);
|
||||
assert!(gap < 1e-9, "case {n}: gap {gap} at {e:?}: {out:?}");
|
||||
}
|
||||
}
|
||||
// Where the round meets only lines, the arc lies on the round's circle.
|
||||
if n == 0 {
|
||||
let Some(LinkGeom::Arc { c, a, b }) = out.get(2) else { panic!("{out:?}") };
|
||||
assert!(c.dist(v(8., 8.)) < 1e-9 && (c.dist(*a) - 2.).abs() < 1e-9 && (c.dist(*b) - 2.).abs() < 1e-9, "{out:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_round_beside_another_arc_is_refitted_when_their_joint_stays_put() {
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_sketch::LinkGeom;
|
||||
let v = Vec2::new;
|
||||
// The R2 round of a 10 x 10 square misses its circle (centre (8, 8)) by `s`, and an arc of
|
||||
// radius 10 runs on from its end. The refit moves that joint by `s`. While that is within
|
||||
// the 1e-4 a profile joins ends across, the round is refitted as before the fix for
|
||||
// tangency-labs/solidcore#2908, and the profile still closes.
|
||||
let exact: Vec<Vec2> =
|
||||
(0..=8).map(|k| f64::from(k) * std::f64::consts::FRAC_PI_8 / 2.).map(|t| v(8. + 2. * t.cos(), 8. + 2. * t.sin())).collect();
|
||||
for s in [1e-8, 1e-6, 1e-5, 5e-5, 9e-5] {
|
||||
let c = v(8., 8. + s);
|
||||
let (p, q) = (v(10., c.y), v(8., 10. + s));
|
||||
let bulge = v(8., 20. + s);
|
||||
let from = bulge + v(-(10f64.to_radians().sin()), -(10f64.to_radians().cos())) * 10.;
|
||||
let fitted = vec![
|
||||
LinkGeom::Line(v(0., 0.), v(10., 0.)),
|
||||
LinkGeom::Line(v(10., 0.), p),
|
||||
LinkGeom::Arc { c, a: p, b: q },
|
||||
LinkGeom::Arc { c: bulge, a: from, b: q },
|
||||
LinkGeom::Line(from, v(0., 10.)),
|
||||
LinkGeom::Line(v(0., 10.), v(0., 0.)),
|
||||
];
|
||||
let out = super::super::refine_rounds(fitted, &exact, 200f64.sqrt());
|
||||
let (Some(LinkGeom::Arc { c, b, .. }), Some(LinkGeom::Arc { b: joint, .. })) = (out.get(2), out.get(3)) else { panic!("{out:?}") };
|
||||
assert!(c.dist(v(8., 8.)) < 1e-12, "shift {s}: the round is refitted: {out:?}");
|
||||
assert!(b.dist(*joint) < 1e-4, "shift {s}: {out:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_arcs_refitted_the_same_way_keep_their_joint() {
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_sketch::LinkGeom;
|
||||
let v = Vec2::new;
|
||||
// The R2 round of a 10 x 10 square and the arc of radius 10 running on from it both miss
|
||||
// their circles (centres (8, 8) and (8, 20)) by `s` in the same direction. Both refits move
|
||||
// their joint from (8, 10 + s) to (8, 10), so the two refitted arcs still meet, as they do
|
||||
// before the fix for tangency-labs/solidcore#2908, even where `s` is past a profile's 1e-4.
|
||||
let round = (0..=8).map(|k| f64::from(k) * std::f64::consts::FRAC_PI_8 / 2.).map(|t| v(8. + 2. * t.cos(), 8. + 2. * t.sin()));
|
||||
let side = [261., 263., 265., 267., 269.].map(|d: f64| v(8. + 10. * d.to_radians().cos(), 20. + 10. * d.to_radians().sin()));
|
||||
let exact: Vec<Vec2> = round.chain(side).collect();
|
||||
for s in [1e-5, 2e-4, 5e-4] {
|
||||
let c = v(8., 8. + s);
|
||||
let (p, q) = (v(10., c.y), v(8., 10. + s));
|
||||
let bulge = v(8., 20. + s);
|
||||
let from = bulge + v(-(10f64.to_radians().sin()), -(10f64.to_radians().cos())) * 10.;
|
||||
let fitted = vec![
|
||||
LinkGeom::Line(v(0., 0.), v(10., 0.)),
|
||||
LinkGeom::Line(v(10., 0.), p),
|
||||
LinkGeom::Arc { c, a: p, b: q },
|
||||
LinkGeom::Arc { c: bulge, a: from, b: q },
|
||||
LinkGeom::Line(from, v(0., 10.)),
|
||||
LinkGeom::Line(v(0., 10.), v(0., 0.)),
|
||||
];
|
||||
let out = super::super::refine_rounds(fitted, &exact, 200f64.sqrt());
|
||||
let (Some(LinkGeom::Arc { c: c1, b: b1, .. }), Some(LinkGeom::Arc { c: c2, b: b2, .. })) = (out.get(2), out.get(3)) else {
|
||||
panic!("{out:?}")
|
||||
};
|
||||
assert!(c1.dist(v(8., 8.)) < 1e-9 && c2.dist(v(8., 20.)) < 1e-9, "shift {s}: both arcs are refitted: {out:?}");
|
||||
assert!(b1.dist(*b2) < 1e-4, "shift {s}: {out:?}");
|
||||
// The lines on the arcs' other ends move with them: the whole outline stays closed.
|
||||
let ends = |g: &LinkGeom| match *g {
|
||||
LinkGeom::Line(s, t) => vec![s, t],
|
||||
LinkGeom::Arc { c, a, b } => vec![a, c + (b - c).normalized().unwrap_or(Vec2::X) * c.dist(a)],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for (i, g) in out.iter().enumerate() {
|
||||
for e in ends(g) {
|
||||
let gap =
|
||||
out.iter().enumerate().filter(|(j, _)| *j != i).flat_map(|(_, h)| ends(h)).map(|x| x.dist(e)).fold(f64::INFINITY, f64::min);
|
||||
assert!(gap < 1e-9, "shift {s}: gap {gap} at {e:?}: {out:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundaries_touching_at_a_vertex_project() {
|
||||
// Two squares of one face family meet only at the corner (1, 1): four boundary edges meet
|
||||
|
||||
@@ -465,7 +465,17 @@ mod tests {
|
||||
let doc = crate::Document { render: Arc::new(settings.clone()), ..Default::default() };
|
||||
let wire = doc.to_json();
|
||||
assert_eq!(crate::Document::from_json(&wire).unwrap(), doc);
|
||||
assert!(wire.contains("embedded-surface-textures"));
|
||||
// The saved design holds the texture's bytes under the resource's own key.
|
||||
fn resource_saved(v: &serde_json::Value, key: &str) -> bool {
|
||||
match v {
|
||||
serde_json::Value::Object(map) => {
|
||||
map.get("texture_resources").and_then(|t| t.get(key)).is_some() || map.values().any(|v| resource_saved(v, key))
|
||||
}
|
||||
serde_json::Value::Array(items) => items.iter().any(|v| resource_saved(v, key)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
assert!(resource_saved(&serde_json::from_str(&wire).unwrap(), &id.to_string()));
|
||||
assert!(!wire.contains("builtin://"));
|
||||
assert!(!wire.contains("file://"));
|
||||
settings.texture_resources.remove(&id);
|
||||
|
||||
@@ -871,6 +871,86 @@ fn a_join_keeps_blocks_apart_in_one_body() {
|
||||
assert_eq!(kept, None);
|
||||
}
|
||||
|
||||
/// A body of two pieces joined apart, split by a plane through both pieces: the split gives the
|
||||
/// halves of every piece, not the halves of one of them (#6738).
|
||||
#[test]
|
||||
fn a_split_of_a_body_of_two_pieces_gives_the_halves_of_both() {
|
||||
let mut doc = Document::new("split two pieces");
|
||||
// One sketch, two squares 20 mm across and 30 mm apart.
|
||||
let mut sk = Sketch::new();
|
||||
let mut corners = Vec::new();
|
||||
for x in [0.0, 50.0] {
|
||||
let p: Vec<usize> = [Vec2::new(x, 10.0), Vec2::new(x + 20.0, 10.0), Vec2::new(x + 20.0, 30.0), Vec2::new(x, 30.0)]
|
||||
.iter()
|
||||
.map(|c| sk.add_point(*c, None).unwrap())
|
||||
.collect();
|
||||
for i in 0..4 {
|
||||
sk.add_line_pts(p[i], p[(i + 1) % 4], None).unwrap();
|
||||
}
|
||||
corners.extend(p);
|
||||
}
|
||||
for p in corners {
|
||||
sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap();
|
||||
}
|
||||
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: crate::SketchId(s),
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "10".into(),
|
||||
direction: Direction::Positive,
|
||||
distance2: None,
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
second_follows_first: false,
|
||||
taper: None,
|
||||
corner: Default::default(),
|
||||
to: None,
|
||||
to_offset: None,
|
||||
from: None,
|
||||
to_body_apart: false,
|
||||
},
|
||||
operation: Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Combine { target: "Body1".into(), tools: vec!["Body2".into()], operation: Operation::Join, keep_tools: false },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
// A plane through the middle of both squares (the extrusion is 10 mm tall).
|
||||
let split = doc
|
||||
.add_feature(
|
||||
FeatureKind::Split {
|
||||
body: "Body1".into(),
|
||||
plane: PlaneRef::Offset { base: Box::new(PlaneRef::Origin { name: "XY".into() }), distance: "5".into() },
|
||||
tool: None,
|
||||
sketch: None,
|
||||
line: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
let error = m.result(split).unwrap().error.clone();
|
||||
assert!(error.is_none(), "the split of a body of two pieces: {error:?}");
|
||||
let bodies: Vec<(String, f64, usize)> =
|
||||
m.state().bodies.iter().map(|b| (b.name.clone(), measure(&b.body).unwrap().volume, b.body.lumps().unwrap().len())).collect();
|
||||
assert_eq!(bodies.len(), 2, "{bodies:?}");
|
||||
for (name, volume, lumps) in &bodies {
|
||||
// Each half holds the half of both squares: two pieces, half the body each.
|
||||
assert!(rel(*volume, 4000.0) < 1e-6, "{name}: {bodies:?}");
|
||||
assert_eq!(*lumps, 2, "{name}: {bodies:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A square ring joined with an arm touching its outer corner along an edge and a hub apart in
|
||||
/// its hole: the ring and the hub stay one body under the ring's name, the arm is split off, and
|
||||
/// the join says it touched along an edge.
|
||||
|
||||
@@ -6,9 +6,9 @@ use crate::model::{Integrator, MAX_MAGNITUDE, MAX_STEPS, Model, Settings};
|
||||
use crate::system::System;
|
||||
use crate::{DynamicsError, Result, Sample, Stats, Trajectory};
|
||||
|
||||
/// Dormand–Prince 5(4) tableau (the system is autonomous, so the nodes are not needed): stage
|
||||
/// weights, fifth-order weights and the difference between the fifth- and fourth-order weights
|
||||
/// (the error estimate).
|
||||
/// Dormand–Prince 5(4) tableau: nodes, stage weights, fifth-order weights and the difference
|
||||
/// between the fifth- and fourth-order weights (the error estimate).
|
||||
const DP_C: [f64; 7] = [0.0, 1.0 / 5.0, 3.0 / 10.0, 4.0 / 5.0, 8.0 / 9.0, 1.0, 1.0];
|
||||
const DP_A: [[f64; 6]; 7] = [
|
||||
[0.0; 6],
|
||||
[1.0 / 5.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
@@ -58,17 +58,18 @@ struct Run<'a> {
|
||||
}
|
||||
|
||||
impl Run<'_> {
|
||||
fn deriv(&self, y: &[f64]) -> Result<Vec<f64>> {
|
||||
self.sys.accelerations(y).map(|a| a.ydot)
|
||||
fn deriv(&self, t: f64, y: &[f64]) -> Result<Vec<f64>> {
|
||||
self.sys.accelerations(t, y).map(|a| a.ydot)
|
||||
}
|
||||
|
||||
/// Normalise, project and check a new state.
|
||||
fn finish_step(&mut self, y: &mut [f64], t: f64) -> Result<()> {
|
||||
self.sys.normalize(y).map_err(|e| at_time(e, t))?;
|
||||
if self.settings.projection {
|
||||
self.sys.project_positions(y, self.settings.projection_tol).map_err(|e| at_time(e, t))?;
|
||||
self.sys.project_velocities(y).map_err(|e| at_time(e, t))?;
|
||||
self.sys.project_positions(t, y, self.settings.projection_tol).map_err(|e| at_time(e, t))?;
|
||||
self.sys.project_velocities(t, y).map_err(|e| at_time(e, t))?;
|
||||
}
|
||||
self.sys.resync(y);
|
||||
if !y.iter().all(|x| x.is_finite() && x.abs() < MAX_MAGNITUDE) {
|
||||
return Err(DynamicsError::Diverged { time: t });
|
||||
}
|
||||
@@ -82,23 +83,23 @@ impl Run<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rk4(&mut self, y: &[f64], h: f64) -> Result<Vec<f64>> {
|
||||
let k1 = self.deriv(y)?;
|
||||
let k2 = self.deriv(&combine(y, std::slice::from_ref(&k1), &[0.5], h))?;
|
||||
let k3 = self.deriv(&combine(y, std::slice::from_ref(&k2), &[0.5], h))?;
|
||||
let k4 = self.deriv(&combine(y, std::slice::from_ref(&k3), &[1.0], h))?;
|
||||
fn rk4(&mut self, t: f64, y: &[f64], h: f64) -> Result<Vec<f64>> {
|
||||
let k1 = self.deriv(t, y)?;
|
||||
let k2 = self.deriv(t + 0.5 * h, &combine(y, std::slice::from_ref(&k1), &[0.5], h))?;
|
||||
let k3 = self.deriv(t + 0.5 * h, &combine(y, std::slice::from_ref(&k2), &[0.5], h))?;
|
||||
let k4 = self.deriv(t + h, &combine(y, std::slice::from_ref(&k3), &[1.0], h))?;
|
||||
Ok(combine(y, &[k1, k2, k3, k4], &[1.0 / 6.0, 1.0 / 3.0, 1.0 / 3.0, 1.0 / 6.0], h))
|
||||
}
|
||||
|
||||
/// One Dormand–Prince step: the fifth-order state and the scaled error norm.
|
||||
fn dopri(&mut self, y: &[f64], h: f64, rel_tol: f64, abs_tol: f64) -> Result<(Vec<f64>, f64)> {
|
||||
fn dopri(&mut self, t: f64, y: &[f64], h: f64, rel_tol: f64, abs_tol: f64) -> Result<(Vec<f64>, f64)> {
|
||||
let mut ks: Vec<Vec<f64>> = Vec::with_capacity(7);
|
||||
for a in DP_A.iter().take(6) {
|
||||
for (a, c) in DP_A.iter().zip(DP_C).take(6) {
|
||||
let stage = combine(y, &ks, a, h);
|
||||
ks.push(self.deriv(&stage)?);
|
||||
ks.push(self.deriv(t + c * h, &stage)?);
|
||||
}
|
||||
let y5 = combine(y, &ks, &DP_B, h);
|
||||
ks.push(self.deriv(&y5)?);
|
||||
ks.push(self.deriv(t + h, &y5)?);
|
||||
let err = combine(&vec![0.0; y.len()], &ks, &DP_E, h);
|
||||
let n = y.len().max(1) as f64;
|
||||
let sum: f64 = err
|
||||
@@ -123,7 +124,7 @@ impl Run<'_> {
|
||||
let h = span / n;
|
||||
for _ in 0..n as usize {
|
||||
self.count_step(*t)?;
|
||||
let mut next = self.rk4(y, h).map_err(|e| at_time(e, *t))?;
|
||||
let mut next = self.rk4(*t, y, h).map_err(|e| at_time(e, *t))?;
|
||||
*t += h;
|
||||
self.finish_step(&mut next, *t)?;
|
||||
*y = next;
|
||||
@@ -136,7 +137,7 @@ impl Run<'_> {
|
||||
self.count_step(*t)?;
|
||||
let remaining = t_end - *t;
|
||||
let h = h_next.clamp(min_step, max_step).min(remaining);
|
||||
let (mut next, err) = self.dopri(y, h, rel_tol, abs_tol).map_err(|e| at_time(e, *t))?;
|
||||
let (mut next, err) = self.dopri(*t, y, h, rel_tol, abs_tol).map_err(|e| at_time(e, *t))?;
|
||||
// The usual controller: 0.9·err^(−1/5), within [0.2, 5].
|
||||
let factor = if err > 0.0 { (0.9 * err.powf(-0.2)).clamp(0.2, 5.0) } else { 5.0 };
|
||||
if err <= 1.0 {
|
||||
@@ -164,11 +165,18 @@ impl Run<'_> {
|
||||
}
|
||||
|
||||
fn sample(&mut self, y: &[f64], t: f64) -> Result<Sample> {
|
||||
let acc = self.sys.accelerations(y).map_err(|e| at_time(e, t))?;
|
||||
let constraint_error = self.sys.constraint_error(y);
|
||||
let acc = self.sys.accelerations(t, y).map_err(|e| at_time(e, t))?;
|
||||
let constraint_error = self.sys.constraint_error(t, y);
|
||||
self.stats.max_constraint_error = self.stats.max_constraint_error.max(constraint_error);
|
||||
self.stats.redundant_equations = self.stats.redundant_equations.max(acc.row_count().saturating_sub(acc.rank));
|
||||
Ok(Sample { time: t, bodies: self.sys.body_states(y), joints: self.sys.joint_results(y, &acc), energy: self.sys.energy(y), constraint_error })
|
||||
Ok(Sample {
|
||||
time: t,
|
||||
bodies: self.sys.body_states(y),
|
||||
joints: self.sys.joint_results(y, &acc),
|
||||
drives: self.sys.drive_efforts(&acc),
|
||||
energy: self.sys.energy(y),
|
||||
constraint_error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,11 +185,12 @@ impl Run<'_> {
|
||||
pub fn simulate(model: &Model, settings: &Settings) -> Result<Trajectory> {
|
||||
settings.validate()?;
|
||||
let sys = System::new(model, settings.baumgarte)?;
|
||||
let mut y = System::initial_state(model)?;
|
||||
// An inconsistent start (frames a little apart, velocities the joints don't allow) is
|
||||
// brought onto the joints first, whether or not later steps project.
|
||||
sys.project_positions(&mut y, settings.projection_tol).map_err(|e| at_time(e, 0.0))?;
|
||||
sys.project_velocities(&mut y).map_err(|e| at_time(e, 0.0))?;
|
||||
let mut y = sys.initial_state(model)?;
|
||||
// An inconsistent start (frames a little apart, velocities the joints don't allow, drives
|
||||
// starting elsewhere) is brought onto the joints first, whether or not later steps project.
|
||||
sys.project_positions(0.0, &mut y, settings.projection_tol).map_err(|e| at_time(e, 0.0))?;
|
||||
sys.project_velocities(0.0, &mut y).map_err(|e| at_time(e, 0.0))?;
|
||||
sys.resync(&mut y);
|
||||
let mut run = Run { sys: &sys, settings, stats: Stats::default() };
|
||||
let count = (settings.duration / settings.output_interval).ceil() as usize;
|
||||
let mut samples = Vec::with_capacity(count + 1);
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
//!
|
||||
//! The crate knows nothing of documents or occurrences: a [`Model`] lists bodies (mass,
|
||||
//! inertia about the centre of mass, starting pose and velocities), joints between frames on
|
||||
//! them ([`JointKind`]: rigid, revolute, slider, cylindrical, planar, ball) and force elements;
|
||||
//! them ([`JointKind`]: rigid, revolute, slider, cylindrical, planar, ball), force elements
|
||||
//! (point and joint spring-dampers, motors along joint values, loads), drives that make a joint
|
||||
//! value follow a [`Profile`] in time, and motion links (gears, racks, screws) between values;
|
||||
//! [`simulate`] returns a [`Trajectory`] of poses, joint values and rates, joint reactions and
|
||||
//! energies at a fixed output interval. The assembly layer maps occurrences and joints to it.
|
||||
//!
|
||||
@@ -31,8 +33,8 @@ use solidcore_record::Vec3;
|
||||
pub use integrate::simulate;
|
||||
pub use math::{Mat3, Quat};
|
||||
pub use model::{
|
||||
Anchor, Body, Force, Integrator, Joint, JointKind, MAX_BODIES, MAX_CONSTRAINT_ROWS, MAX_FORCES, MAX_JOINTS, MAX_MAGNITUDE,
|
||||
MAX_PROJECTION_ITERATIONS, MAX_SAMPLES, MAX_STEPS, Marker, Model, STANDARD_GRAVITY, Settings,
|
||||
Anchor, Body, Coord, Drive, Force, Integrator, Joint, JointKind, Link, MAX_BODIES, MAX_CONSTRAINT_ROWS, MAX_FORCES, MAX_JOINTS, MAX_MAGNITUDE,
|
||||
MAX_PROFILE_POINTS, MAX_PROJECTION_ITERATIONS, MAX_SAMPLES, MAX_STEPS, Marker, Model, Profile, STANDARD_GRAVITY, Settings,
|
||||
};
|
||||
|
||||
/// Why a simulation could not run or stopped.
|
||||
@@ -90,14 +92,25 @@ pub struct Energy {
|
||||
pub kinetic: f64,
|
||||
/// Potential energy of gravity, zero at the world origin.
|
||||
pub gravity: f64,
|
||||
/// Energy stored in springs (preload included).
|
||||
/// Energy stored in springs, point and joint ones (preload included).
|
||||
pub spring: f64,
|
||||
/// Work put in since the start by motors, drives and loads.
|
||||
#[serde(default)]
|
||||
pub input: f64,
|
||||
/// Energy taken out since the start by dampers.
|
||||
#[serde(default)]
|
||||
pub dissipated: f64,
|
||||
}
|
||||
|
||||
impl Energy {
|
||||
/// Kinetic plus potential energy.
|
||||
pub fn total(&self) -> f64 {
|
||||
self.kinetic + self.gravity + self.spring
|
||||
}
|
||||
/// `total + dissipated − input`: constant along a run up to the integration error.
|
||||
pub fn balance(&self) -> f64 {
|
||||
self.total() + self.dissipated - self.input
|
||||
}
|
||||
}
|
||||
|
||||
/// The system at one output time.
|
||||
@@ -107,8 +120,12 @@ pub struct Sample {
|
||||
pub time: f64,
|
||||
pub bodies: Vec<BodyState>,
|
||||
pub joints: Vec<JointResult>,
|
||||
/// What each drive applies along its joint value to make it follow (mN·mm for an angle, mN
|
||||
/// for a distance), in [`Model::drives`] order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub drives: Vec<f64>,
|
||||
pub energy: Energy,
|
||||
/// The largest joint equation error (mm for points; directions are unitless).
|
||||
/// The largest constraint equation error (mm for points; directions are unitless).
|
||||
pub constraint_error: f64,
|
||||
}
|
||||
|
||||
|
||||
@@ -141,10 +141,172 @@ pub struct Joint {
|
||||
pub b: Marker,
|
||||
}
|
||||
|
||||
/// A force element.
|
||||
/// One value of a joint: `dof` indexes [`JointKind::values`]. Revolute, slider, cylindrical
|
||||
/// and planar joints have values that springs, motors, drives and links can act on; ball and
|
||||
/// rigid joints have none.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Coord {
|
||||
pub joint: usize,
|
||||
#[serde(default)]
|
||||
pub dof: usize,
|
||||
}
|
||||
|
||||
impl Coord {
|
||||
pub fn new(joint: usize, dof: usize) -> Coord {
|
||||
Coord { joint, dof }
|
||||
}
|
||||
}
|
||||
|
||||
/// Points a table may hold at most.
|
||||
pub const MAX_PROFILE_POINTS: usize = 10_000;
|
||||
|
||||
/// A value along time (s): a motor's torque or force, or the value a drive imposes (radians,
|
||||
/// mm).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Profile {
|
||||
Constant {
|
||||
value: f64,
|
||||
},
|
||||
/// `value + rate·t`.
|
||||
Linear {
|
||||
value: f64,
|
||||
rate: f64,
|
||||
},
|
||||
/// From `from` to `to` between `start` and `start + duration`, along a cycloidal step
|
||||
/// (`τ − sin(2πτ)/2π`) whose speed and acceleration both start and end at zero.
|
||||
Ramp {
|
||||
from: f64,
|
||||
to: f64,
|
||||
#[serde(default)]
|
||||
start: f64,
|
||||
duration: f64,
|
||||
},
|
||||
/// `offset + amplitude·sin(2π·frequency·t + phase)` (frequency in Hz).
|
||||
Harmonic {
|
||||
#[serde(default)]
|
||||
offset: f64,
|
||||
amplitude: f64,
|
||||
frequency: f64,
|
||||
#[serde(default)]
|
||||
phase: f64,
|
||||
},
|
||||
/// (time, value) points, times increasing: linear in between, held before the first and
|
||||
/// after the last. As a drive its speed jumps at the points (its acceleration is taken as 0).
|
||||
Table {
|
||||
points: Vec<(f64, f64)>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
/// The value and its first and second time derivatives at `t`.
|
||||
pub fn eval(&self, t: f64) -> (f64, f64, f64) {
|
||||
match self {
|
||||
Profile::Constant { value } => (*value, 0.0, 0.0),
|
||||
Profile::Linear { value, rate } => (value + rate * t, *rate, 0.0),
|
||||
Profile::Ramp { from, to, start, duration } => {
|
||||
let tau = (t - start) / duration;
|
||||
if !(tau > 0.0) {
|
||||
return (*from, 0.0, 0.0);
|
||||
}
|
||||
if tau >= 1.0 {
|
||||
return (*to, 0.0, 0.0);
|
||||
}
|
||||
let span = to - from;
|
||||
let (s, c) = (std::f64::consts::TAU * tau).sin_cos();
|
||||
(
|
||||
from + span * (tau - s / std::f64::consts::TAU),
|
||||
span / duration * (1.0 - c),
|
||||
span / (duration * duration) * std::f64::consts::TAU * s,
|
||||
)
|
||||
}
|
||||
Profile::Harmonic { offset, amplitude, frequency, phase } => {
|
||||
let w = std::f64::consts::TAU * frequency;
|
||||
let (s, c) = (w * t + phase).sin_cos();
|
||||
(offset + amplitude * s, amplitude * w * c, -amplitude * w * w * s)
|
||||
}
|
||||
Profile::Table { points } => {
|
||||
let (Some(first), Some(last)) = (points.first(), points.last()) else { return (0.0, 0.0, 0.0) };
|
||||
if t <= first.0 {
|
||||
return (first.1, 0.0, 0.0);
|
||||
}
|
||||
if t >= last.0 {
|
||||
return (last.1, 0.0, 0.0);
|
||||
}
|
||||
points
|
||||
.windows(2)
|
||||
.find_map(|w| {
|
||||
let (a, b) = (w.first()?, w.get(1)?);
|
||||
(t >= a.0 && t < b.0).then(|| {
|
||||
let slope = (b.1 - a.1) / (b.0 - a.0);
|
||||
(a.1 + slope * (t - a.0), slope, 0.0)
|
||||
})
|
||||
})
|
||||
.unwrap_or((last.1, 0.0, 0.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self) -> std::result::Result<(), String> {
|
||||
let fin = |x: f64| x.is_finite() && x.abs() < MAX_MAGNITUDE;
|
||||
let ok = match self {
|
||||
Profile::Constant { value } => fin(*value),
|
||||
Profile::Linear { value, rate } => fin(*value) && fin(*rate),
|
||||
Profile::Ramp { from, to, start, duration } => fin(*from) && fin(*to) && fin(*start) && fin(*duration) && *duration > 1e-9,
|
||||
Profile::Harmonic { offset, amplitude, frequency, phase } => {
|
||||
fin(*offset) && fin(*amplitude) && fin(*frequency) && fin(*phase) && frequency.abs() < 1e6
|
||||
}
|
||||
Profile::Table { points } => {
|
||||
if points.is_empty() || points.len() > MAX_PROFILE_POINTS {
|
||||
return Err(format!("a table needs 1 to {MAX_PROFILE_POINTS} points"));
|
||||
}
|
||||
points.iter().all(|(t, v)| fin(*t) && fin(*v))
|
||||
&& points.windows(2).all(|w| matches!((w.first(), w.get(1)), (Some(a), Some(b)) if b.0 > a.0))
|
||||
}
|
||||
};
|
||||
if ok { Ok(()) } else { Err("a profile's numbers must be finite (ramps need a duration, tables increasing times)".into()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A joint value made to follow a profile along time (radians or mm): the joint turns or
|
||||
/// slides as told, whatever it takes; [`crate::Sample::drives`] reports what it takes.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Drive {
|
||||
pub at: Coord,
|
||||
pub motion: Profile,
|
||||
}
|
||||
|
||||
/// A motion link: `follower = ratio·driver + offset`, between values of two joints (a gear
|
||||
/// pair: angle to angle; a rack and pinion: angle to distance, the ratio being the pinion's
|
||||
/// radius; a screw: angle to distance along the same cylindrical joint, the ratio being
|
||||
/// pitch / 2π). Without an offset, the one that holds at the start is kept.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Link {
|
||||
pub driver: Coord,
|
||||
pub follower: Coord,
|
||||
pub ratio: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<f64>,
|
||||
}
|
||||
|
||||
/// A force element.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Force {
|
||||
/// A spring and damper on a joint value: the joint is pushed back by
|
||||
/// `stiffness·(value − rest) + damping·rate` (torsional for an angle: mN·mm per radian and
|
||||
/// mN·mm·s; linear for a distance: kg/s² and kg/s).
|
||||
JointSpring {
|
||||
at: Coord,
|
||||
stiffness: f64,
|
||||
#[serde(default)]
|
||||
damping: f64,
|
||||
#[serde(default)]
|
||||
rest: f64,
|
||||
},
|
||||
/// A motor's torque (mN·mm) or force (mN) along a joint value, following a profile: it
|
||||
/// pushes B's side forward and A's side back.
|
||||
Motor { at: Coord, effort: Profile },
|
||||
/// A linear spring and damper between two points: the tension is
|
||||
/// `stiffness·(length − free_length) + damping·(rate of length) + preload`.
|
||||
Spring {
|
||||
@@ -243,6 +405,10 @@ pub struct Model {
|
||||
pub joints: Vec<Joint>,
|
||||
#[serde(default)]
|
||||
pub forces: Vec<Force>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub drives: Vec<Drive>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub links: Vec<Link>,
|
||||
/// Gravity (mm/s²), e.g. `(0, 0, −9806.65)`.
|
||||
#[serde(default)]
|
||||
pub gravity: Vec3,
|
||||
@@ -282,7 +448,10 @@ impl Model {
|
||||
if self.forces.len() > MAX_FORCES {
|
||||
return Err(DynamicsError::TooLarge(format!("{} force elements (at most {MAX_FORCES})", self.forces.len())));
|
||||
}
|
||||
let rows: usize = self.joints.iter().map(|j| j.kind.equations()).sum();
|
||||
if self.drives.len() > MAX_JOINTS || self.links.len() > MAX_JOINTS {
|
||||
return Err(DynamicsError::TooLarge(format!("drives and links: at most {MAX_JOINTS} each")));
|
||||
}
|
||||
let rows: usize = self.joints.iter().map(|j| j.kind.equations()).sum::<usize>() + self.drives.len() + self.links.len();
|
||||
if rows > MAX_CONSTRAINT_ROWS {
|
||||
return Err(DynamicsError::TooLarge(format!("{rows} constraint equations (at most {MAX_CONSTRAINT_ROWS})")));
|
||||
}
|
||||
@@ -339,8 +508,46 @@ impl Model {
|
||||
return Err(bad(format!("force {i}: point, force and torque must be finite")));
|
||||
}
|
||||
}
|
||||
Force::JointSpring { at, stiffness, damping, rest } => {
|
||||
self.check_coord(*at).map_err(|e| bad(format!("force {i}: {e}")))?;
|
||||
let ok = |x: f64| (0.0..MAX_MAGNITUDE).contains(&x);
|
||||
if !ok(*stiffness) || !ok(*damping) || !rest.is_finite() || rest.abs() >= MAX_MAGNITUDE {
|
||||
return Err(bad(format!("force {i}: stiffness and damping must be ≥ 0 and finite, rest finite")));
|
||||
}
|
||||
}
|
||||
Force::Motor { at, effort } => {
|
||||
self.check_coord(*at).map_err(|e| bad(format!("force {i}: {e}")))?;
|
||||
effort.validate().map_err(|e| bad(format!("force {i}: {e}")))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, d) in self.drives.iter().enumerate() {
|
||||
self.check_coord(d.at).map_err(|e| bad(format!("drive {i}: {e}")))?;
|
||||
d.motion.validate().map_err(|e| bad(format!("drive {i}: {e}")))?;
|
||||
}
|
||||
for (i, l) in self.links.iter().enumerate() {
|
||||
self.check_coord(l.driver).map_err(|e| bad(format!("link {i}: {e}")))?;
|
||||
self.check_coord(l.follower).map_err(|e| bad(format!("link {i}: {e}")))?;
|
||||
if l.driver == l.follower {
|
||||
return Err(bad(format!("link {i}: a value can't drive itself")));
|
||||
}
|
||||
let fin = |x: f64| x.is_finite() && x.abs() < MAX_MAGNITUDE;
|
||||
if !fin(l.ratio) || !l.offset.is_none_or(fin) {
|
||||
return Err(bad(format!("link {i}: ratio and offset must be finite")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The joint and value a [`Coord`] names, if they exist and can be acted on.
|
||||
fn check_coord(&self, c: Coord) -> std::result::Result<(), String> {
|
||||
let j = self.joints.get(c.joint).ok_or_else(|| format!("no joint {}", c.joint))?;
|
||||
if matches!(j.kind, JointKind::Ball | JointKind::Rigid) {
|
||||
return Err(format!("joint {}: not supported yet: springs, motors, drives and links on {:?} joints", c.joint, j.kind));
|
||||
}
|
||||
if c.dof >= j.kind.values().len() {
|
||||
return Err(format!("joint {} has no value {}", c.joint, c.dof));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+306
-40
@@ -12,13 +12,20 @@
|
||||
//!
|
||||
//! through the Schur complement `(J M⁻¹ Jᵀ) λ = J M⁻¹ F − γ` (`linalg::solve_psd`, which takes
|
||||
//! repeated joint equations), and `λ` gives the joints' reactions.
|
||||
//!
|
||||
//! Joint springs and motors act along joint values (generalized forces `Jᵀ Q` along a value's
|
||||
//! row); drives add a rheonomic equation `value − f(t) = 0` (so `J u = ḟ`, `J u̇ = f̈ − J̇ u`),
|
||||
//! and motion links a linear one between two values. The values they use are also kept in the
|
||||
//! state, so an angle counts whole turns.
|
||||
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
use crate::linalg::{Sym, solve_psd};
|
||||
use crate::math::{Mat3, Quat, cross2};
|
||||
use crate::model::{Force, JointKind, MAX_MAGNITUDE, MAX_PROJECTION_ITERATIONS, Model, marker_axes};
|
||||
use crate::model::{Coord, Force, JointKind, MAX_MAGNITUDE, MAX_PROJECTION_ITERATIONS, Model, Profile, marker_axes};
|
||||
use crate::{BodyState, DynamicsError, Energy, JointResult, Result};
|
||||
|
||||
/// Numbers per body in the flat state: x (3), q (4), v (3), ω (3).
|
||||
@@ -97,18 +104,32 @@ struct MarkerWorld {
|
||||
/// A body's part of one Jacobian row: (linear, angular).
|
||||
type Block = (usize, Vec3, Vec3);
|
||||
|
||||
/// One constraint equation at a state.
|
||||
/// One constraint equation at a state: `Φ = 0`, `J u = rate_target`.
|
||||
pub(crate) struct Row {
|
||||
/// A's and B's parts (for a joint's own equations, its two sides).
|
||||
a: Option<Block>,
|
||||
b: Option<Block>,
|
||||
/// A motion link's driver's parts.
|
||||
c: Option<Block>,
|
||||
d: Option<Block>,
|
||||
pub(crate) phi: f64,
|
||||
/// `J̇ u`: the equation's second derivative at zero accelerations.
|
||||
/// `J̇ u` less the imposed acceleration: the equation's second derivative at zero
|
||||
/// accelerations.
|
||||
jdot_u: f64,
|
||||
/// The rate a drive imposes (`ḟ`); 0 for the others.
|
||||
rate_target: f64,
|
||||
}
|
||||
|
||||
fn scaled(b: Option<Block>, k: f64) -> Option<Block> {
|
||||
b.map(|(i, l, a)| (i, l * k, a * k))
|
||||
}
|
||||
|
||||
impl Row {
|
||||
fn new(a: Option<Block>, b: Option<Block>, phi: f64, jdot_u: f64) -> Row {
|
||||
Row { a, b, c: None, d: None, phi, jdot_u, rate_target: 0.0 }
|
||||
}
|
||||
fn blocks(&self) -> impl Iterator<Item = &Block> {
|
||||
self.a.iter().chain(self.b.iter())
|
||||
self.a.iter().chain(self.b.iter()).chain(self.c.iter()).chain(self.d.iter())
|
||||
}
|
||||
/// `J u`.
|
||||
fn rate(&self, kin: &[Kin]) -> f64 {
|
||||
@@ -116,13 +137,58 @@ impl Row {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a joint value is measured.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum CoordKind {
|
||||
/// B's turn about A's z (from A's x towards A's y).
|
||||
Angle,
|
||||
/// B's origin along one of A's axes (0, 1, 2 = x, y, z).
|
||||
Along(usize),
|
||||
}
|
||||
|
||||
/// A joint value springs, motors, drives or links act on. Its value is kept in the state too,
|
||||
/// so an angle counts whole turns.
|
||||
#[derive(Clone, Copy)]
|
||||
struct CoordConst {
|
||||
joint: usize,
|
||||
kind: CoordKind,
|
||||
}
|
||||
|
||||
struct LinkConst {
|
||||
driver: usize,
|
||||
follower: usize,
|
||||
ratio: f64,
|
||||
offset: f64,
|
||||
}
|
||||
|
||||
/// A model ready to integrate.
|
||||
///
|
||||
/// The flat state holds every body (`STRIDE` numbers each), then the tracked joint values,
|
||||
/// then the work put in (motors, drives, loads) and the energy dissipated (dampers).
|
||||
pub(crate) struct System {
|
||||
bodies: Vec<BodyConst>,
|
||||
joints: Vec<JointConst>,
|
||||
/// Point springs and loads.
|
||||
forces: Vec<Force>,
|
||||
gravity: Vec3,
|
||||
baumgarte: Option<(f64, f64)>,
|
||||
coords: Vec<CoordConst>,
|
||||
/// (value, stiffness, damping, rest).
|
||||
springs: Vec<(usize, f64, f64, f64)>,
|
||||
/// (value, effort).
|
||||
motors: Vec<(usize, Profile)>,
|
||||
/// (value, motion).
|
||||
drives: Vec<(usize, Profile)>,
|
||||
links: Vec<LinkConst>,
|
||||
}
|
||||
|
||||
/// `θ` turned by whole turns to lie nearest `near`.
|
||||
fn unwrap_angle(theta: f64, near: f64) -> f64 {
|
||||
if !near.is_finite() {
|
||||
return theta;
|
||||
}
|
||||
let turns = ((near - theta) / TAU).round();
|
||||
if turns.is_finite() { theta + TAU * turns } else { theta }
|
||||
}
|
||||
|
||||
/// Accelerations and multipliers at one state.
|
||||
@@ -164,12 +230,81 @@ impl System {
|
||||
.iter()
|
||||
.map(|j| Ok(JointConst { kind: j.kind, a: marker_const(&j.a)?, b: marker_const(&j.b)? }))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(System { bodies, joints, forces: model.forces.clone(), gravity: model.gravity, baumgarte })
|
||||
let mut sys = System {
|
||||
bodies,
|
||||
joints,
|
||||
forces: model.forces.iter().filter(|f| matches!(f, Force::Spring { .. } | Force::Load { .. })).cloned().collect(),
|
||||
gravity: model.gravity,
|
||||
baumgarte,
|
||||
coords: Vec::new(),
|
||||
springs: Vec::new(),
|
||||
motors: Vec::new(),
|
||||
drives: Vec::new(),
|
||||
links: Vec::new(),
|
||||
};
|
||||
let mut found: Vec<Coord> = Vec::new();
|
||||
let mut index = |c: Coord, sys: &mut System| -> Result<usize> {
|
||||
if let Some(i) = found.iter().position(|f| *f == c) {
|
||||
return Ok(i);
|
||||
}
|
||||
let kind = model.joints.get(c.joint).map(|j| j.kind);
|
||||
let kind = match (kind, c.dof) {
|
||||
(Some(JointKind::Revolute | JointKind::Cylindrical), 0) | (Some(JointKind::Planar), 2) => CoordKind::Angle,
|
||||
(Some(JointKind::Slider), 0) | (Some(JointKind::Cylindrical), 1) => CoordKind::Along(2),
|
||||
(Some(JointKind::Planar), d @ (0 | 1)) => CoordKind::Along(d),
|
||||
_ => return Err(DynamicsError::InvalidModel(format!("joint {} has no value {} to act on", c.joint, c.dof))),
|
||||
};
|
||||
found.push(c);
|
||||
sys.coords.push(CoordConst { joint: c.joint, kind });
|
||||
Ok(found.len() - 1)
|
||||
};
|
||||
for f in &model.forces {
|
||||
match f {
|
||||
Force::JointSpring { at, stiffness, damping, rest } => {
|
||||
let i = index(*at, &mut sys)?;
|
||||
sys.springs.push((i, *stiffness, *damping, *rest));
|
||||
}
|
||||
Force::Motor { at, effort } => {
|
||||
let i = index(*at, &mut sys)?;
|
||||
sys.motors.push((i, effort.clone()));
|
||||
}
|
||||
Force::Spring { .. } | Force::Load { .. } => {}
|
||||
}
|
||||
}
|
||||
for d in &model.drives {
|
||||
let i = index(d.at, &mut sys)?;
|
||||
sys.drives.push((i, d.motion.clone()));
|
||||
}
|
||||
let mut pending = Vec::new();
|
||||
for l in &model.links {
|
||||
pending.push((index(l.driver, &mut sys)?, index(l.follower, &mut sys)?, l.ratio, l.offset));
|
||||
}
|
||||
// A link without an offset keeps the one that holds at the start.
|
||||
let y = sys.initial_state(model)?;
|
||||
let kin = sys.kinematics(&y);
|
||||
let value = |i: usize| sys.coord_row(&kin, &y, i).phi;
|
||||
let links = pending
|
||||
.into_iter()
|
||||
.map(|(d, f, ratio, offset)| LinkConst { driver: d, follower: f, ratio, offset: offset.unwrap_or_else(|| value(f) - ratio * value(d)) })
|
||||
.collect();
|
||||
sys.links = links;
|
||||
Ok(sys)
|
||||
}
|
||||
|
||||
fn coord_base(&self) -> usize {
|
||||
self.bodies.len() * STRIDE
|
||||
}
|
||||
fn work_base(&self) -> usize {
|
||||
self.coord_base() + self.coords.len()
|
||||
}
|
||||
/// Numbers in the flat state.
|
||||
pub(crate) fn state_len(&self) -> usize {
|
||||
self.work_base() + 2
|
||||
}
|
||||
|
||||
/// The flat state of the model's starting conditions.
|
||||
pub(crate) fn initial_state(model: &Model) -> Result<Vec<f64>> {
|
||||
let mut y = vec![0.0; model.bodies.len() * STRIDE];
|
||||
pub(crate) fn initial_state(&self, model: &Model) -> Result<Vec<f64>> {
|
||||
let mut y = vec![0.0; self.state_len()];
|
||||
for (i, b) in model.bodies.iter().enumerate() {
|
||||
let at = i * STRIDE;
|
||||
let q = b.orientation.normalized().ok_or_else(|| DynamicsError::InvalidModel(format!("body {i}: orientation is not a rotation")))?;
|
||||
@@ -178,9 +313,48 @@ impl System {
|
||||
put3(&mut y, at + 7, b.velocity);
|
||||
put3(&mut y, at + 10, b.angular_velocity);
|
||||
}
|
||||
// A driven angle counts its turns from where its motion starts.
|
||||
for (i, motion) in &self.drives {
|
||||
if let Some(s) = y.get_mut(self.coord_base() + i) {
|
||||
*s = motion.eval(0.0).0;
|
||||
}
|
||||
}
|
||||
self.resync(&mut y);
|
||||
Ok(y)
|
||||
}
|
||||
|
||||
/// Sets each tracked joint value to the one the bodies show (an angle: the turn nearest
|
||||
/// the tracked one).
|
||||
pub(crate) fn resync(&self, y: &mut [f64]) {
|
||||
let kin = self.kinematics(y);
|
||||
let values: Vec<f64> = (0..self.coords.len()).map(|i| self.coord_row(&kin, y, i).phi).collect();
|
||||
for (i, v) in values.into_iter().enumerate() {
|
||||
if let Some(s) = y.get_mut(self.coord_base() + i) {
|
||||
*s = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tracked joint value's equation row: `Φ` is the value itself, `J u` its rate.
|
||||
fn coord_row(&self, kin: &[Kin], y: &[f64], i: usize) -> Row {
|
||||
let (Some(c), tracked) = (self.coords.get(i), y.get(self.coord_base() + i).copied().unwrap_or(f64::NAN)) else {
|
||||
return Row::new(None, None, 0.0, 0.0);
|
||||
};
|
||||
let Some(j) = self.joints.get(c.joint) else { return Row::new(None, None, 0.0, 0.0) };
|
||||
let a = Self::marker_world(kin, &j.a);
|
||||
let b = Self::marker_world(kin, &j.b);
|
||||
let [ax, ay, az] = a.axes;
|
||||
match c.kind {
|
||||
CoordKind::Angle => {
|
||||
let bx = b.axes[0];
|
||||
let theta = unwrap_angle(bx.dot(ay).atan2(bx.dot(ax)), tracked);
|
||||
// θ̇ = (ω_B − ω_A)·z_A; at zero accelerations its rate is (ω_B − ω_A)·(ω_A × z_A).
|
||||
Row::new(a.body.map(|k| (k, Vec3::ZERO, -az)), b.body.map(|k| (k, Vec3::ZERO, az)), theta, (b.w - a.w).dot(a.w.cross(az)))
|
||||
}
|
||||
CoordKind::Along(k) => dot2_row(&a, a.axes.get(k).copied().unwrap_or(az), &b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit quaternions again after a step.
|
||||
pub(crate) fn normalize(&self, y: &mut [f64]) -> Result<()> {
|
||||
for i in 0..self.bodies.len() {
|
||||
@@ -210,17 +384,42 @@ impl System {
|
||||
MarkerWorld { body: m.body, r, p: k.x + r, pdot: k.v + k.w.cross(r), axes, w: k.w }
|
||||
}
|
||||
|
||||
/// The constraint equations of every joint at `kin`, in joint order.
|
||||
fn rows(&self, kin: &[Kin]) -> Vec<Row> {
|
||||
/// The constraint equations at time `t`: every joint's in joint order, then one per drive
|
||||
/// (`value − f(t) = 0`), then one per link (`follower − ratio·driver − offset = 0`).
|
||||
fn rows(&self, kin: &[Kin], t: f64, y: &[f64]) -> Vec<Row> {
|
||||
let mut rows = Vec::new();
|
||||
for j in &self.joints {
|
||||
let a = Self::marker_world(kin, &j.a);
|
||||
let b = Self::marker_world(kin, &j.b);
|
||||
joint_rows(j.kind, &a, &b, &mut rows);
|
||||
}
|
||||
for (i, motion) in &self.drives {
|
||||
let (f, fd, fdd) = motion.eval(t);
|
||||
let mut r = self.coord_row(kin, y, *i);
|
||||
r.phi -= f;
|
||||
r.jdot_u -= fdd;
|
||||
r.rate_target = fd;
|
||||
rows.push(r);
|
||||
}
|
||||
for l in &self.links {
|
||||
let (f, d) = (self.coord_row(kin, y, l.follower), self.coord_row(kin, y, l.driver));
|
||||
rows.push(Row {
|
||||
a: f.a,
|
||||
b: f.b,
|
||||
c: scaled(d.a, -l.ratio),
|
||||
d: scaled(d.b, -l.ratio),
|
||||
phi: f.phi - l.ratio * d.phi - l.offset,
|
||||
jdot_u: f.jdot_u - l.ratio * d.jdot_u,
|
||||
rate_target: 0.0,
|
||||
});
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn joint_row_count(&self) -> usize {
|
||||
self.joints.iter().map(|j| j.kind.equations()).sum()
|
||||
}
|
||||
|
||||
/// `M⁻¹` applied to one body's (force, torque).
|
||||
fn minv(&self, kin: &[Kin], body: usize, f: Vec3, t: Vec3) -> (Vec3, Vec3) {
|
||||
match (self.bodies.get(body), kin.get(body)) {
|
||||
@@ -264,8 +463,38 @@ impl System {
|
||||
(s, w)
|
||||
}
|
||||
|
||||
/// Applied forces and torques (about each centre of mass, world), gyroscopic terms included.
|
||||
fn applied(&self, kin: &[Kin]) -> Vec<(Vec3, Vec3)> {
|
||||
/// Applied forces and torques (about each centre of mass, world), gyroscopic terms included,
|
||||
/// and the power they put in and the power dampers take out.
|
||||
fn applied(&self, kin: &[Kin], t: f64, y: &[f64]) -> (Vec<(Vec3, Vec3)>, f64, f64) {
|
||||
let mut input = 0.0;
|
||||
let mut dissipated = 0.0;
|
||||
let mut out = self.point_forces(kin, &mut input, &mut dissipated);
|
||||
// A generalized force Q along a joint value is Jᵀ Q on the bodies.
|
||||
let mut along = |row: &Row, q: f64| {
|
||||
for (i, l, a) in row.blocks() {
|
||||
if let Some(slot) = out.get_mut(*i) {
|
||||
slot.0 += *l * q;
|
||||
slot.1 += *a * q;
|
||||
}
|
||||
}
|
||||
};
|
||||
for (i, k, c, rest) in &self.springs {
|
||||
let row = self.coord_row(kin, y, *i);
|
||||
let rate = row.rate(kin);
|
||||
along(&row, -k * (row.phi - rest) - c * rate);
|
||||
dissipated += c * rate * rate;
|
||||
}
|
||||
for (i, effort) in &self.motors {
|
||||
let row = self.coord_row(kin, y, *i);
|
||||
let q = effort.eval(t).0;
|
||||
along(&row, q);
|
||||
input += q * row.rate(kin);
|
||||
}
|
||||
(out, input, dissipated)
|
||||
}
|
||||
|
||||
/// Gravity, gyroscopic terms, point springs and loads.
|
||||
fn point_forces(&self, kin: &[Kin], input: &mut f64, dissipated: &mut f64) -> Vec<(Vec3, Vec3)> {
|
||||
let mut out: Vec<(Vec3, Vec3)> = self
|
||||
.bodies
|
||||
.iter()
|
||||
@@ -293,21 +522,25 @@ impl System {
|
||||
let tension = stiffness * (len - free_length) + damping * rate + preload;
|
||||
add(a.body, ra, n * tension, Vec3::ZERO);
|
||||
add(b.body, rb, n * -tension, Vec3::ZERO);
|
||||
*dissipated += damping * rate * rate;
|
||||
}
|
||||
Force::Load { body, point, force, torque } => {
|
||||
let r = kin.get(body).map_or(Vec3::ZERO, |k| k.rot.mul_vec(point));
|
||||
let k = kin.get(body).copied().unwrap_or(GROUND);
|
||||
let r = k.rot.mul_vec(point);
|
||||
add(Some(body), r, force, torque);
|
||||
*input += force.dot(k.v + k.w.cross(r)) + torque.dot(k.w);
|
||||
}
|
||||
Force::JointSpring { .. } | Force::Motor { .. } => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The state's derivative and the joints' multipliers.
|
||||
pub(crate) fn accelerations(&self, y: &[f64]) -> Result<Accel> {
|
||||
pub(crate) fn accelerations(&self, t: f64, y: &[f64]) -> Result<Accel> {
|
||||
let kin = self.kinematics(y);
|
||||
let applied = self.applied(&kin);
|
||||
let rows = self.rows(&kin);
|
||||
let (applied, mut input, dissipated) = self.applied(&kin, t, y);
|
||||
let rows = self.rows(&kin, t, y);
|
||||
let (s, w) = self.schur(&kin, &rows);
|
||||
// Unconstrained accelerations M⁻¹F.
|
||||
let free: Vec<(Vec3, Vec3)> = applied.iter().enumerate().map(|(i, (f, t))| self.minv(&kin, i, *f, *t)).collect();
|
||||
@@ -315,7 +548,7 @@ impl System {
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let jmf: f64 = r.blocks().map(|(i, l, a)| free.get(*i).map_or(0.0, |(fl, fa)| l.dot(*fl) + a.dot(*fa))).sum();
|
||||
let stab = self.baumgarte.map_or(0.0, |(alpha, beta)| 2.0 * alpha * r.rate(&kin) + beta * beta * r.phi);
|
||||
let stab = self.baumgarte.map_or(0.0, |(alpha, beta)| 2.0 * alpha * (r.rate(&kin) - r.rate_target) + beta * beta * r.phi);
|
||||
jmf + r.jdot_u + stab
|
||||
})
|
||||
.collect();
|
||||
@@ -338,25 +571,50 @@ impl System {
|
||||
put3(&mut ydot, at + 7, a.0);
|
||||
put3(&mut ydot, at + 10, a.1);
|
||||
}
|
||||
for i in 0..self.coords.len() {
|
||||
let rate = self.coord_row(&kin, y, i).rate(&kin);
|
||||
if let Some(s) = ydot.get_mut(self.coord_base() + i) {
|
||||
*s = rate;
|
||||
}
|
||||
}
|
||||
// A drive's generalized force is −λ along its value: its power is −λ·(value rate).
|
||||
let first_drive = self.joint_row_count();
|
||||
for k in 0..self.drives.len() {
|
||||
if let (Some(r), Some(l)) = (rows.get(first_drive + k), lambda.get(first_drive + k)) {
|
||||
input -= l * r.rate(&kin);
|
||||
}
|
||||
}
|
||||
let wb = self.work_base();
|
||||
for (k, p) in [input, dissipated].into_iter().enumerate() {
|
||||
if let Some(s) = ydot.get_mut(wb + k) {
|
||||
*s = p;
|
||||
}
|
||||
}
|
||||
if !ydot.iter().all(|x| x.is_finite()) {
|
||||
return Err(DynamicsError::Diverged { time: f64::NAN });
|
||||
}
|
||||
Ok(Accel { ydot, lambda, rows, rank })
|
||||
}
|
||||
|
||||
/// The largest joint equation error at `y`.
|
||||
pub(crate) fn constraint_error(&self, y: &[f64]) -> f64 {
|
||||
self.rows(&self.kinematics(y)).iter().map(|r| r.phi.abs()).fold(0.0, f64::max)
|
||||
/// What each drive applies along its value (mN·mm or mN), from the multipliers.
|
||||
pub(crate) fn drive_efforts(&self, acc: &Accel) -> Vec<f64> {
|
||||
let first = self.joint_row_count();
|
||||
(0..self.drives.len()).map(|k| -acc.lambda.get(first + k).copied().unwrap_or(0.0)).collect()
|
||||
}
|
||||
|
||||
/// The largest constraint equation error at `y` and time `t`.
|
||||
pub(crate) fn constraint_error(&self, t: f64, y: &[f64]) -> f64 {
|
||||
self.rows(&self.kinematics(y), t, y).iter().map(|r| r.phi.abs()).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Moves the bodies the least (mass-weighted) onto the joints: Gauss–Newton on `Φ = 0`.
|
||||
/// Returns the error left.
|
||||
pub(crate) fn project_positions(&self, y: &mut [f64], tol: f64) -> Result<f64> {
|
||||
pub(crate) fn project_positions(&self, t: f64, y: &mut [f64], tol: f64) -> Result<f64> {
|
||||
let mut err;
|
||||
let mut iteration = 0;
|
||||
loop {
|
||||
let kin = self.kinematics(y);
|
||||
let rows = self.rows(&kin);
|
||||
let rows = self.rows(&kin, t, y);
|
||||
err = rows.iter().map(|r| r.phi.abs()).fold(0.0, f64::max);
|
||||
if !(err > tol) || iteration >= MAX_PROJECTION_ITERATIONS {
|
||||
break;
|
||||
@@ -387,15 +645,16 @@ impl System {
|
||||
Ok(err)
|
||||
}
|
||||
|
||||
/// Removes the velocities the joints don't allow (mass-weighted): `J u = 0`.
|
||||
pub(crate) fn project_velocities(&self, y: &mut [f64]) -> Result<()> {
|
||||
/// Removes the velocities the joints don't allow (mass-weighted): `J u = ḟ` (0 but for
|
||||
/// drives).
|
||||
pub(crate) fn project_velocities(&self, t: f64, y: &mut [f64]) -> Result<()> {
|
||||
let kin = self.kinematics(y);
|
||||
let rows = self.rows(&kin);
|
||||
let rows = self.rows(&kin, t, y);
|
||||
if rows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (s, w) = self.schur(&kin, &rows);
|
||||
let ju: Vec<f64> = rows.iter().map(|r| r.rate(&kin)).collect();
|
||||
let ju: Vec<f64> = rows.iter().map(|r| r.rate(&kin) - r.rate_target).collect();
|
||||
let (mu, _) = solve_psd(s, &ju, RANK_TOL).ok_or(DynamicsError::Singular { time: f64::NAN })?;
|
||||
for (wr, m) in w.iter().zip(&mu) {
|
||||
for (i, ml, ma) in wr {
|
||||
@@ -417,7 +676,7 @@ impl System {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Kinetic, gravity and spring energy.
|
||||
/// Kinetic, gravity and spring energy, and the work put in and dissipated so far.
|
||||
pub(crate) fn energy(&self, y: &[f64]) -> Energy {
|
||||
let kin = self.kinematics(y);
|
||||
let mut e = Energy::default();
|
||||
@@ -433,6 +692,13 @@ impl System {
|
||||
e.spring += 0.5 * stiffness * stretch * stretch + preload * stretch;
|
||||
}
|
||||
}
|
||||
for (i, k, _, rest) in &self.springs {
|
||||
let stretch = self.coord_row(&kin, y, *i).phi - rest;
|
||||
e.spring += 0.5 * k * stretch * stretch;
|
||||
}
|
||||
let wb = self.work_base();
|
||||
e.input = y.get(wb).copied().unwrap_or(0.0);
|
||||
e.dissipated = y.get(wb + 1).copied().unwrap_or(0.0);
|
||||
e
|
||||
}
|
||||
|
||||
@@ -477,19 +743,19 @@ impl System {
|
||||
/// `(Φ, J, J̇u)` for one body point's world component along `e`: `e·(p_B − p_A)`.
|
||||
fn point_row(a: &MarkerWorld, b: &MarkerWorld, e: Vec3) -> Row {
|
||||
let acc = |m: &MarkerWorld| cross2(m.w, m.r);
|
||||
Row { a: a.body.map(|i| (i, -e, -a.r.cross(e))), b: b.body.map(|i| (i, e, b.r.cross(e))), phi: e.dot(b.p - a.p), jdot_u: e.dot(acc(b) - acc(a)) }
|
||||
Row::new(a.body.map(|i| (i, -e, -a.r.cross(e))), b.body.map(|i| (i, e, b.r.cross(e))), e.dot(b.p - a.p), e.dot(acc(b) - acc(a)))
|
||||
}
|
||||
|
||||
/// `u·v = 0` for a direction `u` of A and `v` of B.
|
||||
fn dot1_row(a: &MarkerWorld, u: Vec3, b: &MarkerWorld, v: Vec3) -> Row {
|
||||
let (ud, vd) = (a.w.cross(u), b.w.cross(v));
|
||||
let (udd, vdd) = (cross2(a.w, u), cross2(b.w, v));
|
||||
Row {
|
||||
a: a.body.map(|i| (i, Vec3::ZERO, u.cross(v))),
|
||||
b: b.body.map(|i| (i, Vec3::ZERO, v.cross(u))),
|
||||
phi: u.dot(v),
|
||||
jdot_u: udd.dot(v) + 2.0 * ud.dot(vd) + u.dot(vdd),
|
||||
}
|
||||
Row::new(
|
||||
a.body.map(|i| (i, Vec3::ZERO, u.cross(v))),
|
||||
b.body.map(|i| (i, Vec3::ZERO, v.cross(u))),
|
||||
u.dot(v),
|
||||
udd.dot(v) + 2.0 * ud.dot(vd) + u.dot(vdd),
|
||||
)
|
||||
}
|
||||
|
||||
/// `d·u = 0` for `d = p_B − p_A` and a direction `u` of A.
|
||||
@@ -498,12 +764,12 @@ fn dot2_row(a: &MarkerWorld, u: Vec3, b: &MarkerWorld) -> Row {
|
||||
let dd = b.pdot - a.pdot;
|
||||
let ddd = cross2(b.w, b.r) - cross2(a.w, a.r);
|
||||
let (ud, udd) = (a.w.cross(u), cross2(a.w, u));
|
||||
Row {
|
||||
a: a.body.map(|i| (i, -u, u.cross(d) - a.r.cross(u))),
|
||||
b: b.body.map(|i| (i, u, b.r.cross(u))),
|
||||
phi: d.dot(u),
|
||||
jdot_u: ddd.dot(u) + 2.0 * dd.dot(ud) + d.dot(udd),
|
||||
}
|
||||
Row::new(
|
||||
a.body.map(|i| (i, -u, u.cross(d) - a.r.cross(u))),
|
||||
b.body.map(|i| (i, u, b.r.cross(u))),
|
||||
d.dot(u),
|
||||
ddd.dot(u) + 2.0 * dd.dot(ud) + d.dot(udd),
|
||||
)
|
||||
}
|
||||
|
||||
fn joint_rows(kind: JointKind, a: &MarkerWorld, b: &MarkerWorld, rows: &mut Vec<Row>) {
|
||||
@@ -576,7 +842,7 @@ pub(crate) mod testing {
|
||||
/// `(Φ, J u, J̇ u)` of every row at `y`.
|
||||
pub(crate) fn rows_at(sys: &System, y: &[f64]) -> Vec<(f64, f64, f64)> {
|
||||
let kin = sys.kinematics(y);
|
||||
sys.rows(&kin).iter().map(|r| (r.phi, r.rate(&kin), r.jdot_u)).collect()
|
||||
sys.rows(&kin, 0.0, y).iter().map(|r| (r.phi, r.rate(&kin), r.jdot_u)).collect()
|
||||
}
|
||||
|
||||
/// The state moved along its velocities for `h` (positions and orientations only).
|
||||
|
||||
@@ -38,7 +38,7 @@ fn pendulum(len: f64, theta: f64, omega: f64, inertia: f64) -> Model {
|
||||
a: Marker::ground(Vec3::ZERO, Vec3::Y, Vec3::X),
|
||||
b: Marker::new(Some(0), Vec3::new(0.0, 0.0, len), Vec3::Y, Vec3::X),
|
||||
};
|
||||
Model { bodies: vec![body], joints: vec![joint], forces: vec![], gravity: down() }
|
||||
Model { bodies: vec![body], joints: vec![joint], forces: vec![], drives: vec![], links: vec![], gravity: down() }
|
||||
}
|
||||
|
||||
/// Times where `f` crosses zero upwards, linearly interpolated between samples.
|
||||
@@ -68,7 +68,7 @@ fn free_fall_follows_the_parabola() {
|
||||
let mut body = Body::at_rest(2.5, Vec3::new(10.0, 20.0, 30.0), Vec3::new(1.0, 2.0, 3.0));
|
||||
body.velocity = Vec3::new(10.0, -5.0, 50.0);
|
||||
body.angular_velocity = Vec3::new(0.0, 0.0, 1.0);
|
||||
let model = Model { bodies: vec![body.clone()], joints: vec![], forces: vec![], gravity: down() };
|
||||
let model = Model { bodies: vec![body.clone()], joints: vec![], forces: vec![], drives: vec![], links: vec![], gravity: down() };
|
||||
let traj = ok(simulate(&model, &Settings::rk4(1.0, 0.01, 0.1)));
|
||||
assert_eq!(traj.samples.len(), 11);
|
||||
for s in &traj.samples {
|
||||
@@ -158,6 +158,7 @@ fn double_pendulum() -> Model {
|
||||
],
|
||||
forces: vec![],
|
||||
gravity: down(),
|
||||
..Model::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +216,7 @@ fn slider_crank_follows_its_closed_form() {
|
||||
],
|
||||
forces: vec![],
|
||||
gravity: Vec3::ZERO,
|
||||
..Model::default()
|
||||
};
|
||||
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-3, 1e-2)));
|
||||
// A planar linkage built from 3D joints repeats three equations; they are solved, not refused.
|
||||
@@ -256,6 +258,7 @@ fn spring_mass(damping: f64) -> Model {
|
||||
preload: 0.0,
|
||||
}],
|
||||
gravity: down(),
|
||||
..Model::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +297,7 @@ fn torque_free_body_keeps_energy_and_momentum() {
|
||||
let mut body = Body::at_rest(1.0, Vec3::new(1000.0, 2000.0, 3000.0), Vec3::ZERO);
|
||||
body.orientation = Quat::from_axis_angle(Vec3::new(1.0, 2.0, 3.0), 0.7);
|
||||
body.angular_velocity = Vec3::new(1.0, 0.3, 5.0);
|
||||
let model = Model { bodies: vec![body], joints: vec![], forces: vec![], gravity: Vec3::ZERO };
|
||||
let model = Model { bodies: vec![body], joints: vec![], forces: vec![], drives: vec![], links: vec![], gravity: Vec3::ZERO };
|
||||
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-4, 1e-2)));
|
||||
let momentum = |s: &Sample| {
|
||||
let b = s.bodies.first().copied();
|
||||
@@ -329,6 +332,7 @@ fn spherical_pendulum_keeps_energy_and_vertical_momentum() {
|
||||
}],
|
||||
forces: vec![],
|
||||
gravity: down(),
|
||||
..Model::default()
|
||||
};
|
||||
let traj = ok(simulate(&model, &Settings::rk4(3.0, 5e-4, 1e-2)));
|
||||
let lz = |s: &Sample| {
|
||||
@@ -369,9 +373,10 @@ fn jacobians_match_finite_differences() {
|
||||
a: Marker::new(a_body, Vec3::new(4.0, -3.0, 7.0), Vec3::new(0.2, 0.5, 1.0), Vec3::new(1.0, 0.0, 0.3)),
|
||||
b: Marker::new(Some(1), Vec3::new(-6.0, 2.0, 1.0), Vec3::new(-0.4, 1.0, 0.1), Vec3::new(0.0, 0.3, 1.0)),
|
||||
};
|
||||
let model = Model { bodies: vec![a.clone(), b.clone()], joints: vec![joint], forces: vec![], gravity: Vec3::ZERO };
|
||||
let model =
|
||||
Model { bodies: vec![a.clone(), b.clone()], joints: vec![joint], forces: vec![], drives: vec![], links: vec![], gravity: Vec3::ZERO };
|
||||
let sys = ok(System::new(&model, None));
|
||||
let y = ok(System::initial_state(&model));
|
||||
let y = ok(sys.initial_state(&model));
|
||||
let h = 1e-4;
|
||||
let (now, plus, minus) = (
|
||||
testing::rows_at(&sys, &y),
|
||||
@@ -400,6 +405,7 @@ fn joint_values_read_the_relative_motion() {
|
||||
joints: vec![Joint { kind, a: Marker::ground(Vec3::ZERO, Vec3::Z, Vec3::X), b: Marker::new(Some(0), Vec3::ZERO, Vec3::Z, Vec3::X) }],
|
||||
forces: vec![],
|
||||
gravity: Vec3::ZERO,
|
||||
..Model::default()
|
||||
};
|
||||
let traj = ok(simulate(&model, &Settings::rk4(0.01, 0.01, 0.01)));
|
||||
traj.samples.first().and_then(|s| s.joints.first()).map(|j| j.values.clone()).unwrap_or_default()
|
||||
@@ -523,6 +529,10 @@ fn hostile_input_is_an_error_not_a_panic() {
|
||||
fn models_round_trip_through_json() {
|
||||
let mut model = spring_mass(1.5);
|
||||
model.forces.push(Force::Load { body: 0, point: Vec3::new(1.0, 0.0, 0.0), force: Vec3::new(0.0, 5.0, 0.0), torque: Vec3::ZERO });
|
||||
model.forces.push(Force::JointSpring { at: Coord::new(0, 0), stiffness: 3.0, damping: 0.5, rest: 1.0 });
|
||||
model.forces.push(Force::Motor { at: Coord::new(0, 0), effort: Profile::Table { points: vec![(0.0, 1.0), (2.0, 3.0)] } });
|
||||
model.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Ramp { from: 0.0, to: 1.0, start: 0.5, duration: 2.0 } });
|
||||
model.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: 2.0, offset: Some(0.5) });
|
||||
let text = match serde_json::to_string(&model) {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("{e}"),
|
||||
@@ -538,3 +548,253 @@ fn models_round_trip_through_json() {
|
||||
};
|
||||
assert!(settings.projection && settings.validate().is_ok());
|
||||
}
|
||||
|
||||
// Joint springs, motors, drives and motion links.
|
||||
|
||||
/// `a − b` turned into (−π, π].
|
||||
fn wrap(x: f64) -> f64 {
|
||||
(x + PI).rem_euclid(TAU) - PI
|
||||
}
|
||||
|
||||
/// A wheel turning about Z on a revolute to the ground at `at`, its centre of mass on the axis.
|
||||
/// Wheels turning about Z on revolutes to the ground, centres of mass on their axes (inertia,
|
||||
/// centre); joint `i` holds body `i`.
|
||||
fn rotors(parts: &[(f64, Vec3)]) -> Model {
|
||||
let mut model = Model::default();
|
||||
for (i, (inertia, at)) in parts.iter().enumerate() {
|
||||
model.bodies.push(Body::at_rest(1.0, Vec3::new(*inertia, *inertia, *inertia), *at));
|
||||
model.joints.push(Joint {
|
||||
kind: JointKind::Revolute,
|
||||
a: Marker::ground(*at, Vec3::Z, Vec3::X),
|
||||
b: Marker::new(Some(i), Vec3::ZERO, Vec3::Z, Vec3::X),
|
||||
});
|
||||
}
|
||||
model
|
||||
}
|
||||
|
||||
fn value(s: &Sample, joint: usize, dof: usize) -> f64 {
|
||||
s.joints.get(joint).and_then(|j| j.values.get(dof)).copied().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_give_consistent_derivatives() {
|
||||
let profiles = [
|
||||
Profile::Constant { value: 2.0 },
|
||||
Profile::Linear { value: 1.0, rate: -3.0 },
|
||||
Profile::Ramp { from: 0.5, to: 2.0, start: 0.2, duration: 0.6 },
|
||||
Profile::Harmonic { offset: 1.0, amplitude: 0.3, frequency: 2.0, phase: 0.4 },
|
||||
];
|
||||
let h = 1e-5;
|
||||
for p in &profiles {
|
||||
for k in 0..100 {
|
||||
let t = k as f64 * 0.0123;
|
||||
let (f, fd, fdd) = p.eval(t);
|
||||
let (fp, fdp, _) = p.eval(t + h);
|
||||
let (fm, fdm, _) = p.eval(t - h);
|
||||
assert!((fd - (fp - fm) / (2.0 * h)).abs() < 1e-5 * (1.0 + fd.abs()), "{p:?} at {t}: ḟ");
|
||||
assert!((fdd - (fdp - fdm) / (2.0 * h)).abs() < 1e-3 * (1.0 + fdd.abs()), "{p:?} at {t}: f̈");
|
||||
assert!(f.is_finite());
|
||||
}
|
||||
}
|
||||
let ramp = Profile::Ramp { from: 0.5, to: 2.0, start: 0.2, duration: 0.6 };
|
||||
assert_eq!(ramp.eval(0.0).0, 0.5);
|
||||
assert_eq!(ramp.eval(0.8).0, 2.0);
|
||||
assert!((ramp.eval(0.5).0 - 1.25).abs() < 1e-12);
|
||||
let table = Profile::Table { points: vec![(0.0, 1.0), (1.0, 3.0), (2.0, 0.0)] };
|
||||
assert_eq!(table.eval(-1.0), (1.0, 0.0, 0.0));
|
||||
assert_eq!(table.eval(0.5), (2.0, 2.0, 0.0));
|
||||
assert_eq!(table.eval(1.5), (1.5, -3.0, 0.0));
|
||||
assert_eq!(table.eval(9.0), (0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driven_pendulum_reaches_its_commanded_angle_and_holds_it() {
|
||||
// A drive lifts the pendulum from hanging to horizontal along a cycloidal ramp in 1 s,
|
||||
// then holds it: the angle follows the ramp, and the holding torque is m·g·L.
|
||||
let (len, mass) = (100.0, 1.0);
|
||||
let mut model = pendulum(len, 0.0, 0.0, 40.0);
|
||||
let ramp = Profile::Ramp { from: 0.0, to: PI / 2.0, start: 0.0, duration: 1.0 };
|
||||
model.drives.push(Drive { at: Coord::new(0, 0), motion: ramp.clone() });
|
||||
let traj = ok(simulate(&model, &Settings::rk4(1.5, 1e-3, 1e-2)));
|
||||
let b0 = traj.samples.first().map(|s| s.energy.balance()).unwrap_or(f64::NAN);
|
||||
for s in &traj.samples {
|
||||
let angle = value(s, 0, 0);
|
||||
assert!((angle - ramp.eval(s.time).0).abs() < 1e-9, "t={}: angle {angle} vs {}", s.time, ramp.eval(s.time).0);
|
||||
// Work put in = kinetic + gravity energy gained.
|
||||
assert!((s.energy.balance() - b0).abs() < 1e-6 * G * len, "t={}: balance {}", s.time, s.energy.balance() - b0);
|
||||
}
|
||||
let held: Vec<&Sample> = traj.samples.iter().filter(|s| s.time >= 1.0).collect();
|
||||
assert!(held.len() > 40);
|
||||
for s in held {
|
||||
let effort = s.drives.first().copied().unwrap_or(f64::NAN);
|
||||
assert!((effort.abs() - mass * G * len).abs() < 1e-6 * G * len, "t={}: holding torque {effort}", s.time);
|
||||
assert!((s.energy.input - mass * G * len).abs() < 1e-6 * G * len, "work put in {}", s.energy.input);
|
||||
assert!(s.energy.kinetic < 1e-9 * G * len);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driven_speed_counts_whole_turns() {
|
||||
// 10 rad/s for 2 s: more than three turns; the driven angle never jumps a turn.
|
||||
let mut model = rotors(&[(500.0, Vec3::ZERO)]);
|
||||
model.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Linear { value: 0.0, rate: 10.0 } });
|
||||
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-3, 1e-2)));
|
||||
for s in &traj.samples {
|
||||
assert!(wrap(value(s, 0, 0) - 10.0 * s.time).abs() < 1e-9, "t={}", s.time);
|
||||
let w = s.bodies.first().map(|b| b.angular_velocity.z).unwrap_or(f64::NAN);
|
||||
assert!((w - 10.0).abs() < 1e-9);
|
||||
assert!(s.drives.first().is_some_and(|e| e.abs() < 1e-6), "{:?}", s.drives);
|
||||
}
|
||||
assert!(traj.stats.max_constraint_error < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torsion_spring_period() {
|
||||
// ω = √(k/I) = √(5e5 / 500) rad/s; released from 0.3 rad.
|
||||
let (inertia, k) = (500.0_f64, 5e5_f64);
|
||||
let w = (k / inertia).sqrt();
|
||||
let mut model = rotors(&[(inertia, Vec3::ZERO)]);
|
||||
if let Some(b) = model.bodies.first_mut() {
|
||||
b.orientation = Quat::from_axis_angle(Vec3::Z, 0.3);
|
||||
}
|
||||
model.forces.push(Force::JointSpring { at: Coord::new(0, 0), stiffness: k, damping: 0.0, rest: 0.0 });
|
||||
let traj = ok(simulate(&model, &Settings::rk4(1.0, 1e-4, 1e-3)));
|
||||
for s in &traj.samples {
|
||||
let expect = 0.3 * (w * s.time).cos();
|
||||
assert!((value(s, 0, 0) - expect).abs() < 1e-7, "t={}: {} vs {expect}", s.time, value(s, 0, 0));
|
||||
}
|
||||
let measured = mean_period(&upward_crossings(&traj, |s| value(s, 0, 0)));
|
||||
assert!((measured - TAU / w).abs() < 1e-5 * TAU / w);
|
||||
let e0 = traj.samples.first().map(|s| s.energy.total()).unwrap_or(0.0);
|
||||
assert!((e0 - 0.5 * k * 0.09).abs() < 1e-9 * e0);
|
||||
// With damping the energy left plus the energy dissipated stays what it was.
|
||||
if let Some(Force::JointSpring { damping, .. }) = model.forces.first_mut() {
|
||||
*damping = 2.0 * 0.05 * (k * inertia).sqrt();
|
||||
}
|
||||
let traj = ok(simulate(&model, &Settings::rk4(1.0, 1e-4, 1e-2)));
|
||||
let last = traj.samples.last().map(|s| s.energy).unwrap_or_default();
|
||||
assert!(last.total() < 0.2 * e0 && last.dissipated > 0.8 * e0);
|
||||
assert!(traj.samples.iter().all(|s| (s.energy.balance() - e0).abs() < 1e-7 * e0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gear_pair_keeps_its_ratio_and_motor_work_is_kinetic_energy() {
|
||||
// A motor's constant torque τ on gear 1; gear 2 turns at −r₁/r₂ = −0.5 of it. The pair
|
||||
// accelerates as one inertia I₁ + 0.25·I₂: θ₁ = ½(τ/I_eff)t², and the motor's work τ·θ₁ is
|
||||
// all kinetic energy. Over 3 s gear 1 makes more than half a turn past ±π.
|
||||
let (i1, i2, ratio, tau) = (1000.0, 2000.0, -0.5, 1500.0);
|
||||
let alpha = tau / (i1 + ratio * ratio * i2);
|
||||
let mut model = rotors(&[(i1, Vec3::ZERO), (i2, Vec3::new(30.0, 0.0, 0.0))]);
|
||||
model.forces.push(Force::Motor { at: Coord::new(0, 0), effort: Profile::Constant { value: tau } });
|
||||
model.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio, offset: None });
|
||||
let traj = ok(simulate(&model, &Settings::rk4(3.0, 1e-3, 1e-2)));
|
||||
for s in &traj.samples {
|
||||
let theta = 0.5 * alpha * s.time * s.time;
|
||||
assert!(wrap(value(s, 0, 0) - theta).abs() < 1e-8, "t={}: gear 1 {} vs {theta}", s.time, value(s, 0, 0));
|
||||
assert!(wrap(value(s, 1, 0) - ratio * theta).abs() < 1e-8, "t={}: gear 2", s.time);
|
||||
assert!((s.energy.input - tau * theta).abs() < 1e-6 * tau * 4.5, "t={}: work {}", s.time, s.energy.input);
|
||||
assert!((s.energy.kinetic - s.energy.input).abs() < 1e-6 * tau * 4.5, "t={}: kinetic {}", s.time, s.energy.kinetic);
|
||||
}
|
||||
assert!(0.5 * alpha * 9.0 > PI);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rack_and_pinion_follow_the_pinion_radius() {
|
||||
// A pinion of radius 20 mm drives a rack on a slider along X; a harmonic motor torque. The
|
||||
// rack's travel is 20·θ, and the motor's work is the kinetic energy of both.
|
||||
let mut model = rotors(&[(800.0, Vec3::ZERO)]);
|
||||
model.bodies.push(Body::at_rest(0.5, Vec3::new(100.0, 100.0, 100.0), Vec3::new(0.0, 20.0, 0.0)));
|
||||
model.joints.push(Joint {
|
||||
kind: JointKind::Slider,
|
||||
a: Marker::ground(Vec3::new(0.0, 20.0, 0.0), Vec3::X, Vec3::Y),
|
||||
b: Marker::new(Some(1), Vec3::ZERO, Vec3::X, Vec3::Y),
|
||||
});
|
||||
model
|
||||
.forces
|
||||
.push(Force::Motor { at: Coord::new(0, 0), effort: Profile::Harmonic { offset: 0.0, amplitude: 2000.0, frequency: 1.0, phase: 0.0 } });
|
||||
model.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: 20.0, offset: None });
|
||||
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-3, 1e-2)));
|
||||
// I_eff = 800 + 0.5·20²; θ̈ = τ(t)/I_eff with τ = A sin(2πt): θ = A/I_eff·(t/2π − sin(2πt)/4π²).
|
||||
let i_eff = 800.0 + 0.5 * 400.0;
|
||||
for s in &traj.samples {
|
||||
let t = s.time;
|
||||
let theta = 2000.0 / i_eff * (t / TAU - (TAU * t).sin() / (TAU * TAU));
|
||||
assert!((value(s, 0, 0) - theta).abs() < 1e-8, "t={t}: pinion {} vs {theta}", value(s, 0, 0));
|
||||
assert!((value(s, 1, 0) - 20.0 * theta).abs() < 1e-7, "t={t}: rack {}", value(s, 1, 0));
|
||||
assert!((s.energy.kinetic - s.energy.input).abs() < 1e-8 * 2000.0, "t={t}: {:?}", s.energy);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screw_turns_as_it_falls() {
|
||||
// A nut on a vertical cylindrical joint, its travel tied to its turn by a 5 mm pitch, falls
|
||||
// under gravity: z̈ = −m·g·k² / (I + m·k²) with k = pitch / 2π.
|
||||
let (mass, inertia, k) = (0.2, 50.0, 5.0 / TAU);
|
||||
let model = Model {
|
||||
bodies: vec![Body::at_rest(mass, Vec3::new(inertia, inertia, inertia), Vec3::ZERO)],
|
||||
joints: vec![Joint {
|
||||
kind: JointKind::Cylindrical,
|
||||
a: Marker::ground(Vec3::ZERO, Vec3::Z, Vec3::X),
|
||||
b: Marker::new(Some(0), Vec3::ZERO, Vec3::Z, Vec3::X),
|
||||
}],
|
||||
links: vec![Link { driver: Coord::new(0, 0), follower: Coord::new(0, 1), ratio: k, offset: None }],
|
||||
gravity: down(),
|
||||
..Model::default()
|
||||
};
|
||||
let traj = ok(simulate(&model, &Settings::rk4(1.0, 1e-3, 1e-2)));
|
||||
let a = -mass * G * k * k / (inertia + mass * k * k);
|
||||
for s in &traj.samples {
|
||||
let z = s.bodies.first().map(|b| b.position.z).unwrap_or(f64::NAN);
|
||||
assert!((z - 0.5 * a * s.time * s.time).abs() < 1e-7, "t={}: z {z}", s.time);
|
||||
assert!(wrap(value(s, 0, 0) - z / k).abs() < 1e-8, "t={}: turn", s.time);
|
||||
assert!(s.energy.balance().abs() < 1e-6 * mass * G, "t={}: {:?}", s.time, s.energy);
|
||||
}
|
||||
// It went down more than one turn.
|
||||
assert!(0.5 * a < -5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_springs_motors_drives_and_links_are_errors() {
|
||||
let good = rotors(&[(500.0, Vec3::ZERO), (500.0, Vec3::new(30.0, 0.0, 0.0))]);
|
||||
let mut bad: Vec<Model> = Vec::new();
|
||||
let mut with = |f: &dyn Fn(&mut Model)| {
|
||||
let mut m = good.clone();
|
||||
f(&mut m);
|
||||
bad.push(m);
|
||||
};
|
||||
with(&|m| m.drives.push(Drive { at: Coord::new(5, 0), motion: Profile::Constant { value: 0.0 } }));
|
||||
with(&|m| m.drives.push(Drive { at: Coord::new(0, 1), motion: Profile::Constant { value: 0.0 } }));
|
||||
with(&|m| m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Constant { value: f64::NAN } }));
|
||||
with(&|m| m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Ramp { from: 0.0, to: 1.0, start: 0.0, duration: 0.0 } }));
|
||||
with(&|m| m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Table { points: vec![] } }));
|
||||
with(&|m| m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Table { points: vec![(1.0, 0.0), (0.5, 1.0)] } }));
|
||||
with(&|m| m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Table { points: vec![(0.0, 0.0); MAX_PROFILE_POINTS + 1] } }));
|
||||
with(&|m| m.forces.push(Force::JointSpring { at: Coord::new(0, 0), stiffness: -1.0, damping: 0.0, rest: 0.0 }));
|
||||
with(&|m| m.forces.push(Force::JointSpring { at: Coord::new(0, 0), stiffness: 1.0, damping: f64::INFINITY, rest: 0.0 }));
|
||||
with(&|m| m.forces.push(Force::Motor { at: Coord::new(9, 0), effort: Profile::Constant { value: 1.0 } }));
|
||||
with(&|m| {
|
||||
m.forces
|
||||
.push(Force::Motor { at: Coord::new(0, 0), effort: Profile::Harmonic { offset: 0.0, amplitude: 1.0, frequency: f64::NAN, phase: 0.0 } })
|
||||
});
|
||||
with(&|m| m.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(0, 0), ratio: 1.0, offset: None }));
|
||||
with(&|m| m.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: f64::NAN, offset: None }));
|
||||
with(&|m| m.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: 1.0, offset: Some(f64::INFINITY) }));
|
||||
with(&|m| m.links = vec![Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: 1.0, offset: None }; MAX_JOINTS + 1]);
|
||||
with(&|m| {
|
||||
// Ball and rigid joints have no value to act on yet.
|
||||
if let Some(j) = m.joints.get_mut(0) {
|
||||
j.kind = JointKind::Ball;
|
||||
}
|
||||
m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Constant { value: 0.0 } });
|
||||
});
|
||||
for (i, m) in bad.iter().enumerate() {
|
||||
assert!(simulate(m, &Settings::rk4(0.1, 1e-3, 1e-2)).is_err(), "case {i} should be refused");
|
||||
}
|
||||
// Links that fight each other, and drives that can't be met, stop or finish but never panic.
|
||||
let mut locked = good.clone();
|
||||
locked.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: 1.0, offset: None });
|
||||
locked.links.push(Link { driver: Coord::new(1, 0), follower: Coord::new(0, 0), ratio: 2.0, offset: None });
|
||||
locked.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Harmonic { offset: 0.0, amplitude: 1e6, frequency: 1e3, phase: 0.0 } });
|
||||
let _ = simulate(&locked, &Settings::rk4(0.1, 1e-3, 1e-2));
|
||||
let _ = simulate(&locked, &Settings::adaptive(0.1, 1e-2, 1e-9, 1e-9));
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ required-features = ["solver-profiling"]
|
||||
|
||||
[dependencies]
|
||||
solidcore-record = { workspace = true }
|
||||
solidcraft-color = { workspace = true }
|
||||
solidcraft-control = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-sketch = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
|
||||
@@ -1552,3 +1552,57 @@ fn join_that_meets_no_body_keeps_the_piece_and_says_so() {
|
||||
let combine = s.execute("solid.combine", &json!({"target": "A", "tools": ["B"], "operation": "join"})).unwrap();
|
||||
assert!(combine["warning"].is_null(), "{combine}");
|
||||
}
|
||||
|
||||
/// #12996: a circular pattern of an untargeted cut made in a component, run with another
|
||||
/// component active, cuts the bodies of the cut's own component, as the cut did.
|
||||
#[test]
|
||||
fn pattern_of_an_untargeted_cut_in_a_component_works_with_root_active() {
|
||||
let mut s = Session::default();
|
||||
let run = |s: &mut Session, command: &str, params: Value| s.execute(command, ¶ms).unwrap();
|
||||
run(&mut s, "component.create", json!({"name": "Main Body"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Body1"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "10 mm", "direction": "negative", "operation": "cut", "name": "Extrude62"}));
|
||||
let one_hole = volume(&s, "Body1");
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Other"}));
|
||||
run(&mut s, "solid.pattern.circular", json!({"features": ["Extrude62"], "axis": "Z", "count": 10}));
|
||||
let hole = 10.0 * 16.0 * std::f64::consts::PI;
|
||||
let got = volume(&s, "Body1");
|
||||
assert!((one_hole - got - 9.0 * hole).abs() < 0.02 * hole, "{one_hole} -> {got}");
|
||||
}
|
||||
|
||||
/// #12996: when the component active for the pattern has a body of its own, the copies of an
|
||||
/// untargeted cut keep cutting it, as they did before: saved designs rebuild the same.
|
||||
#[test]
|
||||
fn pattern_of_an_untargeted_cut_still_cuts_its_own_components_body() {
|
||||
let mut s = Session::default();
|
||||
let run = |s: &mut Session, command: &str, params: Value| s.execute(command, ¶ms).unwrap();
|
||||
run(&mut s, "component.create", json!({"name": "Main Body"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Body1"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "10 mm", "direction": "negative", "operation": "cut", "name": "Extrude62"}));
|
||||
let one_hole = volume(&s, "Body1");
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Other"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Plate2"}));
|
||||
let plate = volume(&s, "Plate2");
|
||||
run(&mut s, "solid.pattern.circular", json!({"features": ["Extrude62"], "axis": "Z", "count": 10}));
|
||||
let hole = 10.0 * 16.0 * std::f64::consts::PI;
|
||||
let (body1, plate2) = (volume(&s, "Body1"), volume(&s, "Plate2"));
|
||||
assert!((one_hole - body1).abs() < 0.02 * hole, "Body1 untouched: {one_hole} -> {body1}");
|
||||
assert!((plate - plate2 - 9.0 * hole).abs() < 0.02 * hole, "Plate2 cut: {plate} -> {plate2}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Which timeline features built each body (#11945): the data behind `model.body_features` and
|
||||
//! the Browser's optional features-under-bodies tree.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::params::{bad, str_};
|
||||
use crate::schema::inspect::{BodyFeature, BodyFeatures, ModelBodyFeatures};
|
||||
use crate::{Result, Session};
|
||||
|
||||
impl Session {
|
||||
/// The features that built each body of the evaluated model, by the body's model name, in
|
||||
/// timeline order: the feature that created it, every later feature that changed its
|
||||
/// geometry, and the features of the bodies a feature consumed while it made or changed this
|
||||
/// one (a Combine's tools, a body joined in). A piece a cut split off keeps the features of
|
||||
/// the body it was cut from. Features behind the history marker, which built nothing yet,
|
||||
/// are not listed.
|
||||
///
|
||||
/// Decided on exact body identity (a body a feature passed through unchanged is the same
|
||||
/// record), never on a mesh.
|
||||
pub fn body_features(&self) -> BTreeMap<String, Vec<u64>> {
|
||||
let order: HashMap<u64, usize> = self.doc.features.iter().enumerate().map(|(i, f)| (f.id, i)).collect();
|
||||
// Each body before the feature being looked at: its geometry, its creator and its features.
|
||||
let mut before: HashMap<String, (solidcraft_doc::kernel::Body, u64, Vec<u64>)> = HashMap::new();
|
||||
for r in &self.model.results {
|
||||
let id = r.id;
|
||||
let after = &r.state.bodies;
|
||||
let names: HashSet<&str> = after.iter().map(|b| b.name.as_str()).collect();
|
||||
// What the feature consumed: bodies there before and gone after.
|
||||
let consumed: Vec<u64> =
|
||||
before.iter().filter(|(name, _)| !names.contains(name.as_str())).flat_map(|(_, (_, _, list))| list.iter().copied()).collect();
|
||||
let mut next: HashMap<String, (solidcraft_doc::kernel::Body, u64, Vec<u64>)> = HashMap::with_capacity(after.len());
|
||||
for b in after {
|
||||
let list = match before.get(&b.name) {
|
||||
// Passed through unchanged.
|
||||
Some((old, creator, list)) if old.same(&b.body) && *creator == b.feature => list.clone(),
|
||||
// Changed: it keeps what built it so far.
|
||||
Some((_, creator, list)) if *creator == b.feature => touched(list.clone(), &consumed, id, &order),
|
||||
// Made by this feature.
|
||||
_ if b.feature == id => touched(Vec::new(), &consumed, id, &order),
|
||||
// A piece of a body made earlier (a split): what built the bodies of its creator.
|
||||
_ => {
|
||||
let inherited =
|
||||
before.values().filter(|(_, creator, _)| *creator == b.feature).flat_map(|(_, _, list)| list.iter().copied()).collect();
|
||||
touched(inherited, &consumed, id, &order)
|
||||
}
|
||||
};
|
||||
next.insert(b.name.clone(), (b.body.clone(), b.feature, list));
|
||||
}
|
||||
before = next;
|
||||
}
|
||||
// The model as it stands (a sketch shown live keeps its last results).
|
||||
self.model
|
||||
.state()
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| (b.name.clone(), before.get(&b.name).map(|(_, _, list)| list.clone()).unwrap_or_else(|| vec![b.feature])))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// `list` with the features of what was consumed and the feature `id` added, once each, in
|
||||
/// timeline order.
|
||||
fn touched(mut list: Vec<u64>, consumed: &[u64], id: u64, order: &HashMap<u64, usize>) -> Vec<u64> {
|
||||
list.extend(consumed.iter().copied());
|
||||
list.push(id);
|
||||
list.sort_by_key(|f| order.get(f).copied().unwrap_or(usize::MAX));
|
||||
list.dedup();
|
||||
list
|
||||
}
|
||||
|
||||
/// `model.body_features`: the features that built each body, or one body (a placed copy's name
|
||||
/// means the body it shows).
|
||||
pub(super) fn model_body_features(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "model.body_features";
|
||||
let all = s.body_features();
|
||||
let wanted = match str_(p, "body") {
|
||||
Some(name) => {
|
||||
let st = s.world_state()?;
|
||||
let b = st.body(name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?;
|
||||
Some((b.name.clone(), b.definition_name().to_string()))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let feature = |id: &u64| BodyFeature { id: *id, name: s.doc.feature(*id).map(|f| f.name.clone()).unwrap_or_default() };
|
||||
let bodies = match wanted {
|
||||
Some((shown, definition)) => {
|
||||
vec![BodyFeatures { body: shown, features: all.get(&definition).map(|l| l.iter().map(feature).collect()).unwrap_or_default() }]
|
||||
}
|
||||
None => s
|
||||
.model
|
||||
.state()
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| BodyFeatures { body: b.name.clone(), features: all.get(&b.name).map(|l| l.iter().map(feature).collect()).unwrap_or_default() })
|
||||
.collect(),
|
||||
};
|
||||
serde_json::to_value(ModelBodyFeatures { bodies, param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
@@ -13,30 +13,45 @@
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::BomType;
|
||||
use solidcraft_doc::metadata::{BOM_KEYS, BOM_TYPE, MAX_KEYS, Owner, check_text, key_list};
|
||||
use solidcraft_doc::metadata::{BOM_KEYS, BOM_TYPE, DESCRIPTION, MAX_KEYS, Owner, PART_NUMBER, REVISION, check_text, key_list};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::schema::bom::{Bom, BomChild, BomParams, BomRow};
|
||||
use crate::schema::bom::{
|
||||
Bom, BomChild, BomParams, BomRow, ComponentMetadataParams, ComponentMetadataResult, ComponentMetadataSetParams, ComponentMetadataSetResult,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const BOM_CONTRACT: TypedContract<BomParams> = TypedContract::new();
|
||||
const METADATA_CONTRACT: TypedContract<ComponentMetadataParams> = TypedContract::new();
|
||||
const METADATA_SET_CONTRACT: TypedContract<ComponentMetadataSetParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.metadata", "Part Properties", read).icon("settings").noundo().doc_only().params(
|
||||
"component: id or name (0 or \"root\": the design's own properties) → component, name, part_number, description, revision, \
|
||||
CommandSpec::new("component.metadata", "Part Properties", crate::checked!("component.metadata", read))
|
||||
.icon("settings")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&METADATA_CONTRACT)
|
||||
.output(output_schema_for::<ComponentMetadataResult>)
|
||||
.params(
|
||||
"component: id or name (0 or \"root\": the design's own properties) → component, name, part_number, description, revision, \
|
||||
bom_type (components only), metadata (as saved, other tools' keys too), properties: {name: text}",
|
||||
),
|
||||
CommandSpec::new("component.metadata.set", "Change Part Properties", set).icon("settings").doc_only().params(
|
||||
"component: id or name (not the root: use document.metadata.set); part_number?, description?, revision?: text (empty or null \
|
||||
),
|
||||
CommandSpec::new("component.metadata.set", "Change Part Properties", crate::checked!("component.metadata.set", set))
|
||||
.icon("settings")
|
||||
.doc_only()
|
||||
.contract(&METADATA_SET_CONTRACT)
|
||||
.output(output_schema_for::<ComponentMetadataSetResult>)
|
||||
.params(
|
||||
"component: id or name (not the root: use document.metadata.set); part_number?, description?, revision?: text (empty or null \
|
||||
removes); bom_type?: normal|phantom|reference|purchased|inseparable (empty or null: normal); values?: {key: value} \
|
||||
(SolidCraft's keys, as document.metadata's properties lists them, bom_type, or a reverse-DNS key holding an object); \
|
||||
properties?: {name: text, …} (free properties; empty or null removes). All or nothing, one undo step → component, \
|
||||
metadata, properties, changed: [keys]",
|
||||
),
|
||||
),
|
||||
CommandSpec::new("assembly.bom", "Bill of Materials", crate::checked!("assembly.bom", bom))
|
||||
.at("SOLID", "INSPECT")
|
||||
.icon("bom")
|
||||
@@ -80,24 +95,25 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = component_id(s, p, cmd)?;
|
||||
// The root is the design: its own properties, and no free ones.
|
||||
let (name, m, properties) = if id == 0 {
|
||||
(&s.doc.name, &s.doc.metadata, json!({}))
|
||||
(&s.doc.name, &s.doc.metadata, BTreeMap::new())
|
||||
} else {
|
||||
let c = s.doc.components.iter().find(|c| c.id == id).ok_or_else(|| bad(cmd, format!("no component `{id}`")))?;
|
||||
(&c.name, &c.metadata, json!(c.properties))
|
||||
(&c.name, &c.metadata, c.properties.clone())
|
||||
};
|
||||
let mut out = Map::new();
|
||||
out.insert("component".into(), json!(id));
|
||||
out.insert("name".into(), json!(name));
|
||||
for k in BOM_KEYS {
|
||||
out.insert(k.to_string(), json!(m.text(k)));
|
||||
}
|
||||
// A bill of materials type is a component's; the design has none.
|
||||
if id != 0 {
|
||||
out.insert(BOM_TYPE.into(), json!(m.bom_type().as_str()));
|
||||
}
|
||||
out.insert("metadata".into(), json!(m));
|
||||
out.insert("properties".into(), properties);
|
||||
Ok(Value::Object(out))
|
||||
let text = |k: &str| m.text(k).map(str::to_string);
|
||||
let reply = ComponentMetadataResult {
|
||||
component: id,
|
||||
name: name.clone(),
|
||||
part_number: text(PART_NUMBER),
|
||||
description: text(DESCRIPTION),
|
||||
revision: text(REVISION),
|
||||
// A bill of materials type is a component's; the design has none.
|
||||
bom_type: (id != 0).then(|| m.bom_type().as_str().to_string()),
|
||||
metadata: m.0.clone(),
|
||||
properties,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// A free property as it may be saved (`None`: remove it), or why not.
|
||||
@@ -178,8 +194,15 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
c.metadata = metadata;
|
||||
c.properties = properties;
|
||||
}
|
||||
let c = s.doc.components.iter().find(|c| c.id == id);
|
||||
Ok(json!({"component": id, "metadata": c.map(|c| &c.metadata), "properties": c.map(|c| &c.properties), "changed": changed}))
|
||||
let c = s.doc.components.iter().find(|c| c.id == id).ok_or_else(|| bad(cmd, format!("no component `{id}`")))?;
|
||||
let reply = ComponentMetadataSetResult {
|
||||
component: id,
|
||||
metadata: c.metadata.0.clone(),
|
||||
properties: c.properties.clone(),
|
||||
changed,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
|
||||
@@ -19,9 +19,11 @@ use crate::params::{bad, bool_, expr, str_};
|
||||
use crate::schema::browser::{
|
||||
BodyRenameParams, BodyRenamed, BrowserFoldParams, BrowserFoldResult, BrowserGroupMade, BrowserGroupParams, BrowserGroupRenamed,
|
||||
BrowserMoveParams, BrowserMoved, BrowserOrderParams, BrowserOrdered, BrowserRenameGroupParams, BrowserUngroupParams, BrowserUngrouped,
|
||||
BrowserVisibilityParams, BrowserVisibilityResult, DocumentUnitsParams, DocumentUnitsResult, Isolated, RepairDone, RepairFace, RepairHole,
|
||||
RepairParams, RepairResult, RepairSewn, VisibilityState,
|
||||
BrowserVisibilityParams, BrowserVisibilityResult, DocumentUnitsParams, DocumentUnitsResult, Isolated, PlaneFrame, RedefineAttachment,
|
||||
RedefineSketchParams, RedefineSketchResult, RepairDone, RepairFace, RepairHole, RepairParams, RepairResult, RepairSewn, ViewApplyParams,
|
||||
ViewDeleteParams, ViewDeleted, ViewListParams, ViewListResult, ViewRenameParams, ViewRenamed, ViewSaveParams, ViewSaved, VisibilityState,
|
||||
};
|
||||
use crate::schema::view_navigation::CameraReply;
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -35,6 +37,12 @@ const MOVE_CONTRACT: TypedContract<BrowserMoveParams> = TypedContract::new();
|
||||
const ORDER_CONTRACT: TypedContract<BrowserOrderParams> = TypedContract::new();
|
||||
const VISIBILITY_CONTRACT: TypedContract<BrowserVisibilityParams> = TypedContract::new();
|
||||
const FOLD_CONTRACT: TypedContract<BrowserFoldParams> = TypedContract::new();
|
||||
const REDEFINE_SKETCH_CONTRACT: TypedContract<RedefineSketchParams> = TypedContract::new();
|
||||
const VIEW_SAVE_CONTRACT: TypedContract<ViewSaveParams> = TypedContract::new();
|
||||
const VIEW_RENAME_CONTRACT: TypedContract<ViewRenameParams> = TypedContract::new();
|
||||
const VIEW_DELETE_CONTRACT: TypedContract<ViewDeleteParams> = TypedContract::new();
|
||||
const VIEW_LIST_CONTRACT: TypedContract<ViewListParams> = TypedContract::new();
|
||||
const VIEW_APPLY_CONTRACT: TypedContract<ViewApplyParams> = TypedContract::new();
|
||||
|
||||
/// A reply as JSON: the commands here build their replies from the types their output schemas
|
||||
/// describe, so the two can't drift apart.
|
||||
@@ -56,8 +64,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params(
|
||||
"body: its name (a part the modelling kernel refuses, or an open part that should be a solid); gap?: expr (default 0.2 mm, at most 1 mm: free edges that pair up within it are sewn, each sewn edge carrying its own gap as its tolerance); patch_holes?: bool (default false: loops of free edges left are patched, in the plane of the faces around them where those are that plane, else tangent to them, and sewn in; otherwise the reply offers the patches); name? → the feature, and repair: what changed face by face (rebuilt and refused faces, sewn gaps, holes with their surface.patch), faces and largest edge tolerance before and after, closed, modelling, volume. Refused with why when nothing applies",
|
||||
),
|
||||
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch)
|
||||
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", crate::checked!("sketch.redefine", redefine_sketch))
|
||||
.icon("plane")
|
||||
.contract(&REDEFINE_SKETCH_CONTRACT)
|
||||
.output(output_schema_for::<RedefineSketchResult>)
|
||||
.params("sketch: id or name; plane: as sketch.create; frame?: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"),
|
||||
CommandSpec::new("browser.group", "New Group", crate::checked!("browser.group", group))
|
||||
.icon("folder")
|
||||
@@ -84,13 +94,33 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&UNITS_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<DocumentUnitsResult>)
|
||||
.params("units: mm|cm|m|in|ft (display and unit-less parameters; values keep their meaning)"),
|
||||
CommandSpec::new("view.save", "New Named View", view_save)
|
||||
CommandSpec::new("view.save", "New Named View", crate::checked!("view.save", view_save))
|
||||
.icon("camera")
|
||||
.contract(&VIEW_SAVE_CONTRACT)
|
||||
.output(output_schema_for::<ViewSaved>)
|
||||
.params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"),
|
||||
CommandSpec::new("view.rename", "Rename Named View", view_rename).icon("rename").params("view: name, name"),
|
||||
CommandSpec::new("view.delete", "Delete Named View", view_delete).icon("delete").params("view: name"),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).icon("list").noundo().doc_only(),
|
||||
CommandSpec::new("view.apply", "Restore Named View", view_apply).icon("camera").noundo().params(
|
||||
CommandSpec::new("view.rename", "Rename Named View", crate::checked!("view.rename", view_rename))
|
||||
.icon("rename")
|
||||
.contract(&VIEW_RENAME_CONTRACT)
|
||||
.output(output_schema_for::<ViewRenamed>)
|
||||
.params("view: name, name"),
|
||||
CommandSpec::new("view.delete", "Delete Named View", crate::checked!("view.delete", view_delete))
|
||||
.icon("delete")
|
||||
.contract(&VIEW_DELETE_CONTRACT)
|
||||
.output(output_schema_for::<ViewDeleted>)
|
||||
.params("view: name"),
|
||||
CommandSpec::new("view.list", "List Named Views", crate::checked!("view.list", view_list))
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.contract(&VIEW_LIST_CONTRACT)
|
||||
.output(output_schema_for::<ViewListResult>)
|
||||
.doc_only(),
|
||||
CommandSpec::new("view.apply", "Restore Named View", crate::checked!("view.apply", view_apply))
|
||||
.icon("camera")
|
||||
.noundo()
|
||||
.contract(&VIEW_APPLY_CONTRACT)
|
||||
.output(output_schema_for::<CameraReply>)
|
||||
.params(
|
||||
"view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?, roll?} (radians, mm, about the up axis; yaw: heading of the line of sight, 0 along +Y (Front), π/2 along +X (Left), π (Back), −π/2 (Right); pitch: the eye's elevation, within ±π/2, π/2 = Top; roll defaults to 0, turns the picture clockwise, must be finite and is clamped to ±π; the View angles readout shows these in degrees; up is refused: the up axis comes from the design settings) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step",
|
||||
),
|
||||
CommandSpec::new("browser.order", "Order Browser Items", crate::checked!("browser.order", order))
|
||||
@@ -1049,14 +1079,47 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
reply(kept)
|
||||
}
|
||||
|
||||
/// A camera `view.save` keeps, as the handler reads it (and `ViewCamera`, its contract type): a
|
||||
/// [`solidcraft_doc::NamedView`] of finite numbers with a positive distance, or the message the
|
||||
/// handler refuses it with.
|
||||
pub(crate) fn saved_view_form(c: &Value) -> std::result::Result<solidcraft_doc::NamedView, String> {
|
||||
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| format!("bad camera: {e}"))?;
|
||||
let ok = [v.target.x, v.target.y, v.target.z, v.yaw, v.pitch, v.distance, v.fov].iter().all(|x| x.is_finite()) && v.distance > 0.0;
|
||||
if ok { Ok(v) } else { Err("the camera must be finite with a positive distance".into()) }
|
||||
}
|
||||
|
||||
/// The camera `view.apply` turns to for a `camera` it is given, as the handler reads it (and
|
||||
/// `ApplyCamera`, its contract type), or the message the handler refuses it with.
|
||||
pub(crate) fn applied_camera_form(c: &Value) -> std::result::Result<crate::render::Camera, String> {
|
||||
if c.get("up").is_some() {
|
||||
return Err("camera `up` is not supported: the up axis comes from the design settings".into());
|
||||
}
|
||||
let roll = match c.get("roll") {
|
||||
Some(value) => value.as_f64().filter(|r| r.is_finite()).ok_or("camera `roll` must be a finite number in radians")?,
|
||||
None => 0.0,
|
||||
};
|
||||
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| format!("bad camera: {e}"))?;
|
||||
// Past a pole the same view has other angles; the orbit would snap it back (#6032).
|
||||
if !(v.pitch.abs() <= std::f64::consts::FRAC_PI_2 + 1e-9) {
|
||||
return Err("camera `pitch` must be within ±π/2 radians (±90°)".into());
|
||||
}
|
||||
let cam = crate::render::Camera { roll: roll.clamp(-std::f64::consts::PI, std::f64::consts::PI), ..camera_of(&v) };
|
||||
if !usable_camera(&cam) {
|
||||
return Err("the camera must be finite with a positive distance".into());
|
||||
}
|
||||
Ok(cam)
|
||||
}
|
||||
|
||||
/// A camera the viewport can be put at: finite, with a positive distance and a field of view of
|
||||
/// at least 0.
|
||||
fn usable_camera(cam: &crate::render::Camera) -> bool {
|
||||
cam.is_valid() && cam.fov.is_finite() && cam.fov >= 0.0
|
||||
}
|
||||
|
||||
fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "view.save";
|
||||
let c = p.get("camera").ok_or_else(|| bad(cmd, "`camera` is required"))?;
|
||||
let mut v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
|
||||
let ok = [v.target.x, v.target.y, v.target.z, v.yaw, v.pitch, v.distance, v.fov].iter().all(|x| x.is_finite()) && v.distance > 0.0;
|
||||
if !ok {
|
||||
return Err(bad(cmd, "the camera must be finite with a positive distance"));
|
||||
}
|
||||
let mut v = saved_view_form(c).map_err(|e| bad(cmd, e))?;
|
||||
let n = s.doc.named_views.len();
|
||||
v.name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
Some(n) if n.len() <= 128 => n.to_string(),
|
||||
@@ -1070,7 +1133,7 @@ fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = s.doc_mut();
|
||||
d.named_views.retain(|x| x.name != name);
|
||||
d.named_views.push(v);
|
||||
Ok(json!({ "view": name }))
|
||||
reply(cmd, ViewSaved { view: name, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1078,23 +1141,7 @@ fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cam = match (str_(p, "view"), p.get("camera")) {
|
||||
(Some(_), Some(_)) => return Err(bad(cmd, "give `view` or `camera`, not both")),
|
||||
(None, None) => return Err(bad(cmd, "`view` (a name) or `camera` is required")),
|
||||
(None, Some(c)) => {
|
||||
if c.get("up").is_some() {
|
||||
return Err(bad(cmd, "camera `up` is not supported: the up axis comes from the design settings"));
|
||||
}
|
||||
let roll = match c.get("roll") {
|
||||
Some(value) => {
|
||||
value.as_f64().filter(|r| r.is_finite()).ok_or_else(|| bad(cmd, "camera `roll` must be a finite number in radians"))?
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
|
||||
// Past a pole the same view has other angles; the orbit would snap it back (#6032).
|
||||
if !(v.pitch.abs() <= std::f64::consts::FRAC_PI_2 + 1e-9) {
|
||||
return Err(bad(cmd, "camera `pitch` must be within ±π/2 radians (±90°)"));
|
||||
}
|
||||
crate::render::Camera { roll: roll.clamp(-std::f64::consts::PI, std::f64::consts::PI), ..camera_of(&v) }
|
||||
}
|
||||
(None, Some(c)) => applied_camera_form(c).map_err(|e| bad(cmd, e))?,
|
||||
// Standard view names come first, as in the browser's Named Views folder.
|
||||
(Some(name), None) => match crate::render::StandardView::parse(name) {
|
||||
Some(sv) => {
|
||||
@@ -1106,11 +1153,11 @@ fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => s.doc.named_views.iter().find(|v| v.name == name).map(camera_of).ok_or_else(|| bad(cmd, format!("no named view `{name}`")))?,
|
||||
},
|
||||
};
|
||||
if !cam.is_valid() || !cam.fov.is_finite() || cam.fov < 0.0 {
|
||||
if !usable_camera(&cam) {
|
||||
return Err(bad(cmd, "the camera must be finite with a positive distance"));
|
||||
}
|
||||
s.camera = Some(cam);
|
||||
Ok(json!({ "camera": cam }))
|
||||
reply(cmd, CameraReply { camera: cam, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The orbit camera of a named view (Z up, no roll).
|
||||
@@ -1134,17 +1181,18 @@ fn view_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = s.doc_mut().named_views.get_mut(i) {
|
||||
v.name = name.to_string();
|
||||
}
|
||||
Ok(json!({ "view": name }))
|
||||
reply(cmd, ViewRenamed { view: name.to_string(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn view_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = view_index(s, p, "view.delete")?;
|
||||
let cmd = "view.delete";
|
||||
let i = view_index(s, p, cmd)?;
|
||||
let v = s.doc_mut().named_views.remove(i);
|
||||
Ok(json!({ "deleted": v.name }))
|
||||
reply(cmd, ViewDeleted { deleted: v.name, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn view_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "views": s.doc.named_views }))
|
||||
reply("view.list", ViewListResult { views: s.doc.named_views.clone(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The keys whose strings name bodies, alone or in a list (`body`, `targets`…), at any depth of a
|
||||
@@ -1193,7 +1241,9 @@ fn repair_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_kernel::{Continuity, SurfaceKind};
|
||||
let cmd = "body.repair";
|
||||
let name = str_(p, "body").filter(|b| !b.trim().is_empty()).ok_or_else(|| bad(cmd, "`body` must name the part to repair"))?.to_string();
|
||||
let name = str_(p, "body").filter(|b| !b.trim().is_empty()).ok_or_else(|| bad(cmd, "`body` must name the part to repair"))?;
|
||||
// A placed copy's name means the body it shows (#9740); the repair goes on that body.
|
||||
let name = s.own_body_name(name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?;
|
||||
let state = s.model.state();
|
||||
let source = state.body(&name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?;
|
||||
if source.body.is_mesh() {
|
||||
@@ -1491,17 +1541,17 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() {
|
||||
"exact_face"
|
||||
RedefineAttachment::ExactFace
|
||||
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
|
||||
"sheet_face"
|
||||
RedefineAttachment::SheetFace
|
||||
} else {
|
||||
"fixed"
|
||||
RedefineAttachment::Fixed
|
||||
}),
|
||||
_ => None,
|
||||
});
|
||||
let mut out = json!({ "sketch": id,"attachment":attachment });
|
||||
super::sketch::frame_reply(&mut out, frame, &placement);
|
||||
Ok(out)
|
||||
let note = frame.and_then(|f| super::sketch::frame_note(Some(f), &placement)).map(str::to_string);
|
||||
let frame = frame.and_then(PlaneFrame::from_name);
|
||||
reply(cmd, RedefineSketchResult { sketch: id, attachment, frame, note, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1546,6 +1596,23 @@ mod tests {
|
||||
assert!(back.iter().any(|n| n == "KB") && back.iter().any(|n| n == "KB (K:2)"), "{back:?}");
|
||||
}
|
||||
|
||||
/// Repair takes a placed copy's name (`KB (K:2)`) for the body it shows, as Rename does.
|
||||
#[test]
|
||||
fn repair_part_takes_a_placed_copys_name() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "K"}));
|
||||
run(&mut s, "solid.box", json!({"body_name": "KB", "length": 10, "width": 10, "height": 10}));
|
||||
run(&mut s, "surface.unstitch", json!({"body": "KB", "faces": [[5, 5, 10]]}));
|
||||
run(&mut s, "occurrence.copy", json!({"component": "K", "translate": [30, 0, 0]}));
|
||||
assert!(s.model.state().body("KB").is_some_and(|b| b.body.is_surface()));
|
||||
let features = s.doc.features.len();
|
||||
run(&mut s, "body.repair", json!({"body": "KB (K:2)", "patch_holes": true}));
|
||||
assert_eq!(s.doc.features.len(), features + 1);
|
||||
assert!(s.model.state().body("KB").is_some_and(|b| !b.body.is_surface()), "the body both placements show is closed");
|
||||
let err = s.execute("body.repair", &json!({"body": "KB (K:9)"})).unwrap_err().to_string();
|
||||
assert!(err.contains("no body `KB (K:9)`"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_part_patches_a_missing_face_as_one_undoable_feature() {
|
||||
let mut s = Session::default();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user