mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-11 11:11:59 +00:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54b4b8324c | |||
| 525171eb86 | |||
| bb81f13a17 | |||
| 211a904df6 | |||
| dc911ea384 | |||
| 12189e018d | |||
| d1f1631f88 | |||
| 20d8b8bd29 | |||
| acb532e16e | |||
| a8567bf6af | |||
| 0b9ff4c122 | |||
| 4e4f5d4182 | |||
| 30d77e83f9 | |||
| f937ac0f31 | |||
| 3fc688677c | |||
| cb1b4ed203 | |||
| 1bacd8323d | |||
| 38cecb1c6a | |||
| 3134799ac3 | |||
| a23b231a60 | |||
| 3c7329899a | |||
| 1971d71904 | |||
| 180b51468c | |||
| 27de5ba0cc | |||
| eda725dc23 | |||
| 977f7a6df2 | |||
| d7920f71cd | |||
| ec363a78f3 | |||
| 26a643a7b1 | |||
| 14a78595b2 | |||
| fcc79ae793 | |||
| fc81ca5a5b | |||
| d8b95e7bf7 | |||
| 5608c7b930 | |||
| 48f1f9e7e0 | |||
| 5e54501f34 | |||
| 9b95bdd829 | |||
| 7df1139854 | |||
| 5e33828396 | |||
| d6982e5aa7 | |||
| 3bdb61aec8 | |||
| 63a8892f82 | |||
| 8a3c5ec645 | |||
| f6498ea285 | |||
| 3a00f19e71 | |||
| 5799c0d56e | |||
| 1f560322b8 | |||
| 6ca5165a92 | |||
| 76d68ca211 | |||
| 72f039add9 | |||
| c8fc027c53 | |||
| e166d22373 | |||
| ea2fa58254 | |||
| 9a34227883 | |||
| ef49550c5e | |||
| a17e1d2c9b | |||
| c7ce50187d | |||
| 32528d4dcd | |||
| 9f4fa95e13 | |||
| cd2fe9920b | |||
| eda13321af | |||
| 794cfde212 | |||
| 0f8112b74d | |||
| 224e33033c | |||
| c2fe9216bd | |||
| 40e95b706d |
+30
-16
@@ -110,7 +110,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
- name: Install advisory scanner
|
||||
run: cargo install cargo-deny --version 0.20.2 --locked
|
||||
- name: Check locked dependencies
|
||||
@@ -405,7 +405,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
with:
|
||||
components: rustfmt
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
@@ -465,7 +465,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: quick-compile
|
||||
@@ -481,7 +481,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
with:
|
||||
components: clippy
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
@@ -517,7 +517,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
@@ -596,7 +596,7 @@ jobs:
|
||||
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
@@ -723,7 +723,7 @@ jobs:
|
||||
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
@@ -897,7 +897,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
@@ -961,7 +961,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
@@ -1020,7 +1020,7 @@ jobs:
|
||||
VIEWPORT_TEST_REQUIRE_GPU: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-fxc
|
||||
@@ -1044,7 +1044,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows-startup
|
||||
@@ -1093,9 +1093,23 @@ jobs:
|
||||
echo "Waiting for $pending run(s) of this commit to finish."
|
||||
sleep 30
|
||||
done
|
||||
# Runs with every job skipped (labels, before this job reported) are not a result.
|
||||
latest=$(jq -r '[.[] | select(.status == "completed" and .conclusion != "skipped")]
|
||||
| sort_by(.created_at) | last | "\(.conclusion) \(.html_url)"' <<<"$runs")
|
||||
# The result is the newest run that ran checks, by when its latest attempt finished
|
||||
# (updated_at), so a rerun that passed replaces its first attempt's failure. Only runs that
|
||||
# passed or failed count (cancelled ones don't: a run replaced by a newer push or label, or a
|
||||
# fork run that waited for approval and was cancelled), and label runs like this one don't
|
||||
# either: their only job is ci, which repeats an older result, so counting them let one
|
||||
# copied failure outlive the rerun that fixed it.
|
||||
latest=""
|
||||
for id in $(jq -r '[.[] | select(.status == "completed" and (.conclusion == "success" or .conclusion == "failure"))]
|
||||
| sort_by(.updated_at) | reverse | .[].id' <<<"$runs"); do
|
||||
ran=$(gh api "repos/$REPO/actions/runs/$id/jobs?filter=latest&per_page=100" \
|
||||
--jq '[.jobs[] | select(.name != "ci" and .conclusion != "skipped" and .conclusion != null)] | length')
|
||||
if [ "${ran:-0}" -gt 0 ]; then
|
||||
latest=$(jq -r --argjson id "$id" '.[] | select(.id == $id) | "\(.conclusion) \(.html_url)"' <<<"$runs")
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -n "$latest" ] || latest="none (no run of this commit ran its checks)"
|
||||
echo "The commit's latest run: $latest"
|
||||
echo "A label started this run: \`ci\` repeats the commit's latest result ($latest)." >> "$GITHUB_STEP_SUMMARY"
|
||||
test "${latest%% *}" = success
|
||||
@@ -1187,7 +1201,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
@@ -1443,7 +1457,7 @@ jobs:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
- name: Build the CLI
|
||||
run: cargo build --profile ci -p solidcraft-cli
|
||||
- name: Import and check every entry
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
# Only main and develop commit the cache; pull requests and forks mount a clone and never
|
||||
# write back.
|
||||
- name: Mount the compiler cache
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
ref: main
|
||||
# main goes through the merge queue; this deploy key may push past it.
|
||||
ssh-key: ${{ secrets.GENERATED_FILES_DEPLOY_KEY }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: generated-files
|
||||
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
# Only pushes to main and develop commit the cache; pull requests, merge groups and forks
|
||||
# mount a clone and never write back.
|
||||
- name: Mount the compiler cache
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: lists
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-icons
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
@@ -369,7 +369,7 @@ jobs:
|
||||
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_BUILD_DATE"
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
@@ -404,7 +404,7 @@ jobs:
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
@@ -502,7 +502,7 @@ jobs:
|
||||
with:
|
||||
name: icons
|
||||
path: icons
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
@@ -633,7 +633,7 @@ jobs:
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
|
||||
+5
-2
@@ -43,8 +43,11 @@ contributors without write access can't push to them. To update one:
|
||||
|
||||
## Get it running (about 10 minutes on a fast machine)
|
||||
|
||||
You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web
|
||||
build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://github.com/trunk-rs/trunk)
|
||||
You need [rustup](https://rustup.rs/). The repository pins Rust 1.98.0 in
|
||||
`rust-toolchain.toml`, the version CI builds with, and rustup installs it with rustfmt, clippy and
|
||||
the `wasm32-unknown-unknown` target the first time you run `cargo` here. The code itself builds on
|
||||
Rust 1.95 or newer (`rust-version` in Cargo.toml, required by egui/eframe 0.36). For the web build
|
||||
you also need [`trunk`](https://github.com/trunk-rs/trunk)
|
||||
([documentation](https://trunk-rs.github.io/trunk/)).
|
||||
|
||||
```sh
|
||||
|
||||
@@ -35,12 +35,12 @@ fn inspect_schema(args: &[&str], env: Option<&str>) -> Result<Value, String> {
|
||||
fn compact_output_is_off_unless_asked() {
|
||||
let plain = inspect_schema(&[], None).unwrap();
|
||||
assert_eq!(plain["properties"]["sketches"]["type"], "boolean", "{plain}");
|
||||
assert!(plain["properties"].get("bodies").is_none(), "{plain}");
|
||||
assert_eq!(plain["properties"]["bodies"]["type"], json!(["boolean", "string"]), "no list of names: {plain}");
|
||||
assert_eq!(inspect_schema(&[], Some("0")).unwrap(), plain);
|
||||
|
||||
for (args, env) in [(&["--compact"][..], None), (&[][..], Some("1")), (&[][..], Some("true"))] {
|
||||
let compact = inspect_schema(args, env).unwrap();
|
||||
assert!(compact["properties"]["bodies"].is_object(), "{args:?} {env:?}: {compact}");
|
||||
assert!(compact["properties"]["bodies"]["type"].as_array().is_some_and(|t| t.contains(&json!("array"))), "{args:?} {env:?}: {compact}");
|
||||
}
|
||||
|
||||
let e = inspect_schema(&[], Some("maybe")).unwrap_err();
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.assembly_constraint_create": "Constrain"
|
||||
"cmd.assembly_constraint_create": "Constrain",
|
||||
"cmd.assembly_constraint_edit": "Edit Constraint"
|
||||
},
|
||||
"de": {
|
||||
"cmd.assembly_constraint_create": "Abhängigkeit"
|
||||
"cmd.assembly_constraint_create": "Abhängigkeit",
|
||||
"cmd.assembly_constraint_edit": "Abhängigkeit bearbeiten"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.assembly_constraint_create": "拘束"
|
||||
"cmd.assembly_constraint_create": "拘束",
|
||||
"cmd.assembly_constraint_edit": "拘束を編集"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.assembly_constraint_create": "约束"
|
||||
"cmd.assembly_constraint_create": "约束",
|
||||
"cmd.assembly_constraint_edit": "编辑约束"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.assembly_constraint_create": "구속"
|
||||
"cmd.assembly_constraint_create": "구속",
|
||||
"cmd.assembly_constraint_edit": "구속 편집"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.assembly_constraint_create": "Ràng buộc"
|
||||
"cmd.assembly_constraint_create": "Ràng buộc",
|
||||
"cmd.assembly_constraint_edit": "Sửa ràng buộc"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.assembly_constraint_create": "تقييد"
|
||||
"cmd.assembly_constraint_create": "تقييد",
|
||||
"cmd.assembly_constraint_edit": "تحرير التقييد"
|
||||
},
|
||||
"he": {
|
||||
"cmd.assembly_constraint_create": "אילוץ"
|
||||
"cmd.assembly_constraint_create": "אילוץ",
|
||||
"cmd.assembly_constraint_edit": "עריכת אילוץ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"en": {
|
||||
"file_load.opened_behind": "{name} opened in a new tab",
|
||||
"operations.waiting": "Ready",
|
||||
"prefs.file_operations_wait": "Wait for files to open",
|
||||
"prefs.file_operations_wait_note": "While a file opens or is inserted, or links are read again, the design waits behind a card with the file's progress and Cancel. Off: the file shows in the operations panel and you can keep working; an insert lands once you finish what you are doing, and a file opened meanwhile goes in a new tab."
|
||||
},
|
||||
"de": {
|
||||
"file_load.opened_behind": "{name} in einem neuen Tab geöffnet",
|
||||
"operations.waiting": "Bereit",
|
||||
"prefs.file_operations_wait": "Auf das Öffnen von Dateien warten",
|
||||
"prefs.file_operations_wait_note": "Während eine Datei geöffnet oder eingefügt wird oder Verknüpfungen neu gelesen werden, wartet die Konstruktion hinter einer Karte mit dem Fortschritt und Abbrechen. Aus: Die Datei erscheint im Vorgangsbereich und Sie können weiterarbeiten; ein Einfügen wird übernommen, sobald Sie fertig sind, und eine inzwischen geöffnete Datei kommt in einen neuen Tab."
|
||||
},
|
||||
"ja": {
|
||||
"file_load.opened_behind": "{name} を新しいタブで開きました",
|
||||
"operations.waiting": "準備完了",
|
||||
"prefs.file_operations_wait": "ファイルを開く間は待つ",
|
||||
"prefs.file_operations_wait_note": "ファイルを開く・挿入する・リンクを読み直す間、デザインは進行状況とキャンセルを示すカードの後ろで待機します。オフ:ファイルは処理パネルに表示され、作業を続けられます。挿入は作業が終わってから反映され、その間に開いたファイルは新しいタブに入ります。"
|
||||
},
|
||||
"ko": {
|
||||
"file_load.opened_behind": "{name}을(를) 새 탭에서 열었습니다",
|
||||
"operations.waiting": "준비됨",
|
||||
"prefs.file_operations_wait": "파일이 열릴 때까지 기다리기",
|
||||
"prefs.file_operations_wait_note": "파일을 열거나 삽입하거나 링크를 다시 읽는 동안 디자인이 진행 상황과 취소가 있는 카드 뒤에서 기다립니다. 끄면: 파일이 작업 패널에 표시되고 계속 작업할 수 있습니다. 삽입은 하던 작업을 마친 뒤 반영되고, 그 사이에 연 파일은 새 탭에 열립니다."
|
||||
},
|
||||
"zh": {
|
||||
"file_load.opened_behind": "已在新标签页中打开{name}",
|
||||
"operations.waiting": "就绪",
|
||||
"prefs.file_operations_wait": "等待文件打开",
|
||||
"prefs.file_operations_wait_note": "打开或插入文件、重新读取链接时,设计会在显示进度和取消按钮的卡片后等待。关闭:文件显示在操作面板中,您可以继续工作;插入会在您完成当前操作后生效,期间打开的文件会放在新标签页中。"
|
||||
},
|
||||
"ar": {
|
||||
"file_load.opened_behind": "فُتح {name} في علامة تبويب جديدة",
|
||||
"operations.waiting": "جاهز",
|
||||
"prefs.file_operations_wait": "انتظار فتح الملفات",
|
||||
"prefs.file_operations_wait_note": "أثناء فتح ملف أو إدراجه أو إعادة قراءة الروابط، ينتظر التصميم خلف بطاقة تعرض التقدم وزر الإلغاء. عند الإيقاف: يظهر الملف في لوحة العمليات ويمكنك متابعة العمل؛ يُطبَّق الإدراج بعد أن تنتهي مما تفعله، ويُفتح الملف المفتوح في الأثناء في علامة تبويب جديدة."
|
||||
},
|
||||
"he": {
|
||||
"file_load.opened_behind": "{name} נפתח בכרטיסייה חדשה",
|
||||
"operations.waiting": "מוכן",
|
||||
"prefs.file_operations_wait": "המתנה לפתיחת קבצים",
|
||||
"prefs.file_operations_wait_note": "בזמן שקובץ נפתח או מוכנס, או שקישורים נקראים מחדש, התכנון ממתין מאחורי כרטיס עם ההתקדמות וביטול. כבוי: הקובץ מוצג בחלונית הפעולות ואפשר להמשיך לעבוד; הוספה נכנסת לאחר שתסיימו את מה שאתם עושים, וקובץ שנפתח בינתיים נפתח בכרטיסייה חדשה."
|
||||
},
|
||||
"vi": {
|
||||
"file_load.opened_behind": "Đã mở {name} trong thẻ mới",
|
||||
"operations.waiting": "Sẵn sàng",
|
||||
"prefs.file_operations_wait": "Chờ tệp mở xong",
|
||||
"prefs.file_operations_wait_note": "Khi một tệp đang mở, đang chèn hoặc các liên kết được đọc lại, thiết kế chờ sau một thẻ hiện tiến độ và nút Hủy. Tắt: tệp hiện trong bảng thao tác và bạn có thể tiếp tục làm việc; phần chèn được áp dụng khi bạn làm xong việc đang làm, và tệp mở trong lúc đó được đặt vào thẻ mới."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"en": {
|
||||
"prefs.assemblies": "Assemblies",
|
||||
"prefs.joint_drag_trackball": "Turn parts on a ball joint like a trackball when dragged",
|
||||
"prefs.joint_drag_trackball_help": "Dragging a part that a ball joint holds turns it by the smallest turn about the joint, as if rolling a ball the size of the part. Off: the point you grabbed follows the pointer exactly, and the part may also spin about it."
|
||||
},
|
||||
"ar": {
|
||||
"prefs.assemblies": "التجميعات",
|
||||
"prefs.joint_drag_trackball": "تدوير الأجزاء على مفصل كروي مثل كرة التتبع عند سحبها",
|
||||
"prefs.joint_drag_trackball_help": "سحب جزء يثبّته مفصل كروي يدوّره بأصغر دوران حول المفصل، كأنك تدحرج كرة بحجم الجزء. عند الإيقاف: تتبع النقطة التي أمسكتها المؤشر تمامًا، وقد يدور الجزء أيضًا حولها."
|
||||
},
|
||||
"de": {
|
||||
"prefs.assemblies": "Baugruppen",
|
||||
"prefs.joint_drag_trackball": "Teile an einem Kugelgelenk beim Ziehen wie einen Trackball drehen",
|
||||
"prefs.joint_drag_trackball_help": "Wenn Sie ein Teil ziehen, das ein Kugelgelenk hält, dreht es sich um die kleinste Drehung um das Gelenk, als rollten Sie eine Kugel von der Größe des Teils. Aus: Der gegriffene Punkt folgt genau dem Zeiger, und das Teil kann sich zusätzlich um ihn drehen."
|
||||
},
|
||||
"he": {
|
||||
"prefs.assemblies": "מכלולים",
|
||||
"prefs.joint_drag_trackball": "סיבוב חלקים על מפרק כדורי כמו כדור עקיבה בעת גרירה",
|
||||
"prefs.joint_drag_trackball_help": "גרירת חלק שמפרק כדורי מחזיק מסובבת אותו בסיבוב הקטן ביותר סביב המפרק, כאילו מגלגלים כדור בגודל החלק. כבוי: הנקודה שתפסת עוקבת אחרי המצביע במדויק, והחלק עלול גם להסתובב סביבה."
|
||||
},
|
||||
"ja": {
|
||||
"prefs.assemblies": "アセンブリ",
|
||||
"prefs.joint_drag_trackball": "ボールジョイントの部品をドラッグするとトラックボールのように回転",
|
||||
"prefs.joint_drag_trackball_help": "ボールジョイントで保持された部品をドラッグすると、部品の大きさのボールを転がすように、ジョイントを中心に最小の回転で回ります。オフ:つかんだ点がポインターに正確に追従し、部品がその点を軸にさらに回転することがあります。"
|
||||
},
|
||||
"ko": {
|
||||
"prefs.assemblies": "어셈블리",
|
||||
"prefs.joint_drag_trackball": "볼 조인트의 부품을 끌 때 트랙볼처럼 회전",
|
||||
"prefs.joint_drag_trackball_help": "볼 조인트가 잡고 있는 부품을 끌면 부품 크기의 공을 굴리듯 조인트를 중심으로 가장 작은 회전만큼 돌아갑니다. 끄면: 잡은 점이 포인터를 정확히 따라가고, 부품이 그 점을 축으로 더 돌 수도 있습니다."
|
||||
},
|
||||
"vi": {
|
||||
"prefs.assemblies": "Cụm lắp ráp",
|
||||
"prefs.joint_drag_trackball": "Xoay chi tiết trên khớp cầu như trackball khi kéo",
|
||||
"prefs.joint_drag_trackball_help": "Kéo một chi tiết do khớp cầu giữ sẽ xoay nó theo góc xoay nhỏ nhất quanh khớp, như lăn một quả bóng có kích thước bằng chi tiết. Tắt: điểm bạn nắm theo đúng con trỏ, và chi tiết có thể tự xoay quanh điểm đó."
|
||||
},
|
||||
"zh": {
|
||||
"prefs.assemblies": "装配",
|
||||
"prefs.joint_drag_trackball": "拖动球关节上的零件时像轨迹球一样旋转",
|
||||
"prefs.joint_drag_trackball_help": "拖动由球关节固定的零件时,它绕关节以最小的转动旋转,就像滚动一个与零件同样大小的球。关闭:抓取的点精确跟随指针,零件还可能绕该点自旋。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"en": { "render.error_product_placement": "invalid product placement" },
|
||||
"ar": { "render.error_product_placement": "موضع منتج غير صالح" },
|
||||
"he": { "render.error_product_placement": "מיקום מוצר לא תקין" },
|
||||
"ja": { "render.error_product_placement": "製品の配置が無効です" },
|
||||
"vi": { "render.error_product_placement": "vị trí đặt sản phẩm không hợp lệ" },
|
||||
"zh": { "render.error_product_placement": "产品放置无效" }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"en": {
|
||||
"sketch.move_released": "Moved: {count} constraints tying it to what stays were let go ({names}).",
|
||||
"sketch.move_released_one": "Moved: {count} constraint tying it to what stays was let go ({names})."
|
||||
},
|
||||
"ar": {
|
||||
"sketch.move_released": "تم النقل: أُزيلت {count} قيود كانت تربطه بما بقي ({names}).",
|
||||
"sketch.move_released_one": "تم النقل: أُزيل {count} قيد كان يربطه بما بقي ({names})."
|
||||
},
|
||||
"de": {
|
||||
"sketch.move_released": "Verschoben: {count} Abhängigkeiten zum Rest wurden gelöst ({names}).",
|
||||
"sketch.move_released_one": "Verschoben: {count} Abhängigkeit zum Rest wurde gelöst ({names})."
|
||||
},
|
||||
"es": {
|
||||
"sketch.move_released": "Movido: se soltaron {count} restricciones que lo unían a lo que queda ({names}).",
|
||||
"sketch.move_released_one": "Movido: se soltó {count} restricción que lo unía a lo que queda ({names})."
|
||||
},
|
||||
"fr": {
|
||||
"sketch.move_released": "Déplacé : {count} contraintes qui le liaient au reste ont été relâchées ({names}).",
|
||||
"sketch.move_released_one": "Déplacé : {count} contrainte qui le liait au reste a été relâchée ({names})."
|
||||
},
|
||||
"he": {
|
||||
"sketch.move_released": "הוזז: {count} אילוצים שקשרו אותו למה שנשאר שוחררו ({names}).",
|
||||
"sketch.move_released_one": "הוזז: {count} אילוץ שקשר אותו למה שנשאר שוחרר ({names})."
|
||||
},
|
||||
"ja": {
|
||||
"sketch.move_released": "移動しました: 残る要素とつないでいた拘束 {count} 個を解除しました ({names})。",
|
||||
"sketch.move_released_one": "移動しました: 残る要素とつないでいた拘束 {count} 個を解除しました ({names})。"
|
||||
},
|
||||
"vi": {
|
||||
"sketch.move_released": "Đã di chuyển: đã bỏ {count} ràng buộc nối nó với phần còn lại ({names}).",
|
||||
"sketch.move_released_one": "Đã di chuyển: đã bỏ {count} ràng buộc nối nó với phần còn lại ({names})."
|
||||
},
|
||||
"zh": {
|
||||
"sketch.move_released": "已移动:已解除 {count} 个将其与保留部分相连的约束({names})。",
|
||||
"sketch.move_released_one": "已移动:已解除 {count} 个将其与保留部分相连的约束({names})。"
|
||||
}
|
||||
}
|
||||
+26
-26
@@ -4681,32 +4681,32 @@
|
||||
"sketch.dof_left": "剩余 {count} 个自由度",
|
||||
"sketch.dof_left_one": "剩余 {count} 个自由度",
|
||||
"sketch.draw_curves": "绘制为 {linetype}",
|
||||
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
|
||||
"sketch.error.nothing_to_break": "nothing to break here",
|
||||
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
|
||||
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
|
||||
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
|
||||
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
|
||||
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
|
||||
"sketch.hint.break": "Break: click a curve where it should split",
|
||||
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
|
||||
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
|
||||
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
|
||||
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
|
||||
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
|
||||
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
|
||||
"sketch.hint.fallback": "Click in the sketch",
|
||||
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
|
||||
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
|
||||
"sketch.hint.open_done": "Open done",
|
||||
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
|
||||
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
|
||||
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
|
||||
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
|
||||
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
|
||||
"sketch.hint.slot": "Slot: click both ends, then the width",
|
||||
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
|
||||
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
|
||||
"sketch.error.closed_curve_two_crossings": "闭合曲线需要两个交点才能修剪",
|
||||
"sketch.error.nothing_to_break": "此处没有可断开的位置",
|
||||
"sketch.error.offset_fillet_chamfer": "`{id}` 是偏移曲线,其拐角跟随源曲线:请先对源曲线的拐角倒圆角或倒角再偏移,或先断开偏移关联",
|
||||
"sketch.error.offset_trim": "`{id}` 是偏移曲线,其端点跟随源曲线:请改为编辑源曲线,或先断开偏移关联",
|
||||
"sketch.hint.arc.center_point": "圆弧:单击圆心和起点,向任一方向转动后单击终点,或输入带符号的扫过角度",
|
||||
"sketch.hint.arc.three_point": "圆弧:依次单击起点、终点和圆弧上的一点",
|
||||
"sketch.hint.arc3d": "三维圆弧:依次单击起点、终点和圆弧上的一点(点可以离开草图平面)",
|
||||
"sketch.hint.break": "打断:在曲线要分开的位置单击",
|
||||
"sketch.hint.circle.center": "圆:单击圆心,再单击圆上一点",
|
||||
"sketch.hint.constraint.generic": "约束:选择草图图元",
|
||||
"sketch.hint.constraint.horizontal_vertical": "水平/竖直:选择一条直线、两个点,或一个点和一条直线;按住 Shift 可选取直线中点",
|
||||
"sketch.hint.constraint.midpoint": "中点:选择一个点和一条直线,或选择两条直线,使第二条的中点落在第一条上",
|
||||
"sketch.hint.dimension": "尺寸:选择一个或两个图元并输入数值(也可先单击放置尺寸);单击已有尺寸可编辑;按 Enter 应用",
|
||||
"sketch.hint.extend": "延伸:单击曲线靠近端点的位置进行延伸(Shift:依次单击两条曲线,将两者延伸到相交处)",
|
||||
"sketch.hint.fallback": "在草图中单击",
|
||||
"sketch.hint.line": "直线:依次单击各点;右键单击或按 Esc 结束",
|
||||
"sketch.hint.line3d": "三维直线:依次单击空间中的点;右键单击或按 Esc 结束(点可以离开草图平面)",
|
||||
"sketch.hint.open_done": "开放轮廓已完成",
|
||||
"sketch.hint.point3d": "三维点:单击三维点的位置",
|
||||
"sketch.hint.rectangle.center": "中心矩形:单击中心,再单击一个角点",
|
||||
"sketch.hint.rectangle.rounded": "圆角矩形:单击两个对角点(按 Tab 切换到半径框,输入圆角半径)",
|
||||
"sketch.hint.rectangle.three_point": "三点矩形:单击一条边的两个端点,再确定宽度",
|
||||
"sketch.hint.rectangle.two_point": "矩形:单击两个对角点",
|
||||
"sketch.hint.slot": "槽:单击两端,再确定宽度",
|
||||
"sketch.hint.spline3d": "三维样条曲线:在三维空间中单击拟合点;右键单击或按 Esc 结束",
|
||||
"sketch.hint.trim": "修剪:单击要删除的曲线段,或拖动划过要删除的各段",
|
||||
"sketch.make_selection": "将选择转换为 {linetype}",
|
||||
"sketch.open_ends": "开放 {count}",
|
||||
"sketch.status_conflict": "约束冲突",
|
||||
|
||||
@@ -144,6 +144,9 @@ impl Hub {
|
||||
shared.lock().retain(|(i, _)| *i != id);
|
||||
});
|
||||
}
|
||||
// A captured value drops after the body's locals, so without this `_closed` would tell
|
||||
// `Stop::stop` the port is free while the listener still holds it (#14752).
|
||||
drop(listener);
|
||||
});
|
||||
Ok(stop)
|
||||
}
|
||||
|
||||
+120
-16
@@ -47,9 +47,41 @@ fn opaque() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// The name a colour picked or typed in gets: no library entry's, no file's.
|
||||
pub const CUSTOM: &str = "Custom";
|
||||
|
||||
/// The name of a colour a file gave without a name: `Custom(R,G,B)` when it is opaque, else
|
||||
/// `Custom(R,G,B,opacity)`, the opacity to two decimals with at least one shown
|
||||
/// (`Custom(196,199,204)`, `Custom(210,226,240,0.35)`), so the same colour always reads the same.
|
||||
pub fn unnamed_colour(color: [u8; 3], opacity: f64) -> String {
|
||||
let hundredths = (if opacity.is_finite() { opacity.clamp(0.0, 1.0) } else { 1.0 } * 100.0).round() as u32;
|
||||
let [r, g, b] = color;
|
||||
if hundredths == 100 {
|
||||
return format!("{CUSTOM}({r},{g},{b})");
|
||||
}
|
||||
let mut fraction = format!("{:02}", hundredths % 100);
|
||||
if fraction.ends_with('0') {
|
||||
fraction.pop();
|
||||
}
|
||||
format!("{CUSTOM}({r},{g},{b},{}.{fraction})", hundredths / 100)
|
||||
}
|
||||
|
||||
impl Look {
|
||||
pub fn custom(color: [u8; 3], opacity: f64) -> Look {
|
||||
Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0), pbr: None }
|
||||
Look { name: CUSTOM.into(), color, opacity: opacity.clamp(0.0, 1.0), pbr: None }
|
||||
}
|
||||
|
||||
/// A colour read from a file: called what the file calls it, else [`unnamed_colour`].
|
||||
pub fn imported(color: [u8; 3], opacity: f64, name: Option<&str>) -> Look {
|
||||
let opacity = opacity.clamp(0.0, 1.0);
|
||||
let name = name.map_or_else(|| unnamed_colour(color, opacity), str::to_string);
|
||||
Look { name, color, opacity, pbr: None }
|
||||
}
|
||||
|
||||
/// The name to write with this look's colour in a file (a STEP `COLOUR_RGB` name): its own,
|
||||
/// unless it is the plain [`CUSTOM`], which says nothing.
|
||||
pub fn file_name(&self) -> Option<String> {
|
||||
(self.name != CUSTOM).then(|| self.name.clone()).and_then(|n| solidcraft_kernel::colour_name(&n))
|
||||
}
|
||||
pub fn opaque_surface(&self) -> crate::Result<Option<solidcraft_viewport::surface::OpaqueSurface>> {
|
||||
if self.pbr.is_some() && self.opacity != 1.0 {
|
||||
@@ -93,6 +125,10 @@ pub struct ImportedLook {
|
||||
pub color: Option<[f32; 3]>,
|
||||
pub opacity: Option<f32>,
|
||||
pub faces: BTreeMap<String, ([f32; 3], f32)>,
|
||||
/// The body colour's name in the file, if it had one.
|
||||
pub name: Option<String>,
|
||||
/// The names the file gives the faces' colours, by the same face names as `faces`.
|
||||
pub face_names: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ImportedLook {
|
||||
@@ -102,14 +138,17 @@ impl ImportedLook {
|
||||
return b.imported.clone();
|
||||
}
|
||||
let names = crate::naming::face_names(b);
|
||||
let faces = b
|
||||
.body
|
||||
.paint()
|
||||
.map(|p| {
|
||||
p.faces.iter().filter_map(|f| names.get(f.face).map(|n| (crate::naming::strip_piece(n).to_string(), (f.color, f.opacity)))).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Some(std::sync::Arc::new(ImportedLook { color: b.body.color(), opacity: b.body.paint().map(|p| p.opacity), faces }))
|
||||
let named = |f: &solidcraft_kernel::FacePaint| names.get(f.face).map(|n| crate::naming::strip_piece(n).to_string());
|
||||
let painted = b.body.paint().map_or(&[][..], |p| p.faces.as_slice());
|
||||
let faces = painted.iter().filter_map(|f| named(f).map(|n| (n, (f.color, f.opacity)))).collect();
|
||||
let face_names = painted.iter().filter_map(|f| Some((named(f)?, f.name.clone()?))).collect();
|
||||
Some(std::sync::Arc::new(ImportedLook {
|
||||
color: b.body.color(),
|
||||
opacity: b.body.paint().map(|p| p.opacity),
|
||||
faces,
|
||||
name: b.body.paint().and_then(|p| p.name.clone()),
|
||||
face_names,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +356,11 @@ impl Document {
|
||||
pub fn model_display_look(&self, body: &crate::ModelBody) -> Option<Look> {
|
||||
self.model_body_look(body).or_else(|| {
|
||||
let color = body.body.color().or_else(|| body.imported.as_ref().and_then(|p| p.color))?;
|
||||
Some(Look::custom(color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8), self.model_body_opacity(body)))
|
||||
let name = match body.body.color() {
|
||||
Some(_) => body.body.paint().and_then(|p| p.name.as_deref()),
|
||||
None => body.imported.as_ref().and_then(|p| p.name.as_deref()),
|
||||
};
|
||||
Some(Look::imported(color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8), self.model_body_opacity(body), name))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -380,14 +423,17 @@ impl Document {
|
||||
Vec::new()
|
||||
} else {
|
||||
match b.body.paint() {
|
||||
Some(p) => p.faces.iter().map(|f| (f.face, Look::custom(f.color.map(to8), f.opacity as f64))).collect(),
|
||||
Some(p) => p.faces.iter().map(|f| (f.face, Look::imported(f.color.map(to8), f.opacity as f64, f.name.as_deref()))).collect(),
|
||||
// Carried from an earlier version of the body: by face name (worked out only here:
|
||||
// the viewport asks every body for its face colours on every change).
|
||||
None => match &b.imported {
|
||||
Some(imp) => crate::naming::face_names(b)
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, n)| imp.faces.get(crate::naming::strip_piece(n)).map(|(c, o)| (i, Look::custom(c.map(to8), *o as f64))))
|
||||
.filter_map(|(i, n)| {
|
||||
let n = crate::naming::strip_piece(n);
|
||||
imp.faces.get(n).map(|(c, o)| (i, Look::imported(c.map(to8), *o as f64, imp.face_names.get(n).map(String::as_str))))
|
||||
})
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
},
|
||||
@@ -532,9 +578,9 @@ mod tests {
|
||||
.faces(0.01)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|face| solidcraft_kernel::FacePaint { face: face.index, color: [1., 0., 0.], opacity: 0.5 })
|
||||
.map(|face| solidcraft_kernel::FacePaint { face: face.index, color: [1., 0., 0.], opacity: 0.5, name: None })
|
||||
.collect();
|
||||
crate::ModelBody::new("Imported".into(), body.with_paint(Some(solidcraft_kernel::Paint { opacity: 1., faces })), 1)
|
||||
crate::ModelBody::new("Imported".into(), body.with_paint(Some(solidcraft_kernel::Paint { opacity: 1., faces, name: None })), 1)
|
||||
}
|
||||
|
||||
fn check_imported_appearance_precedence(body: &crate::ModelBody) {
|
||||
@@ -542,7 +588,7 @@ mod tests {
|
||||
let face_count = body.body.faces(0.01).unwrap().len();
|
||||
let imported = doc.face_colors(body);
|
||||
assert_eq!(imported.len(), face_count);
|
||||
assert!(imported.iter().all(|(_, look)| *look == Look::custom([255, 0, 0], 0.5)));
|
||||
assert!(imported.iter().all(|(_, look)| *look == Look::imported([255, 0, 0], 0.5, None)));
|
||||
|
||||
let body_look = Look::custom([0, 0, 255], 0.8);
|
||||
doc.appearances.bodies.insert(body.definition_name().into(), body_look.clone());
|
||||
@@ -565,7 +611,7 @@ mod tests {
|
||||
doc.appearances.bodies.clear();
|
||||
let restored = doc.face_colors(body);
|
||||
assert_eq!(restored.len(), face_count);
|
||||
assert!(restored.iter().all(|(face, look)| *look == if *face == 0 { face_look.clone() } else { Look::custom([255, 0, 0], 0.5) }));
|
||||
assert!(restored.iter().all(|(face, look)| *look == if *face == 0 { face_look.clone() } else { Look::imported([255, 0, 0], 0.5, None) }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -590,6 +636,64 @@ mod tests {
|
||||
assert!(faces.iter().all(|face| face.color == [1., 0., 0.] && face.opacity == 0.5));
|
||||
}
|
||||
|
||||
/// A colour a file names is called by that name; one it leaves unnamed by its values, the
|
||||
/// opacity to two decimals with at least one shown. A picked colour's plain name is not written
|
||||
/// to a file.
|
||||
#[test]
|
||||
fn imported_colours_are_called_by_their_file_name_or_their_values() {
|
||||
assert_eq!(unnamed_colour([196, 199, 204], 1.0), "Custom(196,199,204)");
|
||||
assert_eq!(unnamed_colour([210, 226, 240], 0.35), "Custom(210,226,240,0.35)");
|
||||
assert_eq!(unnamed_colour([90, 96, 104], 0.5), "Custom(90,96,104,0.5)");
|
||||
assert_eq!(unnamed_colour([0, 0, 0], 0.0), "Custom(0,0,0,0.0)");
|
||||
// A 32-bit file opacity reads as the number it was written as.
|
||||
assert_eq!(unnamed_colour([1, 2, 3], f64::from(0.35_f32)), "Custom(1,2,3,0.35)");
|
||||
assert_eq!(unnamed_colour([1, 2, 3], 0.995), "Custom(1,2,3)", "rounds to opaque");
|
||||
assert_eq!(unnamed_colour([1, 2, 3], 0.004), "Custom(1,2,3,0.0)");
|
||||
for hostile in [f64::NAN, f64::INFINITY, 7.0] {
|
||||
assert_eq!(unnamed_colour([1, 2, 3], hostile), "Custom(1,2,3)", "{hostile}");
|
||||
}
|
||||
assert_eq!(unnamed_colour([1, 2, 3], -3.0), "Custom(1,2,3,0.0)");
|
||||
let named = Look::imported([160, 160, 160], 1.0, Some("Steel - Satin"));
|
||||
assert_eq!((named.name.as_str(), named.color, named.opacity), ("Steel - Satin", [160, 160, 160], 1.0));
|
||||
assert_eq!(Look::imported([1, 2, 3], 2.0, None).opacity, 1.0);
|
||||
assert_eq!(named.file_name().as_deref(), Some("Steel - Satin"));
|
||||
assert_eq!(Look::imported([1, 2, 3], 1.0, None).file_name().as_deref(), Some("Custom(1,2,3)"));
|
||||
assert_eq!(Look::custom([1, 2, 3], 1.0).file_name(), None, "a picked colour's plain name says nothing");
|
||||
}
|
||||
|
||||
/// The names a file gives a body's colour and its faces' colours name the looks the body
|
||||
/// shows, still after a later feature rebuilds the body without its paint, and they are
|
||||
/// exported with the colours.
|
||||
#[test]
|
||||
fn imported_colour_names_name_the_looks_and_outlive_later_features() {
|
||||
let mut body = imported_box();
|
||||
let mut paint = body.body.paint().unwrap().clone();
|
||||
paint.name = Some("Body grey".into());
|
||||
paint.faces[0].name = Some("Accent".into());
|
||||
body.body = body.body.clone().with_color(Some([0.5, 0.5, 0.5])).with_paint(Some(paint));
|
||||
let doc = Document::default();
|
||||
let check = |body: &crate::ModelBody| {
|
||||
assert_eq!(doc.model_display_look(body).map(|l| l.name), Some("Body grey".into()));
|
||||
let faces = doc.face_colors(body);
|
||||
assert_eq!(faces.iter().find(|(face, _)| *face == 0).map(|(_, l)| l.name.as_str()), Some("Accent"));
|
||||
assert!(faces.iter().filter(|(face, _)| *face != 0).all(|(_, l)| l.name == "Custom(255,0,0,0.5)"), "{faces:?}");
|
||||
};
|
||||
check(&body);
|
||||
// A later feature's body carries the look by face name.
|
||||
let mut later = body.clone();
|
||||
later.imported = ImportedLook::of(&body);
|
||||
later.body = later.body.clone().with_color(None).with_paint(None);
|
||||
assert_eq!(later.imported.as_ref().and_then(|l| l.name.as_deref()), Some("Body grey"));
|
||||
check(&later);
|
||||
// Exported, the colours carry their names.
|
||||
let mut state = crate::ModelState::default();
|
||||
state.bodies.push(body);
|
||||
let painted = doc.painted(&state);
|
||||
let paint = painted.bodies[0].body.paint().unwrap();
|
||||
assert_eq!(paint.name.as_deref(), Some("Body grey"));
|
||||
assert_eq!(paint.faces.iter().find(|f| f.face == 0).and_then(|f| f.name.as_deref()), Some("Accent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_appearance_overrides_imported_face_paint() {
|
||||
check_imported_appearance_precedence(&imported_box());
|
||||
|
||||
+99
-12
@@ -38,6 +38,9 @@ pub enum Column {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConfigRow {
|
||||
/// Fixed for the row's life, from the design's id counter ([`Document::allocate_id`]): a rename
|
||||
/// keeps it and no other row is ever given it, so what points at a row still finds it (#14750).
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// One cell per column: expression, suppression, material name or appearance look.
|
||||
pub cells: Vec<String>,
|
||||
@@ -64,10 +67,59 @@ pub fn suppression_cell(value: &str) -> Result<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of the table, by its id or by its name.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum RowKey<'a> {
|
||||
Id(u64),
|
||||
Name(&'a str),
|
||||
}
|
||||
|
||||
impl<'a> RowKey<'a> {
|
||||
/// A row as a command or a file names it: a whole number is its id, text its name, so a row
|
||||
/// named `"17"` is not row 17. Anything else names no row.
|
||||
pub fn read(v: &'a serde_json::Value) -> Option<Self> {
|
||||
v.as_u64().map(RowKey::Id).or_else(|| v.as_str().map(RowKey::Name))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a str> for RowKey<'a> {
|
||||
fn from(name: &'a str) -> Self {
|
||||
RowKey::Name(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u64> for RowKey<'_> {
|
||||
fn from(id: u64) -> Self {
|
||||
RowKey::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RowKey<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RowKey::Id(id) => write!(f, "{id}"),
|
||||
RowKey::Name(name) => f.write_str(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigTable {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.columns.is_empty() && self.rows.is_empty() && self.rules.is_empty()
|
||||
}
|
||||
|
||||
/// The row `key` names: the one with that id, or with that name.
|
||||
pub fn row(&self, key: RowKey<'_>) -> Option<&ConfigRow> {
|
||||
self.rows.iter().find(|r| match key {
|
||||
RowKey::Id(id) => r.id == id,
|
||||
RowKey::Name(name) => r.name == name,
|
||||
})
|
||||
}
|
||||
|
||||
/// The active row, when the table still has it.
|
||||
pub fn active_row(&self) -> Option<&ConfigRow> {
|
||||
self.row(RowKey::Name(self.active.as_deref()?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an appearance cell. Empty/Default follows the body's material; named library looks
|
||||
@@ -164,13 +216,25 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a row to the configuration table, with an id of its own; at most [`MAX_ROWS`] rows.
|
||||
pub fn add_configuration_row(&mut self, name: String, cells: Vec<String>) -> Result<u64> {
|
||||
if self.configs.rows.len() >= MAX_ROWS {
|
||||
return Err(DocError::Invalid("too many rows".into()));
|
||||
}
|
||||
let id = self.allocate_id()?;
|
||||
self.configs.rows.push(ConfigRow { id, name, cells });
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Set the design to a row's values (all or nothing).
|
||||
pub fn apply_configuration(&mut self, row: &str) -> Result<()> {
|
||||
pub fn apply_configuration<'a>(&mut self, row: impl Into<RowKey<'a>>) -> Result<()> {
|
||||
self.apply_configuration_with_trace(row).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn apply_configuration_with_trace(&mut self, row: &str) -> Result<Vec<RuleTrace>> {
|
||||
let r = self.configs.rows.iter().find(|r| r.name == row).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{row}`")))?;
|
||||
pub fn apply_configuration_with_trace<'a>(&mut self, row: impl Into<RowKey<'a>>) -> Result<Vec<RuleTrace>> {
|
||||
let key = row.into();
|
||||
let r = self.configs.row(key).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{key}`")))?;
|
||||
let row = r.name.as_str();
|
||||
if self.configs.columns.len() > MAX_COLUMNS || self.configs.rows.len() > MAX_ROWS || r.cells.len() != self.configs.columns.len() {
|
||||
return Err(DocError::Invalid("configuration table exceeds limits or has incomplete rows".into()));
|
||||
}
|
||||
@@ -250,13 +314,13 @@ impl Document {
|
||||
|
||||
/// The active row's values all hold in the design.
|
||||
pub fn configuration_matches(&self) -> bool {
|
||||
let Some(r) = self.configs.active.as_ref().and_then(|a| self.configs.rows.iter().find(|r| r.name == *a)) else { return false };
|
||||
let Some(r) = self.configs.active_row() else { return false };
|
||||
if r.cells.len() != self.configs.columns.len() {
|
||||
return false;
|
||||
}
|
||||
if !self.configs.rules.is_empty() {
|
||||
let mut expected = self.clone();
|
||||
if expected.apply_configuration(&r.name).is_err() {
|
||||
if expected.apply_configuration(r.id).is_err() {
|
||||
return false;
|
||||
}
|
||||
return self.configs.columns.iter().all(|column| self.config_current(column) == expected.config_current(column));
|
||||
@@ -291,11 +355,11 @@ mod tests {
|
||||
d.set_param("a", "1 mm", None, None).unwrap();
|
||||
d.set_param("b", "a", None, None).unwrap();
|
||||
d.configs.columns = vec![Column::Param("a".into()), Column::Param("b".into())];
|
||||
d.configs.rows.push(ConfigRow { name: "Reordered".into(), cells: vec!["b".into(), "2 mm".into()] });
|
||||
d.configs.rows.push(ConfigRow { id: 1, name: "Reordered".into(), cells: vec!["b".into(), "2 mm".into()] });
|
||||
d.apply_configuration("Reordered").unwrap();
|
||||
assert!(d.configuration_matches());
|
||||
assert_eq!(d.param_values().0["a"].v, 2.0);
|
||||
d.configs.rows.push(ConfigRow { name: "Cycle".into(), cells: vec!["b".into(), "a".into()] });
|
||||
d.configs.rows.push(ConfigRow { id: 2, name: "Cycle".into(), cells: vec!["b".into(), "a".into()] });
|
||||
let before = d.clone();
|
||||
assert!(d.apply_configuration("Cycle").is_err());
|
||||
assert_eq!(d, before);
|
||||
@@ -323,28 +387,50 @@ mod tests {
|
||||
d.set_param("width", "10 mm", None, None).unwrap();
|
||||
d.params[0].expr = "1/".into();
|
||||
d.configs.columns = vec![Column::Param("width".into())];
|
||||
d.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["1/".into()] });
|
||||
d.configs.rows.push(ConfigRow { id: 3, name: "Default".into(), cells: vec!["1/".into()] });
|
||||
d.apply_configuration("Default").unwrap();
|
||||
assert!(d.configuration_matches());
|
||||
d.configs.rows.push(ConfigRow { name: "Different error".into(), cells: vec!["missing + 1".into()] });
|
||||
d.configs.rows.push(ConfigRow { id: 4, name: "Different error".into(), cells: vec!["missing + 1".into()] });
|
||||
let before = d.clone();
|
||||
assert!(d.apply_configuration("Different error").is_err());
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
/// A row is found by its id or its name, and activating it by id names it active. A deleted
|
||||
/// row's id is never given out again (#14750).
|
||||
#[test]
|
||||
fn a_row_is_found_by_its_id_or_its_name() {
|
||||
let mut d = Document::new("A");
|
||||
d.set_param("w", "10 mm", None, None).unwrap();
|
||||
d.configs.columns.push(Column::Param("w".into()));
|
||||
let (small, large) = (d.allocate_id().unwrap(), d.allocate_id().unwrap());
|
||||
d.configs.rows.push(ConfigRow { id: small, name: "Small".into(), cells: vec!["10 mm".into()] });
|
||||
d.configs.rows.push(ConfigRow { id: large, name: "Large".into(), cells: vec!["40 mm".into()] });
|
||||
assert_eq!(d.configs.row(RowKey::Id(large)).map(|r| r.name.as_str()), Some("Large"));
|
||||
assert_eq!(d.configs.row("Small".into()).map(|r| r.id), Some(small));
|
||||
d.apply_configuration(large).unwrap();
|
||||
assert_eq!((d.configs.active.as_deref(), d.configs.active_row().map(|r| r.id)), (Some("Large"), Some(large)));
|
||||
assert!(d.configuration_matches());
|
||||
d.configs.rows.retain(|r| r.id != large);
|
||||
assert!(d.configs.active_row().is_none() && !d.configuration_matches());
|
||||
assert!(d.allocate_id().unwrap() > large, "a deleted row's id is never given again");
|
||||
let gone = d.apply_configuration(large).unwrap_err().to_string();
|
||||
assert!(gone.contains(&format!("configuration `{large}`")), "{gone}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_switch_parameters() {
|
||||
let mut d = Document::new("A");
|
||||
d.set_param("w", "10 mm", None, None).unwrap();
|
||||
d.configs.columns.push(Column::Param("w".into()));
|
||||
d.configs.rows.push(ConfigRow { name: "Small".into(), cells: vec!["10 mm".into()] });
|
||||
d.configs.rows.push(ConfigRow { name: "Large".into(), cells: vec!["40 mm".into()] });
|
||||
d.configs.rows.push(ConfigRow { id: 5, name: "Small".into(), cells: vec!["10 mm".into()] });
|
||||
d.configs.rows.push(ConfigRow { id: 6, name: "Large".into(), cells: vec!["40 mm".into()] });
|
||||
d.apply_configuration("Large").unwrap();
|
||||
assert_eq!(d.config_current(&Column::Param("w".into())).as_deref(), Some("40 mm"));
|
||||
assert!(d.configuration_matches());
|
||||
d.set_param("w", "12 mm", None, None).unwrap();
|
||||
assert!(!d.configuration_matches());
|
||||
d.configs.rows.push(ConfigRow { name: "Bad".into(), cells: vec!["1/".into()] });
|
||||
d.configs.rows.push(ConfigRow { id: 7, name: "Bad".into(), cells: vec!["1/".into()] });
|
||||
let before = d.clone();
|
||||
assert!(d.apply_configuration("Bad").is_err());
|
||||
assert_eq!(d, before, "all or nothing");
|
||||
@@ -356,6 +442,7 @@ mod tests {
|
||||
d.set_param("width", "10 mm", None, None).unwrap();
|
||||
d.configs.columns = vec![Column::Param("width".into()), Column::Material("Body".into()), Column::Appearance("Body".into())];
|
||||
d.configs.rows.push(ConfigRow {
|
||||
id: 8,
|
||||
name: "Broken".into(),
|
||||
cells: vec!["20 mm".into(), "Steel".into(), r#"{"name":"Invalid","color":[1,2,3],"opacity":2}"#.into()],
|
||||
});
|
||||
|
||||
@@ -147,6 +147,7 @@ mod tests {
|
||||
}),
|
||||
],
|
||||
rows: vec![ConfigRow {
|
||||
id: 1,
|
||||
name: "Default".into(),
|
||||
cells: vec!["d1 * 2 + d2".into(), "4 mm".into(), "d1".into(), String::new(), String::new()],
|
||||
}],
|
||||
@@ -203,7 +204,7 @@ mod tests {
|
||||
certificate: None,
|
||||
}),
|
||||
],
|
||||
rows: vec![ConfigRow { name: "Default".into(), cells: vec!["2 mm".into(), String::new()] }],
|
||||
rows: vec![ConfigRow { id: 2, name: "Default".into(), cells: vec!["2 mm".into(), String::new()] }],
|
||||
..Default::default()
|
||||
};
|
||||
let original = table.clone();
|
||||
|
||||
@@ -529,7 +529,7 @@ mod tests {
|
||||
doc.set_param("width", "10 mm", None, None).unwrap();
|
||||
doc.set_param("depth", "5 mm", None, None).unwrap();
|
||||
doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
|
||||
doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
|
||||
doc.configs.rows.push(ConfigRow { id: 1, name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
|
||||
doc
|
||||
}
|
||||
fn rule(id: &str, condition: RuleCondition, effect: RuleEffect) -> ConfigRule {
|
||||
@@ -730,7 +730,7 @@ mod tests {
|
||||
doc.rename_param(&width, "width").unwrap();
|
||||
doc.rename_param(&depth, "depth").unwrap();
|
||||
doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
|
||||
doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
|
||||
doc.configs.rows.push(ConfigRow { id: 2, name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
|
||||
doc.units = "in".into();
|
||||
let mut first =
|
||||
rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "depth + 1".into() });
|
||||
|
||||
@@ -1087,13 +1087,19 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "RailFit::is_scale")]
|
||||
rail_fit: RailFit,
|
||||
},
|
||||
/// Sweep profiles along a chain of sketch curves.
|
||||
/// Sweep profiles along a chain of sketch curves, or along a chain of a body's edges.
|
||||
Sweep {
|
||||
sketch: SketchId,
|
||||
#[walk(profiles_of = sketch)]
|
||||
profiles: ProfileSel,
|
||||
path_sketch: SketchId,
|
||||
path: Vec<String>,
|
||||
/// The path on a body's edges, instead of the curves of `path_sketch` (#2216): the body
|
||||
/// and a point on each edge of the chain.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
path_body: Option<BodyName>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
path_edges: Vec<Position>,
|
||||
/// How the profile turns along the path (default: perpendicular to it).
|
||||
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
|
||||
orientation: SweepOrientation,
|
||||
@@ -1815,6 +1821,12 @@ pub enum FeatureKind {
|
||||
curves: Vec<String>,
|
||||
path_sketch: SketchId,
|
||||
path: Vec<String>,
|
||||
/// The path on a body's edges, instead of the curves of `path_sketch` (#2216): the body
|
||||
/// and a point on each edge of the chain.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
path_body: Option<BodyName>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
path_edges: Vec<Position>,
|
||||
/// How the profile turns along the path (default: perpendicular to it).
|
||||
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
|
||||
orientation: SweepOrientation,
|
||||
@@ -3649,6 +3661,7 @@ impl Document {
|
||||
.chain(a.contact_sets.iter().map(|c| c.id))
|
||||
.chain(a.studies.iter().map(|s| s.id))
|
||||
.chain(a.exploded.iter().map(|v| v.id))
|
||||
.chain(self.configs.rows.iter().map(|r| r.id))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let floor = largest.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
|
||||
@@ -3904,10 +3917,14 @@ impl Document {
|
||||
for b in &mut st.bodies {
|
||||
let c = self.body_color(b);
|
||||
let face_colors = if authored_faces { self.face_colors(b) } else { self.imported_face_colors(b) };
|
||||
let faces: Vec<solidcraft_kernel::FacePaint> =
|
||||
face_colors.into_iter().map(|(face, l)| solidcraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32 }).collect();
|
||||
let faces: Vec<solidcraft_kernel::FacePaint> = face_colors
|
||||
.into_iter()
|
||||
.map(|(face, l)| solidcraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32, name: l.file_name() })
|
||||
.collect();
|
||||
let opacity = self.model_body_opacity(b) as f32;
|
||||
let paint = solidcraft_kernel::Paint { opacity, faces };
|
||||
// The look's name goes with its colour (a STEP `COLOUR_RGB` name), so it comes back on import.
|
||||
let name = c.and_then(|_| self.model_display_look(b)).and_then(|l| l.file_name());
|
||||
let paint = solidcraft_kernel::Paint { opacity, faces, name };
|
||||
if c != b.body.color() || b.body.paint() != Some(&paint) {
|
||||
b.body = b.body.clone().with_color(c).with_paint(Some(paint));
|
||||
}
|
||||
|
||||
+1005
-139
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use solidcraft_kernel as kernel;
|
||||
|
||||
use super::{CachedEval, EvalCache, FeatureResult, KeptSlot, ModelBody, ModelState};
|
||||
use super::{CacheEntry, EvalCache, FeatureResult, KeptSlot, ModelBody, ModelState};
|
||||
|
||||
/// The evaluation's loop state just before a feature was evaluated: what a refusal of its check
|
||||
/// puts back before evaluating it again.
|
||||
@@ -61,7 +61,7 @@ pub(super) struct Checks {
|
||||
most: usize,
|
||||
running: VecDeque<Running>,
|
||||
/// Cache entries of features evaluated after a check that is still running, in order.
|
||||
held: Vec<(usize, u64, CachedEval)>,
|
||||
held: Vec<(usize, CacheEntry)>,
|
||||
/// Features whose check refused their bodies, to fail again as it did.
|
||||
refused: HashMap<usize, Failure>,
|
||||
}
|
||||
@@ -92,6 +92,11 @@ impl Checks {
|
||||
}
|
||||
|
||||
/// The failure feature `index` ends with, when its check refused it before.
|
||||
/// Whether feature `index`'s check refused its bodies, leaving the failure in place.
|
||||
pub fn refused_at(&self, index: usize) -> Option<&Failure> {
|
||||
self.refused.get(&index)
|
||||
}
|
||||
|
||||
pub fn refused(&mut self, index: usize) -> Option<Failure> {
|
||||
self.refused.remove(&index)
|
||||
}
|
||||
@@ -125,11 +130,11 @@ impl Checks {
|
||||
}
|
||||
|
||||
/// Stores the cache entry of feature `index` once no check before it, or of it, is running.
|
||||
pub fn store(&mut self, index: usize, key: u64, entry: CachedEval, cache: &Mutex<EvalCache>, kept: &KeptSlot) {
|
||||
pub fn store(&mut self, index: usize, entry: CacheEntry, cache: &Mutex<EvalCache>, kept: &KeptSlot) {
|
||||
if self.running.front().is_some_and(|r| r.index <= index) {
|
||||
self.held.push((index, key, entry));
|
||||
self.held.push((index, entry));
|
||||
} else {
|
||||
put(key, entry, cache, kept);
|
||||
entry.put(cache, kept);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,15 +155,15 @@ impl Checks {
|
||||
// What came after it was evaluated on bodies the check refuses: none of it stands.
|
||||
// The checks still running are left to end on their own.
|
||||
self.running.clear();
|
||||
self.held.retain(|(i, _, _)| *i < r.index);
|
||||
self.held.retain(|(i, _)| *i < r.index);
|
||||
return Some(Refused { index: r.index, saved: r.saved, failure: Failure { error, ..r.failure } });
|
||||
}
|
||||
let upto = self.running.front().map_or(usize::MAX, |next| next.index);
|
||||
for (i, key, entry) in std::mem::take(&mut self.held) {
|
||||
for (i, entry) in std::mem::take(&mut self.held) {
|
||||
if i < upto {
|
||||
put(key, entry, cache, kept);
|
||||
entry.put(cache, kept);
|
||||
} else {
|
||||
self.held.push((i, key, entry));
|
||||
self.held.push((i, entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,17 +176,9 @@ impl Checks {
|
||||
}
|
||||
}
|
||||
|
||||
fn put(key: u64, entry: CachedEval, cache: &Mutex<EvalCache>, kept: &KeptSlot) {
|
||||
if let Some(k) = &kept.0 {
|
||||
k.put(key, &entry);
|
||||
}
|
||||
if let Ok(mut m) = cache.lock() {
|
||||
m.put(key, entry);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::CachedEval;
|
||||
use super::*;
|
||||
use kernel::KernelError;
|
||||
|
||||
@@ -205,8 +202,9 @@ mod tests {
|
||||
Failure { error: KernelError::Failed(String::new()), warning: Some("kept".into()), import_report: None, repair: None, ms: 1.0 }
|
||||
}
|
||||
|
||||
fn entry() -> CachedEval {
|
||||
CachedEval { error: None, warning: None, import_report: None, repair: None, ms: 0.0, state: Arc::new(ModelState::default()), bytes: 1 }
|
||||
fn entry(key: u64) -> CacheEntry {
|
||||
let whole = CachedEval { error: None, warning: None, import_report: None, repair: None, ms: 0.0, state: Arc::new(ModelState::default()) };
|
||||
CacheEntry { key, whole, bytes: 1, change: None }
|
||||
}
|
||||
|
||||
fn checks() -> Checks {
|
||||
@@ -229,10 +227,10 @@ mod tests {
|
||||
let (cache, kept) = (Mutex::new(EvalCache::default()), KeptSlot(None));
|
||||
let mut c = checks();
|
||||
assert!(c.start(0, saved(), failure(), later(Ok(()))).is_ok());
|
||||
c.store(0, 10, entry(), &cache, &kept);
|
||||
c.store(0, entry(10), &cache, &kept);
|
||||
assert!(c.start(1, saved(), failure(), later(Err(KernelError::Failed("refused".into())))).is_ok());
|
||||
c.store(1, 11, entry(), &cache, &kept);
|
||||
c.store(2, 12, entry(), &cache, &kept);
|
||||
c.store(1, entry(11), &cache, &kept);
|
||||
c.store(2, entry(12), &cache, &kept);
|
||||
assert!(cache.lock().unwrap().is_empty(), "held while the checks run");
|
||||
let refused = c.settle(true, &cache, &kept).expect("the second check refuses");
|
||||
assert_eq!(refused.index, 1);
|
||||
@@ -240,7 +238,7 @@ mod tests {
|
||||
assert_eq!(refused.failure.warning.as_deref(), Some("kept"));
|
||||
{
|
||||
let m = cache.lock().unwrap();
|
||||
assert!(m.map.contains_key(&10) && !m.map.contains_key(&11) && !m.map.contains_key(&12));
|
||||
assert!(m.states.map.contains_key(&10) && !m.states.map.contains_key(&11) && !m.states.map.contains_key(&12));
|
||||
}
|
||||
c.refuse(1, refused.failure);
|
||||
assert!(c.refused(1).is_some() && c.refused(1).is_none());
|
||||
@@ -253,11 +251,11 @@ mod tests {
|
||||
fn passed_checks_store_what_waited() {
|
||||
let (cache, kept) = (Mutex::new(EvalCache::default()), KeptSlot(None));
|
||||
let mut c = checks();
|
||||
c.store(0, 10, entry(), &cache, &kept);
|
||||
c.store(0, entry(10), &cache, &kept);
|
||||
assert_eq!(cache.lock().unwrap().len(), 1);
|
||||
assert!(c.start(1, saved(), failure(), later(Ok(()))).is_ok());
|
||||
c.store(1, 11, entry(), &cache, &kept);
|
||||
c.store(2, 12, entry(), &cache, &kept);
|
||||
c.store(1, entry(11), &cache, &kept);
|
||||
c.store(2, entry(12), &cache, &kept);
|
||||
assert!(c.settle(true, &cache, &kept).is_none());
|
||||
assert_eq!(cache.lock().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
@@ -379,8 +379,36 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale, rails, rail_fit } => {
|
||||
FeatureKind::SurfaceSweep {
|
||||
sketch,
|
||||
profiles,
|
||||
curves,
|
||||
path_sketch,
|
||||
path,
|
||||
path_body,
|
||||
path_edges,
|
||||
orientation,
|
||||
twist,
|
||||
scale,
|
||||
rails,
|
||||
rail_fit,
|
||||
} => {
|
||||
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
|
||||
// The path is a chain of a body's edges (#2216) or the curves of a sketch.
|
||||
if let Some(body) = path_body {
|
||||
let chains = sheet_chains(ss, profiles.as_ref(), curves)?;
|
||||
// The path runs on from the end nearest the middle of the curves.
|
||||
let pts: Vec<Vec2> = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect();
|
||||
let start = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64));
|
||||
let segs = crate::eval::edge_path_segments(st, body, path_edges, start, Some(&ss.plane))?;
|
||||
let path_start = segs.first().map(kernel::PathSeg::start).unwrap_or(start);
|
||||
let rails = crate::eval::rail_polylines(doc, st, rails, f.component, path_start)?;
|
||||
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale, rails, *rail_fit)?;
|
||||
let body = kernel::sweep_sheet_with(&ss.plane, &chains, &segs, &ctl)?;
|
||||
let traced = crate::lineage::ChainRoles { sketch: ss, chains: &chains }.tool(body);
|
||||
push_sheet(st, f, traced);
|
||||
return Ok(());
|
||||
}
|
||||
let ps =
|
||||
st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
|
||||
// As Sweep: a 3D path sketch is followed in the feature's frame, the curves with it.
|
||||
|
||||
@@ -883,7 +883,7 @@ fn cut_with_flanks(
|
||||
kernel::boolean(&input.body, &sleeve, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("narrowing the hole removed everything".into()))?;
|
||||
let unnamed = crate::lineage::Lineage::unnamed_faces(faces);
|
||||
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&filled, &[&l, &unnamed]));
|
||||
input = Traced { body: filled, lineage };
|
||||
input = Traced { body: filled, lineage, recipe: None };
|
||||
groove_radius = r_min;
|
||||
}
|
||||
// The exact grooves: SMLib's screw sweeps, cut by one B-rep boolean.
|
||||
@@ -905,7 +905,7 @@ fn cut_with_flanks(
|
||||
kernel::boolean(&input.body, &cone, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("the chamfer removed everything".into()))?;
|
||||
let unnamed = crate::lineage::Lineage::unnamed_faces(faces);
|
||||
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&cut, &[&l, &unnamed]));
|
||||
input = Traced { body: cut, lineage };
|
||||
input = Traced { body: cut, lineage, recipe: None };
|
||||
}
|
||||
}
|
||||
Ok(input)
|
||||
@@ -957,7 +957,7 @@ fn cut_with_flanks(
|
||||
// The groove's faces have no role to name them by.
|
||||
let groove = crate::lineage::Lineage::unnamed_faces(groove_faces);
|
||||
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove]));
|
||||
let nb = Traced { body: nb, lineage };
|
||||
let nb = Traced { body: nb, lineage, recipe: None };
|
||||
let nb = if continuous_flanks { nb } else { chamfer(nb)? };
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||||
|
||||
@@ -819,8 +819,9 @@ pub(crate) fn decode(mut v: Value, version: u32, applied: Option<&[String]>) ->
|
||||
if !all_unique(d.features.iter().map(|f| f.id))
|
||||
|| !all_unique(d.components.iter().map(|c| c.id))
|
||||
|| !all_unique(d.occurrences.iter().map(|o| o.id))
|
||||
|| !all_unique(d.configs.rows.iter().map(|r| r.id))
|
||||
{
|
||||
return Err(DocError::Invalid("damaged design: duplicate feature, component or occurrence id".into()));
|
||||
return Err(DocError::Invalid("damaged design: duplicate feature, component, occurrence or configuration row id".into()));
|
||||
}
|
||||
let component_ids: std::collections::BTreeSet<_> = d.components.iter().map(|component| component.id).chain(std::iter::once(0)).collect();
|
||||
for feature in &d.features {
|
||||
@@ -2212,9 +2213,10 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
d.configs.columns = vec![Column::Param("width".into()), Column::Suppress(feature)];
|
||||
let ids = [d.allocate_id().unwrap(), d.allocate_id().unwrap()];
|
||||
d.configs.rows = vec![
|
||||
ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "false".into()] },
|
||||
ConfigRow { name: "Alternate".into(), cells: vec!["width / 2 + 5 mm".into(), "yes".into()] },
|
||||
ConfigRow { id: ids[0], name: "Default".into(), cells: vec!["10 mm".into(), "false".into()] },
|
||||
ConfigRow { id: ids[1], name: "Alternate".into(), cells: vec!["width / 2 + 5 mm".into(), "yes".into()] },
|
||||
];
|
||||
d.configs.active = Some("Default".into());
|
||||
let mut legacy: Value = serde_json::from_str(&d.to_json()).unwrap();
|
||||
@@ -2238,6 +2240,7 @@ mod tests {
|
||||
let mut d = Document::new("configured styles");
|
||||
d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())];
|
||||
d.configs.rows = vec![ConfigRow {
|
||||
id: d.allocate_id().unwrap(),
|
||||
name: "Blue steel".into(),
|
||||
cells: vec!["Steel".into(), r#"{"name":"Custom blue","color":[40,90,186],"opacity":0.4}"#.into()],
|
||||
}];
|
||||
@@ -2549,12 +2552,15 @@ mod tests {
|
||||
("contact_sets", json!({"id": 100, "name": "Contact", "occurrences": []})),
|
||||
("studies", json!({"id": 100, "name": "Study", "steps": 10, "keys": []})),
|
||||
("exploded", json!({"id": 100, "name": "View", "moves": []})),
|
||||
("configs", json!({"columns": [], "rows": [{"id": 100, "name": "Row", "cells": []}]})),
|
||||
];
|
||||
for (field, item) in cases {
|
||||
let json = edited(&Document::new("Legacy IDs"), |v| {
|
||||
v.as_object_mut().unwrap().remove("next_id");
|
||||
if matches!(field, "browser_groups" | "timeline_groups" | "canvases") {
|
||||
v[field] = json!([item]);
|
||||
} else if field == "configs" {
|
||||
v[field] = item;
|
||||
} else {
|
||||
v["assembly"] = json!({field: [item]});
|
||||
}
|
||||
@@ -2565,6 +2571,7 @@ mod tests {
|
||||
back.timeline_groups.clear();
|
||||
back.canvases.clear();
|
||||
back.assembly = Default::default();
|
||||
back.configs = Default::default();
|
||||
let mut reopened = read(&back.to_json()).unwrap();
|
||||
assert_eq!(reopened.allocate_id().unwrap(), 101, "{field}: deleted legacy id was forgotten");
|
||||
assert_eq!(reopened.allocate_id().unwrap(), 102, "{field}");
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Configuration rows save a fixed id (`id`, #14750), from the design's id counter: a rename keeps
|
||||
//! it and no other row is ever given it, so what points at a row still finds it after a rename.
|
||||
//! The table still saves its active row by name. An older design's rows get their ids as it opens,
|
||||
//! in row order from its saved `next_id` (past any id a row already has), and `next_id` moves past
|
||||
//! them. The last release warns about the newer format number, opens the design with its whole
|
||||
//! table and keeps the row ids it can't read.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Result};
|
||||
|
||||
pub fn step(v: &mut Value) -> Result<()> {
|
||||
let saved = v.get("next_id").and_then(Value::as_u64).unwrap_or(0);
|
||||
let Some(rows) = v.pointer_mut("/configs/rows").and_then(Value::as_array_mut) else { return Ok(()) };
|
||||
let past = |id: u64| id.checked_add(1).ok_or_else(|| DocError::Invalid("damaged design: the id space is exhausted".into()));
|
||||
let mut next = saved.max(1);
|
||||
if let Some(largest) = rows.iter().filter_map(|row| row.get("id")?.as_u64()).max() {
|
||||
next = next.max(past(largest)?);
|
||||
}
|
||||
let first = next;
|
||||
for row in rows.iter_mut().filter_map(Value::as_object_mut) {
|
||||
if !row.contains_key("id") {
|
||||
row.insert("id".into(), next.into());
|
||||
next = past(next)?;
|
||||
}
|
||||
}
|
||||
if next != first
|
||||
&& let Some(design) = v.as_object_mut()
|
||||
{
|
||||
design.insert("next_id".into(), next.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::step;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn design(next_id: Option<u64>, rows: Value) -> Value {
|
||||
let mut v = json!({"name": "Plate", "configs": {"columns": [], "rows": rows, "active": "Long"}});
|
||||
if let Some(next) = next_id {
|
||||
v["next_id"] = next.into();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<Option<u64>> {
|
||||
v["configs"]["rows"].as_array().map_or(Vec::new(), |rows| rows.iter().map(|row| row["id"].as_u64()).collect())
|
||||
}
|
||||
|
||||
/// Rows get ids in order from the saved counter, past any id a row has, and the counter moves
|
||||
/// past them; the active row stays named.
|
||||
#[test]
|
||||
fn an_older_designs_rows_get_ids_in_order_from_its_counter() {
|
||||
let rows = json!([{"name": "Short", "cells": []}, {"name": "Long", "cells": []}]);
|
||||
let mut v = design(Some(7), rows.clone());
|
||||
step(&mut v).unwrap();
|
||||
assert_eq!((ids(&v), &v["next_id"], &v["configs"]["active"]), (vec![Some(7), Some(8)], &json!(9), &json!("Long")));
|
||||
// No counter: from 1.
|
||||
let mut v = design(None, rows);
|
||||
step(&mut v).unwrap();
|
||||
assert_eq!((ids(&v), &v["next_id"]), (vec![Some(1), Some(2)], &json!(3)));
|
||||
// A row that has an id keeps it, and the others go past it.
|
||||
let mut v = design(Some(2), json!([{"name": "Short", "cells": []}, {"id": 40, "name": "Long", "cells": []}]));
|
||||
step(&mut v).unwrap();
|
||||
assert_eq!((ids(&v), &v["next_id"]), (vec![Some(41), Some(40)], &json!(42)));
|
||||
}
|
||||
|
||||
/// A design with no table, or whose rows all have ids, is left as it is.
|
||||
#[test]
|
||||
fn a_design_with_nothing_to_give_is_left_as_it_is() {
|
||||
for mut v in
|
||||
[json!({"name": "Plate", "next_id": 3}), design(Some(3), json!([{"id": 1, "name": "Long", "cells": []}])), design(None, json!([]))]
|
||||
{
|
||||
let before = v.clone();
|
||||
step(&mut v).unwrap();
|
||||
assert_eq!(v, before);
|
||||
}
|
||||
}
|
||||
|
||||
/// A counter at the end of the id space can give no id: the design is refused, not given a
|
||||
/// repeated one.
|
||||
#[test]
|
||||
fn a_counter_at_the_end_is_refused() {
|
||||
let mut v = design(Some(u64::MAX), json!([{"name": "Long", "cells": []}]));
|
||||
assert!(step(&mut v).unwrap_err().to_string().contains("id space is exhausted"));
|
||||
let mut v = design(Some(1), json!([{"id": u64::MAX, "name": "Short", "cells": []}, {"name": "Long", "cells": []}]));
|
||||
assert!(step(&mut v).is_err());
|
||||
}
|
||||
|
||||
/// The largest table a design may hold gets one id per row, in order, in one pass.
|
||||
#[test]
|
||||
fn the_largest_table_gets_an_id_for_every_row() {
|
||||
let rows: Vec<Value> = (0..crate::config::MAX_ROWS).map(|i| json!({"name": format!("Row {i}"), "cells": []})).collect();
|
||||
let mut v = design(Some(5), Value::Array(rows));
|
||||
step(&mut v).unwrap();
|
||||
let expected: Vec<Option<u64>> = (5..5 + crate::config::MAX_ROWS as u64).map(Some).collect();
|
||||
assert_eq!(ids(&v), expected);
|
||||
assert_eq!(v["next_id"], json!(5 + crate::config::MAX_ROWS as u64));
|
||||
}
|
||||
|
||||
/// An older design opens with an id for every row, its active row still matching, and the
|
||||
/// design's next id past them; rows that repeat an id are a damaged design.
|
||||
#[test]
|
||||
fn an_older_design_opens_with_row_ids_and_its_active_row() {
|
||||
let mut d = crate::Document::new("Plate");
|
||||
d.set_param("L", "25 mm", None, None).unwrap();
|
||||
d.configs.columns = vec![crate::config::Column::Param("L".into())];
|
||||
let ids = [d.allocate_id().unwrap(), d.allocate_id().unwrap()];
|
||||
d.configs.rows = vec![
|
||||
crate::config::ConfigRow { id: ids[0], name: "Short".into(), cells: vec!["25 mm".into()] },
|
||||
crate::config::ConfigRow { id: ids[1], name: "Long".into(), cells: vec!["30 mm".into()] },
|
||||
];
|
||||
d.apply_configuration("Long").unwrap();
|
||||
let mut old: Value = serde_json::from_str(&d.to_json()).unwrap();
|
||||
old["format"] = Value::from("solidcraft/6");
|
||||
for row in old["configs"]["rows"].as_array_mut().unwrap() {
|
||||
row.as_object_mut().unwrap().remove("id");
|
||||
}
|
||||
let opened = crate::format::read(&old.to_string()).unwrap();
|
||||
let given: Vec<u64> = opened.configs.rows.iter().map(|row| row.id).collect();
|
||||
assert!(given[0] != given[1] && given.iter().all(|id| *id > 0), "{given:?}");
|
||||
assert!(opened.next_id > given[0].max(given[1]));
|
||||
assert_eq!(opened.configs.active_row().map(|row| row.name.as_str()), Some("Long"));
|
||||
assert!(opened.configuration_matches());
|
||||
let mut twice: Value = serde_json::from_str(&d.to_json()).unwrap();
|
||||
twice["configs"]["rows"][1]["id"] = twice["configs"]["rows"][0]["id"].clone();
|
||||
let refused = crate::format::read(&twice.to_string()).unwrap_err().to_string();
|
||||
assert!(refused.contains("configuration row id"), "{refused}");
|
||||
}
|
||||
|
||||
/// What the last release does with a row field it doesn't know, as it does with `id`: the
|
||||
/// table opens whole and the field goes back into the file when it saves.
|
||||
#[test]
|
||||
fn a_newer_designs_unknown_row_field_keeps_the_table_whole() {
|
||||
let mut d = crate::Document::new("Plate");
|
||||
d.set_param("L", "25 mm", None, None).unwrap();
|
||||
d.configs.columns = vec![crate::config::Column::Param("L".into())];
|
||||
d.configs.rows = vec![crate::config::ConfigRow { id: d.allocate_id().unwrap(), name: "Long".into(), cells: vec!["30 mm".into()] }];
|
||||
d.configs.active = Some("Long".into());
|
||||
let mut newer: Value = serde_json::from_str(&d.to_json()).unwrap();
|
||||
newer["format"] = Value::from(crate::format::format_string(crate::format::FORMAT + 1));
|
||||
newer["configs"]["rows"][0]["revision"] = json!(3);
|
||||
let opened = crate::format::read(&newer.to_string()).unwrap();
|
||||
assert_eq!(opened.configs.rows.len(), 1);
|
||||
assert_eq!(opened.configs.active.as_deref(), Some("Long"));
|
||||
let saved: Value = serde_json::from_str(&opened.to_json()).unwrap();
|
||||
assert_eq!(saved["configs"], newer["configs"], "kept for the build that reads it");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A sweep saves the path it follows when the path is a chain of a body's edges: `path_body`
|
||||
//! (the body) and `path_edges` (a point on each edge of the chain), beside the curves of the
|
||||
//! path sketch a sweep saved before this change has. An older design has none and keeps its
|
||||
//! path of sketch curves, as it was built. The last release refuses a design that has it (it
|
||||
//! asks for an update) instead of opening it without the path.
|
||||
@@ -148,6 +148,9 @@ impl Lineage {
|
||||
pub(crate) struct Traced {
|
||||
pub body: Body,
|
||||
pub lineage: Option<Lineage>,
|
||||
/// How the feature built it, when that alone fixes it (a primitive from its numbers, a copy
|
||||
/// of one moved): what a cut by it is remembered by (see `ModelBody::cut`).
|
||||
pub recipe: Option<u64>,
|
||||
}
|
||||
|
||||
impl Traced {
|
||||
@@ -155,13 +158,13 @@ impl Traced {
|
||||
/// geometry, those of the bodies it is combined with by their history.
|
||||
pub fn unnamed(body: Body) -> Traced {
|
||||
let roles = vec![Vec::new(); body.face_count()];
|
||||
Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body }
|
||||
Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body, recipe: None }
|
||||
}
|
||||
|
||||
/// A body the feature built, its faces' roles given (one per face), else not known.
|
||||
pub fn made(body: Body, roles: Option<Vec<Vec<String>>>) -> Traced {
|
||||
match roles.filter(|r| r.len() == body.face_count()) {
|
||||
Some(roles) => Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body },
|
||||
Some(roles) => Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body, recipe: None },
|
||||
None => Traced::unnamed(body),
|
||||
}
|
||||
}
|
||||
@@ -170,14 +173,19 @@ impl Traced {
|
||||
/// which built it.
|
||||
pub fn copied(&self, copy: Body, feature: u64, instance: usize) -> Traced {
|
||||
let lineage = self.lineage.as_ref().and_then(|l| Lineage::step(©, &[&l.copied(feature, instance)]));
|
||||
Traced { body: copy, lineage }
|
||||
Traced { body: copy, lineage, recipe: None }
|
||||
}
|
||||
|
||||
/// This tool, built by `recipe`.
|
||||
pub fn with_recipe(self, recipe: Option<u64>) -> Traced {
|
||||
Traced { recipe, ..self }
|
||||
}
|
||||
|
||||
/// `result`, made by one kernel operation from `inputs` (in its operand order).
|
||||
pub fn step(result: Body, inputs: &[&Traced]) -> Traced {
|
||||
let lineages: Option<Vec<&Lineage>> = inputs.iter().map(|t| t.lineage.as_ref()).collect();
|
||||
let lineage = lineages.and_then(|l| Lineage::step(&result, &l));
|
||||
Traced { body: result, lineage }
|
||||
Traced { body: result, lineage, recipe: None }
|
||||
}
|
||||
|
||||
/// `result`, made by one kernel operation from this body alone. Where that operation's
|
||||
@@ -198,7 +206,7 @@ impl Traced {
|
||||
let stage = Arc::new(Operand::Stage(Box::new(Stage { body, lineage: l.clone(), names: OnceLock::new() })));
|
||||
Some(Lineage { history, operands: vec![stage] })
|
||||
});
|
||||
Traced { body: result, lineage }
|
||||
Traced { body: result, lineage, recipe: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,14 +579,14 @@ impl crate::ModelBody {
|
||||
Made::Traced(l) => Some(l.as_ref().clone()),
|
||||
Made::Unknown => None,
|
||||
};
|
||||
Traced { body: self.body.clone(), lineage }
|
||||
Traced { body: self.body.clone(), lineage, recipe: None }
|
||||
}
|
||||
|
||||
/// This body, as it was before the feature, as an operand named by `operand` (a moved copy of
|
||||
/// it, the cavity side of a shell grown from it); `None` once the feature has changed it.
|
||||
pub(crate) fn before_as(&self, operand: Operand) -> Traced {
|
||||
let lineage = matches!(self.made, Made::Before).then(|| Lineage::of(&self.body, operand));
|
||||
Traced { body: self.body.clone(), lineage }
|
||||
Traced { body: self.body.clone(), lineage, recipe: None }
|
||||
}
|
||||
|
||||
/// The lineage a feature left this body with, when it made or changed it with one.
|
||||
|
||||
@@ -234,6 +234,17 @@ impl Writer {
|
||||
if let Some(look) = &b.imported {
|
||||
let faces: Vec<Json> = look.faces.iter().map(|(name, (color, opacity))| json!([name, color, opacity])).collect();
|
||||
r["imported"] = json!({"color": look.color, "opacity": look.opacity, "faces": faces});
|
||||
if look.name.is_some() || !look.face_names.is_empty() {
|
||||
r["imported"]["name"] = json!(look.name);
|
||||
r["imported"]["face_names"] = json!(look.face_names);
|
||||
}
|
||||
}
|
||||
// The kernel body's own encoding keeps no colour names: the file's names for its paint.
|
||||
if let Some(paint) = b.body.paint() {
|
||||
let faces: Vec<Json> = paint.faces.iter().filter_map(|f| Some(json!([f.face, f.name.as_ref()?]))).collect();
|
||||
if paint.name.is_some() || !faces.is_empty() {
|
||||
r["paint_names"] = json!({"body": paint.name, "faces": faces});
|
||||
}
|
||||
}
|
||||
r["body"] = json!(self.body(&b.body));
|
||||
self.model_bodies.push(r);
|
||||
@@ -447,12 +458,34 @@ impl SavedModel {
|
||||
b.bytes = b.body.retained_bytes();
|
||||
if let Some(look) = r.get("imported") {
|
||||
let faces: Vec<(String, [f32; 3], f32)> = serde_json::from_value(parse(look.get("faces"))?).map_err(|_| bad("saved model: look"))?;
|
||||
let colour_name = |v: Option<&Json>| -> Result<Option<String>> {
|
||||
let name: Option<String> = serde_json::from_value(v.cloned().unwrap_or(Json::Null)).map_err(|_| bad("saved model: look"))?;
|
||||
Ok(name.as_deref().and_then(solidcraft_kernel::colour_name))
|
||||
};
|
||||
let face_names: BTreeMap<String, String> =
|
||||
serde_json::from_value(look.get("face_names").cloned().unwrap_or_else(|| json!({}))).map_err(|_| bad("saved model: look"))?;
|
||||
b.imported = Some(Arc::new(crate::appearance::ImportedLook {
|
||||
color: serde_json::from_value(parse(look.get("color"))?).map_err(|_| bad("saved model: look"))?,
|
||||
opacity: serde_json::from_value(parse(look.get("opacity"))?).map_err(|_| bad("saved model: look"))?,
|
||||
faces: faces.into_iter().map(|(name, color, opacity)| (name, (color, opacity))).collect(),
|
||||
name: colour_name(look.get("name"))?,
|
||||
face_names: face_names.into_iter().filter_map(|(face, name)| Some((face, solidcraft_kernel::colour_name(&name)?))).collect(),
|
||||
}));
|
||||
}
|
||||
if let Some(names) = r.get("paint_names")
|
||||
&& let Some(paint) = b.body.paint()
|
||||
{
|
||||
let body: Option<String> =
|
||||
serde_json::from_value(names.get("body").cloned().unwrap_or(Json::Null)).map_err(|_| bad("saved model: paint"))?;
|
||||
let faces: Vec<(usize, String)> =
|
||||
serde_json::from_value(names.get("faces").cloned().unwrap_or_else(|| json!([]))).map_err(|_| bad("saved model: paint"))?;
|
||||
let mut paint = paint.clone();
|
||||
paint.name = body.as_deref().and_then(solidcraft_kernel::colour_name);
|
||||
for f in &mut paint.faces {
|
||||
f.name = faces.iter().rfind(|(face, _)| *face == f.face).and_then(|(_, name)| solidcraft_kernel::colour_name(name));
|
||||
}
|
||||
b.body = b.body.clone().with_paint(Some(paint));
|
||||
}
|
||||
model_bodies.push(b);
|
||||
}
|
||||
let mut sheets = Vec::new();
|
||||
@@ -944,8 +977,10 @@ impl Model {
|
||||
before = r.state.clone();
|
||||
continue;
|
||||
}
|
||||
let local = fingerprint(doc, 0, f, &vals, false, &model.import_digests) ^ placement_digest(doc, f, Some(&before));
|
||||
cache.put(
|
||||
let local = fingerprint(doc, 0, f, &vals, false, &model.import_digests)
|
||||
^ placement_digest(doc, f, Some(&before))
|
||||
^ construct_digest(doc, &vals, f, Some(&before));
|
||||
cache.states.put(
|
||||
cache_key(f, r.fingerprint, local, ctx, &before),
|
||||
CachedEval {
|
||||
error: None,
|
||||
@@ -953,9 +988,10 @@ impl Model {
|
||||
import_report: r.import_report.clone(),
|
||||
repair: r.repair.clone(),
|
||||
ms: r.ms,
|
||||
bytes: r.state.retained_bytes(),
|
||||
state: r.state.clone(),
|
||||
},
|
||||
r.state.retained_bytes(),
|
||||
r.ms,
|
||||
);
|
||||
before = r.state.clone();
|
||||
}
|
||||
|
||||
+397
-2
@@ -294,10 +294,11 @@ fn set_back_restores_everything_when_states_share_bodies() {
|
||||
m.evaluate(&doc);
|
||||
// Room for a few whole models: enough for both timelines' new bodies, not for every state
|
||||
// charged as a whole model.
|
||||
m.cache.lock().unwrap().budget = 3 * m.state().retained_bytes();
|
||||
m.cache.lock().unwrap().set_budget(3 * m.state().retained_bytes());
|
||||
doc.set_param("h", "12 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!(m.last_recomputed, 30);
|
||||
// Only the box reading `h` is made again (#6183): the others' changes are reused.
|
||||
assert_eq!(m.last_recomputed, 1);
|
||||
doc.set_param("h", "10 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (0, 30));
|
||||
@@ -447,6 +448,400 @@ fn rolled_results_never_mask_edits_or_disabled_reuse() {
|
||||
}
|
||||
}
|
||||
|
||||
fn add_in(doc: &mut Document, component: u64, name: &str, kind: serde_json::Value) {
|
||||
let kind: FeatureKind = serde_json::from_value(kind).unwrap();
|
||||
let id = doc.add_feature(kind, Some(name)).unwrap();
|
||||
doc.feature_mut(id).unwrap().component = component;
|
||||
}
|
||||
|
||||
/// A plate in one component, then a rod in another, slotted by a box cut patterned round it.
|
||||
fn two_parts(slot_targets: &[&str]) -> Document {
|
||||
let mut doc = Document::new("two parts");
|
||||
doc.set_param("w", "40 mm", None, None).unwrap();
|
||||
doc.set_param("slot", "1 mm", None, None).unwrap();
|
||||
let plate = doc.add_component(Some("Plate"), 0).unwrap();
|
||||
let rod = doc.add_component(Some("Rod"), 0).unwrap();
|
||||
add_in(
|
||||
&mut doc,
|
||||
plate,
|
||||
"Plate",
|
||||
serde_json::json!({"type": "box", "corner": ["0", "0", "0"], "length": "w", "width": "30", "height": "5", "operation": "new_body"}),
|
||||
);
|
||||
add_in(
|
||||
&mut doc,
|
||||
rod,
|
||||
"Rod",
|
||||
serde_json::json!({"type": "cylinder", "base": ["0", "0", "0"], "axis": [0.0, 0.0, 1.0], "radius": "4", "height": "20", "operation": "new_body"}),
|
||||
);
|
||||
add_in(
|
||||
&mut doc,
|
||||
rod,
|
||||
"Slot",
|
||||
serde_json::json!({"type": "box", "corner": ["3", "-0.5", "2"], "length": "2", "width": "slot", "height": "20", "operation": "cut"}),
|
||||
);
|
||||
for f in doc.features.iter_mut().filter(|f| f.name == "Plate" || f.name == "Rod") {
|
||||
f.body_names = vec![f.name.clone()];
|
||||
}
|
||||
if let Some(f) = doc.features.iter_mut().find(|f| f.name == "Slot") {
|
||||
f.participants = slot_targets.iter().map(|t| t.to_string()).collect();
|
||||
}
|
||||
let slot = doc.find_feature("Slot").map_or(0, |f| f.id);
|
||||
add_in(
|
||||
&mut doc,
|
||||
rod,
|
||||
"Slots",
|
||||
serde_json::json!({"type": "pattern", "features": [slot], "pattern": {"type": "circular", "origin": [0.0, 0.0, 0.0], "axis": [0.0, 0.0, 1.0], "count": "4", "angle": "360 deg"}}),
|
||||
);
|
||||
doc
|
||||
}
|
||||
|
||||
/// The bodies as an evaluation from scratch makes them: names, volumes, face names.
|
||||
fn summary(st: &ModelState) -> Vec<(String, u64, i64, Vec<String>)> {
|
||||
st.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let volume = measure(&b.body).unwrap().volume;
|
||||
(b.name.clone(), b.component, (volume * 1e6).round() as i64, crate::naming::face_names(b).to_vec())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn from_scratch(doc: &Document) -> Vec<(String, u64, i64, Vec<String>)> {
|
||||
let mut m = Model::new();
|
||||
m.no_cache = true;
|
||||
m.evaluate(doc);
|
||||
summary(&m.state())
|
||||
}
|
||||
|
||||
/// An edit in one component leaves another component's features after it as they were: they
|
||||
/// are restored, not recomputed, and give what evaluating from scratch gives.
|
||||
#[test]
|
||||
fn edit_in_one_component_keeps_the_others_results() {
|
||||
let mut doc = two_parts(&[]);
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
|
||||
assert_eq!(m.last_recomputed, 4);
|
||||
doc.set_param("w", "50 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (1, 3));
|
||||
assert_eq!(summary(&m.state()), from_scratch(&doc));
|
||||
// An edit in the rod's own component recomputes its features.
|
||||
doc.set_param("slot", "1.5 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
|
||||
assert_eq!(summary(&m.state()), from_scratch(&doc));
|
||||
doc.set_param("w", "60 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (1, 3));
|
||||
assert_eq!(summary(&m.state()), from_scratch(&doc));
|
||||
}
|
||||
|
||||
/// A sketch on a fixed plane, the new body extruded from it and that body moved read nothing
|
||||
/// of another component either.
|
||||
#[test]
|
||||
fn sketch_extrude_and_move_keep_their_results() {
|
||||
let mut doc = two_parts(&[]);
|
||||
let rod = doc.find_component("Rod").unwrap();
|
||||
let mut sk = Sketch::new();
|
||||
sk.add_circle(Vec2::new(20.0, 0.0), 2.0, None, None).unwrap();
|
||||
let id = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, Some("Peg sketch")).unwrap();
|
||||
doc.feature_mut(id).unwrap().component = rod;
|
||||
add_in(
|
||||
&mut doc,
|
||||
rod,
|
||||
"Peg",
|
||||
serde_json::json!({"type": "extrude", "sketch": id, "profiles": {"type": "all"}, "extent": {"convention": "total_distance", "direction": "positive", "distance": "8"}, "operation": "new_body"}),
|
||||
);
|
||||
if let Some(f) = doc.features.iter_mut().find(|f| f.name == "Peg") {
|
||||
f.body_names = vec!["Peg".into()];
|
||||
}
|
||||
add_in(&mut doc, rod, "Peg up", serde_json::json!({"type": "move", "bodies": ["Peg"], "translate": ["0", "0", "slot * 3"]}));
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
|
||||
doc.set_param("w", "50 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (1, 6));
|
||||
assert_eq!(summary(&m.state()), from_scratch(&doc));
|
||||
// The move follows its own parameter.
|
||||
doc.set_param("slot", "2 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (3, 2));
|
||||
assert_eq!(summary(&m.state()), from_scratch(&doc));
|
||||
}
|
||||
|
||||
/// A feature that cuts a body of another component reads it: it is recomputed when that body
|
||||
/// changes, and so is what follows it in its own component.
|
||||
#[test]
|
||||
fn a_cut_of_another_components_body_follows_it() {
|
||||
let mut doc = two_parts(&["Plate", "Rod"]);
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
|
||||
doc.set_param("w", "50 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (3, 1));
|
||||
assert_eq!(summary(&m.state()), from_scratch(&doc));
|
||||
}
|
||||
|
||||
/// A pattern of a cut reads the bodies its source cuts, as the copies cut them: the source's
|
||||
/// participants, though the pattern names none and that body is in another component. The plate
|
||||
/// lies clear of every copy at first; moved into one by `w`, it must be cut again.
|
||||
#[test]
|
||||
fn a_pattern_reads_the_bodies_its_source_cuts() {
|
||||
let mut doc = two_parts(&["Plate"]);
|
||||
if let Some(f) = doc.features.iter_mut().find(|f| f.name == "Plate") {
|
||||
f.kind = serde_json::from_value(
|
||||
serde_json::json!({"type": "box", "corner": ["-20", "w", "0"], "length": "40", "width": "30", "height": "5", "operation": "new_body"}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
|
||||
assert_eq!(summary(&m.state()), from_scratch(&doc));
|
||||
// The copy turned a quarter round now reaches the plate.
|
||||
doc.set_param("w", "4 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!(summary(&m.state()), from_scratch(&doc));
|
||||
}
|
||||
|
||||
/// A cut of several bodies cuts again only those that changed: the rod, which `w` does not
|
||||
/// move, keeps the very body its earlier cut gave, while the cap `w` sizes is cut again.
|
||||
#[test]
|
||||
fn a_cut_of_several_bodies_cuts_again_only_those_that_changed() {
|
||||
let mut doc = Document::new("rod and cap");
|
||||
doc.set_param("w", "6 mm", None, None).unwrap();
|
||||
let rod = doc.add_component(Some("Rod"), 0).unwrap();
|
||||
add_in(
|
||||
&mut doc,
|
||||
rod,
|
||||
"Rod",
|
||||
serde_json::json!({"type": "cylinder", "base": ["0", "0", "0"], "axis": [0.0, 0.0, 1.0], "radius": "4", "height": "20", "operation": "new_body"}),
|
||||
);
|
||||
add_in(
|
||||
&mut doc,
|
||||
rod,
|
||||
"Cap",
|
||||
serde_json::json!({"type": "box", "corner": ["-5", "-5", "20"], "length": "10", "width": "10", "height": "w", "operation": "new_body"}),
|
||||
);
|
||||
for f in doc.features.iter_mut().filter(|f| f.name == "Rod" || f.name == "Cap") {
|
||||
f.body_names = vec![f.name.clone()];
|
||||
}
|
||||
// The slot runs through both: the component's bodies are its targets.
|
||||
add_in(
|
||||
&mut doc,
|
||||
rod,
|
||||
"Slot",
|
||||
serde_json::json!({"type": "box", "corner": ["3", "-0.5", "2"], "length": "4", "width": "1", "height": "30", "operation": "cut"}),
|
||||
);
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
|
||||
let cut_rod = m.state().body("Rod").unwrap().body.clone();
|
||||
doc.set_param("w", "8 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!(m.last_recomputed, 2, "the cap and the slot");
|
||||
let st = m.state();
|
||||
assert!(st.body("Rod").unwrap().body.same(&cut_rod), "the rod's cut is the one made before");
|
||||
assert_eq!(summary(&st), from_scratch(&doc));
|
||||
}
|
||||
|
||||
/// A parameter that drives the first feature of the timeline, with the bodies after it each cut
|
||||
/// by a combine: only the first body and its cut are recomputed, every other cut is restored.
|
||||
#[test]
|
||||
fn a_combine_reads_only_its_target_and_tools() {
|
||||
let mut doc = Document::new("rows");
|
||||
doc.set_param("w", "20 mm", None, None).unwrap();
|
||||
let c = doc.add_component(Some("Rows"), 0).unwrap();
|
||||
for i in 0..3 {
|
||||
let x = (i * 30) as f64;
|
||||
let (body, tool, cut) = (format!("Body{i}"), format!("Tool{i}"), format!("Cut{i}"));
|
||||
let length = if i == 0 { "w".to_string() } else { "20".to_string() };
|
||||
add_in(
|
||||
&mut doc,
|
||||
c,
|
||||
&body,
|
||||
serde_json::json!({"type": "box", "corner": [x.to_string(), "0", "0"], "length": length, "width": "15", "height": "5", "operation": "new_body"}),
|
||||
);
|
||||
add_in(
|
||||
&mut doc,
|
||||
c,
|
||||
&tool,
|
||||
serde_json::json!({"type": "cylinder", "base": [(x + 7.0).to_string(), "7.5", "-1"], "axis": [0.0, 0.0, 1.0], "radius": "1.5", "height": "7", "operation": "new_body"}),
|
||||
);
|
||||
for f in doc.features.iter_mut().filter(|f| f.name == body || f.name == tool) {
|
||||
f.body_names = vec![f.name.clone()];
|
||||
}
|
||||
add_in(&mut doc, c, &cut, serde_json::json!({"type": "combine", "target": body, "tools": [tool], "operation": "cut"}));
|
||||
}
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
|
||||
assert_eq!(m.last_recomputed, 9);
|
||||
doc.set_param("w", "32 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (2, 7), "the first body and its cut");
|
||||
assert_eq!(summary(&m.state()), from_scratch(&doc));
|
||||
}
|
||||
|
||||
/// A circle sketch on `plane` in `component`, extruded by `distance` (`operation`), named
|
||||
/// after `name`.
|
||||
fn circle_extrude(doc: &mut Document, component: u64, name: &str, plane: PlaneRef, center: Vec2, r: f64, distance: &str, operation: &str) {
|
||||
let mut sk = Sketch::new();
|
||||
sk.add_circle(center, r, None, None).unwrap();
|
||||
let id = doc.add_feature(FeatureKind::Sketch { plane, sketch: sk }, Some(&format!("{name} sketch"))).unwrap();
|
||||
doc.feature_mut(id).unwrap().component = component;
|
||||
add_in(
|
||||
doc,
|
||||
component,
|
||||
name,
|
||||
serde_json::json!({"type": "extrude", "sketch": id, "profiles": {"type": "all"}, "extent": {"convention": "total_distance", "direction": "positive", "distance": distance}, "operation": operation}),
|
||||
);
|
||||
if let Some(f) = doc.features.iter_mut().find(|f| f.name == name).filter(|_| operation == "new_body") {
|
||||
f.body_names = vec![name.into()];
|
||||
}
|
||||
}
|
||||
|
||||
/// Three components on one timeline (#6183): A's post (height `A_h`) with the construction plane
|
||||
/// `A_top` above it when `plane`; B's post, cut by a hole from a sketch on a plane given by
|
||||
/// numbers and filleted; C's post, filleted twice (the last by `C_r`). Nothing in B or C reads A.
|
||||
fn three_parts(plane: bool) -> Document {
|
||||
let mut doc = Document::new("three parts");
|
||||
doc.set_param("A_h", "10 mm", None, None).unwrap();
|
||||
doc.set_param("C_r", "1 mm", None, None).unwrap();
|
||||
let [a, b, c] = ["A", "B", "C"].map(|n| doc.add_component(Some(n), 0).unwrap());
|
||||
let xy = || PlaneRef::Origin { name: "XY".into() };
|
||||
circle_extrude(&mut doc, a, "A post", xy(), Vec2::new(0.0, 0.0), 5.0, "A_h", "new_body");
|
||||
if plane {
|
||||
add_in(
|
||||
&mut doc,
|
||||
a,
|
||||
"A_top",
|
||||
serde_json::json!({"type": "construction_plane", "plane": {"type": "offset", "base": {"type": "origin", "name": "XY"}, "distance": "A_h + 5"}}),
|
||||
);
|
||||
}
|
||||
circle_extrude(&mut doc, b, "B post", xy(), Vec2::new(40.0, 0.0), 8.0, "12", "new_body");
|
||||
let top = PlaneRef::Offset { base: Box::new(xy()), distance: "12".into() };
|
||||
circle_extrude(&mut doc, b, "B hole", top, Vec2::new(40.0, 0.0), 3.0, "-4", "cut");
|
||||
add_in(&mut doc, b, "B round", serde_json::json!({"type": "fillet", "edges": [[48.0, 0.0, 0.0]], "radius": "1"}));
|
||||
circle_extrude(&mut doc, c, "C post", xy(), Vec2::new(80.0, 0.0), 6.0, "15", "new_body");
|
||||
add_in(&mut doc, c, "C round", serde_json::json!({"type": "fillet", "edges": [[86.0, 0.0, 15.0]], "radius": "1.5"}));
|
||||
add_in(&mut doc, c, "C foot", serde_json::json!({"type": "fillet", "edges": [[86.0, 0.0, 0.0]], "radius": "C_r"}));
|
||||
doc
|
||||
}
|
||||
|
||||
fn no_errors(m: &Model) {
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| (&r.name, &r.error)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// An edit in component A recomputes A's features that use it and nothing in B or C (#6183):
|
||||
/// sketches, cuts on a plane given by numbers and fillets of their own body are kept, and the
|
||||
/// bodies match an evaluation from scratch.
|
||||
#[test]
|
||||
fn an_edit_in_one_component_recomputes_only_what_reads_it() {
|
||||
for plane in [false, true] {
|
||||
let mut doc = three_parts(plane);
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
no_errors(&m);
|
||||
for h in ["11 mm", "12 mm", "13 mm"] {
|
||||
doc.set_param("A_h", h, None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
no_errors(&m);
|
||||
// The extrude, and the plane offset by `A_h`.
|
||||
assert_eq!(m.last_recomputed, 1 + usize::from(plane), "A_h = {h}, plane: {plane}");
|
||||
assert_eq!(summary(&m.state()), from_scratch(&doc));
|
||||
}
|
||||
// The last fillet recomputes alone, as before.
|
||||
doc.set_param("C_r", "2 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!(m.last_recomputed, 1);
|
||||
assert_eq!(summary(&m.state()), from_scratch(&doc));
|
||||
// Back where it was: every result comes from the cache.
|
||||
doc.set_param("A_h", "10 mm", None, None).unwrap();
|
||||
doc.set_param("C_r", "1 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!(summary(&m.state()), from_scratch(&doc));
|
||||
}
|
||||
}
|
||||
|
||||
/// A construction plane's edit recomputes only the features that use it: a sketch on it and what
|
||||
/// is made from that sketch, wherever they are in the timeline, and nothing else.
|
||||
#[test]
|
||||
fn a_construction_plane_edit_recomputes_only_its_users() {
|
||||
let mut doc = three_parts(true);
|
||||
let b = doc.find_component("B").unwrap();
|
||||
// A peg in B on A's plane: B now reads A through it.
|
||||
let a_top = doc.find_feature("A_top").unwrap().id;
|
||||
circle_extrude(&mut doc, b, "B peg", PlaneRef::Construction { feature: a_top.into() }, Vec2::new(40.0, 20.0), 2.0, "3", "new_body");
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
no_errors(&m);
|
||||
let total = m.results.len();
|
||||
doc.set_param("A_h", "11 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
no_errors(&m);
|
||||
assert_eq!(m.last_recomputed, 4, "A's extrude and plane, the peg's sketch and extrude, of {total}");
|
||||
assert_eq!(summary(&m.state()), from_scratch(&doc));
|
||||
let peg = m.state().body("B peg").map(|b| b.body.outline_bounds().min.z);
|
||||
assert!(peg.is_some_and(|z| (z - 16.0).abs() < 1e-6), "the peg stands on the plane: {peg:?}");
|
||||
// Results restored whole after the plane carry the plane as it is now.
|
||||
let st = m.state();
|
||||
let top = st.construct.iter().find(|c| c.name == "A_top").map(|c| &c.geom);
|
||||
assert!(matches!(top, Some(crate::construct::ConstructGeom::Plane(p)) if (p.origin.z - 16.0).abs() < 1e-9), "{top:?}");
|
||||
let mut scratch = Model::new();
|
||||
scratch.no_cache = true;
|
||||
scratch.evaluate(&doc);
|
||||
assert_eq!(st.construct, scratch.state().construct);
|
||||
}
|
||||
|
||||
/// A coil sweeping a sketch's profile is made again when that sketch changes, not restored as
|
||||
/// it was (a coil of its own section reads no sketch).
|
||||
#[test]
|
||||
fn a_coil_follows_its_sketch() {
|
||||
let section = |r: f64| {
|
||||
let mut sk = Sketch::new();
|
||||
sk.add_circle(Vec2::new(20.0, 0.0), r, None, None).unwrap();
|
||||
FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XZ".into() }, sketch: sk }
|
||||
};
|
||||
let mut doc = Document::new("coil");
|
||||
let sketch = doc.add_feature(section(1.0), None).unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Coil {
|
||||
base: point_expr(Vec3::new(0.0, 0.0, 0.0)),
|
||||
axis: Dir(Vec3::Z),
|
||||
sketch: Some(crate::SketchId(sketch)),
|
||||
profiles: ProfileSel::All,
|
||||
diameter: None,
|
||||
pitch: "8".into(),
|
||||
turns: "2".into(),
|
||||
section_size: None,
|
||||
section: Default::default(),
|
||||
position: 0,
|
||||
start_angle: None,
|
||||
clockwise: false,
|
||||
operation: Operation::NewBody,
|
||||
targets: Vec::new(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
no_errors(&m);
|
||||
let before = summary(&m.state());
|
||||
if let Some(f) = doc.feature_mut(sketch) {
|
||||
f.kind = section(2.0);
|
||||
}
|
||||
m.evaluate(&doc);
|
||||
no_errors(&m);
|
||||
assert_eq!(m.last_recomputed, 2, "the sketch and its coil");
|
||||
assert_eq!(summary(&m.state()), from_scratch(&doc));
|
||||
assert_ne!(summary(&m.state()), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_fillet_cut_timeline() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
//! Assembly constraints (`assembly.constraint.create`): a geometric rule between a reference on
|
||||
//! each of two parts (a mate, a flush, concentric holes…), in the words of the Constrain dialog.
|
||||
//! Assembly constraints (`assembly.constraint.create`, `assembly.constraint.edit`): a geometric rule
|
||||
//! between a reference on each of two parts (a mate, a flush, concentric holes…), in the words of the
|
||||
//! Constrain dialog.
|
||||
//! Each rule is kept as a joint of the kind it needs, made where the parts are and tagged with the
|
||||
//! rule (`Joint::constraint`), so the one joint solver places, counts and checks it with the
|
||||
//! others: several constraints between one pair add up, and b keeps every motion they leave free.
|
||||
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::joints::{Constraint, JointKind, JointOrigin, Snap};
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_doc::joints::{Constraint, Joint, JointKind, JointOrigin, Snap};
|
||||
|
||||
use super::{CommandSpec, add_joint, new_joint, origin_param, value};
|
||||
use super::{CommandSpec, add_joint, joint_id, new_joint, origin_param, value};
|
||||
use crate::params::{bad, bool_, str_};
|
||||
use crate::schema::joints::{CreateConstraintParams, JointAddedResult};
|
||||
use crate::schema::joints::{CreateConstraintParams, EditConstraintParams, EditJointResult, JointAddedResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CREATE_CONTRACT: TypedContract<CreateConstraintParams> = TypedContract::new();
|
||||
const EDIT_CONTRACT: TypedContract<EditConstraintParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] =
|
||||
&[CommandSpec::new("assembly.constraint.create", "Constrain", crate::checked!("assembly.constraint.create", create))
|
||||
const TYPE_MESSAGE: &str = "`type` must be mate, flush, distance, concentric, insert, parallel, perpendicular, angle, tangent or point_on_axis";
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("assembly.constraint.create", "Constrain", crate::checked!("assembly.constraint.create", create))
|
||||
.icon("joint")
|
||||
.contract(&CREATE_CONTRACT)
|
||||
.output(output_schema_for::<JointAddedResult>)
|
||||
@@ -27,7 +31,19 @@ pub static COMMANDS: &[CommandSpec] =
|
||||
Kept as a joint of the kind the rule needs (mate and flush planar, distance planar or point on plane, concentric cylindrical, \
|
||||
insert revolute, tangent line on plane, point on axis point on line), made where the parts are, so b moves no further than the \
|
||||
rule needs → joint, name, dof, conflicts, fixed as joint.create",
|
||||
)];
|
||||
),
|
||||
CommandSpec::new("assembly.constraint.edit", "Edit Constraint", crate::checked!("assembly.constraint.edit", edit))
|
||||
.icon("edit")
|
||||
.contract(&EDIT_CONTRACT)
|
||||
.output(output_schema_for::<EditJointResult>)
|
||||
.params(
|
||||
"constraint: id|name (a joint made with assembly.constraint.create); type?: a rule as assembly.constraint.create takes it \
|
||||
(the references it has must fit it; the gap carries over); offset?: the gap (mm or an expression); angle?: an angle \
|
||||
rule's angle; flip?: true turns b over (from the new rule's way after a type change, else from how it faces now); \
|
||||
name?; suppressed?; quiet?. Changed as joint.edit changes a joint: its values start again where its parts are \
|
||||
→ conflicts, errors, dof, fixed as joint.edit",
|
||||
),
|
||||
];
|
||||
|
||||
/// What a reference is, for the rules that need one kind.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -105,6 +121,102 @@ fn needs(c: Constraint) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether b's reference faces along a's now (true) or against it.
|
||||
fn along(s: &Session, a: &JointOrigin, b: &JointOrigin, cmd: &str) -> Result<bool> {
|
||||
let st = s.model.state();
|
||||
let za = s.doc.origin_world(&st, a).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let zb = s.doc.origin_world(&st, b).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok((0..3).map(|i| za[2][i] * zb[2][i]).sum::<f64>() > 0.0)
|
||||
}
|
||||
|
||||
/// The joint's `flip` for a rule: the way the rule fixes (a mate against, a flush along), or for a
|
||||
/// rule that fixes neither, the way the parts face now, so b is not turned over; `turn` turns it.
|
||||
fn facing(s: &Session, rule: &Kept, a: &JointOrigin, b: &JointOrigin, turn: bool, cmd: &str) -> Result<bool> {
|
||||
let f = match rule.flip {
|
||||
Some(f) => f,
|
||||
None => along(s, a, b, cmd)?,
|
||||
};
|
||||
Ok(f != turn)
|
||||
}
|
||||
|
||||
/// A cylinder resting on a plane needs its round face's radius: refused when b's reference has none.
|
||||
fn check_round(s: &Session, c: Constraint, b: &JointOrigin, cmd: &str) -> Result<()> {
|
||||
if c == Constraint::Tangent {
|
||||
s.doc.origin_radius(&s.model.state(), b).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The gap a constraint keeps when its rule changes: its offset's expression where it has one (a
|
||||
/// tangent's gap always is: its offset adds the radius), else its offset.
|
||||
fn gap(j: &Joint) -> Value {
|
||||
j.exprs.offset.clone().map(Value::String).unwrap_or_else(|| json!(j.offset))
|
||||
}
|
||||
|
||||
/// Change a constraint in place: its rule, gap, angle, facing, name or suppression. The new rule is
|
||||
/// mapped onto a joint kind as `create` maps it, and the change goes through `joint.edit`, so the
|
||||
/// values start again where the parts are and the joint is solved as any edited joint is.
|
||||
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "assembly.constraint.edit";
|
||||
// Its key read as the contract reads it, then found as joint.edit finds a joint.
|
||||
let id = match p.get("constraint") {
|
||||
None => return Err(bad(cmd, "`constraint` is required")),
|
||||
Some(v @ (Value::Number(_) | Value::String(_))) => joint_id(s, Some(v), cmd)?,
|
||||
Some(_) => return Err(bad(cmd, "`constraint` must be an id or name")),
|
||||
};
|
||||
let j = s.doc.assembly.joints.iter().find(|j| j.id == id).cloned().ok_or_else(|| bad(cmd, "constraint"))?;
|
||||
let Some(was) = j.constraint else {
|
||||
return Err(bad(cmd, format!("{} was made as a joint, not as a constraint: change it with joint.edit", j.name)));
|
||||
};
|
||||
let c = match str_(p, "type") {
|
||||
Some(t) => Some(Constraint::parse(t).ok_or_else(|| bad(cmd, TYPE_MESSAGE))?),
|
||||
None => None,
|
||||
};
|
||||
for (key, angle) in [("offset", false), ("angle", true)] {
|
||||
if let Some(v) = p.get(key) {
|
||||
value(s, v, angle, cmd)?;
|
||||
}
|
||||
}
|
||||
// joint.edit's params: what is given, and what the rule means.
|
||||
let mut q = Map::new();
|
||||
q.insert("joint".into(), json!(id));
|
||||
for key in ["offset", "angle", "name", "suppressed", "quiet"] {
|
||||
if let Some(v) = p.get(key) {
|
||||
q.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
let turn = bool_(p, "flip") == Some(true);
|
||||
let mut swap = false;
|
||||
if let Some(c) = c {
|
||||
let rule = kept(c, shape(&j.a), shape(&j.b), cmd)?;
|
||||
let (a, b) = if rule.swap { (&j.b, &j.a) } else { (&j.a, &j.b) };
|
||||
if c == Constraint::Angle && p.get("angle").is_none() && j.kind != JointKind::Angle {
|
||||
return Err(bad(cmd, "angle needs `angle`"));
|
||||
}
|
||||
check_round(s, c, b, cmd)?;
|
||||
let flip = facing(s, &rule, a, b, turn, cmd)?;
|
||||
q.insert("type".into(), serde_json::to_value(rule.kind).map_err(|e| bad(cmd, e.to_string()))?);
|
||||
q.insert("flip".into(), json!(flip));
|
||||
// The gap carries over to the new rule.
|
||||
if p.get("offset").is_none() {
|
||||
q.insert("offset".into(), gap(&j));
|
||||
}
|
||||
swap = rule.swap;
|
||||
} else if turn {
|
||||
q.insert("flip".into(), json!(!j.flip));
|
||||
}
|
||||
// The rule (and the side order it wants) is written first, so joint.edit restarts and keeps the
|
||||
// gap as this rule's joint. A refusal after this leaves the design as it was (the session's undo).
|
||||
if let Some(jj) = s.doc_mut().assembly.joints.iter_mut().find(|j| j.id == id) {
|
||||
if swap {
|
||||
std::mem::swap(&mut jj.a, &mut jj.b);
|
||||
}
|
||||
jj.constraint = Some(c.unwrap_or(was));
|
||||
}
|
||||
// As joint.edit changes a joint, keeping the rule just set through a change of kind.
|
||||
super::edit_joint(s, &Value::Object(q), false)
|
||||
}
|
||||
|
||||
/// The rule's default name: `Mate1`, `Point on axis2`…, the first number no joint's name has.
|
||||
fn default_name(s: &Session, c: Constraint) -> String {
|
||||
let word = c.name().replace('_', " ");
|
||||
@@ -119,9 +231,7 @@ fn default_name(s: &Session, c: Constraint) -> String {
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "assembly.constraint.create";
|
||||
let c = str_(p, "type").and_then(Constraint::parse).ok_or_else(|| {
|
||||
bad(cmd, "`type` must be mate, flush, distance, concentric, insert, parallel, perpendicular, angle, tangent or point_on_axis")
|
||||
})?;
|
||||
let c = str_(p, "type").and_then(Constraint::parse).ok_or_else(|| bad(cmd, TYPE_MESSAGE))?;
|
||||
let a = origin_param(s, p.get("a"), cmd)?;
|
||||
let b = origin_param(s, p.get("b"), cmd)?;
|
||||
// The numbers are read before the references are checked, in the order the contract reads them.
|
||||
@@ -138,28 +248,8 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if c == Constraint::Angle && p.get("angle").is_none() {
|
||||
return Err(bad(cmd, "angle needs `angle`"));
|
||||
}
|
||||
let flip = {
|
||||
let st = s.model.state();
|
||||
// The way b faces a now: along (true) or against. A rule that fixes neither keeps it, so b is
|
||||
// not turned over.
|
||||
let along = || -> Result<bool> {
|
||||
let za = s.doc.origin_world(&st, &a).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let zb = s.doc.origin_world(&st, &b).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok((0..3).map(|i| za[2][i] * zb[2][i]).sum::<f64>() > 0.0)
|
||||
};
|
||||
let mut flip = match rule.flip {
|
||||
Some(f) => f,
|
||||
None => along()?,
|
||||
};
|
||||
if bool_(p, "flip") == Some(true) {
|
||||
flip = !flip;
|
||||
}
|
||||
// A cylinder resting on a plane needs its round face's radius (refused when it has none).
|
||||
if c == Constraint::Tangent {
|
||||
s.doc.origin_radius(&st, &b).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
}
|
||||
flip
|
||||
};
|
||||
let flip = facing(s, &rule, &a, &b, bool_(p, "flip") == Some(true), cmd)?;
|
||||
check_round(s, c, &b, cmd)?;
|
||||
// The joint's own params: the kind and facing the rule needs, the rest as given.
|
||||
let mut q = p.clone();
|
||||
if let Some(m) = q.as_object_mut() {
|
||||
|
||||
@@ -1118,7 +1118,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.doc_mut().preserve_configuration_units(&old, &kept);
|
||||
if let Some(row) = matched_configuration {
|
||||
s.doc_mut().apply_configuration(&row)?;
|
||||
s.doc_mut().apply_configuration(row.as_str())?;
|
||||
}
|
||||
s.settle_value_edit(&solved)?;
|
||||
reply(kept)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::DEFAULT_MATERIAL;
|
||||
use solidcraft_doc::config::{Column, ConfigRow, MAX_COLUMNS, MAX_ROWS};
|
||||
use solidcraft_doc::config::{Column, MAX_COLUMNS, RowKey};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_, string_list};
|
||||
@@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.contract(&TABLE_CONTRACT)
|
||||
.output(output_schema_for::<ConfigTableReply>)
|
||||
.params("→ columns (param:<name> or suppress:<feature>), rows, the active row and whether the design still matches it"),
|
||||
.params("→ columns (param:<name> or suppress:<feature>), rows (each with its fixed id), the active row's name and whether the design still matches it"),
|
||||
CommandSpec::new("config.column", "Configuration Column", crate::checked!("config.column", column))
|
||||
.icon("configuration_table")
|
||||
.contract(&COLUMN_CONTRACT)
|
||||
@@ -47,19 +47,19 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&ROW_EDIT_CONTRACT)
|
||||
.output(output_schema_for::<ConfigTableReply>)
|
||||
.params(
|
||||
"name; from? (row to copy; default current values); values?: {column key: expression, suppression, material name, appearance name or Look object}; rename?; delete?: bool",
|
||||
"name (a row's name, or its id from config.table); from? (row to copy, by name or id; default current values); values?: {column key: expression, suppression, material name, appearance name or Look object}; rename?; delete?: bool",
|
||||
),
|
||||
CommandSpec::new("config.activate", "Activate Configuration", crate::checked!("config.activate", activate))
|
||||
.icon("activate")
|
||||
.contract(&ROW_CONTRACT)
|
||||
.output(output_schema_for::<ConfigActivation>)
|
||||
.params("row: name — sets its parameter values and suppressions"),
|
||||
.params("row: a row's name, or its id from config.table — sets its parameter values and suppressions"),
|
||||
CommandSpec::new("config.preview", "Preview Configuration", crate::checked!("config.preview", preview))
|
||||
.icon("eye")
|
||||
.noundo()
|
||||
.contract(&ROW_CONTRACT)
|
||||
.output(output_schema_for::<ConfigActivation>)
|
||||
.params("row: name — validates a rebuild without changing the design or history"),
|
||||
.params("row: a row's name, or its id from config.table — validates a rebuild without changing the design or history"),
|
||||
CommandSpec::new("config.rule", "Configuration Rule", crate::checked!("config.rule", rule))
|
||||
.icon("configuration_table")
|
||||
.contract(&RULE_CONTRACT)
|
||||
@@ -206,9 +206,8 @@ fn configure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if s.doc.configs.rows.is_empty() {
|
||||
let cells = s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect();
|
||||
let t = &mut s.doc_mut().configs;
|
||||
t.rows.push(ConfigRow { name: "Default".into(), cells });
|
||||
t.active = Some("Default".into());
|
||||
s.doc_mut().add_configuration_row("Default".into(), cells).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
s.doc_mut().configs.active = Some("Default".into());
|
||||
}
|
||||
show(s, p)
|
||||
}
|
||||
@@ -226,8 +225,11 @@ fn to_reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
fn table(s: &Session, cmd: &str) -> Result<ConfigTable> {
|
||||
let t = &s.doc.configs;
|
||||
let keys: Vec<String> = t.columns.iter().map(|c| s.doc.config_key(c)).collect();
|
||||
let rows =
|
||||
t.rows.iter().map(|r| ConfigTableRow { name: r.name.clone(), values: keys.iter().cloned().zip(r.cells.iter().cloned()).collect() }).collect();
|
||||
let rows = t
|
||||
.rows
|
||||
.iter()
|
||||
.map(|r| ConfigTableRow { id: r.id, name: r.name.clone(), values: keys.iter().cloned().zip(r.cells.iter().cloned()).collect() })
|
||||
.collect();
|
||||
let targets = t
|
||||
.columns
|
||||
.iter()
|
||||
@@ -286,8 +288,18 @@ fn column(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "config.row";
|
||||
let name = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
|
||||
let idx = s.doc.configs.rows.iter().position(|r| r.name == name);
|
||||
// By id, the row must be there; by name, a name the table doesn't have adds a row.
|
||||
let (idx, name) = match p.get("name").and_then(RowKey::read) {
|
||||
Some(RowKey::Id(id)) => {
|
||||
let i = s.doc.configs.rows.iter().position(|r| r.id == id).ok_or_else(|| bad(cmd, format!("no row {id}")))?;
|
||||
(Some(i), s.doc.configs.rows.get(i).map(|r| r.name.clone()).unwrap_or_default())
|
||||
}
|
||||
_ => {
|
||||
let name =
|
||||
str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
|
||||
(s.doc.configs.rows.iter().position(|r| r.name == name), name)
|
||||
}
|
||||
};
|
||||
if bool_(p, "delete").unwrap_or(false) {
|
||||
let i = idx.ok_or_else(|| bad(cmd, format!("no row `{name}`")))?;
|
||||
if s.doc.configs.rows.len() == 1 && !s.doc.configs.rules.is_empty() {
|
||||
@@ -300,8 +312,12 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
return show(s, p);
|
||||
}
|
||||
let mut cells: Vec<String> = match (idx, str_(p, "from")) {
|
||||
(_, Some(f)) => s.doc.configs.rows.iter().find(|r| r.name == f).map(|r| r.cells.clone()).ok_or_else(|| bad(cmd, format!("no row `{f}`")))?,
|
||||
let from = match p.get("from") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(given) => Some(RowKey::read(given).ok_or_else(|| bad(cmd, "`from` must be a row's name or id"))?),
|
||||
};
|
||||
let mut cells: Vec<String> = match (idx, from) {
|
||||
(_, Some(f)) => s.doc.configs.row(f).map(|r| r.cells.clone()).ok_or_else(|| bad(cmd, format!("no row `{f}`")))?,
|
||||
(Some(i), None) => s.doc.configs.rows.get(i).map(|r| r.cells.clone()).unwrap_or_default(),
|
||||
(None, None) => s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect(),
|
||||
};
|
||||
@@ -379,10 +395,10 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut trial = s.doc.as_ref().clone();
|
||||
trial.change_params_atomic(&changes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
}
|
||||
let t = &mut s.doc_mut().configs;
|
||||
let validated_name = rename.clone().unwrap_or_else(|| name.clone());
|
||||
match idx {
|
||||
Some(i) => {
|
||||
let t = &mut s.doc_mut().configs;
|
||||
if let Some(r) = t.rows.get_mut(i) {
|
||||
r.cells = cells;
|
||||
if let Some(n) = rename {
|
||||
@@ -394,10 +410,7 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if t.rows.len() >= MAX_ROWS {
|
||||
return Err(bad(cmd, "too many rows"));
|
||||
}
|
||||
t.rows.push(ConfigRow { name, cells });
|
||||
s.doc_mut().add_configuration_row(name, cells).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
}
|
||||
}
|
||||
if !s.doc.configs.rules.is_empty() {
|
||||
@@ -413,7 +426,7 @@ fn validate_rows(s: &Session, cmd: &str) -> Result<()> {
|
||||
}
|
||||
for row in &s.doc.configs.rows {
|
||||
let mut trial = s.scratch();
|
||||
activate(&mut trial, &json!({"row":row.name})).map_err(|e| bad(cmd, format!("row `{}`: {e}", row.name)))?;
|
||||
activate(&mut trial, &json!({"row": row.id})).map_err(|e| bad(cmd, format!("row `{}`: {e}", row.name)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -488,9 +501,11 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// `config.preview` return it.
|
||||
fn activation(s: &mut Session, p: &Value) -> Result<ConfigActivation> {
|
||||
let cmd = "config.activate";
|
||||
let r = str_(p, "row").ok_or_else(|| bad(cmd, "`row` is required"))?.to_string();
|
||||
let key = p.get("row").and_then(RowKey::read).ok_or_else(|| bad(cmd, "`row` is required"))?;
|
||||
let mut trial = s.scratch();
|
||||
let trace = trial.doc_mut().apply_configuration_with_trace(&r).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let trace = trial.doc_mut().apply_configuration_with_trace(key).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
// The row by its name in what follows, however it was given.
|
||||
let r = trial.doc.configs.active.clone().unwrap_or_default();
|
||||
trial.refresh();
|
||||
let new_errors: Vec<String> = trial
|
||||
.model
|
||||
@@ -566,6 +581,48 @@ mod tests {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// Rows have fixed ids (#14750): `config.table` lists them, and activate, preview and row take
|
||||
/// a row by its id or its name. A rename keeps the id and the active row, a row named after
|
||||
/// another's id is still that name, and a new row never takes a deleted row's id.
|
||||
#[test]
|
||||
fn rows_are_named_by_their_id_or_their_name() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "W", "expression": "10 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": "W", "width": 10, "height": 10}));
|
||||
run(&mut s, "config.configure", json!({"params": ["W"]}));
|
||||
let id_of =
|
||||
|t: &Value, name: &str| t["rows"].as_array().and_then(|rows| rows.iter().find(|r| r["name"] == name)).and_then(|r| r["id"].as_u64());
|
||||
let t = run(&mut s, "config.row", json!({"name": "Long", "values": {"W": "30 mm"}}));
|
||||
let (default, long) = (id_of(&t, "Default").unwrap(), id_of(&t, "Long").unwrap());
|
||||
assert_ne!(default, long);
|
||||
run(&mut s, "config.activate", json!({"row": long}));
|
||||
assert!((volume(&mut s) - 3000.0).abs() < 1e-6, "{}", volume(&mut s));
|
||||
assert_eq!(run(&mut s, "config.table", json!({}))["active"], json!("Long"));
|
||||
let t = run(&mut s, "config.row", json!({"name": long, "rename": "Longer"}));
|
||||
assert_eq!((id_of(&t, "Longer"), &t["active"]), (Some(long), &json!("Longer")));
|
||||
assert_eq!(run(&mut s, "config.preview", json!({"row": long}))["preview"], true);
|
||||
// A row named after Longer's id, a copy of Default: the text is its name, the number Longer's id.
|
||||
run(&mut s, "config.row", json!({"name": long.to_string(), "from": default}));
|
||||
run(&mut s, "config.activate", json!({"row": long.to_string()}));
|
||||
assert!((volume(&mut s) - 1000.0).abs() < 1e-6, "the row named {long}");
|
||||
run(&mut s, "config.activate", json!({"row": long}));
|
||||
assert!((volume(&mut s) - 3000.0).abs() < 1e-6, "row {long}");
|
||||
let unknown = long + 1000;
|
||||
for (cmd, p) in [
|
||||
("config.activate", json!({"row": unknown})),
|
||||
("config.preview", json!({"row": unknown})),
|
||||
("config.row", json!({"name": unknown})),
|
||||
("config.row", json!({"name": "R", "from": unknown})),
|
||||
("config.row", json!({"name": "R", "from": true})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
|
||||
}
|
||||
run(&mut s, "config.row", json!({"name": long, "delete": true}));
|
||||
assert_eq!(run(&mut s, "config.table", json!({}))["active"], Value::Null);
|
||||
let t = run(&mut s, "config.row", json!({"name": "Again"}));
|
||||
assert!(id_of(&t, "Again").unwrap() > long, "{t}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_switch_parameter_alone_switches_the_features_its_rules_suppress() {
|
||||
// A 10 mm cube with a 4 mm hole cut through it; `Enable` and the hole's suppression are
|
||||
|
||||
@@ -402,28 +402,47 @@ fn plane_mid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn plane_edges(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "construct.plane.two_edges";
|
||||
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
|
||||
plane(s, p, PlaneRef::TwoEdges { a: Box::new(a), b: Box::new(b) })
|
||||
plane_of_picks(s, p, "construct.plane.two_edges")
|
||||
}
|
||||
|
||||
fn plane_axis_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = plane_axis_point_kind(s, p)?;
|
||||
add_feature(s, p, kind)
|
||||
}
|
||||
|
||||
/// Shared parser for the constructor and its world-to-authoring-frame boundary.
|
||||
pub(crate) fn plane_axis_point_kind(s: &Session, p: &Value) -> Result<FeatureKind> {
|
||||
let cmd = "construct.plane.axis_point";
|
||||
let axis = geo(s, p.get("axis"), cmd, "axis")?;
|
||||
let point = geo(s, p.get("point"), cmd, "point")?;
|
||||
Ok(FeatureKind::ConstructionPlane { plane: PlaneRef::AxisPoint { axis: Box::new(axis), point: Box::new(point) } })
|
||||
plane_of_picks(s, p, "construct.plane.axis_point")
|
||||
}
|
||||
|
||||
fn plane_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "construct.plane.three_points";
|
||||
let (a, b, c) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?, geo(s, p.get("c"), cmd, "c")?);
|
||||
plane(s, p, PlaneRef::ThreePoints { a: Box::new(a), b: Box::new(b), c: Box::new(c) })
|
||||
plane_of_picks(s, p, "construct.plane.three_points")
|
||||
}
|
||||
|
||||
fn plane_of_picks(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let made = picked_plane(s, cmd, p)?.ok_or_else(|| bad(cmd, "not a plane made of picks"))?;
|
||||
plane(s, p, made)
|
||||
}
|
||||
|
||||
/// The construction planes made of picks alone, read from `cmd`'s parameters as that command
|
||||
/// reads them (`None`: `cmd` makes no such plane). Shared by the constructors and the
|
||||
/// world-to-authoring-frame boundary, which maps an edit's saved references once (`frames`).
|
||||
pub(crate) fn picked_plane(s: &Session, cmd: &str, p: &Value) -> Result<Option<PlaneRef>> {
|
||||
let pick = |key: &str| geo(s, p.get(key), cmd, key).map(Box::new);
|
||||
Ok(Some(match cmd {
|
||||
"construct.plane.two_edges" => PlaneRef::TwoEdges { a: pick("a")?, b: pick("b")? },
|
||||
"construct.plane.axis_point" => PlaneRef::AxisPoint { axis: pick("axis")?, point: pick("point")? },
|
||||
"construct.plane.three_points" => PlaneRef::ThreePoints { a: pick("a")?, b: pick("b")?, c: pick("c")? },
|
||||
"construct.plane.perpendicular" => PlaneRef::Perpendicular { plane: pick("plane")?, line: pick("line")? },
|
||||
_ => return Ok(None),
|
||||
}))
|
||||
}
|
||||
|
||||
/// A plane made of picks as its command and saved references, each under the parameter of
|
||||
/// the command that takes it ([`picked_plane`] backwards; `None`: not such a plane). Edit
|
||||
/// Feature fills the command's form from it.
|
||||
pub fn picked_plane_refs(plane: &PlaneRef) -> Option<(&'static str, Vec<(&'static str, &GeoRef)>)> {
|
||||
Some(match plane {
|
||||
PlaneRef::TwoEdges { a, b } => ("construct.plane.two_edges", vec![("a", &**a), ("b", &**b)]),
|
||||
PlaneRef::AxisPoint { axis, point } => ("construct.plane.axis_point", vec![("axis", &**axis), ("point", &**point)]),
|
||||
PlaneRef::ThreePoints { a, b, c } => ("construct.plane.three_points", vec![("a", &**a), ("b", &**b), ("c", &**c)]),
|
||||
PlaneRef::Perpendicular { plane, line } => ("construct.plane.perpendicular", vec![("plane", &**plane), ("line", &**line)]),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn plane_tangent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -458,7 +477,5 @@ fn plane_along(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn plane_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "construct.plane.perpendicular";
|
||||
let (base, line) = (geo(s, p.get("plane"), cmd, "plane")?, geo(s, p.get("line"), cmd, "line")?);
|
||||
plane(s, p, PlaneRef::Perpendicular { plane: Box::new(base), line: Box::new(line) })
|
||||
plane_of_picks(s, p, "construct.plane.perpendicular")
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("sweep")
|
||||
.contract(&SWEEP_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); operation?, touching?, targets?, name?, body_name?"),
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; path_body?: body name; path_edges?: [points on its edges] (the path is a chain of that body's edges, instead of a sketch's curves); orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); operation?, touching?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2…50, in order; matching hole counts, no holes at an apex); rails?: [{sketch, curves: [ids in order]}] (one or two guide rails from the first section to the last, through every section's outline: the loft follows them between the sections; not with sections that have holes); rail_fit?: scale|stretch (default scale: with one rail the sections grow evenly with the rail's distance from their middle, with two with the distance between the rails; stretch: only that way); operation?, touching?, targets? (Join excludes hidden bodies from automatic targets; explicit targets or participants may include them), name?, body_name?"),
|
||||
CommandSpec::new("solid.box", "Box", crate::checked!("solid.box", prim_box))
|
||||
.at("SOLID", "CREATE")
|
||||
@@ -3635,13 +3635,9 @@ pub(crate) fn sketch_key(v: Option<&Value>) -> Option<String> {
|
||||
fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.sweep";
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
|
||||
let path = string_list(cmd, p, "path")?;
|
||||
if path.is_empty() {
|
||||
return Err(bad(cmd, "`path` must list curve ids"));
|
||||
}
|
||||
let (path_sketch, path, path_body, path_edges) = sweep_path(s, p, cmd)?;
|
||||
let (orientation, twist, scale) = sweep_options(s, p, cmd)?;
|
||||
let (rails, rail_fit) = sweep_rails(s, p, cmd, path_sketch)?;
|
||||
let (rails, rail_fit) = sweep_rails(s, p, cmd, path_body.is_none().then_some(path_sketch))?;
|
||||
let operation = operation(p, cmd)?;
|
||||
let targets = face_join_targets(s, p, operation, &[sketch], cmd)?;
|
||||
add_feature(
|
||||
@@ -3652,6 +3648,8 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
profiles: profiles(p, cmd)?,
|
||||
path_sketch: SketchId(path_sketch),
|
||||
path,
|
||||
path_body,
|
||||
path_edges,
|
||||
orientation,
|
||||
twist,
|
||||
scale,
|
||||
@@ -3663,6 +3661,35 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
)
|
||||
}
|
||||
|
||||
/// A sweep's path (#2216): a chain of a body's edges (`path_body` and `path_edges`) or the
|
||||
/// curves of a sketch (`path_sketch` and `path`). The body's edges are a point on each edge of
|
||||
/// the chain; the path runs from the end nearest the profile.
|
||||
pub(super) fn sweep_path(s: &Session, p: &Value, cmd: &str) -> Result<(u64, Vec<String>, Option<BodyName>, Vec<Position>)> {
|
||||
if p.get("path_body").is_some_and(|b| !b.is_null()) {
|
||||
let body = str_(p, "path_body").ok_or_else(|| bad(cmd, "`path_body` must be a body name"))?;
|
||||
if s.model.state().body(body).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{body}`")));
|
||||
}
|
||||
let edges = path_edge_points(p, cmd)?;
|
||||
return Ok((0, Vec::new(), Some(BodyName::from(body)), edges));
|
||||
}
|
||||
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
|
||||
let path = string_list(cmd, p, "path")?;
|
||||
if path.is_empty() {
|
||||
return Err(bad(cmd, "`path` must list curve ids"));
|
||||
}
|
||||
Ok((path_sketch, path, None, Vec::new()))
|
||||
}
|
||||
|
||||
/// The points on the edges of a sweep's path (`path_edges`), one on each edge of the chain.
|
||||
fn path_edge_points(p: &Value, cmd: &str) -> Result<Vec<Position>> {
|
||||
let a = p.get("path_edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`path_edges` must be a list of points"))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
return Err(bad(cmd, "the path needs 1…1000 edges"));
|
||||
}
|
||||
a.iter().map(|e| vec3(e).map(Position::from).ok_or_else(|| bad(cmd, "each `path_edges` entry must be a point [x,y,z]"))).collect()
|
||||
}
|
||||
|
||||
/// A sweep's `orientation`, `twist` and `scale` (solid.sweep and surface.sweep), checked: an
|
||||
/// orientation the sweep knows, a twist within four turns, an end scale within 0.01…100.
|
||||
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<Expr>, Option<Expr>)> {
|
||||
@@ -3715,10 +3742,11 @@ pub(crate) fn sweep_orientation(v: &Value) -> std::result::Result<SweepOrientati
|
||||
}
|
||||
|
||||
/// A sweep's guide `rails` and `rail_fit` (solid.sweep and surface.sweep), checked: one or two
|
||||
/// rails, each a sketch (default: the path's) and 1…1000 of its curves; a fit of scale or
|
||||
/// stretch; and no orientation, twist or scale besides, since the rails set those.
|
||||
pub(super) fn sweep_rails(s: &Session, p: &Value, cmd: &str, path_sketch: u64) -> Result<(Vec<SweepRail>, RailFit)> {
|
||||
let (rails, rail_fit) = guide_rails(s, p, cmd, "sweep", Some(path_sketch))?;
|
||||
/// rails, each a sketch (default: the path's, when the path is a sketch's) and 1…1000 of its
|
||||
/// curves; a fit of scale or stretch; and no orientation, twist or scale besides, since the
|
||||
/// rails set those.
|
||||
pub(super) fn sweep_rails(s: &Session, p: &Value, cmd: &str, path_sketch: Option<u64>) -> Result<(Vec<SweepRail>, RailFit)> {
|
||||
let (rails, rail_fit) = guide_rails(s, p, cmd, "sweep", path_sketch)?;
|
||||
if !rails.is_empty() {
|
||||
for key in ["orientation", "twist", "scale"] {
|
||||
if p.get(key).is_some_and(|v| !v.is_null()) {
|
||||
|
||||
@@ -2784,3 +2784,31 @@ fn align_by_a_frame_picked_on_another_copy_moves_the_part_in_its_own_coordinates
|
||||
assert_bounds(&mut s, "Peg", [4., 4., 10.], [6., 6., 12.]);
|
||||
assert_bounds(&mut s, "Peg (Board:2/Header:1)", [104., 4., 10.], [106., 6., 12.]);
|
||||
}
|
||||
|
||||
/// A coil cut from a sketch follows that sketch when its points move, and the body it cuts when
|
||||
/// that changes upstream: its cached result is not taken back while either differs.
|
||||
#[test]
|
||||
fn a_sketch_coil_cut_follows_its_profile_and_target() {
|
||||
use std::f64::consts::PI;
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 20, "height": 40, "body_name": "Shaft", "name": "ShaftFeature"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "YZ", "name": "Groove"}));
|
||||
// A 1 x 1 mm square, 18..19 mm from the axis.
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [18, 1], "p1": [19, 2]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = volume(&mut s);
|
||||
run(&mut s, "solid.coil", json!({"sketch": "Groove", "revolutions": 3, "pitch": 8, "operation": "cut", "targets": ["Shaft"]}));
|
||||
let removed = before - volume(&mut s);
|
||||
assert!(rel(removed, 2.0 * PI * 3.0 * 18.5) < 1e-3, "{removed}");
|
||||
// Widen the square inward to 17..19 mm: twice the area, centroid at 18 mm.
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Groove"}));
|
||||
run(&mut s, "sketch.move_point", json!({"point": "p1", "to": [17, 1]}));
|
||||
run(&mut s, "sketch.move_point", json!({"point": "p4", "to": [17, 2]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let removed = before - volume(&mut s);
|
||||
assert!(rel(removed, 2.0 * PI * 3.0 * 2.0 * 18.0) < 1e-3, "the groove kept the old profile: {removed}");
|
||||
// A thinner shaft upstream (radius 18): the groove now cuts only 17..18 mm of it.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "ShaftFeature", "set": {"radius": "18"}}));
|
||||
let removed = PI * 18.0 * 18.0 * 40.0 - volume(&mut s);
|
||||
assert!(rel(removed, 2.0 * PI * 3.0 * 17.5) < 1e-3, "the groove kept the old shaft: {removed}");
|
||||
}
|
||||
|
||||
@@ -11,13 +11,14 @@ use solidcraft_sketch::CurveKind;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_, string_list};
|
||||
use crate::schema::inspect::{BodiesDetail, DocumentBodies, TimelineDetail, TimelineRow};
|
||||
use crate::schema::inspect::{
|
||||
BodyFailure, BodyFeaturesParams, BodyFigures, BodyListParams, BodyMeasurement, BodyName, BodyNames, BodyOutcome, BoundingBox, CommandEntry,
|
||||
CommandsParams, CurveShape, DocumentBody, DocumentBodyDetail, DocumentInspectParams, DocumentInspectResult, DocumentParam, DocumentSketch,
|
||||
FaceCylinder, KernelCounts, ListedMaterial, MaterialAssignParams, MaterialAssignResult, MaterialListParams, MaterialListResult, MeasureParams,
|
||||
MeasureResult, MeasureTotal, Measured, MeasuredBodies, MeasuredBody, ModelBodyFeatures, ModelEdge, ModelEdges, ModelFace, ModelFaces,
|
||||
ModelThread, ModelThreads, ModelThreadsParams, SketchConstraint, SketchCurve, SketchInspectParams, SketchInspected, SketchPoint, SketchProfile,
|
||||
SketchWire, TimelineEntry, WireArcInfo,
|
||||
SketchWire, TimelineEntry, TimelineSummary, WireArcInfo,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -58,7 +59,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.contract(&DOCUMENT_INSPECT_CONTRACT)
|
||||
.output(output_schema_for::<DocumentInspectResult>)
|
||||
.params("measure?: bool (include body measurements); sketch_counts?: bool (each sketch also gives its plane and its number of points and constraints)"),
|
||||
.params(
|
||||
"measure?: bool (include body measurements); sketch_counts?: bool (each sketch also gives its plane and its number of points and constraints); \
|
||||
timeline?: true (default) | \"summary\" (each feature as id, name, type, with error, warning, suppressed and rolled_back only when set) | false (left out); \
|
||||
bodies?: true (default) | \"names\" (only the body names, nothing measured) | false (left out)",
|
||||
),
|
||||
CommandSpec::new("document.revision", "Design Revision", revision).icon("inspect").noundo().params(
|
||||
"→ document_id (this open design's id: kept while it stays open, another for each design started or opened), \
|
||||
revision (moves whenever the design, what is hidden or a component's uncaptured position changes, \
|
||||
@@ -544,6 +549,8 @@ fn revision(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let timeline_detail = TimelineDetail::read(p.get("timeline")).map_err(|e| bad("document.inspect", e))?;
|
||||
let bodies_detail = BodiesDetail::read(p.get("bodies")).map_err(|e| bad("document.inspect", e))?;
|
||||
let st = s.model.state();
|
||||
let (vals, perr) = s.doc.param_values();
|
||||
let params: Vec<DocumentParam> = s
|
||||
@@ -561,27 +568,41 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect();
|
||||
let marker = s.doc.marker.unwrap_or(s.doc.features.len());
|
||||
let timeline: Vec<TimelineEntry> = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let r = s.model.result(f.id);
|
||||
TimelineEntry {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
kind: f.kind.type_name().to_string(),
|
||||
suppressed: f.suppressed,
|
||||
rolled_back: i >= marker,
|
||||
error: r.and_then(|r| r.error.clone()),
|
||||
warning: r.and_then(|r| r.warning.clone()),
|
||||
ms: r.map(|r| r.ms),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let timeline: Option<Vec<TimelineRow>> = (timeline_detail != TimelineDetail::Left).then(|| {
|
||||
s.doc
|
||||
.features
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let r = s.model.result(f.id);
|
||||
let (error, warning) = (r.and_then(|r| r.error.clone()), r.and_then(|r| r.warning.clone()));
|
||||
if timeline_detail == TimelineDetail::Summary {
|
||||
return TimelineRow::Summary(TimelineSummary {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
kind: f.kind.type_name().to_string(),
|
||||
error,
|
||||
warning,
|
||||
suppressed: f.suppressed.then_some(true),
|
||||
rolled_back: (i >= marker).then_some(true),
|
||||
});
|
||||
}
|
||||
TimelineRow::Full(TimelineEntry {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
kind: f.kind.type_name().to_string(),
|
||||
suppressed: f.suppressed,
|
||||
rolled_back: i >= marker,
|
||||
error,
|
||||
warning,
|
||||
ms: r.map(|r| r.ms),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let definitions = s.model.state();
|
||||
let with_measure = bool_(p, "measure").unwrap_or(false);
|
||||
// Only bodies given in full are measured.
|
||||
let with_measure = bool_(p, "measure").unwrap_or(false) && bodies_detail == BodiesDetail::Full;
|
||||
let with_counts = bool_(p, "sketch_counts").unwrap_or(false);
|
||||
// Each body is measured on its own: on all cores, in body order (as `inspect.measure`).
|
||||
let mut measured =
|
||||
@@ -601,6 +622,11 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
DocumentBody { detail, visible: s.body_visible(&b.name), removed_later: s.body_removed_later(&b.name).then_some(true) }
|
||||
})
|
||||
.collect();
|
||||
let bodies = match bodies_detail {
|
||||
BodiesDetail::Full => Some(DocumentBodies::Full(bodies)),
|
||||
BodiesDetail::Names => Some(DocumentBodies::Names(st.bodies.iter().map(|b| b.name.clone()).collect())),
|
||||
BodiesDetail::Left => None,
|
||||
};
|
||||
let sketches: Vec<DocumentSketch> = st
|
||||
.sketches
|
||||
.iter()
|
||||
|
||||
@@ -1654,9 +1654,26 @@ fn reach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let before = super::motion::positions(s);
|
||||
let st = s.model.state();
|
||||
let r = joints::reach(&s.doc, &st, &joints::Reach { occurrence: occ, point: at, to, axis, joints: only });
|
||||
let mut stopped_by_contact = None;
|
||||
let reached = match &r {
|
||||
// Only a reach that moves something touches the design (and so adds an undo step).
|
||||
Ok(x) if !x.values.is_empty() && s.doc.assembly.contact_enabled => {
|
||||
// With contact sets on, the joints go toward the values found as joint.drive moves
|
||||
// them, stopping where two parts of a set would pass into each other (#14682). No
|
||||
// value is held on the way: the loops close from each step, so a loop the values
|
||||
// close at both ends doesn't stop the drag in between.
|
||||
let want: Vec<(u64, Vec<f64>)> = x.values.iter().map(|(id, q)| (*id, q.clone())).collect();
|
||||
let (out, _) = super::motion::drive_with_contact(s, &want, &BTreeSet::new())?;
|
||||
stopped_by_contact = out
|
||||
.get("stopped_by_contact")
|
||||
.filter(|v| v.is_object())
|
||||
.cloned()
|
||||
.map(serde_json::from_value::<ContactStop>)
|
||||
.transpose()
|
||||
.map_err(|e| bad(cmd, e.to_string()))?;
|
||||
x.clone()
|
||||
}
|
||||
Ok(x) => {
|
||||
// Only a reach that moves something touches the design (and so adds an undo step).
|
||||
if !x.values.is_empty() {
|
||||
let doc = s.doc_mut();
|
||||
for (id, q) in &x.values {
|
||||
@@ -1691,7 +1708,10 @@ fn reach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter_map(|id| name(*id))
|
||||
.map(|j| ReachedJoint { joint: j.id, name: j.name.clone(), values: named_value_list(j, effective.get(&j.id).unwrap_or(&j.values)) })
|
||||
.collect();
|
||||
let stopped_by_limit = (!reached.limits.is_empty()).then(|| {
|
||||
// A contact that stopped it came first: the limits and loop the reach found further on are
|
||||
// not where it rests.
|
||||
let contact_stopped = stopped_by_contact.is_some();
|
||||
let stopped_by_limit = (!contact_stopped && !reached.limits.is_empty()).then(|| {
|
||||
reached
|
||||
.limits
|
||||
.iter()
|
||||
@@ -1715,7 +1735,8 @@ fn reach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
values,
|
||||
moved: super::motion::moved(&before, &super::motion::positions(s)).next().is_some(),
|
||||
stopped_by_limit,
|
||||
stopped_by_loop: reached.stopped_by_loop.and_then(name).map(|j| ReachLoopStop { joint: j.name.clone() }),
|
||||
stopped_by_loop: reached.stopped_by_loop.filter(|_| !contact_stopped).and_then(name).map(|j| ReachLoopStop { joint: j.name.clone() }),
|
||||
stopped_by_contact,
|
||||
reason: r.err(),
|
||||
continuous: (bool_(p, "continuous") == Some(true)).then_some(true),
|
||||
param_warnings: Vec::new(),
|
||||
@@ -2050,6 +2071,12 @@ fn limits(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_joint(s, p, true)
|
||||
}
|
||||
|
||||
/// joint.edit's change, also run by `assembly.constraint.edit`. `plain`: a change of kind makes a
|
||||
/// constraint a plain joint (joint.edit); the constraint command keeps the rule it has just set.
|
||||
pub(super) fn edit_joint(s: &mut Session, p: &Value, plain: bool) -> Result<Value> {
|
||||
let cmd = "joint.edit";
|
||||
let id = joint_id(s, p.get("joint"), cmd)?;
|
||||
let kind = match str_(p, "type") {
|
||||
@@ -2089,7 +2116,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
j.kind = k;
|
||||
// A constraint changed to another kind of joint is a plain joint now: its tag would keep
|
||||
// restarting it, and a tangent's gap expression would leave the radius out of its offset.
|
||||
if k != was {
|
||||
if k != was && plain {
|
||||
if j.constraint == Some(joints::Constraint::Tangent) {
|
||||
j.exprs.offset = None;
|
||||
}
|
||||
|
||||
@@ -425,3 +425,54 @@ fn quarter_turn_reaches_keep_the_four_bar_closed_and_diagnostics_current() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A grounded block A and a block B on a slider toward it, 10 mm clear, in one contact set:
|
||||
/// `contact` turns contact sets on or off. The slider stops 10 mm past the touch.
|
||||
fn blocks_on_a_rail(contact: bool) -> Session {
|
||||
let mut s = Session::default();
|
||||
for (name, x) in [("A", 0), ("B", 20)] {
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
|
||||
}
|
||||
let a = occ(&s, "A");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
|
||||
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
|
||||
run(&mut s, "joint.limits", json!({"joint": "Rail", "index": 0, "min": -10, "max": 100}));
|
||||
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
|
||||
run(&mut s, "contact.enable_sets", json!({"enabled": contact}));
|
||||
s
|
||||
}
|
||||
|
||||
/// A drag (joint.reach) stops where a contact set says two parts touch, as joint.drive does, and
|
||||
/// says so, not the limit further on; with contact sets off it goes on to that limit (#14682).
|
||||
#[test]
|
||||
fn a_reach_stops_where_a_contact_set_touches() {
|
||||
// B's near face, in its own coordinates; the drag pulls its centre 25 mm toward A, 15 mm past
|
||||
// where B touches A.
|
||||
let near_face = Vec3::new(20.0, 5.0, 5.0);
|
||||
let pull = |s: &mut Session| {
|
||||
let from = place(s, "B", Vec3::new(25.0, 5.0, 5.0));
|
||||
let to = from - Vec3::new(25.0, 0.0, 0.0);
|
||||
reach(s, "B", Vec3::new(25.0, 5.0, 5.0), json!({"point": pt(to)}))
|
||||
};
|
||||
let mut s = blocks_on_a_rail(true);
|
||||
let start = place(&s, "B", near_face).x;
|
||||
assert!((start - 20.0).abs() < 1e-6, "B starts 10 mm clear of A: {start}");
|
||||
let r = pull(&mut s);
|
||||
assert!(r["stopped_by_contact"].is_object(), "{r}");
|
||||
assert!(r["stopped_by_limit"].is_null(), "the contact stopped it, not the limit: {r}");
|
||||
assert_eq!(r["reached"], false, "{r}");
|
||||
let x = place(&s, "B", near_face).x;
|
||||
assert!((10.0 - 1e-6..10.0 + 1e-3).contains(&x), "B stops touching A, not through it: its face at x = {x}");
|
||||
let collisions = run(&mut s, "contact.check", json!({}));
|
||||
assert!(collisions["collisions"].as_array().is_some_and(Vec::is_empty), "{collisions}");
|
||||
// Contact sets off: the same drag goes through A, to the slider's limit.
|
||||
let mut s = blocks_on_a_rail(false);
|
||||
let r = pull(&mut s);
|
||||
assert!(r["stopped_by_contact"].is_null(), "{r}");
|
||||
assert!(r["stopped_by_limit"].is_array(), "{r}");
|
||||
let x = place(&s, "B", near_face).x;
|
||||
assert!(x.abs() < 1e-6, "B's face at x = {x}, not at the limit, 0");
|
||||
}
|
||||
|
||||
@@ -644,3 +644,166 @@ fn a_tangent_constraint_follows_its_faces_radius() {
|
||||
assert!((axis(&s) - want).abs() < 1e-6, "R {radius}, gap {gap:?}: axis at {}, expected {want}", axis(&s));
|
||||
}
|
||||
}
|
||||
|
||||
// assembly.constraint.edit: a constraint's rule, gap, angle, facing or suppression changed in place.
|
||||
|
||||
/// A plate (100 x 100 x 10) and a 20 mm block above it at (30, 40, 50), the plate grounded; the
|
||||
/// block's bottom mated to the plate's top as `Mate1`.
|
||||
fn plate_and_block() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Plate"}));
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 10, "corner": [0, 0, 0]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Block"}));
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [30, 40, 50]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let plate = occ(&s, "Plate");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": plate, "grounded": true}));
|
||||
let r = constrain(
|
||||
&mut s,
|
||||
"mate",
|
||||
json!({"occurrence": "Plate", "face": [5, 5, 10]}),
|
||||
json!({"occurrence": "Block", "face": [31, 41, 50]}),
|
||||
json!({}),
|
||||
);
|
||||
assert!(clean(&r), "{r}");
|
||||
s
|
||||
}
|
||||
|
||||
/// The block's lowest and highest z, from its eight corners.
|
||||
fn block_z(s: &Session) -> (f64, f64) {
|
||||
let mut zs = Vec::new();
|
||||
for x in [30.0, 50.0] {
|
||||
for y in [40.0, 60.0] {
|
||||
for z in [50.0, 70.0] {
|
||||
zs.push(place(s, "Block", Vec3::new(x, y, z)).z);
|
||||
}
|
||||
}
|
||||
}
|
||||
(zs.iter().copied().fold(f64::INFINITY, f64::min), zs.iter().copied().fold(f64::NEG_INFINITY, f64::max))
|
||||
}
|
||||
|
||||
fn near_z(got: (f64, f64), want: (f64, f64)) -> bool {
|
||||
(got.0 - want.0).abs() < 1e-6 && (got.1 - want.1).abs() < 1e-6
|
||||
}
|
||||
|
||||
fn edit_rule(s: &mut Session, p: Value) -> Value {
|
||||
run(s, "assembly.constraint.edit", p)
|
||||
}
|
||||
|
||||
/// One constraint changed from Mate to Distance to Flush: each rule places the block where its
|
||||
/// numbers say, the gap carries over to the next rule, and the joint is the kind its rule needs.
|
||||
#[test]
|
||||
fn a_constraints_rule_changes_in_place() {
|
||||
let mut s = plate_and_block();
|
||||
assert!(near_z(block_z(&s), (10.0, 30.0)), "mate: {:?}", block_z(&s));
|
||||
// Distance 15: the bottom 15 mm above the top, still facing it.
|
||||
let r = edit_rule(&mut s, json!({"constraint": "Mate1", "type": "distance", "offset": 15}));
|
||||
assert!(clean(&r) && dof_of(&r, &s, "Block") == Some(3), "{r}");
|
||||
assert!(near_z(block_z(&s), (25.0, 45.0)), "distance: {:?}", block_z(&s));
|
||||
// Flush keeps the 15 mm gap: the bottom turned to face up, 15 mm above the top.
|
||||
let r = edit_rule(&mut s, json!({"constraint": "Mate1", "type": "flush"}));
|
||||
assert!(clean(&r) && dof_of(&r, &s, "Block") == Some(3), "{r}");
|
||||
assert!(near_z(block_z(&s), (5.0, 25.0)), "flush, 15 mm: {:?}", block_z(&s));
|
||||
// Flush at 0: the bottom in the top's plane, the block below it.
|
||||
edit_rule(&mut s, json!({"constraint": "Mate1", "offset": 0}));
|
||||
assert!(near_z(block_z(&s), (-10.0, 10.0)), "flush, 0: {:?}", block_z(&s));
|
||||
let j = s.doc.assembly.joints.iter().find(|j| j.name == "Mate1").unwrap();
|
||||
use solidcraft_doc::joints::{Constraint, JointKind};
|
||||
assert_eq!((j.kind, j.flip, j.constraint), (JointKind::Planar, true, Some(Constraint::Flush)));
|
||||
// Saved and reopened, the new rule stays.
|
||||
let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(back.assembly.joints.iter().find(|j| j.name == "Mate1").and_then(|j| j.constraint), Some(Constraint::Flush));
|
||||
// Turned over again without a type: back to facing the top, 0 mm off it.
|
||||
edit_rule(&mut s, json!({"constraint": "Mate1", "flip": true}));
|
||||
assert!(near_z(block_z(&s), (10.0, 30.0)), "turned over: {:?}", block_z(&s));
|
||||
}
|
||||
|
||||
/// A new gap moves the block straight across it: not turned, not slid.
|
||||
#[test]
|
||||
fn an_offset_edit_moves_the_part_only_across_the_gap() {
|
||||
let mut s = plate_and_block();
|
||||
let corners = [Vec3::new(30.0, 40.0, 50.0), Vec3::new(50.0, 40.0, 50.0), Vec3::new(30.0, 60.0, 70.0)];
|
||||
let before = corners.map(|c| place(&s, "Block", c));
|
||||
let r = edit_rule(&mut s, json!({"constraint": "Mate1", "offset": 5}));
|
||||
assert!(clean(&r), "{r}");
|
||||
for (c, was) in corners.into_iter().zip(before) {
|
||||
let got = place(&s, "Block", c);
|
||||
assert!(near(got, was + Vec3::new(0.0, 0.0, 5.0), 1e-6), "{c:?}: {got:?}, was {was:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A rule the references don't fit, a plain joint, a missing angle: refused with why, the joint
|
||||
/// as it was.
|
||||
#[test]
|
||||
fn an_edit_that_does_not_fit_is_refused_and_changes_nothing() {
|
||||
let mut s = plate_and_block();
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "parallel", "name": "parallel Top-Bottom", "a": {"occurrence": "Plate", "face": [5, 5, 10]}, "b": {"occurrence": "Block", "face": [31, 41, 10]}}),
|
||||
);
|
||||
let joints = s.doc.assembly.joints.clone();
|
||||
let z = block_z(&s);
|
||||
for (p, says) in [
|
||||
(json!({"constraint": "Mate1", "type": "insert"}), "insert needs two round edges"),
|
||||
(json!({"constraint": "Mate1", "type": "tangent"}), "tangent needs a planar face and a round face"),
|
||||
(json!({"constraint": "Mate1", "type": "angle"}), "angle needs `angle`"),
|
||||
(json!({"constraint": "Mate1", "type": "weld"}), "`type` must be"),
|
||||
(json!({"constraint": "parallel Top-Bottom", "offset": 1}), "was made as a joint"),
|
||||
(json!({"constraint": "Nothing"}), "no joint"),
|
||||
] {
|
||||
let e = s.execute("assembly.constraint.edit", &p).expect_err(&p.to_string()).to_string();
|
||||
assert!(e.contains(says), "{p}: {e}");
|
||||
assert_eq!(s.doc.assembly.joints, joints, "{p}");
|
||||
assert!(near_z(block_z(&s), z), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Suppressed, the constraint places nothing and the block is free; brought back, it sits on the
|
||||
/// plate again.
|
||||
#[test]
|
||||
fn a_constraint_suppressed_and_brought_back() {
|
||||
let mut s = plate_and_block();
|
||||
let block = occ(&s, "Block");
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": block, "translate": [0, 0, 40]}));
|
||||
let r = edit_rule(&mut s, json!({"constraint": "Mate1", "suppressed": true}));
|
||||
assert!(s.doc.assembly.joints.iter().all(|j| j.suppressed), "{r}");
|
||||
assert_eq!(dof_of(&r, &s, "Block"), Some(6), "{r}");
|
||||
let r = edit_rule(&mut s, json!({"constraint": "Mate1", "suppressed": false}));
|
||||
assert!(clean(&r) && dof_of(&r, &s, "Block") == Some(3), "{r}");
|
||||
assert!(near_z(block_z(&s), (10.0, 30.0)), "back: {:?}", block_z(&s));
|
||||
}
|
||||
|
||||
/// A tangent's gap carries over its radius: changed to 2 mm, then the rule mapped again, the
|
||||
/// roller's axis stays a radius and 2 mm above the panel.
|
||||
#[test]
|
||||
fn a_tangent_keeps_its_gap_through_an_edit() {
|
||||
let mut s = panel();
|
||||
run(&mut s, "component.create", json!({"name": "Roller"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": 4, "height": 30, "operation": "new"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
constrain(&mut s, "tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({}));
|
||||
let axis = |s: &Session| place(s, "Roller", Vec3::new(0.0, 600.0, 50.0)).z;
|
||||
edit_rule(&mut s, json!({"constraint": "Tangent1", "offset": 2}));
|
||||
assert!((axis(&s) - 8.0).abs() < 1e-6, "{}", axis(&s));
|
||||
let r = edit_rule(&mut s, json!({"constraint": "Tangent1", "type": "tangent"}));
|
||||
assert!(clean(&r), "{r}");
|
||||
assert!((axis(&s) - 8.0).abs() < 1e-6, "{}", axis(&s));
|
||||
}
|
||||
|
||||
/// A rule changed to one kept as another kind of joint (a mate is planar, a parallel rule is not)
|
||||
/// keeps a tag, with the new rule; the same change through joint.edit makes it a plain joint.
|
||||
#[test]
|
||||
fn a_rule_change_keeps_the_tag_where_joint_edit_drops_it() {
|
||||
use solidcraft_doc::joints::{Constraint, JointKind};
|
||||
let mut s = plate_and_block();
|
||||
let r = edit_rule(&mut s, json!({"constraint": "Mate1", "type": "parallel"}));
|
||||
assert!(clean(&r), "{r}");
|
||||
let rule = |s: &Session| s.doc.assembly.joints.iter().find(|j| j.name == "Mate1").map(|j| (j.kind, j.constraint));
|
||||
assert_eq!(rule(&s), Some((JointKind::Parallel, Some(Constraint::Parallel))));
|
||||
// Started where its parts are: the block didn't move.
|
||||
assert!(near_z(block_z(&s), (10.0, 30.0)), "{:?}", block_z(&s));
|
||||
run(&mut s, "joint.edit", json!({"joint": "Mate1", "type": "planar"}));
|
||||
assert_eq!(rule(&s), Some((JointKind::Planar, None)));
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ mod face;
|
||||
mod features;
|
||||
mod features_more;
|
||||
mod join_pieces;
|
||||
pub(crate) use construct::{GeoForm, GeoFormError, REF as GEO_REF_FORMS, SKETCH_PART, geo_form, is_model_axis, plane_axis_point_kind};
|
||||
pub use construct::picked_plane_refs;
|
||||
pub(crate) use construct::{GeoForm, GeoFormError, REF as GEO_REF_FORMS, SKETCH_PART, geo_form, is_model_axis, picked_plane};
|
||||
pub use construct_auto::{construct_method, construct_methods, is_construct_tool};
|
||||
pub use design_link::{LinkProblem, LinkState};
|
||||
pub(crate) use face::{FaceRefForm, face_ref_form};
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::schema::motion::{
|
||||
FindLimitsResult, MotionListParams, MotionStudies, MotionStudyDeleteParams, MotionStudyDeleted, MotionStudyInfo, MotionStudyKeyInfo,
|
||||
MotionStudyParams, MotionStudySaved,
|
||||
};
|
||||
use crate::schema::motion_animation::{AnimationExported, ExportAnimationParams};
|
||||
use crate::schema::motion_play::{
|
||||
DriveReport, ExportParams, PartPose, PlayParams, Pose, StudyExport, StudyExported, StudyPlayed, StudySample, UnmovedJoint,
|
||||
};
|
||||
@@ -67,6 +68,7 @@ const EXPLODE_LIST_CONTRACT: TypedContract<ExplodeListParams> = TypedContract::n
|
||||
const EXPLODE_DELETE_CONTRACT: TypedContract<ExplodeDeleteParams> = TypedContract::new();
|
||||
const PLAY_CONTRACT: TypedContract<PlayParams> = TypedContract::new();
|
||||
const EXPORT_CONTRACT: TypedContract<ExportParams> = TypedContract::new();
|
||||
const EXPORT_ANIMATION_CONTRACT: TypedContract<ExportAnimationParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", crate::checked!("contact.enable_sets", enable_sets))
|
||||
@@ -138,8 +140,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&EXPORT_CONTRACT)
|
||||
.output(output_schema_for::<StudyExported>)
|
||||
.params("study; samples? (default steps + 1); path? (.json or .csv) → occurrence positions per step (the design is not changed)"),
|
||||
CommandSpec::new("motion.export_animation", "Export Motion Animation", animation::export).icon("export").noundo().params(
|
||||
"study: id|name, or joint: id|name with from and to (degrees for angles, mm for distances, or expressions) and index?, or \
|
||||
CommandSpec::new("motion.export_animation", "Export Motion Animation", crate::checked!("motion.export_animation", animation::export))
|
||||
.icon("export")
|
||||
.noundo()
|
||||
.contract(&EXPORT_ANIMATION_CONTRACT)
|
||||
.output(output_schema_for::<AnimationExported>)
|
||||
.params(
|
||||
"study: id|name, or joint: id|name with from and to (degrees for angles, mm for distances, or expressions) and index?, or \
|
||||
view: id|name (an exploded view, coming apart from the assembled placement) with back? (default false; true: back together \
|
||||
too, exploded at the middle frame of an odd count); path: a .gif file, or a folder for PNG frames (frame-0001.png, \
|
||||
frame-0002.png, …); samples? (frames, 2…1000; default the study's steps + 1, 100 for a joint, 41 for a view or 81 with \
|
||||
@@ -149,7 +156,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
axes) - draws the study as it plays, the joint as it is driven or the view as it explodes, with the headless renderer → \
|
||||
path, format, frames, width, height, fps (GIF), bytes, renderer, stopped_at, stopped_by_loop, stopped_by_contact (the \
|
||||
design is not changed, nor its placements)",
|
||||
),
|
||||
),
|
||||
CommandSpec::new("motion.trace", "Motion Trace", crate::checked!("motion.trace", trace))
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("trace")
|
||||
@@ -640,7 +647,7 @@ fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
/// longer close: the values are moved from where they are toward `want` as far as they go. The
|
||||
/// `given` values (joint, index) are driven; in a closed loop the others follow. Returns the
|
||||
/// answer and the last solve.
|
||||
fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)], given: &BTreeSet<(u64, usize)>) -> Result<(Value, joints::Solution)> {
|
||||
pub(super) fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)], given: &BTreeSet<(u64, usize)>) -> Result<(Value, joints::Solution)> {
|
||||
drive_with_contact_within(s, want, given, false)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{Map, Value};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_doc::Mat;
|
||||
|
||||
use super::{Plan, drive_with_contact, explode_moves, exploded_at, motion_plan, sample_count, view_index};
|
||||
use crate::capture::{self, Renderer};
|
||||
use crate::params::{bad, str_};
|
||||
use crate::schema::motion_analysis::StudyStop;
|
||||
use crate::schema::motion_animation::{AnimationExported, CAMERA_FIELDS, MAX_FRAMES, MAX_GIF_PIXELS, MAX_PNG_PIXELS, MAX_SIDE};
|
||||
use crate::{Result, Session};
|
||||
use solidcraft_viewport::Camera;
|
||||
|
||||
@@ -23,19 +25,6 @@ enum Motion {
|
||||
Explode(Vec<(u64, Mat, Vec3)>, bool),
|
||||
}
|
||||
|
||||
/// Most frames of one animation.
|
||||
const MAX_FRAMES: usize = 1000;
|
||||
/// Largest frame side, in pixels.
|
||||
const MAX_SIDE: u32 = 4096;
|
||||
/// Most pixels of a GIF's frames together: they are held in memory (4 bytes each) until the
|
||||
/// palette is chosen, so 256 MB at most.
|
||||
const MAX_GIF_PIXELS: usize = 1 << 26;
|
||||
/// Most pixels of a PNG folder's frames together (each frame is written as it is drawn): about
|
||||
/// 500 frames of 1920 × 1080.
|
||||
const MAX_PNG_PIXELS: usize = 1 << 30;
|
||||
/// The camera fields `camera` may set: those of `ui.camera`'s reply.
|
||||
const FIELDS: [&str; 7] = ["target", "yaw", "pitch", "roll", "distance", "fov", "up"];
|
||||
|
||||
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
export_with(s, p, capture::installed().as_deref())
|
||||
}
|
||||
@@ -128,14 +117,15 @@ pub(super) fn export_with(s: &Session, p: &Value, renderer: Option<&Renderer>) -
|
||||
// Motion Study dialog shows) are not where the joints are solved, nor the assembled placement.
|
||||
t.pending_moves.clear();
|
||||
let (mut frames, mut bytes, mut drawn_by) = (Vec::new(), 0, "");
|
||||
let mut stop = (Value::Null, Value::Null, Value::Null);
|
||||
// The frame where the motion stopped short, and what stopped it: a loop, or a contact set.
|
||||
let mut stop: Option<(usize, Option<Map<String, Value>>, Option<Map<String, Value>>)> = None;
|
||||
for k in 0..n {
|
||||
match &motion {
|
||||
Motion::Drive(given, plan) => {
|
||||
let want = plan(&t, k, n);
|
||||
let (r, _) = drive_with_contact(&mut t, &want, given)?;
|
||||
if stop.0.is_null() && (r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object()) {
|
||||
stop = (json!(k), r["stopped_by_loop"].clone(), r["stopped_by_contact"].clone());
|
||||
if stop.is_none() && (r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object()) {
|
||||
stop = Some((k, r["stopped_by_loop"].as_object().cloned(), r["stopped_by_contact"].as_object().cloned()));
|
||||
}
|
||||
}
|
||||
// Shown as explode.show shows it, the way out at a steady pace (and back).
|
||||
@@ -156,24 +146,37 @@ pub(super) fn export_with(s: &Session, p: &Value, renderer: Option<&Renderer>) -
|
||||
bytes += png.len();
|
||||
}
|
||||
}
|
||||
let mut out = json!({"path": path, "format": if gif { "gif" } else { "png" }, "frames": n, "width": width, "height": height});
|
||||
if gif {
|
||||
let fps = if gif {
|
||||
// GIF counts in hundredths of a second, and viewers slow anything under 2 down.
|
||||
let delay = (100.0 / fps).round().clamp(2.0, 100.0) as u16;
|
||||
let gif = crate::render::gif(width as usize, height as usize, &frames, delay).ok_or_else(|| bad(cmd, "GIF encoding failed"))?;
|
||||
solidcraft_io::write_atomic(std::path::Path::new(path), &gif, false).map_err(io)?;
|
||||
bytes = gif.len();
|
||||
out["fps"] = json!(100.0 / f64::from(delay));
|
||||
}
|
||||
out["bytes"] = json!(bytes);
|
||||
out["renderer"] = json!(drawn_by);
|
||||
if let Some(w) = capture::warning(drawn_by) {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
out["stopped_at"] = stop.0;
|
||||
out["stopped_by_loop"] = stop.1;
|
||||
out["stopped_by_contact"] = stop.2;
|
||||
Ok(out)
|
||||
Some(100.0 / f64::from(delay))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (stopped_at, stopped_by_loop, stopped_by_contact) = match stop {
|
||||
Some((k, by_loop, by_contact)) => (Some(k), by_loop, by_contact),
|
||||
None => (None, None, None),
|
||||
};
|
||||
super::typed_reply(
|
||||
cmd,
|
||||
AnimationExported {
|
||||
path: path.to_string(),
|
||||
format: if gif { "gif" } else { "png" },
|
||||
frames: n,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bytes,
|
||||
renderer: drawn_by,
|
||||
warning: capture::warning(drawn_by),
|
||||
stop: StudyStop { stopped_at, stopped_by_loop },
|
||||
stopped_by_contact,
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The camera the frames are drawn from: the session's (`view.apply`), or `home` framing the
|
||||
@@ -187,8 +190,8 @@ fn camera(s: &Session, home: impl FnOnce() -> Result<Camera>, given: Option<&Val
|
||||
let fields = given.as_object().ok_or_else(|| bad(cmd, "`camera` must be an object, such as ui.camera's reply"))?;
|
||||
let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
for (k, x) in fields {
|
||||
if !FIELDS.contains(&k.as_str()) {
|
||||
return Err(bad(cmd, format!("unknown camera field `{k}` (it takes {})", FIELDS.join(", "))));
|
||||
if !CAMERA_FIELDS.contains(&k.as_str()) {
|
||||
return Err(bad(cmd, format!("unknown camera field `{k}` (it takes {})", CAMERA_FIELDS.join(", "))));
|
||||
}
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert(k.clone(), x.clone());
|
||||
|
||||
+129
-14
@@ -38,7 +38,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("fastener")
|
||||
.contract(&INSERT_CONTRACT)
|
||||
.output(output_schema_for::<InsertedPart>)
|
||||
.params("the Insert Part dialog's command (same as parts.insert): family, size, length?, at?|point?+direction? (from the shank's tip toward the head), name?"),
|
||||
.params("the Insert Part dialog's command (same as parts.insert): family, size, length?, at?|point?+direction? (from the shank's tip toward the head), flip?, name?"),
|
||||
CommandSpec::new("parts.insert", "Insert Part", crate::checked!("parts.insert", insert_atomic))
|
||||
.icon("fastener")
|
||||
.contract(&INSERT_CONTRACT)
|
||||
@@ -47,11 +47,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"family (socket_head_cap_screw | button_head_cap_screw | countersunk_socket_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing | \
|
||||
hex_cap_screw_inch | hex_nut_inch | washer_sae | washer_uss); size (\"M6\", \"608\" for bearings, \"1/2-13 UNC\" for inch screws and nuts, \
|
||||
\"1/2\" for inch washers); length? (mm, inch parts too; screws, bolts, set screws, pins; default a standard length near 2.5 d); \
|
||||
at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint), or a list of them (one component, \
|
||||
placed and seated once per hole) | point?: [x,y,z] with direction?: [x,y,z] (no joint: the part's seating plane, under a head or \
|
||||
at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint, head on the picked face and \
|
||||
shank into the hole, whichever way the face points) or on a flat face (seated at that point, head out of the face), or a list of \
|
||||
them (one component, placed and seated once per pick) | point?: [x,y,z] with direction?: [x,y,z] (no joint: the part's seating plane, under a head or \
|
||||
the bottom of a nut, washer or bearing, the top of a set screw, pin or countersunk head, goes to point, and direction is its +z, from the shank's tip toward the head; default [0,0,1], \
|
||||
head up, shank down -z); name? → component, occurrence, joint?, \
|
||||
and with at: occurrences, joints (one per hole, in pick order)",
|
||||
head up, shank down -z); flip?: bool (default false: turned over, head into the material with at, +z against direction \
|
||||
with point); name? → component, occurrence, joint?, directions (each placement's head direction in the world, the one \
|
||||
chosen), and with at: occurrences, joints (one per pick, in pick order)",
|
||||
),
|
||||
CommandSpec::new("parts.resize", "Change Part Size", crate::checked!("parts.resize", resize_atomic))
|
||||
.icon("scale")
|
||||
@@ -836,10 +838,15 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A pick on a hole's rim also touches its end face: step onto the wall just inside the rim.
|
||||
// Each hole's occurrence is found before the part exists, so a pick off every body fails the
|
||||
// insert before anything is made.
|
||||
let flip = p.get("flip").and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut holes: Vec<(Vec3, u64)> = Vec::new();
|
||||
let mut seats: Vec<(u64, HoleWall)> = Vec::new();
|
||||
// Picks on a flat face, off every hole: the point and the face's outward normal (world).
|
||||
let mut faces: Vec<Option<Vec3>> = Vec::new();
|
||||
for a in hole_picks(p, cmd)? {
|
||||
let wall = hole_wall(s, a).unwrap_or(HoleWall { at: a, seat: a, axis: None, ends: None, open: true, radius: 0.0 });
|
||||
let found = hole_wall(s, a);
|
||||
let face = if found.is_none() { outward_normal(s, a) } else { None };
|
||||
let wall = found.unwrap_or(HoleWall { at: a, seat: a, axis: None, ends: None, open: true, radius: 0.0 });
|
||||
let host = super::joints::occurrence_at(s, wall.at).ok_or_else(|| bad(cmd, "no body at `at` (pick a hole's rim or wall)"))?;
|
||||
// A rim is drawn as more than one edge, and one click on it picks them all; a hole can
|
||||
// also be picked at both its ends (#12191). The picks of one hole seat one part, at an
|
||||
@@ -857,6 +864,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => {
|
||||
holes.push((wall.at, host));
|
||||
seats.push((host, wall));
|
||||
faces.push(face);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -876,8 +884,11 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
joint: None,
|
||||
occurrences: None,
|
||||
joints: None,
|
||||
directions: Vec::new(),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
// The part's −z (its shank) mates with the seat's outward z; flipped, its +z does.
|
||||
let part_z = if flip { [0.0, 0.0, 1.0] } else { [0.0, 0.0, -1.0] };
|
||||
if !holes.is_empty() {
|
||||
// The first hole has the placement the component came with; each further hole places the
|
||||
// same part again. All are made before any is seated, while each is still at the part's
|
||||
@@ -891,7 +902,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
);
|
||||
}
|
||||
let mut joints = Vec::new();
|
||||
for (i, ((a, h), o)) in holes.into_iter().zip(&occurrences).enumerate() {
|
||||
for (i, (((a, h), o), face)) in holes.into_iter().zip(&occurrences).zip(faces).enumerate() {
|
||||
// Seat it: its axis on the hole's, its seating plane on the hole's face, the shank
|
||||
// going into the hole (the joint mates the part's −z with the face's outward z).
|
||||
let seat = if i == 0 { format!("{name} seat") } else { format!("{name} seat {}", i + 1) };
|
||||
@@ -908,19 +919,22 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => [0.0, 0.0, 0.0],
|
||||
};
|
||||
let j = run(
|
||||
s,
|
||||
"joint.create",
|
||||
json!({"type": "rigid", "a": {"occurrence": h, "circle": a}, "b": {"occurrence": o, "point": at_rim, "z": [0, 0, -1]}, "name": seat}),
|
||||
)?;
|
||||
// On a flat face: seated at the picked point, its z the face's outward normal.
|
||||
let host = match face {
|
||||
Some(n) => json!({"occurrence": h, "point": [a.x, a.y, a.z], "z": [n.x, n.y, n.z]}),
|
||||
None => json!({"occurrence": h, "circle": a}),
|
||||
};
|
||||
let j = run(s, "joint.create", json!({"type": "rigid", "a": host, "b": {"occurrence": o, "point": at_rim, "z": part_z}, "name": seat}))?;
|
||||
joints.push(j["joint"].as_u64().ok_or_else(|| bad(cmd, "the joint has no id"))?);
|
||||
}
|
||||
out.joint = joints.first().copied();
|
||||
out.directions = occurrences.iter().map(|o| head_direction(s, *o)).collect();
|
||||
out.occurrences = Some(occurrences);
|
||||
out.joints = Some(joints);
|
||||
} else if let Some(pt) = p.get("point").and_then(vec3) {
|
||||
// Placed at a point, its +z along `direction`.
|
||||
let dir = p.get("direction").and_then(vec3).and_then(|d| d.normalized()).unwrap_or(Vec3::Z);
|
||||
let dir = if flip { dir * -1.0 } else { dir };
|
||||
let m = solidcraft_doc::rigid(
|
||||
Vec3::ZERO,
|
||||
Vec3::ZERO,
|
||||
@@ -936,9 +950,44 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.transform = m;
|
||||
}
|
||||
}
|
||||
if out.directions.is_empty() {
|
||||
out.directions = vec![head_direction(s, occ)];
|
||||
}
|
||||
reply(cmd, out)
|
||||
}
|
||||
|
||||
/// The way a placed part's head points in the world: its +z.
|
||||
fn head_direction(s: &Session, occ: u64) -> [f64; 3] {
|
||||
let z = solidcraft_doc::apply_vector(&world_of(s, occ), Vec3::Z);
|
||||
let z = z.normalized().unwrap_or(z);
|
||||
// Rounding noise reads as zero.
|
||||
let tidy = |v: f64| if v.abs() < 1e-12 { 0.0 } else { v };
|
||||
[tidy(z.x), tidy(z.y), tidy(z.z)]
|
||||
}
|
||||
|
||||
/// An occurrence's placement in the world (the design itself: none).
|
||||
fn world_of(s: &Session, occ: u64) -> solidcraft_doc::Mat {
|
||||
if occ == 0 { solidcraft_doc::IDENTITY } else { solidcraft_doc::joints::occurrence_world(&s.doc, occ) }
|
||||
}
|
||||
|
||||
/// The outward normal (world) of the flat face at `a`, on the body the pick is on.
|
||||
fn outward_normal(s: &Session, a: Vec3) -> Option<Vec3> {
|
||||
use solidcraft_doc::joints::{JointOrigin, Snap};
|
||||
let occurrence = super::joints::occurrence_at(s, a)?;
|
||||
let t = world_of(s, occurrence);
|
||||
let inv = solidcraft_doc::mat_inverse(&t)?;
|
||||
let pick = solidcraft_doc::apply_point(&inv, a);
|
||||
let st = s.model.state();
|
||||
let o = JointOrigin::new(occurrence, Snap::FaceCenter { pick, face: None, x: None });
|
||||
let m = solidcraft_doc::joints::resolve_origin(&s.doc, &st, &o).ok()?;
|
||||
let (c, n) = (Vec3::new(m[3][0], m[3][1], m[3][2]), Vec3::new(m[2][0], m[2][1], m[2][2]));
|
||||
// The nearest plane must be the one picked: the point lies on it.
|
||||
if (pick - c).dot(n).abs() > 1e-6 {
|
||||
return None;
|
||||
}
|
||||
solidcraft_doc::apply_vector(&t, n).normalized()
|
||||
}
|
||||
|
||||
/// The most holes one insert seats a part in.
|
||||
const MAX_HOLES: usize = 1000;
|
||||
|
||||
@@ -1436,11 +1485,11 @@ mod tests {
|
||||
let undo = s.undo.len();
|
||||
run(&mut s, "solid.combine", json!({"target":"Plate","tools":["Block"],"operation":"join","new_component":true}));
|
||||
assert_eq!(s.undo.len(), undo + 1, "solid.combine new_component");
|
||||
// A failed command: no inner step stays, and redo is kept.
|
||||
// A failed command (a pick off every body): no inner step stays, and redo is kept.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let (undo, redo, doc) = (s.undo.len(), s.redo.len(), s.doc.clone());
|
||||
assert!(redo > 0);
|
||||
let failed = s.execute("parts.insert", &json!({"family":"socket_head_cap_screw","size":"M6","length":20,"at":[20,20,10]}));
|
||||
let failed = s.execute("parts.insert", &json!({"family":"socket_head_cap_screw","size":"M6","length":20,"at":[200,200,200]}));
|
||||
assert!(failed.is_err());
|
||||
assert_eq!((s.undo.len(), s.redo.len()), (undo, redo), "a failed insert leaves the history alone");
|
||||
assert_eq!(s.doc, doc);
|
||||
@@ -1503,6 +1552,72 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #12150: a fastener picked on a flat face, off any hole, seats there with its head out of
|
||||
/// the face, whichever way the face points, also on a component that is turned over; the
|
||||
/// reply says which way each head points.
|
||||
#[test]
|
||||
fn a_fastener_picked_on_a_flat_face_seats_head_out_of_it() {
|
||||
let screw = |at: Value| json!({"family": "socket_head_cap_screw", "size": "M3", "length": 8, "at": at});
|
||||
// (pick, the face normal's axis, the part's extent on it: the 3 mm head outside the face,
|
||||
// the 8 mm shank inside, the head's direction)
|
||||
let cases: [(Value, usize, (f64, f64), [f64; 3]); 3] = [
|
||||
(json!([10, 10, 10]), 2, (2.0, 13.0), [0.0, 0.0, 1.0]),
|
||||
(json!([10, 10, 0]), 2, (-3.0, 8.0), [0.0, 0.0, -1.0]),
|
||||
(json!([40, 10, 5]), 0, (32.0, 43.0), [1.0, 0.0, 0.0]),
|
||||
];
|
||||
for (at, axis, span, direction) in cases {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
|
||||
let r = run(&mut s, "parts.insert", screw(at.clone()));
|
||||
assert!(r["joint"].is_number(), "{at}: {r}");
|
||||
assert_eq!(r["directions"], json!([direction]), "{at}: {r}");
|
||||
let (lo, hi) = bbox(&s, "SHCS M3x8");
|
||||
let (lo, hi) = ([lo.x, lo.y, lo.z][axis], [hi.x, hi.y, hi.z][axis]);
|
||||
assert!((lo - span.0).abs() < 1e-6 && (hi - span.1).abs() < 1e-6, "{at}: {lo} {hi}");
|
||||
}
|
||||
// A plate component turned over: its top face now faces down, and the head goes below it.
|
||||
let mut s = Session::default();
|
||||
let plate = run(&mut s, "component.create", json!({"name": "P"}))["occurrence"].clone();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(
|
||||
&mut s,
|
||||
"occurrence.move",
|
||||
json!({"occurrence": plate, "axis": [1, 0, 0], "angle": "180 deg", "origin": [0, 0, 0], "move_grounded": true}),
|
||||
);
|
||||
let r = run(&mut s, "parts.insert", screw(json!([10, -10, -10])));
|
||||
assert_eq!(r["directions"], json!([[0.0, 0.0, -1.0]]), "{r}");
|
||||
let (lo, hi) = bbox(&s, "SHCS M3x8");
|
||||
assert!((lo.z + 13.0).abs() < 1e-6 && (hi.z + 2.0).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
}
|
||||
|
||||
/// #12150: `flip` turns a seated part over (head into the hole, shank out of the face) and a
|
||||
/// part placed at a point against `direction`; the reply reports the direction chosen.
|
||||
#[test]
|
||||
fn flip_turns_an_inserted_part_over() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
|
||||
run(&mut s, "solid.hole", json!({"position": [20, 15, 10], "diameter": 6.4}));
|
||||
let seat = |flip: bool| json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20, "at": [23.2, 15, 10], "flip": flip});
|
||||
let r = run(&mut s, "parts.insert", seat(false));
|
||||
assert_eq!(r["directions"], json!([[0.0, 0.0, 1.0]]), "{r}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let r = run(&mut s, "parts.insert", seat(true));
|
||||
assert_eq!(r["directions"], json!([[0.0, 0.0, -1.0]]), "{r}");
|
||||
// The 6 mm head down in the hole under the top face, the 20 mm shank standing out of it.
|
||||
let (lo, hi) = bbox(&s, "SHCS M6x20");
|
||||
assert!((lo.z - 4.0).abs() < 1e-6 && (hi.z - 30.0).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
let mut s = Session::default();
|
||||
let r = run(
|
||||
&mut s,
|
||||
"parts.insert",
|
||||
json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20, "point": [5, 5, 10], "direction": [0, 0, 1], "flip": true}),
|
||||
);
|
||||
assert_eq!(r["directions"], json!([[0.0, 0.0, -1.0]]), "{r}");
|
||||
let (lo, hi) = bbox(&s, "SHCS M6x20");
|
||||
assert!((lo.z - 4.0).abs() < 1e-6 && (hi.z - 30.0).abs() < 1e-6, "{lo:?} {hi:?}");
|
||||
}
|
||||
|
||||
/// Several holes in one insert: one component placed and seated once per hole, in one undo
|
||||
/// step (#9341); two picks of one rim are one hole; a pick off every body fails the whole
|
||||
/// insert.
|
||||
|
||||
@@ -3859,6 +3859,153 @@ fn a_hole_on_a_flange_panel_unfolds_with_the_flange() {
|
||||
assert!(!contains(&flat_first, [30.0, 1.25, 12.0]) && contains(&flat_first, [33.0, 1.25, 12.0]));
|
||||
}
|
||||
|
||||
/// A hat bent from one contour (#11728), 40 deep, of 1.5 mm sheet (R 1.5, K 0.44): feet from x ±21.55
|
||||
/// out to ±29 at z 0…1.5, walls 56 high, the top at z 56…57.5. The flat lays the five panels and four
|
||||
/// bends end to end: the feet 4.45 long, the walls 51.5, the top 40.1, each bend (π/2)(R + K·T).
|
||||
fn hat(s: &mut Session) {
|
||||
run(s, "sheet.manage_rules", json!({"name": "Al 1.5", "thickness": "1.5 mm", "bend_radius": "1.5 mm", "k_factor": "0.44", "active": true}));
|
||||
run(s, "sketch.create", json!({"plane": {"origin": [0, 20, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Profile"}));
|
||||
run(s, "sketch.line", json!({"points": [[-29, 0], [-21.55, 0], [-21.55, 56], [21.55, 56], [21.55, 0], [29, 0]]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(
|
||||
s,
|
||||
"sheet.flange",
|
||||
json!({"type": "contour", "sketch": "Profile", "curves": ["l1", "l2", "l3", "l4", "l5"], "distance": "40 mm", "flip": true, "body_name": "Hat"}),
|
||||
);
|
||||
}
|
||||
|
||||
/// The hat's holes: a Ø22.4 bore and four M3 clearance holes (Ø3.4) in the top, and one M3 hole in
|
||||
/// each foot at x ±`foot`, y 5.
|
||||
fn hat_holes(s: &mut Session, foot: f64) {
|
||||
run(s, "solid.hole", json!({"position": [0, 0, 57.5], "diameter": 22.4, "targets": ["Hat"]}));
|
||||
for (x, y, z) in [(15.5, 15.5, 57.5), (-15.5, 15.5, 57.5), (15.5, -15.5, 57.5), (-15.5, -15.5, 57.5), (foot, 5.0, 1.5), (-foot, 5.0, 1.5)] {
|
||||
run(s, "solid.hole", json!({"position": [x, y, z], "clearance": "M3", "fit": "normal", "targets": ["Hat"]}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Holes in the end panels of a contour flange (#11728) land in its flat pattern like the holes in
|
||||
/// the panels between. Inside a foot, an M3 clearance hole is a cut-out and a DXF CIRCLE. At x ±27.5
|
||||
/// it is 1.5 from the foot's free edge at ±29, and its radius is 1.7: it breaks out 0.2 past the
|
||||
/// edge, so the flat has it as a notch in its outline (DXF ARCs), not as a cut-out. Places are read
|
||||
/// from the flat's corner: u along the unrolled profile from the left foot's end, v = y + 20 across.
|
||||
#[test]
|
||||
fn holes_in_a_contour_flanges_end_panels_are_cut_outs_or_notches_of_its_flat_pattern() {
|
||||
let (t, k, r) = (1.5, 0.44, 1.7);
|
||||
let ba = FRAC_PI_2 * (1.5 + k * t);
|
||||
let length = 2.0 * 4.45 + 2.0 * 51.5 + 40.1 + 4.0 * ba;
|
||||
// Where x = 0 of the top lies: past a foot, a wall and two bends, and the top's 20.05 to its middle.
|
||||
let mid = 4.45 + 51.5 + 2.0 * ba + 20.05;
|
||||
// What the flat's corner is, and a place (u, v) from it.
|
||||
let corner = |f: &Value| {
|
||||
let points = f["outline"].as_array().into_iter().flatten().flat_map(|l| l.as_array().into_iter().flatten());
|
||||
points.fold([f64::MAX, f64::MAX], |m, p| [m[0].min(p[0].as_f64().unwrap_or(f64::MAX)), m[1].min(p[1].as_f64().unwrap_or(f64::MAX))])
|
||||
};
|
||||
let at = |c: [f64; 2], u: f64, v: f64| [c[0] + u, c[1] + v];
|
||||
let top_holes = |c: [f64; 2]| {
|
||||
let mut holes = vec![(at(c, mid, 20.0), 11.2)];
|
||||
holes.extend([(15.5, 15.5), (-15.5, 15.5), (15.5, -15.5), (-15.5, -15.5)].map(|(x, y)| (at(c, mid + x, 20.0 + y), r)));
|
||||
holes
|
||||
};
|
||||
let circles = |s: &mut Session| -> Vec<(solidcore_record::Vec2, f64)> {
|
||||
let dxf = run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
|
||||
let geoms = solidcraft_io::read_dxf(&dxf).unwrap_or_default();
|
||||
geoms.into_iter().filter_map(|g| if let solidcraft_io::Geom2::Circle(c, radius) = g { Some((c, radius)) } else { None }).collect()
|
||||
};
|
||||
|
||||
// The part alone: the flat's area times the thickness, plus (π/2)·T²·(½ − K) for each 40 of bend.
|
||||
let mut s = Session::default();
|
||||
hat(&mut s);
|
||||
let v0 = volume(&mut s);
|
||||
let want = t * length * 40.0 + 4.0 * FRAC_PI_2 * t * t * (0.5 - k) * 40.0;
|
||||
assert!(rel(v0, want) < 1e-9, "{v0} vs {want}");
|
||||
|
||||
// Inside the feet (x ±26.8: 0.55 from the bends, 0.5 from the edges): seven cut-outs, seven circles.
|
||||
let mut inside = Session::default();
|
||||
hat(&mut inside);
|
||||
hat_holes(&mut inside, 26.8);
|
||||
let removed = t * PI * (11.2 * 11.2 + 6.0 * r * r);
|
||||
assert!((v0 - volume(&mut inside) - removed).abs() < 0.05, "{}", v0 - volume(&mut inside));
|
||||
let f = flat(&mut inside);
|
||||
let c = corner(&f);
|
||||
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(7), "{}", f["cutouts"]);
|
||||
let mut holes = top_holes(c);
|
||||
holes.extend([(at(c, 2.2, 25.0), r), (at(c, length - 2.2, 25.0), r)]);
|
||||
let drawn = circles(&mut inside);
|
||||
assert_eq!(drawn.len(), 7, "{drawn:?}");
|
||||
for (centre, radius) in holes {
|
||||
assert!(has_round_cutout(&f, centre, radius), "{centre:?}: {}", f["cutouts"]);
|
||||
assert!(
|
||||
drawn.iter().any(|(p, rr)| p.dist(solidcore_record::Vec2::new(centre[0], centre[1])) < 1e-5 && (rr - radius).abs() < 1e-5),
|
||||
"{centre:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The issue's feet holes, at x ±27.5: each cuts all of its circle but the segment past the edge.
|
||||
hat_holes(&mut s, 27.5);
|
||||
let past = r * r * (1.5 / r).acos() - 1.5 * (r * r - 1.5 * 1.5).sqrt();
|
||||
let removed = t * (PI * 11.2 * 11.2 + 4.0 * PI * r * r + 2.0 * (PI * r * r - past));
|
||||
let v1 = volume(&mut s);
|
||||
assert!((v0 - v1 - removed).abs() < 0.05, "{} vs {removed}", v0 - v1);
|
||||
let f = flat(&mut s);
|
||||
let size = &f["flat_size_mm"];
|
||||
assert!((size[0].as_f64().unwrap_or(f64::NAN) - length).abs() < 1e-6 && (size[1].as_f64().unwrap_or(f64::NAN) - 40.0).abs() < 1e-6, "{size}");
|
||||
let c = corner(&f);
|
||||
// Five cut-outs and five circles: the top's.
|
||||
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(5), "{}", f["cutouts"]);
|
||||
let drawn = circles(&mut s);
|
||||
assert_eq!(drawn.len(), 5, "{drawn:?}");
|
||||
for (centre, radius) in top_holes(c) {
|
||||
assert!(has_round_cutout(&f, centre, radius), "{centre:?}: {}", f["cutouts"]);
|
||||
assert!(
|
||||
drawn.iter().any(|(p, rr)| p.dist(solidcore_record::Vec2::new(centre[0], centre[1])) < 1e-5 && (rr - radius).abs() < 1e-5),
|
||||
"{centre:?}"
|
||||
);
|
||||
}
|
||||
// The feet holes are notches: the outline runs round their circles, and the DXF draws them as
|
||||
// arcs ending on the end edge, ±0.8 (√(1.7² − 1.5²)) either side of v 25.
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
|
||||
let arcs: Vec<_> = solidcraft_io::read_dxf(&dxf)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|g| if let solidcraft_io::Geom2::Arc { c, a, b } = g { Some((c, a, b)) } else { None })
|
||||
.collect();
|
||||
let outline: Vec<[f64; 2]> = f["outline"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|l| l.as_array().into_iter().flatten())
|
||||
.map(|p| [p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN)])
|
||||
.collect();
|
||||
for (u, edge) in [(1.5, 0.0), (length - 1.5, length)] {
|
||||
let centre = at(c, u, 25.0);
|
||||
let centre = solidcore_record::Vec2::new(centre[0], centre[1]);
|
||||
let on_circle = outline.iter().filter(|p| (solidcore_record::Vec2::new(p[0], p[1]).dist(centre) - r).abs() < 1e-3).count();
|
||||
assert!(on_circle >= 3, "the outline does not run round the notch at u {u}: {on_circle} points");
|
||||
let notch: Vec<_> = arcs.iter().filter(|(ac, a, _)| ac.dist(centre) < 1e-5 && (a.dist(centre) - r).abs() < 1e-5).collect();
|
||||
let ends: Vec<solidcore_record::Vec2> = notch.iter().flat_map(|(_, a, b)| [*a, *b]).collect();
|
||||
for v in [25.0 - 0.8, 25.0 + 0.8] {
|
||||
let p = at(c, edge, v);
|
||||
assert!(ends.iter().any(|e| e.dist(solidcore_record::Vec2::new(p[0], p[1])) < 1e-5), "no notch arc ends at {p:?}: {notch:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Unfolded, the sheet is its flat less the holes and the notches; refolded, the part again.
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let unfolded = t * (length * 40.0 - PI * 11.2 * 11.2 - 4.0 * PI * r * r - 2.0 * (PI * r * r - past));
|
||||
assert!((volume(&mut s) - unfolded).abs() < 0.05, "{} vs {unfolded}", volume(&mut s));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v1).abs() < 0.05, "{} vs {v1}", volume(&mut s));
|
||||
|
||||
// A cut that reaches a foot's bend (x 23.3…26.7, the bend ending at 24.55) is refused.
|
||||
let mut bend = Session::default();
|
||||
hat(&mut bend);
|
||||
run(&mut bend, "sketch.create", json!({"plane": "XY", "name": "Bend"}));
|
||||
run(&mut bend, "sketch.circle.center", json!({"center": [25, 5], "radius": r}));
|
||||
run(&mut bend, "sketch.finish", json!({}));
|
||||
let error = bend.execute("solid.extrude", &json!({"sketch": "Bend", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
assert!(error.as_ref().is_err_and(|e| e.to_string().contains("bend")), "{error:?}");
|
||||
}
|
||||
|
||||
/// On a folded sheet a counterbore is the same pocket around the same hole, on the base and on a
|
||||
/// flange, and stays through unfold and refold.
|
||||
#[test]
|
||||
|
||||
@@ -6,12 +6,12 @@ use std::collections::HashMap;
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_io::{DxfEntity, DxfLayer, Geom2, MAX_DRAWING_BYTES};
|
||||
use solidcraft_sketch::{Analysis, CurveKind, LinkKind, LinkSource, Sketch};
|
||||
use solidcraft_sketch::{Analysis, ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::sketch::{Left, edit_solved, ids_of};
|
||||
use super::sketch_store;
|
||||
use crate::params::{bad, num, str_, vec2};
|
||||
use crate::params::{bad, bool_, num, str_, vec2};
|
||||
use crate::schema::sketch_import::{DrawingInserted, DxfExported, ExportDxfParams, ExportDxfResult, InsertDxfParams, InsertSvgParams, SketchSolve};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -32,17 +32,17 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("sketch_dxf")
|
||||
.contract(&INSERT_DXF_CONTRACT)
|
||||
.output(output_schema_for::<DrawingInserted>)
|
||||
.params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
|
||||
.params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale? | width? | height?: mm, the size of the drawn geometry's bounds before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); fixed?: bool (a Fix constraint on every point the drawing adds, as sketch.constraint.fix puts one; a circle's or ellipse's radius stays free; default false); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
|
||||
CommandSpec::new("sketch.insert_svg", "Insert SVG", crate::checked!("sketch.insert_svg", insert_svg))
|
||||
.at("SKETCH", "INSERT")
|
||||
.icon("svg")
|
||||
.contract(&INSERT_SVG_CONTRACT)
|
||||
.output(output_schema_for::<DrawingInserted>)
|
||||
.params(concat!("path | text: SVG; at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
|
||||
.params(concat!("path | text: SVG; at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); size_from?: \"geometry\" (default: width and height are the bounds of the drawn geometry) | \"viewbox\" (the SVG sheet: its viewBox, else its width and height; a drawing smaller than its sheet then comes out smaller than width or height say); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's (or sheet's) middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); fixed?: bool (every point it adds gets a Fix constraint, so the drawing stays where it is put); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
|
||||
CommandSpec::new("sketch.drawing_bounds", "Drawing Size", drawing_bounds)
|
||||
.icon("svg")
|
||||
.noundo()
|
||||
.params("path | text: SVG or DXF (by `format`: \"svg\" | \"dxf\", default by the file name, else SVG); returns min, max, width and height in the file's own size"),
|
||||
.params("path | text: SVG or DXF (by `format`: \"svg\" | \"dxf\", default by the file name, else SVG); returns min, max, width and height of the drawn geometry in the file's own size, and for an SVG with a viewBox (or width and height) its sheet: {min, max, width, height}"),
|
||||
CommandSpec::new("sketch.export_dxf", "Save As DXF", crate::checked!("sketch.export_dxf", export_dxf))
|
||||
.icon("sketch_dxf")
|
||||
.noundo()
|
||||
@@ -206,14 +206,18 @@ fn bounds_of(geom: &[Geom2]) -> Option<(Vec2, Vec2)> {
|
||||
|
||||
/// The drawing's geometry and the box around it; a drawing with none is an error that says why
|
||||
/// (#9641).
|
||||
fn read_drawing(p: &Value, cmd: &str, svg: bool) -> Result<(Vec<Geom2>, (Vec2, Vec2))> {
|
||||
fn read_drawing(p: &Value, cmd: &str, svg: bool) -> Result<(Vec<Geom2>, (Vec2, Vec2), String)> {
|
||||
let text = source_text(p, cmd, svg)?;
|
||||
let geom = if svg { solidcraft_io::read_svg(&text) } else { solidcraft_io::read_dxf(&text) }.map_err(|e| drawing_error(p, svg, e.to_string()))?;
|
||||
let Some(bounds) = bounds_of(&geom) else {
|
||||
let why = if svg { solidcraft_io::svg_nothing_to_import(&text) } else { "the DXF has no entities SolidCraft reads".to_string() };
|
||||
return Err(drawing_error(p, svg, why));
|
||||
};
|
||||
Ok((geom, bounds))
|
||||
Ok((geom, bounds, text))
|
||||
}
|
||||
|
||||
fn sheet_json((lo, hi): (Vec2, Vec2)) -> Value {
|
||||
json!({"min": [lo.x, lo.y], "max": [hi.x, hi.y], "width": hi.x - lo.x, "height": hi.y - lo.y})
|
||||
}
|
||||
|
||||
/// How big a drawing is in its own file, to size it before it is inserted.
|
||||
@@ -224,12 +228,23 @@ fn drawing_bounds(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(f) => !f.eq_ignore_ascii_case("dxf"),
|
||||
None => !name_is_dxf,
|
||||
};
|
||||
let (_, (lo, hi)) = read_drawing(p, cmd, svg)?;
|
||||
Ok(json!({"min": [lo.x, lo.y], "max": [hi.x, hi.y], "width": hi.x - lo.x, "height": hi.y - lo.y}))
|
||||
let (_, bounds, text) = read_drawing(p, cmd, svg)?;
|
||||
let mut out = sheet_json(bounds);
|
||||
if svg && let Some(sheet) = solidcraft_io::svg_sheet(&text) {
|
||||
out["sheet"] = sheet_json(sheet);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
let (geom, (lo, hi)) = read_drawing(p, cmd, svg)?;
|
||||
let (geom, (lo, hi), text) = read_drawing(p, cmd, svg)?;
|
||||
// What `width`, `height` and `align: center` refer to: the drawn geometry, or the SVG sheet.
|
||||
let (lo, hi) = match str_(p, "size_from") {
|
||||
None | Some("geometry") => (lo, hi),
|
||||
Some("viewbox") if !svg => return Err(bad(cmd, "`size_from: viewbox` is for SVG drawings; a DXF has no sheet")),
|
||||
Some("viewbox") => solidcraft_io::svg_sheet(&text).ok_or_else(|| bad(cmd, "the SVG has no viewBox or width and height to size by"))?,
|
||||
Some(_) => return Err(bad(cmd, "`size_from` must be \"geometry\" or \"viewbox\"")),
|
||||
};
|
||||
// A size in millimetres wins over a factor; the aspect ratio is always kept.
|
||||
let natural = (hi.x - lo.x, hi.y - lo.y);
|
||||
let sized = |key: &str, of: fn(&(f64, f64)) -> f64| -> Result<Option<f64>> {
|
||||
@@ -273,11 +288,26 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
if geom.len() > solidcraft_sketch::limits::max_points() {
|
||||
return Err(solidcraft_sketch::SketchError::TooLarge.into());
|
||||
}
|
||||
let fixed = bool_(p, "fixed").unwrap_or(false);
|
||||
// `sketch::edit`, but keeping the solve as a typed value for the reply.
|
||||
let target = sketch_store::target(s, p, cmd)?;
|
||||
let ((curves, texts), report) = edit_solved(s, target, false, Analysis::Freedom, |sk, _| {
|
||||
let ((curves, texts, n_fixed), report) = edit_solved(s, target, false, Analysis::Freedom, |sk, _| {
|
||||
let first = sk.points.len();
|
||||
let (cs, texts) = add_geometry(sk, &geom, &f, k, tol)?;
|
||||
Ok(((ids_of(sk, &cs), texts), Left::Unsolved))
|
||||
// Fixed where it is put: a Fix constraint on every point the drawing added (a circle's
|
||||
// or ellipse's radius stays free). Refused as a whole when the constraints would not fit.
|
||||
let mut n_fixed = 0;
|
||||
if fixed {
|
||||
let limit = solidcraft_sketch::limits::max_constraints();
|
||||
if sk.constraints.len() + (sk.points.len() - first) > limit {
|
||||
return Err(bad(cmd, format!("fixing the drawing's points needs more than the sketch's {limit} constraints")));
|
||||
}
|
||||
for q in first..sk.points.len() {
|
||||
sk.add_constraint(ConstraintKind::Fix { p: q }, None)?;
|
||||
n_fixed += 1;
|
||||
}
|
||||
}
|
||||
Ok(((ids_of(sk, &cs), texts, n_fixed), Left::Unsolved))
|
||||
})?;
|
||||
let info = report.map(|rep| SketchSolve { solved: rep.keeps_orientation(), dof: rep.dof });
|
||||
// Text becomes sketch text.
|
||||
@@ -304,7 +334,18 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
reply(cmd, DrawingInserted { sketch: id, created_sketch: created, curves: curves.len(), texts: n_text, info, param_warnings: Vec::new() })
|
||||
reply(
|
||||
cmd,
|
||||
DrawingInserted {
|
||||
sketch: id,
|
||||
created_sketch: created,
|
||||
curves: curves.len(),
|
||||
texts: n_text,
|
||||
fixed: fixed.then_some(n_fixed),
|
||||
info,
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn insert_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -759,6 +800,68 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 20 × 10 rectangle drawn at (10, 10) on a 100 × 50 sheet.
|
||||
const ON_SHEET: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100mm" height="50mm" viewBox="0 0 100 50"><rect x="10" y="10" width="20" height="10"/></svg>"#;
|
||||
|
||||
/// `width` and `height` size the drawn geometry unless `size_from: viewbox` makes them the
|
||||
/// sheet's, so a drawing that does not fill its sheet keeps its place and size on it.
|
||||
#[test]
|
||||
fn an_svg_can_be_sized_by_its_sheet() {
|
||||
let near = |a: (f64, f64, f64, f64), e: (f64, f64, f64, f64)| {
|
||||
assert!((a.0 - e.0).abs() < 1e-6 && (a.1 - e.1).abs() < 1e-6 && (a.2 - e.2).abs() < 1e-6 && (a.3 - e.3).abs() < 1e-6, "{a:?} vs {e:?}")
|
||||
};
|
||||
let inserted = |params: serde_json::Value| {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
let mut p = json!({"text": ON_SHEET});
|
||||
p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone());
|
||||
s.execute("sketch.insert_svg", &p).unwrap();
|
||||
span(&s)
|
||||
};
|
||||
// By the geometry (as before): the rectangle itself becomes 200 wide, placed at the origin.
|
||||
near(inserted(json!({"width": 200})), (100.0, -200.0, 300.0, -100.0));
|
||||
// By the sheet: the sheet becomes 200 wide, so the rectangle is 40 × 20 at (20, −40).
|
||||
near(inserted(json!({"width": 200, "size_from": "viewbox"})), (20.0, -40.0, 60.0, -20.0));
|
||||
near(inserted(json!({"size_from": "viewbox"})), (10.0, -20.0, 30.0, -10.0));
|
||||
// Centred on `at` by the sheet's middle (50, −25): the rectangle sits above and left of it.
|
||||
near(inserted(json!({"size_from": "viewbox", "align": "center", "at": [50, 50]})), (10.0, 55.0, 30.0, 65.0));
|
||||
let mut s = Session::default();
|
||||
let b = s.execute("sketch.drawing_bounds", &json!({"text": ON_SHEET})).unwrap();
|
||||
assert_eq!((b["width"].as_f64(), b["sheet"]["width"].as_f64(), b["sheet"]["height"].as_f64()), (Some(20.0), Some(100.0), Some(50.0)), "{b}");
|
||||
let b = s.execute("sketch.drawing_bounds", &json!({"text": r#"<svg><rect width="20" height="10"/></svg>"#})).unwrap();
|
||||
assert!(b.get("sheet").is_none(), "no viewBox, no width: {b}");
|
||||
// No sheet to size by, a DXF, and a wrong word are refused.
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
let bare = json!({"text": r#"<svg><rect width="20" height="10"/></svg>"#, "size_from": "viewbox", "width": 50});
|
||||
assert!(s.execute("sketch.insert_svg", &bare).unwrap_err().to_string().contains("viewBox"));
|
||||
let dxf = json!({"text": "0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n10\n0\n20\n0\n11\n10\n21\n0\n0\nENDSEC\n0\nEOF\n", "size_from": "viewbox"});
|
||||
assert!(s.execute("sketch.insert_dxf", &dxf).unwrap_err().to_string().contains("DXF"));
|
||||
let word = json!({"text": ON_SHEET, "size_from": "sheet"});
|
||||
assert!(s.execute("sketch.insert_svg", &word).unwrap_err().to_string().contains("size_from"));
|
||||
}
|
||||
|
||||
/// `fixed: true` fixes every point the drawing adds, so the sketch has nothing left to move.
|
||||
#[test]
|
||||
fn an_inserted_drawing_can_come_in_fixed() {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
let r = s.execute("sketch.insert_svg", &json!({"text": PLATE, "fixed": true})).unwrap();
|
||||
assert_eq!((r["curves"].as_u64(), r["fixed"].as_u64()), (Some(4), Some(4)), "{r}");
|
||||
assert_eq!(r["created"]["constraints"].as_array().map(Vec::len), Some(4), "{r}");
|
||||
let solved = s.execute("sketch.solve", &json!({})).unwrap();
|
||||
assert_eq!(solved["dof"], json!(0), "{solved}");
|
||||
// Without it, as before: nothing fixed, four free points.
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
let r = s.execute("sketch.insert_svg", &json!({"text": PLATE})).unwrap();
|
||||
assert!(r.get("fixed").is_none() && r["created"]["constraints"].as_array().is_some_and(Vec::is_empty), "{r}");
|
||||
// A circle: its centre is fixed, its radius stays free.
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
let circle = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><circle cx="10" cy="10" r="5"/></svg>"#;
|
||||
let r = s.execute("sketch.insert_svg", &json!({"text": circle, "fixed": true})).unwrap();
|
||||
assert_eq!(r["fixed"], json!(1), "{r}");
|
||||
assert_eq!(s.execute("sketch.solve", &json!({})).unwrap()["dof"], json!(1), "the radius");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_dxf_default_is_byte_identical_to_before() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -128,7 +128,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_scale")
|
||||
.enabled(in_sketch)
|
||||
.params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"),
|
||||
.params("entities: [curve/point ids], base: [x,y] or point ref, factor: number; the driving length and radius dimensions wholly inside the selection scale too (a formula becomes `(formula) * factor`)"),
|
||||
CommandSpec::new("sketch.transform", "Transform", transform)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_move")
|
||||
@@ -141,7 +141,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_move")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool, rotate_constraints?: bool (default true; false keeps constraints unchanged); a rotation turns the constraints tied to the sketch axes when all their geometry moves (horizontal and vertical relations and dimensions swap at odd quarter turns and stay at half turns, line angles turn with the line) and removes the horizontal and vertical ones that cannot hold at other angles; picking a text outline or linked point transforms its whole text source once, and copies remain editable text; copy also accepts drawn 3D curves, preserving exact arcs and height above the sketch plane", created_doc!())),
|
||||
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool, rotate_constraints?: bool (default true; false keeps constraints unchanged); a rotation turns the constraints tied to the sketch axes when all their geometry moves (horizontal and vertical relations and dimensions swap at odd quarter turns and stay at half turns, line angles turn with the line) and removes the horizontal and vertical ones that cannot hold at other angles; picking a text outline or linked point transforms its whole text source once, and copies remain editable text; copy also accepts drawn 3D curves, preserving exact arcs and height above the sketch plane; returns `released`: the constraints tying the moved geometry to what stays that the move let go, as {id, kind}", created_doc!())),
|
||||
CommandSpec::new("sketch.mirror", "Mirror", crate::checked!("sketch.mirror", mirror))
|
||||
.contract(&MIRROR_CONTRACT)
|
||||
.output(output_schema_for::<MirrorResult>)
|
||||
@@ -3792,6 +3792,7 @@ fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let rotate_constraints = bool_(p, "rotate_constraints").unwrap_or(true);
|
||||
let selection = string_list(cmd, p, "entities")?;
|
||||
let (planar, drawn) = if copy { drawn_copy_selection(s, p, selection)? } else { (selection, Vec::new()) };
|
||||
let mut released = Vec::new();
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
|
||||
let (mut cs, mut ps) =
|
||||
if planar.is_empty() && !drawn.is_empty() { (Vec::new(), Vec::new()) } else { entities(sk, &json!({"entities": planar}), cmd)? };
|
||||
@@ -3823,16 +3824,18 @@ fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(ids);
|
||||
}
|
||||
with_ellipse_axes(sk, &mut cs);
|
||||
let held = holding(sk, &involved_points(sk, &cs, &ps));
|
||||
transform_in_place(sk, &cs, &ps, &f, 1.0, cmd)?;
|
||||
if rotate_constraints {
|
||||
let moved = involved_points(sk, &cs, &ps);
|
||||
rotate_axis_constraints(sk, doc, &moved, ang)?;
|
||||
}
|
||||
released = release_broken(sk, &held);
|
||||
cs.extend(text_curves);
|
||||
Ok(ids_of(sk, &cs))
|
||||
})?;
|
||||
s.doc_mut().prune_model_params();
|
||||
reply(cmd, MoveResult { curves: out, sketch: info, param_warnings: Vec::new() })
|
||||
reply(cmd, MoveResult { curves: out, released, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Scale, turn and move the entities in one step: scaled about `center`, then turned about it,
|
||||
@@ -3919,17 +3922,70 @@ fn scale_dimensions(sk: &mut Sketch, doc: &mut solidcraft_doc::Document, curves:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The constraints that tie the points about to move to points that stay (a corner on the
|
||||
/// origin, a distance to a line left behind) and hold now: their ids and type names. Constraints
|
||||
/// among the moved points alone (a rectangle's sides, a line's typed angle) move with them and
|
||||
/// are kept.
|
||||
fn holding(sk: &Sketch, moved: &[usize]) -> Vec<(String, &'static str)> {
|
||||
sk.constraints
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| {
|
||||
let touched = sk.constraint_points(&c.kind);
|
||||
touched.iter().any(|q| moved.contains(q)) && touched.iter().any(|q| !moved.contains(q))
|
||||
})
|
||||
.filter(|(ci, c)| solidcraft_sketch::constraint_residual(sk, *ci).is_some_and(|r| r <= broken_at(sk, &c.kind)))
|
||||
.map(|(_, c)| (c.id.clone(), c.kind.name()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// After a move: the constraints of `held` that no longer hold are let go. Kept, the solve
|
||||
/// would pull the moved geometry back to what stays and bend it (#2980). Returns every
|
||||
/// constraint of `held` the move let go, these and the origin holds the move itself dropped
|
||||
/// (#5034), as `{id, kind}` for the status line to name them.
|
||||
fn release_broken(sk: &mut Sketch, held: &[(String, &'static str)]) -> Vec<Value> {
|
||||
let broken: Vec<String> = sk
|
||||
.constraints
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| held.iter().any(|(id, _)| *id == c.id))
|
||||
.filter(|(ci, c)| solidcraft_sketch::constraint_residual(sk, *ci).is_some_and(|r| r > broken_at(sk, &c.kind)))
|
||||
.map(|(_, c)| c.id.clone())
|
||||
.collect();
|
||||
sk.constraints.retain(|c| !broken.contains(&c.id));
|
||||
held.iter().filter(|(id, _)| !sk.constraints.iter().any(|c| c.id == *id)).map(|(id, kind)| json!({"id": id, "kind": kind})).collect()
|
||||
}
|
||||
|
||||
/// A residual above this is a constraint that doesn't hold. A distance is judged against the size
|
||||
/// of the geometry the constraint itself touches, so a far-away point elsewhere in the sketch can't
|
||||
/// hide a broken one; the sine or cosine between two lines has no length to scale by.
|
||||
fn broken_at(sk: &Sketch, kind: &ConstraintKind) -> f64 {
|
||||
let direction_only = match *kind {
|
||||
ConstraintKind::Parallel { .. } | ConstraintKind::Perpendicular { .. } => true,
|
||||
ConstraintKind::Horizontal { l } | ConstraintKind::Vertical { l } => sk.curves.get(l).is_some_and(|c| c.infinite),
|
||||
_ => false,
|
||||
};
|
||||
if direction_only {
|
||||
return 1e-7;
|
||||
}
|
||||
1e-7 * sk.constraint_points(kind).iter().filter_map(|&q| sk.points.get(q)).map(|p| p.pos.len()).fold(1.0, f64::max)
|
||||
}
|
||||
|
||||
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.scale";
|
||||
let k = num(p, "factor").filter(|k| *k > 1e-9 && *k < 1e6).ok_or_else(|| bad(cmd, "`factor` must be a positive number"))?;
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
|
||||
let (mut cs, ps) = entities(sk, p, cmd)?;
|
||||
with_ellipse_axes(sk, &mut cs);
|
||||
let b = if p.get("base").is_some() { point_or_ref(sk, p, "base", cmd)? } else { Vec2::ZERO };
|
||||
let f = move |q: Vec2| b + (q - b) * k;
|
||||
transform_in_place(sk, &cs, &ps, &f, k, cmd)?;
|
||||
// The dimensions go with the geometry, else the solve pulls it back to the old sizes (#14411).
|
||||
let moved = involved_points(sk, &cs, &ps);
|
||||
scale_dimensions(sk, doc, &cs, &moved, k)?;
|
||||
Ok(ids_of(sk, &cs))
|
||||
})?;
|
||||
s.doc_mut().prune_model_params();
|
||||
reply(cmd, ScaleResult { curves: out, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
|
||||
@@ -1621,6 +1621,33 @@ fn transform_scales_dimension_formulas_once_and_keeps_unselected_dimensions() {
|
||||
assert!(close(s.doc.eval(height["param"].as_str().unwrap(), length).unwrap(), 60.0), "dependent dimensions scale only once");
|
||||
}
|
||||
|
||||
/// #14411: Scale takes the driving dimensions of what it scales with it, as Transform does: a
|
||||
/// 40 × 20 rectangle filleted r5, scaled 2× about the origin, is 80 × 40 with r10 corners, not a
|
||||
/// skewed shape the solver bent to keep the old radius.
|
||||
#[test]
|
||||
fn scale_scales_the_dimensions_it_takes_with_it() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 20], [0, 20]], "closed": true}));
|
||||
run(&mut s, "sketch.fillet", json!({"points": ["l1.end", "l2.end", "l3.end", "l4.end"], "radius": 5}));
|
||||
let curves = ["l1", "l2", "l3", "l4", "a1", "a2", "a3", "a4"];
|
||||
let radius = sketch(&s).constraints.iter().find_map(|c| c.param.clone()).unwrap();
|
||||
let out = run(&mut s, "sketch.scale", json!({"entities": curves, "base": [0, 0], "factor": 2}));
|
||||
assert_eq!(out["sketch"]["solved"], true, "{out}");
|
||||
assert!(close(s.doc.eval(&radius, solidcraft_doc::expr::Kind::Length).unwrap(), 10.0), "{:?}", s.doc.param(&radius));
|
||||
let sk = sketch(&s);
|
||||
for (id, a, b) in [("l1", [10.0, 0.0], [70.0, 0.0]), ("l2", [80.0, 10.0], [80.0, 30.0])] {
|
||||
let (p, q) = (
|
||||
sk.point(sk.resolve_point(&format!("{id}.start")).unwrap()).unwrap(),
|
||||
sk.point(sk.resolve_point(&format!("{id}.end")).unwrap()).unwrap(),
|
||||
);
|
||||
assert!(p.dist(Vec2::new(a[0], a[1])) < 1e-6 && q.dist(Vec2::new(b[0], b[1])) < 1e-6, "{id}: {p:?} {q:?}");
|
||||
}
|
||||
let area = 80.0 * 40.0 - (4.0 - std::f64::consts::PI) * 100.0;
|
||||
assert!((profiles(&s)[0] - area).abs() < 1e-6, "{:?}", profiles(&s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(close(s.doc.eval(&radius, solidcraft_doc::expr::Kind::Length).unwrap(), 5.0), "one undo restores the radius");
|
||||
}
|
||||
|
||||
/// #1485: after a circle is trimmed between two construction lines, clicking the trimmed end
|
||||
/// picks the construction line's end lying there, not the arc's own; the tangent arc still starts
|
||||
/// from the trimmed arc, tangent to it.
|
||||
@@ -5740,6 +5767,47 @@ fn split_failures_name_the_public_command() {
|
||||
assert!(!text.contains("sketch.modify"), "{text}");
|
||||
}
|
||||
|
||||
/// #2980: a rectangle drawn from the origin and a circle, all moved: they move as drawn. The
|
||||
/// corner's coincidence with the origin is let go instead of pulling that corner back and
|
||||
/// bending the rectangle; its own horizontals and verticals stay.
|
||||
#[test]
|
||||
fn moving_shapes_held_to_the_origin_moves_them_whole() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, -30]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [-5, 20], "radius": 12}));
|
||||
let sk = sketch(&s);
|
||||
let at_origin = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { .. })).map(|c| c.id.clone());
|
||||
assert!(at_origin.is_some(), "the rectangle's corner is held on the origin");
|
||||
let before: Vec<(String, Vec2)> = sk.points.iter().filter(|p| p.id != "origin").map(|p| (p.id.clone(), p.pos)).collect();
|
||||
let ids: Vec<String> = sk.curves.iter().map(|c| c.id.clone()).collect();
|
||||
let r = run(&mut s, "sketch.move", json!({"entities": ids, "translate": [15.5, 0]}));
|
||||
assert_eq!(r["released"], json!([{"id": at_origin.clone().unwrap(), "kind": "Coincident"}]), "{r}");
|
||||
let after = sketch(&s);
|
||||
for (id, p) in &before {
|
||||
let q = after.points.iter().find(|q| &q.id == id).map(|q| q.pos).unwrap();
|
||||
assert!(q.dist(*p + Vec2::new(15.5, 0.0)) < 1e-9, "{id} went from {p:?} to {q:?}");
|
||||
}
|
||||
assert_eq!(after.constraints.len(), sk.constraints.len() - 1, "only the origin's coincidence is let go");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(sketch(&s).constraints.len(), sk.constraints.len(), "undo brings it back");
|
||||
}
|
||||
|
||||
/// A point far from the move says nothing about how exactly the moved line has to stay level with
|
||||
/// the line that stays: a short move still breaks that relation, so it is still let go.
|
||||
#[test]
|
||||
fn a_far_away_point_does_not_keep_a_constraint_the_move_broke() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let stays = ids(&run(&mut s, "sketch.line", json!({"points": [[5, 5], [15, 5]], "connect": false}))["curves"])[0].clone();
|
||||
let moved = ids(&run(&mut s, "sketch.line", json!({"points": [[30, 5], [30, 20]], "connect": false}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"points": [format!("{moved}.start"), format!("{stays}.end")], "mode": "horizontal"}));
|
||||
let level = sketch(&s).constraints.iter().find(|c| matches!(c.kind, ConstraintKind::HorizontalPoints { .. })).map(|c| c.id.clone()).unwrap();
|
||||
run(&mut s, "sketch.point", json!({"point": [1e8, 0], "id": "far"}));
|
||||
let r = run(&mut s, "sketch.move", json!({"entities": [moved], "translate": [0, 1]}));
|
||||
assert_eq!(r["released"], json!([{"id": level, "kind": "Horizontal"}]), "{r}");
|
||||
}
|
||||
|
||||
/// #12294: a spline offset together with a line is trimmed and unlinked, so a distance that names a
|
||||
/// parameter drives nothing. The reply says so; a linked offset of the same parameter does not.
|
||||
#[test]
|
||||
|
||||
@@ -2504,7 +2504,9 @@ fn editing_a_mid_timeline_sketch_rebuilds_later_features_once_at_finish() {
|
||||
run(&mut s, "sketch.edit", json!({"sketch": id, "restore_marker": false}));
|
||||
assert_eq!(s.doc.marker, None);
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 20], [10, 20]]}));
|
||||
assert!(s.model.last_recomputed > 1, "in place, the later features are rebuilt");
|
||||
// Rebuilt, or made again from what they changed when what they read is as it was (#6183).
|
||||
let evaluated = s.model.last_recomputed + s.model.last_restored;
|
||||
assert!(evaluated > 1, "in place, the later features are evaluated: {evaluated}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(s.doc.marker, None);
|
||||
assert!(s.execute("sketch.edit", &json!({"sketch": id, "restore_marker": true})).is_err());
|
||||
|
||||
@@ -83,7 +83,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("surface_sweep")
|
||||
.contract(&SURFACE_SWEEP_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); orientation?, twist?, scale?, rails?, rail_fit? (as solid.sweep); name?, body_name? (a surface body, no caps)"),
|
||||
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); path_body?: body name; path_edges?: [points on its edges] (the path is a chain of that body's edges, instead of a sketch's curves); orientation?, twist?, scale?, rails?, rail_fit? (as solid.sweep); name?, body_name? (a surface body, no caps)"),
|
||||
CommandSpec::new("surface.loft", "Surface Loft", crate::checked!("surface.loft", surface_loft))
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_loft")
|
||||
@@ -424,13 +424,12 @@ fn surface_sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "surface.sweep";
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let (profiles, curves) = sheet_inputs(p, cmd)?;
|
||||
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
|
||||
let path = string_list(cmd, p, "path")?;
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
let (path_sketch, path, path_body, path_edges) = super::features::sweep_path(s, p, cmd)?;
|
||||
if path_body.is_none() && (path.is_empty() || path.len() > 1000) {
|
||||
return Err(bad(cmd, "`path` must list 1…1000 curve ids"));
|
||||
}
|
||||
let (orientation, twist, scale) = super::features::sweep_options(s, p, cmd)?;
|
||||
let (rails, rail_fit) = super::features::sweep_rails(s, p, cmd, path_sketch)?;
|
||||
let (rails, rail_fit) = super::features::sweep_rails(s, p, cmd, path_body.is_none().then_some(path_sketch))?;
|
||||
add_feature(
|
||||
s,
|
||||
p,
|
||||
@@ -440,6 +439,8 @@ fn surface_sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
curves,
|
||||
path_sketch: SketchId(path_sketch),
|
||||
path,
|
||||
path_body,
|
||||
path_edges,
|
||||
orientation,
|
||||
twist,
|
||||
scale,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
use std::f64::consts::{FRAC_1_SQRT_2, PI};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -193,6 +193,66 @@ fn a_sweep_round_a_closed_path_starts_where_the_profile_is() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2216: a sweep whose path is a closed loop of a body's edges is followed from the profile,
|
||||
/// like a closed sketch path: the tube stays on the loop, within the profile's half width of
|
||||
/// it, instead of standing off it by the distance between the profile and the vertex the
|
||||
/// first picked edge happened to be on. A body's top face is a closed loop of eight edges; a
|
||||
/// 4 x 3 profile at the middle of one of them, square to it, swept round the whole loop.
|
||||
#[test]
|
||||
fn a_sweep_round_a_closed_body_edge_path_starts_where_the_profile_is() {
|
||||
let mut s = Session::default();
|
||||
// A rounded rectangle 60 x 40, corners r8, extruded 10: its top face is a closed loop of
|
||||
// eight edges, smooth all round.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Loop"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-22, -20], [22, -20]], "infer": false, "ids": ["l1"]}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [22, -12], "start": [22, -20], "end": [30, -12], "id": "a1"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[30, -12], [30, 12]], "infer": false, "ids": ["l2"]}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [22, 12], "start": [30, 12], "end": [22, 20], "id": "a2"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[22, 20], [-22, 20]], "infer": false, "ids": ["l3"]}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [-22, 12], "start": [-22, 20], "end": [-30, 12], "id": "a3"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-30, 12], [-30, -12]], "infer": false, "ids": ["l4"]}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [-22, -12], "start": [-30, -12], "end": [-22, -20], "id": "a4"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Loop", "distance": 10, "operation": "new", "body_name": "Slab"}));
|
||||
// A 4 x 3 profile at the middle of the loop's bottom edge, square to it.
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, -20, 10], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}, "name": "Prof"}));
|
||||
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [2, 1.5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// The path: the loop's eight edges, one point on each, in order round it.
|
||||
let c = 8.0 * FRAC_1_SQRT_2;
|
||||
run(
|
||||
&mut s,
|
||||
"solid.sweep",
|
||||
json!({
|
||||
"sketch": "Prof",
|
||||
"path_body": "Slab",
|
||||
"path_edges": [
|
||||
[-11.0, -20.0, 10.0],
|
||||
[22.0 + c, -12.0 - c, 10.0],
|
||||
[30.0, 0.0, 10.0],
|
||||
[22.0 + c, 12.0 + c, 10.0],
|
||||
[-11.0, 20.0, 10.0],
|
||||
[-22.0 - c, 12.0 + c, 10.0],
|
||||
[-30.0, 0.0, 10.0],
|
||||
[-22.0 - c, -12.0 - c, 10.0]
|
||||
],
|
||||
"operation": "new",
|
||||
"body_name": "Tube"
|
||||
}),
|
||||
);
|
||||
let m = measure(&s, "Tube");
|
||||
// Pappus: the profile's area (4 x 3) times the loop's length.
|
||||
let want = 12.0 * (2.0 * 44.0 + 2.0 * 24.0 + 4.0 * (PI / 2.0) * 8.0);
|
||||
assert!((m.volume - want).abs() < want * 1e-3, "{} vs {want}", m.volume);
|
||||
// The tube stays on the loop: within the profile's half width (2) of it, the loop's own
|
||||
// bounds (-30..30 by -20..20) grown by it.
|
||||
for (got, want) in
|
||||
[(m.bbox.min.x, -32.0), (m.bbox.max.x, 32.0), (m.bbox.min.y, -22.0), (m.bbox.max.y, 22.0), (m.bbox.min.z, 8.5), (m.bbox.max.z, 11.5)]
|
||||
{
|
||||
assert!((got - want).abs() < 0.08, "{:?}", m.bbox);
|
||||
}
|
||||
}
|
||||
|
||||
/// #3604: a path whose first curve is a spline of several exact pieces, the next curve meeting
|
||||
/// it at its start, follows the spline back from its end: a curve is turned round whole, not
|
||||
/// piece by piece. A 2 x 2 profile at the spline's end (20, 0), swept along the spline through
|
||||
|
||||
@@ -1077,3 +1077,24 @@ fn plane_axis_and_point_refuse_references_no_method_takes() {
|
||||
assert_eq!(*s.doc, before, "{id} {p}: a refusal changes nothing");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12022: a plane made of picks, made in a moved component, goes through its picks in the
|
||||
/// world, whichever constructor makes it (Plane Through Axis and Point left its point unmapped).
|
||||
#[test]
|
||||
fn planes_made_of_picks_in_a_moved_component_go_through_their_world_picks() {
|
||||
let cases = [
|
||||
("construct.plane.axis_point", json!({"axis":{"axis":"Mast"},"point":{"point":[60,5,7]},"name":"F"})),
|
||||
("construct.plane.three_points", json!({"a":{"point":[50,0,0]},"b":{"point":[50,0,10]},"c":{"point":[60,5,7]},"name":"F"})),
|
||||
];
|
||||
for (cmd, params) in cases {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name":"Part","grounded":false}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":"Part:1","translate":[50,0,3]}));
|
||||
run(&mut s, "construct.axis.two_points", json!({"a":{"point":[50,0,0]},"b":{"point":[50,0,10]},"name":"Mast"}));
|
||||
run(&mut s, cmd, params);
|
||||
let pl = plane(&s, "F");
|
||||
for p in [Vec3::new(50.0, 0.0, 0.0), Vec3::new(50.0, 0.0, 10.0), Vec3::new(60.0, 5.0, 7.0)] {
|
||||
assert!(on_plane(&pl, p), "{cmd}: {p:?} on {pl:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-10
@@ -52,6 +52,11 @@ use K::*;
|
||||
/// What `timeline.redefine` maps for a surface revolve, which no command is called.
|
||||
const SURFACE_REVOLVE_EDIT: &str = "surface.revolve (edit)";
|
||||
|
||||
/// The construction planes made of picks alone (`cmd::picked_plane`): their references are
|
||||
/// parsed and mapped as a whole, saved forms included, rather than key by key (#12022).
|
||||
const PICKED_PLANES: [&str; 4] =
|
||||
["construct.plane.axis_point", "construct.plane.two_edges", "construct.plane.three_points", "construct.plane.perpendicular"];
|
||||
|
||||
/// The parameters each command takes in world coordinates.
|
||||
fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
match id {
|
||||
@@ -474,7 +479,7 @@ impl Nested {
|
||||
});
|
||||
if outer {
|
||||
let inner = if id == "timeline.redefine" { None } else { Some(id) };
|
||||
MAPPED.with(|m| m.set(inner.is_none_or(|i| !table(i).is_empty() || i == "construct.plane.axis_point")));
|
||||
MAPPED.with(|m| m.set(inner.is_none_or(|i| !table(i).is_empty())));
|
||||
OUTER_MOVE.with(|m| m.set(id == "solid.move"));
|
||||
}
|
||||
(Nested, outer)
|
||||
@@ -660,18 +665,19 @@ fn map_local(s: &Session, world: &solidcraft_doc::ModelState, id: &str, params:
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if id == "construct.plane.axis_point" && placed(s) {
|
||||
// Construction references include tagged saved forms as well as pick wrappers.
|
||||
// Map the parsed definition once at the boundary, including a nested Edit Feature.
|
||||
if PICKED_PLANES.contains(&id) && placed(s) {
|
||||
// An Edit Feature (`timeline.redefine`) of such a plane: its references include tagged
|
||||
// saved forms as well as pick wrappers, so the parsed definition is mapped once here.
|
||||
// The constructor can then name local model picks and retain their exact references.
|
||||
let mut kind = crate::cmd::plane_axis_point_kind(s, params).ok()?;
|
||||
// (Run directly, the plane goes into the active frame as a whole: `to_active_frame`.)
|
||||
let plane = crate::cmd::picked_plane(s, id, params).ok()??;
|
||||
let mut kind = solidcraft_doc::FeatureKind::ConstructionPlane { plane };
|
||||
kind.transform_geometry(&mat_inverse(&s.active_component_frame())?);
|
||||
let solidcraft_doc::FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::AxisPoint { axis, point } } = kind else {
|
||||
return None;
|
||||
};
|
||||
let solidcraft_doc::FeatureKind::ConstructionPlane { plane } = &kind else { return None };
|
||||
let mut out = params.clone();
|
||||
out["axis"] = serde_json::to_value(axis).ok()?;
|
||||
out["point"] = serde_json::to_value(point).ok()?;
|
||||
for (key, reference) in crate::cmd::picked_plane_refs(plane)?.1 {
|
||||
out[key] = serde_json::to_value(reference).ok()?;
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
let keys = table(id);
|
||||
|
||||
@@ -52,6 +52,33 @@ pub fn extract(s: &Session) -> Result<ProductScene> {
|
||||
Ok(scene)
|
||||
}
|
||||
|
||||
/// For each of `scene`'s meshes, the shared mesh it is drawn from and the placement that moves
|
||||
/// it there (column-major, as the document's placements): a part placed by several occurrences
|
||||
/// is the same definition mesh moved, and any other body is its own mesh, unmoved. A renderer
|
||||
/// that draws one copy of the triangles at many placements can use it; the scene's own
|
||||
/// world-space meshes stay authoritative. `None` for a mesh that is not, or no longer, the
|
||||
/// body's mesh.
|
||||
pub fn placements(s: &Session, scene: &ProductScene) -> Vec<Option<(std::sync::Arc<crate::geom::Mesh>, crate::doc::Mat)>> {
|
||||
// A model that cannot be placed has no bodies whose meshes the scene could share.
|
||||
let Ok(world) = s.world_state() else {
|
||||
return scene.meshes.iter().map(|_| None).collect();
|
||||
};
|
||||
let bodies: std::collections::HashMap<&str, &crate::doc::ModelBody> = world.bodies.iter().map(|b| (b.name.as_str(), b)).collect();
|
||||
scene
|
||||
.meshes
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let body = bodies.get(m.name.as_str())?;
|
||||
// Only the mesh this body has (made once and kept): a scene from another state
|
||||
// names other meshes.
|
||||
if !body.cached_mesh().is_some_and(|mesh| std::sync::Arc::ptr_eq(&mesh, &m.mesh)) {
|
||||
return None;
|
||||
}
|
||||
Some(body.mesh_in_place())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -59,9 +86,41 @@ mod tests {
|
||||
use crate::render::*;
|
||||
use serde_json::json;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::Vec3Geom;
|
||||
use solidcraft_viewport::{Camera, UpAxis};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A part placed twice is two product meshes moved from one shared mesh; a body of the root
|
||||
/// is its own mesh, unmoved, and a mesh from another state names nothing.
|
||||
#[test]
|
||||
fn placements_name_the_shared_mesh_of_placed_copies() {
|
||||
let mut s = Session::default();
|
||||
let part = s.execute("component.create", &json!({"name":"Part","grounded":false})).unwrap();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Block"})).unwrap();
|
||||
s.execute("component.activate", &json!({"component":0})).unwrap();
|
||||
s.execute("occurrence.copy", &json!({"component":part["component"],"translate":[30,0,0]})).unwrap();
|
||||
s.execute("occurrence.move", &json!({"occurrence":part["occurrence"],"translate":[10,0,0]})).unwrap();
|
||||
s.execute("solid.box", &json!({"length":5,"width":5,"height":5,"corner":[0,40,0],"body_name":"Root"})).unwrap();
|
||||
let scene = extract(&s).unwrap();
|
||||
let placed = placements(&s, &scene);
|
||||
assert_eq!(placed.len(), scene.meshes.len());
|
||||
let copies: Vec<_> = scene.meshes.iter().zip(&placed).filter(|(m, _)| !m.name.starts_with("Root")).collect();
|
||||
assert_eq!(copies.len(), 2);
|
||||
let sources: Vec<_> = copies.iter().map(|(_, p)| p.as_ref().map(|(source, _)| source.clone()).expect("a placed copy")).collect();
|
||||
assert!(Arc::ptr_eq(&sources[0], &sources[1]), "both placements share one mesh");
|
||||
for (m, p) in &copies {
|
||||
let (source, transform) = p.as_ref().unwrap();
|
||||
let moved = crate::doc::apply_point(transform, source.positions[0]);
|
||||
assert!(moved.dist(m.mesh.positions[0]) < 1e-9, "the shared mesh moved is the product mesh");
|
||||
}
|
||||
let root = scene.meshes.iter().position(|m| m.name.starts_with("Root")).unwrap();
|
||||
let (own, unmoved) = placed[root].as_ref().unwrap();
|
||||
assert!(Arc::ptr_eq(own, &scene.meshes[root].mesh) && crate::doc::is_identity(unmoved), "a root body is its own mesh, unmoved");
|
||||
let mut other = scene.clone();
|
||||
other.meshes[0].mesh = Arc::new((*other.meshes[0].mesh).clone());
|
||||
assert!(placements(&s, &other)[0].is_none(), "another mesh under the same name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn product_scene_keeps_placed_appearances_and_meshes_without_design_overlays() {
|
||||
let mut s = Session::default();
|
||||
@@ -179,7 +238,7 @@ mod tests {
|
||||
let body = crate::kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0))
|
||||
.unwrap()
|
||||
.with_color(Some([0.2, 0.6, 0.8]))
|
||||
.with_paint(Some(crate::kernel::Paint { opacity: 0.4, faces: Vec::new() }));
|
||||
.with_paint(Some(crate::kernel::Paint { opacity: 0.4, faces: Vec::new(), name: None }));
|
||||
let step = crate::kernel::step_export_bodies(
|
||||
&[crate::kernel::ExportBody { name: "Imported glass".into(), body: &body, color: body.color() }],
|
||||
&Default::default(),
|
||||
|
||||
@@ -15,7 +15,7 @@ use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use solidcraft_doc::config::{MAX_COLUMNS, MAX_RULES};
|
||||
use solidcraft_doc::config::{MAX_COLUMNS, MAX_RULES, RowKey};
|
||||
|
||||
use super::check::whole;
|
||||
use crate::params::{self, MAX_STRING_LIST, json_brief};
|
||||
@@ -32,19 +32,46 @@ pub struct ConfigTableParams {}
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ConfigRowParams {
|
||||
/// The row, by name.
|
||||
/// The row, by name or by id.
|
||||
#[serde(deserialize_with = "row")]
|
||||
pub row: String,
|
||||
pub row: RowRef,
|
||||
}
|
||||
|
||||
/// The row's name, with the handler's message for anything that isn't text.
|
||||
fn row<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Err(D::Error::custom(whole("`row` is required"))),
|
||||
/// A configuration row, as the commands name one: its name (text) or its fixed id (a whole
|
||||
/// number, as `config.table` lists it). A row named `"17"` is not row 17.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum RowRef {
|
||||
Name(String),
|
||||
Id(u64),
|
||||
}
|
||||
|
||||
impl RowRef {
|
||||
/// As the handlers read it: [`RowKey::read`], the one rule.
|
||||
fn read(v: &Value) -> Option<Self> {
|
||||
RowKey::read(v).map(|key| match key {
|
||||
RowKey::Id(id) => RowRef::Id(id),
|
||||
RowKey::Name(name) => RowRef::Name(name.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for RowRef {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"RowRef".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A configuration row: its name, or its id as `config.table` lists it.",
|
||||
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0}]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The row, by name or id, with the handler's message for anything else.
|
||||
fn row<'de, D: Deserializer<'de>>(d: D) -> Result<RowRef, D::Error> {
|
||||
RowRef::read(&Value::deserialize(d)?).ok_or_else(|| D::Error::custom(whole("`row` is required")))
|
||||
}
|
||||
|
||||
/// Add columns to the configuration table: parameters, feature suppressions, and bodies' and
|
||||
/// components' materials and looks, each filled in every row with the design's current value. A
|
||||
/// column the table has is left as it is. With no rows yet, a "Default" row takes the current
|
||||
@@ -119,15 +146,16 @@ pub struct ConfigColumnParams {
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ConfigRowEditParams {
|
||||
/// The row, by name: 1 to 128 bytes once spaces at either end are dropped. A name the table
|
||||
/// doesn't have adds a row (at most 2000).
|
||||
/// The row, by name (1 to 128 bytes once spaces at either end are dropped) or by id. A name the
|
||||
/// table doesn't have adds a row (at most 2000); an id must be one of its rows.
|
||||
#[serde(deserialize_with = "row_name")]
|
||||
#[schemars(length(min = 1, max = 128))]
|
||||
pub name: String,
|
||||
/// A row to copy the cells of, by name. Default: the row's own cells, or the design's current
|
||||
/// values for a new row.
|
||||
#[serde(default)]
|
||||
pub from: Option<String>,
|
||||
#[schemars(schema_with = "row_name_schema")]
|
||||
pub name: RowRef,
|
||||
/// A row to copy the cells of, by name or id. Default: the row's own cells, or the design's
|
||||
/// current values for a new row.
|
||||
#[serde(default, deserialize_with = "from_row", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Option<RowRef>")]
|
||||
pub from: Option<RowRef>,
|
||||
#[serde(default, deserialize_with = "row_values", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "row_values_schema")]
|
||||
pub values: Option<Map<String, Value>>,
|
||||
@@ -349,12 +377,28 @@ fn exactly_one_target(schema: &mut Schema) {
|
||||
schema.insert("oneOf".into(), Value::Array(one));
|
||||
}
|
||||
|
||||
/// A row's name as `config.row` reads `name`: text that, without spaces at either end, is 1 to
|
||||
/// A row as `config.row` reads `name`: an id, or text that, without spaces at either end, is 1 to
|
||||
/// 128 bytes, with the handler's message for anything else.
|
||||
fn row_name<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
fn row_name<'de, D: Deserializer<'de>>(d: D) -> Result<RowRef, D::Error> {
|
||||
match RowRef::read(&Value::deserialize(d)?) {
|
||||
Some(RowRef::Name(name)) if !is_row_name(&name) => Err(D::Error::custom(whole("`name` is required"))),
|
||||
Some(row) => Ok(row),
|
||||
None => Err(D::Error::custom(whole("`name` is required"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_name_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A row: its name, or its id as `config.table` lists it.",
|
||||
"anyOf": [{"type": "string", "minLength": 1, "maxLength": 128}, {"type": "integer", "minimum": 0}]
|
||||
})
|
||||
}
|
||||
|
||||
/// `from`: a row by name or id; `null` is left out, as the handler reads it.
|
||||
fn from_row<'de, D: Deserializer<'de>>(d: D) -> Result<Option<RowRef>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(name) if is_row_name(&name) => Ok(name),
|
||||
_ => Err(D::Error::custom(whole("`name` is required"))),
|
||||
Value::Null => Ok(None),
|
||||
v => RowRef::read(&v).map(Some).ok_or_else(|| D::Error::custom(whole("`from` must be a row's name or id"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +578,8 @@ pub struct ConfigTarget {
|
||||
/// One row of the table.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ConfigTableRow {
|
||||
/// Its fixed id: a rename keeps it.
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// Its cell for each column, by column key.
|
||||
pub values: BTreeMap<String, String>,
|
||||
|
||||
@@ -43,6 +43,8 @@ fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("config.activate", json!({})),
|
||||
("config.activate", json!({"row": 5})),
|
||||
("config.activate", json!({"row": -1})),
|
||||
("config.activate", json!({"row": 1.5})),
|
||||
("config.activate", json!({"row": "Nope"})),
|
||||
("config.configure", json!({"params": 5})),
|
||||
("config.configure", json!({"params": ["W", 1]})),
|
||||
@@ -78,6 +80,9 @@ fn strict_keeps_todays_errors() {
|
||||
("config.row", json!({"name": "R", "values": {"param:W": "1".repeat(4097)}})),
|
||||
("config.row", json!({"name": "R", "values": {"param:W": "1"}})),
|
||||
("config.row", json!({"name": "R", "from": "Nope"})),
|
||||
("config.row", json!({"name": "R", "from": 99_999})),
|
||||
("config.row", json!({"name": "R", "from": true})),
|
||||
("config.row", json!({"name": 99_999, "delete": true})),
|
||||
("config.row", json!({"name": "R", "rename": null})),
|
||||
("config.row", json!({"name": "R", "rename": "S"})),
|
||||
("config.row", json!({"name": "R", "delete": true})),
|
||||
|
||||
@@ -453,10 +453,21 @@ pub struct SweepParams {
|
||||
/// whole drawn profile. The dialogs send `true`: they highlight the regions they pick.
|
||||
#[serde(default)]
|
||||
pub exact_regions: Option<bool>,
|
||||
/// The sketch holding the path, by id or name.
|
||||
pub path_sketch: SketchRef,
|
||||
/// The path's curve ids, in order along the path.
|
||||
pub path: StringList,
|
||||
/// The sketch holding the path, by id or name. Not needed when the path is a body's edges.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SketchRef")]
|
||||
pub path_sketch: Option<SketchRef>,
|
||||
/// The path's curve ids, in order along the path. Not needed when the path is a body's edges.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "StringList")]
|
||||
pub path: Option<StringList>,
|
||||
/// The name of the body whose edges are the path, instead of a sketch's curves.
|
||||
#[serde(default, deserialize_with = "path_body_opt", skip_serializing_if = "Option::is_none")]
|
||||
pub path_body: Option<String>,
|
||||
/// The points on that body's edges that are the path, 1 to 1000.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "PathEdgePoints")]
|
||||
pub path_edges: Option<PathEdgePoints>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub shape: SweepShape,
|
||||
/// How the sweep combines with existing bodies. Default: new.
|
||||
@@ -499,10 +510,22 @@ pub struct SurfaceSweepParams {
|
||||
/// whole drawn profile. The dialogs send `true`: they highlight the regions they pick.
|
||||
#[serde(default)]
|
||||
pub exact_regions: Option<bool>,
|
||||
/// The sketch holding the path, by id or name.
|
||||
pub path_sketch: SketchRef,
|
||||
/// The path's curve ids, in order along the path: 1 to 1000.
|
||||
pub path: StringList,
|
||||
/// The sketch holding the path, by id or name. Not needed when the path is a body's edges.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SketchRef")]
|
||||
pub path_sketch: Option<SketchRef>,
|
||||
/// The path's curve ids, in order along the path: 1 to 1000. Not needed when the path is a
|
||||
/// body's edges.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "StringList")]
|
||||
pub path: Option<StringList>,
|
||||
/// The name of the body whose edges are the path, instead of a sketch's curves.
|
||||
#[serde(default, deserialize_with = "path_body_opt", skip_serializing_if = "Option::is_none")]
|
||||
pub path_body: Option<String>,
|
||||
/// The points on that body's edges that are the path, 1 to 1000.
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "PathEdgePoints")]
|
||||
pub path_edges: Option<PathEdgePoints>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub shape: SweepShape,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
@@ -620,6 +643,53 @@ fn target_body<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `path_body`: the name of the body whose edges are the path, as the handler's `sweep_path`
|
||||
/// reads it: a body name, or `null` (which the handler reads as absent). Anything else is
|
||||
/// refused with the handler's message.
|
||||
fn path_body_opt<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(name) => Ok(Some(name)),
|
||||
_ => Err(D::Error::custom(whole("`path_body` must be a body name"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `path_edges`: the points on the edges of the path, as the handler's `path_edge_points` reads
|
||||
/// them: 1 to 1000 points, each `[x, y, z]` or `{x, y, z}` of finite numbers.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PathEdgePoints(pub Vec<Point3>);
|
||||
|
||||
impl<'de> Deserialize<'de> for PathEdgePoints {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let items = v.as_array().ok_or_else(|| D::Error::custom(whole("`path_edges` must be a list of points")))?;
|
||||
if items.is_empty() || items.len() > 1000 {
|
||||
return Err(D::Error::custom(whole("the path needs 1…1000 edges")));
|
||||
}
|
||||
items
|
||||
.iter()
|
||||
.map(|item| Point3::deserialize(item).map_err(|_| D::Error::custom(whole("each `path_edges` entry must be a point [x,y,z]"))))
|
||||
.collect::<Result<_, _>>()
|
||||
.map(PathEdgePoints)
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for PathEdgePoints {
|
||||
fn schema_name() -> std::borrow::Cow<'static, str> {
|
||||
"PathEdgePoints".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let item = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "The points on the body's edges that are the path, one on each edge of the chain: 1 to 1000.",
|
||||
"type": "array",
|
||||
"items": item,
|
||||
"minItems": 1,
|
||||
"maxItems": 1000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The handler's message for no tools.
|
||||
const NO_TOOLS: &str = "`tools` must list bodies";
|
||||
|
||||
|
||||
@@ -383,6 +383,8 @@ fn sweep_schemas_and_the_key_check_agree() {
|
||||
"operation",
|
||||
"participants",
|
||||
"path",
|
||||
"path_body",
|
||||
"path_edges",
|
||||
"path_sketch",
|
||||
"profiles",
|
||||
"sketch",
|
||||
@@ -392,12 +394,79 @@ fn sweep_schemas_and_the_key_check_agree() {
|
||||
solid.extend(shape);
|
||||
solid.sort_unstable();
|
||||
assert_keys(input_schema_for::<SweepParams>(), &solid);
|
||||
let mut surface = vec!["body_name", "body_names", "curves", "exact_regions", "name", "participants", "path", "path_sketch", "profiles", "sketch"];
|
||||
let mut surface = vec![
|
||||
"body_name",
|
||||
"body_names",
|
||||
"curves",
|
||||
"exact_regions",
|
||||
"name",
|
||||
"participants",
|
||||
"path",
|
||||
"path_body",
|
||||
"path_edges",
|
||||
"path_sketch",
|
||||
"profiles",
|
||||
"sketch",
|
||||
];
|
||||
surface.extend(shape);
|
||||
surface.sort_unstable();
|
||||
assert_keys(input_schema_for::<SurfaceSweepParams>(), &surface);
|
||||
}
|
||||
|
||||
/// #2216: a sweep's path may be a chain of a body's edges (`path_body` and `path_edges`), so
|
||||
/// the contracts take those keys: a call with them is not refused, and a key that is not one
|
||||
/// of the command's still is.
|
||||
#[test]
|
||||
fn sweep_path_on_a_body_is_accepted_and_unknown_keys_are_not() {
|
||||
let p = json!({"path_body": "Body1", "path_edges": [[0, 0, 0], [10, 0, 0]]});
|
||||
let solid = crate::schema::TypedContract::<SweepParams>::new();
|
||||
let surface = crate::schema::TypedContract::<SurfaceSweepParams>::new();
|
||||
assert!(crate::schema::Contract::check(&solid, &p).is_empty(), "{p}");
|
||||
assert!(crate::schema::Contract::check(&surface, &p).is_empty(), "{p}");
|
||||
// A key that is not one of the command's is still refused.
|
||||
let typo = json!({"path_body": "Body1", "path_edges": [[0, 0, 0]], "path_boddy": "Body1"});
|
||||
for issues in [crate::schema::Contract::check(&solid, &typo), crate::schema::Contract::check(&surface, &typo)] {
|
||||
assert_eq!(issues.len(), 1, "{typo}");
|
||||
assert_eq!(issues[0].path, "path_boddy", "{typo}");
|
||||
assert_eq!(issues[0].message, "unknown key `path_boddy`; did you mean `path_body`?", "{typo}");
|
||||
}
|
||||
// `path_body` is a body name, or `null` (which the handler reads as absent).
|
||||
for p in [json!({"path_body": null}), json!({"path_body": "Body1", "path_edges": [[0, 0, 0]]})] {
|
||||
assert!(crate::schema::Contract::check(&solid, &p).is_empty(), "{p}");
|
||||
assert!(crate::schema::Contract::check(&surface, &p).is_empty(), "{p}");
|
||||
}
|
||||
for p in [json!({"path_body": 5}), json!({"path_body": true}), json!({"path_body": []})] {
|
||||
for issues in [crate::schema::Contract::check(&solid, &p), crate::schema::Contract::check(&surface, &p)] {
|
||||
assert_eq!(issues.len(), 1, "{p}");
|
||||
assert_eq!(issues[0].path, "path_body", "{p}");
|
||||
assert_eq!(issues[0].message, "`path_body` must be a body name", "{p}");
|
||||
}
|
||||
}
|
||||
// `path_edges` is a list of 1 to 1000 points, as the handler's `path_edge_points` reads them.
|
||||
for (p, message) in [
|
||||
(json!({"path_edges": 5}), "`path_edges` must be a list of points"),
|
||||
(json!({"path_edges": []}), "the path needs 1…1000 edges"),
|
||||
(json!({"path_edges": [[0, 0, 0], [1]]}), "each `path_edges` entry must be a point [x,y,z]"),
|
||||
] {
|
||||
for issues in [crate::schema::Contract::check(&solid, &p), crate::schema::Contract::check(&surface, &p)] {
|
||||
assert_eq!(issues.len(), 1, "{p}");
|
||||
assert_eq!(issues[0].path, "path_edges", "{p}");
|
||||
assert_eq!(issues[0].message, message, "{p}");
|
||||
}
|
||||
}
|
||||
// The schemas the commands publish list both keys.
|
||||
for schema in [input_schema_for::<SweepParams>(), input_schema_for::<SurfaceSweepParams>()] {
|
||||
let schema = schema.as_value();
|
||||
assert_eq!(schema["properties"]["path_body"]["type"], json!(["string", "null"]), "{schema}");
|
||||
// `path_edges` is published as a `$ref` to its own definition, as `path` is.
|
||||
let name = schema["properties"]["path_edges"]["$ref"].as_str().unwrap_or_default().strip_prefix("#/$defs/").unwrap_or_default().to_string();
|
||||
let edges = &schema["$defs"][name.clone()];
|
||||
assert_eq!(edges["type"], "array", "{schema}");
|
||||
assert_eq!(edges["minItems"], 1, "{schema}");
|
||||
assert_eq!(edges["maxItems"], 1000, "{schema}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The calls both sweeps refuse today, as `sweep` and `surface_sweep` read them, each run on
|
||||
/// `sketch_rectangle`'s sketch (`Sketch1`, which is the path sketch too: the path's curves are
|
||||
/// only looked up when the feature is evaluated).
|
||||
|
||||
@@ -342,6 +342,98 @@ pub struct DocumentInspectParams {
|
||||
/// Give each sketch also its plane and its number of points and constraints. Default: false.
|
||||
#[serde(default)]
|
||||
pub sketch_counts: Option<bool>,
|
||||
/// How much of the timeline to give: true (the default) each feature with its state and
|
||||
/// timing; `"summary"` each as its id, name and type, with `error`, `warning`, `suppressed`
|
||||
/// and `rolled_back` only when it has them (a reply a large design keeps small); false leaves
|
||||
/// `timeline` out.
|
||||
#[serde(default)]
|
||||
pub timeline: Option<TimelineDetail>,
|
||||
/// How much of each body to give: true (the default) as `measure` says; `"names"` only their
|
||||
/// names, nothing measured; false leaves `bodies` out.
|
||||
#[serde(default)]
|
||||
pub bodies: Option<BodiesDetail>,
|
||||
}
|
||||
|
||||
/// `document.inspect`'s `timeline`: the whole timeline, a summary of it, or none.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TimelineDetail {
|
||||
Full,
|
||||
Summary,
|
||||
Left,
|
||||
}
|
||||
|
||||
const TIMELINE_FORMS: &str = "`timeline` must be true, false or \"summary\"";
|
||||
|
||||
impl TimelineDetail {
|
||||
/// `timeline` as the handler reads it: absent or `null` is the whole timeline.
|
||||
pub fn read(v: Option<&Value>) -> Result<Self, &'static str> {
|
||||
match v {
|
||||
None | Some(Value::Null) | Some(Value::Bool(true)) => Ok(TimelineDetail::Full),
|
||||
Some(Value::Bool(false)) => Ok(TimelineDetail::Left),
|
||||
Some(Value::String(s)) if s == "summary" => Ok(TimelineDetail::Summary),
|
||||
Some(_) => Err(TIMELINE_FORMS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TimelineDetail {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
TimelineDetail::read(Some(&v)).map_err(|e| D::Error::custom(whole(e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for TimelineDetail {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"TimelineDetail".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "true: every feature with its state and timing; \"summary\": id, name and type, with error, warning, suppressed and rolled_back only when set; false: no timeline.",
|
||||
"anyOf": [{"type": "boolean"}, {"type": "string", "enum": ["summary"]}]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `document.inspect`'s `bodies`: each body in full, only the names, or none.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BodiesDetail {
|
||||
Full,
|
||||
Names,
|
||||
Left,
|
||||
}
|
||||
|
||||
const BODIES_FORMS: &str = "`bodies` must be true, false or \"names\"";
|
||||
|
||||
impl BodiesDetail {
|
||||
/// `bodies` as the handler reads it: absent or `null` is every body in full.
|
||||
pub fn read(v: Option<&Value>) -> Result<Self, &'static str> {
|
||||
match v {
|
||||
None | Some(Value::Null) | Some(Value::Bool(true)) => Ok(BodiesDetail::Full),
|
||||
Some(Value::Bool(false)) => Ok(BodiesDetail::Left),
|
||||
Some(Value::String(s)) if s == "names" => Ok(BodiesDetail::Names),
|
||||
Some(_) => Err(BODIES_FORMS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for BodiesDetail {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
BodiesDetail::read(Some(&v)).map_err(|e| D::Error::custom(whole(e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for BodiesDetail {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"BodiesDetail".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "true: each body as `measure` says; \"names\": only the body names, nothing measured; false: no bodies.",
|
||||
"anyOf": [{"type": "boolean"}, {"type": "string", "enum": ["names"]}]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What `document.inspect` returns.
|
||||
@@ -365,10 +457,13 @@ pub struct DocumentInspectResult {
|
||||
pub marker: usize,
|
||||
/// The design's parameters.
|
||||
pub params: Vec<DocumentParam>,
|
||||
/// Its features, in timeline order.
|
||||
pub timeline: Vec<TimelineEntry>,
|
||||
/// Its bodies.
|
||||
pub bodies: Vec<DocumentBody>,
|
||||
/// Its features, in timeline order (in short with `timeline: "summary"`); left out with
|
||||
/// `timeline: false`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeline: Option<Vec<TimelineRow>>,
|
||||
/// Its bodies (only their names with `bodies: "names"`); left out with `bodies: false`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bodies: Option<DocumentBodies>,
|
||||
/// Its sketches.
|
||||
pub sketches: Vec<DocumentSketch>,
|
||||
/// What is selected, each as the selection commands take it.
|
||||
@@ -420,6 +515,44 @@ pub struct TimelineEntry {
|
||||
pub ms: Option<f64>,
|
||||
}
|
||||
|
||||
/// A feature of `document.inspect`'s timeline: in full, or in short with `timeline: "summary"`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum TimelineRow {
|
||||
Full(TimelineEntry),
|
||||
Summary(TimelineSummary),
|
||||
}
|
||||
|
||||
/// A feature in short: what it is, and only the states it has.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct TimelineSummary {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// The feature's kind, such as `Extrude`.
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
/// Why it failed; present only when it did.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
/// Present only when it has one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub warning: Option<String>,
|
||||
/// Present, and true, only when it is suppressed.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppressed: Option<bool>,
|
||||
/// Present, and true, only when the timeline marker has rolled it back.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rolled_back: Option<bool>,
|
||||
}
|
||||
|
||||
/// `document.inspect`'s bodies: each in full, or only their names with `bodies: "names"`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum DocumentBodies {
|
||||
Full(Vec<DocumentBody>),
|
||||
Names(Vec<String>),
|
||||
}
|
||||
|
||||
/// A body of the design: measured with `measure`, else with the feature that made it.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DocumentBody {
|
||||
|
||||
@@ -99,7 +99,7 @@ fn describe_returns_both_schemas() {
|
||||
|
||||
#[test]
|
||||
fn inspect_schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<DocumentInspectParams>(), &["measure", "sketch_counts"]);
|
||||
assert_keys(input_schema_for::<DocumentInspectParams>(), &["bodies", "measure", "sketch_counts", "timeline"]);
|
||||
assert_keys(input_schema_for::<MeasureParams>(), &["bodies", "items", "names_only"]);
|
||||
assert_keys(input_schema_for::<CommandsParams>(), &[]);
|
||||
assert_keys(input_schema_for::<MaterialListParams>(), &[]);
|
||||
@@ -182,6 +182,15 @@ fn the_types_read_each_key_as_the_handler_does() {
|
||||
// document.inspect's flags are plain booleans, `null` is left out.
|
||||
assert_eq!(issues("document.inspect", json!({"measure": true, "sketch_counts": null})), none);
|
||||
assert_eq!(issues("document.inspect", json!({"measure": "yes"})), ["`measure` must be true or false"]);
|
||||
for ok in
|
||||
[json!({"timeline": "summary", "bodies": "names"}), json!({"timeline": false, "bodies": false}), json!({"timeline": true, "bodies": null})]
|
||||
{
|
||||
assert_eq!(issues("document.inspect", ok.clone()), none, "{ok}");
|
||||
}
|
||||
for bad in [json!("full"), json!(1), json!(["Box"])] {
|
||||
assert_eq!(issues("document.inspect", json!({ "timeline": bad.clone() })), ["`timeline` must be true, false or \"summary\""], "{bad}");
|
||||
assert_eq!(issues("document.inspect", json!({ "bodies": bad.clone() })), ["`bodies` must be true, false or \"names\""], "{bad}");
|
||||
}
|
||||
// Neither list command takes a key.
|
||||
assert_eq!(issues("engine.commands", json!({"x": 1})), ["unknown key `x`"]);
|
||||
assert_eq!(issues("material.list", json!({"x": 1})), ["unknown key `x`"]);
|
||||
@@ -234,7 +243,17 @@ fn the_schemas_take_what_the_handlers_read() {
|
||||
(json!({"sketch": true}), false),
|
||||
],
|
||||
);
|
||||
takes(input_schema_for::<DocumentInspectParams>(), &[(json!({"measure": true, "sketch_counts": false}), true), (json!({"measure": 1}), false)]);
|
||||
takes(
|
||||
input_schema_for::<DocumentInspectParams>(),
|
||||
&[
|
||||
(json!({"measure": true, "sketch_counts": false}), true),
|
||||
(json!({"measure": 1}), false),
|
||||
(json!({"timeline": "summary", "bodies": "names"}), true),
|
||||
(json!({"timeline": false, "bodies": true}), true),
|
||||
(json!({"timeline": "names"}), false),
|
||||
(json!({"bodies": ["Block"]}), false),
|
||||
],
|
||||
);
|
||||
takes(input_schema_for::<CommandsParams>(), &[(json!({}), true), (json!({"x": 1}), false)]);
|
||||
}
|
||||
|
||||
@@ -325,6 +344,81 @@ fn document_inspect_replies_match_its_schema() {
|
||||
assert!(r["bodies"].as_array().is_some_and(|b| b.iter().any(|b| b["removed_later"] == true)), "{r}");
|
||||
}
|
||||
|
||||
/// `timeline` and `bodies` make a large design's reply small (#14162): a summary row has only
|
||||
/// what the feature is and the states it has, `"names"` lists only body names and measures
|
||||
/// nothing, `false` leaves either out. The default reply is as it was; a bad value is refused
|
||||
/// by the handler too, with the check off.
|
||||
#[test]
|
||||
fn document_inspect_gives_a_short_timeline_and_body_names_when_asked() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
design(&mut s);
|
||||
// A feature that fails: a box whose length goes to zero.
|
||||
run(&mut s, "parameters.change", json!({"name": "t", "expression": "5 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": "t", "width": 5, "height": 5, "corner": [80, 0, 0], "body_name": "Broken"}));
|
||||
run(&mut s, "parameters.change", json!({"name": "t", "expression": "0 mm"}));
|
||||
let keys = |row: &Value| -> Vec<String> {
|
||||
let mut k: Vec<String> = row.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();
|
||||
k.sort();
|
||||
k
|
||||
};
|
||||
let full = run(&mut s, "document.inspect", json!({"measure": true}));
|
||||
for row in full["timeline"].as_array().unwrap() {
|
||||
assert_eq!(keys(row), ["error", "id", "ms", "name", "rolled_back", "suppressed", "type", "warning"], "the default row: {row}");
|
||||
}
|
||||
assert_eq!(run(&mut s, "document.inspect", json!({"measure": true, "timeline": true, "bodies": true})), full);
|
||||
let failing = full["timeline"].as_array().unwrap().iter().filter(|f| !f["error"].is_null()).count();
|
||||
assert_eq!(failing, 1, "the zero-length box fails: {full}");
|
||||
|
||||
let short = run(&mut s, "document.inspect", json!({"measure": true, "timeline": "summary", "bodies": "names"}));
|
||||
matches::<DocumentInspectResult>("document.inspect", &short, false);
|
||||
let rows = short["timeline"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), full["timeline"].as_array().unwrap().len(), "{short}");
|
||||
for (row, was) in rows.iter().zip(full["timeline"].as_array().unwrap()) {
|
||||
let mut want = vec!["id", "name", "type"];
|
||||
if !was["error"].is_null() {
|
||||
want.push("error");
|
||||
}
|
||||
if !was["warning"].is_null() {
|
||||
want.push("warning");
|
||||
}
|
||||
want.sort();
|
||||
assert_eq!(keys(row), want, "{row} from {was}");
|
||||
assert_eq!(
|
||||
(&row["id"], &row["name"], &row["type"], &row.get("error")),
|
||||
(&was["id"], &was["name"], &was["type"], &was.get("error").filter(|e| !e.is_null()))
|
||||
);
|
||||
}
|
||||
let names: Vec<&Value> = full["bodies"].as_array().unwrap().iter().map(|b| &b["name"]).collect();
|
||||
assert_eq!(short["bodies"].as_array().unwrap().iter().collect::<Vec<_>>(), names, "names only, nothing measured: {short}");
|
||||
let size = |r: &Value| r["timeline"].to_string().len() + r["bodies"].to_string().len();
|
||||
assert!(size(&short) * 3 < size(&full), "timeline and bodies: {} vs {} bytes", size(&short), size(&full));
|
||||
// Everything else is as it was.
|
||||
for key in ["params", "sketches", "undo", "marker", "selection"] {
|
||||
assert_eq!(short[key], full[key], "{key}");
|
||||
}
|
||||
|
||||
let none = run(&mut s, "document.inspect", json!({"timeline": false, "bodies": false}));
|
||||
matches::<DocumentInspectResult>("document.inspect", &none, false);
|
||||
assert!(none.get("timeline").is_none() && none.get("bodies").is_none(), "{none}");
|
||||
assert_eq!(none["params"], full["params"]);
|
||||
|
||||
// A rolled-back or suppressed feature says so in its summary row.
|
||||
run(&mut s, "timeline.roll_to", json!({"feature": "Shapes"}));
|
||||
let rolled = run(&mut s, "document.inspect", json!({"timeline": "summary"}));
|
||||
assert!(rolled["timeline"].as_array().unwrap().iter().any(|f| f["rolled_back"] == true), "{rolled}");
|
||||
assert!(rolled["timeline"].as_array().unwrap().iter().all(|f| f.get("rolled_back").is_none_or(|r| r == true)), "{rolled}");
|
||||
|
||||
// The handler refuses a bad value itself (with the check off as well).
|
||||
let mut off = Session { param_check: ParamCheck::Off, ..Session::default() };
|
||||
for (p, want) in [
|
||||
(json!({"timeline": "full"}), "`timeline` must be true, false or \"summary\""),
|
||||
(json!({"bodies": ["Block"]}), "`bodies` must be true, false or \"names\""),
|
||||
] {
|
||||
let e = off.execute("document.inspect", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(e.contains(want), "{p}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `inspect.measure` in each form: bodies (with sheet metal and a material), names only, one
|
||||
/// item and two, in Warn without warnings. Everything it ignores with `items` is ignored.
|
||||
#[test]
|
||||
|
||||
@@ -413,7 +413,7 @@ macro_rules! read_with {
|
||||
)*};
|
||||
}
|
||||
|
||||
read_with!(JointKey, OccurrenceKey, JointTypeParam, JointValue, JointValues, JointLimits, JointOriginArg);
|
||||
read_with!(JointKey, OccurrenceKey, JointTypeParam, ConstraintTypeParam, JointValue, JointValues, JointLimits, JointOriginArg);
|
||||
|
||||
/// The call's keys as an object. (`TypedContract::check` has refused anything else already.)
|
||||
fn keys<'de, D: Deserializer<'de>>(d: D) -> Result<Map<String, Value>, D::Error> {
|
||||
@@ -641,6 +641,37 @@ impl<'de> Deserialize<'de> for CreateConstraintParams {
|
||||
}
|
||||
}
|
||||
|
||||
/// Change a constraint (a joint made with `assembly.constraint.create`) in place: its rule, gap,
|
||||
/// angle, facing, name or suppression. A new rule must fit the references the constraint has; the gap
|
||||
/// carries over. The change goes through joint.edit: the values start again where the parts are.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct EditConstraintParams {
|
||||
/// The constraint, by id or name.
|
||||
pub constraint: IdOrName,
|
||||
/// The new rule, as assembly.constraint.create takes it. Default: the rule it has.
|
||||
#[serde(default, rename = "type", deserialize_with = "present")]
|
||||
pub kind: Option<ConstraintTypeParam>,
|
||||
/// The gap: a length, or an expression the constraint follows. Default: the gap it has.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
pub offset: Option<LengthExpr>,
|
||||
/// An angle rule's angle: an angle, or an expression. Needed to change to angle.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
pub angle: Option<AngleExpr>,
|
||||
/// True turns b over: from the new rule's way after a type change, else from how it faces now.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
pub flip: Option<bool>,
|
||||
/// The constraint's new name.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
pub name: Option<String>,
|
||||
/// Suppress the constraint (true) or bring it back (false).
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
pub suppressed: Option<bool>,
|
||||
/// Leave out the degrees of freedom and the fixed occurrences in the reply.
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
pub quiet: Option<bool>,
|
||||
}
|
||||
|
||||
/// Add a joint between two occurrences that stay where they are: its frame is a joint origin
|
||||
/// (`at`, default b's origin), and the joint starts with the origins coinciding as they are. An
|
||||
/// origin on a or b is kept on its face or hole there, so the joint follows it as the design
|
||||
@@ -2360,6 +2391,9 @@ pub struct ReachResult {
|
||||
pub stopped_by_limit: Option<Vec<LimitStop>>,
|
||||
/// The closed loop that stopped it (its chain alone gets closer); `null` when none.
|
||||
pub stopped_by_loop: Option<ReachLoopStop>,
|
||||
/// With contact sets on: the pair of a set that stopped it where they touch, as `joint.drive`
|
||||
/// says; `null` when none.
|
||||
pub stopped_by_contact: Option<ContactStop>,
|
||||
/// Why nothing can move. Left out when the reach worked.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
|
||||
@@ -4,9 +4,9 @@ use solidcraft_doc::joints::JointKind;
|
||||
|
||||
use super::{
|
||||
AsBuiltJointParams, CamParams, CamResult, CouplingResult, CreateConstraintParams, CreateJointParams, DeleteJointParams, DeleteJointResult,
|
||||
DriveJointParams, DriveJointResult, EditJointParams, GearParams, InspectJointParams, InspectJointResult, JointAddedResult, JointKindName,
|
||||
JointLimitsParams, ListJointsParams, ListJointsResult, MakeJointOriginParams, MakeJointOriginResult, MotionLinkParams, RackPinionParams,
|
||||
ReachParams, ReachResult, RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveResult,
|
||||
DriveJointParams, DriveJointResult, EditConstraintParams, EditJointParams, EditJointResult, GearParams, InspectJointParams, InspectJointResult,
|
||||
JointAddedResult, JointKindName, JointLimitsParams, ListJointsParams, ListJointsResult, MakeJointOriginParams, MakeJointOriginResult,
|
||||
MotionLinkParams, RackPinionParams, ReachParams, ReachResult, RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveResult,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
@@ -19,6 +19,7 @@ fn schemas_and_the_key_check_agree() {
|
||||
input_schema_for::<CreateJointParams>(),
|
||||
&["a", "angle", "b", "flip", "limits", "name", "offset", "quiet", "type", "value", "values"],
|
||||
);
|
||||
assert_keys(input_schema_for::<EditConstraintParams>(), &["angle", "constraint", "flip", "name", "offset", "quiet", "suppressed", "type"]);
|
||||
assert_keys(input_schema_for::<CreateConstraintParams>(), &["a", "angle", "b", "flip", "name", "offset", "quiet", "type"]);
|
||||
assert_keys(input_schema_for::<AsBuiltJointParams>(), &["a", "at", "b", "limits", "name", "quiet", "type", "value", "values"]);
|
||||
assert_keys(input_schema_for::<DriveJointParams>(), &["continuous", "index", "joint", "quiet", "stop_at_budget", "value", "values"]);
|
||||
@@ -44,6 +45,7 @@ fn schemas_and_the_key_check_agree() {
|
||||
fn the_commands_publish_their_schemas() {
|
||||
for id in [
|
||||
"assembly.constraint.create",
|
||||
"assembly.constraint.edit",
|
||||
"joint.create",
|
||||
"joint.as_built",
|
||||
"joint.drive",
|
||||
@@ -206,6 +208,10 @@ fn strict_keeps_todays_errors() {
|
||||
("joint.screw", json!({"a": true, "b": 1, "pitch": 1})),
|
||||
("joint.cam", json!({})),
|
||||
("joint.cam", json!({"a": false, "b": 1})),
|
||||
("assembly.constraint.edit", json!({})),
|
||||
("assembly.constraint.edit", json!({"constraint": true})),
|
||||
("assembly.constraint.edit", json!({"constraint": null})),
|
||||
("assembly.constraint.edit", json!({"constraint": [1]})),
|
||||
("assembly.constraint.create", json!({})),
|
||||
("assembly.constraint.create", json!({"type": "weld"})),
|
||||
("assembly.constraint.create", json!({"type": 5})),
|
||||
@@ -531,6 +537,18 @@ fn the_schemas_take_what_the_handlers_read() {
|
||||
(json!({"type": "mate", "a": "A", "b": "B", "values": [1]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<EditConstraintParams>(),
|
||||
&[
|
||||
(json!({"constraint": "Mate1"}), true),
|
||||
(json!({"constraint": 4, "type": "flush", "offset": "gap", "flip": true}), true),
|
||||
(json!({"constraint": "Mate1", "angle": 30, "name": "M", "suppressed": false, "quiet": true}), true),
|
||||
(json!({}), false),
|
||||
(json!({"constraint": "Mate1", "type": 5}), false),
|
||||
(json!({"constraint": "Mate1", "flip": 1}), false),
|
||||
(json!({"constraint": "Mate1", "joint": "Mate1"}), false),
|
||||
],
|
||||
);
|
||||
takes(input_schema_for::<ListJointsParams>(), &[(json!({}), true), (json!({"quiet": false}), true), (json!({"quiet": 1}), false)]);
|
||||
takes(input_schema_for::<SolveJointsParams>(), &[(json!({}), true), (json!({"quiet": false}), true), (json!({"quiet": 1}), false)]);
|
||||
}
|
||||
@@ -589,6 +607,11 @@ fn replies_match_the_output_schema() {
|
||||
let inspected = run("joint.inspect", json!({"joint": "Rule"}));
|
||||
matches(output_schema_for::<InspectJointResult>(), "joint.inspect", &inspected);
|
||||
assert_eq!(inspected["joints"][0]["constraint"], "parallel", "{inspected}");
|
||||
// assembly.constraint.edit answers as joint.edit does.
|
||||
let edited = run("assembly.constraint.edit", json!({"constraint": "Rule", "type": "perpendicular"}));
|
||||
matches(output_schema_for::<EditJointResult>(), "assembly.constraint.edit", &edited);
|
||||
let edited = run("assembly.constraint.edit", json!({"constraint": "Rule", "suppressed": true, "quiet": true}));
|
||||
matches(output_schema_for::<EditJointResult>(), "assembly.constraint.edit", &edited);
|
||||
run("joint.delete", json!({"joint": "Rule"}));
|
||||
let built = run("joint.as_built", json!({"type": "slider", "a": "LeafB", "b": "LeafC", "name": "Slide", "limits": [[0, 10]], "quiet": false}));
|
||||
matches(output_schema_for::<JointAddedResult>(), "joint.as_built", &built);
|
||||
|
||||
@@ -86,6 +86,7 @@ pub mod mesh;
|
||||
pub mod metadata;
|
||||
pub mod motion;
|
||||
pub mod motion_analysis;
|
||||
pub mod motion_animation;
|
||||
pub mod motion_play;
|
||||
pub mod motion_trace;
|
||||
pub mod new_design;
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Contract and reply type for Export Motion Animation (`motion.export_animation`, #13310): a
|
||||
//! study played, one joint driven or an exploded view coming apart, drawn frame by frame and
|
||||
//! written as an animated GIF or a folder of PNG frames. The design is not changed.
|
||||
//!
|
||||
//! The handler (`cmd/motion_animation.rs`) reads its parameters from the raw JSON one after the
|
||||
//! other, so the contract reads the whole call in the same order and with the same messages:
|
||||
//! `path`, `width` and `height`, `fps`, `grid` and `axes`, what moves (an exploded `view` with
|
||||
//! `back`, a `study`, or a `joint` with `from` and `to`), the `camera`, and `samples`, whose
|
||||
//! upper end follows from the frame size and the format. A key given as `null` is given, as the
|
||||
//! handler reads it. Keys the handler doesn't read in a case aren't read here either: `back` only
|
||||
//! goes with a `view`, and `index`, `from` and `to` with a `joint`.
|
||||
//!
|
||||
//! The camera's fields are tried on the default camera: a field the handler refuses is wrong
|
||||
//! whatever camera it completes, so the message is the handler's. What only the session can tell
|
||||
//! stays the handler's to check: whether the view, the study and the joint exist, whether the
|
||||
//! joint moves, `index` (its message names the joint's value count), what `from` and `to`
|
||||
//! evaluate to, whether a renderer is installed and where the file may be written. A call with
|
||||
//! several problems may get a different one first in strict than with the check off.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::check::whole;
|
||||
use super::motion_analysis::StudyStop;
|
||||
use super::types::not_blank_pattern;
|
||||
use solidcraft_viewport::Camera;
|
||||
|
||||
/// The most frames of one animation. The handler (`cmd/motion_animation.rs`) uses these limits
|
||||
/// too, so the schema, the strict check and the handler cannot disagree.
|
||||
pub(crate) const MAX_FRAMES: usize = 1000;
|
||||
/// The largest frame side, in pixels.
|
||||
pub(crate) const MAX_SIDE: u32 = 4096;
|
||||
/// The most pixels of a GIF's frames together: they are held in memory (4 bytes each) until the
|
||||
/// palette is chosen, so 256 MB at most.
|
||||
pub(crate) const MAX_GIF_PIXELS: usize = 1 << 26;
|
||||
/// The most pixels of a PNG folder's frames together (each frame is written as it is drawn):
|
||||
/// about 500 frames of 1920 × 1080.
|
||||
pub(crate) const MAX_PNG_PIXELS: usize = 1 << 30;
|
||||
/// The camera fields `camera` may set: those of `ui.camera`'s reply.
|
||||
pub(crate) const CAMERA_FIELDS: [&str; 7] = ["target", "yaw", "pitch", "roll", "distance", "fov", "up"];
|
||||
/// The handler's message for a camera that isn't finite or is out of range.
|
||||
const BAD_CAMERA: &str = "the camera must be finite, with a distance up to 1e15 mm, a pitch within ±π/2 and a fov of 0 (orthographic) to 3 radians";
|
||||
|
||||
/// Draw a motion as an animation, frame by frame with the headless renderer: a motion study
|
||||
/// played, one joint driven from `from` to `to`, or an exploded view coming apart (and back
|
||||
/// together with `back`). Written as an animated GIF, or as a folder of PNG frames. The design
|
||||
/// is not changed, nor its placements.
|
||||
#[derive(Debug)]
|
||||
pub struct ExportAnimationParams {
|
||||
/// The `.gif` file, or the folder for the PNG frames.
|
||||
pub path: String,
|
||||
/// The frame size, in pixels.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// A GIF's frames per second.
|
||||
pub fps: f64,
|
||||
/// Whether the ground grid and the origin's axes are drawn.
|
||||
pub grid: bool,
|
||||
pub axes: bool,
|
||||
/// What moves.
|
||||
pub motion: AnimatedMotion,
|
||||
/// The camera fields given, if any.
|
||||
pub camera: Option<Map<String, Value>>,
|
||||
/// The frames asked for, if any.
|
||||
pub samples: Option<usize>,
|
||||
}
|
||||
|
||||
/// What an animation shows.
|
||||
#[derive(Debug)]
|
||||
pub enum AnimatedMotion {
|
||||
/// An exploded view, by id or name, coming apart (and back together with `back`).
|
||||
View { view: Value, back: bool },
|
||||
/// A motion study, by id or name, played from start to end.
|
||||
Study(Value),
|
||||
/// A joint, by id or name, driven from `from` to `to`: its value `index` (as given; the
|
||||
/// handler checks it against the joint's value count).
|
||||
Joint { joint: Value, index: Option<Value>, from: Value, to: Value },
|
||||
}
|
||||
|
||||
impl ExportAnimationParams {
|
||||
/// The keys the handler reads, in its order and in the cases it reads them.
|
||||
fn read(o: &Map<String, Value>) -> Result<ExportAnimationParams, String> {
|
||||
let path = o
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|x| !x.trim().is_empty() && x.len() <= 4096 && !x.chars().any(char::is_control))
|
||||
.ok_or("`path` must name a .gif file, or a folder for PNG frames")?;
|
||||
let lower = path.to_ascii_lowercase();
|
||||
if lower.ends_with(".png") {
|
||||
return Err("`path` is a .gif file or a folder for the PNG frames (frame-0001.png, …), not one .png".into());
|
||||
}
|
||||
let gif = lower.ends_with(".gif");
|
||||
if !gif && cfg!(target_arch = "wasm32") {
|
||||
return Err("a folder of PNG frames is not available in the browser: give a .gif path".into());
|
||||
}
|
||||
let (width, height) = (side(o, "width", 800)?, side(o, "height", 500)?);
|
||||
let fps = match o.get("fps") {
|
||||
None => 20.0,
|
||||
Some(v) => v.as_f64().filter(|n| n.is_finite() && (1.0..=50.0).contains(n)).ok_or("`fps` must be 1…50")?,
|
||||
};
|
||||
let (grid, axes) = (flag(o, "grid", true)?, flag(o, "axes", true)?);
|
||||
let motion = motion(o)?;
|
||||
let camera = o.get("camera").map(camera_fields).transpose()?;
|
||||
let max = MAX_FRAMES.min(if gif { MAX_GIF_PIXELS } else { MAX_PNG_PIXELS } / (width as usize * height as usize));
|
||||
let samples = match o.get("samples") {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_f64()
|
||||
.filter(|n| n.is_finite() && *n >= 2.0 && *n <= max as f64)
|
||||
.map(|n| n.round() as usize)
|
||||
.ok_or_else(|| format!("`samples` must be 2…{max} for {width} × {height} frames"))?,
|
||||
),
|
||||
};
|
||||
Ok(ExportAnimationParams { path: path.to_string(), width, height, fps, grid, axes, motion, camera, samples })
|
||||
}
|
||||
}
|
||||
|
||||
/// `width` or `height`: 16 to 4096 pixels, rounded, or `default` left out.
|
||||
fn side(o: &Map<String, Value>, key: &str, default: u32) -> Result<u32, String> {
|
||||
match o.get(key) {
|
||||
None => Ok(default),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|n| n.is_finite() && (16.0..=f64::from(MAX_SIDE)).contains(n))
|
||||
.map(|n| n.round() as u32)
|
||||
.ok_or_else(|| format!("`{key}` must be 16…{MAX_SIDE} pixels")),
|
||||
}
|
||||
}
|
||||
|
||||
/// True or false, or `default` left out.
|
||||
fn flag(o: &Map<String, Value>, key: &str, default: bool) -> Result<bool, String> {
|
||||
match o.get(key) {
|
||||
None => Ok(default),
|
||||
Some(Value::Bool(b)) => Ok(*b),
|
||||
Some(_) => Err(format!("`{key}` must be true or false")),
|
||||
}
|
||||
}
|
||||
|
||||
/// An id (a number) or a name, or `message`.
|
||||
fn id_or_name(v: &Value, message: &str) -> Result<Value, String> {
|
||||
match v {
|
||||
Value::Number(_) | Value::String(_) => Ok(v.clone()),
|
||||
_ => Err(message.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What moves, as the handler picks it: an exploded `view` alone, else `study` or `joint`
|
||||
/// (through `motion_plan` in `cmd/motion.rs`).
|
||||
fn motion(o: &Map<String, Value>) -> Result<AnimatedMotion, String> {
|
||||
let picked = "give a `study`, a `joint` with `from` and `to`, or an exploded `view`";
|
||||
match (o.get("view"), o.get("study").or(o.get("joint"))) {
|
||||
(Some(_), Some(_)) => Err("give an exploded `view`, a `study` or a `joint`: one of them".into()),
|
||||
(Some(view), None) => {
|
||||
let back = flag(o, "back", false)?;
|
||||
Ok(AnimatedMotion::View { view: id_or_name(view, "`view` must be an id or a name")?, back })
|
||||
}
|
||||
(None, None) => Err(picked.into()),
|
||||
(None, Some(_)) => {
|
||||
if o.contains_key("back") {
|
||||
return Err("`back` goes with an exploded `view`".into());
|
||||
}
|
||||
match (o.get("study"), o.get("joint")) {
|
||||
(Some(_), Some(_)) => Err("give `study` or `joint`, not both".into()),
|
||||
(Some(study), None) => Ok(AnimatedMotion::Study(id_or_name(study, "`study` must be an id or a name")?)),
|
||||
(None, Some(joint)) => {
|
||||
let joint = id_or_name(joint, "`joint` must be an id or a name")?;
|
||||
// The handler looks the joint up and reads `index` between the two.
|
||||
let end = |key: &str| match o.get(key) {
|
||||
Some(v) => joint_value(v),
|
||||
None => Err(format!("give `from` and `to`: the joint's values the motion runs between (no `{key}`)")),
|
||||
};
|
||||
let (from, to) = (end("from")?, end("to")?);
|
||||
Ok(AnimatedMotion::Joint { joint, index: o.get("index").cloned(), from, to })
|
||||
}
|
||||
(None, None) => Err(picked.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `from` or `to`, as `joints::value` reads it: a number between -1e9 and 1e9, or text (an
|
||||
/// expression, which the handler evaluates), with its message for anything else.
|
||||
fn joint_value(v: &Value) -> Result<Value, String> {
|
||||
match v {
|
||||
Value::Number(n) if n.as_f64().is_some_and(|x| x.is_finite() && x.abs() < 1e9) => Ok(v.clone()),
|
||||
Value::String(_) => Ok(v.clone()),
|
||||
_ => Err(format!("a joint value {}", crate::params::expr_problem(Some(v)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `camera`: an object of `ui.camera`'s fields, read as the handler reads it, on the default
|
||||
/// camera instead of the session's (each check is on the fields given).
|
||||
fn camera_fields(given: &Value) -> Result<Map<String, Value>, String> {
|
||||
let fields = given.as_object().ok_or("`camera` must be an object, such as ui.camera's reply")?;
|
||||
let mut v = serde_json::to_value(Camera::default()).map_err(|e| e.to_string())?;
|
||||
for (k, x) in fields {
|
||||
if !CAMERA_FIELDS.contains(&k.as_str()) {
|
||||
return Err(format!("unknown camera field `{k}` (it takes {})", CAMERA_FIELDS.join(", ")));
|
||||
}
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert(k.clone(), x.clone());
|
||||
}
|
||||
}
|
||||
let cam: Camera = serde_json::from_value(v).map_err(|e| format!("bad camera: {e}"))?;
|
||||
if !cam.is_valid() || cam.distance > 1e15 || cam.pitch.abs() > std::f64::consts::FRAC_PI_2 || !(0.0..3.0).contains(&cam.fov) {
|
||||
return Err(BAD_CAMERA.into());
|
||||
}
|
||||
Ok(fields.clone())
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ExportAnimationParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let o = Map::<String, Value>::deserialize(d)?;
|
||||
ExportAnimationParams::read(&o).map_err(|why| D::Error::custom(whole(why)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ExportAnimationParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ExportAnimationParams".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
// `index`, `from` and `to` are read only with a `joint`: their shapes are in the `anyOf` below,
|
||||
// and the properties only describe them.
|
||||
let end_description = |end: &str| {
|
||||
format!(
|
||||
"With `joint`: the value the motion {end} at, a number (degrees for an angle, mm for a distance, between -1e9 and 1e9) \
|
||||
or an expression such as \"width / 2\" or \"30 deg\". Not read without a joint."
|
||||
)
|
||||
};
|
||||
let index = "With `joint`: which of its values to drive, below its value count. Default: 0. Not read without a joint.";
|
||||
let side_schema = |what: &str, default: u32| {
|
||||
json_schema!({
|
||||
"description": format!("The frames' {what} in pixels, 16…{MAX_SIDE} (rounded). Default: {default}."),
|
||||
"type": "number",
|
||||
"minimum": 16,
|
||||
"maximum": MAX_SIDE,
|
||||
"default": default
|
||||
})
|
||||
};
|
||||
json_schema!({
|
||||
"description": "Draw a motion as an animation, frame by frame with the headless renderer: a motion study played (`study`), \
|
||||
one joint driven from `from` to `to` (`joint`), or an exploded view coming apart (`view`, and back together with \
|
||||
`back`). Written as an animated GIF, or as a folder of PNG frames. The design is not changed, nor its placements.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"description": "A .gif file, or a folder for the PNG frames (frame-0001.png, frame-0002.png, …), made where \
|
||||
missing; frames of an earlier export there are removed first. Not one .png file. At most 4096 bytes (the \
|
||||
schema counts characters), with no control characters.",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": not_blank_pattern()
|
||||
},
|
||||
"width": side_schema("width", 800),
|
||||
"height": side_schema("height", 500),
|
||||
"fps": {
|
||||
"description": "A GIF's frames per second, 1…50. A GIF counts in hundredths of a second, so the reply's `fps` \
|
||||
is the rate it plays at. Default: 20.",
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"maximum": 50,
|
||||
"default": 20
|
||||
},
|
||||
"grid": {"description": "Draw the ground grid. Default: true.", "type": "boolean", "default": true},
|
||||
"axes": {"description": "Draw the origin's axes. Default: true.", "type": "boolean", "default": true},
|
||||
"view": {
|
||||
"description": "An exploded view, by id (a number) or name: the parts coming apart from where they are \
|
||||
assembled. Instead of `study` or `joint`.",
|
||||
"type": ["number", "string"]
|
||||
},
|
||||
"back": {
|
||||
"description": "With `view`: true comes back together too, exploded at the middle frame of an odd count. \
|
||||
Default: false.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"study": {
|
||||
"description": "A motion study, by id (a number) or name, played from start to end. A number is looked up as \
|
||||
an id first, text as a name first. Instead of `view` or `joint`.",
|
||||
"type": ["number", "string"]
|
||||
},
|
||||
"joint": {
|
||||
"description": "A joint, by id (a number) or name, driven from `from` to `to`; a rigid joint doesn't move. \
|
||||
Instead of `view` or `study`.",
|
||||
"type": ["number", "string"]
|
||||
},
|
||||
"index": {"description": index},
|
||||
"from": {"description": end_description("starts")},
|
||||
"to": {"description": end_description("ends")},
|
||||
"camera": {
|
||||
"description": "The camera the frames are drawn from: fields of ui.camera's reply. Fields left out keep the \
|
||||
default view: view.apply's camera, or the home view framing the model (for an exploded view, both \
|
||||
assembled and exploded). Angles in radians.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"description": "The point looked at, [x, y, z] in mm.",
|
||||
"type": "array",
|
||||
"items": {"type": "number"},
|
||||
"minItems": 3,
|
||||
"maxItems": 3
|
||||
},
|
||||
"yaw": {"description": "Turn about the up axis; 0 looks along +Y, from the front.", "type": "number"},
|
||||
"pitch": {
|
||||
"description": "Elevation of the eye above the ground plane.",
|
||||
"type": "number",
|
||||
"minimum": -std::f64::consts::FRAC_PI_2,
|
||||
"maximum": std::f64::consts::FRAC_PI_2
|
||||
},
|
||||
"roll": {"description": "Turn about the viewing direction.", "type": "number"},
|
||||
"distance": {
|
||||
"description": "From the target to the eye, in mm.",
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 1e15
|
||||
},
|
||||
"fov": {
|
||||
"description": "Vertical field of view; 0 is orthographic.",
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"exclusiveMaximum": 3
|
||||
},
|
||||
"up": {"description": "The world's up axis.", "enum": ["z", "y"]}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"samples": {
|
||||
"description": format!(
|
||||
"How many frames, 2…{MAX_FRAMES}, with at most 2^26 pixels in all for a GIF and 2^30 for PNG frames, so \
|
||||
fewer for big frames (4 at 4096 × 4096 for a GIF). Default: the study's steps + 1, 100 for a joint, 41 for \
|
||||
an exploded view (81 with `back`)."
|
||||
),
|
||||
"type": "number",
|
||||
"minimum": 2,
|
||||
"maximum": MAX_FRAMES
|
||||
}
|
||||
},
|
||||
"required": ["path"],
|
||||
"oneOf": [{"required": ["view"]}, {"required": ["study"]}, {"required": ["joint"]}],
|
||||
"anyOf": [
|
||||
{"not": {"required": ["joint"]}},
|
||||
{
|
||||
"properties": {
|
||||
"index": {"type": "integer", "minimum": 0, "default": 0},
|
||||
"from": {"type": ["number", "string"]},
|
||||
"to": {"type": ["number", "string"]}
|
||||
}
|
||||
}
|
||||
],
|
||||
"dependentRequired": {"back": ["view"], "joint": ["from", "to"]},
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What `motion.export_animation` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AnimationExported {
|
||||
/// The file or the folder written, as given.
|
||||
pub path: String,
|
||||
/// `gif` for an animated GIF, `png` for a folder of PNG frames.
|
||||
pub format: &'static str,
|
||||
/// How many frames were drawn.
|
||||
pub frames: usize,
|
||||
/// The frame size, in pixels.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// A GIF's frames per second: the rate it plays at, which a GIF counts in hundredths of a
|
||||
/// second. Absent for PNG frames.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fps: Option<f64>,
|
||||
/// The bytes written: the GIF's, or all the PNG frames'.
|
||||
pub bytes: usize,
|
||||
/// What drew the frames: `cpu` for the CPU rasterizer.
|
||||
pub renderer: &'static str,
|
||||
/// What frames drawn by the CPU rasterizer lack. Absent otherwise.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub warning: Option<&'static str>,
|
||||
#[serde(flatten)]
|
||||
pub stop: StudyStop,
|
||||
/// Where two parts in a contact set touched and stopped the motion, or `null`.
|
||||
pub stopped_by_contact: Option<Map<String, Value>>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "motion_animation_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,253 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{AnimationExported, ExportAnimationParams};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
const CMD: &str = "motion.export_animation";
|
||||
|
||||
/// Cube B (x 20…30) slides on joint `Rail` toward cube A (x 0…10), as in
|
||||
/// `cmd/motion_tests.rs`: study `Push` takes it from 10 mm away until it touches A, study
|
||||
/// `Through` 5 mm into A, and the exploded view `Apart` pulls the two apart.
|
||||
fn rail() -> Session {
|
||||
let mut s = Session::default();
|
||||
let run = |s: &mut Session, id: &str, p: Value| {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
};
|
||||
for (name, x) in [("A", 0), ("B", 20)] {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
|
||||
}
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let a = s.doc.find_component("A").and_then(|c| s.doc.occurrence_of(c)).map(|o| o.id).unwrap_or(0);
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
|
||||
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
|
||||
run(&mut s, "motion.study", json!({"name": "Push", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, 0]]}]}));
|
||||
run(&mut s, "motion.study", json!({"name": "Through", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, -5]]}]}));
|
||||
run(&mut s, "explode.create", json!({"name": "Apart", "scale": 1}));
|
||||
s
|
||||
}
|
||||
|
||||
/// A folder of its own for one test's files.
|
||||
fn out_dir(test: &str) -> std::path::PathBuf {
|
||||
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-anim-schema-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
let keys = ["axes", "back", "camera", "fps", "from", "grid", "height", "index", "joint", "path", "samples", "study", "to", "view", "width"];
|
||||
assert_keys(input_schema_for::<ExportAnimationParams>(), &keys);
|
||||
let schema = input_schema_for::<ExportAnimationParams>();
|
||||
let schema = schema.as_value();
|
||||
assert_eq!(schema["required"], json!(["path"]));
|
||||
assert_eq!(schema["dependentRequired"], json!({"back": ["view"], "joint": ["from", "to"]}));
|
||||
assert_eq!(schema["oneOf"], json!([{"required": ["view"]}, {"required": ["study"]}, {"required": ["joint"]}]));
|
||||
}
|
||||
|
||||
/// #13310: calls that fail today get the same message in strict: the handler's for `path`, the
|
||||
/// frame size, `fps`, `grid` and `axes`, what moves (none, two of `view`, `study` and `joint`,
|
||||
/// `back` without a view, a view, study or joint of the wrong shape, `from` or `to` left out or
|
||||
/// of the wrong shape), `camera` (not an object, an unknown field, a field of the wrong shape or
|
||||
/// out of range) and `samples` (also past what the frame size allows), and the handler's own for
|
||||
/// what only the design can tell (a view, study or joint it lacks, `index` past the joint's
|
||||
/// values, `to` that is no expression).
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let dir = out_dir("strict");
|
||||
let gif = dir.join("x.gif").to_string_lossy().to_string();
|
||||
let frames = dir.join("frames").to_string_lossy().to_string();
|
||||
let push = |extra: Value| {
|
||||
let mut p = json!({"study": "Push", "path": gif});
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
p
|
||||
};
|
||||
let camera = |c: Value| push(json!({"camera": c}));
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"study": "Push", "path": null}),
|
||||
json!({"study": "Push", "path": ""}),
|
||||
json!({"study": "Push", "path": " "}),
|
||||
json!({"study": "Push", "path": 5}),
|
||||
json!({"study": "Push", "path": "x".repeat(4097)}),
|
||||
json!({"study": "Push", "path": "a\u{1}b.gif"}),
|
||||
json!({"study": "Push", "path": "one.png"}),
|
||||
json!({"study": "Push", "path": "One.PNG"}),
|
||||
push(json!({"width": 15})),
|
||||
push(json!({"width": "800"})),
|
||||
push(json!({"width": null})),
|
||||
push(json!({"height": 1e9})),
|
||||
push(json!({"fps": 0})),
|
||||
push(json!({"fps": "20"})),
|
||||
push(json!({"grid": "no"})),
|
||||
push(json!({"axes": null})),
|
||||
json!({"path": gif}),
|
||||
json!({"path": gif, "view": "Apart", "study": "Push"}),
|
||||
json!({"path": gif, "view": "Apart", "joint": "Rail", "from": 0, "to": 1}),
|
||||
json!({"path": gif, "view": "Apart", "study": null}),
|
||||
json!({"path": gif, "view": "Apart", "back": "yes"}),
|
||||
json!({"path": gif, "view": [1]}),
|
||||
json!({"path": gif, "view": null}),
|
||||
json!({"path": gif, "view": "Nope"}),
|
||||
push(json!({"back": true})),
|
||||
push(json!({"back": false})),
|
||||
json!({"path": gif, "joint": "Rail", "from": 0, "to": 1, "back": null}),
|
||||
push(json!({"joint": "Rail"})),
|
||||
push(json!({"study": null})),
|
||||
push(json!({"study": [1]})),
|
||||
push(json!({"study": "Nope"})),
|
||||
json!({"path": gif, "joint": null}),
|
||||
json!({"path": gif, "joint": true, "from": 0, "to": 1}),
|
||||
json!({"path": gif, "joint": "Nope", "from": 0, "to": 1}),
|
||||
json!({"path": gif, "joint": "Rail"}),
|
||||
json!({"path": gif, "joint": "Rail", "from": 0}),
|
||||
json!({"path": gif, "joint": "Rail", "from": true, "to": 1}),
|
||||
json!({"path": gif, "joint": "Rail", "from": 0, "to": 2e9}),
|
||||
json!({"path": gif, "joint": "Rail", "from": 0, "to": null}),
|
||||
json!({"path": gif, "joint": "Rail", "from": 0, "to": "1 +"}),
|
||||
json!({"path": gif, "joint": "Rail", "index": 1, "from": 0, "to": 1}),
|
||||
json!({"path": gif, "joint": "Rail", "index": -1, "from": 0, "to": 1}),
|
||||
json!({"path": gif, "joint": "Rail", "index": "0", "from": 0, "to": 1}),
|
||||
camera(json!("iso")),
|
||||
camera(json!(null)),
|
||||
camera(json!({"fit": true})),
|
||||
camera(json!({"yaw": 1, "zoom": 2})),
|
||||
camera(json!({"target": [1, 2]})),
|
||||
camera(json!({"yaw": "a"})),
|
||||
camera(json!({"up": "x"})),
|
||||
camera(json!({"distance": -1})),
|
||||
camera(json!({"distance": 1e300})),
|
||||
camera(json!({"pitch": 4})),
|
||||
camera(json!({"fov": 3})),
|
||||
camera(json!({"fov": -0.1})),
|
||||
push(json!({"samples": 1})),
|
||||
push(json!({"samples": 1001})),
|
||||
push(json!({"samples": "10"})),
|
||||
push(json!({"samples": null})),
|
||||
// 800 × 500 frames: a GIF holds 167 of them; 4096 × 4096: 4, and a folder of PNG 64.
|
||||
push(json!({"samples": 168})),
|
||||
push(json!({"width": 4096, "height": 4096, "samples": 5})),
|
||||
json!({"study": "Push", "path": frames, "width": 4095.6, "height": 4096, "samples": 65}),
|
||||
] {
|
||||
let error = |level: ParamCheck| {
|
||||
let mut s = rail();
|
||||
s.param_check = level;
|
||||
s.execute(CMD, &p).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{p}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #13310: the contract takes what the input schema allows: a call's check finds no issue
|
||||
/// exactly when it matches the schema. Left out of this on purpose, as JSON Schema (or the
|
||||
/// schema check here) can't tell them: what moves (one of `view`, `study` and `joint`, `back`
|
||||
/// only with a view and `from` and `to` with a joint: `oneOf` and `dependentRequired`), a blank
|
||||
/// or `.png` path, `samples` past what the frame size allows, and `index`, which the handler
|
||||
/// checks against the joint's value count.
|
||||
#[test]
|
||||
fn the_check_takes_what_the_schema_allows() {
|
||||
let schema = input_schema_for::<ExportAnimationParams>();
|
||||
let schema = schema.as_value();
|
||||
let full_camera = json!({"target": [0, 0, 0], "yaw": 1, "pitch": -1.5, "roll": 0.2, "distance": 100, "fov": 0.5, "up": "y"});
|
||||
for (p, takes) in [
|
||||
(json!({"path": "a.gif", "study": "Push"}), true),
|
||||
(json!({"path": "frames", "study": 1, "width": 16, "height": 4096, "fps": 50, "grid": false, "axes": true, "samples": 2}), true),
|
||||
(json!({"path": "a.gif", "joint": "Rail", "from": 0, "to": "10 mm", "index": 0}), true),
|
||||
(json!({"path": "a.gif", "view": "Apart", "back": true, "samples": 81}), true),
|
||||
(json!({"path": "a.gif", "study": "Push", "camera": full_camera}), true),
|
||||
(json!({"path": "a.gif", "study": "Push", "camera": {}}), true),
|
||||
// Read only with a joint, as the handler reads them.
|
||||
(json!({"path": "a.gif", "study": "Push", "index": null, "from": [], "to": false}), true),
|
||||
(json!({"path": "", "study": "Push"}), false),
|
||||
(json!({"path": 5, "study": "Push"}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "width": 15}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "height": 4097}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "width": null}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "fps": 0.5}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "grid": 1}), false),
|
||||
(json!({"path": "a.gif", "study": [1]}), false),
|
||||
(json!({"path": "a.gif", "view": null}), false),
|
||||
(json!({"path": "a.gif", "view": "Apart", "back": "yes"}), false),
|
||||
(json!({"path": "a.gif", "joint": "Rail", "from": true, "to": 1}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "camera": "iso"}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "camera": {"fit": true}}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "camera": {"target": [1, 2]}}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "camera": {"yaw": "a"}}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "camera": {"distance": 0}}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "camera": {"distance": 2e15}}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "camera": {"pitch": 2}}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "camera": {"fov": 3}}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "camera": {"up": "x"}}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "samples": 1}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "samples": 1001}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "samples": null}), false),
|
||||
(json!({"path": "a.gif", "study": "Push", "colour": "red"}), false),
|
||||
] {
|
||||
let issues = crate::schema::issues(CMD, &p);
|
||||
assert_eq!(issues.is_empty(), takes, "{p}: {issues:?}");
|
||||
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys of a reply.
|
||||
fn keys(r: &Value) -> BTreeSet<&str> {
|
||||
r.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect()
|
||||
}
|
||||
|
||||
/// An export through the registry, in Warn: it replies as its output schema says, without
|
||||
/// warnings.
|
||||
fn exported(s: &mut Session, p: Value) -> Value {
|
||||
let schema = output_schema_for::<AnimationExported>();
|
||||
let schema = schema.as_value();
|
||||
let r = s.execute(CMD, &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
r
|
||||
}
|
||||
|
||||
/// #13310: the reply keeps the keys it had: a GIF of a study played (with its `fps`), a folder of
|
||||
/// PNG frames of a joint driven (without), an exploded view coming apart and back, and a study
|
||||
/// that a contact set stops short. The design is not changed.
|
||||
#[test]
|
||||
fn an_animation_replies_with_its_schema() {
|
||||
crate::capture::install_test_renderer();
|
||||
let mut s = rail();
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let dir = out_dir("replies");
|
||||
let gif = dir.join("push.gif").to_string_lossy().to_string();
|
||||
let frames = dir.join("frames").to_string_lossy().to_string();
|
||||
let before = s.doc.clone();
|
||||
let plain: BTreeSet<&str> =
|
||||
["path", "format", "frames", "width", "height", "bytes", "renderer", "stopped_at", "stopped_by_loop", "stopped_by_contact"].into();
|
||||
|
||||
let played = exported(&mut s, json!({"study": "Push", "path": gif, "samples": 3, "width": 16, "height": 16, "fps": 25}));
|
||||
assert_eq!(keys(&played), plain.iter().copied().chain(["fps"]).collect::<BTreeSet<_>>(), "{played}");
|
||||
assert_eq!((&played["format"], &played["frames"], &played["fps"]), (&json!("gif"), &json!(3), &json!(25.0)), "{played}");
|
||||
assert_eq!((&played["stopped_at"], &played["stopped_by_contact"]), (&Value::Null, &Value::Null), "{played}");
|
||||
|
||||
let driven = exported(&mut s, json!({"joint": "Rail", "from": 10, "to": 0, "path": frames, "samples": 2, "width": 16, "height": 16}));
|
||||
assert_eq!(keys(&driven), plain, "{driven}");
|
||||
assert_eq!((&driven["format"], &driven["frames"]), (&json!("png"), &json!(2)), "{driven}");
|
||||
|
||||
let exploded = exported(&mut s, json!({"view": "Apart", "back": true, "path": gif, "samples": 3, "width": 16, "height": 16}));
|
||||
assert_eq!(keys(&exploded), plain.iter().copied().chain(["fps"]).collect::<BTreeSet<_>>(), "{exploded}");
|
||||
assert_eq!(*s.doc, *before, "the design is not changed");
|
||||
|
||||
// B stops where it touches A, once the two are a contact set.
|
||||
s.execute("contact.create", &json!({"occurrences": ["A", "B"]})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let stopped = exported(&mut s, json!({"study": "Through", "path": gif, "width": 16, "height": 16}));
|
||||
assert!(stopped["stopped_at"].is_u64() && stopped["stopped_by_contact"].is_object(), "{stopped}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -49,6 +49,10 @@ pub struct PartsInsertParams {
|
||||
/// The component's name (default: the part's, such as `Socket Head Cap Screw M6x20`).
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// Turn the part over from the way it is placed: with `at`, head into the material and shank
|
||||
/// out of the face; with `point`, its +Z against `direction` (default false).
|
||||
#[serde(default)]
|
||||
pub flip: Option<bool>,
|
||||
}
|
||||
|
||||
/// Change an inserted standard part's size or length, rebuilding it in place; its joints keep it
|
||||
@@ -215,6 +219,10 @@ pub struct InsertedPart {
|
||||
/// With `at`: a joint per hole, in pick order.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub joints: Option<Vec<u64>>,
|
||||
/// The way each placement's head points (its +Z, from the shank's tip toward the head), in
|
||||
/// world coordinates and in pick order: the direction that was chosen.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub directions: Vec<[f64; 3]>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
|
||||
@@ -21,7 +21,7 @@ fn plate(s: &mut Session) -> [[f64; 3]; 2] {
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<PartsLibraryParams>(), &["family"]);
|
||||
assert_keys(input_schema_for::<PartsInsertParams>(), &["at", "direction", "family", "length", "name", "point", "size"]);
|
||||
assert_keys(input_schema_for::<PartsInsertParams>(), &["at", "direction", "family", "flip", "length", "name", "point", "size"]);
|
||||
assert_keys(input_schema_for::<PartsResizeParams>(), &["component", "length", "size"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,10 +73,20 @@ pub struct InsertDrawingKeys {
|
||||
/// then also turns and scales about). Default: origin.
|
||||
#[serde(default)]
|
||||
pub align: Option<DrawingAlign>,
|
||||
/// What `width`, `height` and the middle of `align` "center" refer to: the bounds of the
|
||||
/// drawn geometry ("geometry") or the SVG sheet ("viewbox": its viewBox, else its width and
|
||||
/// height). Default: geometry. "viewbox" is for SVG drawings that have a sheet; a DXF has none.
|
||||
#[serde(default)]
|
||||
pub size_from: Option<DrawingSizeFrom>,
|
||||
/// The distance in mm within which ends join into one point, 1e-9 to 10 (a value outside is
|
||||
/// brought inside; one that is not above 0 reads as the default). Default: 0.0001.
|
||||
#[serde(default)]
|
||||
pub tolerance: Option<f64>,
|
||||
/// `true` puts a Fix constraint on every point the drawing adds (as `sketch.constraint.fix`
|
||||
/// does), so it stays where it is put; a circle's or ellipse's radius stays free, and text and
|
||||
/// the points the sketch already had are not touched. Default: false.
|
||||
#[serde(default)]
|
||||
pub fixed: Option<bool>,
|
||||
/// Read only when no sketch is being edited: the plane of the new sketch, as `sketch.create`
|
||||
/// takes it. Default: XY. Ignored (and not checked) while a sketch is being edited.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -301,6 +311,39 @@ impl<'de> Deserialize<'de> for DrawingAlign {
|
||||
}
|
||||
}
|
||||
|
||||
/// What an insert's `width` and `height` size, as `size_from` is read (the words are
|
||||
/// case-sensitive).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum DrawingSizeFrom {
|
||||
/// The bounds of the drawn geometry.
|
||||
Geometry,
|
||||
/// The SVG sheet: its viewBox, else its width and height.
|
||||
Viewbox,
|
||||
}
|
||||
|
||||
impl JsonSchema for DrawingSizeFrom {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DrawingSizeFrom".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "What `width` and `height` size: \"geometry\" (the bounds of the drawn geometry) or \"viewbox\" (the SVG sheet: its viewBox, else its width and height). Lower case only.",
|
||||
"type": "string",
|
||||
"enum": ["geometry", "viewbox"]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DrawingSizeFrom {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)?.as_str() {
|
||||
Some("geometry") => Ok(DrawingSizeFrom::Geometry),
|
||||
Some("viewbox") => Ok(DrawingSizeFrom::Viewbox),
|
||||
_ => Err(D::Error::custom("must be \"geometry\" or \"viewbox\"")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `sketch.insert_dxf` and `sketch.insert_svg` return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DrawingInserted {
|
||||
@@ -312,6 +355,9 @@ pub struct DrawingInserted {
|
||||
pub curves: usize,
|
||||
/// How many sketch texts were added.
|
||||
pub texts: usize,
|
||||
/// With `fixed: true`: how many points got a Fix constraint. Left out otherwise.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fixed: Option<usize>,
|
||||
/// The sketch's solve afterwards; null when it was not solved.
|
||||
pub info: Option<SketchSolve>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
|
||||
@@ -19,7 +19,7 @@ fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
let insert = ["align", "angle", "at", "height", "name", "path", "plane", "scale", "sketch", "text", "tolerance", "width"];
|
||||
let insert = ["align", "angle", "at", "fixed", "height", "name", "path", "plane", "scale", "size_from", "sketch", "text", "tolerance", "width"];
|
||||
assert_keys(input_schema_for::<InsertDxfParams>(), &insert);
|
||||
assert_keys(input_schema_for::<InsertSvgParams>(), &insert);
|
||||
assert_keys(input_schema_for::<ExportDxfParams>(), &["join", "layer", "layers", "path", "sketch", "units"]);
|
||||
@@ -133,6 +133,9 @@ fn the_types_read_like_the_handler() {
|
||||
assert_eq!(issues(json!({"text": "x", "height": -1})), ["`height` must be a positive number (mm)"]);
|
||||
assert_eq!(issues(json!({"text": "x", "angle": "1"})), ["`angle` must be a number (degrees)"]);
|
||||
assert_eq!(issues(json!({"text": "x", "align": "left"})), ["`align` must be \"origin\" or \"center\""]);
|
||||
assert_eq!(issues(json!({"text": "x", "size_from": "sheet"})), ["`size_from` must be \"geometry\" or \"viewbox\""]);
|
||||
assert!(issues(json!({"text": "x", "size_from": "viewbox", "fixed": true})).is_empty());
|
||||
assert_eq!(issues(json!({"text": "x", "fixed": "yes"})).len(), 1);
|
||||
assert_eq!(issues(json!({"path": " "})), ["give `path` or `text`"]);
|
||||
assert_eq!(issues(json!({"text": "x", "sketch": true})), ["`sketch` must be an id or a name"]);
|
||||
assert_eq!(issues(json!({"text": "x", "wdth": 5})), ["unknown key `wdth`; did you mean `width`?"]);
|
||||
@@ -226,6 +229,11 @@ fn replies_match_the_output_schema() {
|
||||
};
|
||||
let insert_keys: std::collections::BTreeSet<String> = ["created_sketch", "curves", "info", "sketch", "texts"].map(String::from).into();
|
||||
|
||||
// A drawing fixed where it is put: the reply says how many points, and matches the schema.
|
||||
let r = run(&mut s, "sketch.insert_svg", json!({"text": SVG, "fixed": true, "size_from": "geometry"}));
|
||||
matches(output_schema_for::<DrawingInserted>(), &r);
|
||||
assert_eq!(r["fixed"], json!(4), "{r}");
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
// With no sketch being edited: a new one is made, on the plane and with the name given.
|
||||
let r = run(&mut s, "sketch.insert_dxf", json!({"text": DXF, "plane": "XZ", "name": "Plate", "at": [5, 5]}));
|
||||
matches(output_schema_for::<DrawingInserted>(), &r);
|
||||
|
||||
@@ -1482,6 +1482,9 @@ pub struct MirrorResult {
|
||||
pub struct MoveResult {
|
||||
/// The curves moved, or with `copy` the copies made, by id.
|
||||
pub curves: Vec<String>,
|
||||
/// The constraints tying the moved geometry to what stays that the move let go, as
|
||||
/// `{id, kind}`.
|
||||
pub released: Vec<Value>,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
# per line.
|
||||
face.delete
|
||||
file.insert_step
|
||||
motion.export_animation
|
||||
render.aging.describe
|
||||
render.backends
|
||||
render.effects.describe
|
||||
|
||||
@@ -216,6 +216,7 @@ fn comparison_frame(scene: GpuScene, cam: &Camera, width: u32, height: u32, key:
|
||||
pv_hide: false,
|
||||
preview_bounds: None,
|
||||
view_proj: cam.view_proj(f64::from(width) / f64::from(height), 200.0).to_f32(),
|
||||
turned_depth: None,
|
||||
back: cam.back().to_f32(),
|
||||
size_px: [width as f32, height as f32],
|
||||
clip: [0.0; 4],
|
||||
|
||||
+31
-1
@@ -1478,6 +1478,36 @@ mod tests {
|
||||
/// #9170: a mesh body (an imported STL) is kept in the model cache with the rest of the
|
||||
/// model, as its own triangles, colour and paint. One mesh body left the whole cache out, so
|
||||
/// opening the design evaluated every feature again.
|
||||
/// The names a STEP file gives its colours survive saving a design with its model and opening
|
||||
/// it from that cache: the kernel bodies' own encoding keeps no names, the records do.
|
||||
#[test]
|
||||
fn model_cache_keeps_the_names_of_imported_colours() {
|
||||
use solidcore_record::Vec3;
|
||||
let part = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
|
||||
let faces = vec![solidcraft_kernel::FacePaint { face: 0, color: [0., 0., 1.], opacity: 1., name: Some("Accent".into()) }];
|
||||
let named =
|
||||
part.with_color(Some([0.5, 0.5, 0.5])).with_paint(Some(solidcraft_kernel::Paint { opacity: 1., faces, name: Some("Body grey".into()) }));
|
||||
let step = solidcraft_kernel::step_export(&[&named], "named").unwrap();
|
||||
let mut doc = Document::new("Named colours");
|
||||
doc.add_feature(FeatureKind::Import { file: "named.step".into(), step: step.into(), components: Vec::new(), skip: Vec::new() }, None)
|
||||
.unwrap();
|
||||
let mut model = solidcraft_doc::Model::new();
|
||||
model.evaluate(&doc);
|
||||
let names = |model: &solidcraft_doc::Model| {
|
||||
let state = model.state();
|
||||
let paint = state.bodies[0].body.paint().cloned().unwrap();
|
||||
(paint.name, paint.faces.iter().map(|f| f.name.clone()).collect::<Vec<_>>())
|
||||
};
|
||||
let evaluated = names(&model);
|
||||
assert_eq!(evaluated, (Some("Body grey".into()), vec![Some("Accent".into())]));
|
||||
let saved = model.saved(&doc).unwrap();
|
||||
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&saved)).unwrap();
|
||||
let read = DesignContainerReader::new(Cursor::new(&bytes)).unwrap().geometry().expect("the cache reads back");
|
||||
assert!(read.bodies.iter().all(|b| b.paint().is_none_or(|p| p.name.is_none())), "the body encoding has no names");
|
||||
let reopened = solidcraft_doc::Model::adopt(&doc, &read).unwrap();
|
||||
assert_eq!(names(&reopened), evaluated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_cache_keeps_mesh_bodies_with_the_rest_of_the_model() {
|
||||
use solidcore_record::Vec3;
|
||||
@@ -1485,7 +1515,7 @@ mod tests {
|
||||
let tetra = solidcraft_kernel::mesh_body(&corners, &[[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]])
|
||||
.unwrap()
|
||||
.with_color(Some([0.25, 0.5, 0.75]))
|
||||
.with_paint(Some(solidcraft_kernel::Paint { opacity: 0.5, faces: Vec::new() }));
|
||||
.with_paint(Some(solidcraft_kernel::Paint { opacity: 0.5, faces: Vec::new(), name: None }));
|
||||
let block = solidcraft_kernel::box_solid(Vec3::new(40., 0., 0.), Vec3::new(60., 20., 20.)).unwrap();
|
||||
let model = saved_model(vec![block, tetra.clone()]);
|
||||
let doc = document();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use super::*;
|
||||
use solidcraft_doc::{ModelState, SavedModel};
|
||||
|
||||
const FORMAT: &str = "solidcraft-model-cache/9";
|
||||
const FORMAT: &str = "solidcraft-model-cache/10";
|
||||
const PRODUCER: &str = "solidcraft-truck-adapter/4";
|
||||
const MANIFEST: &str = "cache/model.json";
|
||||
const PAYLOAD: &str = "cache/bodies.bin";
|
||||
|
||||
@@ -47,7 +47,7 @@ pub use dxf_out::{
|
||||
pub use legacy_dir::{APP_ID, LEGACY_APP_ID, PREVIOUS_APP_IDS, adopt_legacy_dir, legacy_flatpak_paths};
|
||||
pub use safe_write::{backup_path, write_atomic};
|
||||
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
|
||||
pub use svg::{decode_svg, read_svg, svg_nothing_to_import};
|
||||
pub use svg::{decode_svg, read_svg, svg_nothing_to_import, svg_sheet};
|
||||
mod gltf_in;
|
||||
pub use gltf_in::{GltfImport, Loader, MAX_GLTF_BYTES, MeshPlan, PlanPlace, glb_container, is_gltf_path, read_gltf};
|
||||
mod obj;
|
||||
|
||||
+61
-8
@@ -424,6 +424,51 @@ fn not_drawn(name: &str, a: &[(String, String)]) -> bool {
|
||||
.is_some_and(|s| s.split(';').filter_map(|d| d.split_once(':')).any(|(k, v)| k.trim().eq_ignore_ascii_case("display") && none(v)))
|
||||
}
|
||||
|
||||
/// The root element's transformation (user units to millimetres, y flipped) and its sheet in
|
||||
/// user units (`viewBox`, else `width` and `height` as a box at the origin), when it has one.
|
||||
fn root_transform(a: &[(String, String)]) -> (Tf, Option<[f64; 4]>) {
|
||||
let vb = attr(a, "viewBox").map(numbers).filter(|v| v.len() == 4);
|
||||
let (w, h) = (attr(a, "width").and_then(length_mm), attr(a, "height").and_then(length_mm));
|
||||
let (sx, sy, ox, oy, sheet) = match (vb, w, h) {
|
||||
(Some(v), Some(w), Some(h)) if v[2] > 0.0 && v[3] > 0.0 => (w / v[2], h / v[3], v[0], v[1], Some([v[0], v[1], v[2], v[3]])),
|
||||
(Some(v), _, _) => (PX_MM, PX_MM, v[0], v[1], (v[2] > 0.0 && v[3] > 0.0).then_some([v[0], v[1], v[2], v[3]])),
|
||||
(None, Some(w), Some(h)) if w > 0.0 && h > 0.0 => (PX_MM, PX_MM, 0.0, 0.0, Some([0.0, 0.0, w / PX_MM, h / PX_MM])),
|
||||
_ => (PX_MM, PX_MM, 0.0, 0.0, None),
|
||||
};
|
||||
(Tf([sx, 0.0, 0.0, -sy, -ox * sx, oy * sy]), sheet)
|
||||
}
|
||||
|
||||
/// The sheet of an SVG drawing in millimetres, in the coordinates its geometry comes out in: the
|
||||
/// root's `viewBox` (else its `width` and `height`) through the same transformation. None when
|
||||
/// the file gives no sheet.
|
||||
pub fn svg_sheet(text: &str) -> Option<(Vec2, Vec2)> {
|
||||
if text.len() > MAX_DRAWING_BYTES {
|
||||
return None;
|
||||
}
|
||||
let mut rd = Reader::from_reader(text.as_bytes());
|
||||
rd.config_mut().trim_text(true);
|
||||
let mut buf = Vec::new();
|
||||
let mut elements = 0;
|
||||
while elements < MAX_ENTITIES {
|
||||
match rd.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(e) | Event::Empty(e)) => {
|
||||
elements += 1;
|
||||
if e.local_name().as_ref() == b"svg" {
|
||||
let (tf, sheet) = root_transform(&attrs(&e));
|
||||
let [x, y, w, h] = sheet?;
|
||||
let (a, b) = (tf.apply(Vec2::new(x, y)), tf.apply(Vec2::new(x + w, y + h)));
|
||||
let (lo, hi) = (Vec2::new(a.x.min(b.x), a.y.min(b.y)), Vec2::new(a.x.max(b.x), a.y.max(b.y)));
|
||||
return (lo.is_finite() && hi.is_finite() && hi.x > lo.x && hi.y > lo.y).then_some((lo, hi));
|
||||
}
|
||||
}
|
||||
Ok(Event::Eof) | Err(_) => return None,
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read the geometry of an SVG document (millimetres, y up). `scale` multiplies the result.
|
||||
pub fn read_svg(text: &str) -> Result<Vec<Geom2>> {
|
||||
if text.len() > MAX_DRAWING_BYTES {
|
||||
@@ -490,14 +535,7 @@ pub fn read_svg(text: &str) -> Result<Vec<Geom2>> {
|
||||
None => parent,
|
||||
};
|
||||
if name == "svg" && root.is_none() {
|
||||
// Root: user units to millimetres, y flipped.
|
||||
let vb = attr(&a, "viewBox").map(numbers).filter(|v| v.len() == 4);
|
||||
let (sx, sy, ox, oy) = match (vb, attr(&a, "width").and_then(length_mm), attr(&a, "height").and_then(length_mm)) {
|
||||
(Some(v), Some(w), Some(h)) if v[2] > 0.0 && v[3] > 0.0 => (w / v[2], h / v[3], v[0], v[1]),
|
||||
(Some(v), _, _) => (PX_MM, PX_MM, v[0], v[1]),
|
||||
_ => (PX_MM, PX_MM, 0.0, 0.0),
|
||||
};
|
||||
let r = Tf([sx, 0.0, 0.0, -sy, -ox * sx, oy * sy]);
|
||||
let (r, _) = root_transform(&a);
|
||||
root = Some(r);
|
||||
tf = r;
|
||||
}
|
||||
@@ -638,6 +676,21 @@ pub fn svg_nothing_to_import(text: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The sheet: the viewBox in the millimetres `width` and `height` give, else in pixels; only
|
||||
/// `width` and `height` make a sheet at the origin; without either there is none.
|
||||
#[test]
|
||||
fn the_sheet_follows_the_root_like_the_geometry() {
|
||||
let close = |a: Vec2, b: Vec2| (a.x - b.x).abs() < 1e-9 && (a.y - b.y).abs() < 1e-9;
|
||||
let both = svg_sheet(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100mm" height="50mm" viewBox="0 0 200 100"/>"#).unwrap();
|
||||
assert!(close(both.0, Vec2::new(0.0, -50.0)) && close(both.1, Vec2::new(100.0, 0.0)), "{both:?}");
|
||||
let vb = svg_sheet(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 20 200 100"/>"#).unwrap();
|
||||
assert!(close(vb.0, Vec2::new(0.0, -100.0 * PX_MM)) && close(vb.1, Vec2::new(200.0 * PX_MM, 0.0)), "{vb:?}");
|
||||
let wh = svg_sheet(r#"<svg xmlns="http://www.w3.org/2000/svg" width="30mm" height="20mm"/>"#).unwrap();
|
||||
assert!(close(wh.0, Vec2::new(0.0, -20.0)) && close(wh.1, Vec2::new(30.0, 0.0)), "{wh:?}");
|
||||
assert!(svg_sheet(r#"<svg xmlns="http://www.w3.org/2000/svg"><rect width="3" height="4"/></svg>"#).is_none());
|
||||
assert!(svg_sheet(r#"<svg viewBox="0 0 0 10"/>"#).is_none(), "a sheet without area is none");
|
||||
}
|
||||
|
||||
/// The parser recurses over groups and reads numbers out of a document the user did not write,
|
||||
/// so none of these may panic: `catch_unwind` turns one into a failed test, the way
|
||||
/// `hostile_files_never_panic` does for STEP (solidcraft#469).
|
||||
|
||||
+394
-34
@@ -278,8 +278,97 @@ fn fillet_turning(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) ->
|
||||
pub fn fillet_removing(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<(Body, Vec<Vec3>)> {
|
||||
let _span = solidcraft_profile::span("kernel", "fillet");
|
||||
body.require_brep("fillet")?;
|
||||
let (b, gone) = crate::blend_smlib::fillet_removing(body, edges, radius, turn)?;
|
||||
Ok((closed(body, b)?, gone))
|
||||
match crate::blend_smlib::fillet_removing(body, edges, radius, turn) {
|
||||
Ok((b, gone)) => Ok((closed(body, b)?, gone)),
|
||||
Err(KernelError::TooLarge(why)) => match edges {
|
||||
[pick] => match concave_overflow_round(body, *pick, radius, turn) {
|
||||
Some(b) => Ok((b, Vec::new())),
|
||||
None => Err(KernelError::TooLarge(why)),
|
||||
},
|
||||
_ => Err(KernelError::TooLarge(why)),
|
||||
},
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// A concave round on one straight edge whose contact line runs off the flat face it rolls on
|
||||
/// (the face's outline turns away there, into a rounded corner or an opening), which SMLib
|
||||
/// refuses (solidcore#3252): the round of the edge on the body with that face carried on as a
|
||||
/// slab behind its plane, over the edge's length, the material that round adds kept within the
|
||||
/// face's outline (its sheet thickened along its normal) and joined to the body. So the round
|
||||
/// ends where the face's outline ends, on the surface of whatever the face turns into there,
|
||||
/// carried on square to the face.
|
||||
///
|
||||
/// Only where it gives the round SMLib would build on an unbounded face: the edge is straight,
|
||||
/// one of its faces is planar and it is concave (material in front of that face on the other
|
||||
/// face's side), and at each end of the edge a planar face square to it ends the round, so the
|
||||
/// slab's square ends add no material the round could run into. `None` where it does not apply
|
||||
/// or a step fails: the caller keeps SMLib's refusal. Decided on exact geometry.
|
||||
fn concave_overflow_round(body: &Body, pick: Vec3, radius: f64, turn: FilletTurn) -> Option<Body> {
|
||||
use solidcore_record::{Loop2, Plane, Region2, Vec2};
|
||||
if !(radius.is_finite() && radius > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let rec = Faces::of(body).ok()?;
|
||||
let (picked, _) = crate::blend_smlib::picked_edges(body, &[pick]).ok()?;
|
||||
let [e] = picked.as_slice() else { return None };
|
||||
let e = *e;
|
||||
if !rec.straight(e) {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = rec.ends(e)?;
|
||||
let length = a.dist(b);
|
||||
let d = (b - a).normalized()?;
|
||||
let size = body.size();
|
||||
if !(length > 0.0 && size.is_finite() && size > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let eps = (size * 1e-4).max(1e-4).min(radius * 0.1);
|
||||
let middle = (a + b) * 0.5;
|
||||
let [vs, ve] = rec.rec.edges.get(e)?.vertices;
|
||||
// A planar face square to the edge, other than its own, at vertex v.
|
||||
let squared_at = |v: u32| {
|
||||
rec.rec.edges.iter().enumerate().filter(|(_, x)| x.vertices.contains(&v)).any(|(i, _)| {
|
||||
rec.faces_of(i)
|
||||
.into_iter()
|
||||
.any(|f| !rec.faces_of(e).contains(&f) && rec.plane_normal(f as usize).is_some_and(|n| n.dot(d).abs() > 1.0 - 1e-9))
|
||||
})
|
||||
};
|
||||
if !(squared_at(vs) && squared_at(ve)) {
|
||||
return None;
|
||||
}
|
||||
for f in rec.faces_of(e) {
|
||||
let f = f as usize;
|
||||
let Some(n) = rec.plane_normal(f) else { continue };
|
||||
if n.dot(d).abs() > 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let u = n.cross(d);
|
||||
// concave: material in front of the plane beside the edge, on the other face's side
|
||||
let inside = |p: Vec3| matches!(body.classify_point(p), Ok(crate::PointClass::Inside));
|
||||
if !(inside(middle + n * eps + u * eps) || inside(middle + n * eps - u * eps)) {
|
||||
continue;
|
||||
}
|
||||
let far = 4.0 * (size + radius);
|
||||
let plane = Plane { origin: a, x: d, y: u };
|
||||
let rect = Loop2::polygon(&[Vec2::new(0.0, -far), Vec2::new(length, -far), Vec2::new(length, far), Vec2::new(0.0, far)]);
|
||||
let slab = crate::build::extrude(&plane, &[Region2 { outer: rect, holes: vec![] }], -far, 0.0).ok()?.into_iter().next()?;
|
||||
let carried = crate::boolean(body, &slab, crate::BoolOp::Union).ok()??;
|
||||
let rounded = crate::blend_smlib::fillet(&carried, &[pick], radius, turn);
|
||||
let rounded = rounded.ok()?;
|
||||
let fill = crate::boolean(&rounded, &carried, crate::BoolOp::Cut).ok()??;
|
||||
// the face itself, by its index, for its sheet
|
||||
let sheet = crate::surfaces::taken(body, &rec.rec, &[(f, false)]).ok()?;
|
||||
let prism = crate::thicken(&sheet, 4.0 * (radius + size * 1e-3 + 1.0), true).ok()?;
|
||||
let kept = crate::boolean(&fill, &prism, crate::BoolOp::Intersect).ok()??;
|
||||
let joined = crate::boolean(body, &kept, crate::BoolOp::Union).ok()??;
|
||||
let joined = closed(body, joined).ok()?;
|
||||
let grew = |x: &Body| crate::measure(x).ok().map(|m| m.volume);
|
||||
if grew(&joined)? > grew(body)? {
|
||||
return Some(over_body_alone(joined, body, e));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// What a refusal of a setback too short for its corner says before the least that builds (in
|
||||
@@ -301,6 +390,61 @@ pub fn fillet_setback(body: &Body, edges: &[Vec3], radius: f64, setbacks: &[(Vec
|
||||
crate::blend_smlib::fillet_setback(body, edges, radius, setbacks).and_then(|b| closed(body, b))
|
||||
}
|
||||
|
||||
/// `joined` (the union of `body` and the material a round of its edge `edge` adds, its history
|
||||
/// over those two) with its history over `body` alone, as a fillet reports it: a face or an
|
||||
/// edge of the body stays that; a face only the added material brought is made in place of
|
||||
/// the edge, and an edge where such a face meets a face of the body is where the edge's round
|
||||
/// meets that face. Without a history where the union reported none or the rewritten one does
|
||||
/// not describe the body: a result is never given a guessed history.
|
||||
fn over_body_alone(joined: Body, body: &Body, edge: usize) -> Body {
|
||||
use crate::history::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, History, Side};
|
||||
let Some(union) = joined.history().filter(|h| h.operands == 2).cloned() else { return joined.without_history() };
|
||||
let round = FaceOrigin::Edge { operand: 0, edge };
|
||||
// An origin in the body stays; one in the added material is the round.
|
||||
let of_body = |o: &FaceOrigin| match *o {
|
||||
FaceOrigin::Face { operand, .. } | FaceOrigin::Edge { operand, .. } | FaceOrigin::Offset { operand, .. } => operand == 0,
|
||||
FaceOrigin::Profile { .. } | FaceOrigin::Cap { .. } => false,
|
||||
};
|
||||
let faces = union
|
||||
.faces
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let origins: Vec<FaceOrigin> = f.origins.iter().filter(|o| of_body(o)).copied().collect();
|
||||
if origins.is_empty() {
|
||||
return FaceProv { origins: vec![round], change: Change::Generated };
|
||||
}
|
||||
// A face of the body the added material's face was joined to is that face, grown.
|
||||
let change = if f.change == Change::Merged && origins.len() == 1 { Change::Modified } else { f.change };
|
||||
FaceProv { origins, change }
|
||||
})
|
||||
.collect();
|
||||
let side = |s: &Side| Side::new(s.origins().iter().map(|o| if of_body(o) { *o } else { round }));
|
||||
let edges = union
|
||||
.edges
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let origins = e
|
||||
.origins
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
EdgeOrigin::Edge { operand: 0, .. } => Some(o.clone()),
|
||||
EdgeOrigin::Edge { .. } => None,
|
||||
EdgeOrigin::Meet { a, b } => match (side(a), side(b)) {
|
||||
(Some(a), Some(b)) if a != b => Some(EdgeOrigin::meet(a, b)),
|
||||
_ => None,
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
EdgeProv { faces: e.faces.clone(), origins }
|
||||
})
|
||||
.collect();
|
||||
let history = History { operands: 1, faces, edges };
|
||||
match history.check_counts((joined.face_count(), joined.edge_count()), &crate::record::counts(&[body])) {
|
||||
Ok(()) => joined.clone().with_history_counts(history, &crate::record::counts(&[body])).unwrap_or_else(|_| joined.without_history()),
|
||||
Err(_) => joined.without_history(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses a setback that does not reach past where its corner's rounds meet without one
|
||||
/// ([`setback_minimum`]), naming the least it must exceed, at a corner where every edge is one
|
||||
/// of `edges` (the others are SMLib's to refuse). SMLib fails there with its own status
|
||||
@@ -541,31 +685,41 @@ fn radius_step(radius: f64) -> f64 {
|
||||
/// The largest radius below `radius` (to three significant digits) at which the fillet of
|
||||
/// these edges works, with its result; `Ok(None)` when none does. Each candidate is a real fillet
|
||||
/// of the edges, so the radius returned is one that was built; the search runs up to 16 of
|
||||
/// them (a refusal of a fillet feature's exact fit runs it to name the largest that fits, and
|
||||
/// so does `fit: reduce`). Only a refusal as too large
|
||||
/// ([`KernelError::TooLarge`]) narrows the search: any other failure of a candidate is the
|
||||
/// them, plus up to 6 led by the sizes the refusals name (a refusal of a fillet feature's exact
|
||||
/// fit runs it to name the largest that fits, and so does `fit: reduce`). Only a refusal as too
|
||||
/// large ([`KernelError::TooLarge`]) narrows the search: any other failure of a candidate is the
|
||||
/// search's error, since a smaller radius would not be the largest that fits. The rounds turn at
|
||||
/// side edges as `turn` says.
|
||||
pub fn fillet_max(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<Option<(f64, Body)>> {
|
||||
fillet_max_why(body, edges, radius, turn).map(|search| search.found)
|
||||
fillet_max_why(body, edges, radius, turn, None).map(|search| search.found)
|
||||
}
|
||||
|
||||
/// [`fillet_max`], with what the search found when nothing builds ([`FitSearch`]).
|
||||
pub fn fillet_max_why(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<FitSearch> {
|
||||
/// [`fillet_max`], with what the search found when nothing builds ([`FitSearch`]). `hint`: the
|
||||
/// largest radius the refusal of `radius` named ([`named_fit`]), tried first.
|
||||
pub fn fillet_max_why(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn, hint: Option<f64>) -> Result<FitSearch> {
|
||||
let _span = solidcraft_profile::span("kernel", "fillet_max");
|
||||
largest_that_fits(radius, |r| fillet_turning(body, edges, r, turn))
|
||||
largest_that_fits(radius, hint, |r| fillet_turning(body, edges, r, turn))
|
||||
}
|
||||
|
||||
/// [`fillet_max`] for an equal-distance chamfer of these edges: the largest distance below
|
||||
/// `distance` (to three significant digits) at which it works, with its result.
|
||||
pub fn chamfer_max(body: &Body, edges: &[Vec3], distance: f64) -> Result<Option<(f64, Body)>> {
|
||||
chamfer_max_why(body, edges, distance).map(|search| search.found)
|
||||
chamfer_max_why(body, edges, distance, None).map(|search| search.found)
|
||||
}
|
||||
|
||||
/// [`chamfer_max`], with what the search found when nothing builds ([`FitSearch`]).
|
||||
pub fn chamfer_max_why(body: &Body, edges: &[Vec3], distance: f64) -> Result<FitSearch> {
|
||||
/// [`chamfer_max`], with what the search found when nothing builds ([`FitSearch`]). `hint` as
|
||||
/// for [`fillet_max_why`].
|
||||
pub fn chamfer_max_why(body: &Body, edges: &[Vec3], distance: f64, hint: Option<f64>) -> Result<FitSearch> {
|
||||
let _span = solidcraft_profile::span("kernel", "chamfer_max");
|
||||
largest_that_fits(distance, |d| chamfer(body, edges, d))
|
||||
largest_that_fits(distance, hint, |d| chamfer(body, edges, d))
|
||||
}
|
||||
|
||||
/// The largest size in mm a refusal names ([`largest_fit_note`]): 3 for "...; the largest radius
|
||||
/// that fits is 3 mm". `None` where it names none, or names a scale ("2 times the distances").
|
||||
pub fn named_fit(why: &str) -> Option<f64> {
|
||||
let (_, rest) = why.split_once("that fits is ")?;
|
||||
let (number, _) = rest.split_once(" mm")?;
|
||||
number.parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0)
|
||||
}
|
||||
|
||||
/// What [`largest_that_fits`] found: the largest size it built, with its result, or (none built)
|
||||
@@ -576,17 +730,29 @@ pub struct FitSearch {
|
||||
pub found: Option<(f64, Body)>,
|
||||
/// When nothing built: the smallest size tried and how it failed.
|
||||
pub smallest_refused: Option<(f64, KernelError)>,
|
||||
/// How many candidates were built or refused (the search's work).
|
||||
pub tried: usize,
|
||||
}
|
||||
|
||||
/// The largest size n·q below `size` (q: three significant digits of it) at which `blend` works,
|
||||
/// by bisection over a bounded number of blends. A candidate refused as too large or failing as an
|
||||
/// operation (its check refusing the result, say) narrows the search: a size that does not build is
|
||||
/// not one that fits, so the size found is one that was built. With none built, the smallest size
|
||||
/// tried and its failure are kept ([`FitSearch::smallest_refused`]). Bad input and the kernel's own
|
||||
/// faults are errors, as before.
|
||||
fn largest_that_fits(size: f64, blend: impl Fn(f64) -> Result<Body>) -> Result<FitSearch> {
|
||||
/// over a bounded number of blends. A candidate refused as too large or failing as an operation
|
||||
/// (its check refusing the result, say) narrows the search: a size that does not build is not one
|
||||
/// that fits, so the size found is one that was built, and (unless the cap stops the search) the
|
||||
/// next size up, n·q + q, was refused. With none built, the smallest size tried and its failure are
|
||||
/// kept ([`FitSearch::smallest_refused`]). Bad input and the kernel's own faults are errors.
|
||||
///
|
||||
/// The search brackets the largest size between the largest built (`lo`) and the smallest refused
|
||||
/// (`hi`), and each candidate narrows the bracket, by bisection where nothing better is known. A
|
||||
/// refusal as too large names the largest size it estimates fits ([`named_fit`]; `hint` for the
|
||||
/// request's own refusal), usually within a step or two of it: that size is tried next (unless it
|
||||
/// is within 1% of the size refused, which says nothing of where the largest lies), and once
|
||||
/// it builds the sizes above it are tried 1, 2, 4, ... steps up until one is refused, so a
|
||||
/// close estimate settles the search in two or three blends where bisection takes eleven or
|
||||
/// more. Where building is monotone in the size (everything below the largest that fits builds),
|
||||
/// the size found is the one bisection finds; it never depends on how the bracket narrowed.
|
||||
fn largest_that_fits(size: f64, hint: Option<f64>, blend: impl Fn(f64) -> Result<Body>) -> Result<FitSearch> {
|
||||
if !(size.is_finite() && size > 1e-6) {
|
||||
return Ok(FitSearch { found: None, smallest_refused: None });
|
||||
return Ok(FitSearch { found: None, smallest_refused: None, tried: 0 });
|
||||
}
|
||||
let q = radius_step(size);
|
||||
// Sizes n·q for n in [0, hi): hi is too large (it is the request, or above it), lo works.
|
||||
@@ -594,22 +760,64 @@ fn largest_that_fits(size: f64, blend: impl Fn(f64) -> Result<Body>) -> Result<F
|
||||
let mut lo = 0u64;
|
||||
let mut best: Option<Body> = None;
|
||||
let mut smallest_refused: Option<(f64, KernelError)> = None;
|
||||
// log2(10⁴) steps reach the step size; the cap keeps a slow body from stalling.
|
||||
for _ in 0..16 {
|
||||
if hi - lo <= 1 {
|
||||
break;
|
||||
}
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
// The step on the grid at or below an estimate (a hair over, so 1.24 / 0.001 is 1240), for an
|
||||
// estimate that says where the largest size lies: one at least 1% below the size refused. An
|
||||
// estimate a margin below it (2.99 for 3: solidcore names the size less a margin where the
|
||||
// ball is as wide as a face it rolls on) says only that the size was refused, and the blend
|
||||
// just under such a limit can be the slowest to try: on the engine fuzzer's seed-2 design a
|
||||
// 2.99 mm round ran 834 s before it failed, where bisection's 1.5 mm took 2 s.
|
||||
let lead = |estimate: f64, refused: f64| (estimate < 0.99 * refused).then(|| (estimate / q * (1.0 + 1e-12)).floor() as u64);
|
||||
let mut next_hint = hint.and_then(|e| lead(e, size));
|
||||
// Steps up from `lo` while climbing from a built estimate.
|
||||
let mut climb: Option<u64> = None;
|
||||
// log2(10⁴) bisection steps reach the step size, and the estimates lead at most 6 more; the
|
||||
// caps keep a slow body from stalling.
|
||||
let (mut bisections, mut led, mut tried) = (0, 0, 0);
|
||||
while hi - lo > 1 {
|
||||
let guided = if led < 6 {
|
||||
match next_hint.take().filter(|h| lo < *h && *h < hi) {
|
||||
Some(h) => Some((h, true)),
|
||||
None => climb.map(|step| (lo.saturating_add(step).min(hi - 1), false)),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (n, from_hint) = match guided {
|
||||
Some(g) => {
|
||||
led += 1;
|
||||
g
|
||||
}
|
||||
None if bisections < 16 => {
|
||||
bisections += 1;
|
||||
(lo + (hi - lo) / 2, false)
|
||||
}
|
||||
None => break,
|
||||
};
|
||||
let was_guided = guided.is_some();
|
||||
tried += 1;
|
||||
// A candidate fits only as a body SMLib's check accepts: each is checked as it is built,
|
||||
// whatever the evaluation's validation mode (`crate::validation`).
|
||||
match crate::validation::validating(crate::Validation::EachStep, || blend(mid as f64 * q)).0 {
|
||||
match crate::validation::validating(crate::Validation::EachStep, || blend(n as f64 * q)).0 {
|
||||
Ok(b) => {
|
||||
lo = mid;
|
||||
let step = n - lo;
|
||||
lo = n;
|
||||
best = Some(b);
|
||||
// Climb from a built estimate, doubling the step each time one builds.
|
||||
climb = if from_hint {
|
||||
Some(1)
|
||||
} else if was_guided {
|
||||
Some(step.saturating_mul(2))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
Err(e @ (KernelError::TooLarge(_) | KernelError::Failed(_))) => {
|
||||
hi = mid;
|
||||
smallest_refused = Some((mid as f64 * q, e));
|
||||
hi = n;
|
||||
climb = None;
|
||||
if let KernelError::TooLarge(why) = &e {
|
||||
next_hint = named_fit(why).and_then(|est| lead(est, n as f64 * q));
|
||||
}
|
||||
smallest_refused = Some((n as f64 * q, e));
|
||||
}
|
||||
// Not a size the blend could not take: bad input, or the kernel's own fault.
|
||||
Err(e) => return Err(e),
|
||||
@@ -617,7 +825,7 @@ fn largest_that_fits(size: f64, blend: impl Fn(f64) -> Result<Body>) -> Result<F
|
||||
}
|
||||
let found = best.map(|b| (lo as f64 * q, b));
|
||||
let smallest_refused = if found.is_some() { None } else { smallest_refused };
|
||||
Ok(FitSearch { found, smallest_refused })
|
||||
Ok(FitSearch { found, smallest_refused, tried })
|
||||
}
|
||||
|
||||
/// [`fillet_max`] for each group of the edges that may limit each other's radius (sharing a
|
||||
@@ -1202,12 +1410,80 @@ mod tests {
|
||||
use super::*;
|
||||
use solidcore_record::{Loop2, Plane, Region2, Vec2};
|
||||
|
||||
/// The plate with its r16 corner, boss and cut (SolidCraft's filletfile.sc).
|
||||
fn boss_on_rounded_plate() -> Body {
|
||||
let plate = crate::box_solid(Vec3::new(0.0, -10.5, -100.0), Vec3::new(48.5, 0.0, 0.0)).unwrap();
|
||||
let plate = fillet(&plate, &[Vec3::new(48.5, -5.25, 0.0)], 16.0).unwrap();
|
||||
let boss = crate::cylinder(Vec3::new(14.388009991673611, -14.038301415494109, -24.0), Vec3::Z, 12.90348790056394, 24.0).unwrap();
|
||||
let body = crate::boolean(&plate, &boss, crate::BoolOp::Union).unwrap().unwrap();
|
||||
let cut = crate::box_solid(Vec3::new(-1.0, -11.0, -64.0), Vec3::new(26.796894836538296, 1.0, -24.0)).unwrap();
|
||||
crate::boolean(&body, &cut, crate::BoolOp::Cut).unwrap().unwrap()
|
||||
}
|
||||
|
||||
/// The boss's concave base edge on the rounded plate: rounded 5 (its contact line on the
|
||||
/// plate's front face stays short of x 32.5, where the face's top edge turns into the r16
|
||||
/// corner's arc) SMLib builds it; rounded 6 and 8 the line runs past there and SMLib
|
||||
/// refuses it (solidcore#3252), and the round is built by carrying the face on: its
|
||||
/// material is the round's section between the plane and the boss (exact, by integration
|
||||
/// below) over the boss's 24 mm, less the sliver past the arc. At 5 the carried-on build
|
||||
/// gives SMLib's volume. Closed, one shell, inside the plate's box.
|
||||
#[test]
|
||||
fn a_concave_round_runs_on_past_where_its_face_turns_into_a_round() {
|
||||
let body = boss_on_rounded_plate();
|
||||
let before = crate::measure(&body).unwrap();
|
||||
let pick = Vec3::new(26.796894836538296, -10.5, -12.0);
|
||||
// the round's section between the plate's plane, the boss's circle and the round's arc
|
||||
let section = |r: f64| {
|
||||
let (cx, cy, big, yp) = (14.388009991673611, -14.038301415494109, 12.90348790056394, -10.5);
|
||||
let (bx, by) = (cx + ((big + r).powi(2) - (yp - r - cy).powi(2)).sqrt(), yp - r);
|
||||
let k = big / (big + r);
|
||||
let (tx, ty) = (cx + (bx - cx) * k, cy + (by - cy) * k);
|
||||
let ex = cx + (big * big - (yp - cy).powi(2)).sqrt();
|
||||
let mut pts = vec![(ex, yp), (bx, yp)];
|
||||
let n = 4000;
|
||||
let (a0, a1) = ((yp - by).atan2(0.0), (ty - by).atan2(tx - bx));
|
||||
pts.extend((1..=n).map(|i| {
|
||||
let t = a0 + (a1 - a0) * f64::from(i) / f64::from(n);
|
||||
(bx + r * t.cos(), by + r * t.sin())
|
||||
}));
|
||||
let (c0, c1) = ((ty - cy).atan2(tx - cx), (yp - cy).atan2(ex - cx));
|
||||
pts.extend((1..n).map(|i| {
|
||||
let t = c0 + (c1 - c0) * f64::from(i) / f64::from(n);
|
||||
(cx + big * t.cos(), cy + big * t.sin())
|
||||
}));
|
||||
let twice: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum();
|
||||
twice.abs() / 2.0
|
||||
};
|
||||
let smlib = crate::measure(&fillet(&body, &[pick], 5.0).unwrap()).unwrap().volume;
|
||||
let (carried, _) = fillet_removing(&body, &[pick], 5.0, FilletTurn::Mitre).unwrap();
|
||||
assert!((crate::measure(&carried).unwrap().volume - smlib).abs() < 1e-6);
|
||||
assert!((smlib - before.volume - section(5.0) * 24.0).abs() < 1e-3, "{}", smlib - before.volume);
|
||||
for r in [6.0, 8.0] {
|
||||
assert!(matches!(fillet(&body, &[pick], r), Err(KernelError::TooLarge(_))), "SMLib refuses r {r}");
|
||||
let (rounded, gone) = fillet_removing(&body, &[pick], r, FilletTurn::Mitre).unwrap_or_else(|e| panic!("r {r}: {e}"));
|
||||
assert!(gone.is_empty());
|
||||
// its history is a fillet's, over the body alone: a face made in place of the edge
|
||||
let (picked, _) = crate::blend_smlib::picked_edges(&body, &[pick]).unwrap();
|
||||
let h = rounded.history().expect("a history");
|
||||
assert_eq!(h.operands, 1);
|
||||
assert!(h.faces.iter().any(|f| f.origins == [crate::history::FaceOrigin::Edge { operand: 0, edge: picked[0] }]), "r {r}");
|
||||
// and every face of the body is still there, under its own origin
|
||||
let still = |i: usize| h.faces.iter().any(|f| f.origins.contains(&crate::history::FaceOrigin::Face { operand: 0, face: i }));
|
||||
assert!((0..body.face_count()).all(still), "r {r}");
|
||||
let m = crate::measure(&rounded).unwrap();
|
||||
let added = m.volume - before.volume;
|
||||
let full = section(r) * 24.0;
|
||||
assert!(m.shells == 1 && (full - added).abs() < 0.05, "r {r}: added {added} of {full}");
|
||||
assert!(m.bbox.min.dist(before.bbox.min) < 1e-9 && m.bbox.max.dist(before.bbox.max) < 1e-9, "r {r}: {:?}", m.bbox);
|
||||
}
|
||||
}
|
||||
|
||||
/// A search where every candidate is refused as too large keeps the smallest size it tried
|
||||
/// and that candidate's own refusal, and builds nothing: the caller can say why nothing
|
||||
/// smaller builds instead of repeating the first refusal's estimate.
|
||||
#[test]
|
||||
fn a_search_that_builds_nothing_keeps_its_smallest_refusal() {
|
||||
let search = largest_that_fits(0.3, |d| Err(KernelError::TooLarge(format!("refused at {d}")))).unwrap();
|
||||
let search = largest_that_fits(0.3, None, |d| Err(KernelError::TooLarge(format!("refused at {d}")))).unwrap();
|
||||
assert!(search.found.is_none());
|
||||
let (at, e) = search.smallest_refused.expect("a refusal");
|
||||
assert!(at > 0.0 && at < 0.001, "the bisection goes down to the step: {at}");
|
||||
@@ -1218,7 +1494,8 @@ mod tests {
|
||||
#[test]
|
||||
fn a_search_that_builds_reports_what_it_built() {
|
||||
let body = crate::build::box_solid(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0)).unwrap();
|
||||
let search = largest_that_fits(0.3, |d| if d <= 0.1 { Ok(body.clone()) } else { Err(KernelError::TooLarge("too large".into())) }).unwrap();
|
||||
let search =
|
||||
largest_that_fits(0.3, None, |d| if d <= 0.1 { Ok(body.clone()) } else { Err(KernelError::TooLarge("too large".into())) }).unwrap();
|
||||
let (at, _) = search.found.expect("built");
|
||||
assert!((0.099..=0.1).contains(&at), "{at}");
|
||||
assert!(search.smallest_refused.is_none());
|
||||
@@ -1230,11 +1507,94 @@ mod tests {
|
||||
fn a_candidate_failing_the_check_narrows_the_search() {
|
||||
let body = crate::build::box_solid(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0)).unwrap();
|
||||
let search =
|
||||
largest_that_fits(1.0, |r| if r <= 0.95 { Ok(body.clone()) } else { Err(KernelError::Failed("the check refuses it".into())) }).unwrap();
|
||||
largest_that_fits(1.0, None, |r| if r <= 0.95 { Ok(body.clone()) } else { Err(KernelError::Failed("the check refuses it".into())) })
|
||||
.unwrap();
|
||||
let (at, _) = search.found.expect("built");
|
||||
assert!((0.949..=0.95).contains(&at), "{at}");
|
||||
}
|
||||
|
||||
/// A refusal that names an estimate within a step of the largest size that fits settles the
|
||||
/// search in two blends (the estimate builds, the next step up is refused) where bisection
|
||||
/// takes eleven, and finds the same size. Work counter: `FitSearch::tried`.
|
||||
#[test]
|
||||
fn a_close_estimate_settles_the_search_in_two_blends() {
|
||||
let body = crate::build::box_solid(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0)).unwrap();
|
||||
// Builds up to 1.904 mm (q = 0.001 for a 2 mm request); a refusal estimates 1.904762.
|
||||
// Sizes are n·q, as the search builds them.
|
||||
let blend = |r: f64| {
|
||||
if r <= 1.9045 { Ok(body.clone()) } else { Err(KernelError::TooLarge(format!("too large; {}", largest_fit_note("radius", 1.904762)))) }
|
||||
};
|
||||
// Bisection alone: the same refusals, naming no estimate.
|
||||
let unnamed = |r: f64| if r <= 1.9045 { Ok(body.clone()) } else { Err(KernelError::TooLarge("too large".into())) };
|
||||
let plain = largest_that_fits(2.0, None, unnamed).unwrap();
|
||||
let led = largest_that_fits(2.0, Some(1.904762), blend).unwrap();
|
||||
assert_eq!(plain.found.as_ref().map(|f| f.0), Some(1904.0 * 0.001));
|
||||
assert_eq!(led.found.as_ref().map(|f| f.0), Some(1904.0 * 0.001));
|
||||
assert!(plain.tried >= 11, "bisection alone: {}", plain.tried);
|
||||
assert_eq!(led.tried, 2);
|
||||
// With no estimate of its own, the first candidate refused leads with its estimate.
|
||||
let after_one = largest_that_fits(2.0, None, blend).unwrap();
|
||||
assert_eq!(after_one.found.as_ref().map(|f| f.0), Some(1904.0 * 0.001));
|
||||
assert!(after_one.tried < plain.tried, "{} against {}", after_one.tried, plain.tried);
|
||||
}
|
||||
|
||||
/// The seed-2 design of the engine's fuzzer refused a 3 mm round naming 2.99 mm (the size less
|
||||
/// a margin: the ball was as wide as a face it rolls on), while its candidates' refusals named
|
||||
/// 1.24 mm against 1.25 that builds. The 2.99 is not followed (a 2.99 mm round there ran 834 s
|
||||
/// before it failed): the search bisects to 1.5 first, then follows 1.24 up a step, then 2,
|
||||
/// 4, ... and finds 1.25 as bisection does, in fewer blends. Over every estimate, above or
|
||||
/// below the size, the answer is bisection's.
|
||||
#[test]
|
||||
fn estimates_below_or_above_the_size_find_what_bisection_finds() {
|
||||
let body = crate::build::box_solid(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0)).unwrap();
|
||||
// A refusal naming `names` (none where it is NaN), of a round that builds up to `fits`.
|
||||
let refusing = |fits: f64, names: f64| {
|
||||
let body = body.clone();
|
||||
move |r: f64| {
|
||||
if r <= fits + 1e-9 {
|
||||
Ok(body.clone())
|
||||
} else if names.is_nan() {
|
||||
Err(KernelError::TooLarge("too large".into()))
|
||||
} else {
|
||||
Err(KernelError::TooLarge(format!("too large; {}", largest_fit_note("radius", names))))
|
||||
}
|
||||
}
|
||||
};
|
||||
let plain = largest_that_fits(3.0, None, refusing(1.25, f64::NAN)).unwrap();
|
||||
let tried = std::cell::RefCell::new(Vec::new());
|
||||
let seed2 = refusing(1.25, 1.24);
|
||||
let led = largest_that_fits(3.0, Some(2.99), |r| {
|
||||
tried.borrow_mut().push(r);
|
||||
seed2(r)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(tried.borrow().first().copied(), Some(1500.0 * 0.001), "{:?}", tried.borrow());
|
||||
assert!(tried.borrow().iter().all(|r| *r < 2.0), "{:?}", tried.borrow());
|
||||
assert_eq!(plain.found.as_ref().map(|f| f.0), Some(1250.0 * 0.001));
|
||||
assert_eq!(led.found.as_ref().map(|f| f.0), Some(1250.0 * 0.001));
|
||||
assert!(led.tried < plain.tried, "{} against {}", led.tried, plain.tried);
|
||||
// Every size the estimates may point at, over and under: the same answer as bisection.
|
||||
for fits in [0.5, 1.0, 1.25, 1.999, 2.5, 2.998] {
|
||||
let plain = largest_that_fits(3.0, None, refusing(fits, f64::NAN)).unwrap().found.map(|f| f.0);
|
||||
for names in [0.001, 0.4, fits - 0.01, fits - 0.001, fits, fits + 0.001, fits + 0.2, 2.99, 5.0] {
|
||||
for hint in [None, Some(names), Some(2.99)] {
|
||||
let led = largest_that_fits(3.0, hint, refusing(fits, names)).unwrap();
|
||||
assert_eq!(led.found.map(|f| f.0), plain, "fits {fits}, refusals name {names}, hint {hint:?}");
|
||||
assert!(led.tried <= 22, "{}", led.tried);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A refusal names its estimate as the largest size that fits, and only then.
|
||||
#[test]
|
||||
fn a_refusal_names_its_estimate() {
|
||||
assert_eq!(named_fit("too large, at the edge at (1, 2, 3); the largest radius that fits is 1.904762 mm (tangent chain)"), Some(1.904762));
|
||||
assert_eq!(named_fit(&format!("x; {}", largest_fit_note("distance", 0.25))), Some(0.25));
|
||||
assert_eq!(named_fit("too large; the largest that fits is 2 times the distances given"), None);
|
||||
assert_eq!(named_fit("the trim leaks"), None);
|
||||
}
|
||||
|
||||
/// The largest-fit clause comes out of a refusal with its separator, and nothing else does.
|
||||
#[test]
|
||||
fn the_fit_note_comes_out_with_its_separator() {
|
||||
|
||||
@@ -481,6 +481,18 @@ mod imp {
|
||||
KernelError::TooLarge(format!("the {what} is larger than the faces beside its edges allow{beside}; {fits}"))
|
||||
}
|
||||
SmError::Unported(why) => KernelError::Invalid(format!("not supported yet: {why}")),
|
||||
// Not too large: a longer setback. Named as building only where solidcore's fill
|
||||
// is the corner; where another patch replaces it (a G2 corner) it is what
|
||||
// solidcore's corner needs, which that patch may not fit.
|
||||
SmError::SetbackTooShort { least, at, builds } => {
|
||||
let at = at.map(|[x, y, z]| format!(" at ({x:.3}, {y:.3}, {z:.3})")).unwrap_or_default();
|
||||
let least = crate::blend::trimmed_mm(least);
|
||||
KernelError::Invalid(if builds {
|
||||
format!("the setback{at} of the {what} is too short for its corner's fill; {}{least} mm", crate::blend::SETBACK_AT_LEAST)
|
||||
} else {
|
||||
format!("the setback{at} of the {what} is too short for its corner's fill: solidcore's corner needs a setback of at least {least} mm")
|
||||
})
|
||||
}
|
||||
e => failed(what, "operation", e),
|
||||
})?;
|
||||
// Checked here unless the evaluation's validation mode leaves it to the feature's end
|
||||
|
||||
@@ -240,14 +240,32 @@ pub struct Paint {
|
||||
/// 1 = opaque.
|
||||
pub opacity: f32,
|
||||
pub faces: Vec<FacePaint>,
|
||||
/// The body colour's name in the file it came from (a STEP `COLOUR_RGB` name), if it had one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// A face's own colour: the B-rep face index (`face_iter` order), sRGB as 0..1, opacity.
|
||||
/// A face's own colour: the B-rep face index (`face_iter` order), sRGB as 0..1, opacity, and
|
||||
/// its name in the file it came from, if it had one.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct FacePaint {
|
||||
pub face: usize,
|
||||
pub color: [f32; 3],
|
||||
pub opacity: f32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// The most characters a colour name read from a file keeps (as a design look's name).
|
||||
pub const COLOUR_NAME_MAX: usize = 128;
|
||||
|
||||
/// A colour name read from a file, made safe to show and keep: control characters dropped,
|
||||
/// surrounding space trimmed, at most [`COLOUR_NAME_MAX`] characters. `None` when nothing is left.
|
||||
pub fn colour_name(raw: &str) -> Option<String> {
|
||||
let cleaned: String = raw.chars().filter(|c| !c.is_control()).collect();
|
||||
let name: String = cleaned.trim().chars().take(COLOUR_NAME_MAX).collect();
|
||||
let name = name.trim_end();
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// A B-rep edge as seen by the rest of the application.
|
||||
@@ -980,21 +998,24 @@ impl Body {
|
||||
self.paint.as_deref()
|
||||
}
|
||||
|
||||
/// The same body with opacity and face colours for export (`None` or opaque with no face
|
||||
/// colours clears them).
|
||||
/// The same body with opacity, face colours and colour names for export (`None`, or opaque
|
||||
/// with no face colours and no name, clears them).
|
||||
pub fn with_paint(mut self, p: Option<Paint>) -> Body {
|
||||
let clean = |x: f32| if x.is_finite() { x.clamp(0.0, 1.0) } else { 1.0 };
|
||||
self.paint = p
|
||||
.map(|mut p| {
|
||||
p.opacity = clean(p.opacity);
|
||||
p.name = p.name.as_deref().and_then(colour_name);
|
||||
p.faces.retain(|f| f.color.iter().all(|x| x.is_finite()));
|
||||
for f in &mut p.faces {
|
||||
f.color = f.color.map(|x| x.clamp(0.0, 1.0));
|
||||
f.opacity = clean(f.opacity);
|
||||
f.name = f.name.as_deref().and_then(colour_name);
|
||||
}
|
||||
p
|
||||
})
|
||||
.filter(|p| p.opacity < 1.0 || !p.faces.is_empty())
|
||||
// An opaque body colour's name is kept with it: the paint holds it.
|
||||
.filter(|p| p.opacity < 1.0 || !p.faces.is_empty() || p.name.is_some())
|
||||
.map(std::sync::Arc::new);
|
||||
self.regularization_protected |= self.paint().is_some_and(|paint| !paint.faces.is_empty());
|
||||
self
|
||||
|
||||
@@ -372,3 +372,20 @@ fn curvature_rounds_meet_at_a_g2_corner() {
|
||||
let (a, b) = (measure(&g2).unwrap().volume, measure(&smlib_corner).unwrap().volume);
|
||||
assert!((a - b).abs() < 7.5f64.powi(3), "{a} vs {b}");
|
||||
}
|
||||
|
||||
/// fillets/every-box-corner-three-edges-r3-curvature-setback-3-5 (tangency-labs/solidcraft#13913):
|
||||
/// the 40 x 30 x 20 box's corner (40, 30, 20), its three edges rounded r3, set back 3.5. The
|
||||
/// corner's fill is certified convex only from a setback of 3.5595 (solidcore's
|
||||
/// `SetbackTooShort`, from the fill's convex test), so 3.5 is refused naming it. The G2 patch
|
||||
/// replaces solidcore's fill here, so 3.5595 is named as what solidcore's corner needs, never as
|
||||
/// a setback that builds: the patch does not fit there (it misses the rounds' curvature by
|
||||
/// 2.09e-3 against 1.67e-3, measured).
|
||||
#[test]
|
||||
fn a_setback_too_short_for_the_fill_names_what_solidcores_corner_needs() {
|
||||
let body = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||
let edges = [Vec3::new(20.0, 30.0, 20.0), Vec3::new(40.0, 15.0, 20.0), Vec3::new(40.0, 30.0, 10.0)];
|
||||
let corner = Vec3::new(40.0, 30.0, 20.0);
|
||||
let short = fillet_g2_corners(&body, &edges, 3.0, &[(corner, 3.5)]).unwrap_err().to_string();
|
||||
assert!(short.contains("is too short for its corner's fill: solidcore's corner needs a setback of at least 3.5595 mm"), "{short}");
|
||||
assert!(!short.contains("builds"), "{short}");
|
||||
}
|
||||
|
||||
@@ -74,3 +74,22 @@ fn a_setback_not_past_the_radius_is_refused_naming_the_least() {
|
||||
assert!(least.is_some_and(|l| (l - 5.0).abs() < 1e-9), "{least:?}");
|
||||
assert_eq!(crate::fillet_least_setback(&b, Vec3::new(30.0, 20.0, 10.0), 5.0).unwrap(), None);
|
||||
}
|
||||
|
||||
/// tangency-labs/solidcraft#13913: the 40 x 30 x 20 box's corner (40, 30, 20), its three edges
|
||||
/// rounded r3, set back 3.5. The corner's fill (solidcore's own here, no patch replaces it) is
|
||||
/// certified convex only from a setback of 3.5595 (solidcore's `SetbackTooShort`, from the fill's
|
||||
/// convex test), so 3.5 is refused naming it as the smallest that builds; set back that far, it
|
||||
/// builds, a valid body.
|
||||
#[test]
|
||||
fn a_setback_too_short_for_the_fill_is_refused_naming_the_one_that_builds() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||
let edges = [Vec3::new(20.0, 30.0, 20.0), Vec3::new(40.0, 15.0, 20.0), Vec3::new(40.0, 30.0, 10.0)];
|
||||
let corner = Vec3::new(40.0, 30.0, 20.0);
|
||||
let why = match fillet_setback(&b, &edges, 3.0, &[(corner, 3.5)]) {
|
||||
Err(KernelError::Invalid(why)) => why,
|
||||
other => panic!("setback 3.5: {:?}", other.map(|b| b.face_count())),
|
||||
};
|
||||
assert!(why.contains(&format!("{SETBACK_AT_LEAST}3.5595 mm")), "{why}");
|
||||
let out = fillet_setback(&b, &edges, 3.0, &[(corner, 3.5595)]).unwrap();
|
||||
assert!(out.validity().is_empty(), "{:?}", out.validity());
|
||||
}
|
||||
|
||||
@@ -159,13 +159,13 @@ pub mod watch;
|
||||
pub use blend::{
|
||||
ChamferSide, FilletTurn, FitSearch, SETBACK_AT_LEAST, blend_edges, chamfer, chamfer_max, chamfer_max_why, chamfer_sides, fillet, fillet_each,
|
||||
fillet_g2, fillet_g2_corners, fillet_g2_with_g2_corners, fillet_least_setback, fillet_max, fillet_max_each, fillet_max_why, fillet_removing,
|
||||
fillet_setback, largest_fit_note, names_largest_fit, tangent_chain, with_fit_note, without_fit_note,
|
||||
fillet_setback, largest_fit_note, named_fit, names_largest_fit, tangent_chain, with_fit_note, without_fit_note,
|
||||
};
|
||||
pub use blend_corner::fillet_radii;
|
||||
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper};
|
||||
pub use body::{
|
||||
Body, CapSignature, DISPLAY_ANGLE, DisplayShare, EdgeInfo, FaceInfo, FacePaint, FaceSource, MeshTolerance, Paint, PointClass, Refusal,
|
||||
SurfaceKind,
|
||||
Body, COLOUR_NAME_MAX, CapSignature, DISPLAY_ANGLE, DisplayShare, EdgeInfo, FaceInfo, FacePaint, FaceSource, MeshTolerance, Paint, PointClass,
|
||||
Refusal, SurfaceKind, colour_name,
|
||||
};
|
||||
pub use boundary::{Boundary, boundary_patch};
|
||||
pub use boundary_network::{BoundaryCurve, CurveSource, MAX_CHAIN_CURVES, MAX_EDGE_PICKS, boundary_join, boundary_surface};
|
||||
|
||||
@@ -457,7 +457,11 @@ fn mirrored_copy_keeps_every_face_and_edge() {
|
||||
/// The block with a face painted: an authored boundary, so the cuts leave the faces they split
|
||||
/// across their slabs in pieces (none of them joined on their common supports).
|
||||
fn painted_cube() -> Body {
|
||||
cube().with_paint(Some(crate::Paint { opacity: 1., faces: vec![crate::FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }))
|
||||
cube().with_paint(Some(crate::Paint {
|
||||
opacity: 1.,
|
||||
faces: vec![crate::FacePaint { face: 0, color: [1., 0., 0.], opacity: 1., name: None }],
|
||||
name: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The block `b` with box cuts of each common shape: a notch through the back, a pocket, a
|
||||
|
||||
@@ -331,7 +331,8 @@ mod tests {
|
||||
let extruded = crate::extrude(&Plane::XY, &[region], 0., 5.).unwrap().remove(0);
|
||||
assert!(extruded.face_sources().is_some_and(|s| s.iter().any(Option::is_some)), "an extrusion carries its face sources");
|
||||
assert!(cut.history().is_some(), "a cut carries its history");
|
||||
let paint = crate::Paint { opacity: 0.5, faces: vec![crate::FacePaint { face: 1, color: [0.9, 0.1, 0.2], opacity: 0.75 }] };
|
||||
let paint =
|
||||
crate::Paint { opacity: 0.5, faces: vec![crate::FacePaint { face: 1, color: [0.9, 0.1, 0.2], opacity: 0.75, name: None }], name: None };
|
||||
let painted = cube.clone().with_color(Some([0.1, 0.5, 0.9])).with_paint(Some(paint));
|
||||
let top = (0..cube.face_count()).find(|f| cube.planar_face(*f).is_some_and(|p| p.normal().z > 0.5 && p.origin.z > 9.)).unwrap();
|
||||
let split =
|
||||
@@ -452,9 +453,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn native_preserves_face_appearance_and_authored_boundaries() {
|
||||
let mut body = crate::box_solid(Vec3::new(1., 2., 3.), Vec3::new(10., 20., 30.))
|
||||
.unwrap()
|
||||
.with_paint(Some(crate::Paint { opacity: 0.7, faces: vec![crate::FacePaint { face: 2, color: [0.25, 0.5, 0.75], opacity: 0.6 }] }));
|
||||
let mut body = crate::box_solid(Vec3::new(1., 2., 3.), Vec3::new(10., 20., 30.)).unwrap().with_paint(Some(crate::Paint {
|
||||
opacity: 0.7,
|
||||
faces: vec![crate::FacePaint { face: 2, color: [0.25, 0.5, 0.75], opacity: 0.6, name: None }],
|
||||
name: None,
|
||||
}));
|
||||
body.splits = Some(Arc::new(vec![vec![Vec3::new(1., 2., 3.), Vec3::new(11., 2., 3.)]]));
|
||||
body.regularization_protected = true;
|
||||
let restored = decode_native_body(&encode_native_body(&body).unwrap()).unwrap();
|
||||
|
||||
@@ -274,7 +274,9 @@ pub(super) fn apply(body: &mut Body, saved: w::Metadata) -> Result<()> {
|
||||
body.paint = saved.paint.map(|p| {
|
||||
std::sync::Arc::new(crate::Paint {
|
||||
opacity: p.opacity,
|
||||
faces: p.faces.into_iter().map(|f| crate::FacePaint { face: f.face as usize, color: f.color, opacity: f.opacity }).collect(),
|
||||
faces: p.faces.into_iter().map(|f| crate::FacePaint { face: f.face as usize, color: f.color, opacity: f.opacity, name: None }).collect(),
|
||||
// Colour names are not part of this record: the model cache keeps them beside it.
|
||||
name: None,
|
||||
})
|
||||
});
|
||||
body.splits = saved.boundaries.map(|s| std::sync::Arc::new(s.into_iter().map(|line| line.into_iter().map(p).collect()).collect()));
|
||||
|
||||
@@ -14,7 +14,11 @@ fn stamped_shell_keeps_authored_painted_and_split_boundaries() {
|
||||
let stock = stamped_stock();
|
||||
let ordinary = cup(&stock);
|
||||
assert!(!ordinary.regularization_protected);
|
||||
let painted = stock.clone().with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
|
||||
let painted = stock.clone().with_paint(Some(Paint {
|
||||
opacity: 1.,
|
||||
faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1., name: None }],
|
||||
name: None,
|
||||
}));
|
||||
let side = (0..stock.face_count()).find(|i| stock.planar_face(*i).is_some_and(|p| p.normal().dot(Vec3::X) > 0.999 && p.origin.x == 20.)).unwrap();
|
||||
let split = crate::split_faces(&stock, &[side], &crate::SplitTool::Plane(Plane { origin: Vec3::new(0., 0., 8.), ..Plane::XY })).unwrap();
|
||||
assert!(split.split_lines().is_some());
|
||||
@@ -49,7 +53,11 @@ fn stamped_shell_keeps_authored_painted_and_split_boundaries() {
|
||||
#[test]
|
||||
fn protection_is_identity_and_survives_tool_operand_and_compounds() {
|
||||
let a = stamped_stock();
|
||||
let protected = a.clone().with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [0., 1., 0.], opacity: 1. }] }));
|
||||
let protected = a.clone().with_paint(Some(Paint {
|
||||
opacity: 1.,
|
||||
faces: vec![FacePaint { face: 0, color: [0., 1., 0.], opacity: 1., name: None }],
|
||||
name: None,
|
||||
}));
|
||||
let cleared = protected.with_paint(None);
|
||||
assert!(!a.same(&cleared));
|
||||
let tool = crate::box_solid(Vec3::new(-5., -5., 23.), Vec3::new(5., 5., 30.)).unwrap().inherit_regularization_protection(&cleared);
|
||||
|
||||
@@ -151,7 +151,8 @@ mod imp {
|
||||
}
|
||||
|
||||
/// The pieces as SMLib's curves: one each, but one B-spline for each run of pieces that join
|
||||
/// the one before them ([`Piece::joins_previous`]).
|
||||
/// the one before them ([`Piece::joins_previous`]). A Bézier piece whose control points turn
|
||||
/// back on themselves goes in as its halves ([`unfolded`]): the same curve.
|
||||
fn curves(plane: &Plane, pieces: &[Piece]) -> Result<Vec<Curve>> {
|
||||
let mut out = Vec::new();
|
||||
let mut run = Vec::new();
|
||||
@@ -159,7 +160,7 @@ mod imp {
|
||||
if !x.joins_previous && !run.is_empty() {
|
||||
out.push(joined(std::mem::take(&mut run))?);
|
||||
}
|
||||
run.push(curve(plane, &x.seg)?);
|
||||
run.extend(unfolded(curve(plane, &x.seg)?, UNFOLD_DEPTH));
|
||||
}
|
||||
if !run.is_empty() {
|
||||
out.push(joined(run)?);
|
||||
@@ -167,6 +168,70 @@ mod imp {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How sharply a Bézier piece's control polygon may turn back between neighbouring steps
|
||||
/// before the piece goes in as its halves (cosine of the turn, 160°). SMLib's check refuses
|
||||
/// a swept wall whose control net turns back by more than 170° ("Bad NonMonotonic CPts",
|
||||
/// #13243, #12107), though the curve itself may be smooth: a lettering outline drawn with
|
||||
/// one handle pulled past the other. Its halves are the same curve on control polygons
|
||||
/// closer to it, which turn less; 160° leaves the halves room below SMLib's 170°.
|
||||
const UNFOLD_TURN: f64 = -0.939_692_620_785_908_4;
|
||||
|
||||
/// How many times a piece is halved at most: a curve that really turns back on itself (a
|
||||
/// cusp) has a control polygon that does at any depth, and goes in as it is.
|
||||
const UNFOLD_DEPTH: usize = 4;
|
||||
|
||||
/// `c` as it goes into a sweep: a Bézier piece whose control polygon turns back by more than
|
||||
/// [`UNFOLD_TURN`] as its two halves at the middle of its parameter (de Casteljau, in
|
||||
/// homogeneous points, so a rational piece is split exactly too), each again so up to
|
||||
/// `depth` times; anything else as it is. [`joined`] takes the halves back as one B-spline:
|
||||
/// one curve, one wall.
|
||||
fn unfolded(c: Curve, depth: usize) -> Vec<Curve> {
|
||||
let Curve::Nurbs { degree, points, knots } = &c else { return vec![c] };
|
||||
if depth == 0 || points.len() != degree + 1 || knots.len() != 2 * (degree + 1) || !turns_back(points) {
|
||||
return vec![c];
|
||||
}
|
||||
let (left, right) = halves(points);
|
||||
let piece = |points: Vec<[f64; 4]>| Curve::Nurbs { degree: *degree, points, knots: knots.clone() };
|
||||
let mut out = unfolded(piece(left), depth - 1);
|
||||
out.extend(unfolded(piece(right), depth - 1));
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the control polygon `points` (`[x, y, z, weight]`) turns back by more than
|
||||
/// [`UNFOLD_TURN`] between two steps, a step too short to have a direction passed over.
|
||||
fn turns_back(points: &[[f64; 4]]) -> bool {
|
||||
let at = |q: &[f64; 4]| Vec3::new(q[0], q[1], q[2]);
|
||||
let size = points.iter().map(|q| at(q).len()).fold(1.0, f64::max);
|
||||
let mut last: Option<Vec3> = None;
|
||||
for w in points.windows(2) {
|
||||
let [a, b] = w else { continue };
|
||||
let d = at(b) - at(a);
|
||||
let Some(step) = d.normalized().filter(|_| d.len() > size * 1e-12) else { continue };
|
||||
if last.is_some_and(|l| l.dot(step) < UNFOLD_TURN) {
|
||||
return true;
|
||||
}
|
||||
last = Some(step);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The control points of the two halves of the Bézier `points` (`[x, y, z, weight]`) split
|
||||
/// at the middle of its parameter, by de Casteljau's construction on the homogeneous points.
|
||||
fn halves(points: &[[f64; 4]]) -> (Vec<[f64; 4]>, Vec<[f64; 4]>) {
|
||||
let hom = |q: &[f64; 4]| [q[0] * q[3], q[1] * q[3], q[2] * q[3], q[3]];
|
||||
let euclid = |h: [f64; 4]| if h[3] != 0.0 { [h[0] / h[3], h[1] / h[3], h[2] / h[3], h[3]] } else { h };
|
||||
let mut row: Vec<[f64; 4]> = points.iter().map(hom).collect();
|
||||
let (mut left, mut right) = (Vec::with_capacity(row.len()), Vec::with_capacity(row.len()));
|
||||
while let (Some(first), Some(last)) = (row.first().copied(), row.last().copied()) {
|
||||
left.push(euclid(first));
|
||||
right.push(euclid(last));
|
||||
let mid = |a: &[f64; 4], b: &[f64; 4]| [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1]), 0.5 * (a[2] + b[2]), 0.5 * (a[3] + b[3])];
|
||||
row = row.iter().zip(row.iter().skip(1)).map(|(a, b)| mid(a, b)).collect();
|
||||
}
|
||||
right.reverse();
|
||||
(left, right)
|
||||
}
|
||||
|
||||
/// Bézier curves of one degree, each starting where the one before ends, as the one B-spline
|
||||
/// they are, exactly: their control points in a row (each joint once) on knots repeated the
|
||||
/// degree times at each joint. A rational piece's weights are scaled (which leaves it the same
|
||||
|
||||
@@ -458,3 +458,39 @@ fn a_controlled_sweep_takes_a_profile_drawn_part_way_along_its_path_as_its_start
|
||||
assert!(b.min.dist(w.min) < 1e-6 && b.max.dist(w.max) < 1e-6, "{what}: {b:?} vs {w:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extrusion_of_a_cubic_whose_control_points_fold_back_passes_the_check() {
|
||||
// #13243, #12107: a smooth cubic whose control polygon runs 6 left, steps 0.02 back to the
|
||||
// right and runs 4 left again (the handles of an imported outline pulled past each other).
|
||||
// Its wall's control net turned back by 174 degrees and SMLib's check refused the extrusion
|
||||
// ("Bad NonMonotonic CPts"); the curve itself has no cusp.
|
||||
let top = Seg2::Cubic { p0: Vec2::new(10.0, 10.0), p1: Vec2::new(4.0, 10.0), p2: Vec2::new(4.02, 10.002), p3: Vec2::new(0.0, 10.0) };
|
||||
let line = |a: Vec2, b: Vec2| Seg2::Line { a, b };
|
||||
let segs = vec![
|
||||
line(Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)),
|
||||
line(Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0)),
|
||||
top,
|
||||
line(Vec2::new(0.0, 10.0), Vec2::new(0.0, 0.0)),
|
||||
];
|
||||
let r = region(Loop2 { segs });
|
||||
let b = smlib_sweeps::extrude(&Plane::XY, &pieces_of(&r), Some(&r), 0.0, 3.0, 1.0, None).unwrap();
|
||||
assert_eq!(b.validity(), Vec::<String>::new());
|
||||
// The area under the cubic, by Green's theorem on a fine polyline of it.
|
||||
let at = |t: f64| {
|
||||
let s = 1.0 - t;
|
||||
Vec2::new(10.0, 10.0) * (s * s * s)
|
||||
+ Vec2::new(4.0, 10.0) * (3.0 * s * s * t)
|
||||
+ Vec2::new(4.02, 10.002) * (3.0 * s * t * t)
|
||||
+ Vec2::new(0.0, 10.0) * (t * t * t)
|
||||
};
|
||||
let n = 20_000;
|
||||
let mut pts: Vec<Vec2> = (0..=n).map(|i| at(f64::from(i) / f64::from(n))).collect();
|
||||
pts.extend([Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)]);
|
||||
let area = 0.5 * pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f64>();
|
||||
close(volume(&b), 3.0 * area, 1e-3, "folded cubic prism");
|
||||
// One wall for the cubic, as for any other piece.
|
||||
let mut want = vec![cap(false), cap(true), profile(0), profile(1), profile(2), profile(3)];
|
||||
want.sort();
|
||||
assert_eq!(origins(&b), want);
|
||||
}
|
||||
|
||||
@@ -281,6 +281,9 @@ struct Reader {
|
||||
placements: HashMap<u64, u64>,
|
||||
/// Styled item → colour.
|
||||
colors: HashMap<u64, [f32; 3]>,
|
||||
/// Styled item → its colour's name in the file (`COLOUR_RGB`'s name, or a pre-defined
|
||||
/// colour's), when the file names it.
|
||||
color_names: HashMap<u64, String>,
|
||||
/// Opacity of styled items with a transparency (1 = opaque).
|
||||
opacity: HashMap<u64, f32>,
|
||||
/// Items that gave no part: reported once when the file could not be read, else (the
|
||||
@@ -337,6 +340,7 @@ impl Reader {
|
||||
children: HashMap::new(),
|
||||
placements: HashMap::new(),
|
||||
colors: HashMap::new(),
|
||||
color_names: HashMap::new(),
|
||||
opacity: HashMap::new(),
|
||||
unreadable: HashMap::new(),
|
||||
once: HashSet::new(),
|
||||
@@ -455,8 +459,13 @@ impl Reader {
|
||||
}
|
||||
"STYLED_ITEM" | "OVER_RIDING_STYLED_ITEM" => {
|
||||
if let Some(item) = rf(2)
|
||||
&& let Some(c) = self.find_colour(p.get(1))
|
||||
&& let Some((c, name)) = self.find_colour(p.get(1))
|
||||
{
|
||||
if !self.colors.contains_key(&item)
|
||||
&& let Some(name) = name
|
||||
{
|
||||
self.color_names.insert(item, name);
|
||||
}
|
||||
self.colors.entry(item).or_insert(c);
|
||||
if let Some(t) = self.find_transparency(p.get(1)) {
|
||||
self.opacity.entry(item).or_insert((1.0 - t).clamp(0.0, 1.0));
|
||||
@@ -558,7 +567,8 @@ impl Reader {
|
||||
None
|
||||
}
|
||||
|
||||
fn find_colour(&self, styles: Option<&Param>) -> Option<[f32; 3]> {
|
||||
/// The surface colour a style list gives, with its name in the file when it has one.
|
||||
fn find_colour(&self, styles: Option<&Param>) -> Option<([f32; 3], Option<String>)> {
|
||||
let mut queue: Vec<u64> = styles?.as_list()?.iter().filter_map(Param::as_ref_id).collect();
|
||||
let mut seen = HashSet::new();
|
||||
while let Some(id) = queue.pop() {
|
||||
@@ -588,19 +598,25 @@ impl Reader {
|
||||
if e.name() == "COLOUR_RGB" {
|
||||
let p = e.params();
|
||||
let c = |i: usize| p.get(i).and_then(Param::as_f64).map(|x| x.clamp(0.0, 1.0) as f32);
|
||||
return Some([c(1)?, c(2)?, c(3)?]);
|
||||
let name = p.first().and_then(Param::as_str).and_then(crate::body::colour_name);
|
||||
return Some(([c(1)?, c(2)?, c(3)?], name));
|
||||
}
|
||||
if e.name() == "DRAUGHTING_PRE_DEFINED_COLOUR" {
|
||||
return Some(match e.params().first().and_then(Param::as_str).unwrap_or("") {
|
||||
"red" => [1.0, 0.0, 0.0],
|
||||
"green" => [0.0, 1.0, 0.0],
|
||||
"blue" => [0.0, 0.0, 1.0],
|
||||
"yellow" => [1.0, 1.0, 0.0],
|
||||
"magenta" => [1.0, 0.0, 1.0],
|
||||
"cyan" => [0.0, 1.0, 1.0],
|
||||
"black" => [0.0, 0.0, 0.0],
|
||||
_ => [1.0, 1.0, 1.0],
|
||||
});
|
||||
let named = e.params().first().and_then(Param::as_str).unwrap_or("");
|
||||
let name = crate::body::colour_name(named);
|
||||
return Some((
|
||||
match named {
|
||||
"red" => [1.0, 0.0, 0.0],
|
||||
"green" => [0.0, 1.0, 0.0],
|
||||
"blue" => [0.0, 0.0, 1.0],
|
||||
"yellow" => [1.0, 1.0, 0.0],
|
||||
"magenta" => [1.0, 0.0, 1.0],
|
||||
"cyan" => [0.0, 1.0, 1.0],
|
||||
"black" => [0.0, 0.0, 0.0],
|
||||
_ => [1.0, 1.0, 1.0],
|
||||
},
|
||||
name,
|
||||
));
|
||||
}
|
||||
fn walk(p: &Param, out: &mut Vec<u64>, depth: usize) {
|
||||
match p {
|
||||
@@ -743,6 +759,11 @@ impl Reader {
|
||||
for piece in solidcore_step::topology::items(&self.ex, item) {
|
||||
if piece != item {
|
||||
if let Some(c) = self.colors.get(&item).copied() {
|
||||
if !self.colors.contains_key(&piece)
|
||||
&& let Some(name) = self.color_names.get(&item).cloned()
|
||||
{
|
||||
self.color_names.insert(piece, name);
|
||||
}
|
||||
self.colors.entry(piece).or_insert(c);
|
||||
}
|
||||
if let Some(o) = self.opacity.get(&item).copied() {
|
||||
@@ -899,16 +920,26 @@ impl Reader {
|
||||
*slot = Some(self.bodies.len());
|
||||
}
|
||||
let color = self.colors.get(&item).copied();
|
||||
// See-through bodies and faces with colours of their own.
|
||||
// See-through bodies and faces with colours of their own, and the names the
|
||||
// file gives those colours.
|
||||
let faces = face_origin
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, f)| {
|
||||
let c = self.colors.get(f)?;
|
||||
Some(crate::FacePaint { face: i, color: *c, opacity: self.opacity.get(f).copied().unwrap_or(1.0) })
|
||||
Some(crate::FacePaint {
|
||||
face: i,
|
||||
color: *c,
|
||||
opacity: self.opacity.get(f).copied().unwrap_or(1.0),
|
||||
name: self.color_names.get(f).cloned(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let paint = crate::Paint { opacity: self.opacity.get(&item).copied().unwrap_or(1.0), faces };
|
||||
let paint = crate::Paint {
|
||||
opacity: self.opacity.get(&item).copied().unwrap_or(1.0),
|
||||
faces,
|
||||
name: color.and_then(|_| self.color_names.get(&item).cloned()),
|
||||
};
|
||||
let body = body.with_color(color).with_paint(Some(paint));
|
||||
let identities = face_origin
|
||||
.iter()
|
||||
|
||||
@@ -351,8 +351,11 @@ fn parallel_curved_import_keeps_face_colours_and_exact_display_meshes() {
|
||||
let bodies: Vec<_> = (0..8)
|
||||
.map(|i| {
|
||||
let body = cylinder(Vec3::new(i as f64 * 20.0, 0.0, 0.0), Vec3::Z, 5.0, 12.0).unwrap();
|
||||
body.with_color(Some([0.2, 0.4, 0.6]))
|
||||
.with_paint(Some(crate::Paint { opacity: 0.7, faces: vec![crate::FacePaint { face: 0, color: [0.8, 0.1, 0.3], opacity: 0.5 }] }))
|
||||
body.with_color(Some([0.2, 0.4, 0.6])).with_paint(Some(crate::Paint {
|
||||
opacity: 0.7,
|
||||
faces: vec![crate::FacePaint { face: 0, color: [0.8, 0.1, 0.3], opacity: 0.5, name: None }],
|
||||
name: None,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
let text = step_export(&bodies.iter().collect::<Vec<_>>(), "coloured cylinders").unwrap();
|
||||
@@ -2578,6 +2581,67 @@ fn a_refused_solid_comes_in_as_a_surface_body_of_the_faces_that_read() {
|
||||
assert!(rel(measure(&back).unwrap().volume, 250.0 * pi) < 1e-6, "{}", measure(&back).unwrap().volume);
|
||||
}
|
||||
|
||||
/// The can of [`can_with_its_lid_at`] with a cavity, a 1 mm ball at its middle, written as a
|
||||
/// BREP_WITH_VOIDS (the void's closed shell used through an ORIENTED_CLOSED_SHELL `.F.`).
|
||||
fn hollow_can_with_its_lid_at(lid_z: f64) -> String {
|
||||
let renumber = |text: &str, by: u64| {
|
||||
let mut out = String::new();
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
out.push(c);
|
||||
if c == '#' {
|
||||
let mut n = String::new();
|
||||
while let Some(d) = chars.next_if(char::is_ascii_digit) {
|
||||
n.push(d);
|
||||
}
|
||||
out += &(n.parse::<u64>().unwrap() + by).to_string();
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
let ball = seam_sphere_step(Vec3::new(0.0, 0.0, 5.0), 1.0, Vec3::Z, Vec3::X, 0.0, false, std::f64::consts::PI);
|
||||
let ball = &ball[ball.find("#1=").unwrap()..ball.find("#21=").unwrap()];
|
||||
let brep = "#22=MANIFOLD_SOLID_BREP('Can',#21);\n";
|
||||
let can = can_with_its_lid_at(lid_z);
|
||||
assert!(can.contains(brep));
|
||||
let voids = format!("{}#1200=ORIENTED_CLOSED_SHELL('',*,#1020,.F.);\n#22=BREP_WITH_VOIDS('Can',#21,(#1200));\n", renumber(ball, 1000));
|
||||
can.replacen(brep, &voids, 1)
|
||||
}
|
||||
|
||||
/// A solid with a cavity no B-rep can be made of comes in as surfaces, as a solid without one
|
||||
/// does ([`a_refused_solid_comes_in_as_a_surface_body_of_the_faces_that_read`]), never dropped:
|
||||
/// the hollow can whose lid lies 1.5 mm off its rim is its bottom, wall and cavity, the lid left
|
||||
/// out and named. An engine block and a cylinder head of an assembly, both written with voids,
|
||||
/// were lost whole on one face each.
|
||||
#[test]
|
||||
fn a_refused_solid_with_a_cavity_comes_in_as_surfaces() {
|
||||
let pi = std::f64::consts::PI;
|
||||
let whole = step_import(&hollow_can_with_its_lid_at(10.0)).unwrap();
|
||||
assert_eq!(whole.bodies.len(), 1, "{:?}", whole.warnings);
|
||||
assert!(whole.bodies[0].closed && whole.bodies[0].refused.is_none(), "{:?}", whole.warnings);
|
||||
let want = 250.0 * pi - 4.0 / 3.0 * pi;
|
||||
let v = measure(&whole.bodies[0].body).unwrap().volume;
|
||||
assert!(rel(v, want) < 1e-3, "{v} vs {want}");
|
||||
|
||||
let imp = step_import(&hollow_can_with_its_lid_at(11.5)).unwrap();
|
||||
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
|
||||
let can = &imp.bodies[0];
|
||||
assert!(!can.body.is_mesh() && can.body.is_surface() && !can.closed, "{:?}", imp.warnings);
|
||||
let why = can.refused.as_deref().unwrap_or_default();
|
||||
assert!(why.contains("face #45"), "{why}");
|
||||
assert_eq!(can.file_faces, 4);
|
||||
assert_eq!(can.body.face_count(), 3, "{:?}", imp.warnings);
|
||||
assert!(!can.face_origin.contains(&45));
|
||||
assert!(imp.warnings.iter().any(|w| w.contains("'Can': imported as surfaces: ")), "{:?}", imp.warnings);
|
||||
assert!(imp.warnings.iter().any(|w| w.contains("'Can': left out: ") && w.contains("face #45")), "{:?}", imp.warnings);
|
||||
assert!(!imp.warnings.iter().any(|w| w.contains("not imported")), "{:?}", imp.warnings);
|
||||
// The bottom, the wall and the cavity's ball.
|
||||
let mut kept = can.face_origin.clone();
|
||||
kept.sort_unstable();
|
||||
assert_eq!(kept[..2], [15, 20], "{kept:?}");
|
||||
assert!(kept[2] > 1000, "the cavity's face: {kept:?}");
|
||||
}
|
||||
|
||||
/// A sector of a cylinder (radius 5, height 10) about z, `sweep` radians from `start` on the
|
||||
/// wall's frame: a pie slice of the cylinder, its two sides through the axis.
|
||||
fn cylinder_sector(start: f64, sweep: f64) -> String {
|
||||
@@ -3096,6 +3160,76 @@ fn a_torus_face_round_most_of_its_tube_is_formed_within_one_turn_of_it() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour comes in with its name in the file: `COLOUR_RGB`'s own, or a pre-defined colour's,
|
||||
/// decoded and made safe as a design keeps names (no control characters, at most 128
|
||||
/// characters); an empty or blank name is none. A face's over-riding style names the face's
|
||||
/// colour, a see-through style keeps its name, and an export writes the names back.
|
||||
#[test]
|
||||
fn colour_names_come_in_with_their_colours_and_go_out_again() {
|
||||
let style = |id: u64, item: &str, colour: &str, extra: &str| {
|
||||
format!(
|
||||
"#{id}=STYLED_ITEM('',(#{}),{item});\n#{}=PRESENTATION_STYLE_ASSIGNMENT((#{}));\n#{}=SURFACE_STYLE_USAGE(.BOTH.,#{});\n#{}=SURFACE_SIDE_STYLE('',(#{}{extra}));\n#{}=SURFACE_STYLE_FILL_AREA(#{});\n#{}=FILL_AREA_STYLE('',(#{}));\n#{}=FILL_AREA_STYLE_COLOUR('',#{});\n#{}={colour};\n",
|
||||
id + 1,
|
||||
id + 1,
|
||||
id + 2,
|
||||
id + 2,
|
||||
id + 3,
|
||||
id + 3,
|
||||
id + 4,
|
||||
id + 4,
|
||||
id + 5,
|
||||
id + 5,
|
||||
id + 6,
|
||||
id + 6,
|
||||
id + 7,
|
||||
id + 7,
|
||||
)
|
||||
};
|
||||
let import = |styles: &str| {
|
||||
let mut t = String::from(HEAD);
|
||||
t += &cube_entities();
|
||||
t += &context(20, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
|
||||
t += "#60=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101),#20);\n";
|
||||
t += styles;
|
||||
t += TAIL;
|
||||
let imp = step_import(&t).unwrap();
|
||||
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
|
||||
imp.bodies.into_iter().next().unwrap()
|
||||
};
|
||||
// An opaque colour without a name needs no paint; with one, the paint holds it.
|
||||
let named = |name: &str| import(&style(70, "#101", &format!("COLOUR_RGB('{name}',1.,0.5,0.)"), "")).body.paint().and_then(|p| p.name.clone());
|
||||
assert_eq!(named("Steel - Satin").as_deref(), Some("Steel - Satin"));
|
||||
assert_eq!(named("Opaque(97,69,55)").as_deref(), Some("Opaque(97,69,55)"));
|
||||
assert_eq!(named(""), None);
|
||||
assert_eq!(named(" "), None);
|
||||
assert_eq!(named(" Blue ").as_deref(), Some("Blue"));
|
||||
assert_eq!(named(r"Bl\X2\00E9\X0\").as_deref(), Some("Blé"), "escapes are decoded");
|
||||
assert_eq!(named(r"a\X\0Ab\X\09c").as_deref(), Some("abc"), "control characters are dropped");
|
||||
assert_eq!(named(&"x".repeat(300)).map(|n| n.chars().count()), Some(crate::COLOUR_NAME_MAX));
|
||||
// A pre-defined colour is called by its name.
|
||||
let red = import(&style(70, "#101", "DRAUGHTING_PRE_DEFINED_COLOUR('red')", ""));
|
||||
assert_eq!((red.color, red.body.paint().unwrap().name.as_deref()), (Some([1.0, 0.0, 0.0]), Some("red")));
|
||||
// A see-through colour keeps its name.
|
||||
let glass = import(&(style(70, "#101", "COLOUR_RGB('Smoked',0.4,0.4,0.4)", ",#90") + "#90=SURFACE_STYLE_TRANSPARENT(0.5);\n"));
|
||||
let paint = glass.body.paint().unwrap();
|
||||
assert_eq!((paint.opacity, paint.name.as_deref()), (0.5, Some("Smoked")));
|
||||
// A face over-ridden by a style of its own: its colour is named as the file names it.
|
||||
let cube = cube_entities();
|
||||
let face = cube.split('\n').find(|l| l.contains("=ADVANCED_FACE(")).and_then(|l| l.split('=').next()).unwrap().to_string();
|
||||
let styles = style(70, "#101", "COLOUR_RGB('Body grey',0.5,0.5,0.5)", "") + &style(80, &face, "COLOUR_RGB('Accent',0.,0.,1.)", "");
|
||||
let painted = import(&styles);
|
||||
let paint = painted.body.paint().unwrap();
|
||||
assert_eq!(paint.name.as_deref(), Some("Body grey"));
|
||||
assert_eq!(paint.faces.iter().map(|f| (f.color, f.name.as_deref())).collect::<Vec<_>>(), [([0.0, 0.0, 1.0], Some("Accent"))]);
|
||||
// Written out and read back, the names come back with their colours.
|
||||
let text = step_export(&[&painted.body.clone().with_color(painted.color)], "named").unwrap();
|
||||
assert!(text.contains("COLOUR_RGB('Body grey'") && text.contains("COLOUR_RGB('Accent'"), "{text}");
|
||||
let back = step_import(&text).unwrap().bodies.remove(0);
|
||||
let back_paint = back.body.paint().unwrap();
|
||||
assert_eq!(back_paint.name.as_deref(), Some("Body grey"));
|
||||
assert_eq!(back_paint.faces.iter().map(|f| f.name.as_deref()).collect::<Vec<_>>(), [Some("Accent")]);
|
||||
}
|
||||
|
||||
/// #12291: a part styled with a surface colour and, beside it, a curve style (its edges) is the
|
||||
/// surface colour, whichever style the file lists first or last. The edge's colour once painted
|
||||
/// the whole solid, because the first colour found was taken.
|
||||
|
||||
@@ -209,14 +209,15 @@ pub fn step_export_products(products: &[ExportProduct], root: usize, header: &St
|
||||
let opacity = b.body.paint().map_or(1.0, |p| p.opacity);
|
||||
let items: Vec<u64> = written.solids.iter().copied().chain(written.sheets).collect();
|
||||
if let Some(c) = b.color {
|
||||
let name = b.body.paint().and_then(|p| p.name.as_deref());
|
||||
for item in &items {
|
||||
styled.push(style(&mut w, *item, c, opacity));
|
||||
styled.push(style(&mut w, *item, c, opacity, name));
|
||||
}
|
||||
}
|
||||
// Faces with colours of their own.
|
||||
for f in b.body.paint().map(|p| p.faces.as_slice()).unwrap_or(&[]) {
|
||||
if let Some(&face) = written.faces.get(f.face).filter(|id| **id != 0) {
|
||||
styled.push(style(&mut w, face, f.color, f.opacity));
|
||||
styled.push(style(&mut w, face, f.color, f.opacity, f.name.as_deref()));
|
||||
}
|
||||
}
|
||||
solids.extend(written.solids);
|
||||
@@ -279,9 +280,11 @@ pub fn step_export_products(products: &[ExportProduct], root: usize, header: &St
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// A surface colour for an item (STYLED_ITEM chain; the styles are shared).
|
||||
fn style(w: &mut Writer, item: u64, c: [f32; 3], opacity: f32) -> u64 {
|
||||
let rgb = w.add("COLOUR_RGB", &[s(""), Param::Real(c[0] as f64), Param::Real(c[1] as f64), Param::Real(c[2] as f64)]);
|
||||
/// A surface colour for an item (STYLED_ITEM chain; the styles are shared), named as its look is
|
||||
/// (`COLOUR_RGB`'s name, read back on import) when it has a name.
|
||||
fn style(w: &mut Writer, item: u64, c: [f32; 3], opacity: f32, name: Option<&str>) -> u64 {
|
||||
let name = name.and_then(crate::body::colour_name).unwrap_or_default();
|
||||
let rgb = w.add("COLOUR_RGB", &[s(&name), Param::Real(c[0] as f64), Param::Real(c[1] as f64), Param::Real(c[2] as f64)]);
|
||||
let fasc = w.add("FILL_AREA_STYLE_COLOUR", &[s(""), r(rgb)]);
|
||||
let fas = w.add("FILL_AREA_STYLE", &[s(""), refs(&[fasc])]);
|
||||
let ssfa = w.add("SURFACE_STYLE_FILL_AREA", &[r(fas)]);
|
||||
|
||||
+166
-2
@@ -962,6 +962,138 @@ fn check_parameters_tries_values_and_puts_the_design_back() {
|
||||
assert!(d["timeline"].as_array().unwrap().iter().all(|f| f["error"].is_null()), "{d}");
|
||||
}
|
||||
|
||||
/// `bodies` and `summary` keep `check_parameters`'s reply small on a large design (#14162): only
|
||||
/// the bodies named are measured and their cylinders listed, and `measure` is the measure
|
||||
/// tool's summary. Without them the reply is as before; a bad `bodies` changes nothing.
|
||||
#[test]
|
||||
fn check_parameters_reports_only_the_bodies_asked_for_in_summary() {
|
||||
let mut s = server();
|
||||
parametric_box_with_cylinder(&mut s);
|
||||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [100, 0, 0]}}));
|
||||
tool(&mut s, "execute", json!({"command": "solid.cylinder", "params": {"radius": 2, "height": 10, "base": [200, 0, 0]}}));
|
||||
let names: Vec<String> =
|
||||
tool(&mut s, "measure", json!({}))["bodies"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(names.len(), 3, "{names:?}");
|
||||
let before = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
|
||||
|
||||
// The default: every body measured in full, every cylinder.
|
||||
let all = tool(&mut s, "check_parameters", json!({"set": {"len": 60}, "report": ["measure", "cylinders"]}));
|
||||
assert_eq!(all["measure"]["body_count"], 3, "{all}");
|
||||
assert!(all["measure"]["bodies"][0]["area_mm2"].is_number() && all["measure"]["bodies"][0]["kernel"].is_object(), "full measure: {all}");
|
||||
assert_eq!(all["cylinders"].as_array().map(Vec::len), Some(2), "{all}");
|
||||
|
||||
let one = tool(&mut s, "check_parameters", json!({"set": {"len": 60}, "report": ["measure", "cylinders"], "bodies": ["Body1"], "summary": true}));
|
||||
assert_eq!(one["restored"], true, "{one}");
|
||||
let m = &one["measure"];
|
||||
assert_eq!(m["bodies"].as_array().map(Vec::len), Some(1), "{one}");
|
||||
let mut keys: Vec<&String> = m["bodies"][0].as_object().unwrap().keys().collect();
|
||||
keys.sort();
|
||||
assert_eq!(keys, ["bbox", "closed", "faces", "name", "volume_mm3"], "the measure tool's summary: {one}");
|
||||
assert_eq!(m["bodies"][0]["name"], "Body1");
|
||||
let v = m["total"]["volume_mm3"].as_f64().unwrap();
|
||||
assert!(rel(v, box_with_cylinder_volume(60.0)) < 5e-4, "{v}");
|
||||
assert_eq!(m["bodies"][0]["bbox"], all["measure"]["bodies"][0]["bbox"], "{one}");
|
||||
let cyl = one["cylinders"].as_array().unwrap();
|
||||
assert!(cyl.len() == 1 && cyl[0]["body"] == "Body1", "{one}");
|
||||
assert!(one.to_string().len() * 3 < all.to_string().len(), "{} vs {} bytes", one.to_string().len(), all.to_string().len());
|
||||
// `summary` on its own summarises every body.
|
||||
let sum = tool(&mut s, "check_parameters", json!({"set": {"len": 60}, "report": ["measure"], "summary": true}));
|
||||
assert_eq!(sum["measure"]["bodies"].as_array().map(Vec::len), Some(3), "{sum}");
|
||||
assert!(sum["measure"]["bodies"][0].get("area_mm2").is_none(), "{sum}");
|
||||
|
||||
// A body the design doesn't have: an error, and the design is put back.
|
||||
for report in [json!(["measure"]), json!(["cylinders"])] {
|
||||
let r = raw_tool(&mut s, "check_parameters", json!({"set": {"len": 60}, "report": report, "bodies": ["Nope"]}));
|
||||
assert_eq!(r["result"]["isError"], json!(true), "{report}: {r}");
|
||||
let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(body["restored"], true, "{body}");
|
||||
let e = body["error"].as_str().unwrap();
|
||||
assert!(e.contains("no body `Nope`") && e.contains("Body1"), "{body}");
|
||||
}
|
||||
// A `bodies` that is not a list of names, or a `summary` that is not a flag: refused first.
|
||||
for args in [json!({"set": {"len": 60}, "bodies": "Body1"}), json!({"set": {"len": 60}, "summary": "yes"})] {
|
||||
let r = raw_tool(&mut s, "check_parameters", args.clone());
|
||||
assert_eq!(r["error"]["code"], -32602, "{args}: {r}");
|
||||
}
|
||||
for args in [json!({"set": {"len": 60}, "bodies": []}), json!({"set": {"len": 60}, "bodies": [5]}), json!({"set": {"len": 60}, "bodies": [""]})] {
|
||||
let r = raw_tool(&mut s, "check_parameters", args.clone());
|
||||
assert_eq!(r["result"]["isError"], json!(true), "{args}: {r}");
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("`bodies` must be a list of body names"), "{args}: {r}");
|
||||
}
|
||||
assert!(rel(volume(&mut s), box_with_cylinder_volume(40.0) + 1000.0 + PI * 4.0 * 10.0) < 5e-4);
|
||||
let after = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
|
||||
assert_eq!(after["undo"], before["undo"], "{after}");
|
||||
}
|
||||
|
||||
/// `timeline` and `bodies` keep `inspect_design` small on a large design (#14162), with and
|
||||
/// without compact output and as a batch step; left out, the call and its reply are as before.
|
||||
#[test]
|
||||
fn inspect_design_gives_a_timeline_summary_and_body_names_when_asked() {
|
||||
for opts in [crate::Options::default(), crate::Options { compact: true }] {
|
||||
let mut s = Server::new(Box::new(Headless::default())).with_options(opts);
|
||||
parametric_box_with_cylinder(&mut s);
|
||||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [100, 0, 0]}}));
|
||||
let base = json!({"measure": true, "sketches": false, "joints": false});
|
||||
let with = |extra: Value| {
|
||||
let mut a = base.clone();
|
||||
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
a
|
||||
};
|
||||
let full = tool(&mut s, "inspect_design", base.clone());
|
||||
assert_eq!(tool(&mut s, "inspect_design", with(json!({"timeline": true, "bodies": true}))), full, "{opts:?}");
|
||||
assert!(full["timeline"][0]["ms"].is_number() && full["bodies"][0]["volume_mm3"].is_number(), "{full}");
|
||||
|
||||
let short = tool(&mut s, "inspect_design", with(json!({"timeline": "summary", "bodies": "names"})));
|
||||
let rows = short["timeline"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), full["timeline"].as_array().unwrap().len(), "{short}");
|
||||
for row in rows {
|
||||
let mut keys: Vec<&String> = row.as_object().unwrap().keys().collect();
|
||||
keys.sort();
|
||||
assert_eq!(keys, ["id", "name", "type"], "{opts:?}: {row}");
|
||||
}
|
||||
assert_eq!(short["bodies"], json!(["Body1", "Body2"]), "{opts:?}: {short}");
|
||||
assert_eq!(short["params"], full["params"]);
|
||||
let gone = tool(&mut s, "inspect_design", with(json!({"timeline": false, "bodies": false})));
|
||||
assert!(gone.get("timeline").is_none() && gone.get("bodies").is_none(), "{opts:?}: {gone}");
|
||||
|
||||
// A value the tool doesn't take is refused before anything is sent.
|
||||
for bad in [json!({"timeline": "full"}), json!({"timeline": 1})] {
|
||||
let r = raw_tool(&mut s, "inspect_design", bad.clone());
|
||||
assert_eq!(r["error"]["code"], -32602, "{opts:?} {bad}: {r}");
|
||||
}
|
||||
// `bodies` takes a list with compact output, so its check names every form.
|
||||
let r = raw_tool(&mut s, "inspect_design", json!({"bodies": "all"}));
|
||||
if opts.compact {
|
||||
assert_eq!(r["result"]["isError"], json!(true), "{r}");
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("list of body names, true, false or \"names\""), "{r}");
|
||||
} else {
|
||||
assert_eq!(r["error"]["code"], -32602, "{r}");
|
||||
}
|
||||
|
||||
// As a batch step.
|
||||
let r = tool(
|
||||
&mut s,
|
||||
"batch",
|
||||
json!({"commands": [{"command": "inspect_design", "params": {"measure": false, "timeline": "summary", "bodies": "names"}}]}),
|
||||
);
|
||||
let step = &r["results"][0];
|
||||
assert_eq!(step["bodies"], json!(["Body1", "Body2"]), "{opts:?}: {r}");
|
||||
assert!(step["timeline"][0].get("ms").is_none(), "{opts:?}: {r}");
|
||||
}
|
||||
|
||||
// Without the new arguments, document.inspect is asked exactly as before, so an app from
|
||||
// before them answers as it always did.
|
||||
let mut b = Answers { calls: Vec::new(), answer: Box::new(|_, _| Ok(json!({"timeline": [], "bodies": [], "sketches": []}))) };
|
||||
crate::tools::call_tool(&mut b, "inspect_design", &json!({"sketches": false, "joints": false}));
|
||||
crate::tools::call_tool_with(&mut b, "inspect_design", &json!({"joints": false}), crate::Options { compact: true });
|
||||
crate::tools::call_tool_with(&mut b, "inspect_design", &json!({"joints": false, "bodies": ["B"]}), crate::Options { compact: true });
|
||||
let sent: Vec<&Value> = b.calls.iter().filter(|(m, _)| m == "document.inspect").map(|(_, p)| p).collect();
|
||||
assert_eq!(
|
||||
sent,
|
||||
[&json!({"measure": true}), &json!({"measure": true, "sketch_counts": true}), &json!({"measure": false, "sketch_counts": true})]
|
||||
);
|
||||
}
|
||||
|
||||
/// An unknown parameter name, a bad expression or a bad report list is an error that leaves the
|
||||
/// design untouched; `set` that is not an object is refused by argument checking; the tool is
|
||||
/// annotated as an edit (it changes the design and puts it back) and is not a batch step.
|
||||
@@ -2438,6 +2570,38 @@ fn compact_batch_reply_keeps_changed_bodies_and_created_ids() {
|
||||
assert!(rect["sides"].is_object(), "results that are not repeated stay: {r}");
|
||||
}
|
||||
|
||||
/// A drawing of hundreds of curves comes back with hundreds of ids: a compact reply leaves each
|
||||
/// long `created` list out and counts it, the full reply has every id; 20 ids are still a list.
|
||||
#[test]
|
||||
fn compact_reply_counts_long_created_lists() {
|
||||
let mut s = server();
|
||||
let polyline = |n: usize| {
|
||||
let points: Vec<String> = (0..=n).map(|i| format!("{i},{}", i % 2)).collect();
|
||||
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 10"><polyline points="{}"/></svg>"#, points.join(" "))
|
||||
};
|
||||
tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}));
|
||||
let full = tool(&mut s, "execute", json!({"command": "sketch.insert_svg", "params": {"text": polyline(30)}}));
|
||||
assert_eq!(full["created"]["curves"].as_array().map(Vec::len), Some(30), "{full}");
|
||||
assert_eq!(full["created"]["points"].as_array().map(Vec::len), Some(31), "{full}");
|
||||
tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}));
|
||||
let short = tool(&mut s, "execute", json!({"command": "sketch.insert_svg", "params": {"text": polyline(30)}, "verbose": false}));
|
||||
assert!(short["created"].get("curves").is_none() && short["created"].get("points").is_none(), "{short}");
|
||||
assert_eq!((&short["created"]["curve_count"], &short["created"]["point_count"]), (&json!(30), &json!(31)), "{short}");
|
||||
assert_eq!(short["curves"], json!(30), "the count outside `created` stays: {short}");
|
||||
// Twenty curves are still listed.
|
||||
tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}));
|
||||
let twenty = tool(&mut s, "execute", json!({"command": "sketch.insert_svg", "params": {"text": polyline(20)}, "verbose": false}));
|
||||
assert_eq!(twenty["created"]["curves"].as_array().map(Vec::len), Some(20), "{twenty}");
|
||||
// The same in a batch.
|
||||
let b = tool(
|
||||
&mut s,
|
||||
"batch",
|
||||
json!({"commands": [{"command": "sketch.create", "params": {"plane": "XY"}}, {"command": "sketch.insert_svg", "params": {"text": polyline(30)}}], "verbose": false}),
|
||||
);
|
||||
let step = &b["results"][1];
|
||||
assert!(step["created"].get("curves").is_none() && step["created"]["curve_count"] == json!(30), "{b}");
|
||||
}
|
||||
|
||||
/// #531: a product model keeps an optional variant hidden in its default presentation. Hiding
|
||||
/// it must survive saving and reopening the design (suppressing it would change the design).
|
||||
#[test]
|
||||
@@ -4074,7 +4238,7 @@ fn inspect_design_is_unchanged_without_compact_output() {
|
||||
let defs = tool_definitions();
|
||||
let def = defs.iter().find(|d| d["name"] == "inspect_design").unwrap();
|
||||
assert_eq!(def["inputSchema"]["properties"]["sketches"]["type"], "boolean", "{def}");
|
||||
assert!(def["inputSchema"]["properties"].get("bodies").is_none(), "{def}");
|
||||
assert_eq!(def["inputSchema"]["properties"]["bodies"]["type"], json!(["boolean", "string"]), "no list of names: {def}");
|
||||
let listed = call(&mut s, 1, "tools/list", json!({}));
|
||||
assert!(listed["result"]["tools"].as_array().unwrap().iter().any(|t| t == def), "tools/list serves the default definition");
|
||||
|
||||
@@ -4169,7 +4333,7 @@ impl crate::Backend for AppWithPrefs {
|
||||
fn inspect_design_is_compact(s: &mut Server) -> bool {
|
||||
let listed = call(s, 1, "tools/list", json!({}));
|
||||
let def = listed["result"]["tools"].as_array().and_then(|t| t.iter().find(|d| d["name"] == "inspect_design")).cloned().unwrap_or_default();
|
||||
def["inputSchema"]["properties"]["bodies"].is_object()
|
||||
def["inputSchema"]["properties"]["bodies"]["type"].as_array().is_some_and(|t| t.contains(&json!("array")))
|
||||
}
|
||||
|
||||
/// The app's Preferences turn compact output on for a bridged server; it asks until the app
|
||||
|
||||
+123
-20
@@ -122,7 +122,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
json!({
|
||||
"command": string("Command id (see list_commands)"),
|
||||
"params": {"type": "object", "description": "The command's parameters"},
|
||||
"verbose": {"type": "boolean", "description": "false: omit the full feature body list and sketch ids repeated outside `created`; keep created_bodies, changed_bodies and removed_bodies. When a sketch command creates more points than its new curves have ends (spline poles), `created.points` becomes `created.point_count` (default true)"},
|
||||
"verbose": {"type": "boolean", "description": "false: omit the full feature body list and sketch ids repeated outside `created`; keep created_bodies, changed_bodies and removed_bodies. When a sketch command creates more points than its new curves have ends (spline poles), `created.points` becomes `created.point_count`. A `created` list of more than 20 ids (a drawing of many curves) is left out and counted instead: `curve_count`, `constraint_count`, `dimension_count` (and `point_count`); `sketch.inspect` lists every id (default true)"},
|
||||
"expect": expect_schema(),
|
||||
}),
|
||||
&["command"],
|
||||
@@ -160,7 +160,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
"coalesce_parameters": {"type": "boolean", "description": "Set each run of consecutive plain parameter edits (parameters.change or set_parameter with a name and a value, or changes) together, rebuilding the design once for the run instead of once per edit; a run that is refused goes step by step, naming the refused step. Other steps still see the design up to date. Each step keeps its own result; the run is one undo step (default false)"},
|
||||
"timings": {"type": "boolean", "description": "Add `timings`: [{index, command, ms}] for every step run (the failing one included) and `total_ms` (default false)"},
|
||||
"profile": {"type": "boolean", "description": "Like timings, and each step's `profile`: {boolean_ms, tessellate_ms, other_ms}, its time in kernel booleans, in tessellation and the rest (default false)"},
|
||||
"verbose": {"type": "boolean", "description": "false: a compact reply. Feature steps leave out the full body list (created_bodies, changed_bodies and removed_bodies stay), sketch steps the ids they repeat outside `created`, and `created.points` becomes `created.point_count` when there are more points than the new curves have ends (default true)"},
|
||||
"verbose": {"type": "boolean", "description": "false: a compact reply. Feature steps leave out the full body list (created_bodies, changed_bodies and removed_bodies stay), sketch steps the ids they repeat outside `created`, and `created.points` becomes `created.point_count` when there are more points than the new curves have ends; a `created` list of more than 20 ids is left out and counted instead (`curve_count` and the like); `sketch.inspect` lists every id (default true)"},
|
||||
"expect": expect_schema(),
|
||||
}),
|
||||
&[],
|
||||
@@ -237,7 +237,8 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
kernel splits one cylinder into merged (origin is the axis point closest to the world origin). The reply ends with \
|
||||
`restored: true`; if the design could not be put back the call is an error with `restored: false` and the values \
|
||||
still applied. An unknown parameter name is an error and nothing changes. Replaces the loop set_parameter, \
|
||||
measure, body_topology, set_parameter back.",
|
||||
measure, body_topology, set_parameter back. On a large design keep the reply small with `bodies` (only those \
|
||||
bodies are measured and their cylinders listed) and `summary: true` (measure as the measure tool's summary).",
|
||||
object(
|
||||
json!({
|
||||
"set": {
|
||||
@@ -250,6 +251,8 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
|
||||
"items": {"type": "string", "enum": REPORTS},
|
||||
"description": "What to report: measure, cylinders, errors (default all three)",
|
||||
},
|
||||
"bodies": {"type": "array", "items": {"type": "string"}, "description": "Only these bodies, by name, for measure and cylinders (default: all)"},
|
||||
"summary": {"type": "boolean", "description": "measure as the measure tool's `summary: true`: per body only name, volume_mm3, bbox {min, max}, faces and closed, and total.volume_mm3 (default false: the full reply)"},
|
||||
}),
|
||||
&["set"],
|
||||
),
|
||||
@@ -452,12 +455,15 @@ fn inspect_design_tool(opts: Options) -> Value {
|
||||
counts), sketches (curves with ids and coordinates, points, constraints, profiles, degrees of freedom, solve \
|
||||
status), assembly joints (what each origin is attached to: occurrence, the feature, face, edge or sketch \
|
||||
curve its face names refer to, whether it resolves and why not; `execute` `joint.inspect {joint}` gives one), \
|
||||
the active sketch, selection and undo depth.",
|
||||
the active sketch, selection and undo depth. On a large design keep the reply small with `timeline: \"summary\"` \
|
||||
(each feature as id, name, type, and error or warning only when it has one) or false, and `bodies: \"names\"` or false.",
|
||||
object(
|
||||
json!({
|
||||
"measure": {"type": "boolean", "description": "Measure bodies (default true)"},
|
||||
"sketches": {"type": "boolean", "description": "Full sketch contents (default true; false = summary only)"},
|
||||
"joints": {"type": "boolean", "description": "Joint definitions as joint.inspect gives them, when the design has joints (default true)"},
|
||||
"timeline": timeline_arg(),
|
||||
"bodies": {"type": ["boolean", "string"], "enum": [true, false, "names"], "description": BODIES_ARG},
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
@@ -473,7 +479,9 @@ fn inspect_design_tool(opts: Options) -> Value {
|
||||
plane, counts of points, curves, constraints and profiles, degrees of freedom, solve status); their full contents \
|
||||
(curves with ids and coordinates, points, constraints, profiles) only for the sketches named in `sketches`, or \
|
||||
all with `sketches: true` (large: a 720-point sketch is some 200 KB). Assembly joints: what each origin is \
|
||||
attached to (`execute` `joint.inspect {joint}` gives one). Arguments: measure, sketches, bodies, joints.",
|
||||
attached to (`execute` `joint.inspect {joint}` gives one). On a large design keep the reply small with \
|
||||
`timeline: \"summary\"` (each feature as id, name, type, and error or warning only when it has one) or false, and \
|
||||
`bodies: \"names\"` or false. Arguments: measure, sketches, bodies, timeline, joints.",
|
||||
object(
|
||||
json!({
|
||||
"measure": {"type": "boolean", "description": "Measure bodies (default true)"},
|
||||
@@ -482,8 +490,13 @@ fn inspect_design_tool(opts: Options) -> Value {
|
||||
"items": {"type": ["string", "integer"]},
|
||||
"description": "Which sketches in full: a list of sketch names or ids, true (all of them), or false / \"summary\" (none: summaries only, the default)",
|
||||
},
|
||||
"bodies": {"type": "array", "items": {"type": "string"}, "description": "Only these bodies, by name (default: all)"},
|
||||
"bodies": {
|
||||
"type": ["array", "boolean", "string"],
|
||||
"items": {"type": "string"},
|
||||
"description": "Only these bodies, by name (default: all); or \"names\" (only the body names, nothing measured) or false (no bodies)",
|
||||
},
|
||||
"joints": {"type": "boolean", "description": "Joint definitions as joint.inspect gives them, when the design has joints (default true)"},
|
||||
"timeline": timeline_arg(),
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
@@ -491,6 +504,18 @@ fn inspect_design_tool(opts: Options) -> Value {
|
||||
)
|
||||
}
|
||||
|
||||
/// `inspect_design`'s `timeline` argument, as `document.inspect` takes it.
|
||||
fn timeline_arg() -> Value {
|
||||
json!({
|
||||
"type": ["boolean", "string"],
|
||||
"enum": [true, false, "summary"],
|
||||
"description": "true (default): every feature with its state and timing; \"summary\": each feature as id, name and type, with error, warning, suppressed and rolled_back only when set; false: no timeline",
|
||||
})
|
||||
}
|
||||
|
||||
/// `inspect_design`'s `bodies` argument without compact output.
|
||||
const BODIES_ARG: &str = "true (default): every body as `measure` says; \"names\": only the body names, nothing measured; false: no bodies";
|
||||
|
||||
/// A tool result: content blocks plus the error flag. JSON is pretty-printed, or compact with
|
||||
/// compact output.
|
||||
fn text_result(v: &Value, is_error: bool, opts: Options) -> Value {
|
||||
@@ -849,6 +874,14 @@ fn measure_params(a: &Map<String, Value>) -> Value {
|
||||
p
|
||||
}
|
||||
|
||||
/// `inspect_design`'s arguments as `document.inspect` takes them: `measure`, and `keys` only when
|
||||
/// given, so an app from before them is asked as it always was.
|
||||
fn inspect_params(a: &Map<String, Value>, measure: bool, keys: &[&str]) -> Value {
|
||||
let mut p = pick(a, keys);
|
||||
p["measure"] = json!(measure);
|
||||
p
|
||||
}
|
||||
|
||||
/// `summary` narrows measured bodies; a `names_only` list has nothing to narrow.
|
||||
fn measure_summary_wanted(a: &Map<String, Value>) -> bool {
|
||||
let flag = |k: &str| a.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
@@ -875,7 +908,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
|
||||
}
|
||||
"inspect_design" => {
|
||||
let measure = a.get("measure").and_then(Value::as_bool).unwrap_or(true);
|
||||
let mut d = b.call("document.inspect", json!({"measure": measure}))?;
|
||||
let mut d = b.call("document.inspect", inspect_params(a, measure, &["timeline", "bodies"]))?;
|
||||
if a.get("sketches").and_then(Value::as_bool).unwrap_or(true) {
|
||||
let ids: Vec<Value> = d["sketches"].as_array().map(|v| v.iter().map(|s| s["id"].clone()).collect()).unwrap_or_default();
|
||||
let full: Vec<Value> = ids
|
||||
@@ -1121,7 +1154,10 @@ fn batch_step(step: &Value) -> Result<(String, Value, Option<Narrow>), String> {
|
||||
}
|
||||
mapped("inspect.compare_picture", p)
|
||||
}
|
||||
"inspect_design" => mapped("document.inspect", json!({"measure": a.get("measure").and_then(Value::as_bool).unwrap_or(true)})),
|
||||
"inspect_design" => {
|
||||
let keys: &[&str] = if a.get("bodies").is_some_and(Value::is_array) { &["timeline"] } else { &["timeline", "bodies"] };
|
||||
mapped("document.inspect", inspect_params(a, a.get("measure").and_then(Value::as_bool).unwrap_or(true), keys))
|
||||
}
|
||||
"undo" => mapped("edit.undo", json!({})),
|
||||
"redo" => mapped("edit.redo", json!({})),
|
||||
"export" => mapped("file.export", pick(a, &["path", "format", "bodies", "mesh_prefix", "effort", "velocity"])),
|
||||
@@ -1184,7 +1220,13 @@ fn name_list(names: &[String]) -> String {
|
||||
/// sketches are asked for in full (by name or id, or all), and only the bodies asked for.
|
||||
fn inspect_design_compact(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<Value, String> {
|
||||
let measure = a.get("measure").and_then(Value::as_bool).unwrap_or(true);
|
||||
let bodies: Option<Vec<String>> = match a.get("bodies").filter(|v| !v.is_null()) {
|
||||
// `bodies` as a list keeps only those bodies; true, false or "names" go to document.inspect.
|
||||
if let Some(Value::String(form)) = a.get("bodies")
|
||||
&& form != "names"
|
||||
{
|
||||
return Err("inspect_design: `bodies` must be a list of body names, true, false or \"names\"".into());
|
||||
}
|
||||
let bodies: Option<Vec<String>> = match a.get("bodies").filter(|v| v.is_array()) {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
v.as_array()
|
||||
@@ -1193,7 +1235,10 @@ fn inspect_design_compact(b: &mut dyn Backend, a: &Map<String, Value>) -> Result
|
||||
),
|
||||
};
|
||||
// With `bodies`, only those are measured.
|
||||
let mut d = b.call("document.inspect", json!({"measure": measure && bodies.is_none(), "sketch_counts": true}))?;
|
||||
let keys: &[&str] = if bodies.is_some() { &["timeline"] } else { &["timeline", "bodies"] };
|
||||
let mut p = inspect_params(a, measure && bodies.is_none(), keys);
|
||||
p["sketch_counts"] = json!(true);
|
||||
let mut d = b.call("document.inspect", p)?;
|
||||
if let Some(want) = bodies {
|
||||
let all: Vec<Value> = d["bodies"].as_array().cloned().unwrap_or_default();
|
||||
let names: Vec<String> = all.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect();
|
||||
@@ -1834,9 +1879,14 @@ fn topology_cylinders(faces: &Value, edges: &Value) -> Value {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A `created` list longer than this (a drawing of hundreds of curves) is left out of a compact
|
||||
/// reply and counted instead (`curve_count` and the like, as `point_count`): the ids are no
|
||||
/// sequence an agent could derive, each kind of curve counts on its own. `sketch.inspect` has them.
|
||||
const COMPACT_LIST: usize = 20;
|
||||
|
||||
/// A command's result without what the design already had (`execute` or `batch` with `verbose: false`): a
|
||||
/// feature's full body list (its created, changed and removed bodies stay), and the sketch ids
|
||||
/// a sketch command repeats outside `created`.
|
||||
/// a sketch command repeats outside `created`. Long `created` lists are counted instead of listed.
|
||||
fn compact(mut v: Value) -> Value {
|
||||
let Some(o) = v.as_object_mut() else { return v };
|
||||
if o.contains_key("created_bodies") {
|
||||
@@ -1859,6 +1909,15 @@ fn compact(mut v: Value) -> Value {
|
||||
created.insert("point_count".into(), json!(points));
|
||||
}
|
||||
}
|
||||
if let Some(created) = o.get_mut("created").and_then(Value::as_object_mut) {
|
||||
for (k, count) in
|
||||
[("points", "point_count"), ("curves", "curve_count"), ("constraints", "constraint_count"), ("dimensions", "dimension_count")]
|
||||
{
|
||||
let Some(n) = created.get(k).and_then(Value::as_array).map(Vec::len).filter(|n| *n > COMPACT_LIST) else { continue };
|
||||
created.remove(k);
|
||||
created.insert(count.into(), json!(n));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -1868,7 +1927,7 @@ fn compact(mut v: Value) -> Value {
|
||||
/// changed a parameter to see its effect spent three to five calls on this loop, and one saved
|
||||
/// the design with the test values still in it.
|
||||
fn check_parameters(b: &mut dyn Backend, a: &Map<String, Value>, opts: Options) -> Value {
|
||||
let (changes, reports) = match check_parameters_args(a) {
|
||||
let (changes, reports, narrow) = match check_parameters_args(a) {
|
||||
Ok(x) => x,
|
||||
Err(e) => return text_result(&Value::String(e), true, opts),
|
||||
};
|
||||
@@ -1881,7 +1940,7 @@ fn check_parameters(b: &mut dyn Backend, a: &Map<String, Value>, opts: Options)
|
||||
let e = "check_parameters: this app has no edit.checkpoint to put the design back with; use set_parameter and measure, and set the values back yourself";
|
||||
return text_result(&json!(e), true, opts);
|
||||
};
|
||||
let outcome = try_parameters(b, &changes, &reports);
|
||||
let outcome = try_parameters(b, &changes, &reports, &narrow);
|
||||
let restore = exec(b, "edit.restore_checkpoint", json!({"checkpoint": id}));
|
||||
match (outcome, restore) {
|
||||
(Ok(mut reply), Ok(_)) => {
|
||||
@@ -1904,9 +1963,17 @@ fn check_parameters(b: &mut dyn Backend, a: &Map<String, Value>, opts: Options)
|
||||
}
|
||||
}
|
||||
|
||||
/// How small `check_parameters`'s reports are: only these bodies (`None`: all), and `measure`
|
||||
/// summarised as the measure tool's `summary: true`.
|
||||
struct ReportScope {
|
||||
bodies: Option<Vec<String>>,
|
||||
summary: bool,
|
||||
}
|
||||
|
||||
/// `check_parameters`'s arguments, checked before anything is sent: the values to try (a
|
||||
/// number or an expression each, as `set_parameter` takes its `value`) and what to report.
|
||||
fn check_parameters_args(a: &Map<String, Value>) -> Result<(Map<String, Value>, Vec<&'static str>), String> {
|
||||
/// number or an expression each, as `set_parameter` takes its `value`), what to report and how
|
||||
/// small.
|
||||
fn check_parameters_args(a: &Map<String, Value>) -> Result<(Map<String, Value>, Vec<&'static str>, ReportScope), String> {
|
||||
let set = a.get("set").and_then(Value::as_object).ok_or("check_parameters: `set` must be an object of parameter name: value")?;
|
||||
if set.is_empty() {
|
||||
return Err("check_parameters: `set` is empty; give at least one parameter name: value".into());
|
||||
@@ -1939,7 +2006,22 @@ fn check_parameters_args(a: &Map<String, Value>) -> Result<(Map<String, Value>,
|
||||
out
|
||||
}
|
||||
};
|
||||
Ok((changes, reports))
|
||||
let bodies = match a.get("bodies").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let names = v
|
||||
.as_array()
|
||||
.and_then(|l| l.iter().map(|n| n.as_str().map(str::trim).filter(|n| !n.is_empty()).map(str::to_string)).collect::<Option<Vec<_>>>())
|
||||
.filter(|l| !l.is_empty())
|
||||
.ok_or("check_parameters: `bodies` must be a list of body names")?;
|
||||
Some(names)
|
||||
}
|
||||
};
|
||||
let summary = match a.get("summary").filter(|v| !v.is_null()) {
|
||||
None => false,
|
||||
Some(v) => v.as_bool().ok_or("check_parameters: `summary` must be true or false")?,
|
||||
};
|
||||
Ok((changes, reports, ReportScope { bodies, summary }))
|
||||
}
|
||||
|
||||
/// Refuse a name the design has no parameter of, before anything changes. An app without
|
||||
@@ -1960,14 +2042,32 @@ fn known_parameters(b: &mut dyn Backend, changes: &Map<String, Value>) -> Result
|
||||
|
||||
/// Apply the values and read back what was asked for: what each parameter is now, the
|
||||
/// features rebuilt, and the reports.
|
||||
fn try_parameters(b: &mut dyn Backend, changes: &Map<String, Value>, reports: &[&str]) -> Result<Value, String> {
|
||||
fn try_parameters(b: &mut dyn Backend, changes: &Map<String, Value>, reports: &[&str], scope: &ReportScope) -> Result<Value, String> {
|
||||
let (values, recomputed) = apply_parameters(b, changes)?;
|
||||
let mut reply = json!({"set": changes, "values": values, "recomputed": recomputed});
|
||||
// The bodies and each feature's state: one cheap read serves `errors` and `cylinders`.
|
||||
let design = if reports.iter().any(|r| *r != "measure") { Some(b.call("document.inspect", json!({"measure": false}))?) } else { None };
|
||||
let design = if reports.iter().any(|r| *r != "measure") || scope.bodies.is_some() {
|
||||
Some(b.call("document.inspect", json!({"measure": false}))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// A body named in `bodies` that the design doesn't have (now) is an error that names them.
|
||||
if let (Some(want), Some(d)) = (&scope.bodies, &design) {
|
||||
let names: Vec<String> = d["bodies"].as_array().into_iter().flatten().filter_map(|body| body["name"].as_str()).map(str::to_string).collect();
|
||||
if let Some(missing) = want.iter().find(|n| !names.contains(n)) {
|
||||
return Err(format!("check_parameters: no body `{missing}` (bodies: {})", name_list(&names)));
|
||||
}
|
||||
}
|
||||
for r in reports {
|
||||
match *r {
|
||||
"measure" => reply["measure"] = exec(b, "inspect.measure", json!({}))?,
|
||||
"measure" => {
|
||||
let p = match &scope.bodies {
|
||||
Some(names) => json!({"bodies": names}),
|
||||
None => json!({}),
|
||||
};
|
||||
let m = exec(b, "inspect.measure", p)?;
|
||||
reply["measure"] = if scope.summary { measure_summary(m) } else { m };
|
||||
}
|
||||
"errors" => {
|
||||
if let Some(d) = &design {
|
||||
let pick = |key: &str| -> Value {
|
||||
@@ -1982,8 +2082,11 @@ fn try_parameters(b: &mut dyn Backend, changes: &Map<String, Value>, reports: &[
|
||||
}
|
||||
"cylinders" => {
|
||||
if let Some(d) = &design {
|
||||
let bodies: Vec<String> =
|
||||
let mut bodies: Vec<String> =
|
||||
d["bodies"].as_array().into_iter().flatten().filter_map(|body| body["name"].as_str()).map(str::to_string).collect();
|
||||
if let Some(want) = &scope.bodies {
|
||||
bodies.retain(|n| want.contains(n));
|
||||
}
|
||||
reply["cylinders"] = Value::Array(cylinders(b, &bodies));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,8 +80,7 @@ impl ImageExecutor for CpuExecutor {
|
||||
#[cfg(all(test, feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
impl ImageHost {
|
||||
pub(super) fn join_worker_for_test(&mut self) -> Result<()> {
|
||||
self.0.shared.stop.store(true, Ordering::Release);
|
||||
self.0.shared.wake.notify_all();
|
||||
self.0.shared.stop_worker();
|
||||
let inner = Arc::get_mut(&mut self.0).ok_or_else(|| JobError::new(ErrorCode::Internal, "test must own its host"))?;
|
||||
if let Some(worker) = inner._worker.take() {
|
||||
worker.join().map_err(|_| JobError::new(ErrorCode::Internal, "test worker panicked"))?;
|
||||
@@ -311,8 +310,7 @@ mod light_union_lease_tests {
|
||||
assert_eq!(job.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
|
||||
// Synchronize this test's completed worker before asserting final Arc release;
|
||||
// publication wakes waiters just before Running drops its temporary handle.
|
||||
host.0.shared.stop.store(true, Ordering::Release);
|
||||
host.0.shared.wake.notify_all();
|
||||
host.0.shared.stop_worker();
|
||||
Arc::get_mut(&mut host.0).unwrap()._worker.take().unwrap().join().unwrap();
|
||||
let retained = job.0.request.raytrace.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
@@ -1709,6 +1707,19 @@ struct Shared {
|
||||
wake: Condvar,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
/// Tell the native worker to stop and wake it. The worker checks `stop` and starts waiting
|
||||
/// while it holds the queue lock, so both happen under that lock: a wake sent between the
|
||||
/// check and the wait would be lost, and the worker would wait for good (#14500).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn stop_worker(&self) {
|
||||
// A poisoned lock still hands back its guard; holding it is all that matters here.
|
||||
let _queue = self.queue.lock();
|
||||
self.stop.store(true, Ordering::Release);
|
||||
self.wake.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
struct Owner {
|
||||
executor: Option<Arc<dyn ImageExecutor>>,
|
||||
shared: Arc<Shared>,
|
||||
@@ -1739,7 +1750,7 @@ impl Drop for Owner {
|
||||
terminal(&work.handle, JobError::new(ErrorCode::Cancelled, "render host closed"));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.shared.wake.notify_all();
|
||||
self.shared.stop_worker();
|
||||
// Detaching cannot stall the UI; the bounded worker observes stop/cancel at its next unit.
|
||||
}
|
||||
}
|
||||
@@ -2840,8 +2851,7 @@ mod retained_request_tests {
|
||||
assert_eq!(slot.0.capacity() as u64 * std::mem::size_of::<JobOptions>() as u64, expected);
|
||||
}
|
||||
assert_eq!(job.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
|
||||
host.0.shared.stop.store(true, Ordering::Release);
|
||||
host.0.shared.wake.notify_all();
|
||||
host.0.shared.stop_worker();
|
||||
Arc::get_mut(&mut host.0).unwrap()._worker.take().unwrap().join().unwrap();
|
||||
assert!(job.work_released());
|
||||
let frame = job.frame().unwrap().unwrap();
|
||||
@@ -2910,8 +2920,7 @@ mod retained_request_tests {
|
||||
received.recv_timeout(std::time::Duration::from_secs(60)).unwrap();
|
||||
job.cancel().unwrap();
|
||||
assert_eq!(job.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Cancelled);
|
||||
host.0.shared.stop.store(true, Ordering::Release);
|
||||
host.0.shared.wake.notify_all();
|
||||
host.0.shared.stop_worker();
|
||||
Arc::get_mut(&mut host.0).unwrap()._worker.take().unwrap().join().unwrap();
|
||||
assert!(job.work_released());
|
||||
assert!(job.frame().unwrap().is_none());
|
||||
@@ -2944,8 +2953,7 @@ mod retained_request_tests {
|
||||
let presentation = super::super::presentation::Presentation { exposure_ev: 1.0, ..Default::default() };
|
||||
let derivative = host.submit_regrade(JobId { session: 903, serial: 2, generation: 2 }, &original, presentation).unwrap();
|
||||
assert_eq!(derivative.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
|
||||
host.0.shared.stop.store(true, Ordering::Release);
|
||||
host.0.shared.wake.notify_all();
|
||||
host.0.shared.stop_worker();
|
||||
Arc::get_mut(&mut host.0).unwrap()._worker.take().unwrap().join().unwrap();
|
||||
let frame = derivative.frame().unwrap().unwrap();
|
||||
assert_eq!(frame.linear_exr().unwrap(), original_frame.linear_exr().unwrap());
|
||||
@@ -3041,8 +3049,7 @@ mod retained_request_tests {
|
||||
assert_eq!(job.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
|
||||
// Completion publication precedes the worker's last temporary handle release.
|
||||
// Join this test's worker before asserting exact lease ownership, as in the union test.
|
||||
host.0.shared.stop.store(true, Ordering::Release);
|
||||
host.0.shared.wake.notify_all();
|
||||
host.0.shared.stop_worker();
|
||||
Arc::get_mut(&mut host.0).unwrap()._worker.take().unwrap().join().unwrap();
|
||||
let frame = job.frame().unwrap().unwrap();
|
||||
let held = job.clone();
|
||||
@@ -3414,8 +3421,7 @@ mod surface_request_metadata_tests {
|
||||
let job = host.submit(id(40, 2), 1, scene, request).unwrap();
|
||||
assert_eq!(job.wait_timeout(Duration::from_secs(10)).unwrap().progress.phase, Phase::Completed);
|
||||
// Wait for this host's last temporary worker handle before exact lease checks.
|
||||
host.0.shared.stop.store(true, Ordering::Release);
|
||||
host.0.shared.wake.notify_all();
|
||||
host.0.shared.stop_worker();
|
||||
Arc::get_mut(&mut host.0).unwrap()._worker.take().unwrap().join().unwrap();
|
||||
let frame = job.frame().unwrap().unwrap();
|
||||
let png_bytes = frame._reservation.bytes;
|
||||
|
||||
@@ -2627,6 +2627,42 @@ fn actual_cpu_texture_worker_releases_sources_after_completion_or_cancel_while_s
|
||||
}
|
||||
}
|
||||
|
||||
// The native worker checks `stop` and starts waiting while it holds the queue lock. A stop sent
|
||||
// in between must still wake it, else joining the worker waits for good (#14500).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_stop_sent_while_the_worker_is_about_to_wait_still_wakes_it() {
|
||||
use std::sync::mpsc::sync_channel;
|
||||
use std::time::Duration;
|
||||
let shared = ImageHost::shared(MEMORY_LIMIT);
|
||||
let (checked, on_checked) = sync_channel(1);
|
||||
let (stopped, on_stopped) = sync_channel(1);
|
||||
let worker = {
|
||||
let shared = Arc::clone(&shared);
|
||||
std::thread::spawn(move || {
|
||||
// Hold the lock between the check and the wait, as native_worker does.
|
||||
let queue = shared.queue.lock().unwrap();
|
||||
assert!(!shared.stop.load(Ordering::Acquire));
|
||||
checked.send(()).unwrap();
|
||||
// A stop that takes the lock cannot finish here; one that doesn't finishes now.
|
||||
let _ = on_stopped.recv_timeout(Duration::from_millis(500));
|
||||
// One wait, without checking `stop` again, as native_worker's wait.
|
||||
let (_queue, waited) = shared.wake.wait_timeout(queue, Duration::from_secs(10)).unwrap();
|
||||
!waited.timed_out()
|
||||
})
|
||||
};
|
||||
on_checked.recv().unwrap();
|
||||
let stopper = {
|
||||
let shared = Arc::clone(&shared);
|
||||
std::thread::spawn(move || {
|
||||
shared.stop_worker();
|
||||
let _ = stopped.send(());
|
||||
})
|
||||
};
|
||||
assert!(worker.join().unwrap(), "the worker missed the stop and waited until its timeout");
|
||||
stopper.join().unwrap();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn registered_preview_and_final_profiles_keep_capture_identity_and_completed_mode() {
|
||||
|
||||
+13
-11
@@ -305,7 +305,7 @@ enum LCurve {
|
||||
Spline(usize, usize, usize, usize, bool, u8, bool, Option<crate::Nurbs>),
|
||||
}
|
||||
|
||||
fn layout(geom: &[LinkGeom]) -> Result<Layout> {
|
||||
fn layout(geom: &[LinkGeom], kind: LinkKind) -> Result<Layout> {
|
||||
if geom.len() > MAX_LINK_ENTITIES {
|
||||
return Err(SketchError::TooLarge);
|
||||
}
|
||||
@@ -382,8 +382,9 @@ fn layout(geom: &[LinkGeom]) -> Result<Layout> {
|
||||
let curves = l.curves.clone();
|
||||
l.lone.retain(|i| !used(*i, &curves));
|
||||
// A face seen edge-on: every edge falls on one line, and one line end to end stands for
|
||||
// them all (its vertices can be projected one by one).
|
||||
if l.lone.is_empty() && l.curves.len() > 1 && flat(&l.curves, &l.pts) {
|
||||
// them all (its vertices can be projected one by one). A section is not a projection: pieces
|
||||
// on one line with a gap between them (a ring cut through its hole) stay apart (#14282).
|
||||
if kind != LinkKind::Intersect && l.lone.is_empty() && l.curves.len() > 1 && flat(&l.curves, &l.pts) {
|
||||
let (a, b) = extent(&l.pts);
|
||||
l = Layout { pts: vec![a, b], lone: Vec::new(), curves: vec![LCurve::Line(0, 1)] };
|
||||
}
|
||||
@@ -700,7 +701,7 @@ impl Sketch {
|
||||
/// and ends; other curves, and points on their own (the origin, a vertex), never count as
|
||||
/// present.
|
||||
pub fn link_geometry_present(&self, geom: &[LinkGeom]) -> bool {
|
||||
self.geometry_present(geom, false)
|
||||
self.geometry_present(geom, LinkKind::Project, false)
|
||||
}
|
||||
|
||||
/// [`Sketch::link_geometry_present`], where a point on its own counts too when a linked
|
||||
@@ -708,11 +709,12 @@ impl Sketch {
|
||||
/// cutting the same one again would stack a second point on the first. The end of a linked
|
||||
/// curve (a corner of the face's border) does not count.
|
||||
pub fn link_geometry_or_points_present(&self, geom: &[LinkGeom]) -> bool {
|
||||
self.geometry_present(geom, true)
|
||||
self.geometry_present(geom, LinkKind::Intersect, true)
|
||||
}
|
||||
|
||||
fn geometry_present(&self, geom: &[LinkGeom], points: bool) -> bool {
|
||||
let Ok(lay) = layout(geom) else { return false };
|
||||
/// `kind` lays `geom` out as a new link of that kind would be (see [`layout`]).
|
||||
fn geometry_present(&self, geom: &[LinkGeom], kind: LinkKind, points: bool) -> bool {
|
||||
let Ok(lay) = layout(geom, kind) else { return false };
|
||||
if lay.curves.is_empty() && lay.lone.is_empty() {
|
||||
return false;
|
||||
}
|
||||
@@ -744,7 +746,7 @@ impl Sketch {
|
||||
/// Add linked geometry. Returns the link id; the new curve and point ids are
|
||||
/// [`Sketch::link_curves`] / [`Sketch::link_points`] of it.
|
||||
pub fn add_link(&mut self, kind: LinkKind, source: LinkSource, geom: &[LinkGeom]) -> Result<String> {
|
||||
let lay = layout(geom)?;
|
||||
let lay = layout(geom, kind)?;
|
||||
if lay.curves.is_empty() && lay.lone.is_empty() {
|
||||
return Err(SketchError::Invalid("the reference projects to nothing on this sketch plane".into()));
|
||||
}
|
||||
@@ -815,10 +817,10 @@ impl Sketch {
|
||||
/// sketch's own curves and constraints on it move to the new entities that match the old
|
||||
/// ones (`rebuild_link`). Returns `true` when it was rebuilt.
|
||||
pub fn update_link(&mut self, id: &str, geom: &[LinkGeom]) -> Result<bool> {
|
||||
if self.link(id).is_none() {
|
||||
let Some(kind) = self.link(id).map(|l| l.kind) else {
|
||||
return Err(SketchError::Unknown(id.into()));
|
||||
}
|
||||
let lay = layout(geom)?;
|
||||
};
|
||||
let lay = layout(geom, kind)?;
|
||||
// A projected face boundary is a closed loop: crossings and touching points make its
|
||||
// identity ambiguous, and the link keeps the geometry it had. A face taken with
|
||||
// `Intersect` is a section: an open chain, which no closed boundary describes.
|
||||
|
||||
@@ -361,6 +361,37 @@ fn edge_on_face_lays_out_as_one_silhouette_line_and_stays_current() {
|
||||
assert!(!sk.link(&link).unwrap().lost);
|
||||
}
|
||||
|
||||
/// A face with a hole cut through the hole by the sketch plane: its section is two pieces on
|
||||
/// one line, one each side of the hole. Only a projection seen edge-on is one line end to end;
|
||||
/// a section keeps the gap (#14282).
|
||||
#[test]
|
||||
fn intersected_face_with_a_hole_keeps_both_pieces_on_one_line() {
|
||||
// A pipe's top ring (bore 10, outside 22) cut through its axis.
|
||||
let section = vec![LinkGeom::Line(Vec2::new(-22.0, 40.0), Vec2::new(-10.0, 40.0)), LinkGeom::Line(Vec2::new(10.0, 40.0), Vec2::new(22.0, 40.0))];
|
||||
let ends = |sk: &Sketch, link: &str| -> Vec<[Vec2; 2]> {
|
||||
sk.link_curves(link)
|
||||
.iter()
|
||||
.map(|c| match sk.curves[*c].kind {
|
||||
CurveKind::Line { a, b } => [sk.points[a].pos, sk.points[b].pos],
|
||||
_ => panic!("a line"),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut sk = Sketch::new();
|
||||
let link = sk.add_link(LinkKind::Intersect, face(), §ion).unwrap();
|
||||
let pieces = [[Vec2::new(-22.0, 40.0), Vec2::new(-10.0, 40.0)], [Vec2::new(10.0, 40.0), Vec2::new(22.0, 40.0)]];
|
||||
assert_eq!(ends(&sk, &link), pieces, "one line each side of the hole");
|
||||
assert!(!sk.update_link(&link, §ion).unwrap(), "the same section keeps its curves");
|
||||
assert_eq!(ends(&sk, &link), pieces);
|
||||
assert!(!sk.link(&link).unwrap().lost);
|
||||
// Intersecting the same face again finds both pieces already there, so nothing is added.
|
||||
assert!(sk.link_geometry_or_points_present(§ion), "the section is already linked");
|
||||
// Projected, the same ring seen edge-on is still one line end to end.
|
||||
let projected = sk.add_link(LinkKind::Project, face(), §ion).unwrap();
|
||||
let [[a, b]] = ends(&sk, &projected)[..] else { panic!("one line") };
|
||||
assert!([a, b].contains(&Vec2::new(-22.0, 40.0)) && [a, b].contains(&Vec2::new(22.0, 40.0)), "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
/// A conic that comes with a link (projected geometry) takes the curve prefix `n` like a drawn
|
||||
/// one, not the constraints' `k` (#6173).
|
||||
#[test]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user