Compare commits

..

1 Commits

Author SHA1 Message Date
bherbruck 3fffb8a9b9 offset: the executive marks how it made each face; faces_continue; ops history (#3138)
SolidCraft decides whether a shell's face is a face or its offset copy by mesh
distance (offset_backend.rs with_offsets), because the tag a copy carries is its
source's. The executive already relates each face it makes to what made it.
mark_offset_roles, called first in piecewise_merge where those relations are
final, marks each such face with ids::OFFSET_ROLE: an offset copy, a face along
an edge (with the closing faces, a sheet's side walls), a face at a vertex.
It adds no relation and changes no decision.

query::faces_continue proves two faces across an edge lie on one surface with
the same outward side only on identical definitions, bit for bit, and is
Undecided otherwise; it never samples. solidcore-ops' history::recorded tags the
operand's faces and edges by record index, runs the call, and reads each result
face's origin and change and each edge's operand edges, in record order. The
tags and roles are removed before it returns.

offset.offset_executive_p2: 30 rows, the attributes block only (the role now on
the offset faces). A/B: 30 with the pass, 0 without. Tests: a shelled box
(424), a thickened sheet (100), a moved face (1200), and #865's plate, whose
crossing-loops-rebuilt top is the top's offset.

Part of #3138.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:24:19 -05:00
641 changed files with 6483 additions and 32711 deletions
+16 -30
View File
@@ -110,7 +110,7 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@1.98.0
- uses: dtolnay/rust-toolchain@stable
- 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@1.98.0
- uses: dtolnay/rust-toolchain@stable
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@1.98.0
- uses: dtolnay/rust-toolchain@stable
- 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@1.98.0
- uses: dtolnay/rust-toolchain@stable
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@1.98.0
- uses: dtolnay/rust-toolchain@stable
# 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@1.98.0
- uses: dtolnay/rust-toolchain@stable
# 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@1.98.0
- uses: dtolnay/rust-toolchain@stable
# 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@1.98.0
- uses: dtolnay/rust-toolchain@stable
# 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@1.98.0
- uses: dtolnay/rust-toolchain@stable
# 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@1.98.0
- uses: dtolnay/rust-toolchain@stable
- 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@1.98.0
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-startup
@@ -1093,23 +1093,9 @@ jobs:
echo "Waiting for $pending run(s) of this commit to finish."
sleep 30
done
# 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)"
# 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")
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
@@ -1201,7 +1187,7 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@1.98.0
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
@@ -1457,7 +1443,7 @@ jobs:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@1.98.0
- uses: dtolnay/rust-toolchain@stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
packages: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@1.98.0
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
timeout-minutes: 120
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@1.98.0
- uses: dtolnay/rust-toolchain@stable
- name: Build the CLI
run: cargo build --profile ci -p solidcraft-cli
- name: Import and check every entry
+1 -1
View File
@@ -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@1.98.0
- uses: dtolnay/rust-toolchain@stable
# Only main and develop commit the cache; pull requests and forks mount a clone and never
# write back.
- name: Mount the compiler cache
+1 -1
View File
@@ -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@1.98.0
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: generated-files
+1 -1
View File
@@ -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@1.98.0
- uses: dtolnay/rust-toolchain@stable
# 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
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@1.98.0
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: lists
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@1.98.0
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+6 -6
View File
@@ -122,7 +122,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@1.98.0
- uses: dtolnay/rust-toolchain@stable
- 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@1.98.0
- uses: dtolnay/rust-toolchain@stable
- 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 1.98.0
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
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@1.98.0
- uses: dtolnay/rust-toolchain@stable
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@1.98.0
- uses: dtolnay/rust-toolchain@stable
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@1.98.0
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+2 -5
View File
@@ -43,11 +43,8 @@ contributors without write access can't push to them. To update one:
## Get it running (about 10 minutes on a fast machine)
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)
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)
([documentation](https://trunk-rs.github.io/trunk/)).
```sh
Generated
+33 -33
View File
@@ -4259,7 +4259,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4302,11 +4302,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-cli"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"image",
"log",
@@ -4322,11 +4322,11 @@ dependencies = [
[[package]]
name = "solidcraft-color"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-control"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"getrandom 0.3.4",
"serde",
@@ -4335,7 +4335,7 @@ dependencies = [
[[package]]
name = "solidcraft-control-egui"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"image",
@@ -4345,7 +4345,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"proc-macro2",
"quote",
@@ -4354,7 +4354,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"log",
"serde",
@@ -4376,7 +4376,7 @@ dependencies = [
[[package]]
name = "solidcraft-dynamics"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"serde",
"serde_json",
@@ -4387,7 +4387,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"image",
"log",
@@ -4413,7 +4413,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"serde",
"serde_json",
@@ -4426,7 +4426,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"image",
"log",
@@ -4451,7 +4451,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4479,7 +4479,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"log",
"proptest",
@@ -4500,7 +4500,7 @@ dependencies = [
[[package]]
name = "solidcraft-look"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"log",
@@ -4512,7 +4512,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"image",
"serde_json",
@@ -4526,14 +4526,14 @@ dependencies = [
[[package]]
name = "solidcraft-mcp-server"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"serde_json",
]
[[package]]
name = "solidcraft-profile"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"serde_json",
"web-time",
@@ -4541,7 +4541,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"flate2",
"image",
@@ -4564,7 +4564,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"harfrust",
"serde",
@@ -4578,7 +4578,7 @@ dependencies = [
[[package]]
name = "solidcraft-smlib"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"flate2",
"log",
@@ -4592,18 +4592,18 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-testkit"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-theme"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"serde",
@@ -4613,7 +4613,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"arboard",
"egui",
@@ -4655,7 +4655,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-kit"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"arboard",
"egui",
@@ -4678,14 +4678,14 @@ dependencies = [
[[package]]
name = "solidcraft-ui-registry"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"egui",
]
[[package]]
name = "solidcraft-ui-text"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"log",
"serde_json",
@@ -4695,7 +4695,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"image",
"serde",
@@ -4707,7 +4707,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-egui"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"egui-wgpu",
@@ -4718,7 +4718,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-gpu"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"log",
"naga",
@@ -4734,7 +4734,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"eframe",
"egui",
@@ -6220,7 +6220,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"flate2",
"proc-macro2",
+1 -1
View File
@@ -6,7 +6,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["solidcore", "vendor"]
[workspace.package]
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+2 -2
View File
@@ -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_eq!(plain["properties"]["bodies"]["type"], json!(["boolean", "string"]), "no list of names: {plain}");
assert!(plain["properties"].get("bodies").is_none(), "{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"]["type"].as_array().is_some_and(|t| t.contains(&json!("array"))), "{args:?} {env:?}: {compact}");
assert!(compact["properties"]["bodies"].is_object(), "{args:?} {env:?}: {compact}");
}
let e = inspect_schema(&[], Some("maybe")).unwrap_err();
+6 -37
View File
@@ -361,54 +361,23 @@ 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 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));
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));
std::thread::scope(|scope| {
for _ in 0..workers {
scope.spawn(|| {
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);
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));
}
});
}
});
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();
let mut results = results.into_inner().unwrap_or_else(|e| e.into_inner());
results.sort_by(|a, b| a.0.cmp(&b.0));
let mut problems = Vec::new();
for (name, got) in &results {
+1 -4
View File
@@ -149,12 +149,9 @@ self.addEventListener("activate", (event) => {
return self.registration.unregister();
}
const older = (await caches.keys()).filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE);
// Claim first: from the moment this worker is the active one, a page the old worker controlled has
// no controller until it is claimed, and a request it makes then goes straight to the network, which
// serves the newest build, not the page's. Nothing slow goes before this.
await self.clients.claim();
for (const name of older) await (await caches.open(name)).put(url(RETIRED), new Response(""));
await sweep();
await self.clients.claim();
// With no earlier build kept, the pages open when the first worker takes over came from the
// network, and get their files from it. After an update a page with no record is not
// assumed to be anything: its record may only be late, and the network now serves a newer
@@ -1,83 +0,0 @@
{
"en": {
"ui.agent_chat_answered_in": "Answered in {time}",
"ui.agent_chat_ended_after": "Ended after {time}",
"ui.agent_chat_span_h": "{h} h {min} min",
"ui.agent_chat_span_min": "{n} min",
"ui.agent_chat_span_s": "{n} s",
"ui.agent_chat_thinking_for": "Thinking for {time}",
"ui.agent_chat_tool_running_for": "running for {time}"
},
"ar": {
"ui.agent_chat_answered_in": "أُجيب خلال {time}",
"ui.agent_chat_ended_after": "انتهى بعد {time}",
"ui.agent_chat_span_h": "{h} ساعة {min} دقيقة",
"ui.agent_chat_span_min": "{n} دقيقة",
"ui.agent_chat_span_s": "{n} ث",
"ui.agent_chat_thinking_for": "يفكر منذ {time}",
"ui.agent_chat_tool_running_for": "قيد التشغيل منذ {time}"
},
"de": {
"ui.agent_chat_answered_in": "Beantwortet in {time}",
"ui.agent_chat_ended_after": "Beendet nach {time}",
"ui.agent_chat_span_h": "{h} Std. {min} Min.",
"ui.agent_chat_span_min": "{n} Min.",
"ui.agent_chat_span_s": "{n} Sek.",
"ui.agent_chat_thinking_for": "Denkt seit {time} nach",
"ui.agent_chat_tool_running_for": "läuft seit {time}"
},
"fr": {
"ui.agent_chat_answered_in": "Répondu en {time}",
"ui.agent_chat_ended_after": "Arrêté après {time}",
"ui.agent_chat_span_h": "{h} h {min} min",
"ui.agent_chat_span_min": "{n} min",
"ui.agent_chat_span_s": "{n} s",
"ui.agent_chat_thinking_for": "Réfléchit depuis {time}",
"ui.agent_chat_tool_running_for": "en cours depuis {time}"
},
"he": {
"ui.agent_chat_answered_in": "נענה תוך {time}",
"ui.agent_chat_ended_after": "הסתיים אחרי {time}",
"ui.agent_chat_span_h": "{h} שע' {min} דק'",
"ui.agent_chat_span_min": "{n} דק'",
"ui.agent_chat_span_s": "{n} שנ'",
"ui.agent_chat_thinking_for": "חושב כבר {time}",
"ui.agent_chat_tool_running_for": "רץ כבר {time}"
},
"ja": {
"ui.agent_chat_answered_in": "{time}で回答",
"ui.agent_chat_ended_after": "{time}後に終了",
"ui.agent_chat_span_h": "{h} 時間 {min} 分",
"ui.agent_chat_span_min": "{n} 分",
"ui.agent_chat_span_s": "{n} 秒",
"ui.agent_chat_thinking_for": "{time}考え中",
"ui.agent_chat_tool_running_for": "実行中 ({time})"
},
"ko": {
"ui.agent_chat_answered_in": "{time} 만에 답변",
"ui.agent_chat_ended_after": "{time} 후 종료",
"ui.agent_chat_span_h": "{h}시간 {min}분",
"ui.agent_chat_span_min": "{n}분",
"ui.agent_chat_span_s": "{n}초",
"ui.agent_chat_thinking_for": "{time}째 생각 중",
"ui.agent_chat_tool_running_for": "{time}째 실행 중"
},
"vi": {
"ui.agent_chat_answered_in": "Đã trả lời trong {time}",
"ui.agent_chat_ended_after": "Đã dừng sau {time}",
"ui.agent_chat_span_h": "{h} giờ {min} phút",
"ui.agent_chat_span_min": "{n} phút",
"ui.agent_chat_span_s": "{n} giây",
"ui.agent_chat_thinking_for": "Đang suy nghĩ {time}",
"ui.agent_chat_tool_running_for": "đang chạy {time}"
},
"zh": {
"ui.agent_chat_answered_in": "用时 {time} 完成回答",
"ui.agent_chat_ended_after": "{time} 后结束",
"ui.agent_chat_span_h": "{h} 小时 {min} 分钟",
"ui.agent_chat_span_min": "{n} 分钟",
"ui.agent_chat_span_s": "{n} 秒",
"ui.agent_chat_thinking_for": "已思考 {time}",
"ui.agent_chat_tool_running_for": "已运行 {time}"
}
}
@@ -1,34 +0,0 @@
{
"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": "עריכת אילוץ"
}
}
@@ -1,23 +0,0 @@
{
"en": {
"dialog.component_origin": "{placement} · {item}"
},
"ar": {
"dialog.component_origin": "{placement} · {item}"
},
"de": {
"dialog.component_origin": "{placement} · {item}"
},
"he": {
"dialog.component_origin": "{placement} · {item}"
},
"ja": {
"dialog.component_origin": "{placement} · {item}"
},
"vi": {
"dialog.component_origin": "{placement} · {item}"
},
"zh": {
"dialog.component_origin": "{placement} · {item}"
}
}
@@ -1,80 +0,0 @@
{
"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"
}
}
@@ -1,34 +0,0 @@
{
"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": "打开对话框时,按下按钮标签的首字母即可触发该按钮:如“取”对应“取消”。一个字母若匹配两个按钮则不起作用,获得焦点的文本框保留按键输入。关闭时:按键不影响按钮,与以前相同。"
}
}
@@ -1,50 +0,0 @@
{
"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."
}
}
@@ -1,42 +0,0 @@
{
"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": "拖动由球关节固定的零件时,它绕关节以最小的转动旋转,就像滚动一个与零件同样大小的球。关闭:抓取的点精确跟随指针,零件还可能绕该点自旋。"
}
}
-38
View File
@@ -1,38 +0,0 @@
{
"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": "编辑备注"
}
}
@@ -1,8 +0,0 @@
{
"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": "产品放置无效" }
}
@@ -1,38 +0,0 @@
{
"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})。"
}
}
@@ -1,240 +0,0 @@
{
"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
View File
@@ -4681,32 +4681,32 @@
"sketch.dof_left": "剩余 {count} 个自由度",
"sketch.dof_left_one": "剩余 {count} 个自由度",
"sketch.draw_curves": "绘制为 {linetype}",
"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.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.make_selection": "将选择转换为 {linetype}",
"sketch.open_ends": "开放 {count}",
"sketch.status_conflict": "约束冲突",
@@ -1,3 +0,0 @@
| 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) |
+1 -4
View File
@@ -144,9 +144,6 @@ 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)
}
@@ -800,7 +797,7 @@ impl Hub {
}
};
// Subscribed once the window named the design (not when it refused or timed out).
let sent = match joining.zip(document_of(&reply)) {
let sent = match joining.and_then(|counted| document_of(&reply).map(|seen| (counted, seen))) {
Some((counted, seen)) => match Subscription::start(documents, self.protocol.change_event(), counted, &out, seen, &reply) {
Some((subscription, sent)) => {
events = Some(subscription);
+16 -120
View File
@@ -47,41 +47,9 @@ 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 }
}
/// 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))
Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0), pbr: None }
}
pub fn opaque_surface(&self) -> crate::Result<Option<solidcraft_viewport::surface::OpaqueSurface>> {
if self.pbr.is_some() && self.opacity != 1.0 {
@@ -125,10 +93,6 @@ 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 {
@@ -138,17 +102,14 @@ impl ImportedLook {
return b.imported.clone();
}
let names = crate::naming::face_names(b);
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,
}))
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 }))
}
}
@@ -356,11 +317,7 @@ 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))?;
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))
Some(Look::custom(color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8), self.model_body_opacity(body)))
})
}
@@ -423,17 +380,14 @@ impl Document {
Vec::new()
} else {
match b.body.paint() {
Some(p) => p.faces.iter().map(|f| (f.face, Look::imported(f.color.map(to8), f.opacity as f64, f.name.as_deref()))).collect(),
Some(p) => p.faces.iter().map(|f| (f.face, Look::custom(f.color.map(to8), f.opacity as f64))).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)| {
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))))
})
.filter_map(|(i, n)| imp.faces.get(crate::naming::strip_piece(n)).map(|(c, o)| (i, Look::custom(c.map(to8), *o as f64))))
.collect(),
None => Vec::new(),
},
@@ -578,9 +532,9 @@ mod tests {
.faces(0.01)
.unwrap()
.iter()
.map(|face| solidcraft_kernel::FacePaint { face: face.index, color: [1., 0., 0.], opacity: 0.5, name: None })
.map(|face| solidcraft_kernel::FacePaint { face: face.index, color: [1., 0., 0.], opacity: 0.5 })
.collect();
crate::ModelBody::new("Imported".into(), body.with_paint(Some(solidcraft_kernel::Paint { opacity: 1., faces, name: None })), 1)
crate::ModelBody::new("Imported".into(), body.with_paint(Some(solidcraft_kernel::Paint { opacity: 1., faces })), 1)
}
fn check_imported_appearance_precedence(body: &crate::ModelBody) {
@@ -588,7 +542,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::imported([255, 0, 0], 0.5, None)));
assert!(imported.iter().all(|(_, look)| *look == Look::custom([255, 0, 0], 0.5)));
let body_look = Look::custom([0, 0, 255], 0.8);
doc.appearances.bodies.insert(body.definition_name().into(), body_look.clone());
@@ -611,7 +565,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::imported([255, 0, 0], 0.5, None) }));
assert!(restored.iter().all(|(face, look)| *look == if *face == 0 { face_look.clone() } else { Look::custom([255, 0, 0], 0.5) }));
}
#[test]
@@ -636,64 +590,6 @@ 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());
+21 -56
View File
@@ -97,14 +97,7 @@ pub fn clone_expression(source: &str, parameters: &BTreeMap<String, String>) ->
result
}
fn remap_geo(
g: &mut crate::construct::GeoRef,
ids: &BTreeMap<u64, u64>,
bodies: &BTreeMap<String, String>,
depth: usize,
occurrences: &BTreeMap<u64, u64>,
doc: &crate::Document,
) -> crate::Result<()> {
fn remap_geo(g: &mut crate::construct::GeoRef, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>, depth: usize) -> crate::Result<()> {
use crate::construct::GeoRef;
if depth > 64 {
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
@@ -121,38 +114,29 @@ fn remap_geo(
*n = remap_topology(n, ids, bodies);
}
}
GeoRef::Plane { plane } => remap_plane(plane, ids, bodies, depth + 1, occurrences, doc)?,
GeoRef::Datum { datum } => remap_datum_path(datum, occurrences, doc)?,
GeoRef::Plane { plane } => remap_plane(plane, ids, bodies, depth + 1)?,
_ => {}
}
Ok(())
}
fn remap_plane(
p: &mut PlaneRef,
ids: &BTreeMap<u64, u64>,
bodies: &BTreeMap<String, String>,
depth: usize,
occurrences: &BTreeMap<u64, u64>,
doc: &crate::Document,
) -> crate::Result<()> {
fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>, depth: usize) -> crate::Result<()> {
if depth > 64 {
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
}
match p {
PlaneRef::Datum { datum } => remap_datum_path(datum, occurrences, doc)?,
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => remap_plane(base, ids, bodies, depth + 1, occurrences, doc)?,
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => remap_plane(base, ids, bodies, depth + 1)?,
PlaneRef::ThroughPoint { base, point } => {
remap_plane(base, ids, bodies, depth + 1, occurrences, doc)?;
remap_geo(point, ids, bodies, depth + 1, occurrences, doc)?;
remap_plane(base, ids, bodies, depth + 1)?;
remap_geo(point, ids, bodies, depth + 1)?;
}
PlaneRef::AtAngle { base, line, pivot, .. } => {
remap_plane(base, ids, bodies, depth + 1, occurrences, doc)?;
remap_plane(base, ids, bodies, depth + 1)?;
if let Some(g) = line {
remap_geo(g, ids, bodies, depth + 1, occurrences, doc)?;
remap_geo(g, ids, bodies, depth + 1)?;
}
if let Some(g) = pivot {
remap_geo(g, ids, bodies, depth + 1, occurrences, doc)?;
remap_geo(g, ids, bodies, depth + 1)?;
}
}
PlaneRef::Face { name, anchor, .. } => {
@@ -165,28 +149,13 @@ fn remap_plane(
}
_ => {
for r in p.refs_mut() {
remap_geo(r, ids, bodies, depth + 1, occurrences, doc)?;
remap_geo(r, ids, bodies, depth + 1)?;
}
}
}
Ok(())
}
/// A datum reference names an exact placement by its complete occurrence path. A copy within one
/// design rewrites the path to the copy's own walk (the copied subtree's source ancestors are not
/// in the map); an empty occurrence map (a paste from another design) or a path that leaves the
/// copied tree is refused - the reference could not follow, and a dangling one would resolve
/// against whatever then sits at those ids.
fn remap_datum_path(d: &mut crate::construct::DatumRef, occurrences: &BTreeMap<u64, u64>, doc: &crate::Document) -> crate::Result<()> {
let last = d.path.last().copied().ok_or_else(|| crate::DocError::Invalid("a copied datum names no placement".into()))?;
let new = occurrences
.get(&last)
.copied()
.ok_or_else(|| crate::DocError::Invalid("the copied reference names a placement outside the copied tree".into()))?;
d.path = doc.occurrence_path(new).ok_or_else(|| crate::DocError::Invalid("the copied placement's path is broken".into()))?;
Ok(())
}
pub(crate) fn remap_link(
source: &mut solidcraft_sketch::LinkSource,
ids: &BTreeMap<u64, u64>,
@@ -235,8 +204,6 @@ impl crate::Feature {
names: &BTreeMap<String, String>,
bodies: &BTreeMap<String, String>,
parameters: &BTreeMap<String, String>,
occurrences: &BTreeMap<u64, u64>,
doc: &crate::Document,
) -> crate::Result<()> {
use FeatureKind as K;
match &self.kind {
@@ -299,7 +266,7 @@ impl crate::Feature {
self.kind.remap_bodies(bodies);
match &mut self.kind {
K::Sketch { plane, sketch } => {
remap_plane(plane, ids, bodies, 0, occurrences, doc)?;
remap_plane(plane, ids, bodies, 0)?;
for c in &mut sketch.constraints {
if let Some(n) = &mut c.param {
mapped_name(n, parameters);
@@ -323,33 +290,31 @@ impl crate::Feature {
}
K::FrameEndCap { ends, .. } => {
for end in ends {
remap_plane(&mut end.plane, ids, bodies, 0, occurrences, doc)?;
remap_plane(&mut end.plane, ids, bodies, 0)?;
}
}
K::ConstructionPlane { plane }
| K::Split { plane, .. }
| K::SplitFace { plane, .. }
| K::Mirror { plane, .. }
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, bodies, 0, occurrences, doc)?,
K::Draft { neutral, .. } => remap_plane(neutral, ids, bodies, 0, occurrences, doc)?,
K::ReplaceFace { target, .. } => remap_plane(target, ids, bodies, 0, occurrences, doc)?,
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0, occurrences, doc)?,
K::Thread { up_to: Some(crate::ThreadLimit::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0, occurrences, doc)?,
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, bodies, 0)?,
K::Draft { neutral, .. } => remap_plane(neutral, ids, bodies, 0)?,
K::ReplaceFace { target, .. } => remap_plane(target, ids, bodies, 0)?,
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0)?,
K::Thread { up_to: Some(crate::ThreadLimit::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0)?,
K::ConstructionAxis { def } => {
for r in def.refs_mut() {
remap_geo(r, ids, bodies, 0, occurrences, doc)?;
remap_geo(r, ids, bodies, 0)?;
}
}
K::ConstructionPoint { def } => {
for r in def.refs_mut() {
remap_geo(r, ids, bodies, 0, occurrences, doc)?;
remap_geo(r, ids, bodies, 0)?;
}
}
K::Revolve { axis: crate::AxisRef::Model { geo }, .. }
| K::SurfaceRevolve { axis: crate::AxisRef::Model { geo }, .. }
| K::Pattern { pattern: crate::PatternKind::Circular { axis_ref: Some(geo), .. }, .. } => {
remap_geo(geo, ids, bodies, 0, occurrences, doc)?
}
| K::Pattern { pattern: crate::PatternKind::Circular { axis_ref: Some(geo), .. }, .. } => remap_geo(geo, ids, bodies, 0)?,
// An Align frame's joint origin finds its faces and edges by name, as the feature's
// own face names are found.
K::Align { from_frame, to_frame, .. } => {
@@ -452,7 +417,7 @@ mod clone_tests {
value["name"] = json!("Copy");
let mut feature: crate::Feature = serde_json::from_value(value).unwrap();
let before = serde_json::to_string(&feature).unwrap();
feature.remap_clone(&BTreeMap::new(), &names, &BTreeMap::new(), &BTreeMap::new(), &BTreeMap::new(), &crate::Document::default()).unwrap();
feature.remap_clone(&BTreeMap::new(), &names, &BTreeMap::new(), &BTreeMap::new()).unwrap();
assert_eq!(serde_json::to_string(&feature).unwrap(), before.replace("\"Old\"", "\"New\""));
}
}
+12 -99
View File
@@ -38,9 +38,6 @@ 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>,
@@ -67,59 +64,10 @@ 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
@@ -216,25 +164,13 @@ 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<'a>(&mut self, row: impl Into<RowKey<'a>>) -> Result<()> {
pub fn apply_configuration(&mut self, row: &str) -> Result<()> {
self.apply_configuration_with_trace(row).map(|_| ())
}
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();
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}`")))?;
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()));
}
@@ -314,13 +250,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_row() else { return false };
let Some(r) = self.configs.active.as_ref().and_then(|a| self.configs.rows.iter().find(|r| r.name == *a)) 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.id).is_err() {
if expected.apply_configuration(&r.name).is_err() {
return false;
}
return self.configs.columns.iter().all(|column| self.config_current(column) == expected.config_current(column));
@@ -355,11 +291,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 { id: 1, name: "Reordered".into(), cells: vec!["b".into(), "2 mm".into()] });
d.configs.rows.push(ConfigRow { 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 { id: 2, name: "Cycle".into(), cells: vec!["b".into(), "a".into()] });
d.configs.rows.push(ConfigRow { name: "Cycle".into(), cells: vec!["b".into(), "a".into()] });
let before = d.clone();
assert!(d.apply_configuration("Cycle").is_err());
assert_eq!(d, before);
@@ -387,50 +323,28 @@ 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 { id: 3, name: "Default".into(), cells: vec!["1/".into()] });
d.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["1/".into()] });
d.apply_configuration("Default").unwrap();
assert!(d.configuration_matches());
d.configs.rows.push(ConfigRow { id: 4, name: "Different error".into(), cells: vec!["missing + 1".into()] });
d.configs.rows.push(ConfigRow { 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 { 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.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.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 { id: 7, name: "Bad".into(), cells: vec!["1/".into()] });
d.configs.rows.push(ConfigRow { 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");
@@ -442,7 +356,6 @@ 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()],
});
-1
View File
@@ -988,7 +988,6 @@ mod tests {
sketch,
report,
profiles: Vec::new(),
profiles_3d: Vec::new(),
ends: Default::default(),
}
}
+1 -2
View File
@@ -147,7 +147,6 @@ 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()],
}],
@@ -204,7 +203,7 @@ mod tests {
certificate: None,
}),
],
rows: vec![ConfigRow { id: 2, name: "Default".into(), cells: vec!["2 mm".into(), String::new()] }],
rows: vec![ConfigRow { name: "Default".into(), cells: vec!["2 mm".into(), String::new()] }],
..Default::default()
};
let original = table.clone();
+2 -2
View File
@@ -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 { id: 1, name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
doc.configs.rows.push(ConfigRow { 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 { id: 2, name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
doc.configs.rows.push(ConfigRow { 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() });
+4 -40
View File
@@ -1019,12 +1019,6 @@ 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.
@@ -1093,19 +1087,13 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "RailFit::is_scale")]
rail_fit: RailFit,
},
/// Sweep profiles along a chain of sketch curves, or along a chain of a body's edges.
/// Sweep profiles along a chain of sketch curves.
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,
@@ -1716,10 +1704,6 @@ pub enum FeatureKind {
#[serde(default = "all_profiles")]
#[walk(profiles_of = sketch)]
profiles: ProfileSel,
/// Indices into the sketch's planar 3D loops (`sketch.inspect`'s `profiles_3d`): each is
/// patched in the plane it lies in (#741).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
profiles_3d: Vec<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -1827,12 +1811,6 @@ 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,
@@ -3417,9 +3395,6 @@ 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
@@ -3449,9 +3424,6 @@ pub struct Visibility {
/// The browser's Origin folder is hidden (`None`: the app's preference decides).
#[serde(skip_serializing_if = "Option::is_none")]
pub origin_hidden: Option<bool>,
/// Component Origin folders explicitly shown, by complete occurrence path. Hidden by default.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub shown_component_origins: Vec<Vec<u64>>,
/// Finished sketches whose closed profiles are not shaded, by feature id.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub hidden_profiles: Vec<u64>,
@@ -3479,7 +3451,6 @@ impl Visibility {
&& self.shown_sketches.is_empty()
&& self.hidden_joints.is_empty()
&& self.origin_hidden.is_none()
&& self.shown_component_origins.is_empty()
&& self.hidden_profiles.is_empty()
&& self.shown_dimensions.is_empty()
&& self.locked_bodies.is_empty()
@@ -3662,12 +3633,10 @@ 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()))?;
@@ -3719,7 +3688,6 @@ impl Document {
timeline_groups: Vec::new(),
browser_order: Default::default(),
named_views: Vec::new(),
notes: Vec::new(),
visibility: Visibility::default(),
}
}
@@ -3923,14 +3891,10 @@ 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, name: l.file_name() })
.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 }).collect();
let opacity = self.model_body_opacity(b) as f32;
// 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 };
let paint = solidcraft_kernel::Paint { opacity, faces };
if c != b.body.color() || b.body.paint() != Some(&paint) {
b.body = b.body.clone().with_color(c).with_paint(Some(paint));
}
+152 -1133
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -142,7 +142,6 @@ 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.
@@ -1456,7 +1455,6 @@ 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");
@@ -1574,7 +1572,6 @@ 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");
+27 -25
View File
@@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex};
use solidcraft_kernel as kernel;
use super::{CacheEntry, EvalCache, FeatureResult, KeptSlot, ModelBody, ModelState};
use super::{CachedEval, 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, CacheEntry)>,
held: Vec<(usize, u64, CachedEval)>,
/// Features whose check refused their bodies, to fail again as it did.
refused: HashMap<usize, Failure>,
}
@@ -92,11 +92,6 @@ 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)
}
@@ -130,11 +125,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, entry: CacheEntry, cache: &Mutex<EvalCache>, kept: &KeptSlot) {
pub fn store(&mut self, index: usize, key: u64, entry: CachedEval, cache: &Mutex<EvalCache>, kept: &KeptSlot) {
if self.running.front().is_some_and(|r| r.index <= index) {
self.held.push((index, entry));
self.held.push((index, key, entry));
} else {
entry.put(cache, kept);
put(key, entry, cache, kept);
}
}
@@ -155,15 +150,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, entry) in std::mem::take(&mut self.held) {
for (i, key, entry) in std::mem::take(&mut self.held) {
if i < upto {
entry.put(cache, kept);
put(key, entry, cache, kept);
} else {
self.held.push((i, entry));
self.held.push((i, key, entry));
}
}
}
@@ -176,9 +171,17 @@ 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;
@@ -202,9 +205,8 @@ mod tests {
Failure { error: KernelError::Failed(String::new()), warning: Some("kept".into()), import_report: None, repair: None, ms: 1.0 }
}
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 entry() -> CachedEval {
CachedEval { error: None, warning: None, import_report: None, repair: None, ms: 0.0, state: Arc::new(ModelState::default()), bytes: 1 }
}
fn checks() -> Checks {
@@ -227,10 +229,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, entry(10), &cache, &kept);
c.store(0, 10, entry(), &cache, &kept);
assert!(c.start(1, saved(), failure(), later(Err(KernelError::Failed("refused".into())))).is_ok());
c.store(1, entry(11), &cache, &kept);
c.store(2, entry(12), &cache, &kept);
c.store(1, 11, entry(), &cache, &kept);
c.store(2, 12, entry(), &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);
@@ -238,7 +240,7 @@ mod tests {
assert_eq!(refused.failure.warning.as_deref(), Some("kept"));
{
let m = cache.lock().unwrap();
assert!(m.states.map.contains_key(&10) && !m.states.map.contains_key(&11) && !m.states.map.contains_key(&12));
assert!(m.map.contains_key(&10) && !m.map.contains_key(&11) && !m.map.contains_key(&12));
}
c.refuse(1, refused.failure);
assert!(c.refused(1).is_some() && c.refused(1).is_none());
@@ -251,11 +253,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, entry(10), &cache, &kept);
c.store(0, 10, entry(), &cache, &kept);
assert_eq!(cache.lock().unwrap().len(), 1);
assert!(c.start(1, saved(), failure(), later(Ok(()))).is_ok());
c.store(1, entry(11), &cache, &kept);
c.store(2, entry(12), &cache, &kept);
c.store(1, 11, entry(), &cache, &kept);
c.store(2, 12, entry(), &cache, &kept);
assert!(c.settle(true, &cache, &kept).is_none());
assert_eq!(cache.lock().unwrap().len(), 3);
}
+6 -41
View File
@@ -8,20 +8,13 @@ use solidcore_record::Seg2;
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
match &f.kind {
FeatureKind::Patch { sketch, profiles, profiles_3d, body, edges, crown, continuity, points } => {
FeatureKind::Patch { sketch, profiles, body, edges, crown, continuity, points } => {
let mut made = Vec::new();
if let Some(sk) = sketch {
let ss = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
// An empty index list means the caller asked only for 3D loops (#741).
if !matches!(profiles, ProfileSel::Indices { indices } if indices.is_empty()) {
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
for r in &regions {
made.push(kernel::patch_region(&ss.plane, r)?);
}
}
for &i in profiles_3d {
let p = ss.profiles_3d.get(i).ok_or_else(|| DocError::Invalid(format!("sketch {sk} has no 3D profile {i}")))?;
made.push(kernel::patch_region(&p.plane, &p.region)?);
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
for r in &regions {
made.push(kernel::patch_region(&ss.plane, r)?);
}
}
if !edges.is_empty() {
@@ -40,7 +33,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
made.push(kernel::patch_edges_with(&b.body, &crate::plain(edges), &spec)?);
}
if made.is_empty() {
return Err(DocError::Invalid("a patch needs a sketch region, a 3D sketch loop, or a loop of edges".into()));
return Err(DocError::Invalid("a patch needs a sketch region or a loop of edges".into()));
}
for (k, b) in made.into_iter().enumerate() {
let name = new_name(st, f, k);
@@ -379,36 +372,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::SurfaceSweep {
sketch,
profiles,
curves,
path_sketch,
path,
path_body,
path_edges,
orientation,
twist,
scale,
rails,
rail_fit,
} => {
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, 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.
+3 -3
View File
@@ -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, recipe: None };
input = Traced { body: filled, lineage };
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, recipe: None };
input = Traced { body: cut, lineage };
}
}
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, recipe: None };
let nb = Traced { body: nb, lineage };
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);
+3 -10
View File
@@ -819,9 +819,8 @@ 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, occurrence or configuration row id".into()));
return Err(DocError::Invalid("damaged design: duplicate feature, component or occurrence 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 {
@@ -2213,10 +2212,9 @@ 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 { 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()] },
ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "false".into()] },
ConfigRow { 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();
@@ -2240,7 +2238,6 @@ 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()],
}];
@@ -2552,15 +2549,12 @@ 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]});
}
@@ -2571,7 +2565,6 @@ 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}");
@@ -1,151 +0,0 @@
//! 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");
}
}
@@ -1,47 +0,0 @@
//! 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"}));
}
}
@@ -1,5 +0,0 @@
//! 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.
@@ -1,4 +0,0 @@
//! Component Origin folders explicitly shown save their complete occurrence paths
//! (`Visibility::shown_component_origins`; they are hidden by default). Older designs have none:
//! nothing new, they round-trip as they are. The last release warns about the newer format
//! number, opens what it can read and keeps the rest.
@@ -1,9 +0,0 @@
//! 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.
-4
View File
@@ -1,4 +0,0 @@
//! 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.
@@ -1,5 +0,0 @@
//! 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.
@@ -1,5 +0,0 @@
//! A surface patch can patch planar loops of 3D sketch curves: `profiles_3d` holds indices into
//! the sketch's planar 3D loops (`sketch.inspect`'s `profiles_3d`), each patched in the plane it
//! lies in (#741). Older designs need no transformation: without the field the patch takes its
//! profiles from the sketch's 2D profile list exactly as before. The last release refuses a
//! design saved with this (the newer format number).
-86
View File
@@ -429,75 +429,6 @@ 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 {
@@ -531,17 +462,6 @@ 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),
@@ -2607,11 +2527,6 @@ 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"]
@@ -2841,7 +2756,6 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
})
.collect();
let links =
-1
View File
@@ -945,7 +945,6 @@ 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.
-1
View File
@@ -94,7 +94,6 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
}
}
-2
View File
@@ -49,8 +49,6 @@ 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;
+7 -15
View File
@@ -148,9 +148,6 @@ 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 {
@@ -158,13 +155,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, recipe: None }
Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body }
}
/// 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, recipe: None },
Some(roles) => Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body },
None => Traced::unnamed(body),
}
}
@@ -173,19 +170,14 @@ 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(&copy, &[&l.copied(feature, instance)]));
Traced { body: copy, lineage, recipe: None }
}
/// This tool, built by `recipe`.
pub fn with_recipe(self, recipe: Option<u64>) -> Traced {
Traced { recipe, ..self }
Traced { body: copy, lineage }
}
/// `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, recipe: None }
Traced { body: result, lineage }
}
/// `result`, made by one kernel operation from this body alone. Where that operation's
@@ -206,7 +198,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, recipe: None }
Traced { body: result, lineage }
}
}
@@ -579,14 +571,14 @@ impl crate::ModelBody {
Made::Traced(l) => Some(l.as_ref().clone()),
Made::Unknown => None,
};
Traced { body: self.body.clone(), lineage, recipe: None }
Traced { body: self.body.clone(), lineage }
}
/// 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, recipe: None }
Traced { body: self.body.clone(), lineage }
}
/// The lineage a feature left this body with, when it made or changed it with one.
+4 -42
View File
@@ -234,17 +234,6 @@ 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);
@@ -420,14 +409,12 @@ impl SavedModel {
let mut sketches = Vec::new();
for s in list("sketches")? {
let sketch: solidcraft_sketch::Sketch = serde_json::from_value(parse(s.get("sketch"))?).map_err(|_| bad("saved model: sketch"))?;
let plane: Plane = serde_json::from_value(parse(s.get("plane"))?).map_err(|_| bad("saved model: sketch"))?;
sketches.push(Arc::new(SolvedSketch {
feature: s.get("feature").and_then(Json::as_u64).ok_or_else(|| bad("saved model: sketch"))?,
name: text(s.get("name"))?,
plane,
plane: serde_json::from_value(parse(s.get("plane"))?).map_err(|_| bad("saved model: sketch"))?,
report: serde_json::from_value(parse(s.get("report"))?).map_err(|_| bad("saved model: sketch"))?,
profiles: find_profiles(&sketch),
profiles_3d: solidcraft_sketch::find_planar_3d_profiles(&sketch, plane),
ends: solidcraft_sketch::loose_ends(&sketch),
sketch,
}));
@@ -458,34 +445,12 @@ 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();
@@ -977,10 +942,8 @@ impl Model {
before = r.state.clone();
continue;
}
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(
let local = fingerprint(doc, 0, f, &vals, false, &model.import_digests) ^ placement_digest(doc, f, Some(&before));
cache.put(
cache_key(f, r.fingerprint, local, ctx, &before),
CachedEval {
error: None,
@@ -988,10 +951,9 @@ 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();
}
-39
View File
@@ -350,45 +350,6 @@ fn an_edge_named_by_a_piece_of_a_face_that_is_whole_again_is_found() {
assert!(crate::name_lookup::locate_edge(body, &gone, near).is_err(), "{gone}");
}
/// #13464: a mesh's flat patch outline drops a corner part way along a side, even when single
/// precision has moved it off the line (as an STL's corners are), and when the loop starts there.
#[test]
fn a_mesh_outline_drops_a_rounded_corner_part_way_along_a_side() {
let single = |x: f64| f64::from(x as f32);
// a, c and d make a triangle; b lies on its side a–c up to single-precision rounding.
let a = Vec3::new(0.0, 0.0, 0.0);
let b = Vec3::new(single(0.3), single(0.33), 0.0);
let c = Vec3::new(1.0, single(1.1), 0.0);
let d = Vec3::new(0.0, 2.0, 0.0);
let off = (b - a).cross(c - a).len() / (c - a).len();
assert!(off > 0.0 && off < 1e-7, "b is off the line only by rounding: {off}");
// b first, so the outline's loop starts at it.
let mesh =
solidcraft_geom::Mesh { positions: vec![b, c, d, a], triangles: vec![[0, 1, 2], [0, 2, 3]], tri_face: vec![0, 0], ..Default::default() };
let loops = crate::project3d::flat_outline(&mesh, Vec3::new(0.3, 0.8, 0.0));
assert_eq!(loops.len(), 1, "{loops:?}");
let corners = loops.first().unwrap();
assert_eq!(corners.len(), 4, "three corners and the first again: {corners:?}");
assert!(!corners.iter().any(|p| p.dist(b) < 1e-12), "the corner on the side is gone: {corners:?}");
for p in [a, c, d] {
assert!(corners.iter().any(|q| q.dist(p) < 1e-12), "{p:?} is a corner: {corners:?}");
}
}
/// #13464: far from the origin, a small flat patch keeps its corners. A tolerance that grew with
/// the distance from the origin alone took every corner of a 1 × 1 square at x = 1 000 000 for one
/// on a straight side, and the face projected to nothing.
#[test]
fn a_small_mesh_patch_far_from_the_origin_keeps_its_corners() {
let x = 1_000_000.0;
let corners = [Vec3::new(x, 0.0, 0.0), Vec3::new(x + 1.0, 0.0, 0.0), Vec3::new(x + 1.0, 1.0, 0.0), Vec3::new(x, 1.0, 0.0)];
let mesh =
solidcraft_geom::Mesh { positions: corners.to_vec(), triangles: vec![[0, 1, 2], [0, 2, 3]], tri_face: vec![0, 0], ..Default::default() };
let loops = crate::project3d::flat_outline(&mesh, Vec3::new(x + 0.5, 0.5, 0.0));
assert_eq!(loops.len(), 1, "{loops:?}");
assert_eq!(loops.first().map(Vec::len), Some(5), "four corners and the first again: {loops:?}");
}
/// A ring turned from a rectangle on YZ about Z: each side of the rectangle makes one round face.
fn ring() -> Document {
let doc = Document {
-1
View File
@@ -1606,7 +1606,6 @@ mod tests {
sketch,
report,
profiles,
profiles_3d: Vec::new(),
ends: Default::default(),
};
let full_scan = |q: Vec2| {
-76
View File
@@ -1,76 +0,0 @@
//! 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)));
}
}
-1
View File
@@ -161,7 +161,6 @@ 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
+2 -11
View File
@@ -545,16 +545,7 @@ mod tests {
sketch.add_circle(Vec2::new(0., 0.), 3., None, Some("circle")).unwrap();
let report = solve(&mut sketch);
let profiles = find_profiles(&sketch);
let ss = SolvedSketch {
feature: 1,
name: "ordinary circle".into(),
plane: Plane::XY,
sketch,
report,
profiles,
profiles_3d: Vec::new(),
ends: Default::default(),
};
let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles, ends: Default::default() };
assert_eq!(ss.profiles.len(), 1);
let indices = vec![0; 600];
let selection = ProfileSel::Indices { indices: indices.clone() };
@@ -602,7 +593,7 @@ mod tests {
}
let report = solve(&mut sketch);
let profiles = find_profiles(&sketch);
SolvedSketch { feature: 1, name: "S".into(), plane: Plane::XY, sketch, report, profiles, profiles_3d: Vec::new(), ends: Default::default() }
SolvedSketch { feature: 1, name: "S".into(), plane: Plane::XY, sketch, report, profiles, ends: Default::default() }
}
#[test]
+3 -10
View File
@@ -618,8 +618,7 @@ fn circular(samples: &[Vec2]) -> Option<(LinkGeom, f64)> {
sweep += d;
}
let turn = sweep.abs();
// A NaN turn is not refused here, as before.
if !turn.is_nan() && !(CIRCLE_MIN_TURN..=std::f64::consts::TAU + 1e-6).contains(&turn) {
if turn > std::f64::consts::TAU + 1e-6 || turn < CIRCLE_MIN_TURN {
return None;
}
if first.dist(last) <= tol {
@@ -1489,15 +1488,9 @@ const MESH_POLYGON_CORNERS: usize = 12;
/// [`MESH_POLYGON_CORNERS`], all on one circle, is a tessellated circle, an ellipse on a slanted
/// plane; and the sides of a polygon stay lines, never one fitted spline (#13464). A mesh file
/// keeps its corners in single precision, so they lie on the circle within about 1e-7 of their
/// distance from the origin, not of the circle's size; and never more loosely than 1e-4 of the
/// outline's own size, so a small outline far from the origin is not taken for a circle it misses.
/// size, not of the circle's.
fn fit_mesh_outline(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
let (lo, hi) = pts.iter().fold(
(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY)),
|(lo, hi), p| (lo.min(*p), hi.max(*p)),
);
let size = lo.dist(hi);
let within = (pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6).min(size * 1e-4);
let within = pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6;
if pts.len() > MESH_POLYGON_CORNERS + 1
&& let Some(e) = tilted_circle(plane, pts, Some(within))
{
+4 -13
View File
@@ -379,13 +379,7 @@ pub(crate) fn flat_outline(m: &Mesh, at: Vec3) -> Vec<Vec<Vec3>> {
steps += 1;
}
if v == start && pts.len() >= 4 {
// A corner off the line within the single precision a mesh file keeps is on it, but
// never one farther off than 1e-4 of the loop's own size: far from the origin a small
// patch's real corners stay.
let far = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let (lo, hi) = pts.iter().fold((far, -far), |(lo, hi), p| (lo.min(*p), hi.max(*p)));
let precision = (pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6).min(lo.dist(hi) * 1e-4);
loops.extend(turning_corners(&pts, precision));
loops.extend(turning_corners(&pts, size * 1e-9));
}
}
}
@@ -394,18 +388,15 @@ pub(crate) fn flat_outline(m: &Mesh, at: Vec3) -> Vec<Vec<Vec3>> {
/// A closed loop (its first point again at its end) without the corners where it runs straight
/// on: a mesh's outline often has a corner part way along a side, where a triangle of the patch
/// meets it. A corner within `tol` of the line through its neighbours runs straight on. None when
/// fewer than three corners turn.
/// meets it. None when fewer than three corners turn.
fn turning_corners(pts: &[Vec3], tol: f64) -> Option<Vec<Vec3>> {
let ring = pts.get(..pts.len().saturating_sub(1))?;
let n = ring.len();
let turns = |i: usize| {
let (prev, here, next) = (ring.get((i + n - 1) % n), ring.get(i), ring.get((i + 1) % n));
let (Some(prev), Some(here), Some(next)) = (prev, here, next) else { return false };
// How far the corner lies off the line from the one before it to the one after.
let span = *next - *prev;
let off = (*here - *prev).cross(span).len() / span.len().max(1e-300);
span.len() <= tol || off > tol
let (a, b) = (*here - *prev, *next - *here);
a.cross(b).len() > tol * a.len().max(b.len()).max(1.0)
};
let mut out: Vec<Vec3> = (0..n).filter(|i| turns(*i)).filter_map(|i| ring.get(i).copied()).collect();
if out.len() < 3 {
+2 -397
View File
@@ -294,11 +294,10 @@ 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().set_budget(3 * m.state().retained_bytes());
m.cache.lock().unwrap().budget = 3 * m.state().retained_bytes();
doc.set_param("h", "12 mm", None, None).unwrap();
m.evaluate(&doc);
// Only the box reading `h` is made again (#6183): the others' changes are reused.
assert_eq!(m.last_recomputed, 1);
assert_eq!(m.last_recomputed, 30);
doc.set_param("h", "10 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (0, 30));
@@ -448,400 +447,6 @@ fn rolled_results_never_mask_edits_or_disabled_reuse() {
}
}
fn add_in(doc: &mut Document, component: u64, name: &str, kind: serde_json::Value) {
let kind: FeatureKind = serde_json::from_value(kind).unwrap();
let id = doc.add_feature(kind, Some(name)).unwrap();
doc.feature_mut(id).unwrap().component = component;
}
/// A plate in one component, then a rod in another, slotted by a box cut patterned round it.
fn two_parts(slot_targets: &[&str]) -> Document {
let mut doc = Document::new("two parts");
doc.set_param("w", "40 mm", None, None).unwrap();
doc.set_param("slot", "1 mm", None, None).unwrap();
let plate = doc.add_component(Some("Plate"), 0).unwrap();
let rod = doc.add_component(Some("Rod"), 0).unwrap();
add_in(
&mut doc,
plate,
"Plate",
serde_json::json!({"type": "box", "corner": ["0", "0", "0"], "length": "w", "width": "30", "height": "5", "operation": "new_body"}),
);
add_in(
&mut doc,
rod,
"Rod",
serde_json::json!({"type": "cylinder", "base": ["0", "0", "0"], "axis": [0.0, 0.0, 1.0], "radius": "4", "height": "20", "operation": "new_body"}),
);
add_in(
&mut doc,
rod,
"Slot",
serde_json::json!({"type": "box", "corner": ["3", "-0.5", "2"], "length": "2", "width": "slot", "height": "20", "operation": "cut"}),
);
for f in doc.features.iter_mut().filter(|f| f.name == "Plate" || f.name == "Rod") {
f.body_names = vec![f.name.clone()];
}
if let Some(f) = doc.features.iter_mut().find(|f| f.name == "Slot") {
f.participants = slot_targets.iter().map(|t| t.to_string()).collect();
}
let slot = doc.find_feature("Slot").map_or(0, |f| f.id);
add_in(
&mut doc,
rod,
"Slots",
serde_json::json!({"type": "pattern", "features": [slot], "pattern": {"type": "circular", "origin": [0.0, 0.0, 0.0], "axis": [0.0, 0.0, 1.0], "count": "4", "angle": "360 deg"}}),
);
doc
}
/// The bodies as an evaluation from scratch makes them: names, volumes, face names.
fn summary(st: &ModelState) -> Vec<(String, u64, i64, Vec<String>)> {
st.bodies
.iter()
.map(|b| {
let volume = measure(&b.body).unwrap().volume;
(b.name.clone(), b.component, (volume * 1e6).round() as i64, crate::naming::face_names(b).to_vec())
})
.collect()
}
fn from_scratch(doc: &Document) -> Vec<(String, u64, i64, Vec<String>)> {
let mut m = Model::new();
m.no_cache = true;
m.evaluate(doc);
summary(&m.state())
}
/// An edit in one component leaves another component's features after it as they were: they
/// are restored, not recomputed, and give what evaluating from scratch gives.
#[test]
fn edit_in_one_component_keeps_the_others_results() {
let mut doc = two_parts(&[]);
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
assert_eq!(m.last_recomputed, 4);
doc.set_param("w", "50 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (1, 3));
assert_eq!(summary(&m.state()), from_scratch(&doc));
// An edit in the rod's own component recomputes its features.
doc.set_param("slot", "1.5 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
assert_eq!(summary(&m.state()), from_scratch(&doc));
doc.set_param("w", "60 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (1, 3));
assert_eq!(summary(&m.state()), from_scratch(&doc));
}
/// A sketch on a fixed plane, the new body extruded from it and that body moved read nothing
/// of another component either.
#[test]
fn sketch_extrude_and_move_keep_their_results() {
let mut doc = two_parts(&[]);
let rod = doc.find_component("Rod").unwrap();
let mut sk = Sketch::new();
sk.add_circle(Vec2::new(20.0, 0.0), 2.0, None, None).unwrap();
let id = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, Some("Peg sketch")).unwrap();
doc.feature_mut(id).unwrap().component = rod;
add_in(
&mut doc,
rod,
"Peg",
serde_json::json!({"type": "extrude", "sketch": id, "profiles": {"type": "all"}, "extent": {"convention": "total_distance", "direction": "positive", "distance": "8"}, "operation": "new_body"}),
);
if let Some(f) = doc.features.iter_mut().find(|f| f.name == "Peg") {
f.body_names = vec!["Peg".into()];
}
add_in(&mut doc, rod, "Peg up", serde_json::json!({"type": "move", "bodies": ["Peg"], "translate": ["0", "0", "slot * 3"]}));
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
doc.set_param("w", "50 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (1, 6));
assert_eq!(summary(&m.state()), from_scratch(&doc));
// The move follows its own parameter.
doc.set_param("slot", "2 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (3, 2));
assert_eq!(summary(&m.state()), from_scratch(&doc));
}
/// A feature that cuts a body of another component reads it: it is recomputed when that body
/// changes, and so is what follows it in its own component.
#[test]
fn a_cut_of_another_components_body_follows_it() {
let mut doc = two_parts(&["Plate", "Rod"]);
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
doc.set_param("w", "50 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (3, 1));
assert_eq!(summary(&m.state()), from_scratch(&doc));
}
/// A pattern of a cut reads the bodies its source cuts, as the copies cut them: the source's
/// participants, though the pattern names none and that body is in another component. The plate
/// lies clear of every copy at first; moved into one by `w`, it must be cut again.
#[test]
fn a_pattern_reads_the_bodies_its_source_cuts() {
let mut doc = two_parts(&["Plate"]);
if let Some(f) = doc.features.iter_mut().find(|f| f.name == "Plate") {
f.kind = serde_json::from_value(
serde_json::json!({"type": "box", "corner": ["-20", "w", "0"], "length": "40", "width": "30", "height": "5", "operation": "new_body"}),
)
.unwrap();
}
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
assert_eq!(summary(&m.state()), from_scratch(&doc));
// The copy turned a quarter round now reaches the plate.
doc.set_param("w", "4 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!(summary(&m.state()), from_scratch(&doc));
}
/// A cut of several bodies cuts again only those that changed: the rod, which `w` does not
/// move, keeps the very body its earlier cut gave, while the cap `w` sizes is cut again.
#[test]
fn a_cut_of_several_bodies_cuts_again_only_those_that_changed() {
let mut doc = Document::new("rod and cap");
doc.set_param("w", "6 mm", None, None).unwrap();
let rod = doc.add_component(Some("Rod"), 0).unwrap();
add_in(
&mut doc,
rod,
"Rod",
serde_json::json!({"type": "cylinder", "base": ["0", "0", "0"], "axis": [0.0, 0.0, 1.0], "radius": "4", "height": "20", "operation": "new_body"}),
);
add_in(
&mut doc,
rod,
"Cap",
serde_json::json!({"type": "box", "corner": ["-5", "-5", "20"], "length": "10", "width": "10", "height": "w", "operation": "new_body"}),
);
for f in doc.features.iter_mut().filter(|f| f.name == "Rod" || f.name == "Cap") {
f.body_names = vec![f.name.clone()];
}
// The slot runs through both: the component's bodies are its targets.
add_in(
&mut doc,
rod,
"Slot",
serde_json::json!({"type": "box", "corner": ["3", "-0.5", "2"], "length": "4", "width": "1", "height": "30", "operation": "cut"}),
);
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
let cut_rod = m.state().body("Rod").unwrap().body.clone();
doc.set_param("w", "8 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!(m.last_recomputed, 2, "the cap and the slot");
let st = m.state();
assert!(st.body("Rod").unwrap().body.same(&cut_rod), "the rod's cut is the one made before");
assert_eq!(summary(&st), from_scratch(&doc));
}
/// A parameter that drives the first feature of the timeline, with the bodies after it each cut
/// by a combine: only the first body and its cut are recomputed, every other cut is restored.
#[test]
fn a_combine_reads_only_its_target_and_tools() {
let mut doc = Document::new("rows");
doc.set_param("w", "20 mm", None, None).unwrap();
let c = doc.add_component(Some("Rows"), 0).unwrap();
for i in 0..3 {
let x = (i * 30) as f64;
let (body, tool, cut) = (format!("Body{i}"), format!("Tool{i}"), format!("Cut{i}"));
let length = if i == 0 { "w".to_string() } else { "20".to_string() };
add_in(
&mut doc,
c,
&body,
serde_json::json!({"type": "box", "corner": [x.to_string(), "0", "0"], "length": length, "width": "15", "height": "5", "operation": "new_body"}),
);
add_in(
&mut doc,
c,
&tool,
serde_json::json!({"type": "cylinder", "base": [(x + 7.0).to_string(), "7.5", "-1"], "axis": [0.0, 0.0, 1.0], "radius": "1.5", "height": "7", "operation": "new_body"}),
);
for f in doc.features.iter_mut().filter(|f| f.name == body || f.name == tool) {
f.body_names = vec![f.name.clone()];
}
add_in(&mut doc, c, &cut, serde_json::json!({"type": "combine", "target": body, "tools": [tool], "operation": "cut"}));
}
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
assert_eq!(m.last_recomputed, 9);
doc.set_param("w", "32 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (2, 7), "the first body and its cut");
assert_eq!(summary(&m.state()), from_scratch(&doc));
}
/// A circle sketch on `plane` in `component`, extruded by `distance` (`operation`), named
/// after `name`.
fn circle_extrude(doc: &mut Document, component: u64, name: &str, plane: PlaneRef, center: Vec2, r: f64, distance: &str, operation: &str) {
let mut sk = Sketch::new();
sk.add_circle(center, r, None, None).unwrap();
let id = doc.add_feature(FeatureKind::Sketch { plane, sketch: sk }, Some(&format!("{name} sketch"))).unwrap();
doc.feature_mut(id).unwrap().component = component;
add_in(
doc,
component,
name,
serde_json::json!({"type": "extrude", "sketch": id, "profiles": {"type": "all"}, "extent": {"convention": "total_distance", "direction": "positive", "distance": distance}, "operation": operation}),
);
if let Some(f) = doc.features.iter_mut().find(|f| f.name == name).filter(|_| operation == "new_body") {
f.body_names = vec![name.into()];
}
}
/// Three components on one timeline (#6183): A's post (height `A_h`) with the construction plane
/// `A_top` above it when `plane`; B's post, cut by a hole from a sketch on a plane given by
/// numbers and filleted; C's post, filleted twice (the last by `C_r`). Nothing in B or C reads A.
fn three_parts(plane: bool) -> Document {
let mut doc = Document::new("three parts");
doc.set_param("A_h", "10 mm", None, None).unwrap();
doc.set_param("C_r", "1 mm", None, None).unwrap();
let [a, b, c] = ["A", "B", "C"].map(|n| doc.add_component(Some(n), 0).unwrap());
let xy = || PlaneRef::Origin { name: "XY".into() };
circle_extrude(&mut doc, a, "A post", xy(), Vec2::new(0.0, 0.0), 5.0, "A_h", "new_body");
if plane {
add_in(
&mut doc,
a,
"A_top",
serde_json::json!({"type": "construction_plane", "plane": {"type": "offset", "base": {"type": "origin", "name": "XY"}, "distance": "A_h + 5"}}),
);
}
circle_extrude(&mut doc, b, "B post", xy(), Vec2::new(40.0, 0.0), 8.0, "12", "new_body");
let top = PlaneRef::Offset { base: Box::new(xy()), distance: "12".into() };
circle_extrude(&mut doc, b, "B hole", top, Vec2::new(40.0, 0.0), 3.0, "-4", "cut");
add_in(&mut doc, b, "B round", serde_json::json!({"type": "fillet", "edges": [[48.0, 0.0, 0.0]], "radius": "1"}));
circle_extrude(&mut doc, c, "C post", xy(), Vec2::new(80.0, 0.0), 6.0, "15", "new_body");
add_in(&mut doc, c, "C round", serde_json::json!({"type": "fillet", "edges": [[86.0, 0.0, 15.0]], "radius": "1.5"}));
add_in(&mut doc, c, "C foot", serde_json::json!({"type": "fillet", "edges": [[86.0, 0.0, 0.0]], "radius": "C_r"}));
doc
}
fn no_errors(m: &Model) {
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| (&r.name, &r.error)).collect::<Vec<_>>());
}
/// An edit in component A recomputes A's features that use it and nothing in B or C (#6183):
/// sketches, cuts on a plane given by numbers and fillets of their own body are kept, and the
/// bodies match an evaluation from scratch.
#[test]
fn an_edit_in_one_component_recomputes_only_what_reads_it() {
for plane in [false, true] {
let mut doc = three_parts(plane);
let mut m = Model::new();
m.evaluate(&doc);
no_errors(&m);
for h in ["11 mm", "12 mm", "13 mm"] {
doc.set_param("A_h", h, None, None).unwrap();
m.evaluate(&doc);
no_errors(&m);
// The extrude, and the plane offset by `A_h`.
assert_eq!(m.last_recomputed, 1 + usize::from(plane), "A_h = {h}, plane: {plane}");
assert_eq!(summary(&m.state()), from_scratch(&doc));
}
// The last fillet recomputes alone, as before.
doc.set_param("C_r", "2 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!(m.last_recomputed, 1);
assert_eq!(summary(&m.state()), from_scratch(&doc));
// Back where it was: every result comes from the cache.
doc.set_param("A_h", "10 mm", None, None).unwrap();
doc.set_param("C_r", "1 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!(summary(&m.state()), from_scratch(&doc));
}
}
/// A construction plane's edit recomputes only the features that use it: a sketch on it and what
/// is made from that sketch, wherever they are in the timeline, and nothing else.
#[test]
fn a_construction_plane_edit_recomputes_only_its_users() {
let mut doc = three_parts(true);
let b = doc.find_component("B").unwrap();
// A peg in B on A's plane: B now reads A through it.
let a_top = doc.find_feature("A_top").unwrap().id;
circle_extrude(&mut doc, b, "B peg", PlaneRef::Construction { feature: a_top.into() }, Vec2::new(40.0, 20.0), 2.0, "3", "new_body");
let mut m = Model::new();
m.evaluate(&doc);
no_errors(&m);
let total = m.results.len();
doc.set_param("A_h", "11 mm", None, None).unwrap();
m.evaluate(&doc);
no_errors(&m);
assert_eq!(m.last_recomputed, 4, "A's extrude and plane, the peg's sketch and extrude, of {total}");
assert_eq!(summary(&m.state()), from_scratch(&doc));
let peg = m.state().body("B peg").map(|b| b.body.outline_bounds().min.z);
assert!(peg.is_some_and(|z| (z - 16.0).abs() < 1e-6), "the peg stands on the plane: {peg:?}");
// Results restored whole after the plane carry the plane as it is now.
let st = m.state();
let top = st.construct.iter().find(|c| c.name == "A_top").map(|c| &c.geom);
assert!(matches!(top, Some(crate::construct::ConstructGeom::Plane(p)) if (p.origin.z - 16.0).abs() < 1e-9), "{top:?}");
let mut scratch = Model::new();
scratch.no_cache = true;
scratch.evaluate(&doc);
assert_eq!(st.construct, scratch.state().construct);
}
/// A coil sweeping a sketch's profile is made again when that sketch changes, not restored as
/// it was (a coil of its own section reads no sketch).
#[test]
fn a_coil_follows_its_sketch() {
let section = |r: f64| {
let mut sk = Sketch::new();
sk.add_circle(Vec2::new(20.0, 0.0), r, None, None).unwrap();
FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XZ".into() }, sketch: sk }
};
let mut doc = Document::new("coil");
let sketch = doc.add_feature(section(1.0), None).unwrap();
doc.add_feature(
FeatureKind::Coil {
base: point_expr(Vec3::new(0.0, 0.0, 0.0)),
axis: Dir(Vec3::Z),
sketch: Some(crate::SketchId(sketch)),
profiles: ProfileSel::All,
diameter: None,
pitch: "8".into(),
turns: "2".into(),
section_size: None,
section: Default::default(),
position: 0,
start_angle: None,
clockwise: false,
operation: Operation::NewBody,
targets: Vec::new(),
},
None,
)
.unwrap();
let mut m = Model::new();
m.evaluate(&doc);
no_errors(&m);
let before = summary(&m.state());
if let Some(f) = doc.feature_mut(sketch) {
f.kind = section(2.0);
}
m.evaluate(&doc);
no_errors(&m);
assert_eq!(m.last_recomputed, 2, "the sketch and its coil");
assert_eq!(summary(&m.state()), from_scratch(&doc));
assert_ne!(summary(&m.state()), before);
}
#[test]
fn box_fillet_cut_timeline() {
let (mut doc, _) = plate_doc();
@@ -1,278 +0,0 @@
//! 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)
}
+12 -72
View File
@@ -137,7 +137,7 @@ pub static COMMANDS: &[CommandSpec] = &[
or joints?: [joint id or name] (their glyphs; they still constrain and move parts); \
or features?: [feature id or name] (the bodies they made; path?: [occurrence ids] for only that placement's); \
or folder?: bodies|sketches|construction|origin|joints|all (all of it; showing also shows its browser folders and groups, and `all` takes in the occurrence hides too); \
or tree_folders?: [browser folder or group key: c0/bodies, o:4/sketches, o:4/origin, c0/g7, c0/joints, c0/canvases, c0/point_clouds] (the folder or group itself: its items keep their own state; component origin items use o:4/origin/XY etc. in items); \
or tree_folders?: [browser folder or group key: c0/bodies, o:4/sketches, c0/g7, c0/joints, c0/canvases, c0/point_clouds] (the folder or group itself: its items keep their own state); \
or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (with paths or items of bodies, and visible:true: everything else is hidden, other placements as placements). Saved with the design and undone like any other change (the camera is not)",
),
CommandSpec::new("browser.expand_all", "Expand All", crate::checked!("browser.expand_all", expand_all))
@@ -187,10 +187,7 @@ fn keys(p: &Value, k: &str, cmd: &str) -> Result<Vec<String>> {
_ => None,
};
let v = v.ok_or_else(|| bad(cmd, format!("`{k}` must be a list of item keys")))?;
// Past the 256-character cap only the datum keys go through, each bounded by its own parser:
// an item's `o:<ids>/origin/<item>`, a folder's `o:<ids>/origin` - deep placements need them.
let past_cap = |x: &String| x.len() > 256 && crate::origins::item(x).is_none() && crate::origins::folder_path(x).is_none();
if v.len() > 10_000 || v.iter().any(|x| x.is_empty() || past_cap(x)) {
if v.len() > 10_000 || v.iter().any(|x| x.is_empty() || x.len() > 256) {
return Err(bad(cmd, format!("`{k}` has bad keys")));
}
Ok(v)
@@ -438,6 +435,8 @@ fn order(s: &mut Session, p: &Value) -> Result<Value> {
reply(cmd, BrowserOrdered { param_warnings: Vec::new() })
}
const ORIGIN: [&str; 7] = ["O", "X", "Y", "Z", "XY", "XZ", "YZ"];
/// A browser item whose visibility can change.
enum Shown {
Sketch(u64),
@@ -458,13 +457,10 @@ fn is_construction(k: &FeatureKind) -> bool {
/// The rule, the same for showing and for hiding: an exact name wins over a name that only
/// matches it by case, so the body called "Back Lid" is not the sketch called "Back lid"
/// (#6967). Among the exact matches the order is the one there always was: a feature (by id
/// or by name), then a body, then a joint. With no exact match the key may name a component
/// origin item by its key; else the name is compared without regard to case, as it always was:
/// a feature first, then an origin item.
/// or by name), then a body, then a joint. With no exact match the name is compared without
/// regard to case, as it always was: a feature first, then an origin item.
fn shown_item(s: &Session, world: &solidcraft_doc::ModelState, key: &str) -> Option<Shown> {
shown_item_exact(s, world, key)
.or_else(|| s.origin_geometry(key).map(|_| Shown::Origin(key.to_string())))
.or_else(|| shown_item_ignore_case(s, world, key))
shown_item_exact(s, world, key).or_else(|| shown_item_ignore_case(s, world, key))
}
/// The item a key names exactly: a feature, a body or a joint whose name is the key itself.
@@ -490,7 +486,7 @@ fn shown_item_ignore_case(s: &Session, world: &solidcraft_doc::ModelState, key:
if let Some(shown) = s.doc.find_feature(key).and_then(|f| shown_feature(world, f, key)) {
return Some(shown);
}
crate::origins::ITEMS.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
}
/// The item a feature is: a sketch, or construction geometry by name. A feature that is neither
@@ -513,11 +509,8 @@ fn shown_feature(world: &solidcraft_doc::ModelState, f: &solidcraft_doc::Feature
}
/// A browser folder or group key (`Visibility::hidden_folders`): `c0` or `o:<ids>`, then
/// `/bodies`, `/sketches`, `/construction`, `/joints`, `/canvases`, `/origin` or `/g<group id>`.
/// `/bodies`, `/sketches`, `/construction`, `/joints`, `/canvases` or `/g<group id>`.
pub(crate) fn tree_folder_key(k: &str) -> bool {
if crate::origins::folder_path(k).is_some() {
return true;
}
let Some((scope, last)) = k.rsplit_once('/') else { return false };
let scope_ok =
scope == "c0" || scope.strip_prefix("o:").is_some_and(|ids| ids.split('/').count() <= 64 && ids.split('/').all(|i| i.parse::<u64>().is_ok()));
@@ -634,28 +627,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
})
})
.collect::<Result<_>>()?;
// A component Origin folder's placement has to exist, and showing one is bounded.
for path in keys.iter().filter_map(|key| crate::origins::folder_path(key)) {
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
}
if visible {
let mut shown: BTreeSet<_> = s.visibility.shown_component_origins.iter().cloned().collect();
shown.extend(keys.iter().filter_map(|key| crate::origins::folder_path(key)));
if shown.len() > crate::origins::MAX_SHOWN {
return Err(bad(cmd, "at most 10000 component origins can be shown at once"));
}
}
for k in &keys {
if let Some(path) = crate::origins::folder_path(k) {
if visible {
if !s.visibility.shown_component_origins.contains(&path) {
s.visibility.shown_component_origins.push(path);
}
} else {
s.visibility.shown_component_origins.retain(|p| p != &path);
}
continue;
}
s.visibility.hidden_folders.retain(|h| h != k);
if !visible {
s.visibility.hidden_folders.push(k.clone());
@@ -761,7 +733,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
(Some("bodies"), None) => s.world_state()?.bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(),
(Some("sketches"), None) => s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)).collect(),
(Some("construction"), None) => s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())).collect(),
(Some("origin"), None) => crate::origins::ITEMS.iter().map(|o| Shown::Origin(o.to_string())).collect(),
(Some("origin"), None) => ORIGIN.iter().map(|o| Shown::Origin(o.to_string())).collect(),
(Some("joints"), None) => s.doc.assembly.joints.iter().map(|j| Shown::Joint(j.id)).collect(),
// Everything at once, for a single "show all" step: every body, sketch, construction
// item, origin item and joint.
@@ -769,7 +741,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
let mut all: Vec<Shown> = s.world_state()?.bodies.iter().map(|b| Shown::Body(b.name.clone())).collect();
all.extend(s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)));
all.extend(s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())));
all.extend(crate::origins::ITEMS.iter().map(|o| Shown::Origin(o.to_string())));
all.extend(ORIGIN.iter().map(|o| Shown::Origin(o.to_string())));
all.extend(s.doc.assembly.joints.iter().map(|j| Shown::Joint(j.id)));
all
}
@@ -934,9 +906,6 @@ fn collect_component_keys(s: &Session, prefix: &str, component: u64, root_extras
keys.closed.insert(format!("{prefix}/features"));
}
let occurrence = prefix.starts_with("o:");
if occurrence {
keys.closed.insert(format!("{prefix}/origin"));
}
for f in s.doc.features.iter().filter(|f| f.component == component) {
if let FeatureKind::Sketch { sketch, .. } = &f.kind
&& !sketch.patterns.is_empty()
@@ -1118,7 +1087,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.as_str())?;
s.doc_mut().apply_configuration(&row)?;
}
s.settle_value_edit(&solved)?;
reply(kept)
@@ -2971,35 +2940,6 @@ mod tests {
assert!(plain.visibility.is_empty() && plain.visibility.origin_hidden.is_none());
}
/// A batch with a key that does not name anything (no such placement) fails whole and
/// changes nothing: the atomic contract, for the component origin keys too.
/// A visibility batch that fails validation changes nothing: every key is checked
/// before any of them is applied, and the failed command leaves the saved visibility
/// as it was (#7313).
#[test]
fn invalid_visibility_batches_are_atomic() {
let mut s = Session::default();
// The Origin folder hangs on a placement, so the key takes the placement's occurrence id
// (the create reply also carries the component id, which names no occurrence).
let created = run(&mut s, "component.create", json!({"name": "Part", "grounded": false}));
let occurrence = created["occurrence"].as_u64().unwrap();
let folder = crate::origins::folder(&[occurrence]);
run(&mut s, "browser.visibility", json!({"tree_folders": [&folder], "visible": true}));
run(&mut s, "browser.visibility", json!({"items": [crate::origins::key(&[occurrence], "X")], "visible": false}));
let before = s.visibility.clone();
// The bad key fails the whole batch: the valid folder key changes nothing.
assert!(s.execute("browser.visibility", &json!({"tree_folders": ["c0/bodies", "o:999999/origin"], "visible": true})).is_err());
assert_eq!(s.visibility, before, "invalid batches must be atomic");
// The item form is atomic the same way.
assert!(s.execute("browser.visibility", &json!({"items": ["X", "o:999999/origin/X"], "visible": true})).is_err());
assert_eq!(s.visibility, before);
// And the bounds hold: no `o:` prefix, a zero or non-canonical id, the design's own scope.
for invalid in ["o:/origin", "o:0/origin", "o:01/origin", "c0/origin"] {
assert!(s.execute("browser.visibility", &json!({"tree_folders": [invalid], "visible": true})).is_err(), "{invalid}");
}
assert_eq!(s.visibility, before);
}
#[test]
fn groups_hold_items_and_follow_renames() {
let mut s = Session::default();
+1 -1
View File
@@ -269,7 +269,7 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
feature.remap_clone(&ids, &names, &BTreeMap::new(), &mapped, &BTreeMap::new(), &copy.doc)?;
feature.remap_clone(&ids, &names, &BTreeMap::new(), &mapped)?;
if let FeatureKind::Sketch { plane, sketch } = &mut feature.kind {
// 3D wires carry their own coordinate space, independently of the sketch plane.
for wire in &mut sketch.wires {
+1 -1
View File
@@ -486,7 +486,7 @@ pub(super) fn build(
} else if f.body_names.is_empty() {
f.body_names = outputs.get(&old.id).cloned().unwrap_or_default();
}
f.remap_clone(&features, &names, &bodies, &parameters, &occurrences, &scratch.doc)?;
f.remap_clone(&features, &names, &bodies, &parameters)?;
transfer_into_copy(&mut f.kind, &components)?;
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
&& !face.path.is_empty()
@@ -24,7 +24,6 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
}
}
@@ -749,51 +749,3 @@ fn paste_new_of_a_mesh_import_with_one_label_on_all_objects_moves_the_copy_of_ea
assert_eq!(corners(&s, part), lifted());
assert_eq!(corners(&s, copied), lifted(), "{:?}", body_names(&s));
}
/// A construction axis through a nested placement's origin datum is copied with its datum path
/// rebuilt through the copy (the source's ancestors are not in the occurrence map); an axis
/// whose datum names a placement outside the copied tree refuses the copy - the reference could
/// not follow, and a dangling one would resolve against whatever then sits at those ids.
#[test]
fn cloned_datum_references_follow_the_copy() {
let mut s = Session::default();
let parent = component(&mut s, "Parent");
run(&mut s, "component.create", json!({"name": "Child", "parent": "Parent"}));
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "operation": "new"}));
let parent_occ = s.doc.occurrence_of(parent).unwrap().id;
let child_occ = s.doc.occurrences.iter().find(|o| o.parent == parent).unwrap().id;
run(&mut s, "component.activate", json!({"component": "Parent"}));
run(
&mut s,
"construct.axis.two_points",
json!({"a": format!("o:{parent_occ}/{child_occ}/origin/O"), "b": {"point": [12, 0, 0]}, "name": "DatumAxis"}),
);
let r = copy(&mut s, parent);
let new_comp = r["component"].as_u64().unwrap();
let new_parent_occ = r["occurrence"].as_u64().unwrap();
let new_child_occ = s.doc.occurrences.iter().find(|o| o.parent == new_comp).unwrap().id;
let axis =
s.doc.features.iter().find(|f| f.component == new_comp && matches!(f.kind, solidcraft_doc::FeatureKind::ConstructionAxis { .. })).unwrap();
let solidcraft_doc::FeatureKind::ConstructionAxis { def } = &axis.kind else { panic!("expected a construction axis: {:?}", axis.kind) };
let solidcraft_doc::construct::AxisDef::TwoPoints { a, .. } = def else { panic!("expected a two-points axis: {def:?}") };
match a {
solidcraft_doc::construct::GeoRef::Datum { datum } => {
assert_eq!(datum.path, vec![new_parent_occ, new_child_occ], "the datum names the copy's own nested placement");
}
other => panic!("expected a datum reference, got {other:?}"),
}
// An axis whose datum names a placement outside the copied tree refuses the copy. Outside is
// made at the root: `component.create` places under the active component (the paste left
// Parent active), and a datum key names its placement from the root, so a placement inside
// Parent would be unreachable to the resolver and inside the copy's map at once.
run(&mut s, "component.activate", json!({"component": 0}));
let outside = component(&mut s, "Outside");
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "operation": "new"}));
let outside_occ = s.doc.occurrence_of(outside).unwrap().id;
run(&mut s, "component.activate", json!({"component": "Parent"}));
run(&mut s, "construct.axis.two_points", json!({"a": format!("o:{outside_occ}/origin/O"), "b": {"point": [12, 0, 0]}, "name": "OutsideAxis"}));
assert!(
s.execute("component.paste_new", &json!({"component": parent, "translate": [40, 0, 0]})).is_err(),
"a datum into another tree cannot follow the copy"
);
}
+22 -32
View File
@@ -17,7 +17,7 @@ use serde_json::{Value, json};
use solidcraft_doc::{DesignLink, Document, FeatureKind, LinkedBodyProperties, UserMaterial};
use super::CommandSpec;
use super::design_link::{LinkState, absolute, link_presence, read_linked_file, resolve, same_file, stored};
use super::design_link::{absolute, resolve, stored};
use crate::params::{bad, str_, vec3};
use crate::schema::component_link::{
BreakLinkParams, BreakLinkResult, CopiesParams, CopiesResult, DesignInserted, ExtractParams, ExtractResult, InsertDesignParams,
@@ -112,9 +112,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(
"→ linked components: component, name, path, file (resolved), found (null when the file is on a network location not allowed, \
or names a device: it is not looked for), changed (whether the file is no longer what this design last read: Update Links \
reads it again; null when not found or the link is older than this record), then host (the network location); state (missing, \
unreadable, not_allowed: a network location not allowed or outside the folders this agent may use) and reason when the file \
can't be reached",
reads it again; null when not found or the link is older than this record), and then host (the network location) and reason",
),
];
@@ -213,12 +211,16 @@ fn remember_read(file: &Path, bytes: u64, feature: &solidcraft_io::StepFeature,
/// The bytes of the design at `file`, which must not be the design saved at `own`.
fn read_link(file: &Path, own: Option<&str>) -> Result<Vec<u8>> {
let shown = file.display().to_string();
if own.is_some_and(|o| same_file(Path::new(o), file)) {
if own.is_some_and(|o| absolute(Path::new(o)) == absolute(file)) {
return Err(EngineError::Other(format!("{shown}: a design can't be linked into itself")));
}
// A network location only when its server is allowed, a device never: judged from the text
// before anything is read (on Windows, reading `\\host\share\x` connects to the host).
read_linked_file(file, own).map_err(|e| EngineError::Other(e.reason))
let design = solidcraft_io::limits::Kind::Design;
solidcraft_io::vfs::read_design_path(&shown, own, solidcraft_io::limits::max_design_bytes()).map_err(|e| {
let e = solidcraft_io::limits::explain(design, e).to_string();
EngineError::Other(if e.starts_with(&shown) { e } else { format!("{shown}: {e}") })
})
}
/// The solid bodies of the design at `file`, evaluated under `validation`, as an Import feature, with the digest of
@@ -555,7 +557,7 @@ fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
// A network or device path is judged from its text, as given, before anything reads it.
link_refused(s.path.as_deref(), given).map_err(|e| bad(cmd, e.to_string()))?;
let file = absolute(Path::new(given));
if s.path.as_deref().is_some_and(|own| same_file(Path::new(own), &file)) {
if s.path.as_deref().is_some_and(|own| absolute(Path::new(own)) == file) {
return Err(bad(cmd, "a design can't be linked into itself"));
}
if p.get("reuse").and_then(Value::as_bool).unwrap_or(false) {
@@ -797,9 +799,11 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
}
// A network or device path is judged from its text before the disk is asked anything.
link_refused(s.path.as_deref(), given).map_err(|e| bad(cmd, e.to_string()))?;
// The same path, or on native hosts an alias of this assembly (a symlink or a path with
// different case): the one self-link rule Insert Design and Update Links use too.
if s.path.as_deref().is_some_and(|own| same_file(Path::new(own), &file)) {
// The ordinary lexical check in linked_bodies also works in the browser VFS. On native
// hosts, reject an alias of this assembly too (a symlink or a path with different case).
if let (Ok(candidate), Some(Ok(own))) = (file.canonicalize(), s.path.as_deref().map(std::fs::canonicalize))
&& candidate == own
{
return Err(bad(cmd, "a design can't be linked into itself"));
}
if let Some(occurrence) = lone_placement(s, p, comp, cmd)? {
@@ -955,45 +959,33 @@ fn list_links(s: &mut Session, _p: &Value) -> Result<Value> {
let file = resolve(s.path.as_deref(), &l.path);
let name = s.doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone());
let path = stored(s.path.as_deref(), &file);
let shown = file.to_string_lossy().into_owned();
let file = file.to_string_lossy();
// A refused path is not looked for at all: on Windows asking whether a file on
// `\\host\share` exists already connects to the host.
match link_refusal(s.path.as_deref(), &l.path) {
None => {
let found = solidcraft_io::vfs::design_path_is_file(&shown, s.path.as_deref());
let found = solidcraft_io::vfs::design_path_is_file(&file, s.path.as_deref());
// Whether the file is no longer what this design last read (#9739): `null`
// when it isn't found, or the link keeps no digest (made before they were).
let changed = l
.file_sha256
.as_ref()
.filter(|_| found == Some(true))
.and_then(|kept| file_sha256(Path::new(&shown), s.path.as_deref()).map(|now| now != *kept));
// Why a file that can't be reached can't be: `None` when it can be read.
let (state, reason) = match link_presence(&file, s.path.as_deref()) {
Some(why) => (Some(why.state), Some(why.reason)),
None => (None, None),
};
LinkInfo::Looked { component: *c, name, path, file: shown, found, changed, state, reason }
.and_then(|kept| file_sha256(Path::new(file.as_ref()), s.path.as_deref()).map(|now| now != *kept));
LinkInfo::Looked { component: *c, name, path, file: file.into_owned(), found, changed }
}
Some(solidcraft_io::vfs::Remote::Network(host)) => LinkInfo::Network {
component: *c,
name,
path,
file: shown,
file: file.into_owned(),
found: None,
state: LinkState::NotAllowed,
reason: format!("on the network location \\\\{host}, which is not allowed"),
host,
},
Some(solidcraft_io::vfs::Remote::Device) => LinkInfo::Device {
component: *c,
name,
path,
file: shown,
found: None,
state: LinkState::Unreadable,
reason: "names a device, not a file".into(),
},
Some(solidcraft_io::vfs::Remote::Device) => {
LinkInfo::Device { component: *c, name, path, file: file.into_owned(), found: None, reason: "names a device, not a file".into() }
}
}
})
.collect();
@@ -1138,8 +1130,6 @@ mod tests {
std::fs::remove_file(&part).unwrap();
let err = b.execute("component.update_links", &json!({})).unwrap_err().to_string();
assert!(err.contains("plate"), "{err}");
let links = run(&mut b, "component.links", json!({}));
assert_eq!((&links["links"][0]["found"], &links["links"][0]["state"]), (&json!(false), &json!("missing")), "{links}");
assert!((volume(&mut b) - 9000.0).abs() < 1e-6);
// Break Link keeps the bodies and forgets the file.
run(&mut b, "component.break_link", json!({"component": "plate"}));
+22 -79
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use solidcraft_doc::DEFAULT_MATERIAL;
use solidcraft_doc::config::{Column, MAX_COLUMNS, RowKey};
use solidcraft_doc::config::{Column, ConfigRow, MAX_COLUMNS, MAX_ROWS};
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 (each with its fixed id), the active row's name and whether the design still matches it"),
.params("→ columns (param:<name> or suppress:<feature>), rows, the active row 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 (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",
"name; from? (row to copy; 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: a row's name, or its id from config.table — sets its parameter values and suppressions"),
.params("row: name — 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: a row's name, or its id from config.table — validates a rebuild without changing the design or history"),
.params("row: name — 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,8 +206,9 @@ 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();
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());
let t = &mut s.doc_mut().configs;
t.rows.push(ConfigRow { name: "Default".into(), cells });
t.active = Some("Default".into());
}
show(s, p)
}
@@ -225,11 +226,8 @@ 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 { id: r.id, name: r.name.clone(), values: keys.iter().cloned().zip(r.cells.iter().cloned()).collect() })
.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 targets = t
.columns
.iter()
@@ -288,18 +286,8 @@ fn column(s: &mut Session, p: &Value) -> Result<Value> {
fn row(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "config.row";
// 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)
}
};
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);
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() {
@@ -312,12 +300,8 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
}
return show(s, p);
}
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}`")))?,
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}`")))?,
(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(),
};
@@ -395,10 +379,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 {
@@ -410,7 +394,10 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
}
}
None => {
s.doc_mut().add_configuration_row(name, cells).map_err(|e| bad(cmd, e.to_string()))?;
if t.rows.len() >= MAX_ROWS {
return Err(bad(cmd, "too many rows"));
}
t.rows.push(ConfigRow { name, cells });
}
}
if !s.doc.configs.rules.is_empty() {
@@ -426,7 +413,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.id})).map_err(|e| bad(cmd, format!("row `{}`: {e}", row.name)))?;
activate(&mut trial, &json!({"row":row.name})).map_err(|e| bad(cmd, format!("row `{}`: {e}", row.name)))?;
}
Ok(())
}
@@ -501,11 +488,9 @@ 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 key = p.get("row").and_then(RowKey::read).ok_or_else(|| bad(cmd, "`row` is required"))?;
let r = str_(p, "row").ok_or_else(|| bad(cmd, "`row` is required"))?.to_string();
let mut trial = s.scratch();
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();
let trace = trial.doc_mut().apply_configuration_with_trace(&r).map_err(|e| bad(cmd, e.to_string()))?;
trial.refresh();
let new_errors: Vec<String> = trial
.model
@@ -581,48 +566,6 @@ 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
+27 -70
View File
@@ -8,7 +8,7 @@
use serde_json::Value;
use solidcore_record::Vec3;
use solidcraft_doc::construct::{AxisDef, DatumItem, GeoRef, PointDef};
use solidcraft_doc::construct::{AxisDef, GeoRef, PointDef};
use solidcraft_doc::{FeatureKind, PlaneRef};
use super::CommandSpec;
@@ -171,7 +171,7 @@ pub(crate) fn is_model_axis(v: &Value) -> bool {
pub(super) fn model_axis_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<Option<(GeoRef, Vec3, Vec3)>> {
let Some(v) = v.filter(|v| is_model_axis(v)) else { return Ok(None) };
let g = geo(s, Some(v), cmd, "axis")?;
let (o, d) = solidcraft_doc::construct::model_axis_in(Some(&s.doc), &*s.world_state()?, &g, 0).map_err(|e| bad(cmd, format!("`axis`: {e}")))?;
let (o, d) = solidcraft_doc::construct::model_axis(&*s.world_state()?, &g).map_err(|e| bad(cmd, format!("`axis`: {e}")))?;
Ok(Some((g, o, d)))
}
@@ -265,47 +265,21 @@ pub(super) fn geo(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Resu
}
}
Ok(GeoForm::Point(p)) => GeoRef::Point { p },
Ok(GeoForm::Plane(plane)) => {
// A bare component origin datum key (`o:2/origin/O`, `o:2/origin/X`, `o:2/origin/XY`)
// names a point or an axis as often as a plane, and only the plane items reach
// `plane_param`'s own datum branch. A feature of the same name still wins (the one
// resolution order), so the string is left to `plane_param` then; the O, X, Y and Z
// items are saved as their datum, so the reference follows the placement.
let datum = plane
.as_str()
.map(str::trim)
.filter(|k| s.doc.find_feature(k).is_none())
.and_then(crate::origins::datum)
.filter(|d| matches!(d.item, DatumItem::O | DatumItem::X | DatumItem::Y | DatumItem::Z));
match datum {
Some(datum) => GeoRef::Datum { datum },
None => GeoRef::Plane { plane: plane_param(s, Some(plane), cmd)? },
}
}
Ok(GeoForm::Plane(plane)) => GeoRef::Plane { plane: plane_param(s, Some(plane), cmd)? },
Ok(GeoForm::Axis(a)) => {
if ["x", "y", "z"].contains(&a.to_ascii_lowercase().as_str()) {
GeoRef::World { axis: a.to_ascii_uppercase() }
} else {
match s.doc.find_feature(a).map(|f| &f.kind) {
Some(FeatureKind::ConstructionAxis { .. }) => GeoRef::Axis { name: a.into() },
// A component origin axis: saved as its datum, so it follows the placement.
_ => match crate::origins::datum(a).filter(|d| matches!(d.item, DatumItem::X | DatumItem::Y | DatumItem::Z)) {
Some(datum) => GeoRef::Datum { datum },
None => return Err(bad(cmd, format!("no axis `{a}`"))),
},
_ => return Err(bad(cmd, format!("no axis `{a}`"))),
}
}
}
Ok(GeoForm::ConstructPoint(n)) => {
match s.doc.find_feature(n).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.into() },
// A component origin's O point: its datum, so it follows the placement.
_ => match crate::origins::datum(n).filter(|d| d.item == DatumItem::O) {
Some(datum) => GeoRef::Datum { datum },
None => return Err(bad(cmd, format!("no construction point `{n}`"))),
},
}
}
Ok(GeoForm::ConstructPoint(n)) => match s.doc.find_feature(n).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.into() },
_ => return Err(bad(cmd, format!("no construction point `{n}`"))),
},
Err(GeoFormError::Saved(e)) => return Err(bad(cmd, format!("`{what}`: {e}"))),
Err(GeoFormError::Unknown) => return Err(bad(cmd, format!("`{what}` must be {REF}"))),
};
@@ -402,47 +376,28 @@ fn plane_mid(s: &mut Session, p: &Value) -> Result<Value> {
}
fn plane_edges(s: &mut Session, p: &Value) -> Result<Value> {
plane_of_picks(s, p, "construct.plane.two_edges")
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) })
}
fn plane_axis_point(s: &mut Session, p: &Value) -> Result<Value> {
plane_of_picks(s, p, "construct.plane.axis_point")
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) } })
}
fn plane_points(s: &mut Session, p: &Value) -> Result<Value> {
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,
})
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) })
}
fn plane_tangent(s: &mut Session, p: &Value) -> Result<Value> {
@@ -477,5 +432,7 @@ fn plane_along(s: &mut Session, p: &Value) -> Result<Value> {
}
fn plane_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
plane_of_picks(s, p, "construct.plane.perpendicular")
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) })
}
-207
View File
@@ -1,207 +0,0 @@
//! 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, &params)?;
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)
}
+1 -210
View File
@@ -2,15 +2,9 @@
//! link to come): where a link keeps its file and how it finds it again. A link keeps the file
//! relative to the linking design's folder once that design is saved, so a folder of designs can
//! move together; before the design has a file the path is kept whole.
//!
//! Reading a linked design ([`read_linked_file`]) gives its bytes or its [`LinkState`] with the
//! reason, in one set of state words every kind of link reports.
use std::io::ErrorKind;
use std::path::{Component as PathPart, Path, PathBuf};
use schemars::JsonSchema;
use serde::Serialize;
use solidcraft_doc::{DesignLink, Document};
/// The folder relative link paths are kept against: the design's own, once it has a file.
@@ -74,120 +68,7 @@ pub(super) fn resolve(design: Option<&str>, link: &str) -> PathBuf {
/// The links of `doc` stored for a design saved at `to` instead of at `from`.
pub(super) fn rebased(doc: &Document, from: Option<&str>, to: &str) -> std::collections::BTreeMap<u64, DesignLink> {
doc.links.iter().map(|(c, l)| (*c, DesignLink { path: rebase(&l.path, from, to), ..l.clone() })).collect()
}
/// One link's path, kept for a design saved at `from`, as a design saved at `to` keeps it: the
/// one Save As rule every kind of link follows.
pub(super) fn rebase(link: &str, from: Option<&str>, to: &str) -> String {
stored(Some(to), &resolve(from, link))
}
/// Whether two paths name one file: the same path, or (on native hosts) the same file through a
/// symlink or a path with different case. A design can't link itself under either.
pub(super) fn same_file(a: &Path, b: &Path) -> bool {
absolute(a) == absolute(b) || matches!((a.canonicalize(), b.canonicalize()), (Ok(x), Ok(y)) if x == y)
}
/// Where a link stands against its file: the one set of state words every kind of link reports.
/// Reading tells [`LinkState::Missing`], [`LinkState::Unreadable`] and [`LinkState::NotAllowed`]
/// apart; whether a design that was read is [`LinkState::UpToDate`] or [`LinkState::OutOfDate`]
/// is the link's own comparison. A design a newer SolidCraft saved is not a state of its own: it
/// opens, and its `newer_file` says what this build couldn't read (#12166).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum LinkState {
/// The file holds what the link last took from it.
UpToDate,
/// The file changed since the link last took from it.
OutOfDate,
/// There is no file at the link's path.
Missing,
/// The file is there but isn't a design this build can read, or the path names a device.
Unreadable,
/// The file is on a network location that is not allowed, or outside the folders this agent
/// may use.
NotAllowed,
}
impl LinkState {
/// The state word (`up_to_date`, `missing`…) that link listings report.
pub fn word(self) -> &'static str {
match self {
LinkState::UpToDate => "up_to_date",
LinkState::OutOfDate => "out_of_date",
LinkState::Missing => "missing",
LinkState::Unreadable => "unreadable",
LinkState::NotAllowed => "not_allowed",
}
}
}
/// Why a linked design can't be read: its state, and the reason to show.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LinkProblem {
pub state: LinkState,
pub reason: String,
}
impl std::fmt::Display for LinkProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.reason)
}
}
fn problem(state: LinkState, reason: String) -> LinkProblem {
LinkProblem { state, reason }
}
/// The state of an I/O error reading `shown`, with the reason naming the file once.
fn io_problem(shown: &str, e: &std::io::Error) -> LinkProblem {
let named = |e: String| if e.starts_with(shown) { e } else { format!("{shown}: {e}") };
match e.kind() {
// The folder scope's refusal says which folders the agent may use.
ErrorKind::PermissionDenied => problem(LinkState::NotAllowed, e.to_string()),
ErrorKind::NotFound => problem(LinkState::Missing, format!("{shown}: not found")),
_ => problem(LinkState::Unreadable, named(e.to_string())),
}
}
/// Why the path a design names may not be reached at all, judged from its text before anything
/// asks the disk or the network (#7372): a network location whose server isn't allowed
/// ([`LinkState::NotAllowed`]), or a device ([`LinkState::Unreadable`]). `design` is the linking
/// design's file, whose own server is allowed.
fn refusal(shown: &str, design: Option<&str>) -> Option<LinkProblem> {
let remote = solidcraft_io::vfs::design_path_refusal(shown, design)?;
let reason = solidcraft_io::vfs::check_design_path(shown, design).err().map(|e| e.to_string()).unwrap_or_else(|| shown.to_string());
Some(match remote {
solidcraft_io::vfs::Remote::Network(_) => problem(LinkState::NotAllowed, reason),
solidcraft_io::vfs::Remote::Device => problem(LinkState::Unreadable, reason),
})
}
/// Where the linked design at `file` stands without reading it: `None` when it can be read.
/// Cheap, for a design being opened, which must not read every file it links. A refused network
/// or device path is judged from its text alone, with nothing asked of the disk or the network.
pub(super) fn link_presence(file: &Path, design: Option<&str>) -> Option<LinkProblem> {
let shown = file.display().to_string();
refusal(&shown, design).or_else(|| solidcraft_io::vfs::len(&shown).err().map(|e| io_problem(&shown, &e)))
}
/// The bytes of the linked design at `file`, linked from the design saved at `design`, read
/// through the folders the caller may use, or why they can't be: a network location not allowed
/// or a device (judged from the path's text before anything is read), not a design file name,
/// missing, larger than the design size limit, or outside an agent's folders. Each with the
/// reason to show. Every kind of link reads its file here.
pub(super) fn read_linked_file(file: &Path, design: Option<&str>) -> Result<Vec<u8>, LinkProblem> {
let shown = file.display().to_string();
if let Some(why) = refusal(&shown, design) {
return Err(why);
}
if !solidcraft_io::is_design_path(&shown) {
return Err(problem(LinkState::Unreadable, format!("{shown}: not a .{} design", solidcraft_io::DESIGN_EXT)));
}
let limit = solidcraft_io::limits::max_design_bytes();
solidcraft_io::vfs::read_design_path(&shown, design, limit)
.map_err(|e| io_problem(&shown, &solidcraft_io::limits::explain(solidcraft_io::limits::Kind::Design, e)))
doc.links.iter().map(|(c, l)| (*c, DesignLink { path: stored(Some(to), &resolve(from, &l.path)), ..l.clone() })).collect()
}
#[cfg(test)]
@@ -218,94 +99,4 @@ mod tests {
Some("plate.solidcraft")
);
}
#[test]
fn one_rule_rebases_every_link_for_save_as() {
let root = absolute(Path::new("projects"));
let from = root.join("asm").join("case.solidcraft").to_string_lossy().into_owned();
let to = root.join("asm").join("v2").join("case.solidcraft").to_string_lossy().into_owned();
assert_eq!(rebase("../parts/plate.solidcraft", Some(&from), &to), "../../parts/plate.solidcraft");
// A link kept whole (the design had no file yet) becomes relative too.
let whole = root.join("parts").join("plate.solidcraft").to_string_lossy().into_owned();
assert_eq!(rebase(&whole, None, &to), "../../parts/plate.solidcraft");
}
/// One folder per test, with the files it needs.
fn folder(test: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-design-link-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn reading_a_linked_design_reports_its_state_and_reason() {
let dir = folder("read");
let good = dir.join("plate.sc");
std::fs::write(&good, solidcraft_io::write_design(&Document::new("plate"))).unwrap();
let read = read_linked_file(&good, None).map(|bytes| solidcraft_io::read_design(&bytes).map(|d| d.name).ok());
assert_eq!(read, Ok(Some("plate".to_string())));
assert_eq!(link_presence(&good, None), None);
let state = |file: &Path| read_linked_file(file, None).err().map(|e| (e.state, e.reason));
// Missing: no file there (and the same without reading it).
let missing = dir.join("gone.sc");
assert!(matches!(state(&missing), Some((LinkState::Missing, r)) if r.contains("gone.sc")));
assert_eq!(link_presence(&missing, None).map(|p| p.state), Some(LinkState::Missing));
// Unreadable: not a design file name, or a folder.
std::fs::write(dir.join("part.step"), b"ISO-10303-21;").unwrap();
assert!(matches!(state(&dir.join("part.step")), Some((LinkState::Unreadable, r)) if r.contains("not a .sc design")));
std::fs::create_dir_all(dir.join("folder.sc")).unwrap();
assert!(matches!(state(&dir.join("folder.sc")), Some((LinkState::Unreadable, _))));
// A design a newer SolidCraft saved is read like any other, and opens saying so (#12166).
let mut newer: serde_json::Value = serde_json::from_slice(&solidcraft_io::write_design(&Document::new("next"))).unwrap();
newer["format"] = serde_json::json!(solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT + 1));
std::fs::write(dir.join("next.sc"), newer.to_string()).unwrap();
let opened =
read_linked_file(&dir.join("next.sc"), None).map(|bytes| solidcraft_io::read_design(&bytes).map(|d| d.newer_file.is_some()).ok());
assert_eq!(opened, Ok(Some(true)));
// Not allowed: outside the folders an agent may use, without saying whether it exists.
let inside = dir.join("inside");
std::fs::create_dir_all(&inside).unwrap();
solidcraft_io::vfs::scoped(Some(solidcraft_io::vfs::PathScope::new([inside])), || {
assert_eq!(state(&good).map(|s| s.0), Some(LinkState::NotAllowed));
assert_eq!(link_presence(&missing, None).map(|p| p.state), Some(LinkState::NotAllowed));
});
let words: Vec<&str> = [LinkState::UpToDate, LinkState::OutOfDate, LinkState::Missing, LinkState::Unreadable, LinkState::NotAllowed]
.iter()
.map(|s| s.word())
.collect();
assert_eq!(words, ["up_to_date", "out_of_date", "missing", "unreadable", "not_allowed"]);
assert_eq!(serde_json::json!(LinkState::NotAllowed), serde_json::json!("not_allowed"));
let _ = std::fs::remove_dir_all(&dir);
}
/// A network location not allowed, or a device, is refused from the path's text alone:
/// reading it asks nothing of the disk or the network (#7372).
#[test]
fn network_and_device_paths_are_refused_before_anything_is_read() {
let calls = solidcraft_io::vfs::file_calls();
let network = Path::new(r"\\fileserver\share\standard.sc");
let refused = read_linked_file(network, None).err();
assert!(matches!(&refused, Some(p) if p.state == LinkState::NotAllowed && p.reason.contains("fileserver")), "{refused:?}");
assert_eq!(link_presence(network, None).map(|p| p.state), Some(LinkState::NotAllowed));
let device = Path::new(r"\\.\pipe\standard.sc");
assert_eq!(read_linked_file(device, None).err().map(|p| p.state), Some(LinkState::Unreadable));
assert_eq!(solidcraft_io::vfs::file_calls(), calls, "a refused path asked the file system");
}
#[test]
fn a_path_and_its_alias_are_the_same_file() {
let dir = folder("same");
let file = dir.join("a.sc");
std::fs::write(&file, b"{}").unwrap();
assert!(same_file(&file, &dir.join(".").join("a.sc")));
assert!(!same_file(&file, &dir.join("b.sc")));
#[cfg(unix)]
{
let alias = dir.join("alias.sc");
std::os::unix::fs::symlink(&file, &alias).unwrap();
assert!(same_file(&file, &alias));
}
let _ = std::fs::remove_dir_all(&dir);
}
}
-8
View File
@@ -2124,14 +2124,6 @@ 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"
+38 -99
View File
@@ -2,7 +2,6 @@
use serde_json::{Value, json};
use solidcore_record::Vec3;
use solidcraft_doc::construct::DatumItem;
use solidcraft_doc::{
AxisRef, Direction, Expr, Extent, FeatureKind, Operation, PointExpr, ProfileSel, RailFit, SweepOrientation, SweepRail, ThinSide, ThinWall,
Touching, expr::Kind, point_expr,
@@ -64,7 +63,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]; 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?"),
.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?"),
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 +133,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); 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)"),
.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)"),
CommandSpec::new("solid.pipe", "Pipe", crate::checked!("solid.pipe", pipe))
.at("SOLID", "CREATE")
.icon("pipe")
@@ -429,11 +428,6 @@ pub(super) fn stable_profiles(s: &Session, kind: &mut FeatureKind, exact: bool)
let st = s.model.state();
for (sketch, sel) in kind.profile_sels_mut() {
let Some(ss) = st.sketch(sketch) else { continue };
// An empty selection (a patch asked for only the sketch's 3D loops, #741) has nothing to
// capture: it stays empty.
if matches!(sel, ProfileSel::Indices { indices } if indices.is_empty()) {
continue;
}
let indices = match sel {
ProfileSel::Indices { indices } => {
let have = ss.profiles.len();
@@ -1398,8 +1392,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
/// A revolve axis of `sketch` from its parameter: a sketch line id, the sketch's x or y axis, a
/// world axis (also as saved: {type: world, axis: X|Y|Z}), a construction axis by name
/// (followed when it is edited), a component origin axis (saved as its datum, so it follows the
/// placement), {origin, dir} (each origin coordinate a number or an expression over
/// (followed when it is edited), {origin, dir} (each origin coordinate a number or an expression over
/// the parameters, which the axis follows), {sketch, curve} (a line of another sketch, followed in
/// that sketch, #6548; also as saved: {type: sketch_line, …}), or an edge
/// or round face of the model ({edge|face: [x,y,z]}), which the revolve follows as the model
@@ -1430,13 +1423,6 @@ pub(super) fn axis_param(s: &Session, sketch: u64, p: &Value, cmd: &str) -> Resu
Some(a @ ("X" | "Y" | "Z")) => Ok(AxisRef::World { axis: a.into() }),
Some(a @ ("x" | "y")) => Ok(AxisRef::SketchAxis { axis: a.into() }),
Some(name) if !is_sketch_line(s, sketch, name) && is_construction_axis(s, name) => Ok(AxisRef::Construction { name: name.into() }),
// A component origin axis: saved as its datum, so the revolve follows the placement.
Some(name) if !is_sketch_line(s, sketch, name) => {
match crate::origins::datum(name).filter(|d| matches!(d.item, DatumItem::X | DatumItem::Y | DatumItem::Z)) {
Some(datum) => Ok(AxisRef::Model { geo: solidcraft_doc::construct::GeoRef::Datum { datum } }),
None => Ok(AxisRef::SketchLine { curve: name.into(), sketch: None }),
}
}
Some(id) => Ok(AxisRef::SketchLine { curve: id.into(), sketch: None }),
None => {
Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}"))
@@ -2543,39 +2529,33 @@ fn axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<so
if let Some((geo, o, d)) = super::construct::model_axis_param(s, p.get("axis"), cmd)? {
return Ok((o, d, Some(geo), None));
}
named_axis_line(s, p, cmd)
let (o, d, name) = named_axis_line(s, p, cmd)?;
Ok((o, d, None, name))
}
/// An axis line from `axis`: X, Y, Z, a construction axis by name, a component origin axis (its
/// datum, followed) or {origin, dir}. A construction axis comes back where it is now, with its
/// name; a component origin axis with the datum that finds it again on each evaluation.
fn named_axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<solidcraft_doc::construct::GeoRef>, Option<FeatureName>)> {
if let Some(o @ Value::Object(_)) = p.get("axis") {
let (origin, dir) = axis_line_object(o).map_err(|why| bad(cmd, why))?;
return Ok((origin, dir, None, None));
/// An axis line from `axis`: X, Y, Z, a construction axis by name or {origin, dir}. A construction
/// axis comes back where it is now, with its name.
fn named_axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<FeatureName>)> {
match p.get("axis") {
Some(Value::String(a)) => match a.to_ascii_uppercase().as_str() {
"X" => Ok((Vec3::ZERO, Vec3::X, None)),
"Y" => Ok((Vec3::ZERO, Vec3::Y, None)),
"Z" => Ok((Vec3::ZERO, Vec3::Z, None)),
_ if is_construction_axis(s, a) => {
let (_, _, origin, dir) = crate::view::construction_axes(s)
.into_iter()
.find(|(_, name, _, _)| name == a)
.ok_or_else(|| bad(cmd, format!("construction axis `{a}` has no geometry: fix its definition first")))?;
Ok((origin, dir, Some(a.as_str().into())))
}
_ => Err(bad(cmd, "axis must be X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}")),
},
Some(o @ Value::Object(_)) => {
let (origin, dir) = axis_line_object(o).map_err(|why| bad(cmd, why))?;
Ok((origin, dir, None))
}
_ => Err(bad(cmd, "`axis` is required")),
}
let Some(a) = str_(p, "axis") else { return Err(bad(cmd, "`axis` is required")) };
// The world axes keep the precedence they always had: X, Y and Z name the design's axes
// before a construction axis answers to the same letters (an older design can hold one
// named `x`, and a case-only match must not take them) (#7313 review).
match a.to_ascii_uppercase().as_str() {
"X" => return Ok((Vec3::ZERO, Vec3::X, None, None)),
"Y" => return Ok((Vec3::ZERO, Vec3::Y, None, None)),
"Z" => return Ok((Vec3::ZERO, Vec3::Z, None, None)),
_ => {}
}
if is_construction_axis(s, a) {
let (_, _, origin, dir) = crate::view::construction_axes(s)
.into_iter()
.find(|(_, name, _, _)| name == a)
.ok_or_else(|| bad(cmd, format!("construction axis `{a}` has no geometry: fix its definition first")))?;
return Ok((origin, dir, None, Some(a.into())));
}
if let Some(datum) = crate::origins::datum(a).filter(|d| matches!(d.item, DatumItem::X | DatumItem::Y | DatumItem::Z)) {
let (origin, dir) = s.origin_axis(a).ok_or_else(|| bad(cmd, format!("no axis `{a}`")))?;
return Ok((origin, dir, Some(solidcraft_doc::construct::GeoRef::Datum { datum }), None));
}
Err(bad(cmd, "axis must be X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}"))
}
/// An axis line `{origin?, dir}` as [`named_axis_line`] reads it: the origin (default: the
@@ -2845,18 +2825,7 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
}
Ok(PlaneRef::Origin { name: n.trim().to_ascii_uppercase() })
}
Some(Value::String(n)) => {
// A construction plane feature by name first, so a name that looks like a datum key
// still reaches it; a component origin plane is saved as its datum, so it follows
// the placement instead of staying where it was picked.
if s.doc.find_feature(n.trim()).is_some() {
return construction(n.trim());
}
match crate::origins::datum(n).filter(|d| matches!(d.item, DatumItem::XY | DatumItem::XZ | DatumItem::YZ)) {
Some(datum) => Ok(PlaneRef::Datum { datum }),
None => construction(n.trim()),
}
}
Some(Value::String(n)) => construction(n.trim()),
Some(Value::Number(id)) => match id.as_u64() {
Some(id) => construction(&id.to_string()),
None => Err(bad(cmd, format!("`plane` must be {WANT}"))),
@@ -3205,7 +3174,6 @@ 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)"));
@@ -3430,7 +3398,6 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
ease_out,
to: to.map(FacePick),
references,
old_surface_start,
},
)
}
@@ -3637,9 +3604,13 @@ 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, path, path_body, path_edges) = sweep_path(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 (orientation, twist, scale) = sweep_options(s, p, cmd)?;
let (rails, rail_fit) = sweep_rails(s, p, cmd, path_body.is_none().then_some(path_sketch))?;
let (rails, rail_fit) = sweep_rails(s, p, cmd, path_sketch)?;
let operation = operation(p, cmd)?;
let targets = face_join_targets(s, p, operation, &[sketch], cmd)?;
add_feature(
@@ -3650,8 +3621,6 @@ 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,35 +3632,6 @@ 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>)> {
@@ -3744,11 +3684,10 @@ 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, 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)?;
/// 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))?;
if !rails.is_empty() {
for key in ["orientation", "twist", "scale"] {
if p.get(key).is_some_and(|v| !v.is_null()) {
@@ -2784,31 +2784,3 @@ 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}");
}
-1
View File
@@ -425,7 +425,6 @@ 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<_>>()?;
+22 -66
View File
@@ -11,14 +11,13 @@ 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, TimelineSummary, WireArcInfo,
SketchWire, TimelineEntry, WireArcInfo,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -59,11 +58,7 @@ 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); \
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)",
),
.params("measure?: bool (include body measurements); sketch_counts?: bool (each sketch also gives its plane and its number of points and constraints)"),
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, \
@@ -433,23 +428,6 @@ fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Opti
holes: p.hole_curves.iter().map(|h| solidcraft_sketch::distinct_curves(h)).collect(),
})
.collect();
// Planar loops the 3D sketch curves close in planes of their own (#741): the plane is given
// as an origin and a unit normal, and the centroid is in the same frame as the sketch plane.
let profiles_3d: Vec<Value> = ss
.profiles_3d
.iter()
.enumerate()
.map(|(i, p)| {
json!({
"index": i,
"area_mm2": p.area,
"plane": { "origin": p.plane.origin, "normal": p.plane.normal() },
"centroid": p.centroid,
"curves": p.outer_curves,
"holes": p.hole_curves,
})
})
.collect();
let wires = sk
.wires
.iter()
@@ -480,7 +458,6 @@ fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Opti
points,
constraints,
profiles,
profiles_3d,
links: serde_json::to_value(&sk.links).unwrap_or_default(),
wires,
visible: crate::view::sketch_shown(s, id),
@@ -549,8 +526,6 @@ 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
@@ -568,41 +543,27 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let marker = s.doc.marker.unwrap_or(s.doc.features.len());
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 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 definitions = s.model.state();
// Only bodies given in full are measured.
let with_measure = bool_(p, "measure").unwrap_or(false) && bodies_detail == BodiesDetail::Full;
let with_measure = bool_(p, "measure").unwrap_or(false);
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 =
@@ -622,11 +583,6 @@ 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()
+11 -69
View File
@@ -47,9 +47,6 @@ 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")
@@ -239,12 +236,9 @@ 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. 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.
/// solve leaves that joint out and says why.
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
@@ -253,10 +247,7 @@ fn reexpress(s: &mut Session) {
.enumerate()
.filter(|(_, j)| !j.exprs.is_empty())
.filter_map(|(i, j)| {
let (mut o, a, mut q) = joints::expressed(&params, j).ok()?;
if j.constraint == Some(joints::Constraint::Tangent) {
o += s.doc.origin_radius(&st, &j.b).ok()?;
}
let (o, a, mut q) = joints::expressed(&params, j).ok()?;
joints::clamp(j, &mut q);
((o, a) != (j.offset, j.angle) || q != j.values).then_some((i, (o, a, q)))
})
@@ -1161,14 +1152,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, constraints and loop-closing typed joints start where their parts are. A typed joint in
// Relations 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.starts_where_it_is() || closes_loop(s, &j) || datum_sided(&j))
&& (j.kind.is_relation() || closes_loop(s, &j) || datum_sided(&j))
&& let Some(q) = joints::relation_values(&s.doc, &s.model.state(), &j)
{
j.values = q;
@@ -1354,13 +1345,6 @@ 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") {
@@ -1385,14 +1369,13 @@ fn new_joint(s: &mut Session, p: &Value, kind: JointKind, a: JointOrigin, b: Joi
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);
}
Ok(j)
add_joint(s, j, p)
}
fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1432,7 +1415,6 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
};
add_joint(s, j, p)
}
@@ -1654,26 +1636,9 @@ 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 {
@@ -1708,10 +1673,7 @@ 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();
// 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(|| {
let stopped_by_limit = (!reached.limits.is_empty()).then(|| {
reached
.limits
.iter()
@@ -1735,8 +1697,7 @@ 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.filter(|_| !contact_stopped).and_then(name).map(|j| ReachLoopStop { joint: j.name.clone() }),
stopped_by_contact,
stopped_by_loop: reached.stopped_by_loop.and_then(name).map(|j| ReachLoopStop { joint: j.name.clone() }),
reason: r.err(),
continuous: (bool_(p, "continuous") == Some(true)).then_some(true),
param_warnings: Vec::new(),
@@ -2071,12 +2032,6 @@ 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") {
@@ -2114,14 +2069,6 @@ pub(super) fn edit_joint(s: &mut Session, p: &Value, plain: bool) -> Result<Valu
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());
@@ -2136,10 +2083,6 @@ pub(super) fn edit_joint(s: &mut Session, p: &Value, plain: bool) -> Result<Valu
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;
@@ -2177,11 +2120,11 @@ pub(super) fn edit_joint(s: &mut Session, p: &Value, plain: bool) -> Result<Valu
&& 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 or a constraint whose type, offset, tilt or flip changes, or whose
// sides move, starts again from where its parts are.
// Otherwise a relation 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.starts_where_it_is()).cloned();
let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).filter(|j| j.kind.is_relation()).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)
{
@@ -2275,7 +2218,6 @@ 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();
-1
View File
@@ -64,7 +64,6 @@ 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,54 +425,3 @@ 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,7 +257,6 @@ 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;
@@ -374,436 +373,3 @@ 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)));
}
+2 -6
View File
@@ -42,12 +42,8 @@ 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 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}`")))?;
let body = target(s, p, cmd)?;
let source = state.body(&body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
if !source.body.is_mesh() {
return Err(bad(cmd, "select a mesh body"));
}

Some files were not shown because too many files have changed in this diff Show More