mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-11 11:11:59 +00:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27423c2b78 | |||
| f5d9538979 | |||
| 32f791000d | |||
| 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 | |||
| 6b63b3ce6e | |||
| 3b561df768 | |||
| 5d5e1208f5 | |||
| cfbd729aa8 | |||
| a97524ea67 | |||
| 5d28467789 | |||
| 8c82399584 | |||
| 75a74aeee9 | |||
| 95dec905cd | |||
| 3a40027699 | |||
| 4eaa0adb86 | |||
| 5db0502eb7 | |||
| bf68955a1f | |||
| 0e7812992a | |||
| 3170219cec | |||
| 2e8098ec5c | |||
| ac9b9125f7 | |||
| 43d4f6fa7e |
+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
|
||||
|
||||
Generated
+33
-33
@@ -4259,7 +4259,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4302,11 +4302,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4322,11 +4322,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-color"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-control"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"serde",
|
||||
@@ -4335,7 +4335,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-control-egui"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"image",
|
||||
@@ -4345,7 +4345,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4354,7 +4354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4376,7 +4376,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-dynamics"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4387,7 +4387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4413,7 +4413,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4426,7 +4426,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4451,7 +4451,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4479,7 +4479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4500,7 +4500,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-look"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"log",
|
||||
@@ -4512,7 +4512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
@@ -4526,14 +4526,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp-server"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-profile"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"web-time",
|
||||
@@ -4541,7 +4541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4564,7 +4564,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"serde",
|
||||
@@ -4578,7 +4578,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-smlib"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"log",
|
||||
@@ -4592,18 +4592,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-testkit"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-theme"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
@@ -4613,7 +4613,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4655,7 +4655,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-kit"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4678,14 +4678,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-registry"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-text"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -4695,7 +4695,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-viewport"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -4707,7 +4707,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-viewport-egui"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -4718,7 +4718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-viewport-gpu"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4734,7 +4734,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6220,7 +6220,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proc-macro2",
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["solidcore", "vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.4"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -361,23 +361,54 @@ const UNSTABLE: [(&str, &str); 1] = [(
|
||||
"the halves of each swept wall lie level along every axis, so which is `#1` and which `#2` follows the order the kernel gives the faces in, which changes from one build to the next",
|
||||
)];
|
||||
|
||||
/// The designs that take longest to build, heaviest first, with one build's time (ci profile,
|
||||
/// CLI `run`, one design at a time, on P-cores, 2026-11-10). The test hands them out first, and
|
||||
/// each design's two builds go to two workers, so a heavy design is never built twice in a row on
|
||||
/// one worker while the rest wait. Every other design took under 3.6 s. Only the order of the
|
||||
/// work depends on this list: a stale entry makes the test slower, never wrong. Re-measure when a
|
||||
/// design here gets much faster, or one not listed takes over 5 s.
|
||||
const HEAVIEST: [&str; 6] = [
|
||||
"oracle/77-fillet-variable-linear", // 35.6 s
|
||||
"oracle/83-boundary-loop-thicken", // 12.4 s
|
||||
"regressions/closed_sweep_torus_cut", // 6.8 s
|
||||
"oracle/41-tube-radial-holes", // 4.6 s
|
||||
"oracle/42-overlapping-patterns", // 4.2 s
|
||||
"oracle/35-fillet-curved-curved", // 3.2 s
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone() {
|
||||
let designs = designs();
|
||||
// Every design is built twice, as two jobs: (design, which build). The heaviest go first.
|
||||
for heavy in HEAVIEST {
|
||||
assert!(designs.iter().any(|(name, _)| name == heavy), "HEAVIEST lists `{heavy}`, which is not in the corpus any more");
|
||||
}
|
||||
let rank = |name: &str| HEAVIEST.iter().position(|h| *h == name).unwrap_or(HEAVIEST.len());
|
||||
let mut jobs: Vec<(usize, usize)> = (0..designs.len()).flat_map(|d| [(d, 0), (d, 1)]).collect();
|
||||
jobs.sort_by_key(|&(d, build)| (rank(&designs[d].0), d, build));
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let results = std::sync::Mutex::new(Vec::new());
|
||||
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).min(designs.len().max(1));
|
||||
let builds = std::sync::Mutex::new(std::collections::BTreeMap::new());
|
||||
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).min(jobs.len().max(1));
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..workers {
|
||||
scope.spawn(|| {
|
||||
while let Some((name, source)) = designs.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
let got = load(source).and_then(|first| Ok((build(&first), build(&load(source)?))));
|
||||
results.lock().unwrap_or_else(|e| e.into_inner()).push((name.clone(), got));
|
||||
while let Some(&(d, which)) = jobs.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
let got = load(&designs[d].1).map(|s| build(&s));
|
||||
builds.lock().unwrap_or_else(|e| e.into_inner()).insert((d, which), got);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let mut results = results.into_inner().unwrap_or_else(|e| e.into_inner());
|
||||
let mut builds = builds.into_inner().unwrap_or_else(|e| e.into_inner());
|
||||
let mut results: Vec<(String, Result<(Build, Build), String>)> = designs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(d, (name, _))| {
|
||||
let first = builds.remove(&(d, 0)).unwrap();
|
||||
let second = builds.remove(&(d, 1)).unwrap();
|
||||
(name.clone(), first.and_then(|first| Ok((first, second?))))
|
||||
})
|
||||
.collect();
|
||||
results.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut problems = Vec::new();
|
||||
for (name, got) in &results {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.assembly_constraint_create": "Constrain",
|
||||
"cmd.assembly_constraint_edit": "Edit Constraint"
|
||||
},
|
||||
"de": {
|
||||
"cmd.assembly_constraint_create": "Abhängigkeit",
|
||||
"cmd.assembly_constraint_edit": "Abhängigkeit bearbeiten"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.assembly_constraint_create": "拘束",
|
||||
"cmd.assembly_constraint_edit": "拘束を編集"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.assembly_constraint_create": "约束",
|
||||
"cmd.assembly_constraint_edit": "编辑约束"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.assembly_constraint_create": "구속",
|
||||
"cmd.assembly_constraint_edit": "구속 편집"
|
||||
},
|
||||
"vi": {
|
||||
"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_edit": "تحرير التقييد"
|
||||
},
|
||||
"he": {
|
||||
"cmd.assembly_constraint_create": "אילוץ",
|
||||
"cmd.assembly_constraint_edit": "עריכת אילוץ"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.construct.axis": "Axis",
|
||||
"cmd.construct.plane": "Plane",
|
||||
"cmd.construct.point": "Point",
|
||||
"help.construct.axis": "Creates a construction axis with the method your picks call for: along an edge or line, through a hole or round face, where two planes meet, through two points, or square to a face through a point. Use it when you know the references and not the method.",
|
||||
"help.construct.plane": "Creates a construction plane with the method your picks call for: offset from a plane, through a point, between two planes, through lines or points, tangent to a curved face, or square to a path. Use it when you know the references and not the method.",
|
||||
"help.construct.point": "Creates a construction point with the method your picks call for: at a vertex, at the centre of a circle or sphere, along an edge, where two lines or three planes meet, or where a line crosses a plane. Use it when you know the references and not the method.",
|
||||
"ui.construct_auto.chosen": "Chosen from your picks. Pick another reference, or take one out, to change the method.",
|
||||
"ui.construct_auto.no_fit": "Pick other references: no method builds from these.",
|
||||
"ui.construct_auto.pick": "Pick what to build from: the method follows from your picks.",
|
||||
"ui.construct_auto.refs": "References",
|
||||
"ui.construct_auto.select": "Select references to build from first"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.construct.axis": "軸",
|
||||
"cmd.construct.plane": "平面",
|
||||
"cmd.construct.point": "点",
|
||||
"help.construct.axis": "選択に応じた方法で構築軸を作ります。エッジや線に沿って、穴や丸い面を通って、2つの平面の交線に、2点を通って、または点を通り面に垂直に作ります。参照は決まっているが方法を選びたくないときに使います。",
|
||||
"help.construct.plane": "選択に応じた方法で構築平面を作ります。平面からのオフセット、点を通る、2つの平面の中間、線や点を通る、曲面に接する、またはパスに垂直に作ります。参照は決まっているが方法を選びたくないときに使います。",
|
||||
"help.construct.point": "選択に応じた方法で構築点を作ります。頂点、円や球の中心、エッジ上、2本の線や3つの平面の交点、または線が平面と交わる位置に作ります。参照は決まっているが方法を選びたくないときに使います。",
|
||||
"ui.construct_auto.chosen": "選択から方法を選びました。参照を追加または削除すると方法が変わります。",
|
||||
"ui.construct_auto.no_fit": "別の参照を選択してください。これらから作れる方法がありません。",
|
||||
"ui.construct_auto.pick": "作成元を選択してください。方法は選択から決まります。",
|
||||
"ui.construct_auto.refs": "参照",
|
||||
"ui.construct_auto.select": "先に作成元の参照を選択してください"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.construct.axis": "轴",
|
||||
"cmd.construct.plane": "平面",
|
||||
"cmd.construct.point": "点",
|
||||
"help.construct.axis": "按所选对象对应的方法创建构造轴:沿边或直线、穿过孔或圆形面、两平面的交线、穿过两点,或过一点垂直于面。在已知参照但不想选择方法时使用。",
|
||||
"help.construct.plane": "按所选对象对应的方法创建构造平面:偏移自平面、过一点、两平面之间、过直线或点、与曲面相切,或垂直于路径。在已知参照但不想选择方法时使用。",
|
||||
"help.construct.point": "按所选对象对应的方法创建构造点:在顶点、圆或球的中心、沿边、两直线或三个平面的交点,或直线与平面的交点。在已知参照但不想选择方法时使用。",
|
||||
"ui.construct_auto.chosen": "已根据所选对象选定方法。添加或移除参照即可更换方法。",
|
||||
"ui.construct_auto.no_fit": "请选择其他参照:没有方法能由这些对象创建。",
|
||||
"ui.construct_auto.pick": "选择创建依据:方法由所选对象决定。",
|
||||
"ui.construct_auto.refs": "参照",
|
||||
"ui.construct_auto.select": "请先选择创建依据的参照"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.construct.axis": "محور",
|
||||
"cmd.construct.plane": "مستوى",
|
||||
"cmd.construct.point": "نقطة",
|
||||
"help.construct.axis": "ينشئ محورًا إنشائيًا بالطريقة التي تناسب ما تختاره: على امتداد حافة أو خط، أو عبر ثقب أو وجه دائري، أو عند تقاطع مستويين، أو عبر نقطتين، أو عموديًا على وجه عبر نقطة. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
|
||||
"help.construct.plane": "ينشئ مستوى إنشائيًا بالطريقة التي تناسب ما تختاره: إزاحة عن مستوى، أو عبر نقطة، أو بين مستويين، أو عبر خطوط أو نقاط، أو مماسًا لوجه منحنٍ، أو عموديًا على مسار. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
|
||||
"help.construct.point": "ينشئ نقطة إنشائية بالطريقة التي تناسب ما تختاره: عند رأس، أو في مركز دائرة أو كرة، أو على امتداد حافة، أو عند تقاطع خطين أو ثلاثة مستويات، أو حيث يقطع خط مستوى. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
|
||||
"ui.construct_auto.chosen": "اختيرت الطريقة من اختياراتك. اختر مرجعًا آخر أو أزل مرجعًا لتغيير الطريقة.",
|
||||
"ui.construct_auto.no_fit": "اختر مراجع أخرى: لا توجد طريقة تبني من هذه المراجع.",
|
||||
"ui.construct_auto.pick": "اختر ما تبني منه: الطريقة تتبع اختياراتك.",
|
||||
"ui.construct_auto.refs": "المراجع",
|
||||
"ui.construct_auto.select": "اختر أولًا المراجع التي تبني منها"
|
||||
},
|
||||
"he": {
|
||||
"cmd.construct.axis": "ציר",
|
||||
"cmd.construct.plane": "מישור",
|
||||
"cmd.construct.point": "נקודה",
|
||||
"help.construct.axis": "יוצר ציר עזר בשיטה שמתאימה למה שבחרת: לאורך קצה או קו, דרך חור או פאה עגולה, במפגש של שני מישורים, דרך שתי נקודות, או ניצב לפאה דרך נקודה. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
|
||||
"help.construct.plane": "יוצר מישור עזר בשיטה שמתאימה למה שבחרת: בהיסט ממישור, דרך נקודה, בין שני מישורים, דרך קווים או נקודות, משיק לפאה מעוקלת, או ניצב למסלול. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
|
||||
"help.construct.point": "יוצר נקודת עזר בשיטה שמתאימה למה שבחרת: בקודקוד, במרכז של מעגל או כדור, לאורך קצה, במפגש של שני קווים או שלושה מישורים, או במקום שבו קו חוצה מישור. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
|
||||
"ui.construct_auto.chosen": "השיטה נבחרה לפי הבחירות שלך. בחר הפניה נוספת, או הסר אחת, כדי לשנות את השיטה.",
|
||||
"ui.construct_auto.no_fit": "בחר הפניות אחרות: אין שיטה שבונה מאלה.",
|
||||
"ui.construct_auto.pick": "בחר ממה לבנות: השיטה נקבעת לפי הבחירות שלך.",
|
||||
"ui.construct_auto.refs": "הפניות",
|
||||
"ui.construct_auto.select": "בחר קודם את ההפניות לבנייה"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.construct.axis": "Trục",
|
||||
"cmd.construct.plane": "Mặt phẳng",
|
||||
"cmd.construct.point": "Điểm",
|
||||
"help.construct.axis": "Tạo trục dựng theo phương pháp phù hợp với những gì bạn chọn: dọc theo cạnh hoặc đường, qua lỗ hoặc mặt tròn, nơi hai mặt phẳng giao nhau, qua hai điểm, hoặc vuông góc với mặt qua một điểm. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
|
||||
"help.construct.plane": "Tạo mặt phẳng dựng theo phương pháp phù hợp với những gì bạn chọn: cách một mặt phẳng, qua một điểm, giữa hai mặt phẳng, qua các đường hoặc điểm, tiếp xúc với mặt cong, hoặc vuông góc với đường dẫn. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
|
||||
"help.construct.point": "Tạo điểm dựng theo phương pháp phù hợp với những gì bạn chọn: tại đỉnh, tại tâm đường tròn hoặc mặt cầu, dọc theo cạnh, nơi hai đường hoặc ba mặt phẳng giao nhau, hoặc nơi một đường cắt mặt phẳng. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
|
||||
"ui.construct_auto.chosen": "Đã chọn phương pháp theo lựa chọn của bạn. Chọn thêm hoặc bỏ một tham chiếu để đổi phương pháp.",
|
||||
"ui.construct_auto.no_fit": "Hãy chọn tham chiếu khác: không phương pháp nào dựng được từ các tham chiếu này.",
|
||||
"ui.construct_auto.pick": "Chọn những gì để dựng: phương pháp theo lựa chọn của bạn.",
|
||||
"ui.construct_auto.refs": "Tham chiếu",
|
||||
"ui.construct_auto.select": "Hãy chọn tham chiếu để dựng trước"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"en": {
|
||||
"prefs.dialog_first_letter_buttons": "Dialog buttons answer their first letter",
|
||||
"prefs.dialog_first_letter_buttons_help": "With a dialog open, typing the first letter of a button's label presses it: O for OK, C for Cancel. A letter that fits two buttons does nothing, and a focused text field keeps the keystroke. Off: typing does nothing to the buttons, as before."
|
||||
},
|
||||
"ar": {
|
||||
"prefs.dialog_first_letter_buttons": "أزرار نوافذ الحوار تستجيب لحرفها الأول",
|
||||
"prefs.dialog_first_letter_buttons_help": "عند فتح نافذة حوار، كتابة الحرف الأول من تسمية الزر تضغطه: حرف «ا» يضغط زر «إلغاء» مثلًا. الحرف الذي يناسب زرَّين لا يفعل شيئًا، وحقل النص المركَّز عليه يحتفظ بالتضغطة. عند الإيقاف: الكتابة لا تفعل شيئًا للأزرار، كما كان سابقًا."
|
||||
},
|
||||
"de": {
|
||||
"prefs.dialog_first_letter_buttons": "Dialogschaltflächen reagieren auf ihren Anfangsbuchstaben",
|
||||
"prefs.dialog_first_letter_buttons_help": "Ist ein Dialog geöffnet, drückt der Anfangsbuchstabe einer Beschriftung die zugehörige Schaltfläche: O für OK, A für Abbrechen. Ein Buchstabe, der zu zwei Schaltflächen passt, tut nichts, und ein fokussiertes Textfeld behält die Tasteneingabe. Aus: Eingaben drücken keine Schaltfläche, wie bisher."
|
||||
},
|
||||
"he": {
|
||||
"prefs.dialog_first_letter_buttons": "כפתורי תיבות דו-שיח נענים לאות הראשונה שלהם",
|
||||
"prefs.dialog_first_letter_buttons_help": "כשתיבת דו-שיח פתוחה, הקלדת האות הראשונה של שם הכפתור לוחצת עליו: האות «ב» לוחצת על «ביטול», למשל. אות שמתאימה לשני כפתורים אינה עושה דבר, ושדה טקסט שבו נמצא הסמן שומר את ההקלדה. כבוי: הקלדה אינה משפיעה על הכפתורים, כמו קודם."
|
||||
},
|
||||
"ja": {
|
||||
"prefs.dialog_first_letter_buttons": "ダイアログのボタンはラベルの先頭文字で押せる",
|
||||
"prefs.dialog_first_letter_buttons_help": "ダイアログが開いているとき、ボタンのラベルの先頭文字を入力するとそのボタンが押されます:「キ」で「キャンセル」など。2 つのボタンに当てはまる文字は何もしません。フォーカス中のテキストフィールドはそのまま入力を受け取ります。オフ:従来どおり、入力ではボタンは押されません。"
|
||||
},
|
||||
"ko": {
|
||||
"prefs.dialog_first_letter_buttons": "다이얼로그 단추가 첫 글자에 반응",
|
||||
"prefs.dialog_first_letter_buttons_help": "다이얼로그가 열려 있을 때 단추 레이블의 첫 글자를 입력하면 그 단추를 누릅니다: 예를 들어 「ㅊ」은 「취소」를 누릅니다. 두 단추에 모두 해당하는 글자는 아무 동작을 하지 않으며, 포커스된 텍스트 필드는 입력을 그대로 받습니다. 끔: 기존처럼 입력이 단추에 영향을 주지 않습니다."
|
||||
},
|
||||
"vi": {
|
||||
"prefs.dialog_first_letter_buttons": "Nút trong hộp thoại trả lời chữ cái đầu của nhãn",
|
||||
"prefs.dialog_first_letter_buttons_help": "Khi một hộp thoại đang mở, gõ chữ cái đầu của nhãn một nút sẽ bấm nút đó: chữ H bấm nút Hủy bỏ, chẳng hạn. Một chữ khớp với hai nút không thực hiện gì, và trường văn bản đang tiêu điểm giữ lại phím gõ. Tắt: gõ phím không bấm nút, như trước đây."
|
||||
},
|
||||
"zh": {
|
||||
"prefs.dialog_first_letter_buttons": "对话框按钮响应其首字母",
|
||||
"prefs.dialog_first_letter_buttons_help": "打开对话框时,按下按钮标签的首字母即可触发该按钮:如“取”对应“取消”。一个字母若匹配两个按钮则不起作用,获得焦点的文本框保留按键输入。关闭时:按键不影响按钮,与以前相同。"
|
||||
}
|
||||
}
|
||||
@@ -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,38 @@
|
||||
{
|
||||
"ar": {
|
||||
"cmd.note_add": "إضافة ملاحظة",
|
||||
"cmd.note_delete": "حذف ملاحظة",
|
||||
"cmd.note_list": "عرض الملاحظات",
|
||||
"cmd.note_update": "تحرير الملاحظة"
|
||||
},
|
||||
"en": {
|
||||
"cmd.note_add": "Add Note",
|
||||
"cmd.note_delete": "Delete Note",
|
||||
"cmd.note_list": "List Notes",
|
||||
"cmd.note_update": "Edit Note"
|
||||
},
|
||||
"he": {
|
||||
"cmd.note_add": "הוספת הערה",
|
||||
"cmd.note_delete": "מחיקת הערה",
|
||||
"cmd.note_list": "הצגת הערות",
|
||||
"cmd.note_update": "עריכת הערה"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.note_add": "メモを追加",
|
||||
"cmd.note_delete": "メモを削除",
|
||||
"cmd.note_list": "メモを一覧表示",
|
||||
"cmd.note_update": "メモを編集"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.note_add": "Thêm ghi chú",
|
||||
"cmd.note_delete": "Xóa ghi chú",
|
||||
"cmd.note_list": "Liệt kê ghi chú",
|
||||
"cmd.note_update": "Chỉnh sửa ghi chú"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.note_add": "添加备注",
|
||||
"cmd.note_delete": "删除备注",
|
||||
"cmd.note_list": "列出备注",
|
||||
"cmd.note_update": "编辑备注"
|
||||
}
|
||||
}
|
||||
@@ -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})。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
{
|
||||
"en": {
|
||||
"Vertical": "Vertical",
|
||||
"sketch_text.valign_baseline": "Baseline",
|
||||
"sketch_text.valign_top": "Top",
|
||||
"sketch_text.valign_middle": "Middle",
|
||||
"sketch_text.valign_bottom": "Bottom"
|
||||
},
|
||||
"ar": {
|
||||
"Vertical": "عمودي",
|
||||
"sketch_text.valign_baseline": "خط الأساس",
|
||||
"sketch_text.valign_top": "أعلى",
|
||||
"sketch_text.valign_middle": "وسط",
|
||||
"sketch_text.valign_bottom": "أسفل"
|
||||
},
|
||||
"bg": {
|
||||
"Vertical": "Вертикално",
|
||||
"sketch_text.valign_baseline": "Базова линия",
|
||||
"sketch_text.valign_top": "Горе",
|
||||
"sketch_text.valign_middle": "Среда",
|
||||
"sketch_text.valign_bottom": "Долу"
|
||||
},
|
||||
"bs": {
|
||||
"Vertical": "Vertikalno",
|
||||
"sketch_text.valign_baseline": "Osnovna linija",
|
||||
"sketch_text.valign_top": "Vrh",
|
||||
"sketch_text.valign_middle": "Sredina",
|
||||
"sketch_text.valign_bottom": "Dno"
|
||||
},
|
||||
"ca": {
|
||||
"Vertical": "Vertical",
|
||||
"sketch_text.valign_baseline": "Línia base",
|
||||
"sketch_text.valign_top": "Dalt",
|
||||
"sketch_text.valign_middle": "Mig",
|
||||
"sketch_text.valign_bottom": "Baix"
|
||||
},
|
||||
"cs": {
|
||||
"Vertical": "Svisle",
|
||||
"sketch_text.valign_baseline": "Účaří",
|
||||
"sketch_text.valign_top": "Nahoře",
|
||||
"sketch_text.valign_middle": "Uprostřed",
|
||||
"sketch_text.valign_bottom": "Dole"
|
||||
},
|
||||
"da": {
|
||||
"Vertical": "Lodret",
|
||||
"sketch_text.valign_baseline": "Grundlinje",
|
||||
"sketch_text.valign_top": "Top",
|
||||
"sketch_text.valign_middle": "Midte",
|
||||
"sketch_text.valign_bottom": "Bund"
|
||||
},
|
||||
"de": {
|
||||
"Vertical": "Vertikal",
|
||||
"sketch_text.valign_baseline": "Grundlinie",
|
||||
"sketch_text.valign_top": "Oben",
|
||||
"sketch_text.valign_middle": "Mitte",
|
||||
"sketch_text.valign_bottom": "Unten"
|
||||
},
|
||||
"es": {
|
||||
"Vertical": "Vertical",
|
||||
"sketch_text.valign_baseline": "Línea base",
|
||||
"sketch_text.valign_top": "Arriba",
|
||||
"sketch_text.valign_middle": "Centro",
|
||||
"sketch_text.valign_bottom": "Abajo"
|
||||
},
|
||||
"fi": {
|
||||
"Vertical": "Pystysuunta",
|
||||
"sketch_text.valign_baseline": "Perusviiva",
|
||||
"sketch_text.valign_top": "Ylä",
|
||||
"sketch_text.valign_middle": "Keski",
|
||||
"sketch_text.valign_bottom": "Ala"
|
||||
},
|
||||
"fr": {
|
||||
"Vertical": "Vertical",
|
||||
"sketch_text.valign_baseline": "Ligne de base",
|
||||
"sketch_text.valign_top": "Haut",
|
||||
"sketch_text.valign_middle": "Milieu",
|
||||
"sketch_text.valign_bottom": "Bas"
|
||||
},
|
||||
"gl": {
|
||||
"Vertical": "Vertical",
|
||||
"sketch_text.valign_baseline": "Liña base",
|
||||
"sketch_text.valign_top": "Arriba",
|
||||
"sketch_text.valign_middle": "Medio",
|
||||
"sketch_text.valign_bottom": "Abaixo"
|
||||
},
|
||||
"he": {
|
||||
"Vertical": "אנכי",
|
||||
"sketch_text.valign_baseline": "קו בסיס",
|
||||
"sketch_text.valign_top": "למעלה",
|
||||
"sketch_text.valign_middle": "אמצע",
|
||||
"sketch_text.valign_bottom": "למטה"
|
||||
},
|
||||
"hr": {
|
||||
"Vertical": "Okomito",
|
||||
"sketch_text.valign_baseline": "Osnovna linija",
|
||||
"sketch_text.valign_top": "Vrh",
|
||||
"sketch_text.valign_middle": "Sredina",
|
||||
"sketch_text.valign_bottom": "Dno"
|
||||
},
|
||||
"hu": {
|
||||
"Vertical": "Függőleges",
|
||||
"sketch_text.valign_baseline": "Alapvonal",
|
||||
"sketch_text.valign_top": "Fent",
|
||||
"sketch_text.valign_middle": "Középen",
|
||||
"sketch_text.valign_bottom": "Lent"
|
||||
},
|
||||
"id": {
|
||||
"Vertical": "Vertikal",
|
||||
"sketch_text.valign_baseline": "Garis dasar",
|
||||
"sketch_text.valign_top": "Atas",
|
||||
"sketch_text.valign_middle": "Tengah",
|
||||
"sketch_text.valign_bottom": "Bawah"
|
||||
},
|
||||
"it": {
|
||||
"Vertical": "Verticale",
|
||||
"sketch_text.valign_baseline": "Linea di base",
|
||||
"sketch_text.valign_top": "Alto",
|
||||
"sketch_text.valign_middle": "Centro",
|
||||
"sketch_text.valign_bottom": "Basso"
|
||||
},
|
||||
"ja": {
|
||||
"Vertical": "垂直",
|
||||
"sketch_text.valign_baseline": "ベースライン",
|
||||
"sketch_text.valign_top": "上",
|
||||
"sketch_text.valign_middle": "中央",
|
||||
"sketch_text.valign_bottom": "下"
|
||||
},
|
||||
"ka": {
|
||||
"Vertical": "ვერტიკალური",
|
||||
"sketch_text.valign_baseline": "საბაზისო ხაზი",
|
||||
"sketch_text.valign_top": "ზემოთ",
|
||||
"sketch_text.valign_middle": "შუა",
|
||||
"sketch_text.valign_bottom": "ქვემოთ"
|
||||
},
|
||||
"ko": {
|
||||
"Vertical": "수직",
|
||||
"sketch_text.valign_baseline": "기준선",
|
||||
"sketch_text.valign_top": "위",
|
||||
"sketch_text.valign_middle": "가운데",
|
||||
"sketch_text.valign_bottom": "아래"
|
||||
},
|
||||
"ms": {
|
||||
"Vertical": "Menegak",
|
||||
"sketch_text.valign_baseline": "Garis dasar",
|
||||
"sketch_text.valign_top": "Atas",
|
||||
"sketch_text.valign_middle": "Tengah",
|
||||
"sketch_text.valign_bottom": "Bawah"
|
||||
},
|
||||
"nb": {
|
||||
"Vertical": "Loddrett",
|
||||
"sketch_text.valign_baseline": "Grunnlinje",
|
||||
"sketch_text.valign_top": "Topp",
|
||||
"sketch_text.valign_middle": "Midt",
|
||||
"sketch_text.valign_bottom": "Bunn"
|
||||
},
|
||||
"nl": {
|
||||
"Vertical": "Verticaal",
|
||||
"sketch_text.valign_baseline": "Basislijn",
|
||||
"sketch_text.valign_top": "Boven",
|
||||
"sketch_text.valign_middle": "Midden",
|
||||
"sketch_text.valign_bottom": "Onder"
|
||||
},
|
||||
"pl": {
|
||||
"Vertical": "Pionowo",
|
||||
"sketch_text.valign_baseline": "Linia bazowa",
|
||||
"sketch_text.valign_top": "Góra",
|
||||
"sketch_text.valign_middle": "Środek",
|
||||
"sketch_text.valign_bottom": "Dół"
|
||||
},
|
||||
"pt-BR": {
|
||||
"Vertical": "Vertical",
|
||||
"sketch_text.valign_baseline": "Linha de base",
|
||||
"sketch_text.valign_top": "Topo",
|
||||
"sketch_text.valign_middle": "Meio",
|
||||
"sketch_text.valign_bottom": "Base"
|
||||
},
|
||||
"ro": {
|
||||
"Vertical": "Vertical",
|
||||
"sketch_text.valign_baseline": "Linie de bază",
|
||||
"sketch_text.valign_top": "Sus",
|
||||
"sketch_text.valign_middle": "Mijloc",
|
||||
"sketch_text.valign_bottom": "Jos"
|
||||
},
|
||||
"ru": {
|
||||
"Vertical": "По вертикали",
|
||||
"sketch_text.valign_baseline": "Базовая линия",
|
||||
"sketch_text.valign_top": "Верх",
|
||||
"sketch_text.valign_middle": "Середина",
|
||||
"sketch_text.valign_bottom": "Низ"
|
||||
},
|
||||
"sr": {
|
||||
"Vertical": "Вертикално",
|
||||
"sketch_text.valign_baseline": "Основна линија",
|
||||
"sketch_text.valign_top": "Врх",
|
||||
"sketch_text.valign_middle": "Средина",
|
||||
"sketch_text.valign_bottom": "Дно"
|
||||
},
|
||||
"sv": {
|
||||
"Vertical": "Lodrät",
|
||||
"sketch_text.valign_baseline": "Baslinje",
|
||||
"sketch_text.valign_top": "Överkant",
|
||||
"sketch_text.valign_middle": "Mitten",
|
||||
"sketch_text.valign_bottom": "Nederkant"
|
||||
},
|
||||
"tr": {
|
||||
"Vertical": "Dikey",
|
||||
"sketch_text.valign_baseline": "Taban çizgisi",
|
||||
"sketch_text.valign_top": "Üst",
|
||||
"sketch_text.valign_middle": "Orta",
|
||||
"sketch_text.valign_bottom": "Alt"
|
||||
},
|
||||
"uk": {
|
||||
"Vertical": "По вертикалі",
|
||||
"sketch_text.valign_baseline": "Базова лінія",
|
||||
"sketch_text.valign_top": "Верх",
|
||||
"sketch_text.valign_middle": "Середина",
|
||||
"sketch_text.valign_bottom": "Низ"
|
||||
},
|
||||
"vi": {
|
||||
"Vertical": "Dọc",
|
||||
"sketch_text.valign_baseline": "Đường cơ sở",
|
||||
"sketch_text.valign_top": "Trên",
|
||||
"sketch_text.valign_middle": "Giữa",
|
||||
"sketch_text.valign_bottom": "Dưới"
|
||||
},
|
||||
"zh-TW": {
|
||||
"Vertical": "垂直",
|
||||
"sketch_text.valign_baseline": "基線",
|
||||
"sketch_text.valign_top": "頂端",
|
||||
"sketch_text.valign_middle": "置中",
|
||||
"sketch_text.valign_bottom": "底端"
|
||||
},
|
||||
"zh": {
|
||||
"Vertical": "垂直",
|
||||
"sketch_text.valign_baseline": "基线",
|
||||
"sketch_text.valign_top": "顶端",
|
||||
"sketch_text.valign_middle": "居中",
|
||||
"sketch_text.valign_bottom": "底端"
|
||||
}
|
||||
}
|
||||
+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": "约束冲突",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| crates/look/icons/design/svg/model/plane_auto.svg | SolidCraft contributors | original construction icon master (64 grid) | SolidCraft Brand and Interface Licence (LICENSE-BRAND) |
|
||||
@@ -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() });
|
||||
|
||||
@@ -1019,6 +1019,12 @@ pub enum FeatureKind {
|
||||
/// part changes. `position` keeps where it was placed.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
references: Vec<HoleReference>,
|
||||
/// Holes at sketch points as older builds drilled them: a point inside a body moves back
|
||||
/// to the body's surface whatever plane the sketch lies on. Designs saved before #11538
|
||||
/// carry it, so their holes rebuild where they were; new holes leave it off, and Edit
|
||||
/// Hole keeps it.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
old_surface_start: bool,
|
||||
},
|
||||
/// A thread on a cylindrical face. Cosmetic by default (no geometry change; drawn and
|
||||
/// exported as an annotation); `modeled` cuts the real helical ISO profile into the face.
|
||||
@@ -1087,13 +1093,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 +1827,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,
|
||||
@@ -3399,6 +3417,9 @@ pub struct Document {
|
||||
/// Named views: saved cameras.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub named_views: Vec<NamedView>,
|
||||
/// Notes: text anchored to points in model space, drawn as screen-facing callouts (#1631).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub notes: Vec<crate::notes::Note>,
|
||||
/// What was hidden when the design was saved. A session takes it from here when the design
|
||||
/// opens and keeps the live state itself (`browser.visibility`: saved, and undone like any
|
||||
/// other change), so a document in a session leaves this empty; the session puts it back when
|
||||
@@ -3641,10 +3662,12 @@ impl Document {
|
||||
.chain(self.timeline_groups.iter().map(|g| g.id))
|
||||
.chain(self.canvases.iter().map(|c| c.id))
|
||||
.chain(self.point_clouds.iter().map(|c| c.id))
|
||||
.chain(self.notes.iter().map(|n| n.id))
|
||||
.chain(a.joints.iter().map(|j| j.id))
|
||||
.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()))?;
|
||||
@@ -3696,6 +3719,7 @@ impl Document {
|
||||
timeline_groups: Vec::new(),
|
||||
browser_order: Default::default(),
|
||||
named_views: Vec::new(),
|
||||
notes: Vec::new(),
|
||||
visibility: Visibility::default(),
|
||||
}
|
||||
}
|
||||
@@ -3899,10 +3923,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));
|
||||
}
|
||||
|
||||
+1128
-151
File diff suppressed because it is too large
Load Diff
@@ -142,6 +142,7 @@ fn joint_face_measurements_are_shared_across_motion_samples_and_snap_kinds() {
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
constraint: None,
|
||||
});
|
||||
// One body supplies both old nearest-face snaps and persistent named-face snaps.
|
||||
// A scratch model shares its measurements, even though each sample changes the pose.
|
||||
@@ -1455,6 +1456,7 @@ fn a_tap_marking_searches_only_the_bodies_near_it() {
|
||||
ease_out: None,
|
||||
to: None,
|
||||
references: Vec::new(),
|
||||
old_surface_start: false,
|
||||
};
|
||||
let (work, threads) = marking_work(&mut doc, hole);
|
||||
assert_eq!(work.cylinder_search, 1, "only the plate is searched");
|
||||
@@ -1572,6 +1574,7 @@ fn a_tapped_hole_reads_the_bodies_along_its_drill_line() {
|
||||
ease_out: None,
|
||||
to: None,
|
||||
references: Vec::new(),
|
||||
old_surface_start: false,
|
||||
};
|
||||
let (work, threads) = marking_work(&mut doc, hole);
|
||||
assert_eq!(work.tapped_wall, 3, "Plate, Below and Grazing are read");
|
||||
|
||||
@@ -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,47 @@
|
||||
//! A hole at the points of a sketch on an offset, construction or other derived plane now starts
|
||||
//! at that plane when the plane lies inside a body, instead of at the body's surface (#11538).
|
||||
//! Older builds moved every such point back to the surface, so this step marks every saved hole
|
||||
//! at sketch points (not a Single hole) `old_surface_start`, which keeps that reading: designs
|
||||
//! saved before rebuild their holes where they were. Holes at a position, Single holes and holes
|
||||
//! made or opened from now on are unchanged. The last release asks for an update instead of
|
||||
//! opening a design that carries this change.
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn step(v: &mut Value) -> crate::Result<()> {
|
||||
let Some(Value::Array(features)) = v.get_mut("features") else {
|
||||
return Ok(());
|
||||
};
|
||||
for feature in features {
|
||||
if feature.get("type").and_then(Value::as_str) != Some("hole") {
|
||||
continue;
|
||||
}
|
||||
let at_points =
|
||||
feature.get("points").and_then(Value::as_object).is_some_and(|points| !points.get("single").and_then(Value::as_bool).unwrap_or(false));
|
||||
if let (true, Some(object)) = (at_points, feature.as_object_mut()) {
|
||||
object.insert("old_surface_start".into(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn saved_holes_at_sketch_points_keep_starting_at_the_surface() {
|
||||
let mut v = json!({"features": [
|
||||
{"type": "hole", "points": {"sketch": 3, "ids": ["p1"]}},
|
||||
{"type": "hole", "points": {"sketch": 3, "ids": ["p1", "p2"], "flip": true}},
|
||||
{"type": "hole", "points": {"sketch": 3, "ids": ["p1"], "single": true}},
|
||||
{"type": "hole", "position": [0.0, 0.0, 10.0]},
|
||||
{"type": "extrude", "points": {"sketch": 3, "ids": ["p1"]}}
|
||||
]});
|
||||
super::step(&mut v).unwrap();
|
||||
let marked: Vec<bool> = v["features"].as_array().unwrap().iter().map(|f| f["old_surface_start"] == json!(true)).collect();
|
||||
assert_eq!(marked, [true, true, false, false, false]);
|
||||
let mut empty = json!({"name": "Untitled"});
|
||||
super::step(&mut empty).unwrap();
|
||||
assert_eq!(empty, json!({"name": "Untitled"}));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,9 @@
|
||||
//! A joint can say which assembly constraint it was made as (`"constraint": "mate"`, `flush`,
|
||||
//! `distance`, `concentric`, `insert`, `parallel`, `perpendicular`, `angle`, `tangent` or
|
||||
//! `point_on_axis`), written only for a joint made with `assembly.constraint.create`. The joint
|
||||
//! itself is an ordinary joint of the kind the rule needs (a mate is a planar joint), so the solver
|
||||
//! never reads the field; it names the rule in the browser and the Constrain dialog, and makes the
|
||||
//! joint start where its parts are when it is changed. Older designs need no transformation: their
|
||||
//! joints have no constraint and are written as before. The last release opens a design with such
|
||||
//! a joint with a warning and keeps the field it can't read; there the joint still solves as its
|
||||
//! kind.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A design saves its notes (`notes`): text anchored to a point in model space, drawn in the
|
||||
//! viewport as a small screen-facing callout. An older design has none, and opens exactly as
|
||||
//! before. The last release refuses a design that has notes and asks for an update, instead of
|
||||
//! opening it and losing them on the next save.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A sketch text's style can say where its block of lines sits up and down against its anchor
|
||||
//! (`at`, or the curve it follows): `vertical_align` is `top`, `middle` or `bottom`, and is left
|
||||
//! out for the first line's baseline, where every text sat before. Older designs need no rewrite:
|
||||
//! without the key a text stays on its baseline. The last release opens a design that carries the
|
||||
//! key with a warning, keeps what it can't read, and draws such a text on its baseline.
|
||||
@@ -429,6 +429,75 @@ impl JointKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The assembly constraint a joint was made as (`assembly.constraint.create`): the rule in the
|
||||
/// words of the Constrain dialog. The joint itself is an ordinary joint of the kind the rule needs
|
||||
/// (a mate is planar, a concentric constraint cylindrical); the solver never reads this.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Constraint {
|
||||
/// Two planes against each other (planar), `offset` apart.
|
||||
Mate,
|
||||
/// Two planes facing the same way (planar, flipped), `offset` apart.
|
||||
Flush,
|
||||
/// A plane `offset` from a plane (planar) or a point `offset` from a plane (point on plane).
|
||||
Distance,
|
||||
/// Two axes on one line (cylindrical): B slides and turns along it.
|
||||
Concentric,
|
||||
/// Two round edges at one centre on one axis (revolute): B only turns.
|
||||
Insert,
|
||||
/// Two planes or axes parallel.
|
||||
Parallel,
|
||||
/// Two planes or axes square to each other.
|
||||
Perpendicular,
|
||||
/// Two planes or axes at `angle`.
|
||||
Angle,
|
||||
/// A cylinder resting on a plane (line on plane, `offset` its radius).
|
||||
Tangent,
|
||||
/// A point on an axis (point on line).
|
||||
PointOnAxis,
|
||||
}
|
||||
|
||||
impl Constraint {
|
||||
pub const ALL: [Constraint; 10] = [
|
||||
Constraint::Mate,
|
||||
Constraint::Flush,
|
||||
Constraint::Distance,
|
||||
Constraint::Concentric,
|
||||
Constraint::Insert,
|
||||
Constraint::Parallel,
|
||||
Constraint::Perpendicular,
|
||||
Constraint::Angle,
|
||||
Constraint::Tangent,
|
||||
Constraint::PointOnAxis,
|
||||
];
|
||||
|
||||
/// The rule's command name (`mate`, `point_on_axis`).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Constraint::Mate => "mate",
|
||||
Constraint::Flush => "flush",
|
||||
Constraint::Distance => "distance",
|
||||
Constraint::Concentric => "concentric",
|
||||
Constraint::Insert => "insert",
|
||||
Constraint::Parallel => "parallel",
|
||||
Constraint::Perpendicular => "perpendicular",
|
||||
Constraint::Angle => "angle",
|
||||
Constraint::Tangent => "tangent",
|
||||
Constraint::PointOnAxis => "point_on_axis",
|
||||
}
|
||||
}
|
||||
|
||||
/// Case, spaces, `_` and `-` are ignored; `coincident` and `axis` are also taken.
|
||||
pub fn parse(s: &str) -> Option<Constraint> {
|
||||
let key = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
|
||||
match key.as_str() {
|
||||
"coincident" => Some(Constraint::Mate),
|
||||
"axis" => Some(Constraint::Concentric),
|
||||
_ => Constraint::ALL.into_iter().find(|c| c.name().replace('_', "") == key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A joint between two occurrences.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Joint {
|
||||
@@ -462,6 +531,17 @@ pub struct Joint {
|
||||
/// files from before captured joint values: such a joint keeps its values on Revert Position.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub captured: Option<Vec<f64>>,
|
||||
/// The assembly constraint it was made as; `None` for a joint made as a joint.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub constraint: Option<Constraint>,
|
||||
}
|
||||
|
||||
impl Joint {
|
||||
/// Whether its values start where its parts are when it is made or changed, so B moves no
|
||||
/// further than the joint needs: a relation, or any joint made as an assembly constraint.
|
||||
pub fn starts_where_it_is(&self) -> bool {
|
||||
self.kind.is_relation() || self.constraint.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Expressions a joint's numbers follow (over the design's parameters): the offset (a length),
|
||||
@@ -2527,6 +2607,11 @@ impl Document {
|
||||
let t = if o.occurrence == 0 { IDENTITY } else { occurrence_world(self, o.occurrence) };
|
||||
Ok(mat_mul(&t, &f))
|
||||
}
|
||||
|
||||
/// The radius of the round face a circle origin is on (mm; an error when it is on none).
|
||||
pub fn origin_radius(&self, st: &ModelState, o: &JointOrigin) -> Result<f64> {
|
||||
circle_at(self, st, o).map(|c| c.radius)
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "joints_cam.rs"]
|
||||
@@ -2756,6 +2841,7 @@ mod tests {
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
constraint: None,
|
||||
})
|
||||
.collect();
|
||||
let links =
|
||||
|
||||
@@ -945,6 +945,7 @@ mod tests {
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
constraint: None,
|
||||
};
|
||||
let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded };
|
||||
// 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod.
|
||||
|
||||
@@ -94,6 +94,7 @@ mod tests {
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
constraint: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ mod lineage;
|
||||
pub mod metadata;
|
||||
pub use metadata::Metadata;
|
||||
pub mod motion;
|
||||
pub mod notes;
|
||||
pub use notes::Note;
|
||||
mod name_lookup;
|
||||
mod names;
|
||||
pub mod naming;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Notes: text anchored to a point in model space, drawn in the viewport as a small
|
||||
//! screen-facing callout (#1631). Personal design documentation — reminders, decisions,
|
||||
//! warnings — saved with the design, not collaboration.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcore_record::Vec3;
|
||||
|
||||
/// Longest text a note may hold (characters).
|
||||
pub const MAX_NOTE_CHARS: usize = 4000;
|
||||
|
||||
/// A note: an anchor point in model space (mm, design coordinates) and the text shown at
|
||||
/// its projection. The callout always faces the screen because it is drawn in screen space.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Note {
|
||||
pub id: u64,
|
||||
/// The point the note is anchored to (design coordinates, mm).
|
||||
pub anchor: Vec3,
|
||||
/// The note's text (may span lines).
|
||||
pub text: String,
|
||||
/// Hidden notes are kept but not drawn (`browser.visibility` shows and hides them).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
/// True when the text is usable: not empty and within [`MAX_NOTE_CHARS`].
|
||||
pub fn text_is_valid(text: &str) -> bool {
|
||||
!text.trim().is_empty() && text.chars().count() <= MAX_NOTE_CHARS
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn note(id: u64) -> Note {
|
||||
Note { id, anchor: Vec3::new(1.0, 2.0, 3.0), text: format!("Check clearance here.\nLine {id}"), hidden: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_design_with_notes_survives_a_round_trip() {
|
||||
let mut doc = crate::Document::new("Noted");
|
||||
doc.notes.push(note(1));
|
||||
doc.notes.push(Note { id: 2, anchor: Vec3::new(-4.0, 0.0, 6.5), text: "Adjust after the test print".into(), hidden: true });
|
||||
let json = serde_json::to_string(&doc).unwrap();
|
||||
let back: crate::Document = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, doc);
|
||||
assert_eq!(back.notes.len(), 2);
|
||||
assert!(back.notes[1].hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_design_saved_before_notes_loads_with_none() {
|
||||
// An older design file: no `notes` key at all.
|
||||
let json = serde_json::to_string(&crate::Document::new("Old")).unwrap();
|
||||
let back: crate::Document = serde_json::from_str(&json).unwrap();
|
||||
assert!(back.notes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_notes_are_not_written_when_they_are_visible() {
|
||||
let mut doc = crate::Document::new("Noted");
|
||||
doc.notes.push(note(1));
|
||||
let json = serde_json::to_string(&doc).unwrap();
|
||||
assert!(!json.contains("hidden"));
|
||||
assert!(serde_json::from_str::<crate::Document>(&json).unwrap().notes[0].text.starts_with("Check"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_text_must_be_non_empty_and_bounded() {
|
||||
assert!(Note::text_is_valid("Extra thickness added for strength."));
|
||||
assert!(!Note::text_is_valid(" "));
|
||||
assert!(!Note::text_is_valid(&"x".repeat(MAX_NOTE_CHARS + 1)));
|
||||
assert!(Note::text_is_valid(&"x".repeat(MAX_NOTE_CHARS)));
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,7 @@ fn same_geometry_inputs(a: &Document, b: &Document) -> bool {
|
||||
named_views,
|
||||
visibility,
|
||||
point_clouds,
|
||||
notes: _,
|
||||
} = a;
|
||||
(Arc::ptr_eq(render, &b.render) || *render == b.render)
|
||||
&& *format == b.format
|
||||
|
||||
+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();
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! 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::{Map, Value, json};
|
||||
use solidcraft_doc::joints::{Constraint, Joint, JointKind, JointOrigin, Snap};
|
||||
|
||||
use super::{CommandSpec, add_joint, joint_id, new_joint, origin_param, value};
|
||||
use crate::params::{bad, bool_, str_};
|
||||
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();
|
||||
|
||||
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>)
|
||||
.params(
|
||||
"type: mate|flush|distance|concentric|insert|parallel|perpendicular|angle|tangent|point_on_axis; a, b: references, as joint.create's \
|
||||
origins (a face or a datum plane for a plane, a circle or an axis for an axis, a point; b moves); offset?: the gap (mm or an expression; needed for distance); \
|
||||
angle?: deg or an expression (needed for angle); flip?: turn b over; name? (default: the rule and a number, Mate1); quiet?. \
|
||||
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)]
|
||||
enum Shape {
|
||||
/// A planar face or a frame: its plane and normal.
|
||||
Plane,
|
||||
/// A round face's or edge's centre and axis.
|
||||
Round,
|
||||
/// A point (a vertex, an edge, a sketch point): its position.
|
||||
Point,
|
||||
/// A datum or construction axis: a line, with no rim or radius.
|
||||
Axis,
|
||||
}
|
||||
|
||||
fn shape(o: &JointOrigin) -> Shape {
|
||||
match o.snap {
|
||||
Snap::FaceCenter { .. } | Snap::Frame { .. } | Snap::Plane { .. } => Shape::Plane,
|
||||
Snap::Axis { .. } => Shape::Axis,
|
||||
Snap::CircleCenter { .. } => Shape::Round,
|
||||
Snap::Point { .. } | Snap::Edge { .. } => Shape::Point,
|
||||
}
|
||||
}
|
||||
|
||||
/// How a rule is kept: the joint's kind, whether b is turned to face along a (`flip`), and whether
|
||||
/// the two references were given the other way round (the plane or axis goes on side a).
|
||||
struct Kept {
|
||||
kind: JointKind,
|
||||
/// `Some` where the rule fixes the facing; `None` keeps the parts' facing now.
|
||||
flip: Option<bool>,
|
||||
swap: bool,
|
||||
}
|
||||
|
||||
fn kept(c: Constraint, a: Shape, b: Shape, cmd: &str) -> Result<Kept> {
|
||||
use Shape::*;
|
||||
let fixed = |kind, flip| Ok(Kept { kind, flip: Some(flip), swap: false });
|
||||
let facing = |kind| Ok(Kept { kind, flip: None, swap: false });
|
||||
// A line: a round face's or edge's axis, or an axis itself.
|
||||
let axial = |s: Shape| matches!(s, Round | Axis);
|
||||
// A rule between one plane or axis and something else: the plane or axis on side a.
|
||||
let onto = |kind, want: &dyn Fn(Shape) -> bool, other_ok: &dyn Fn(Shape) -> bool| {
|
||||
if want(a) && other_ok(b) {
|
||||
Ok(Kept { kind, flip: Some(false), swap: false })
|
||||
} else if want(b) && other_ok(a) {
|
||||
Ok(Kept { kind, flip: Some(false), swap: true })
|
||||
} else {
|
||||
Err(bad(cmd, format!("{} needs {}", c.name(), needs(c))))
|
||||
}
|
||||
};
|
||||
match (c, a, b) {
|
||||
(Constraint::Mate, Plane, Plane) => fixed(JointKind::Planar, false),
|
||||
(Constraint::Flush, Plane, Plane) => fixed(JointKind::Planar, true),
|
||||
(Constraint::Distance, Plane, Plane) => facing(JointKind::Planar),
|
||||
(Constraint::Distance, ..) => onto(JointKind::PointOnPlane, &|s| s == Plane, &|s| matches!(s, Round | Point)),
|
||||
(Constraint::Concentric, ..) if axial(a) && axial(b) => facing(JointKind::Cylindrical),
|
||||
(Constraint::Insert, Round, Round) => facing(JointKind::Revolute),
|
||||
(Constraint::Parallel, ..) => facing(JointKind::Parallel),
|
||||
(Constraint::Perpendicular, ..) => fixed(JointKind::Perpendicular, false),
|
||||
(Constraint::Angle, ..) => fixed(JointKind::Angle, false),
|
||||
(Constraint::Tangent, ..) => onto(JointKind::LineOnPlane, &|s| s == Plane, &|s| s == Round),
|
||||
(Constraint::PointOnAxis, ..) => onto(JointKind::PointOnLine, &axial, &|s| s == Point),
|
||||
_ => Err(bad(cmd, format!("{} needs {}", c.name(), needs(c)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The references a rule takes, for its refusal.
|
||||
fn needs(c: Constraint) -> &'static str {
|
||||
match c {
|
||||
Constraint::Mate | Constraint::Flush => "two planar faces",
|
||||
Constraint::Distance => "two planar faces, or a planar face and a point",
|
||||
Constraint::Concentric => "two round faces, round edges or axes",
|
||||
Constraint::Insert => "two round edges",
|
||||
Constraint::Parallel | Constraint::Perpendicular | Constraint::Angle => "two faces or axes",
|
||||
Constraint::Tangent => "a planar face and a round face",
|
||||
Constraint::PointOnAxis => "a round face or edge (its axis) or an axis, and a point",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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('_', " ");
|
||||
let mut label = word.clone();
|
||||
if let Some(first) = label.get(..1) {
|
||||
label = first.to_ascii_uppercase() + label.get(1..).unwrap_or("");
|
||||
}
|
||||
let names: std::collections::BTreeSet<&str> = s.doc.assembly.joints.iter().map(|j| j.name.as_str()).collect();
|
||||
let n = (1..=s.doc.assembly.joints.len().saturating_add(1)).find(|n| !names.contains(format!("{label}{n}").as_str())).unwrap_or(1);
|
||||
format!("{label}{n}")
|
||||
}
|
||||
|
||||
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_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.
|
||||
for (key, angle) in [("offset", false), ("angle", true)] {
|
||||
if let Some(v) = p.get(key) {
|
||||
value(s, v, angle, cmd)?;
|
||||
}
|
||||
}
|
||||
let rule = kept(c, shape(&a), shape(&b), cmd)?;
|
||||
let (a, b) = if rule.swap { (b, a) } else { (a, b) };
|
||||
if c == Constraint::Distance && p.get("offset").is_none() {
|
||||
return Err(bad(cmd, "distance needs `offset`"));
|
||||
}
|
||||
if c == Constraint::Angle && p.get("angle").is_none() {
|
||||
return Err(bad(cmd, "angle needs `angle`"));
|
||||
}
|
||||
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() {
|
||||
m.remove("type");
|
||||
m.remove("flip");
|
||||
m.remove("values");
|
||||
m.remove("value");
|
||||
m.remove("limits");
|
||||
m.insert("flip".into(), Value::Bool(flip));
|
||||
// No name, `null` or blank text: the rule's own name (`Mate1`), not a joint's.
|
||||
if !m.get("name").and_then(Value::as_str).is_some_and(|n| !n.trim().is_empty()) {
|
||||
m.insert("name".into(), Value::String(default_name(s, c)));
|
||||
}
|
||||
}
|
||||
let mut j = new_joint(s, &q, rule.kind, a, b, cmd)?;
|
||||
if c == Constraint::Tangent {
|
||||
// The gap is kept as the offset's expression; the radius is added to it whenever the joints
|
||||
// are solved (`reexpress`, which `add_joint`'s solve runs), so the part follows its face.
|
||||
j.exprs.offset = Some(j.exprs.offset.take().unwrap_or_else(|| format!("{} mm", j.offset)));
|
||||
}
|
||||
if j.offset != 0.0 && !j.offset.is_finite() {
|
||||
return Err(bad(cmd, "`offset` must be finite"));
|
||||
}
|
||||
j.constraint = Some(c);
|
||||
add_joint(s, j, &q)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -24,6 +24,7 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
constraint: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! SOLID ▸ CONSTRUCT: Plane, Axis and Point. They have no methods of their own. Given
|
||||
//! references, they try their family's CONSTRUCT commands (`construct.plane.*`,
|
||||
//! `construct.axis.*`, `construct.point.*`, in the order the commands are listed) with the
|
||||
//! references in each command's reference parameters, and build with the first that takes them.
|
||||
//! A method added to a family is tried as well, with nothing to change here.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::schema::construct::{ConstructAutoParams, ConstructAutoResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CONTRACT: TypedContract<ConstructAutoParams> = TypedContract::new();
|
||||
|
||||
const PARAMS: &str = "refs: 1-3 refs in any order; with?: {the method's other parameters: offset, angle, t, name…}. Builds with the first of the family's CONSTRUCT commands that takes the refs in its reference parameters (and every key of `with`). Returns method: the command used";
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("construct.plane", "Plane", crate::checked!("construct.plane", plane))
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane_auto")
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<ConstructAutoResult>)
|
||||
.params(PARAMS),
|
||||
CommandSpec::new("construct.axis", "Axis", crate::checked!("construct.axis", axis))
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("axis")
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<ConstructAutoResult>)
|
||||
.params(PARAMS),
|
||||
CommandSpec::new("construct.point", "Point", crate::checked!("construct.point", point))
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("point")
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<ConstructAutoResult>)
|
||||
.params(PARAMS),
|
||||
];
|
||||
|
||||
/// Whether `id` is one of the tools that choose their method (`construct.plane`…).
|
||||
pub fn is_construct_tool(id: &str) -> bool {
|
||||
COMMANDS.iter().any(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// The methods of `tool` (`construct.plane` → `construct.plane.offset`, `construct.plane.midplane`
|
||||
/// …), in the order the commands are listed: the order they are tried in. Planned commands are
|
||||
/// left out.
|
||||
pub fn construct_methods(tool: &str) -> Vec<&'static CommandSpec> {
|
||||
let prefix = format!("{tool}.");
|
||||
super::command_specs().into_iter().filter(|c| c.id.starts_with(&prefix) && c.planned.is_none() && c.contract.is_some()).collect()
|
||||
}
|
||||
|
||||
/// How a reference parameter reads a reference (its schema type).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Form {
|
||||
/// Any `GeoRef` form, as given.
|
||||
Geo,
|
||||
/// `PlaneOrFace`: a plane as such (`{plane: p}` given as `p`) or `{face}`.
|
||||
Plane,
|
||||
/// `AxisLine`: an axis as such (`{axis: a}` given as `a`), `{edge}` or `{face}`.
|
||||
Axis,
|
||||
}
|
||||
|
||||
/// A parameter of a method that takes a reference: its key, how it reads one, and whether the
|
||||
/// method needs it.
|
||||
struct Slot {
|
||||
key: String,
|
||||
form: Form,
|
||||
required: bool,
|
||||
}
|
||||
|
||||
/// The schema type a property refers to, directly or as one of its `anyOf`/`oneOf` (an
|
||||
/// optional parameter's).
|
||||
fn form_of(property: &Value) -> Option<Form> {
|
||||
let named = |v: &Value| match v.get("$ref").and_then(Value::as_str).and_then(|r| r.rsplit('/').next()) {
|
||||
Some("GeoRef") => Some(Form::Geo),
|
||||
Some("PlaneOrFace") => Some(Form::Plane),
|
||||
Some("AxisLine") => Some(Form::Axis),
|
||||
_ => None,
|
||||
};
|
||||
named(property).or_else(|| ["anyOf", "oneOf"].iter().filter_map(|k| property.get(*k).and_then(Value::as_array)).flatten().find_map(named))
|
||||
}
|
||||
|
||||
/// The reference parameters of `spec`, read from its input schema.
|
||||
fn slots(spec: &CommandSpec) -> Vec<Slot> {
|
||||
let Some(contract) = spec.contract else { return Vec::new() };
|
||||
let schema = contract.input_schema();
|
||||
let root = schema.as_value();
|
||||
let required: Vec<&str> =
|
||||
root.get("required").and_then(Value::as_array).map(|r| r.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
let Some(properties) = root.get("properties").and_then(Value::as_object) else { return Vec::new() };
|
||||
properties
|
||||
.iter()
|
||||
.filter_map(|(key, p)| form_of(p).map(|form| Slot { key: key.clone(), form, required: required.contains(&key.as_str()) }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `r` as a parameter of `form` reads it.
|
||||
fn as_form(r: &Value, form: Form) -> Value {
|
||||
let key = match form {
|
||||
Form::Geo => return r.clone(),
|
||||
Form::Plane => "plane",
|
||||
Form::Axis => "axis",
|
||||
};
|
||||
match r.as_object() {
|
||||
Some(o) if o.len() == 1 => o.get(key).cloned().unwrap_or_else(|| r.clone()),
|
||||
_ => r.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every way to put each of `refs` in its own slot of `slots`, with every required slot filled:
|
||||
/// the slot index of each reference. The first ways keep the references in the slots' order.
|
||||
fn placements(refs: usize, slots: &[Slot]) -> Vec<Vec<usize>> {
|
||||
/// Extend the placement in `at` by one reference, in every free slot, and keep each complete
|
||||
/// one that fills every required slot.
|
||||
fn go(refs: usize, slots: &[Slot], at: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
|
||||
if at.len() == refs {
|
||||
if slots.iter().enumerate().all(|(i, s)| !s.required || at.contains(&i)) {
|
||||
out.push(at.clone());
|
||||
}
|
||||
return;
|
||||
}
|
||||
for i in 0..slots.len() {
|
||||
if !at.contains(&i) {
|
||||
at.push(i);
|
||||
go(refs, slots, at, out);
|
||||
at.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if refs <= slots.len() {
|
||||
go(refs, slots, &mut Vec::with_capacity(refs), &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The parameters of `spec` with `refs` placed as `at` says, and `with`.
|
||||
fn params(refs: &[Value], slots: &[Slot], at: &[usize], with: &Map<String, Value>) -> Value {
|
||||
let mut p = with.clone();
|
||||
for (r, i) in refs.iter().zip(at) {
|
||||
if let Some(slot) = slots.get(*i) {
|
||||
p.insert(slot.key.clone(), as_form(r, slot.form));
|
||||
}
|
||||
}
|
||||
Value::Object(p)
|
||||
}
|
||||
|
||||
/// The first method of `tool` that builds from `refs` with `with`, tried on a scratch copy of
|
||||
/// `s`: its command and parameters. The error, when none does, is the first a method gave whose
|
||||
/// parameters fitted, else a list of what can be built from.
|
||||
pub fn construct_method(s: &Session, tool: &str, refs: &[Value], with: &Map<String, Value>) -> Result<(&'static str, Value)> {
|
||||
let mut first_error = None;
|
||||
for spec in construct_methods(tool) {
|
||||
let Some(contract) = spec.contract else { continue };
|
||||
let slots = slots(spec);
|
||||
for at in placements(refs.len(), &slots) {
|
||||
let p = params(refs, &slots, &at, with);
|
||||
if !contract.check(&p).is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut trial = s.scratch();
|
||||
match spec.run_nested(&mut trial, &p) {
|
||||
Ok(_) => return Ok((spec.id, p)),
|
||||
Err(e) => {
|
||||
if first_error.is_none() {
|
||||
first_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(first_error.unwrap_or_else(|| {
|
||||
let names: Vec<&str> = construct_methods(tool).iter().map(|c| c.label).collect();
|
||||
bad(tool, format!("no method takes these references; the methods are: {}", names.join(", ")))
|
||||
}))
|
||||
}
|
||||
|
||||
/// Run `tool` (`construct.plane`…): build with its first method that takes `refs` and `with`, as
|
||||
/// one undo step, and name the method in the reply.
|
||||
fn build(s: &mut Session, tool: &str, p: &Value) -> Result<Value> {
|
||||
// The contract has checked `refs` and `with`; this reads them as given.
|
||||
let refs = p.get("refs").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let with = p.get("with").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let (method, params) = construct_method(s, tool, &refs, &with)?;
|
||||
let spec = super::find_command(method).ok_or_else(|| bad(tool, format!("no command `{method}`")))?;
|
||||
let mut reply = spec.run_nested(s, ¶ms)?;
|
||||
if let Value::Object(m) = &mut reply {
|
||||
m.insert("method".into(), json!(method));
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// `construct.plane`.
|
||||
fn plane(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
build(s, "construct.plane", p)
|
||||
}
|
||||
|
||||
/// `construct.axis`.
|
||||
fn axis(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
build(s, "construct.axis", p)
|
||||
}
|
||||
|
||||
/// `construct.point`.
|
||||
fn point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
build(s, "construct.point", p)
|
||||
}
|
||||
@@ -2124,6 +2124,14 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
parameters.entry("old_auto_size").or_insert_with(|| json!(true));
|
||||
}
|
||||
// A hole saved before a sketch on a derived plane started its holes at that plane keeps
|
||||
// starting at the surface when it is edited (#11538).
|
||||
if let FeatureKind::Hole { old_surface_start: true, .. } = &old.kind
|
||||
&& cid == "solid.hole"
|
||||
&& let Some(parameters) = params.as_object_mut()
|
||||
{
|
||||
parameters.entry("old_surface_start").or_insert_with(|| json!(true));
|
||||
}
|
||||
// Members keep their names through an edit, unless it gives them another name.
|
||||
if let FeatureKind::FrameMember { names, body_name, .. } = &old.kind
|
||||
&& cid == "frame.member"
|
||||
|
||||
@@ -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")
|
||||
@@ -134,7 +134,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("hole")
|
||||
.key("H")
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; where faces of several bodies meet there, the face of a body in `targets`; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); head?: \"ISO 4762\" (socket head) | \"ISO 7380-1\" (button head) (with clearance: a counterbore the screw's head sits in, so type defaults to counterbore; its diameter is the head's dk plus head_gap each side, its depth the head's k plus head_gap; a cb_diameter or cb_depth given keeps its value); head_gap?: length (default 0.5 mm; an expression with parameters is kept, so the recess follows them); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?, bridge?: length (counterbore: two 3D-printing bridge layers this thick below its floor, a slot along X then a square; e.g. 0.2 mm); cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; where faces of several bodies meet there, the face of a body in `targets`; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); head?: \"ISO 4762\" (socket head) | \"ISO 7380-1\" (button head) (with clearance: a counterbore the screw's head sits in, so type defaults to counterbore; its diameter is the head's dk plus head_gap each side, its depth the head's k plus head_gap; a cb_diameter or cb_depth given keeps its value); head_gap?: length (default 0.5 mm; an expression with parameters is kept, so the recess follows them); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?, bridge?: length (counterbore: two 3D-printing bridge layers this thick below its floor, a slot along X then a square; e.g. 0.2 mm); cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body); old_surface_start?: bool (sketch points: a point inside a body starts the hole at the body's surface whatever plane the sketch lies on, as designs saved before #11538 drilled; set on those holes, keeps them as they were when edited)"),
|
||||
CommandSpec::new("solid.pipe", "Pipe", crate::checked!("solid.pipe", pipe))
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pipe")
|
||||
@@ -3205,6 +3205,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(req_expr(cmd, p, "thread_depth")?),
|
||||
};
|
||||
let old_surface_start = p.get("old_surface_start").and_then(Value::as_bool).unwrap_or(false);
|
||||
let modeled = p.get("modeled").and_then(Value::as_bool).unwrap_or(false);
|
||||
if modeled && thread.is_none() {
|
||||
return Err(bad(cmd, "`modeled` needs `thread` (a tapped hole)"));
|
||||
@@ -3429,6 +3430,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ease_out,
|
||||
to: to.map(FacePick),
|
||||
references,
|
||||
old_surface_start,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -3635,13 +3637,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 +3650,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 +3663,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 +3744,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}");
|
||||
}
|
||||
|
||||
@@ -425,6 +425,7 @@ fn add_rigid_joints(s: &mut Session, pairs: &[(u64, u64, Mat)]) -> Result<Vec<u6
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
constraint: None,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -47,6 +47,9 @@ const CAM_CONTRACT: TypedContract<CamParams> = TypedContract::new();
|
||||
#[path = "joints_inspect.rs"]
|
||||
mod inspect;
|
||||
|
||||
#[path = "assembly_constraint.rs"]
|
||||
pub(crate) mod constraint;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("joint.origin", "Joint Origin", crate::checked!("joint.origin", joint_origin))
|
||||
.at("SOLID", "CREATE")
|
||||
@@ -236,9 +239,12 @@ pub fn place(s: &mut Session) -> joints::Solution {
|
||||
|
||||
/// Work out the offsets, angles and values joints were given as expressions again (a parameter
|
||||
/// may have changed). One that can't be worked out (its parameter gone) keeps its numbers; the
|
||||
/// solve leaves that joint out and says why.
|
||||
/// solve leaves that joint out and says why. A tangent constraint's offset is its gap plus the
|
||||
/// radius of its round face as the face is now, so the part keeps resting on the plane when the
|
||||
/// radius changes; a face that can't be found keeps the numbers it has.
|
||||
fn reexpress(s: &mut Session) {
|
||||
let params = joints::Params::new(&s.doc);
|
||||
let st = s.model.state();
|
||||
let changed: Vec<(usize, (f64, f64, Vec<f64>))> = s
|
||||
.doc
|
||||
.assembly
|
||||
@@ -247,7 +253,10 @@ fn reexpress(s: &mut Session) {
|
||||
.enumerate()
|
||||
.filter(|(_, j)| !j.exprs.is_empty())
|
||||
.filter_map(|(i, j)| {
|
||||
let (o, a, mut q) = joints::expressed(¶ms, j).ok()?;
|
||||
let (mut o, a, mut q) = joints::expressed(¶ms, j).ok()?;
|
||||
if j.constraint == Some(joints::Constraint::Tangent) {
|
||||
o += s.doc.origin_radius(&st, &j.b).ok()?;
|
||||
}
|
||||
joints::clamp(j, &mut q);
|
||||
((o, a) != (j.offset, j.angle) || q != j.values).then_some((i, (o, a, q)))
|
||||
})
|
||||
@@ -1152,14 +1161,14 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Other("too many joints".into()));
|
||||
}
|
||||
check_sides(&j.a, &j.b, "joint.create")?;
|
||||
// Relations and loop-closing typed joints start where their parts are. A typed joint in
|
||||
// Relations, constraints and loop-closing typed joints start where their parts are. A typed joint in
|
||||
// an open chain keeps its usual initial values; explicitly supplied values also keep theirs.
|
||||
// A joint on a plane or axis side anchors its part to that geometry: it starts where its
|
||||
// parts are too, so the part keeps its place on the plane (or along the axis) instead of
|
||||
// jumping to the geometry's origin.
|
||||
if p.get("values").is_none()
|
||||
&& p.get("value").is_none()
|
||||
&& (j.kind.is_relation() || closes_loop(s, &j) || datum_sided(&j))
|
||||
&& (j.starts_where_it_is() || closes_loop(s, &j) || datum_sided(&j))
|
||||
&& let Some(q) = joints::relation_values(&s.doc, &s.model.state(), &j)
|
||||
{
|
||||
j.values = q;
|
||||
@@ -1345,6 +1354,13 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = kind_param(p, cmd)?;
|
||||
let a = origin_param(s, p.get("a"), cmd)?;
|
||||
let b = origin_param(s, p.get("b"), cmd)?;
|
||||
let j = new_joint(s, p, kind, a, b, cmd)?;
|
||||
add_joint(s, j, p)
|
||||
}
|
||||
|
||||
/// A joint of `kind` between origins `a` and `b`, with the values, limits, flip, offset, angle and
|
||||
/// name `p` gives (as joint.create reads them), not yet added.
|
||||
fn new_joint(s: &mut Session, p: &Value, kind: JointKind, a: JointOrigin, b: JointOrigin, cmd: &str) -> Result<Joint> {
|
||||
let id = s.doc_mut().allocate_id()?;
|
||||
let name = new_joint_name(s, p, kind);
|
||||
let offset = match p.get("offset") {
|
||||
@@ -1369,13 +1385,14 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
suppressed: false,
|
||||
exprs: JointExprs { offset: typed(p.get("offset")), angle: typed(p.get("angle")), values: value_exprs(p) },
|
||||
captured: None,
|
||||
constraint: None,
|
||||
};
|
||||
// No angle given: B keeps its turn about the joint's axis where a half turn would otherwise
|
||||
// come only from how its origin's x was chosen. (An angle relation's angle is its tilt.)
|
||||
if angle.is_none() && !j.kind.is_relation() {
|
||||
j.angle = joints::least_turn_angle(&s.doc, &s.model.state(), &j);
|
||||
}
|
||||
add_joint(s, j, p)
|
||||
Ok(j)
|
||||
}
|
||||
|
||||
fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1415,6 +1432,7 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
constraint: None,
|
||||
};
|
||||
add_joint(s, j, p)
|
||||
}
|
||||
@@ -1636,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 {
|
||||
@@ -1673,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()
|
||||
@@ -1697,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(),
|
||||
@@ -2032,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") {
|
||||
@@ -2069,6 +2114,14 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(k) = kind {
|
||||
let was = j.kind;
|
||||
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 && plain {
|
||||
if j.constraint == Some(joints::Constraint::Tangent) {
|
||||
j.exprs.offset = None;
|
||||
}
|
||||
j.constraint = None;
|
||||
}
|
||||
j.values.resize(k.dofs().len(), 0.0);
|
||||
j.limits.resize(k.dofs().len(), None);
|
||||
j.exprs.values.truncate(k.dofs().len());
|
||||
@@ -2083,6 +2136,10 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(o) = offset {
|
||||
j.offset = o;
|
||||
j.exprs.offset = typed(p.get("offset"));
|
||||
// A tangent constraint's offset is its gap: the radius is added when it is solved.
|
||||
if j.constraint == Some(joints::Constraint::Tangent) {
|
||||
j.exprs.offset.get_or_insert_with(|| format!("{o} mm"));
|
||||
}
|
||||
}
|
||||
if let Some(a) = angle {
|
||||
j.angle = a;
|
||||
@@ -2120,11 +2177,11 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& kind.is_none_or(|k| k == was_kind)
|
||||
&& offset.is_none_or(|o| (o - was_offset).abs() <= 1e-9)
|
||||
&& bool_(p, "flip").is_none_or(|f| f == was_flip);
|
||||
// Otherwise a relation whose type, offset, tilt or flip changes, or whose sides move, starts
|
||||
// again from where its parts are.
|
||||
// Otherwise a relation or a constraint whose type, offset, tilt or flip changes, or whose
|
||||
// sides move, starts again from where its parts are.
|
||||
let reseat = !tilt_only && (kind.is_some() || offset.is_some() || angle.is_some() || p.get("flip").is_some() || sides);
|
||||
if reseat {
|
||||
let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).filter(|j| j.kind.is_relation()).cloned();
|
||||
let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).filter(|j| j.starts_where_it_is()).cloned();
|
||||
if let Some(q) = edited.and_then(|j| joints::relation_values(&s.doc, &s.model.state(), &j))
|
||||
&& let Some(j) = s.doc_mut().assembly.joints.iter_mut().find(|j| j.id == id)
|
||||
{
|
||||
@@ -2218,6 +2275,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
frame_a: world(&j.a),
|
||||
frame_b: world(&j.b),
|
||||
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
|
||||
constraint: j.constraint,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -64,6 +64,7 @@ fn joint_json(s: &Session, st: &ModelState, sol: &joints::Solution, j: &Joint, q
|
||||
errors: why(&sol.errors),
|
||||
conflicts: why(&sol.conflicts),
|
||||
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
|
||||
constraint: j.constraint,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -257,6 +257,7 @@ fn a_panel_full_of_parts_placed_by_relations_stays_solved() {
|
||||
suppressed: false,
|
||||
exprs: Default::default(),
|
||||
captured: None,
|
||||
constraint: None,
|
||||
};
|
||||
if let Some(q) = solidcraft_doc::joints::relation_values(&s.doc, &s.model.state(), &j) {
|
||||
j.values = q;
|
||||
@@ -373,3 +374,436 @@ fn an_angle_edit_while_the_joint_is_suppressed_starts_from_where_the_part_is() {
|
||||
assert!(near(side(&s), moved, 1e-6), "together {together}: {:?}, moved to {moved:?}", side(&s));
|
||||
}
|
||||
}
|
||||
|
||||
// Assembly constraints (`assembly.constraint.create`): the rules in the Constrain dialog's words,
|
||||
// kept as joints of the kinds they need and tagged with the rule.
|
||||
|
||||
fn constrain(s: &mut Session, kind: &str, a: Value, b: Value, extra: Value) -> Value {
|
||||
let mut p = json!({"type": kind, "a": a, "b": b});
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
run(s, "assembly.constraint.create", p)
|
||||
}
|
||||
|
||||
fn dof_of(r: &Value, s: &Session, component: &str) -> Option<u64> {
|
||||
let o = occ(s, component);
|
||||
r["dof"].as_array()?.iter().find(|d| d["occurrence"] == json!(o))?["dof"].as_u64()
|
||||
}
|
||||
|
||||
/// Mate, flush and a distance place the bracket fully, in the panel's words, and it follows the
|
||||
/// panel as it changes size: its bottom on the top, its front flush with the panel's front, its
|
||||
/// right end 40 mm in from the panel's.
|
||||
#[test]
|
||||
fn mate_flush_and_distance_place_a_part_and_follow_the_panel() {
|
||||
let mut s = panel();
|
||||
let r = constrain(&mut s, "mate", json!("Top"), json!("Bottom"), json!({}));
|
||||
assert_eq!(dof(&r, &s), Some(3), "{r}");
|
||||
assert_eq!(r["name"], "Mate1");
|
||||
let r = constrain(&mut s, "flush", json!("Front"), json!("Face"), json!({}));
|
||||
assert_eq!(dof(&r, &s), Some(1), "{r}");
|
||||
let r = constrain(&mut s, "distance", json!("Right"), json!("Corner"), json!({"offset": -40}));
|
||||
assert!(clean(&r), "{r}");
|
||||
assert_eq!(dof(&r, &s), Some(0), "{r}");
|
||||
for w in [400.0, 650.0, 250.0] {
|
||||
run(&mut s, "parameters.change", json!({"name": "W", "expression": format!("{w} mm")}));
|
||||
let r = run(&mut s, "joint.solve", json!({}));
|
||||
assert!(clean(&r), "W {w}: {r}");
|
||||
assert!(near(place(&s, "Bracket", Vec3::new(20.0, 500.0, 0.0)), Vec3::new(w - 40.0, 0.0, 2.0), 1e-6), "W {w}");
|
||||
assert!(near(place(&s, "Bracket", Vec3::new(0.0, 500.0, 0.0)), Vec3::new(w - 60.0, 0.0, 2.0), 1e-6), "W {w}");
|
||||
assert!(near(place(&s, "Bracket", Vec3::new(20.0, 510.0, 6.0)), Vec3::new(w - 40.0, 10.0, 8.0), 1e-6), "W {w}");
|
||||
}
|
||||
// Each is a joint of the kind its rule needs, tagged with the rule.
|
||||
let list = run(&mut s, "joint.list", json!({}));
|
||||
let kinds: Vec<(String, String)> = list["joints"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|j| (j["type"].as_str().unwrap().to_string(), j["constraint"].as_str().unwrap_or("").to_string()))
|
||||
.collect();
|
||||
assert_eq!(kinds, [("planar".into(), "mate".into()), ("planar".into(), "flush".into()), ("point_on_plane".into(), "distance".into())]);
|
||||
let inspect = run(&mut s, "joint.inspect", json!({"joint": "Flush1"}));
|
||||
assert_eq!(inspect["joints"][0]["constraint"], "flush", "{inspect}");
|
||||
// Saved and reopened, the rules stay; a joint made as a joint has none.
|
||||
let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let rules: Vec<_> = back.assembly.joints.iter().map(|j| j.constraint).collect();
|
||||
use solidcraft_doc::joints::Constraint;
|
||||
assert_eq!(rules, [Some(Constraint::Mate), Some(Constraint::Flush), Some(Constraint::Distance)]);
|
||||
relation(&mut s, "parallel", "Top", "Bottom", json!({}));
|
||||
assert!(!s.doc.to_json().contains("\"constraint\":null"));
|
||||
assert_eq!(s.doc.assembly.joints.last().unwrap().constraint, None);
|
||||
}
|
||||
|
||||
/// A mate moves the part only across the gap: a bracket flat above the panel drops straight onto
|
||||
/// it, not turned and not slid.
|
||||
#[test]
|
||||
fn a_mate_moves_the_part_only_across_the_gap() {
|
||||
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 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 r = constrain(
|
||||
&mut s,
|
||||
"mate",
|
||||
json!({"occurrence": "Plate", "face": [5, 5, 10]}),
|
||||
json!({"occurrence": "Block", "face": [31, 41, 50]}),
|
||||
json!({}),
|
||||
);
|
||||
assert!(clean(&r), "{r}");
|
||||
assert_eq!(dof_of(&r, &s, "Block"), Some(3), "{r}");
|
||||
for c in corners {
|
||||
let got = place(&s, "Block", c);
|
||||
assert!(near(got, c - Vec3::new(0.0, 0.0, 40.0), 1e-6), "{c:?}: {got:?}");
|
||||
}
|
||||
// A gap: 5 mm above the plate.
|
||||
let r = run(&mut s, "joint.edit", json!({"joint": "Mate1", "offset": 5}));
|
||||
assert!(clean(&r), "{r}");
|
||||
assert!(near(place(&s, "Block", corners[0]), Vec3::new(30.0, 40.0, 15.0), 1e-6));
|
||||
}
|
||||
|
||||
/// A part's own datum plane and axis are references too: a block mated onto the plate's XY plane
|
||||
/// drops onto it, and a point on the plate's Z axis brings its corner onto that axis. Insert and
|
||||
/// tangent need a rim or a radius, which an axis has not.
|
||||
#[test]
|
||||
fn datum_planes_and_axes_take_constraints() {
|
||||
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 xy = json!({"occurrence": "Plate", "plane": "XY"});
|
||||
let z = json!({"occurrence": "Plate", "axis": "Z"});
|
||||
let bottom = json!({"occurrence": "Block", "face": [31, 41, 50]});
|
||||
let corner = json!({"occurrence": "Block", "point": [30, 40, 50]});
|
||||
let r = constrain(&mut s, "mate", xy.clone(), bottom.clone(), json!({}));
|
||||
assert!(clean(&r), "{r}");
|
||||
assert_eq!(dof_of(&r, &s, "Block"), Some(3), "{r}");
|
||||
assert!(near(place(&s, "Block", Vec3::new(30.0, 40.0, 50.0)), Vec3::new(30.0, 40.0, 0.0), 1e-6));
|
||||
assert!(near(place(&s, "Block", Vec3::new(50.0, 60.0, 70.0)), Vec3::new(50.0, 60.0, 20.0), 1e-6));
|
||||
let r = constrain(&mut s, "point_on_axis", z.clone(), corner.clone(), json!({}));
|
||||
assert!(clean(&r), "{r}");
|
||||
assert_eq!(dof_of(&r, &s, "Block"), Some(1), "{r}");
|
||||
assert!(near(place(&s, "Block", Vec3::new(30.0, 40.0, 50.0)), Vec3::ZERO, 1e-6));
|
||||
for (kind, a, b, says) in [
|
||||
("insert", z.clone(), z.clone(), "two round edges"),
|
||||
("tangent", xy.clone(), z.clone(), "a planar face and a round face"),
|
||||
("distance", xy.clone(), z.clone(), "a planar face and a point"),
|
||||
] {
|
||||
let p = json!({"type": kind, "a": a, "b": b, "offset": 1});
|
||||
let e = s.execute("assembly.constraint.create", &p).expect_err(&p.to_string()).to_string();
|
||||
assert!(e.contains(says), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(s.doc.assembly.joints.len(), 2);
|
||||
}
|
||||
|
||||
/// A shaft and a collar: two round faces. Concentric leaves a slide and a turn; Insert only the
|
||||
/// turn, with the collar's end on the shaft's.
|
||||
#[test]
|
||||
fn concentric_and_insert_leave_a_slide_and_a_turn_or_a_turn() {
|
||||
let shaft = || {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Shaft"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "axis": [0, 0, 1], "radius": 5, "height": 20, "operation": "new"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Collar"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": [50, 10, 30], "axis": [0, 0, 1], "radius": 5, "height": 10, "operation": "new"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let o = occ(&s, "Shaft");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
|
||||
s
|
||||
};
|
||||
let top = json!({"occurrence": "Shaft", "circle": [5, 0, 20]});
|
||||
let bottom = json!({"occurrence": "Collar", "circle": [55, 10, 30]});
|
||||
let mut s = shaft();
|
||||
let r = constrain(&mut s, "concentric", top.clone(), bottom.clone(), json!({}));
|
||||
assert!(clean(&r), "{r}");
|
||||
assert_eq!(dof_of(&r, &s, "Collar"), Some(2), "{r}");
|
||||
let c = place(&s, "Collar", Vec3::new(50.0, 10.0, 30.0));
|
||||
assert!(c.x.abs() < 1e-6 && c.y.abs() < 1e-6, "on the shaft's axis: {c:?}");
|
||||
let mut s = shaft();
|
||||
let r = constrain(&mut s, "insert", top, bottom, json!({}));
|
||||
assert!(clean(&r), "{r}");
|
||||
assert_eq!(dof_of(&r, &s, "Collar"), Some(1), "{r}");
|
||||
assert!(near(place(&s, "Collar", Vec3::new(50.0, 10.0, 30.0)), Vec3::new(0.0, 0.0, 20.0), 1e-6));
|
||||
assert!(near(place(&s, "Collar", Vec3::new(50.0, 10.0, 40.0)), Vec3::new(0.0, 0.0, 30.0), 1e-6), "standing on the shaft, not through it");
|
||||
}
|
||||
|
||||
/// The rules' remaining freedoms: parallel 4, perpendicular and angle 5, point on axis 4, tangent
|
||||
/// 4 (a roller on the panel: it rolls, slides and turns), each made where the parts are.
|
||||
#[test]
|
||||
fn each_rule_leaves_the_freedom_it_should() {
|
||||
let roller = |s: &mut Session| {
|
||||
run(s, "component.create", json!({"name": "Roller"}));
|
||||
run(s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": 4, "height": 30, "operation": "new"}));
|
||||
run(s, "component.activate", json!({"component": "root"}));
|
||||
};
|
||||
for (kind, a, b, extra, part, want) in [
|
||||
("parallel", json!("Top"), json!("Bottom"), json!({}), "Bracket", 4),
|
||||
("perpendicular", json!("Top"), json!("Face"), json!({}), "Bracket", 5),
|
||||
("angle", json!("Top"), json!("Bottom"), json!({"angle": 30}), "Bracket", 5),
|
||||
("distance", json!("Top"), json!("Bottom"), json!({"offset": 12}), "Bracket", 3),
|
||||
("tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({}), "Roller", 4),
|
||||
("point_on_axis", json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!("Corner"), json!({}), "Bracket", 4),
|
||||
] {
|
||||
let mut s = panel();
|
||||
roller(&mut s);
|
||||
if kind == "point_on_axis" {
|
||||
let o = occ(&s, "Roller");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
|
||||
}
|
||||
let r = constrain(&mut s, kind, a, b, extra);
|
||||
assert!(clean(&r), "{kind}: {r}");
|
||||
assert_eq!(dof_of(&r, &s, part), Some(want), "{kind}: {r}");
|
||||
if kind == "tangent" {
|
||||
// Its axis a radius above the panel's top.
|
||||
let p = place(&s, "Roller", Vec3::new(0.0, 600.0, 50.0));
|
||||
assert!((p.z - 6.0).abs() < 1e-6, "tangent: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A rule given the wrong kind of reference, or without the number it needs, is refused with what
|
||||
/// it needs, and nothing is added.
|
||||
#[test]
|
||||
fn a_rule_with_the_wrong_references_is_refused() {
|
||||
let mut s = panel();
|
||||
let cases = [
|
||||
(json!({"type": "mate", "a": "Top", "b": "Corner"}), "two planar faces"),
|
||||
(json!({"type": "insert", "a": "Top", "b": "Bottom"}), "two round edges"),
|
||||
(json!({"type": "distance", "a": "Top", "b": "Bottom"}), "offset"),
|
||||
(json!({"type": "angle", "a": "Top", "b": "Bottom"}), "angle"),
|
||||
(json!({"type": "weld", "a": "Top", "b": "Bottom"}), "`type` must be"),
|
||||
(json!({"type": "mate", "a": "Top", "b": "Bottom", "offset": "1e999"}), ""),
|
||||
];
|
||||
for (p, says) in cases {
|
||||
let e = s.execute("assembly.constraint.create", &p).expect_err(&p.to_string()).to_string();
|
||||
assert!(e.contains(says), "{p}: {e}");
|
||||
}
|
||||
assert!(s.doc.assembly.joints.is_empty());
|
||||
}
|
||||
|
||||
/// A roller tangent on the panel keeps resting on it as its radius changes, and a gap edited in as
|
||||
/// a number stays a gap above the face, not the axis's height.
|
||||
/// A constraint given a blank or `null` name is named after its rule, as one given no name is. A
|
||||
/// joint.edit to another kind of joint makes it a plain joint: the tag goes, and a former tangent's
|
||||
/// offset no longer follows its face's radius.
|
||||
#[test]
|
||||
fn a_constraint_edited_to_another_joint_type_is_a_plain_joint() {
|
||||
let mut s = panel();
|
||||
run(&mut s, "parameters.add", json!({"name": "R", "expression": "4 mm"}));
|
||||
run(&mut s, "component.create", json!({"name": "Roller"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": "R", "height": 30, "operation": "new"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let r = constrain(&mut s, "tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({"name": null}));
|
||||
assert!(clean(&r), "{r}");
|
||||
let r = constrain(&mut s, "parallel", json!("Top"), json!("Bottom"), json!({"name": " "}));
|
||||
assert!(clean(&r), "{r}");
|
||||
let names: Vec<&str> = s.doc.assembly.joints.iter().map(|j| j.name.as_str()).collect();
|
||||
assert_eq!(names, ["Tangent1", "Parallel1"]);
|
||||
run(&mut s, "joint.edit", json!({"joint": "Tangent1", "type": "revolute"}));
|
||||
let j = s.doc.assembly.joints.iter().find(|j| j.name == "Tangent1").cloned().unwrap();
|
||||
assert_eq!((j.kind, j.constraint, j.exprs.offset.clone()), (solidcraft_doc::joints::JointKind::Revolute, None, None));
|
||||
let list = run(&mut s, "joint.list", json!({}));
|
||||
let listed = list["joints"].as_array().and_then(|js| js.iter().find(|x| x["name"] == "Tangent1")).cloned().unwrap_or_default();
|
||||
assert!(listed.get("constraint").is_none(), "{listed}");
|
||||
// The radius no longer moves it.
|
||||
run(&mut s, "parameters.change", json!({"name": "R", "expression": "7 mm"}));
|
||||
run(&mut s, "joint.solve", json!({}));
|
||||
let after = s.doc.assembly.joints.iter().find(|j| j.name == "Tangent1").map(|j| j.offset);
|
||||
assert_eq!(after, Some(j.offset));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tangent_constraint_follows_its_faces_radius() {
|
||||
let mut s = panel();
|
||||
run(&mut s, "parameters.add", json!({"name": "R", "expression": "4 mm"}));
|
||||
run(&mut s, "component.create", json!({"name": "Roller"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": "R", "height": 30, "operation": "new"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let r = constrain(&mut s, "tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({}));
|
||||
assert!(clean(&r), "{r}");
|
||||
let axis = |s: &Session| place(s, "Roller", Vec3::new(0.0, 600.0, 50.0)).z;
|
||||
assert!((axis(&s) - 6.0).abs() < 1e-6, "{}", axis(&s));
|
||||
for (radius, gap, want) in [(7.0, None, 9.0), (2.5, None, 4.5), (2.5, Some(1.0), 5.5), (6.0, None, 9.0)] {
|
||||
run(&mut s, "parameters.change", json!({"name": "R", "expression": format!("{radius} mm")}));
|
||||
if let Some(g) = gap {
|
||||
run(&mut s, "joint.edit", json!({"joint": "Tangent1", "offset": g}));
|
||||
}
|
||||
let r = run(&mut s, "joint.solve", json!({}));
|
||||
assert!(clean(&r), "R {radius}: {r}");
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -42,8 +42,12 @@ fn target(s: &Session, p: &Value, cmd: &str) -> Result<String> {
|
||||
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "mesh.convert";
|
||||
let state = s.model.state();
|
||||
let body = target(s, p, cmd)?;
|
||||
let source = state.body(&body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
|
||||
let given = target(s, p, cmd)?;
|
||||
// The selection and the Browser name a body of a further placement as the world shows it
|
||||
// (`Mesh (Scan:2)`): that name means the body it shows, which the conversion edits in its
|
||||
// component, as `appearance.assign` reads it (#9740).
|
||||
let body = s.own_body_name(&given).ok_or_else(|| bad(cmd, format!("no body `{given}`")))?;
|
||||
let source = state.body(&body).ok_or_else(|| bad(cmd, format!("no body `{given}`")))?;
|
||||
if !source.body.is_mesh() {
|
||||
return Err(bad(cmd, "select a mesh body"));
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ mod component;
|
||||
mod component_clone;
|
||||
mod component_clone_assembly;
|
||||
mod construct;
|
||||
mod construct_auto;
|
||||
mod delete;
|
||||
pub(crate) mod diagnostics;
|
||||
mod edit;
|
||||
@@ -40,7 +41,9 @@ 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};
|
||||
pub(crate) use features::profiles as feature_profiles;
|
||||
@@ -86,6 +89,7 @@ mod measure_shape;
|
||||
mod mesh;
|
||||
mod metadata;
|
||||
pub(crate) mod motion;
|
||||
mod notes;
|
||||
mod parts;
|
||||
mod point_cloud;
|
||||
mod script;
|
||||
@@ -348,6 +352,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(features::COMMANDS.iter());
|
||||
v.extend(features_more::COMMANDS.iter());
|
||||
v.extend(mesh::COMMANDS.iter());
|
||||
v.extend(construct_auto::COMMANDS.iter());
|
||||
v.extend(construct::COMMANDS.iter());
|
||||
v.extend(sheet::COMMANDS.iter());
|
||||
v.extend(plastic::COMMANDS.iter());
|
||||
@@ -356,6 +361,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(component::COMMANDS.iter());
|
||||
v.extend(component_link::COMMANDS.iter());
|
||||
v.extend(joints::COMMANDS.iter());
|
||||
v.extend(joints::constraint::COMMANDS.iter());
|
||||
v.extend(clearance::COMMANDS.iter());
|
||||
v.extend(motion::COMMANDS.iter());
|
||||
v.extend(panels::COMMANDS.iter());
|
||||
@@ -364,6 +370,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(bom::COMMANDS.iter());
|
||||
v.extend(mass_properties::COMMANDS.iter());
|
||||
v.extend(browser::COMMANDS.iter());
|
||||
v.extend(notes::COMMANDS.iter());
|
||||
v.extend(delete::COMMANDS.iter());
|
||||
v.extend(edit::COMMANDS.iter());
|
||||
v.extend(selection::COMMANDS.iter());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Note commands (#1631): personal notes anchored to points in model space, saved with the
|
||||
//! design and drawn in the viewport as screen-facing callouts. `note.add` anchors a note,
|
||||
//! `note.update` edits its text, anchor, or hidden flag, `note.delete` removes it, and
|
||||
//! `note.list` reads them back. Everything lives in the document, so notes are saved and
|
||||
//! undone like any other design change.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use solidcraft_doc::Note;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, req_vec3, str_};
|
||||
use crate::schema::notes::{NoteAddParams, NoteDeleteParams, NoteDeleted, NoteId, NoteItem, NoteListParams, NoteUpdateParams, NotesList};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const ADD: TypedContract<NoteAddParams> = TypedContract::new();
|
||||
const UPDATE: TypedContract<NoteUpdateParams> = TypedContract::new();
|
||||
const DELETE: TypedContract<NoteDeleteParams> = TypedContract::new();
|
||||
const LIST: TypedContract<NoteListParams> = TypedContract::new();
|
||||
|
||||
/// Notes a design may hold.
|
||||
const MAX_NOTES: usize = 1000;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("note.add", "Add Note", crate::checked!("note.add", note_add))
|
||||
.icon("text")
|
||||
.contract(&ADD)
|
||||
.output(output_schema_for::<NoteId>)
|
||||
.params("anchor: [x,y,z] (the point in model space, mm); text: the note (may span lines); hidden?: bool (saved but not drawn) → note: id"),
|
||||
CommandSpec::new("note.update", "Edit Note", crate::checked!("note.update", note_update))
|
||||
.icon("edit")
|
||||
.contract(&UPDATE)
|
||||
.output(output_schema_for::<NoteId>)
|
||||
.params("note: id; text?: the new text; anchor?: [x,y,z] (move the anchor); hidden?: bool (show or hide) → note: id"),
|
||||
CommandSpec::new("note.delete", "Delete Note", crate::checked!("note.delete", note_delete))
|
||||
.icon("delete")
|
||||
.contract(&DELETE)
|
||||
.output(output_schema_for::<NoteDeleted>)
|
||||
.params("note: id → deleted: id"),
|
||||
CommandSpec::new("note.list", "List Notes", crate::checked!("note.list", note_list))
|
||||
.icon("list")
|
||||
.contract(&LIST)
|
||||
.output(output_schema_for::<NotesList>)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("→ notes: [{id, anchor: [x,y,z], text, hidden}] in creation order"),
|
||||
];
|
||||
|
||||
fn note_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "note.add";
|
||||
let anchor = req_vec3(CMD, p, "anchor")?;
|
||||
let text = str_(p, "text").ok_or_else(|| bad(CMD, "`text` is required"))?;
|
||||
if !Note::text_is_valid(text) {
|
||||
return Err(bad(CMD, format!("`text` must not be empty and must be at most {} characters", solidcraft_doc::notes::MAX_NOTE_CHARS)));
|
||||
}
|
||||
if s.doc.notes.len() >= MAX_NOTES {
|
||||
return Err(bad(CMD, "too many notes"));
|
||||
}
|
||||
let id = s.doc_mut().allocate_id()?;
|
||||
let hidden = bool_(p, "hidden").unwrap_or(false);
|
||||
s.doc_mut().notes.push(Note { id, anchor, text: text.to_string(), hidden });
|
||||
Ok(json!({ "note": id }))
|
||||
}
|
||||
|
||||
/// The index of a note by id.
|
||||
fn note_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let id = p.get("note").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`note` must be a note id"))?;
|
||||
s.doc.notes.iter().position(|n| n.id == id).ok_or_else(|| bad(cmd, format!("no note {id}")))
|
||||
}
|
||||
|
||||
fn note_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "note.update";
|
||||
let i = note_index(s, p, CMD)?;
|
||||
let text = match str_(p, "text") {
|
||||
Some(t) if Note::text_is_valid(t) => Some(t.to_string()),
|
||||
Some(_) => {
|
||||
return Err(bad(CMD, format!("`text` must not be empty and must be at most {} characters", solidcraft_doc::notes::MAX_NOTE_CHARS)));
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let anchor = match p.get("anchor") {
|
||||
Some(_) => Some(req_vec3(CMD, p, "anchor")?),
|
||||
None => None,
|
||||
};
|
||||
let hidden = bool_(p, "hidden");
|
||||
if text.is_none() && anchor.is_none() && hidden.is_none() {
|
||||
return Err(bad(CMD, "give at least one of `text`, `anchor`, `hidden`"));
|
||||
}
|
||||
let id = {
|
||||
let n = &mut s.doc_mut().notes[i];
|
||||
if let Some(t) = text {
|
||||
n.text = t;
|
||||
}
|
||||
if let Some(a) = anchor {
|
||||
n.anchor = a;
|
||||
}
|
||||
if let Some(h) = hidden {
|
||||
n.hidden = h;
|
||||
}
|
||||
n.id
|
||||
};
|
||||
Ok(json!({ "note": id }))
|
||||
}
|
||||
|
||||
fn note_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = note_index(s, p, "note.delete")?;
|
||||
let n = s.doc_mut().notes.remove(i);
|
||||
Ok(json!({ "deleted": n.id }))
|
||||
}
|
||||
|
||||
fn note_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let notes: Vec<NoteItem> = s
|
||||
.doc
|
||||
.notes
|
||||
.iter()
|
||||
.map(|n| NoteItem { id: n.id, anchor: [n.anchor.x, n.anchor.y, n.anchor.z], text: n.text.clone(), hidden: n.hidden })
|
||||
.collect();
|
||||
Ok(json!({ "notes": notes }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "notes_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Note commands (#1631): add, edit, delete, list, undo, and persistence.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, params: Value) -> Value {
|
||||
s.execute(id, ¶ms).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_update_delete_and_list_round_trip() {
|
||||
let mut s = Session::default();
|
||||
let added = run(&mut s, "note.add", json!({"anchor": [10.0, 20.0, 30.0], "text": "Check clearance here."}));
|
||||
let id = added["note"].as_u64().unwrap();
|
||||
assert!(id > 0);
|
||||
|
||||
let listed = run(&mut s, "note.list", json!({}));
|
||||
let notes = listed["notes"].as_array().unwrap();
|
||||
assert_eq!(notes.len(), 1);
|
||||
assert_eq!(notes[0]["id"], json!(id));
|
||||
assert_eq!(notes[0]["text"], "Check clearance here.");
|
||||
assert_eq!(notes[0]["anchor"], json!([10.0, 20.0, 30.0]));
|
||||
assert_eq!(notes[0]["hidden"], json!(false));
|
||||
|
||||
run(&mut s, "note.update", json!({"note": id, "text": "Adjust this diameter after the test print.", "hidden": true}));
|
||||
let listed = run(&mut s, "note.list", json!({}));
|
||||
assert_eq!(listed["notes"][0]["text"], "Adjust this diameter after the test print.");
|
||||
assert_eq!(listed["notes"][0]["hidden"], json!(true));
|
||||
|
||||
run(&mut s, "note.update", json!({"note": id, "anchor": [1.0, 2.0, 3.0]}));
|
||||
assert_eq!(run(&mut s, "note.list", json!({}))["notes"][0]["anchor"], json!([1.0, 2.0, 3.0]));
|
||||
|
||||
assert_eq!(run(&mut s, "note.delete", json!({"note": id})), json!({"deleted": id}));
|
||||
assert!(run(&mut s, "note.list", json!({}))["notes"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_survive_a_save_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 5.0], "text": "Extra thickness added for strength."}));
|
||||
let saved = serde_json::to_string(&*s.doc).unwrap();
|
||||
let reopened = Session::new(serde_json::from_str(&saved).unwrap());
|
||||
assert_eq!(reopened.doc.notes.len(), 1);
|
||||
assert_eq!(reopened.doc.notes[0].text, "Extra thickness added for strength.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_undoes_note_changes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "first"}));
|
||||
run(&mut s, "note.add", json!({"anchor": [1.0, 0.0, 0.0], "text": "second"}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.notes.len(), 1);
|
||||
assert_eq!(s.doc.notes[0].text, "first");
|
||||
let id = s.doc.notes[0].id;
|
||||
run(&mut s, "note.update", json!({"note": id, "text": "renamed"}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.notes[0].text, "first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_never_reused() {
|
||||
let mut s = Session::default();
|
||||
let a = run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "a"}))["note"].as_u64().unwrap();
|
||||
run(&mut s, "note.delete", json!({"note": a}));
|
||||
let b = run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "b"}))["note"].as_u64().unwrap();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_calls_are_refused() {
|
||||
let mut s = Session::default();
|
||||
// Empty or missing text.
|
||||
assert!(s.execute("note.add", &json!({"anchor": [0.0, 0.0, 0.0], "text": " "})).is_err());
|
||||
assert!(s.execute("note.add", &json!({"anchor": [0.0, 0.0, 0.0]})).is_err());
|
||||
// A non-finite anchor.
|
||||
assert!(s.execute("note.add", &json!({"anchor": [f64::NAN, 0.0, 0.0], "text": "x"})).is_err());
|
||||
// Unknown note id.
|
||||
assert!(s.execute("note.update", &json!({"note": 999, "text": "x"})).is_err());
|
||||
assert!(s.execute("note.delete", &json!({"note": 999})).is_err());
|
||||
// An update that changes nothing.
|
||||
run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "x"}));
|
||||
let id = s.doc.notes[0].id;
|
||||
assert!(s.execute("note.update", &json!({"note": id})).is_err());
|
||||
assert_eq!(s.doc.notes[0].text, "x");
|
||||
}
|
||||
+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.
|
||||
|
||||
@@ -1296,6 +1296,55 @@ fn circular_pattern_of_a_moved_copy_adds_one_body_per_instance() {
|
||||
assert!(far > 20.0, "the repeats follow the redefined Move: {far}");
|
||||
}
|
||||
|
||||
/// #12199: a path whose curves are listed out of chain order is the same path as in chain order.
|
||||
#[test]
|
||||
fn pattern_on_path_orders_the_curves_into_one_chain() {
|
||||
// Four lines: (10,10) → (30,10) → (30,30) → (60,30) → (60,10); 90 mm long.
|
||||
let copies = |order: [&str; 4]| {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [9, 9, 0], "body_name": "Pin"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
|
||||
for (id, a, b) in [("l1", [10, 10], [30, 10]), ("l2", [30, 10], [30, 30]), ("l3", [30, 30], [60, 30]), ("l4", [60, 30], [60, 10])] {
|
||||
run(&mut s, "sketch.line", json!({"points": [a, b], "connect": false, "ids": [id]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.pattern.path", json!({"bodies": ["Pin"], "path_sketch": "Path", "path": order, "count": 4, "spacing": 25}));
|
||||
let m = run(&mut s, "inspect.measure", json!({}));
|
||||
let mut at: Vec<(i64, i64)> = m["bodies"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|b| ((b["bbox"]["min"][0].as_f64().unwrap() * 1e3).round() as i64, (b["bbox"]["min"][1].as_f64().unwrap() * 1e3).round() as i64))
|
||||
.collect();
|
||||
at.sort();
|
||||
at
|
||||
};
|
||||
// The copies sit 0, 25, 50 and 75 mm along the path, as displacements from its start.
|
||||
let want = vec![(9_000, 9_000), (29_000, 14_000), (39_000, 29_000), (59_000, 24_000)];
|
||||
assert_eq!(copies(["l1", "l2", "l3", "l4"]), want);
|
||||
assert_eq!(copies(["l1", "l3", "l2", "l4"]), want);
|
||||
assert_eq!(copies(["l4", "l3", "l2", "l1"]), copies(["l4", "l2", "l3", "l1"]));
|
||||
}
|
||||
|
||||
/// #12199: putting the listed curves in chain order does not join curves with a gap between
|
||||
/// them: such a path is still refused.
|
||||
#[test]
|
||||
fn pattern_on_path_with_a_gap_is_still_refused() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [9, 9, 0], "body_name": "Pin"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
|
||||
// (10,10) → (30,10), then a 20 mm gap, then (30,30) → (60,30).
|
||||
for (id, a, b) in [("l1", [10, 10], [30, 10]), ("l3", [30, 30], [60, 30])] {
|
||||
run(&mut s, "sketch.line", json!({"points": [a, b], "connect": false, "ids": [id]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
for order in [["l1", "l3"], ["l3", "l1"]] {
|
||||
let p = json!({"bodies": ["Pin"], "path_sketch": "Path", "path": order, "count": 3, "spacing": 10});
|
||||
let error = s.execute("solid.pattern.path", &p).unwrap_err().to_string();
|
||||
assert!(error.contains("not connected end to end"), "{order:?}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12585: a pattern or mirror of an Offset Face repeats it at the copied faces instead of
|
||||
/// failing with "cannot be patterned or mirrored yet". A 40 x 30 x 20 box whose top is split into
|
||||
/// strips at x = 10 and x = 30 has its left strip raised 5; the copy raises the right strip.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Shape, Sketch, TextAlign, TextExprs, TextStyle};
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Shape, Sketch, TextAlign, TextExprs, TextStyle, TextVerticalAlign};
|
||||
|
||||
/// The style parameters of `sketch.text` and `sketch.edit_text`.
|
||||
macro_rules! text_style_doc {
|
||||
() => {
|
||||
"bold?, italic?: bool (an installed family's bold or italic face; italic without one is slanted), align?: left|center|right (the lines against `at`, or along the path), letter_spacing?: extra mm between letters, line_spacing?: times the font's line distance (default 1), path?: a curve id of the sketch the baseline follows (\"\": none; `at` and `angle` then do not place the text; the curve becomes construction, a guide the letters stand on), reverse?: bool (the other way along the path)"
|
||||
"bold?, italic?: bool (an installed family's bold or italic face; italic without one is slanted), align?: left|center|right (the lines against `at`, or along the path), vertical_align?: baseline|top|middle|bottom (the block of lines against `at` or the path: the first line's baseline (default), its capitals' tops, halfway between those and the last line's baseline, or the bottom of the last line's descenders), letter_spacing?: extra mm between letters, line_spacing?: times the font's line distance (default 1), path?: a curve id of the sketch the baseline follows (\"\": none; `at` and `angle` then do not place the text; the curve becomes construction, a guide the letters stand on), reverse?: bool (the other way along the path)"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -246,6 +246,20 @@ fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
|
||||
}
|
||||
Some(_) => return Err(bad(cmd, "`align` must be left, center or right")),
|
||||
}
|
||||
const VERTICAL_MUST: &str = "`vertical_align` must be baseline, top, middle or bottom";
|
||||
match p.get("vertical_align") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::String(a)) => {
|
||||
style.vertical_align = match a.trim().to_ascii_lowercase().as_str() {
|
||||
"baseline" => TextVerticalAlign::Baseline,
|
||||
"top" => TextVerticalAlign::Top,
|
||||
"middle" | "center" | "centre" => TextVerticalAlign::Middle,
|
||||
"bottom" => TextVerticalAlign::Bottom,
|
||||
_ => return Err(bad(cmd, VERTICAL_MUST)),
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(bad(cmd, VERTICAL_MUST)),
|
||||
}
|
||||
if p.get("letter_spacing").is_some_and(|v| !v.is_null()) {
|
||||
style.letter_spacing =
|
||||
num(p, "letter_spacing").filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "`letter_spacing` must be a length in mm"))?;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -226,6 +226,8 @@ fn text_style_parameters_are_checked() {
|
||||
(json!({"link":link,"bold":true}), "no bold face"),
|
||||
(json!({"link":link,"bold":"yes"}), "`bold` must be true or false"),
|
||||
(json!({"link":link,"align":"justify"}), "`align` must be left, center or right"),
|
||||
(json!({"link":link,"vertical_align":"above"}), "`vertical_align` must be baseline, top, middle or bottom"),
|
||||
(json!({"link":link,"vertical_align":1}), "`vertical_align` must be baseline, top, middle or bottom"),
|
||||
(json!({"link":link,"line_spacing":0}), "`line_spacing` must be a positive factor"),
|
||||
(json!({"link":link,"letter_spacing":"wide"}), "`letter_spacing` must be a length"),
|
||||
(json!({"link":link,"path":"no-such-curve"}), "not a curve of this sketch"),
|
||||
@@ -235,14 +237,19 @@ fn text_style_parameters_are_checked() {
|
||||
assert!(e.contains(error), "{params}: {e}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
run(&mut s, "sketch.edit_text", json!({"link":link,"italic":true,"align":"right","letter_spacing":1.5,"line_spacing":1.2}));
|
||||
run(
|
||||
&mut s,
|
||||
"sketch.edit_text",
|
||||
json!({"link":link,"italic":true,"align":"right","vertical_align":"middle","letter_spacing":1.5,"line_spacing":1.2}),
|
||||
);
|
||||
let LinkSource::Text { style, text, .. } = &s.doc.sketch(1).unwrap().link(&link).unwrap().source else { panic!() };
|
||||
assert_eq!(text, "II");
|
||||
assert!(style.italic && style.align == solidcraft_sketch::TextAlign::Right && style.letter_spacing == 1.5 && style.line_spacing == 1.2);
|
||||
assert_eq!(style.vertical_align, solidcraft_sketch::TextVerticalAlign::Middle);
|
||||
// Left out, a style stays.
|
||||
run(&mut s, "sketch.edit_text", json!({"link":link,"text":"I"}));
|
||||
let LinkSource::Text { style, .. } = &s.doc.sketch(1).unwrap().link(&link).unwrap().source else { panic!() };
|
||||
assert!(style.italic && style.letter_spacing == 1.5);
|
||||
assert!(style.italic && style.letter_spacing == 1.5 && style.vertical_align == solidcraft_sketch::TextVerticalAlign::Middle);
|
||||
}
|
||||
|
||||
/// `base` with the keys of `extra` set (a null `extra` value is sent as null).
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user