Compare commits

..
375 changed files with 5487 additions and 51381 deletions

No files matched your search

-25
View File
@@ -1,25 +0,0 @@
SolveCraft is open-source parametric 3D CAD in Rust: sketch, constrain, extrude, and change a
parameter to watch the timeline rebuild. [Documentation](https://bherbruck.github.io/solvecraft/)
· [try it in the browser](https://bherbruck.github.io/solvecraft/app/) ·
[contributing](https://github.com/bherbruck/solvecraft/blob/main/CONTRIBUTING.md) ·
[issues](https://github.com/bherbruck/solvecraft/issues). AI agents can model with it over MCP:
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solvecraft/guide/mcp.html)).
**Which file?**
- **Windows:** `SolveCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
`-portable.zip` (x64, x86 and arm64).
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag SolveCraft onto Applications.
The CLI is in `solvecraft-cli-{version}-macos-universal.zip`.
- **Linux** (x86_64 and aarch64, glibc 2.35+): the `.AppImage` (`chmod +x`, then run it), the
`.deb` or `.rpm`, the `.flatpak` (`flatpak install --user <file>`), or the `.tar.gz`.
- **FreeBSD:** `tar -xzf solvecraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
- **Web:** `solvecraft-web-{version}.zip` is a static site to host yourself.
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`.
The builds aren't signed yet. On macOS, right-click SolveCraft in Applications and choose
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolveCraft.app`).
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
Licences are built in (Help ▸ About ▸ Licences, `solvecraft-cli licences`); the packages also
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
[ROADMAP.md](https://github.com/bherbruck/solvecraft/blob/main/ROADMAP.md#known-limitations).
-206
View File
@@ -1,206 +0,0 @@
# Fast checks always run; native builds run for code/CI changes.
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
name: CI
on:
pull_request:
workflow_dispatch:
inputs:
cross_compile:
description: "Also check WebAssembly and RISC-V (before a release)"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
jobs:
changes:
name: Select checks
runs-on: ubuntu-24.04
outputs:
native: ${{ steps.paths.outputs.native }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- id: paths
name: Detect code and CI changes
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
native=false
if [ "$EVENT_NAME" = workflow_dispatch ]; then
native=true
else
# Compare both old and new paths of renames; unknown paths run native checks.
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/ci-paths"
while IFS= read -r -d '' path; do
case "$path" in
.github/workflows/ci.yml) native=true ;;
*.rs|Cargo.toml|Cargo.lock|*/Cargo.toml|.cargo/*|rust-toolchain*) native=true ;;
docs/*|*.md|.github/*|packaging/*|LICENSE*|NOTICE|THIRD-PARTY-LICENSES.txt) ;;
*) native=true ;;
esac
done < "$RUNNER_TEMP/ci-paths"
fi
echo "native=$native" >> "$GITHUB_OUTPUT"
checks:
name: Format and repository checks
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: repo-checks
- name: Format
run: cargo fmt --all -- --check
- name: Asset attribution
run: cargo xtask assets
- name: Licences
run: cargo xtask licences --check
- name: Dependency layers
run: cargo xtask layers
clippy:
name: Native Linux Clippy
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-clippy
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
tests:
name: Native Linux tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 90
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-tests
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- name: Compile tests
run: cargo test --workspace --profile ci --locked --no-run
- name: Run tests
# Every test binary at once (cargo test runs them one after another).
run: cargo xtask test --locked
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
# Preserve the existing check name for branch protection, including docs-only PRs.
ci:
needs: [changes, checks, clippy, tests]
if: always()
runs-on: ubuntu-24.04
steps:
- name: Verify required checks
env:
CHANGES: ${{ needs.changes.result }}
CHECKS: ${{ needs.checks.result }}
NATIVE: ${{ needs.changes.outputs.native }}
CLIPPY: ${{ needs.clippy.result }}
TESTS: ${{ needs.tests.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
if [ "$NATIVE" = true ]; then
test "$CLIPPY" = success
test "$TESTS" = success
fi
wasm:
name: Compile WebAssembly (manual)
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: wasm
- run: cargo xtask wasm
riscv64-gates:
name: Compile RISC-V (manual)
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: riscv64
- name: Install cross-compilation dependencies
run: |
# Ubuntu's regular archive serves amd64; RISC-V packages live in ports.
# The 24.04 runner uses deb822 sources, so scope every Ubuntu stanza.
sudo sed -i '/^Architectures:/d; /^Types:/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
sudo tee /etc/apt/sources.list.d/riscv64.sources >/dev/null <<'EOF'
Types: deb
URIs: http://ports.ubuntu.com/ubuntu-ports
Suites: noble noble-updates noble-backports noble-security
Components: main restricted universe multiverse
Architectures: riscv64
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
EOF
sudo dpkg --add-architecture riscv64
sudo apt-get update
sudo apt-get install -y gcc-riscv64-linux-gnu libasound2-dev:riscv64 libxkbcommon-dev:riscv64 \
libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 libxi-dev:riscv64 libgl1-mesa-dev:riscv64
- name: Cargo check on RISC-V
env:
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: 1
run: |
. "$HOME/.cargo/env"
export CRAFT_FONTS_DIR="$PWD/craft-fonts"
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
-161
View File
@@ -1,161 +0,0 @@
name: Publish Flatpak repository to Pages
on:
release:
types: [published]
workflow_dispatch:
inputs:
release_tag:
description: "Published GitHub release tag to publish (for example, v1.2.3)"
required: true
type: string
permissions:
contents: read
concurrency:
group: flatpak-pages-${{ github.repository }}
cancel-in-progress: false
jobs:
build:
name: Build Flatpak repository
runs-on: ubuntu-24.04
permissions:
contents: read
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
APP_ID: ai.storyteller.solvecraft
steps:
- name: Configure Pages
id: pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Install Flatpak repository tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak ostree
- name: Download and validate release bundles
shell: bash
run: |
set -euo pipefail
tag="$RELEASE_TAG"
if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::Release tag '$tag' is not strict SemVer prefixed with v"
exit 1
fi
published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \
'select(.draft == false and .published_at != null) | .tag_name')"
if [[ "$published_tag" != "$tag" ]]; then
echo "::error::$tag is not a published release in $GITHUB_REPOSITORY"
exit 1
fi
version="${tag#v}"
mkdir -p bundles
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern SHA256SUMS.txt --dir bundles
for arch in x86_64 aarch64; do
asset="solvecraft-$version-linux-$arch.flatpak"
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$asset" --dir bundles
test -s "bundles/$asset"
(
cd bundles
awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \
SHA256SUMS.txt | sha256sum --check -
)
done
test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2
for arch in x86_64 aarch64; do
asset="bundles/solvecraft-$version-linux-$arch.flatpak"
validation_repo="$(mktemp -d)"
ostree --repo="$validation_repo" init --mode=archive-z2
flatpak build-import-bundle "$validation_repo" "$asset"
expected_ref="app/$APP_ID/$arch/stable"
ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || {
echo "::error::$(basename "$asset") does not contain $expected_ref"
exit 1
}
rm -rf "$validation_repo"
done
- name: Build repository and site
shell: bash
env:
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
run: |
set -euo pipefail
site="$RUNNER_TEMP/site"
repo_dir="$site/flatpak/repo"
mkdir -p "$repo_dir"
ostree --repo="$repo_dir" init --mode=archive-z2
previous_url="${PAGES_BASE_URL%/}/flatpak/repo"
if curl --fail --silent --show-error --location \
--output /dev/null "$previous_url/summary"; then
ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url"
ostree --repo="$repo_dir" pull --mirror previous
ostree --repo="$repo_dir" remote delete previous
fi
version="${RELEASE_TAG#v}"
for arch in x86_64 aarch64; do
flatpak build-import-bundle "$repo_dir" \
"bundles/solvecraft-$version-linux-$arch.flatpak"
done
flatpak build-update-repo \
--title="SolveCraft" \
--comment='Official SolveCraft releases' \
--description='Official Flatpak repository for SolveCraft' \
--homepage="https://github.com/$GITHUB_REPOSITORY" \
--default-branch=stable \
"$repo_dir"
for arch in x86_64 aarch64; do
ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null
done
test -s "$repo_dir/summary"
base_url="${PAGES_BASE_URL%/}"
repo_url="$base_url/flatpak/repo/"
cat >"$site/flatpak/solvecraft.flatpakrepo" <<META
[Flatpak Repo]
Title=SolveCraft
Comment=Official SolveCraft releases
Description=Official Flatpak repository for SolveCraft
Url=$repo_url
Homepage=https://github.com/$GITHUB_REPOSITORY
META
cat >"$site/flatpak/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolveCraft Flatpak repository</title></head><body><main><h1>SolveCraft Flatpak repository</h1><p>Latest published release: <strong>$RELEASE_TAG</strong></p><p><a href="solvecraft.flatpakrepo">Add the SolveCraft repository</a></p><pre>flatpak remote-add --if-not-exists solvecraft $base_url/flatpak/solvecraft.flatpakrepo
flatpak install solvecraft $APP_ID</pre></main></body></html>
HTML
cat >"$site/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolveCraft downloads</title></head><body><main><h1>SolveCraft downloads</h1><p><a href="flatpak/">Flatpak repository</a></p><p><a href="https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG">Release assets</a></p></main></body></html>
HTML
touch "$site/.nojekyll"
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: ${{ runner.temp }}/site
include-hidden-files: true
deploy:
name: Deploy Pages
needs: build
runs-on: ubuntu-24.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
-53
View File
@@ -1,53 +0,0 @@
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, the
# packaging shell and PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
# Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
name: Packaging lint
on:
push:
branches: [main]
paths: [".github/workflows/**", "packaging/**"]
pull_request:
paths: [".github/workflows/**", "packaging/**"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: packaging-lint-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: '"$RUNNER_TEMP/actionlint" -color'
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/solvecraft.wxs packaging/windows/solvecraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.solvecraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solvecraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solvecraft'"
-65
View File
@@ -1,65 +0,0 @@
# The documentation site on GitHub Pages: the book (docs/book) at /solvecraft/ and the web app
# at /solvecraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
# repository is private.
name: Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
MDBOOK_VERSION: v0.5.4
TRUNK_VERSION: v0.21.14
jobs:
build:
if: ${{ !github.event.repository.private }}
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Install mdbook and trunk
run: |
mkdir -p "$HOME/.local/bin"
curl -sSL "https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
curl -sSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Build the book
run: cargo xtask book
- name: Build the web app
working-directory: apps/solvecraft-web
run: trunk build --release --public-url /solvecraft/app/
- name: Assemble the site
run: |
mkdir -p site
cp -r target/book/. site/
cp -r dist/web site/app
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: site
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
-370
View File
@@ -1,370 +0,0 @@
# Release pipeline, after PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
#
# Push to `release` to build packages and create/update a DRAFT GitHub Release. The version
# comes from [workspace.package] in Cargo.toml. An agent verifies the artifacts and publishes
# the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases.
# A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
#
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .tar.gz packaging/linux
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
# macOS universal .dmg, and the CLI as a .zip packaging/macos
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
# Web a .zip of the static site packaging/web
#
# A dry run: dispatch with draft=false (the default). Only the release branch may create drafts.
name: Release
on:
push:
branches: [release]
workflow_dispatch:
inputs:
draft:
description: "Create/update a draft (release branch only; off: build artifacts only)"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
name: Resolve version
env:
REQUEST_DRAFT: ${{ inputs.draft || false }}
run: |
. packaging/env.sh
if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$'; then
echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
if [ "$REQUEST_DRAFT" = true ] && [ "$GITHUB_REF" != refs/heads/release ]; then
echo "::error::Draft releases must be built from the release branch"
exit 1
fi
case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
echo "### SolveCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
icons:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-icons
- run: cargo build --release --locked -p solvecraft-cli
- run: |
. packaging/env.sh
make_icons target/release/solvecraft-cli icons
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: icons
path: icons
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends desktop-file-utils appstream zsync rpm
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
- name: Build and package (AppImage, deb, rpm, tar.gz)
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft$|usr/bin/solvecraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
rpm -qip dist/release/*.rpm
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
ldd target/release/solvecraft
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder than 22.04 ships.
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/solvecraft-$SOLVECRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
runs-on: ubuntu-latest
timeout-minutes: 180
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "SOLVECRAFT_VERSION SOLVECRAFT_BUILD_DATE"
prepare: |
pkg install -y bash git curl pkgconf libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
# runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/solvecraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
tar -tzf dist/release/solvecraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/solvecraft$|/bin/solvecraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solvecraft/THIRD-PARTY-LICENSES\.txt$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
macos:
name: macOS universal
needs: version
runs-on: macos-14
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Build and package (dmg, CLI zip; the dmg is mounted and checked)
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
windows:
name: Windows ${{ matrix.arch }}
needs: [version, icons]
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: icons
path: icons
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-windows-${{ matrix.arch }}
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (msi, portable zip)
shell: pwsh
env:
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
- name: Build and zip
run: packaging/web/package.sh
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-web
path: dist/release/*
if-no-files-found: error
release:
name: Draft GitHub Release or dry-run artifacts
needs: [version, linux, flatpak, freebsd, macos, windows, web]
runs-on: ubuntu-latest
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums and notes
run: |
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
- name: Create or update the draft release
if: github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft)
working-directory: artifacts
run: |
tag="v$VERSION"
# Refuse existing tags: a draft must point at this build, never an older tag.
# matching-refs returns [] for an absent tag and fails on API/auth errors.
existing_refs="$(gh api "repos/$GH_REPO/git/matching-refs/tags/$tag" \
--jq '.[].ref')"
if printf '%s\n' "$existing_refs" | grep -Fxq "refs/tags/$tag"; then
echo "::error::Tag $tag already exists. Choose a new version; never move a release tag."
exit 1
fi
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != true ]; then
echo "::error::Release $tag is already published. Bump the version before releasing."
exit 1
fi
# Replace the complete draft asset set, including assets removed in this build.
assets="$(gh release view "$tag" --json assets --jq '.assets[].name')"
while IFS= read -r asset; do
if [ -n "$asset" ]; then gh release delete-asset "$tag" "$asset" --yes; fi
done <<<"$assets"
gh release edit "$tag" --draft --title "SolveCraft v$VERSION" \
--target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
gh release create "$tag" --draft --title "SolveCraft v$VERSION" \
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft **SolveCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
- name: Keep the files (dry run)
if: github.event_name == 'workflow_dispatch' && !inputs.draft
run: cp notes.md artifacts/release-notes.md
- if: github.event_name == 'workflow_dispatch' && !inputs.draft
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-assets
path: artifacts/*
-1
View File
@@ -6,4 +6,3 @@
*.swp
/.claude/worktrees/
/target-snapshot
__pycache__/
+3 -13
View File
@@ -50,8 +50,7 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
`solid.extrude` or `sketch.rectangle.two_point`, never another product's command id; label;
(`CommandSpec`: id = Fusion's command id where Fusion has the command, else a dotted id; label;
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + tests. The toolbar,
dialogs, sketch tools, palette, scripts, CLI and control channel all reach the same commands.
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form; only toolbar
@@ -71,21 +70,12 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
version bumps solely from commit prefixes, overwrite published versions, or push release tags
before the draft is verified.
## Running and looking at the app
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":1,"method":"engine.execute","params":{"command":"Extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"SketchCreate"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
`ui.render` or `solvecraft-cli snapshot`.
-8
View File
@@ -18,12 +18,6 @@ Generated-in-code assets are original and have no file to list:
| File | Author | Source | Licence |
|---|---|---|---|
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
| docs/screenshots/demo.gif | SolveCraft contributors | screen recording of SolveCraft itself driven by examples/demo (3x speed) | MIT OR Apache-2.0 |
| docs/book/src/generated/sample-plate.png | SolveCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/bracket.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/enclosure.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/revolve.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/sheet-metal.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
| crates/sketch/fonts/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/fonts/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
## Code adapted from sibling projects
@@ -40,7 +34,6 @@ Generated-in-code assets are original and have no file to list:
| crates/io/src/threemf.rs (bounded zip part reading and writing) | GridCraft `crates/xlsx/src/package.rs` | MIT OR Apache-2.0 |
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solvecraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
| .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 |
## Vendored third-party code
@@ -49,4 +42,3 @@ Generated-in-code assets are original and have no file to list:
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
+3 -13
View File
@@ -50,8 +50,7 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
`solid.extrude` or `sketch.rectangle.two_point`, never another product's command id; label;
(`CommandSpec`: id = Fusion's command id where Fusion has the command, else a dotted id; label;
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + tests. The toolbar,
dialogs, sketch tools, palette, scripts, CLI and control channel all reach the same commands.
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form; only toolbar
@@ -71,21 +70,12 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
version bumps solely from commit prefixes, overwrite published versions, or push release tags
before the draft is verified.
## Running and looking at the app
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":1,"method":"engine.execute","params":{"command":"Extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"SketchCreate"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
`ui.render` or `solvecraft-cli snapshot`.
-159
View File
@@ -1,159 +0,0 @@
# Contributing to SolveCraft
SolveCraft is an open-source parametric 3D CAD app written in pure Rust, in the style of
Autodesk Fusion. You sketch on a plane, constrain the sketch, turn profiles into solids, and
change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, Linux), in
the browser (wasm + WebGPU/WebGL2), headless from a CLI, and as an MCP server that AI agents can
model with.
Help is welcome, whether that's a one-line fix or owning a whole area. This page covers how to
build it, how the code is laid out, the few rules that aren't negotiable, and where help is
needed most.
## Get it running (about 10 minutes on a fast machine)
You need stable Rust 1.90 or newer. For the web
build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://trunkrs.dev).
```sh
git clone https://github.com/bherbruck/solvecraft && cd solvecraft
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
cargo xtask test # unit, engine and UI-scenario tests
cargo xtask ci # everything a PR must pass (see below)
```
Web: `cd apps/solvecraft-web && trunk serve --release`, then open the printed URL (`?sample`
opens the sample part, `?webgl` forces WebGL2). Saving and opening files in the browser needs
HTTPS or localhost, because it uses the Origin Private File System.
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
## How the code is laid out
The [README](README.md#architecture) has the layer table. The short version:
- `crates/geom`: vectors, planes, meshes, measures.
- `crates/sketch`: our own constraint solver, plus profile finding.
- `crates/kernel`: the B-rep kernel boundary. It wraps [truck](https://github.com/ricosjp/truck),
and it's the only crate allowed to touch truck. Fillets, chamfers, shell/offset, Delete Face,
Split Face, STEP/IGES read and write, and face provenance are our own code here. Patched
copies of three truck crates live in `vendor/` and are excluded from our fmt/clippy rules.
- `crates/doc`: the document. It holds parameters and expressions, the feature timeline and its
incremental rebuild, components and occurrences, joints, appearances, and persistent naming.
- `crates/io`: design files and mesh formats (3MF, STL, OBJ).
- `crates/engine`: the session and the command registry. **Everything the user can do is a
command**, and the toolbar, dialogs, scripts, CLI, control channel and MCP all run the same
commands.
- `crates/ui-egui`: the desktop/web front end (egui + wgpu). It's swappable; nothing below it
depends on egui.
- `crates/mcp`, `apps/solvecraft`, `apps/solvecraft-cli`, `apps/solvecraft-web`.
`cargo xtask layers` fails the build if a lower layer depends on a higher one.
## Rules that aren't negotiable
1. **Clean-room.** We observe Fusion as a black box: its UI, how parts it builds measure, and
what it does with a given input. We never copy Autodesk code, icons, artwork, help text,
tooltips, templates or materials, and we never commit files Fusion produced. Command ids are
our own (`solid.fillet`, not Fusion's internal names).
2. **No GPL/LGPL/AGPL code, not even reading it.** That rules out OpenCASCADE, FreeCAD
(including planegcs), SolveSpace, CadQuery/OCP, LibreCAD and QCAD. SolveCraft is
MIT OR Apache-2.0, and its solver and blends are written from the maths. Permissive
dependencies are fine. Note which one you're adding and why in the PR, and run
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
3. **Every asset is original or openly licensed and has a row in
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
`cargo xtask assets` enforces this.
4. **Never crash.** People trust a CAD app with their work.
- Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data.
There's no `unsafe`, and clippy denies it.
- An unsupported case returns an error ("not supported yet: …"); it never panics.
- Every call into truck goes through `solvecraft_kernel::guard`.
- Every crash fix comes with a regression test.
5. **Everything is a command, with a test.** A new feature means a `CommandSpec` in
`crates/engine/src/cmd/`, engine tests that measure the result (volume, area,
face/edge/vertex counts against a value you can derive), and for UI work a scenario (below).
[AGENTS.md](AGENTS.md) has the long form of these rules. It's written for AI agents, but it's
the most complete description of the house style.
## Tests
| What | Where | Run |
|---|---|---|
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo xtask test` (or `cargo test --workspace --profile ci`) |
| UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | part of `cargo test` |
| Fusion oracle | `cargo xtask oracle` | needs the local oracle data (see below) |
**UI scenarios** drive the real app headless: start a tool, click a plane or a sketch point,
press keys, then assert on the result.
```json
[
{"start": "sketch.create"},
{"click": {"plane": "XY"}},
{"start": "sketch.rectangle.two_point"},
{"click": {"sketch": [0, 0]}},
{"click": {"sketch": [40, 30]}},
{"expect": {"errors": 0}}
]
```
A new `.json` file in that folder is picked up automatically. To record a bug before it's fixed,
put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the bug.
**Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables,
no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It
builds several times faster than `--release`. If a test fails only in a plain debug build, say so
in the PR. `cargo xtask test` builds the tests with it and then runs every test binary at once
(`cargo test` runs them one after another); arguments after `--` go to each binary, e.g.
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
depend on wall-clock timing.
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
SolveCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's
measurements; [docs/oracle.md](docs/oracle.md) has the current results. The recipes and
measurements live outside the repo (`plan/`, gitignored), because they come from a Fusion
install. If you work on geometry and want to check against it, ask in an issue.
## Sending a change
1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solvecraft/issues), labelled
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). Before starting,
comment on the issue so it gets the `in progress` label and two people don't build the same
thing. For anything bigger than a bug fix that has no issue yet, open one first.
2. Branch from `main` and keep the PR to one topic.
3. Run `cargo xtask ci` before pushing. It runs fmt, the asset, licence and layer checks,
clippy (`-D warnings`), then the tests with the wasm build check alongside. A PR has to pass it.
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
the expected ones).
Much of SolveCraft so far was written by AI coding agents (Claude) working in parallel, each
owning one area, with a human setting direction and reporting bugs. Their working notes are in
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
long as the change meets the rules above.
## Where help is wanted most
See the [`help wanted`](https://github.com/bherbruck/solvecraft/labels/help%20wanted) and
[`good first issue`](https://github.com/bherbruck/solvecraft/labels/good%20first%20issue) labels.
In order of how much they'd help users:
1. **Fillet and blend robustness.** Fillets are our own code (truck has no fillet operation).
Constant, variable-radius and chord fillets work, and so do corners where fillets of
different radii meet, including three rounds meeting at a vertex. Next are setback corners, fillets along curved edges between curved
faces, and fillets that consume neighbouring faces.
2. **Boolean robustness.** A random-design fuzzer currently gets about half of its designs
through without a kernel error. Each failing seed is a reproducible bug.
3. **Y-up / Z-up.** SolveCraft is Z-up only, so most STEP files (from SolidWorks, Onshape, or
Fusion's default) open lying on their backs. Fusion solves this with a "default modeling
orientation" preference.
4. **Imported appearances.** Colours from STEP files are lost when a feature changes the
imported body. They should become design appearances on import.
5. **Drawings, CAM and mesh tools.** These are out of scope for now (see
[ROADMAP.md](ROADMAP.md)), but anyone who wants to own one is welcome.
## Licence
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
of SolveCraft. SolveCraft isn't affiliated with Autodesk. Fusion is a trademark of Autodesk, Inc.
Generated
+119 -22
View File
@@ -422,6 +422,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -518,6 +524,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -673,7 +685,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
dependencies = [
"bytes",
"memchr",
"memchr 2.8.3",
]
[[package]]
@@ -1750,15 +1762,43 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lz4_flex"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05304f8e67dfc93d1b4b990137fd1a7a4c6ad44b60a9c486c8c4486f9d2027ae"
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "matext4cgmath"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6541e181de37f70f0aceb25441823a3d0efa9cc1d23f475a0d3926678949178"
dependencies = [
"cgmath",
"katexit",
"num-complex",
]
[[package]]
name = "memchr"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "148fab2e51b4f1cfc66da2a7c32981d1d3c083a803978268bb11fe4b86925e7a"
dependencies = [
"libc",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -1887,6 +1927,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05aec50c70fd288702bcd93284a8444607f3292dbdf2a30de5ea5dcdbe72287b"
dependencies = [
"memchr 1.0.2",
]
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -1896,6 +1945,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-derive"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2552,13 +2612,23 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8533f14c8382aaad0d592c812ac3b826162128b65662331e1127b45c3d18536b"
dependencies = [
"memchr 2.8.3",
"serde",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
"memchr 2.8.3",
]
[[package]]
@@ -2567,7 +2637,7 @@ version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
"memchr 2.8.3",
]
[[package]]
@@ -2888,7 +2958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"memchr 2.8.3",
"serde",
"serde_core",
"zmij",
@@ -3057,7 +3127,7 @@ dependencies = [
[[package]]
name = "solvecraft"
version = "0.1.4"
version = "0.1.0"
dependencies = [
"eframe",
"egui",
@@ -3066,12 +3136,11 @@ dependencies = [
"serde_json",
"solvecraft-engine",
"solvecraft-ui-egui",
"winit",
]
[[package]]
name = "solvecraft-cli"
version = "0.1.4"
version = "0.1.0"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3080,7 +3149,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.1.4"
version = "0.1.0"
dependencies = [
"log",
"serde",
@@ -3094,7 +3163,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.1.4"
version = "0.1.0"
dependencies = [
"log",
"serde",
@@ -3110,7 +3179,7 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.1.4"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
@@ -3118,7 +3187,7 @@ dependencies = [
[[package]]
name = "solvecraft-io"
version = "0.1.4"
version = "0.1.0"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
@@ -3130,7 +3199,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.1.4"
version = "0.1.0"
dependencies = [
"log",
"serde",
@@ -3145,7 +3214,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.1.4"
version = "0.1.0"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3153,7 +3222,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.1.4"
version = "0.1.0"
dependencies = [
"image",
"serde",
@@ -3162,7 +3231,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.1.4"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
@@ -3173,7 +3242,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.1.4"
version = "0.1.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -3187,13 +3256,11 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.1.4"
version = "0.1.0"
dependencies = [
"eframe",
"egui",
"js-sys",
"log",
"serde_json",
"solvecraft-engine",
"solvecraft-ui-egui",
"wasm-bindgen",
@@ -3235,6 +3302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
@@ -3470,6 +3538,7 @@ dependencies = [
"truck-geometry",
"truck-polymesh",
"truck-topology",
"vtkio",
]
[[package]]
@@ -3646,6 +3715,25 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vtkio"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abbe89e5b97b472d57abeb02755a06d75b28d2df7d1fe3df5baf032281a65c16"
dependencies = [
"base64",
"bytemuck",
"byteorder",
"flate2",
"lz4_flex",
"nom",
"num-derive",
"num-traits",
"quick-xml 0.22.0",
"serde",
"xz2",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -4412,7 +4500,7 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
"memchr 2.8.3",
]
[[package]]
@@ -4486,11 +4574,20 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.4"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "zbus"
version = "5.19.0"
@@ -4627,7 +4724,7 @@ dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"memchr 2.8.3",
"zopfli",
]
+2 -18
View File
@@ -5,12 +5,12 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.1.4"
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
authors = ["SolveCraft contributors"]
repository = "https://github.com/bherbruck/solvecraft"
repository = "https://github.com/storytold/solvecraft"
[workspace.dependencies]
# internal
@@ -72,21 +72,6 @@ opt-level = 2
[profile.dev]
opt-level = 1
# Tests and lints (`cargo xtask ci`, GitHub CI): quick to build, quick enough to run. Light
# optimisation, line tables only (file:line in backtraces), no LTO. Debug assertions are off because
# truck's debug-only checks reject valid geometry (seam edges, closed edges); that was the only
# reason the tests used to build with --release, which cost most of CI's time.
[profile.ci]
inherits = "dev"
opt-level = 1
debug = "line-tables-only"
debug-assertions = false
overflow-checks = true
incremental = false
[profile.ci.package."*"]
opt-level = 2
[profile.release]
opt-level = 3
lto = "thin"
@@ -98,4 +83,3 @@ debug = 0
truck-shapeops = { path = "vendor/truck-shapeops" }
truck-stepio = { path = "vendor/truck-stepio" }
truck-meshalgo = { path = "vendor/truck-meshalgo" }
matext4cgmath = { path = "vendor/matext4cgmath" }
+1 -5
View File
@@ -5,9 +5,7 @@ Licensed under the MIT License or the Apache License, Version 2.0, at your optio
Third-party material
Software dependencies are listed in Cargo.lock; their licences are in THIRD-PARTY-LICENSES.txt
(`cargo xtask licences`), which the programs also carry (Help > About > Licences,
`solvecraft-cli licences`). The geometry kernel builds on the truck crates
Software dependencies are listed in Cargo.lock. The geometry kernel builds on the truck crates
(truck-modeling, truck-shapeops, truck-meshalgo, truck-stepio, truck-topology and their
dependencies, https://github.com/ricosjp/truck), licensed under the Apache License 2.0 and used
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolveCraft
@@ -16,8 +14,6 @@ vendor/truck-shapeops/LICENSE. Copyright (c) RICOS Co. Ltd.
Some code is adapted from sibling projects in the storytold family (CADCraft, GridCraft), which are
licensed MIT OR Apache-2.0; ATTRIBUTION.md lists the files.
The release packaging (.github/workflows/release.yml, packaging/) is adapted from PhotoCraft
(https://github.com/storytold/photocraft), licensed under the Apache License 2.0.
None of the bundled assets come from third parties: every icon is drawn in code. The per-file
list is in ATTRIBUTION.md.
+19 -37
View File
@@ -1,25 +1,16 @@
# SolveCraft
**Parametric 3D CAD; an open-source, [clean-room](CLAUDE.md) reimplementation of Autodesk
Fusion 360, built in pure Rust.**
SolveCraft is an open-source parametric 3D CAD application written in Rust, in the style of
Autodesk Fusion: sketch on a plane, constrain and dimension the sketch, turn profiles into solids
with extrude and revolve, round edges, combine bodies, and change a parameter to watch the whole
timeline rebuild. It is a clean-room implementation (see [CLAUDE.md](CLAUDE.md)) and part of the
storytold "craft" family.
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
model through the built-in MCP server. Part of the storytold "craft" family.
![SolveCraft with the sample plate](docs/screenshots/sample-plate.png)
![An AI agent builds a parametric enclosure in SolveCraft over MCP, then the width is changed in the Parameters dialog and the model rebuilds (3x speed)](docs/screenshots/demo.gif)
*An agent builds an electronics enclosure over MCP: sketches on faces, shell, a port cut, one screw boss patterned to four, then width 80 → 100 mm in the Parameters dialog and the timeline rebuilds. 3× speed; [full video](https://github.com/bherbruck/solvecraft/releases/download/v0.1.0/enclosure-demo-v3.mp4), script in [examples/demo](examples/demo).*
**Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide,
developer guide and command reference; sources in [docs/book](docs/book)), and the
[web app](https://bherbruck.github.io/solvecraft/app/) to try it in the browser.
**Status: early but usable for simple parts.** 76 of 76 reference parts built in Fusion
rebuild in SolveCraft with the same volume, area and topology ([docs/oracle.md](docs/oracle.md));
command parity with Fusion's Design workspace is tracked in [docs/parity.md](docs/parity.md).
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solvecraft/issues),
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
**Status: early (milestone M0 done, M1 in progress).** See [ROADMAP.md](ROADMAP.md),
[docs/parity.md](docs/parity.md) (command parity with Fusion's Design workspace) and
[docs/oracle.md](docs/oracle.md) (parts built in Fusion, rebuilt and measured in SolveCraft).
## What works
@@ -39,14 +30,12 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
deleted.
- **Parameters** with unit-aware expressions (`2 * width + 5 mm`, `angle / 2`).
- **Files**: designs as JSON (`.solvecraft`), export to STEP (AP242 with body names, colours and
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ;
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
need B-rep bodies);
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori), 3MF, STL (binary/ASCII) and OBJ;
3MF and STL import as mesh bodies (they render, measure, move and export; solid features need
B-rep bodies);
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) as a base feature
that later features build on — open a `.step`/`.stp` file, insert one into a design, or drop it
on the window ([docs/step-import.md](docs/step-import.md)).
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
view cube and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
control channel so agents and tests can drive it.
@@ -78,10 +67,10 @@ A script is a JSON list of commands, the same ones the UI runs:
```json
{"commands": [
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "solid.extrude", "params": {"distance": 20}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}}
{"command": "SketchCreate", "params": {"plane": "XY"}},
{"command": "ShapeRectangleTwoPoint", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "Extrude", "params": {"distance": 20}},
{"command": "FusionFilletEdgesCommand", "params": {"edges": [[0, 0, 10]], "radius": 3}}
]}
```
@@ -97,7 +86,6 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
## Architecture
<!-- ANCHOR: architecture -->
| Layer | Crates | Role |
|---|---|---|
| L0 | `geom` | vectors, planes, profiles, meshes and their measures |
@@ -107,12 +95,6 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
| L4 | `engine` | the session and the command registry (everything is a command) |
| L5 | `ui-egui`, `mcp` | the swappable desktop front end; the MCP server (headless or bridged to the app) |
| apps | `solvecraft`, `solvecraft-cli` | desktop app, headless CLI |
<!-- ANCHOR_END: architecture -->
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md): how to build and test, the code map, the clean-room
and licence rules, and where help is wanted most.
## Licence
+13 -25
View File
@@ -1,28 +1,22 @@
# SolveCraft roadmap
Status as of **2026-10-10**. SolveCraft targets Fusion-style parametric modelling (sketch →
Status as of **2026-10-07**. SolveCraft targets Fusion-style parametric modelling (sketch →
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
## At a glance
| Question | Answer |
|---|---|
| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
| Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) |
| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Where are we? | **M0 done; M1/M2 in progress.** Sketch with solver, extrude (taper, two-sided, through all), revolve, fillet/chamfer, holes, patterns, mirror, shell, draft, loft, sweep, split, construction planes, parameters that rebuild the timeline, STEP/STL export, desktop and browser (wasm) app with Fusion-style selection (hover/selected highlights, origin planes, click-to-pick sketch planes, multi-select, window/crossing box selection, animated view cube), CLI, MCP server, oracle harness. |
| Command parity (in scope) | **139 / 541 (26%)** in-scope commands; SOLID + SKETCH **102 / 291 (35%)** — [docs/parity.md](docs/parity.md) |
| Fusion oracle | **72 / 76 parts match** (batch 1: 29/29; batch 2 in progress) — [docs/oracle.md](docs/oracle.md) |
| Tests | 85 (solver, profiles, kernel booleans/blends/measures, expressions, timeline, file formats, camera, engine end-to-end, hostile-input fuzz over every command) |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
| Weighted parity estimate | **≈ 5%** of Fusion's Design workspace by importance (sketch + basic solids are the core, but surfaces, assemblies, sheet metal, CAM, drawings are untouched) |
| Time to a useful alpha (M0–M6) | ≈ **150 agent hours** remain |
## Scope (owner decision, 2026-10-07)
The issue batch adds preservation of blind pockets in through cuts, associative projected
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
acceptance scenarios now include board resizes; STEP reopening is automated. Inspection in
Fusion remains an external acceptance step. See [WIP reconciliation](docs/wip-reconciliation.md).
In scope, in priority order:
1. SOLID and SKETCH (now).
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
@@ -50,22 +44,20 @@ headline counts in-scope tabs only and lists the deferred ones separately.
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 |
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. Left: sheet metal edges, kernel-reported face history (exact through coincident surfaces), more roles (sweep, loft, shell inner faces) | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, exact (unfaceted) modelled threads | in progress | 12 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, thicken (needs surfaces), split face, modelled threads | in progress | 12 |
| M8 | Surface workspace | deferred | — |
| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Bend (Fold) has no engine command yet | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: installers, signing, docs, performance | planned | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Left: configuration rules, per-row materials and appearances | in progress | 4 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (FusionStartDesignConfigModeCmd, FusionShowDesignConfigPanelCmd, config.column/row/activate). Left: configuration rules, per-row materials and appearances | in progress | 4 |
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/2"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
## Known limitations
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
- Fillets and chamfers: straight edges (convex or concave) between planar faces with perpendicular
planar end faces; whole smooth loops of a planar face, picked whole or by one edge (tangent
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
@@ -88,11 +80,7 @@ headline counts in-scope tabs only and lists the deferred ones separately.
product names, colours and assemblies; offset surfaces and pcurve-only edges are not read yet,
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
- Persistent naming (plan/adr/0002) names faces from their history and edges by their faces;
references resolve by name first and warn when the entity split or vanished. Kernel-reported
face history (exact through coincident surfaces) is not wired in yet.
- Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude
and Revolve To Object on a curved face cut the tool back to the target's surface.
- No persistent naming (fillet edges are re-found by position; projected sketch geometry too).
- Free-form curves cut at a tangent touch (no clean crossing) enter that profile as polylines.
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
- Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not:
File diff suppressed because it is too large. Load diff
-223
View File
@@ -1,223 +0,0 @@
//! `solvecraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
//!
//! Each seed runs in its own process (`fuzz-one`) with a time limit, so a hang or a hard crash
//! is recorded like any other failure.
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader, Read};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use solvecraft_engine::fuzz;
use solvecraft_engine::fuzz::boolean_case;
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(default)
}
/// One seed: a design and a boolean case, as JSON on stdout (progress on stderr).
pub fn one(args: &[String]) -> Result<(), String> {
let seed: u64 = args.first().and_then(|s| s.parse().ok()).ok_or("fuzz-one <seed> [--steps N] [--minimise]")?;
let steps = flag(args, "--steps", 8usize);
let minimise = args.iter().any(|a| a == "--minimise");
// Panics caught inside the kernel: harmless natively, fatal in the web build.
static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
std::panic::set_hook(Box::new(|_| {
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}));
eprintln!("design {seed}");
let run = fuzz::run_with(seed, steps, &mut |st| eprintln!("step {}", serde_json::to_string(st).unwrap_or_default()));
let mut design = json!({
"outcome": run.outcome,
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
"unsupported": run.unsupported,
"rejected": run.rejected,
"panics": PANICS.swap(0, std::sync::atomic::Ordering::SeqCst),
});
if run.outcome.is_bug() {
design["script"] = run.script();
if minimise {
eprintln!("minimise");
let m = fuzz::minimise(&run);
design["minimal"] = json!({"outcome": m.outcome, "steps": m.steps.iter().map(|s| s.kind).collect::<Vec<_>>(), "script": m.script()});
}
}
let (a, b) = solvecraft_engine::fuzz::boolean_shapes(seed);
eprintln!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
let b = boolean_case(seed);
let bp = PANICS.swap(0, std::sync::atomic::Ordering::SeqCst);
println!("{}", json!({"seed": seed, "design": design, "boolean": b, "boolean_panics": bp}));
Ok(())
}
struct Job {
seed: u64,
child: std::process::Child,
started: Instant,
err: std::sync::mpsc::Receiver<String>,
last: String,
}
/// Run seeds `--from`…`--from + --count` in `--jobs` processes, `--timeout` seconds each;
/// write failing scripts to `--out` and a summary to `--report`.
pub fn run(args: &[String]) -> Result<(), String> {
let from = flag(args, "--from", 0u64);
let count = flag(args, "--count", 100u64);
let jobs = flag(args, "--jobs", 4usize).clamp(1, 64);
let timeout = Duration::from_secs(flag(args, "--timeout", 120u64));
let steps = flag(args, "--steps", 8usize);
let out_dir = args.iter().position(|a| a == "--out").and_then(|i| args.get(i + 1)).cloned();
let report = args.iter().position(|a| a == "--report").and_then(|i| args.get(i + 1)).cloned();
let minimise = args.iter().any(|a| a == "--minimise");
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
if let Some(d) = &out_dir {
std::fs::create_dir_all(d).map_err(|e| format!("{d}: {e}"))?;
}
let mut next = from;
let mut running: Vec<Job> = Vec::new();
let mut results: Vec<Value> = Vec::new();
let t0 = Instant::now();
while next < from + count || !running.is_empty() {
while running.len() < jobs && next < from + count {
let mut cmd = Command::new(&exe);
cmd.args(["fuzz-one", &next.to_string(), "--steps", &steps.to_string()]);
if minimise {
cmd.arg("--minimise");
}
let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| e.to_string())?;
let (tx, rx) = std::sync::mpsc::channel();
if let Some(err) = child.stderr.take() {
std::thread::spawn(move || {
for line in BufReader::new(err).lines().map_while(Result::ok) {
if tx.send(line).is_err() {
break;
}
}
});
}
running.push(Job { seed: next, child, started: Instant::now(), err: rx, last: String::new() });
next += 1;
}
std::thread::sleep(Duration::from_millis(50));
let mut i = 0;
while i < running.len() {
let Some(j) = running.get_mut(i) else { break };
while let Ok(l) = j.err.try_recv() {
if l.starts_with("step") || l.starts_with("boolean") || l.starts_with("minimise") {
j.last = l;
}
}
let done = match j.child.try_wait() {
Ok(Some(status)) => {
let mut s = String::new();
if let Some(mut o) = j.child.stdout.take() {
let _ = o.read_to_string(&mut s);
}
Some(match serde_json::from_str::<Value>(s.trim()) {
Ok(v) => v,
Err(_) => json!({"seed": j.seed, "crash": format!("exit {status}"), "at": j.last}),
})
}
Ok(None) if j.started.elapsed() > timeout => {
let _ = j.child.kill();
let _ = j.child.wait();
Some(json!({"seed": j.seed, "timeout": timeout.as_secs(), "at": j.last}))
}
Ok(None) => None,
Err(e) => Some(json!({"seed": j.seed, "crash": e.to_string(), "at": j.last})),
};
match done {
Some(v) => {
record(&v, out_dir.as_deref());
results.push(v);
running.swap_remove(i);
}
None => i += 1,
}
}
}
let summary = summarise(&results, t0.elapsed());
println!("{summary}");
if let Some(r) = report {
std::fs::write(&r, &summary).map_err(|e| format!("{r}: {e}"))?;
}
Ok(())
}
/// Print a failure as it comes in and keep its script.
fn record(v: &Value, out_dir: Option<&str>) {
let seed = v["seed"].as_u64().unwrap_or(0);
let d = &v["design"];
let bug = matches!(d["outcome"]["kind"].as_str(), Some("failed" | "panic" | "invalid" | "volume"));
if bug || v.get("timeout").is_some() || v.get("crash").is_some() {
eprintln!("seed {seed}: {}", if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") });
}
let bk = v["boolean"]["outcome"].as_str().unwrap_or("ok");
if bk != "ok" {
eprintln!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]);
}
if let Some(dir) = out_dir {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(format!("{dir}/results.jsonl")) {
let _ = writeln!(f, "{v}");
}
if bug {
let script = if d.get("minimal").is_some() { &d["minimal"]["script"] } else { &d["script"] };
let _ = std::fs::write(format!("{dir}/design-{seed}.json"), serde_json::to_string_pretty(script).unwrap_or_default());
}
if bk != "ok" {
let _ = std::fs::write(format!("{dir}/boolean-{seed}.json"), serde_json::to_string_pretty(&v["boolean"]).unwrap_or_default());
}
}
}
fn summarise(results: &[Value], took: Duration) -> String {
let n = results.len().max(1);
let mut design: BTreeMap<String, usize> = BTreeMap::new();
let mut boolean: BTreeMap<String, usize> = BTreeMap::new();
let mut at_step: BTreeMap<String, usize> = BTreeMap::new();
let mut unsupported: BTreeMap<String, usize> = BTreeMap::new();
let mut steps = 0;
let (mut dp, mut bp) = (0, 0);
for v in results {
dp += usize::from(v["design"]["panics"].as_u64().unwrap_or(0) > 0);
bp += usize::from(v["boolean_panics"].as_u64().unwrap_or(0) > 0);
let k = if v.get("timeout").is_some() {
"timeout".to_string()
} else if v.get("crash").is_some() {
"crash".to_string()
} else {
v["design"]["outcome"]["kind"].as_str().unwrap_or("?").to_string()
};
if k != "ok" {
let last = v["design"]["steps"].as_array().and_then(|a| a.last()).and_then(Value::as_str).unwrap_or("?");
*at_step.entry(format!("{k} at {last}")).or_default() += 1;
}
*design.entry(k).or_default() += 1;
*boolean.entry(v["boolean"]["outcome"].as_str().unwrap_or("not run").to_string()).or_default() += 1;
steps += v["design"]["steps"].as_array().map(Vec::len).unwrap_or(0);
for u in v["design"]["unsupported"].as_array().into_iter().flatten() {
*unsupported.entry(u[0].as_str().unwrap_or("?").to_string()).or_default() += 1;
}
}
let pct = |c: usize| format!("{c} ({:.1} %)", 100.0 * c as f64 / n as f64);
let mut s = format!("{} seeds, {steps} design steps, {:.0} s\n\nDesigns:\n", results.len(), took.as_secs_f64());
for (k, c) in &design {
s += &format!("- {k}: {}\n", pct(*c));
}
s += "\nFailures by step:\n";
for (k, c) in &at_step {
s += &format!("- {k}: {c}\n");
}
s += "\nSteps refused as not supported (undone, the design went on):\n";
for (k, c) in &unsupported {
s += &format!("- {k}: {c}\n");
}
s += &format!("\nCaught panics (fatal in the web build): designs {}, boolean checks {}\n", pct(dp), pct(bp));
s += "\nBoolean identity (union, intersection, cut of two primitives):\n";
for (k, c) in &boolean {
s += &format!("- {k}: {}\n", pct(*c));
}
s
}
+10 -88
View File
@@ -10,13 +10,10 @@
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with measure.json
//! solvecraft-cli step-corpus <case-dir>... import part.step and compare with measure.json
//! solvecraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
//! solvecraft-cli licences SolveCraft's licence and the third-party licences
//! solvecraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod fuzz;
mod oracle;
mod recipe;
mod seams;
@@ -36,11 +33,8 @@ const USAGE: &str = "usage:
solvecraft-cli commands
solvecraft-cli recipe <recipe.json>
solvecraft-cli oracle <case-dir>... [--json]
solvecraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
solvecraft-cli step-corpus <case-dir>... [--json] [--iges]
solvecraft-cli step-corpus <case-dir>... [--json]
solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
solvecraft-cli licences
solvecraft-cli icon --out icon.png [--size 256] | --out icon.ico
";
fn main() -> ExitCode {
@@ -60,16 +54,7 @@ fn main() -> ExitCode {
}),
Some("oracle") => oracle::run(&args[1..]),
Some("step-corpus") => oracle::step_corpus(&args[1..]),
Some("fuzz") => fuzz::run(&args[1..]),
Some("fuzz-one") => fuzz::one(&args[1..]),
Some("mcp") => cmd_mcp(&args[1..]),
Some("icon") => cmd_icon(&args[1..]),
Some("licences" | "licenses") => {
// Written, not print!ed: a closed pipe (`| head`) is not a crash.
use std::io::Write as _;
let _ = std::io::stdout().lock().write_all(solvecraft_engine::licences::all().as_bytes());
Ok(())
}
// Hidden, for the kill-mid-save test: save a large design over and over.
Some("save-stress") => cmd_save_stress(&args[1..]),
// Hidden: time recomputes after parameter edits (docs/perf.md).
@@ -165,13 +150,13 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
_ => {}
}
for out in flags(args, "--out") {
s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?;
s.execute("ExportCommand", &json!({"path": out})).map_err(|e| e.to_string())?;
}
if let Some(p) = flag(args, "--save") {
s.execute("file.save_as", &json!({"path": p})).map_err(|e| e.to_string())?;
s.execute("SaveDocumentAsCommand", &json!({"path": p})).map_err(|e| e.to_string())?;
}
if !args.iter().any(|a| a == "--quiet") {
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let m = s.execute("MeasureCommand", &json!({})).map_err(|e| e.to_string())?;
println!("{}", pretty(&m));
}
Ok(())
@@ -180,7 +165,7 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
fn cmd_eval(args: &[String]) -> Result<(), String> {
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?;
let mut s = load(path)?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let m = s.execute("MeasureCommand", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
let out = json!({
"body_count": m["body_count"],
@@ -211,69 +196,6 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
Ok(())
}
/// The app icon: a blue block with a bore, drawn by the CPU renderer on a transparent background
/// (so packages need no committed image). `.ico` holds the Windows sizes as PNGs.
fn cmd_icon(args: &[String]) -> Result<(), String> {
let out = flag(args, "--out").ok_or("icon needs --out FILE.png or FILE.ico")?;
let size: usize = flag(args, "--size").and_then(|x| x.parse().ok()).unwrap_or(256).clamp(8, 2048);
let bytes = if out.to_ascii_lowercase().ends_with(".ico") {
let pngs: Vec<(usize, Vec<u8>)> =
[16, 20, 24, 32, 40, 48, 64, 128, 256].into_iter().map(|s| icon_png(s).map(|p| (s, p))).collect::<Result<_, _>>()?;
ico(&pngs)?
} else {
icon_png(size)?
};
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
println!("{}", pretty(&json!({"path": out, "bytes": bytes.len()})));
Ok(())
}
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
use solvecraft_engine::render::{Rgb, Scene, SceneLine, SceneMesh, png, render_transparent};
let mut s = Session::default();
for (cmd, p) in [
("solid.box", json!({"length": 20, "width": 20, "height": 20, "center": [0, 0, 0]})),
("solid.cylinder", json!({"diameter": 9, "height": 30, "base": [0, 0, -15], "operation": "cut"})),
] {
s.execute(cmd, &p).map_err(|e| format!("icon: {e}"))?;
}
let mut cam = solvecraft_engine::view::home_camera(&s);
cam.distance *= 0.95;
let mut scene = Scene { radius: 60.0, ..Default::default() };
let px = size as f64 / 256.0;
for body in &s.world_state().bodies {
let mesh = body.mesh();
scene.meshes.push(SceneMesh { mesh: std::sync::Arc::clone(&mesh), color: Rgb(90, 160, 240), face_colors: Vec::new() });
for (i, e) in mesh.edges.iter().enumerate() {
if !mesh.seams.get(i).copied().unwrap_or(false) {
scene.lines.push(SceneLine { points: e.clone(), color: Rgb(24, 52, 96), width: (3.0 * px).max(0.8), on_top: false });
}
}
}
let c = render_transparent(&mut scene, &cam, size, size);
png(c.w, c.h, c.rgba).ok_or_else(|| "icon: PNG encoding failed".to_string())
}
/// A Windows icon file holding PNG images (Vista and later read these).
fn ico(images: &[(usize, Vec<u8>)]) -> Result<Vec<u8>, String> {
let n = u16::try_from(images.len()).map_err(|_| "too many icon sizes")?;
let mut out = Vec::new();
out.extend_from_slice(&[0, 0, 1, 0]);
out.extend_from_slice(&n.to_le_bytes());
let mut offset = 6 + 16 * images.len();
for (size, data) in images {
let dim = if *size >= 256 { 0 } else { *size as u8 };
out.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]);
out.extend_from_slice(&u32::try_from(data.len()).map_err(|_| "icon too large")?.to_le_bytes());
out.extend_from_slice(&u32::try_from(offset).map_err(|_| "icon too large")?.to_le_bytes());
offset += data.len();
}
for (_, data) in images {
out.extend_from_slice(data);
}
Ok(out)
}
fn cmd_exec(args: &[String]) -> Result<(), String> {
let id = args.first().ok_or_else(|| USAGE.to_string())?;
let p: Value = match args.get(1) {
@@ -317,7 +239,7 @@ fn cmd_save_stress(args: &[String]) -> Result<(), String> {
s.doc_mut().params.extend(filler);
for r in 0..rounds {
s.doc_mut().set_param("save_round", &r.to_string(), None, None).map_err(|e| e.to_string())?;
s.execute("file.save", &json!({ "path": path })).map_err(|e| e.to_string())?;
s.execute("SaveDocumentCommand", &json!({ "path": path })).map_err(|e| e.to_string())?;
}
Ok(())
}
@@ -344,10 +266,10 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
Ok(())
};
let expr = expr.replace("{old}", &old);
time(&mut s, "edit", "parameters.change", json!({"name": param, "expression": expr}))?;
time(&mut s, "set back", "parameters.change", json!({"name": param, "expression": old}))?;
time(&mut s, "undo", "edit.undo", json!({}))?;
time(&mut s, "redo", "edit.redo", json!({}))?;
time(&mut s, "edit", "ChangeParameterCommand", json!({"name": param, "expression": expr}))?;
time(&mut s, "set back", "ChangeParameterCommand", json!({"name": param, "expression": old}))?;
time(&mut s, "undo", "UndoCommand", json!({}))?;
time(&mut s, "redo", "RedoCommand", json!({}))?;
println!("{}", pretty(&json!(out)));
Ok(())
}
+6 -15
View File
@@ -31,7 +31,7 @@ fn check_case(dir: &str) -> Value {
if let Err(e) = s.run_script(&script) {
return json!({"case": name, "pass": false, "error": e.to_string()});
}
let got = match s.execute("inspect.measure", &json!({})) {
let got = match s.execute("MeasureCommand", &json!({})) {
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
@@ -170,10 +170,9 @@ const FUSION_AREA_OFF: &[&str] = &["50-sweep-tight-bend"];
/// Import a case's `part.step` (as File → Open does) and compare with Fusion's measurements:
/// body count, volume and area (relative 1e-3) and the faces read from the file.
/// Open the case's `part.step` (or `part.igs` with `iges`) and compare with measure.json.
fn check_step_case(dir: &str, iges: bool) -> Value {
fn check_step_case(dir: &str) -> Value {
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let sp = format!("{dir}/{}", if iges { "part.igs" } else { "part.step" });
let sp = format!("{dir}/part.step");
let want = match read_json(&format!("{dir}/measure.json")) {
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e}),
@@ -184,7 +183,7 @@ fn check_step_case(dir: &str, iges: bool) -> Value {
Ok(v) => v,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
let got = match s.execute("inspect.measure", &json!({})) {
let got = match s.execute("MeasureCommand", &json!({})) {
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
@@ -192,14 +191,7 @@ fn check_step_case(dir: &str, iges: bool) -> Value {
// Faces read from the file (the kernel may split a face that wraps around a closed surface).
let file_faces = std::fs::read(&sp)
.ok()
.and_then(|b| {
let text = String::from_utf8_lossy(&b);
if iges {
solvecraft_engine::kernel::iges_import(&text).ok().map(std::sync::Arc::new)
} else {
solvecraft_engine::kernel::step_import_shared(&text).ok()
}
})
.and_then(|b| solvecraft_engine::kernel::step_import_shared(&String::from_utf8_lossy(&b)).ok())
.map(|imp| imp.bodies.iter().map(|b| b.file_faces).sum::<usize>() as f64);
let mut checks = Vec::new();
let mut pass = true;
@@ -229,8 +221,7 @@ pub fn step_corpus(args: &[String]) -> Result<(), String> {
if dirs.is_empty() {
return Err("step-corpus: give one or more case directories (each with part.step and measure.json)".into());
}
let iges = args.iter().any(|a| a == "--iges");
let results: Vec<Value> = dirs.iter().map(|d| check_step_case(d, iges)).collect();
let results: Vec<Value> = dirs.iter().map(|d| check_step_case(d)).collect();
report(args, &results);
let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false)).count();
if failed > 0 { Err(format!("{failed} STEP case(s) failed")) } else { Ok(()) }
+53 -53
View File
@@ -10,7 +10,7 @@ fn plane_param(pl: &Value) -> Value {
let origin_zero = pl.get("origin").and_then(Value::as_array).is_none_or(|o| o.iter().all(|x| x.as_f64().unwrap_or(0.0).abs() < 1e-12));
let std = match r.to_ascii_uppercase().as_str() {
"XY" => Some(([1.0, 0.0, 0.0], [0.0, 1.0, 0.0])),
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, -1.0])),
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
"YZ" => Some(([0.0, 1.0, 0.0], [0.0, 0.0, 1.0])),
_ => None,
};
@@ -44,23 +44,23 @@ fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
if let Some(n) = f.get("name") {
p["name"] = n.clone();
}
out.push(json!({"command": "sketch.create", "params": p}));
out.push(json!({"command": "SketchCreate", "params": p}));
for e in f.get("entities").and_then(Value::as_array).into_iter().flatten() {
let id = e.get("id").cloned().unwrap_or(Value::Null);
let construction = e.get("construction").cloned().unwrap_or(json!(false));
match e.get("type").and_then(Value::as_str) {
Some("line") => out.push(json!({"command": "sketch.line", "params": {"points": [parg(e.get("start")), parg(e.get("end"))], "ids": [id], "construction": construction}})),
Some("circle") => out.push(json!({"command": "sketch.circle.center", "params": {"center": parg(e.get("center")), "radius": e.get("radius"), "id": id, "construction": construction}})),
Some("arc_three_point") => out.push(json!({"command": "sketch.arc.three_point", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": e.get("mid").or(e.get("through")), "id": id}})),
Some("arc_center_start_sweep") => out.push(json!({"command": "sketch.arc.center_point", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "sweep": e.get("sweep_deg").or(e.get("sweep")), "id": id}})),
Some("line") => out.push(json!({"command": "DrawPolyline", "params": {"points": [parg(e.get("start")), parg(e.get("end"))], "ids": [id], "construction": construction}})),
Some("circle") => out.push(json!({"command": "CircleCenterRadius", "params": {"center": parg(e.get("center")), "radius": e.get("radius"), "id": id, "construction": construction}})),
Some("arc_three_point") => out.push(json!({"command": "ArcThreePoint", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": e.get("mid").or(e.get("through")), "id": id}})),
Some("arc_center_start_sweep") => out.push(json!({"command": "ArcCenterTwoPoint", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "sweep": e.get("sweep_deg").or(e.get("sweep")), "id": id}})),
Some("arc") => {
if e.get("center").is_some() {
out.push(json!({"command": "sketch.arc.center_point", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "end": parg(e.get("end")), "id": id}}))
out.push(json!({"command": "ArcCenterTwoPoint", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "end": parg(e.get("end")), "id": id}}))
} else {
out.push(json!({"command": "sketch.arc.three_point", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": parg(e.get("through").or(e.get("mid"))), "id": id}}))
out.push(json!({"command": "ArcThreePoint", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": parg(e.get("through").or(e.get("mid"))), "id": id}}))
}
}
Some("point") => out.push(json!({"command": "sketch.point", "params": {"point": parg(e.get("at").map(|a| json!({"at": a})).as_ref().or(Some(e))), "id": id}})),
Some("point") => out.push(json!({"command": "DrawPoint", "params": {"point": parg(e.get("at").map(|a| json!({"at": a})).as_ref().or(Some(e))), "id": id}})),
// A 3D helix fit: the sweep along it becomes a coil (see `helix_path`).
Some("fitted_spline") if e.get("fit_points").and_then(Value::as_array).is_some_and(|a| a.iter().any(|p| p.get(2).is_some())) => {}
Some(other) => return Err(format!("recipe: sketch entity type `{other}` is not supported yet")),
@@ -77,9 +77,9 @@ fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
p[k] = v.clone();
}
}
out.push(json!({"command": "sketch.dimension", "params": p}));
out.push(json!({"command": "SketchDimension", "params": p}));
}
out.push(json!({"command": "sketch.finish", "params": {}}));
out.push(json!({"command": "SketchStop", "params": {}}));
Ok(())
}
@@ -138,33 +138,33 @@ fn constraint_cmd(c: &Value) -> Result<Value, String> {
let at = |i: usize| r.get(i).cloned().unwrap_or(Value::Null);
let is_point = |v: &Value| v.as_str().is_some_and(|s| s.contains('.') || s == "origin" || s.starts_with('p'));
let (cmd, p) = match ty.as_str() {
"horizontal" | "vertical" if r.len() >= 2 => ("sketch.constraint.horizontal_vertical", json!({"points": [at(0), at(1)], "mode": ty})),
"horizontal" | "vertical" => ("sketch.constraint.horizontal_vertical", json!({"line": at(0), "mode": ty})),
"horizontal_points" => ("sketch.constraint.horizontal_vertical", json!({"points": [at(0), at(1)], "mode": "horizontal"})),
"vertical_points" => ("sketch.constraint.horizontal_vertical", json!({"points": [at(0), at(1)], "mode": "vertical"})),
"horizontal" | "vertical" if r.len() >= 2 => ("ConstraintHorizontalVertical", json!({"points": [at(0), at(1)], "mode": ty})),
"horizontal" | "vertical" => ("ConstraintHorizontalVertical", json!({"line": at(0), "mode": ty})),
"horizontal_points" => ("ConstraintHorizontalVertical", json!({"points": [at(0), at(1)], "mode": "horizontal"})),
"vertical_points" => ("ConstraintHorizontalVertical", json!({"points": [at(0), at(1)], "mode": "vertical"})),
"coincident" | "point_on_curve" => {
// The point goes first.
if is_point(&at(0)) {
("sketch.constraint.coincident", json!({"a": at(0), "b": at(1)}))
("ConstraintCoincident", json!({"a": at(0), "b": at(1)}))
} else {
("sketch.constraint.coincident", json!({"a": at(1), "b": at(0)}))
("ConstraintCoincident", json!({"a": at(1), "b": at(0)}))
}
}
"midpoint" => {
if is_point(&at(0)) {
("sketch.constraint.midpoint", json!({"point": at(0), "line": at(1)}))
("ConstraintMidPoint", json!({"point": at(0), "line": at(1)}))
} else {
("sketch.constraint.midpoint", json!({"point": at(1), "line": at(0)}))
("ConstraintMidPoint", json!({"point": at(1), "line": at(0)}))
}
}
"symmetric" | "symmetry" => ("sketch.constraint.symmetry", json!({"a": at(0), "b": at(1), "line": at(2)})),
"fix" => ("sketch.constraint.fix", json!({"entity": at(0), "fixed": true})),
"tangent" => ("sketch.constraint.tangent", json!({"a": at(0), "b": at(1)})),
"equal" => ("sketch.constraint.equal", json!({"a": at(0), "b": at(1)})),
"parallel" => ("sketch.constraint.parallel", json!({"a": at(0), "b": at(1)})),
"perpendicular" => ("sketch.constraint.perpendicular", json!({"a": at(0), "b": at(1)})),
"concentric" => ("sketch.constraint.concentric", json!({"a": at(0), "b": at(1)})),
"collinear" => ("sketch.constraint.collinear", json!({"a": at(0), "b": at(1)})),
"symmetric" | "symmetry" => ("ConstraintSymmetry", json!({"a": at(0), "b": at(1), "line": at(2)})),
"fix" => ("ConstraintFix", json!({"entity": at(0), "fixed": true})),
"tangent" => ("ConstraintTangent", json!({"a": at(0), "b": at(1)})),
"equal" => ("ConstraintEqual", json!({"a": at(0), "b": at(1)})),
"parallel" => ("ConstraintParallel", json!({"a": at(0), "b": at(1)})),
"perpendicular" => ("ConstraintPerpendicular", json!({"a": at(0), "b": at(1)})),
"concentric" => ("ConstraintConcentric", json!({"a": at(0), "b": at(1)})),
"collinear" => ("ConstraintCollinear", json!({"a": at(0), "b": at(1)})),
other => return Err(format!("recipe: constraint `{other}` is not supported yet")),
};
Ok(json!({"command": cmd, "params": p}))
@@ -294,7 +294,7 @@ fn circle_fit(pts: &[[f64; 3]]) -> Option<(f64, f64)> {
pub fn to_script(recipe: &Value) -> Result<Value, String> {
let mut out: Vec<Value> = Vec::new();
for p in recipe.get("parameters").and_then(Value::as_array).into_iter().flatten() {
out.push(json!({"command": "parameters.change", "params": {"name": p.get("name"), "expression": p.get("expression").or(p.get("value")), "unit": p.get("unit").cloned().unwrap_or(json!("mm"))}}));
out.push(json!({"command": "ChangeParameterCommand", "params": {"name": p.get("name"), "expression": p.get("expression").or(p.get("value")), "unit": p.get("unit").cloned().unwrap_or(json!("mm"))}}));
}
let features = recipe.get("features").and_then(Value::as_array).ok_or("recipe: missing `features`")?;
for f in features {
@@ -305,9 +305,9 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
Some("extrude") => {
let mut p = json!({"sketch": profile_sketch(f), "profiles": profiles_of(f), "operation": op(f), "name": name, "body_names": bodies});
extent(f, &mut p);
out.push(json!({"command": "solid.extrude", "params": p}));
out.push(json!({"command": "Extrude", "params": p}));
}
Some("combine") => out.push(json!({"command": "solid.combine", "params": {
Some("combine") => out.push(json!({"command": "FusionCombineCommand", "params": {
"target": f.get("target_body").or(f.get("target")), "tools": f.get("tool_bodies").or(f.get("tools")),
"operation": op(f), "keep_tools": f.get("keep_tools").cloned().unwrap_or(json!(false)), "name": name}})),
Some("revolve") => {
@@ -316,7 +316,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
Some(a) => a.get("line").or(a.get("ref")).cloned().unwrap_or_else(|| a.clone()),
None => Value::Null,
};
out.push(json!({"command": "solid.revolve", "params": {
out.push(json!({"command": "Revolve", "params": {
"sketch": profile_sketch(f), "profiles": profiles_of(f), "axis": axis,
"angle": f.get("angle").cloned().unwrap_or(json!("360 deg")), "operation": op(f), "name": name, "body_names": bodies}}));
}
@@ -329,11 +329,11 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
p["count2"] = d2.get("count").cloned().unwrap_or(Value::Null);
p["spacing2"] = d2.get("spacing").cloned().unwrap_or(Value::Null);
}
out.push(json!({"command": "solid.pattern.rectangular", "params": p}));
out.push(json!({"command": "PatternRectangular", "params": p}));
}
Some("circular_pattern") => {
let axis = f.get("axis").map(|a| json!({"origin": a.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "dir": a.get("dir")})).unwrap_or(Value::Null);
out.push(json!({"command": "solid.pattern.circular", "params": {"features": feature_refs(f.get("features")), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}}));
out.push(json!({"command": "PatternCircular", "params": {"features": feature_refs(f.get("features")), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}}));
}
Some("sm_flange") => match f.get("type").and_then(Value::as_str) {
Some("edge") => {
@@ -344,11 +344,11 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
if f.get("flip").and_then(Value::as_bool) == Some(true) {
p["flip"] = json!(true);
}
out.push(json!({"command": "sheet.flange", "params": p}));
out.push(json!({"command": "FusionSheetMetalFlangeCommand", "params": p}));
}
Some("contour") => out.push(json!({"command": "sheet.flange", "params": {
Some("contour") => out.push(json!({"command": "FusionSheetMetalFlangeCommand", "params": {
"type": "contour", "sketch": f.get("profile_sketch"), "curves": f.get("profile_curves"), "distance": f.get("distance"), "name": name}})),
_ => out.push(json!({"command": "sheet.flange", "params": {
_ => out.push(json!({"command": "FusionSheetMetalFlangeCommand", "params": {
"type": "base", "sketch": profile_sketch(f), "profiles": profiles_of(f), "name": name, "body_names": bodies}})),
},
Some("sheet_metal_rule_edit") => {
@@ -368,7 +368,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
p["thickness"] = json!(format!("{v} {u}"));
}
}
out.push(json!({"command": "sheet.manage_rules", "params": p}));
out.push(json!({"command": "FusionSheetMetalRulesCommand", "params": p}));
}
Some("sm_hem") => {
let e = f.get("edge").and_then(|e| e.get("point")).cloned().unwrap_or(Value::Null);
@@ -376,9 +376,9 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
if f.get("flipped").and_then(Value::as_bool) == Some(true) {
p["flip"] = json!(true);
}
out.push(json!({"command": "sheet.hem", "params": p}));
out.push(json!({"command": "FusionSheetMetalHemFlangeCommand", "params": p}));
}
Some("sm_unfold") => out.push(json!({"command": "sheet.unfold", "params": {"name": name}})),
Some("sm_unfold") => out.push(json!({"command": "FusionSheetmetalUnfoldCommand", "params": {"name": name}})),
Some("sm_refold") => out.push(json!({"command": "sheet.refold", "params": {"name": name}})),
Some("path_pattern") => {
let path = f.get("path").cloned().unwrap_or(Value::Null);
@@ -392,7 +392,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
} else {
p["features"] = feature_refs(f.get("features"));
}
out.push(json!({"command": "solid.pattern.path", "params": p}));
out.push(json!({"command": "PatternOnPath", "params": p}));
}
Some("loft") => {
let sections: Vec<Value> = f
@@ -405,24 +405,24 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
None => json!({"sketch": sec.get("sketch"), "profiles": [sec]}),
})
.collect();
out.push(json!({"command": "solid.loft", "params": {"sections": sections, "operation": op(f), "name": name, "body_names": bodies}}));
out.push(json!({"command": "SolidLoft", "params": {"sections": sections, "operation": op(f), "name": name, "body_names": bodies}}));
}
Some("sweep") if helix_path(features, f).is_some() => {
// A swept circle along a fitted 3D helix: a coil (the true helix).
let Some((base, diameter, pitch, turns, start, cw, size)) = helix_path(features, f) else { continue };
out.push(json!({"command": "solid.coil", "params": {
out.push(json!({"command": "PrimitiveCoil", "params": {
"base": base, "diameter": diameter, "pitch": pitch, "revolutions": turns, "section_size": size,
"start_angle": format!("{start} rad"), "clockwise": cw, "operation": op(f), "name": name, "body_names": bodies}}));
}
Some("sweep") => {
let path = f.get("path").cloned().unwrap_or(Value::Null);
out.push(json!({"command": "solid.sweep", "params": {
out.push(json!({"command": "Sweep", "params": {
"sketch": profile_sketch(f), "profiles": profiles_of(f), "path_sketch": path.get("sketch"), "path": path.get("curves"),
"operation": op(f), "name": name, "body_names": bodies}}));
}
Some("shell") => {
let faces: Vec<Value> = f.get("faces_removed").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
out.push(json!({"command": "solid.shell", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}}));
out.push(json!({"command": "FusionShellBodyCommand", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}}));
}
Some("draft") => {
let faces: Vec<Value> = f.get("faces").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
@@ -433,7 +433,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
if f.get("flipped").and_then(Value::as_bool).unwrap_or(false) {
angle = -angle;
}
out.push(json!({"command": "solid.draft", "params": {"faces": faces, "angle": angle, "neutral": neutral, "pull": n, "name": name}}));
out.push(json!({"command": "FusionDraftCommand", "params": {"faces": faces, "angle": angle, "neutral": neutral, "pull": n, "name": name}}));
}
Some("thread") => {
let face = f.get("face").and_then(|x| x.get("point")).cloned().unwrap_or(Value::Null);
@@ -443,7 +443,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
{
p["length"] = l.clone();
}
out.push(json!({"command": "solid.thread", "params": p}));
out.push(json!({"command": "FusionThreadCommand", "params": p}));
}
Some("hole") => {
let n = f.get("face").and_then(|fc| fc.get("plane_normal_outward").or(fc.get("normal_at_point_on_face"))).cloned();
@@ -495,20 +495,20 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
p[k] = v.clone();
}
}
out.push(json!({"command": "solid.hole", "params": p}));
out.push(json!({"command": "FusionHoleCommand", "params": p}));
}
Some("construction_plane") => match f.get("method").and_then(Value::as_str) {
Some("offset") => out.push(json!({"command": "construct.plane.offset", "params": {"base": f.get("base"), "offset": f.get("offset"), "name": name}})),
Some("offset") => out.push(json!({"command": "ConstructionPlaneOffsetFromPlaneCommand", "params": {"base": f.get("base"), "offset": f.get("offset"), "name": name}})),
_ => {
// Use the resulting plane as recorded.
let (o, n) = (f.get("result_origin").cloned().unwrap_or(json!([0, 0, 0])), f.get("result_normal").cloned().unwrap_or(Value::Null));
if n.is_null() {
return Err(format!("recipe: construction plane method {:?} without result_normal", f.get("method")));
}
out.push(json!({"command": "construct.plane.offset", "params": {"base": {"origin": o, "normal": n}, "offset": 0, "name": name}}));
out.push(json!({"command": "ConstructionPlaneOffsetFromPlaneCommand", "params": {"base": {"origin": o, "normal": n}, "offset": 0, "name": name}}));
}
},
Some("split_body") => out.push(json!({"command": "solid.split_body", "params": {"body": f.get("body"), "plane": f.get("tool"), "name": name}})),
Some("split_body") => out.push(json!({"command": "FusionSplitBodyCommand", "params": {"body": f.get("body"), "plane": f.get("tool"), "name": name}})),
Some("mirror") => {
let pl = f.get("plane").cloned().unwrap_or(Value::Null);
let plane = match pl.get("ref").and_then(Value::as_str) {
@@ -519,13 +519,13 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
if f.get("mirror_of").and_then(Value::as_str) == Some("bodies") {
p = json!({"bodies": f.get("bodies"), "combine": f.get("combine").cloned().unwrap_or(json!(false)), "plane": plane, "name": name});
}
out.push(json!({"command": "solid.mirror", "params": p}));
out.push(json!({"command": "MirrorCommand", "params": p}));
}
Some("fillet") => {
out.push(json!({"command": "solid.fillet", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}}))
out.push(json!({"command": "FusionFilletEdgesCommand", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}}))
}
Some("chamfer") => {
out.push(json!({"command": "solid.chamfer", "params": {"edges": edge_points(f), "distance": f.get("distance"), "name": name}}))
out.push(json!({"command": "FusionChamferCommand", "params": {"edges": edge_points(f), "distance": f.get("distance"), "name": name}}))
}
Some(other) => return Err(format!("recipe: feature op `{other}` is not supported yet")),
None => return Err("recipe: feature without `op`".into()),
@@ -1,15 +1,15 @@
{"commands": [
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}},
{"command": "component.create", "params": {"name": "Arm"}},
{"command": "solid.box", "params": {"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"}},
{"command": "solid.fillet", "params": {"edges": [[25, 4, 4]], "radius": 1}},
{"command": "PrimitiveBox", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}},
{"command": "FusionCreateNewComponentCommand", "params": {"name": "Arm"}},
{"command": "PrimitiveBox", "params": {"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"}},
{"command": "FusionFilletEdgesCommand", "params": {"edges": [[25, 4, 4]], "radius": 1}},
{"command": "component.activate", "params": {"component": 0}},
{"command": "occurrence.move", "params": {"occurrence": 3, "translate": [0, 50, 0]}},
{"command": "occurrence.copy", "params": {"component": "Arm", "translate": [0, 20, 0]}},
{"command": "component.paste_new", "params": {"component": "Arm", "translate": [0, -30, 0]}},
{"command": "occurrence.ground", "params": {"occurrence": 3}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "S1"}},
{"command": "sketch.circle.center", "params": {"center": [5, 5], "radius": 2}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 10, "operation": "cut"}}
{"command": "SketchCreate", "params": {"plane": "XY", "name": "S1"}},
{"command": "CircleCenterRadius", "params": {"center": [5, 5], "radius": 2}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": 10, "operation": "cut"}}
]}
@@ -1,14 +1,14 @@
{"commands": [
{"command": "parameters.change", "params": {"name": "open_angle", "expression": "90 deg"}},
{"command": "component.create", "params": {"name": "LeafA"}},
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
{"command": "solid.cylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
{"command": "ChangeParameterCommand", "params": {"name": "open_angle", "expression": "90 deg"}},
{"command": "FusionCreateNewComponentCommand", "params": {"name": "LeafA"}},
{"command": "PrimitiveBox", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
{"command": "PrimitiveCylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
{"command": "component.activate", "params": {"component": "root"}},
{"command": "component.create", "params": {"name": "LeafB"}},
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
{"command": "solid.cylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
{"command": "FusionCreateNewComponentCommand", "params": {"name": "LeafB"}},
{"command": "PrimitiveBox", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
{"command": "PrimitiveCylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
{"command": "component.activate", "params": {"component": "root"}},
{"command": "occurrence.ground", "params": {"occurrence": 2, "grounded": true}},
{"command": "joint.create", "params": {"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "name": "Hinge"}},
{"command": "joint.drive", "params": {"joint": "Hinge", "value": "open_angle"}}
{"command": "JointAssembleCmdNew", "params": {"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "name": "Hinge"}},
{"command": "FusionMoveJointsCommand", "params": {"joint": "Hinge", "value": "open_angle"}}
]}
@@ -1,12 +1,12 @@
{"commands": [
{"command": "parameters.change", "params": {"name": "width", "expression": "40 mm"}},
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": "width / 2"}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}},
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.circle.center", "params": {"center": [20, 15], "radius": 5}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 20, "operation": "cut"}}
{"command": "ChangeParameterCommand", "params": {"name": "width", "expression": "40 mm"}},
{"command": "SketchCreate", "params": {"plane": "XY"}},
{"command": "ShapeRectangleTwoPoint", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": "width / 2"}},
{"command": "FusionFilletEdgesCommand", "params": {"edges": [[0, 0, 10]], "radius": 3}},
{"command": "SketchCreate", "params": {"plane": "XY"}},
{"command": "CircleCenterRadius", "params": {"center": [20, 15], "radius": 5}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": 20, "operation": "cut"}}
]}
@@ -1,11 +1,11 @@
{"commands": [
{"command": "sketch.create", "params": {"plane": "XZ"}},
{"command": "sketch.line", "params": {"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.revolve", "params": {"axis": "y"}},
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]}},
{"command": "solid.cylinder", "params": {"diameter": 4, "height": 20, "base": [35, 5, -5]}},
{"command": "solid.combine", "params": {"target": "Body2", "tools": ["Body3"], "operation": "cut"}},
{"command": "solid.chamfer", "params": {"edges": [[30, 5, 10]], "distance": 1}},
{"command": "SketchCreate", "params": {"plane": "XZ"}},
{"command": "DrawPolyline", "params": {"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}},
{"command": "SketchStop", "params": {}},
{"command": "Revolve", "params": {"axis": "y"}},
{"command": "PrimitiveBox", "params": {"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]}},
{"command": "PrimitiveCylinder", "params": {"diameter": 4, "height": 20, "base": [35, 5, -5]}},
{"command": "FusionCombineCommand", "params": {"target": "Body2", "tools": ["Body3"], "operation": "cut"}},
{"command": "FusionChamferCommand", "params": {"edges": [[30, 5, 10]], "distance": 1}},
{"command": "timeline.edit", "params": {"feature": "Revolve1", "set": {"angle": "180 deg"}}}
]}
@@ -1,14 +1,14 @@
{
"commands": [
{
"command": "sketch.create",
"command": "SketchCreate",
"params": {
"name": "Plate_sketch",
"plane": "XY"
}
},
{
"command": "sketch.line",
"command": "DrawPolyline",
"params": {
"construction": false,
"ids": [
@@ -27,7 +27,7 @@
}
},
{
"command": "sketch.line",
"command": "DrawPolyline",
"params": {
"construction": false,
"ids": [
@@ -43,7 +43,7 @@
}
},
{
"command": "sketch.line",
"command": "DrawPolyline",
"params": {
"construction": false,
"ids": [
@@ -59,7 +59,7 @@
}
},
{
"command": "sketch.line",
"command": "DrawPolyline",
"params": {
"construction": false,
"ids": [
@@ -72,11 +72,11 @@
}
},
{
"command": "sketch.finish",
"command": "SketchStop",
"params": {}
},
{
"command": "solid.extrude",
"command": "Extrude",
"params": {
"body_names": [
"Plate"
@@ -110,14 +110,14 @@
}
},
{
"command": "sketch.create",
"command": "SketchCreate",
"params": {
"name": "PathSketch",
"plane": "XY"
}
},
{
"command": "sketch.arc.three_point",
"command": "ArcThreePoint",
"params": {
"end": [
20,
@@ -135,7 +135,7 @@
}
},
{
"command": "sketch.circle.center",
"command": "CircleCenterRadius",
"params": {
"center": "a1.start",
"construction": false,
@@ -144,11 +144,11 @@
}
},
{
"command": "sketch.finish",
"command": "SketchStop",
"params": {}
},
{
"command": "solid.extrude",
"command": "Extrude",
"params": {
"body_names": [
"Plate"
@@ -179,7 +179,7 @@
}
},
{
"command": "solid.pattern.path",
"command": "PatternOnPath",
"params": {
"count": 7,
"features": [
@@ -1,10 +1,10 @@
{"commands": [
{"command": "parameters.change", "params": {"name": "wall", "expression": "2 mm"}},
{"command": "solid.box", "params": {"length": 80, "width": 50, "height": 30}},
{"command": "solid.shell", "params": {"faces": [[40, 25, 30]], "thickness": "wall"}},
{"command": "plastic.lip", "params": {"face": [1, 25, 30], "width": 1, "height": 2}},
{"command": "plastic.boss", "params": {"position": [20, 20, 2], "diameter": 6, "height": 10, "hole_diameter": 2.5, "draft": "1 deg"}},
{"command": "plastic.rest", "params": {"position": [60, 30, 2], "width": 8, "length": 12, "height": 3, "thickness": 1}},
{"command": "plastic.snap_fit", "params": {"position": [40, 12, 2], "direction": [0, 0, 1], "hook": [0, 1, 0], "length": 12, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 2}},
{"command": "plastic.manage_rules", "params": {"name": "ABS (1.5mm)", "active": true}}
{"command": "ChangeParameterCommand", "params": {"name": "wall", "expression": "2 mm"}},
{"command": "PrimitiveBox", "params": {"length": 80, "width": 50, "height": 30}},
{"command": "FusionShellBodyCommand", "params": {"faces": [[40, 25, 30]], "thickness": "wall"}},
{"command": "FusionLipCommand", "params": {"face": [1, 25, 30], "width": 1, "height": 2}},
{"command": "FusionBossCommand", "params": {"position": [20, 20, 2], "diameter": 6, "height": 10, "hole_diameter": 2.5, "draft": "1 deg"}},
{"command": "FusionRestCommand", "params": {"position": [60, 30, 2], "width": 8, "length": 12, "height": 3, "thickness": 1}},
{"command": "FusionSnapFitCommand", "params": {"position": [40, 12, 2], "direction": [0, 0, 1], "hook": [0, 1, 0], "length": 12, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 2}},
{"command": "FusionManagePlasticRuleCommand", "params": {"name": "ABS (1.5mm)", "active": true}}
]}
@@ -1,7 +1,7 @@
{
"commands": [
{
"command": "sketch.create",
"command": "SketchCreate",
"params": {
"name": "Contour",
"plane": {
@@ -24,7 +24,7 @@
}
},
{
"command": "sketch.line",
"command": "DrawPolyline",
"params": {
"construction": false,
"ids": [
@@ -43,7 +43,7 @@
}
},
{
"command": "sketch.line",
"command": "DrawPolyline",
"params": {
"construction": false,
"ids": [
@@ -59,7 +59,7 @@
}
},
{
"command": "sketch.line",
"command": "DrawPolyline",
"params": {
"construction": false,
"ids": [
@@ -75,11 +75,11 @@
}
},
{
"command": "sketch.finish",
"command": "SketchStop",
"params": {}
},
{
"command": "sheet.flange",
"command": "FusionSheetMetalFlangeCommand",
"params": {
"curves": [
"l1",
+11 -26
View File
@@ -57,7 +57,7 @@ fn old_designs_open_and_evaluate_as_saved() {
}
}
assert_eq!(s.doc.format, solvecraft_engine::doc::format::format_string(solvecraft_engine::doc::format::FORMAT), "{name}: upgraded");
let got = s.execute("inspect.measure", &json!({})).unwrap();
let got = s.execute("MeasureCommand", &json!({})).unwrap();
if got["body_count"] != want["body_count"] && !known(&name, "body_count") {
problems.push(format!("{name}: {} bodies, saved with {}", got["body_count"], want["body_count"]));
}
@@ -145,7 +145,7 @@ fn at_mut<'a>(v: &'a mut Value, path: &[Value]) -> Option<&'a mut Value> {
fn try_bytes(bytes: &[u8]) {
if let Ok(doc) = solvecraft_engine::io::read_design(bytes) {
let mut s = Session::new(doc);
let _ = s.execute("inspect.measure", &json!({}));
let _ = s.execute("MeasureCommand", &json!({}));
let _ = s.execute("document.inspect", &json!({}));
}
}
@@ -153,8 +153,7 @@ fn try_bytes(bytes: &[u8]) {
#[test]
fn damaged_and_hostile_design_files_never_panic() {
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
// The cases come from one seeded sequence; trying them is independent, so threads share it.
let mut all_cases: Vec<(String, String, Vec<u8>)> = Vec::new();
let mut failures = Vec::new();
for p in corpus() {
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let bytes = std::fs::read(&p).unwrap();
@@ -199,28 +198,14 @@ fn damaged_and_hostile_design_files_never_panic() {
cases.push(("features repeated".into(), v.to_string().into_bytes()));
cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes()));
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b)));
for (what, b) in cases {
if std::env::var_os("CORPUS_TRACE").is_some() {
eprintln!("{name}: {what}");
}
if std::panic::catch_unwind(|| try_bytes(&b)).is_err() {
failures.push(format!("{name}: {what}"));
}
}
}
let next = std::sync::atomic::AtomicUsize::new(0);
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let failures: Vec<String> = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut failures = Vec::new();
while let Some((name, what, b)) = all_cases.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
if std::env::var_os("CORPUS_TRACE").is_some() {
eprintln!("{name}: {what}");
}
if std::panic::catch_unwind(|| try_bytes(b)).is_err() {
failures.push(format!("{name}: {what}"));
}
}
failures
})
})
.collect();
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
});
assert!(failures.is_empty(), "panics:\n{}", failures.join("\n"));
}
+2 -12
View File
@@ -22,20 +22,10 @@ fn killed_mid_save_the_old_file_survives() {
assert_eq!(round_of(&path).as_deref(), Some("0"));
let mut kills = 0;
let mut seen = std::collections::BTreeSet::new();
let modified = || std::fs::metadata(&path).and_then(|m| m.modified()).ok();
for i in 0..25u64 {
let before = modified();
let mut child = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "100000"]).spawn().unwrap();
// Wait until it is in its save loop (its first save replaced the file) and has reached a
// round that varies from kill to kill, however busy the machine is; then kill it a
// varying while later.
let target = i % 10;
let reached = || modified() != before && round_of(&path).and_then(|r| r.parse::<u64>().ok()).is_some_and(|r| r >= target);
let t0 = std::time::Instant::now();
while !reached() && t0.elapsed() < Duration::from_secs(20) && child.try_wait().unwrap().is_none() {
std::thread::sleep(Duration::from_millis(2));
}
std::thread::sleep(Duration::from_millis((i * 7) % 30));
// Let it get into its save loop (building the design takes a moment), then kill it.
std::thread::sleep(Duration::from_millis(150 + (i * 37) % 200));
if child.try_wait().unwrap().is_none() {
child.kill().unwrap();
kills += 1;
+1 -8
View File
@@ -23,11 +23,4 @@ log = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
serde_json = { workspace = true }
web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"BeforeUnloadEvent", "Event", "EventTarget",
] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location"] }
-476
View File
@@ -1,476 +0,0 @@
//! Files in the browser. Designs are kept in the origin private file system (OPFS): read into
//! the engine's in-memory files (`solvecraft_engine::io::vfs`) at start, written back whenever a
//! command writes one. Files come in from the computer through a file picker (the File System
//! Access API where the browser has it, an `<input type=file>` otherwise) or a drop on the page,
//! and go out as downloads (exports, and "Download design"). An autosave of unsaved work is kept
//! in OPFS too and offered again after a reload or a crash.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use js_sys::{Array, Function, Object, Reflect, Uint8Array};
use solvecraft_engine::io::vfs;
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetFileOptions, FileSystemWritableFileStream};
/// Where OPFS files appear to the engine.
pub const OPFS: &str = "/opfs/";
/// Paths written here are handed to the user as downloads.
pub const DOWNLOADS: &str = "/downloads/";
/// Files brought in from the computer.
pub const UPLOAD: &str = "/upload/";
/// The autosave of unsaved work (in OPFS: `recovery.solvecraft` and `recovery.json`).
const RECOVERY: &str = "recovery.solvecraft";
const RECOVERY_META: &str = "recovery.json";
/// What to do with a file brought in from the computer.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Intent {
/// Open it (a design, or STEP/mesh as a new design).
Open,
/// Add its bodies to the open design (STEP, 3MF, STL).
Insert,
}
/// A stored design, as the file panel lists it.
#[derive(Clone, Debug)]
pub struct Entry {
pub name: String,
pub size: u64,
/// Last written (ms since 1970; 0 when unknown).
pub modified: f64,
}
/// State shared between the async browser calls and the app's frames.
#[derive(Default)]
pub struct Files {
pub entries: RefCell<Vec<Entry>>,
/// Files read from the computer: (name, bytes, what to do).
pub incoming: RefCell<Vec<(String, Vec<u8>, Intent)>>,
/// Messages for the status line (errors and confirmations).
pub messages: RefCell<Vec<(String, bool)>>,
/// The stored designs are loaded (the app may open them).
pub ready: Cell<bool>,
/// The file panel is open.
pub panel: Cell<bool>,
/// An autosave left by an earlier session: its design's name and file.
pub recovery: RefCell<Option<(String, Option<String>)>>,
/// The app's context, to wake it when a browser call finishes.
pub ctx: RefCell<Option<egui::Context>>,
}
impl Files {
fn say(&self, text: impl Into<String>, error: bool) {
self.messages.borrow_mut().push((text.into(), error));
self.wake();
}
/// A file arrived from the computer.
fn arrive(&self, name: String, bytes: Vec<u8>, intent: Intent) {
self.incoming.borrow_mut().push((name, bytes, intent));
self.wake();
}
/// Run a frame (the app sleeps until something happens).
fn wake(&self) {
if let Some(c) = self.ctx.borrow().as_ref() {
c.request_repaint();
}
}
}
thread_local! {
/// The private file system isn't there (an insecure page: http on a network address, or an
/// old browser): designs are kept in local storage instead.
static LOCAL: Cell<bool> = const { Cell::new(false) };
}
/// Local storage keys of stored files.
const LOCAL_PREFIX: &str = "solvecraft.file.";
fn local() -> Result<web_sys::Storage, String> {
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).ok_or_else(|| "this browser keeps no local storage".to_string())
}
/// Text as is, anything else as hex (local storage holds strings).
fn encode(bytes: &[u8]) -> String {
match std::str::from_utf8(bytes) {
Ok(t) => format!("t{t}"),
Err(_) => {
let mut s = String::with_capacity(bytes.len() * 2 + 1);
s.push('x');
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
}
}
fn decode(s: &str) -> Vec<u8> {
if let Some(t) = s.strip_prefix('t') {
return t.as_bytes().to_vec();
}
let h = s.strip_prefix('x').unwrap_or("");
(0..h.len() / 2).filter_map(|i| h.get(2 * i..2 * i + 2).and_then(|x| u8::from_str_radix(x, 16).ok())).collect()
}
/// Is the browser's storage local storage (no OPFS here)?
pub fn local_only() -> bool {
LOCAL.with(Cell::get)
}
fn err(e: JsValue) -> String {
e.as_string().or_else(|| Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
}
/// The browser's storage manager: missing on insecure pages (http on a network address) and in
/// old browsers, where calling into it would throw.
fn storage_manager() -> Option<web_sys::StorageManager> {
let nav = web_sys::window()?.navigator();
let sm = Reflect::get(&nav, &"storage".into()).ok().filter(|v| v.is_object())?;
Some(sm.unchecked_into())
}
/// Does the storage manager have `method`?
fn has(sm: &web_sys::StorageManager, method: &str) -> bool {
Reflect::get(sm, &method.into()).is_ok_and(|f| f.is_function())
}
async fn root() -> Result<FileSystemDirectoryHandle, String> {
let sm = storage_manager().filter(|sm| has(sm, "getDirectory")).ok_or("this page has no private file storage (it needs https or localhost)")?;
let dir = JsFuture::from(sm.get_directory()).await.map_err(err)?;
dir.dyn_into::<FileSystemDirectoryHandle>().map_err(|_| "this browser has no private file storage (OPFS)".to_string())
}
async fn read_handle(h: &FileSystemFileHandle) -> Result<Vec<u8>, String> {
let file: web_sys::File = JsFuture::from(h.get_file()).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string())?;
blob_bytes(&file).await
}
async fn blob_bytes(b: &web_sys::Blob) -> Result<Vec<u8>, String> {
let buf = JsFuture::from(b.array_buffer()).await.map_err(err)?;
Ok(Uint8Array::new(&buf).to_vec())
}
async fn file_handle(dir: &FileSystemDirectoryHandle, name: &str, create: bool) -> Result<FileSystemFileHandle, String> {
let opts = FileSystemGetFileOptions::new();
opts.set_create(create);
JsFuture::from(dir.get_file_handle_with_options(name, &opts)).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string())
}
async fn write_file(name: &str, bytes: &[u8]) -> Result<(), String> {
if local_only() {
return local()?
.set_item(&format!("{LOCAL_PREFIX}{name}"), &encode(bytes))
.map_err(|e| format!("local storage is full or blocked: {}", err(e)));
}
let dir = root().await?;
let h = file_handle(&dir, name, true).await?;
let w: FileSystemWritableFileStream =
JsFuture::from(h.create_writable()).await.map_err(err)?.dyn_into().map_err(|_| "no writable stream".to_string())?;
JsFuture::from(w.write_with_u8_array(bytes).map_err(err)?).await.map_err(err)?;
JsFuture::from(w.close()).await.map_err(err)?;
Ok(())
}
async fn remove_file(name: &str) -> Result<(), String> {
if local_only() {
return local()?.remove_item(&format!("{LOCAL_PREFIX}{name}")).map_err(err);
}
let dir = root().await?;
JsFuture::from(dir.remove_entry(name)).await.map_err(err)?;
Ok(())
}
/// Every stored file in local storage.
fn read_local() -> Result<Vec<(String, Vec<u8>, f64)>, String> {
let st = local()?;
let mut out = Vec::new();
for i in 0..st.length().unwrap_or(0).min(10_000) {
let Some(k) = st.key(i).ok().flatten() else { continue };
let Some(name) = k.strip_prefix(LOCAL_PREFIX) else { continue };
if let Some(v) = st.get_item(&k).ok().flatten() {
out.push((name.to_string(), decode(&v), 0.0));
}
}
Ok(out)
}
/// Every stored file: (name, bytes). Without OPFS, local storage takes over from here on.
async fn read_all() -> Result<Vec<(String, Vec<u8>, f64)>, String> {
let dir = match root().await {
Ok(d) => d,
Err(e) => {
log::warn!("no OPFS ({e}): designs go to local storage");
LOCAL.with(|l| l.set(true));
return read_local();
}
};
let iter: JsValue = dir.values().into();
let next: Function = Reflect::get(&iter, &"next".into()).map_err(err)?.dyn_into().map_err(|_| "no iterator".to_string())?;
let mut out = Vec::new();
for _ in 0..10_000 {
let step = JsFuture::from(js_sys::Promise::resolve(&next.call0(&iter).map_err(err)?)).await.map_err(err)?;
if Reflect::get(&step, &"done".into()).ok().and_then(|d| d.as_bool()).unwrap_or(true) {
break;
}
let Ok(h) = Reflect::get(&step, &"value".into()).map_err(err)?.dyn_into::<FileSystemFileHandle>() else { continue };
let file =
match JsFuture::from(h.get_file()).await.map_err(err).and_then(|f| f.dyn_into::<web_sys::File>().map_err(|_| "not a file".to_string())) {
Ok(f) => f,
Err(e) => {
log::warn!("{}: {e}", h.name());
continue;
}
};
match blob_bytes(&file).await {
Ok(b) => out.push((h.name(), b, file.last_modified())),
Err(e) => log::warn!("{}: {e}", h.name()),
}
}
Ok(out)
}
/// Ask the browser to keep the storage (not evict it under pressure), then load every stored
/// file into the engine's files.
pub fn start(files: &Rc<Files>) {
if let Some(sm) = storage_manager().filter(|sm| has(sm, "persist"))
&& let Ok(p) = sm.persist()
{
spawn_local(async move {
let _ = JsFuture::from(p).await;
});
}
let files = files.clone();
spawn_local(async move {
match read_all().await {
Ok(all) => {
if local_only() {
files.say("This page can't use the browser's file storage (it needs https or localhost): designs are kept in local storage, a few MB at most.", false);
}
let mut entries = Vec::new();
for (name, bytes, modified) in all {
if name == RECOVERY_META {
let meta: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
let n = meta["name"].as_str().unwrap_or("Untitled").to_string();
*files.recovery.borrow_mut() = Some((n, meta["path"].as_str().map(str::to_string)));
continue;
}
if name != RECOVERY {
entries.push(Entry { name: name.clone(), size: bytes.len() as u64, modified });
}
vfs::insert(&format!("{OPFS}{name}"), bytes);
}
entries.sort_by_key(|a| a.name.to_lowercase());
*files.entries.borrow_mut() = entries;
// An autosave without its design file is nothing to recover.
if !vfs::exists(&format!("{OPFS}{RECOVERY}")) {
*files.recovery.borrow_mut() = None;
}
}
Err(e) => files.say(format!("browser storage: {e}"), true),
}
files.ready.set(true);
files.wake();
});
}
/// Store what commands wrote: OPFS paths into OPFS, download paths as downloads.
pub fn flush(files: &Rc<Files>) {
for (path, bytes) in vfs::take_writes() {
if let Some(name) = path.strip_prefix(OPFS) {
let name = name.to_string();
{
let mut e = files.entries.borrow_mut();
e.retain(|x| x.name != name);
if name != RECOVERY && name != RECOVERY_META {
e.push(Entry { name: name.clone(), size: bytes.len() as u64, modified: js_sys::Date::now() });
e.sort_by_key(|a| a.name.to_lowercase());
}
}
let files = files.clone();
spawn_local(async move {
if let Err(e) = write_file(&name, &bytes).await {
files.say(format!("{name}: {e}"), true);
}
});
} else if let Some(name) = path.strip_prefix(DOWNLOADS)
&& let Err(e) = download(name, &bytes)
{
files.say(format!("{name}: {e}"), true);
}
}
}
/// Hand `bytes` to the user as a download named `name`.
pub fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let parts = Array::new();
parts.push(&Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type("application/octet-stream");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(err)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(err)?;
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(err)?.dyn_into().map_err(|_| "no anchor".to_string())?;
a.set_href(&url);
a.set_download(name);
a.click();
// Let the browser start the download before the URL goes.
let revoke = Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
if let Some(w) = web_sys::window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000);
}
Ok(())
}
/// Delete a stored design.
pub fn delete(files: &Rc<Files>, name: &str) {
let _ = vfs::remove(&format!("{OPFS}{name}"));
files.entries.borrow_mut().retain(|e| e.name != name);
let (files, name) = (files.clone(), name.to_string());
spawn_local(async move {
if let Err(e) = remove_file(&name).await {
files.say(format!("{name}: {e}"), true);
}
});
}
/// Rename a stored design (a copy under the new name, then the old one goes: not every browser
/// can move OPFS files).
pub fn rename(files: &Rc<Files>, from: &str, to: &str) -> Result<(), String> {
let to = to.trim();
if to.is_empty() || to.contains('/') || to.contains('\\') || to.len() > 200 {
return Err("give a file name without slashes".into());
}
if files.entries.borrow().iter().any(|e| e.name == to) {
return Err(format!("there is already a file named {to}"));
}
let bytes = vfs::read(&format!("{OPFS}{from}")).map_err(|e| e.to_string())?;
vfs::write(&format!("{OPFS}{to}"), &bytes).map_err(|e| e.to_string())?;
delete(files, from);
flush(files);
Ok(())
}
/// Keep (or drop) the autosave of the open design.
pub fn autosave(files: &Rc<Files>, doc: Option<(&solvecraft_engine::doc::Document, Option<&str>)>) {
let files = files.clone();
match doc {
Some((d, path)) => {
let bytes = solvecraft_engine::io::write_design(d);
let meta = serde_json::json!({"name": d.name, "path": path, "saved_at": js_sys::Date::now()}).to_string();
spawn_local(async move {
let r = async {
write_file(RECOVERY, &bytes).await?;
write_file(RECOVERY_META, meta.as_bytes()).await
};
if let Err(e) = r.await {
files.say(format!("autosave: {e}"), true);
}
});
}
None => spawn_local(async move {
let _ = remove_file(RECOVERY).await;
let _ = remove_file(RECOVERY_META).await;
}),
}
}
/// A stored path for `name` that no stored file has: `name`, else `name (2)`, `name (3)`…
pub fn free_name(name: &str) -> String {
let (stem, ext) = name.rsplit_once('.').map(|(s, e)| (s.to_string(), format!(".{e}"))).unwrap_or((name.to_string(), String::new()));
for i in 1..1000 {
let n = if i == 1 { name.to_string() } else { format!("{stem} ({i}){ext}") };
let p = format!("{OPFS}{n}");
if !vfs::exists(&p) {
return p;
}
}
format!("{OPFS}{stem} ({}){ext}", js_sys::Date::now() as u64)
}
/// The autosaved design's path (in the engine's files) for opening it.
pub fn recovery_path() -> String {
format!("{OPFS}{RECOVERY}")
}
/// Pick files on the computer: the File System Access picker where there is one, else an
/// `<input type=file>`. Read files arrive in `files.incoming`.
pub fn pick_from_disk(files: &Rc<Files>, intent: Intent) {
let Some(window) = web_sys::window() else { return };
let picker = Reflect::get(&window, &"showOpenFilePicker".into()).ok().and_then(|f| f.dyn_into::<Function>().ok());
match picker {
Some(f) => {
let opts = Object::new();
let _ = Reflect::set(&opts, &"multiple".into(), &JsValue::FALSE);
let promise = f.call1(&window, &opts);
let files = files.clone();
spawn_local(async move {
let r = async {
let p: js_sys::Promise = promise.map_err(err)?.dyn_into().map_err(|_| "no promise".to_string())?;
let handles: Array = JsFuture::from(p).await.map_err(err)?.dyn_into().map_err(|_| "no files".to_string())?;
for h in handles.iter() {
let h: FileSystemFileHandle = h.dyn_into().map_err(|_| "not a file".to_string())?;
let bytes = read_handle(&h).await?;
files.arrive(h.name(), bytes, intent);
}
Ok::<(), String>(())
};
match r.await {
Ok(()) => {}
// Closing the picker is no error.
Err(e) if e.contains("abort") || e.contains("Abort") => {}
Err(e) => files.say(format!("open: {e}"), true),
}
});
}
None => input_picker(files, intent),
}
}
fn input_picker(files: &Rc<Files>, intent: Intent) {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_accept(".solvecraft,.step,.stp,.3mf,.stl");
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
let _ = input.set_attribute("style", "display:none");
if let Some(body) = doc.body() {
let _ = body.append_child(&input);
}
let (files, el) = (files.clone(), input.clone());
let on_change = Closure::once_into_js(move || {
el.remove();
let Some(list) = el.files() else { return };
for i in 0..list.length() {
let Some(f) = list.get(i) else { continue };
let files = files.clone();
spawn_local(async move {
match blob_bytes(&f).await {
Ok(b) => files.arrive(f.name(), b, intent),
Err(e) => files.say(format!("{}: {e}", f.name()), true),
}
});
}
});
input.set_onchange(Some(on_change.unchecked_ref()));
input.click();
}
/// Read files dropped on the page.
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput) {
for f in std::mem::take(&mut raw.dropped_files) {
let files = files.clone();
spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(b) => files.arrive(name, b, Intent::Insert),
Err(e) => files.say(format!("{name}: {e}"), true),
}
});
}
}
+9 -334
View File
@@ -6,324 +6,30 @@
//! sample part.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod files;
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
use solvecraft_engine::Session;
use solvecraft_engine::io::vfs;
use solvecraft_ui_egui::{Services, SolveApp};
use wasm_bindgen::JsCast as _;
use crate::files::{self, Files, Intent};
const CANVAS_ID: &str = "solvecraft_canvas";
const LOADING_ID: &str = "solvecraft_loading";
const DEPTH_BITS: u8 = 24;
/// Seconds between autosaves of unsaved work.
const AUTOSAVE_S: f64 = 30.0;
thread_local! {
/// An open design has unsaved changes: leaving the page asks first.
static UNSAVED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
/// unsaved changes (the page can't show its own dialog when it is being closed).
fn ask_before_unload(window: &web_sys::Window) {
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
if UNSAVED.with(std::cell::Cell::get) {
e.prevent_default();
e.set_return_value("unsaved");
}
});
let _ = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref());
// The listener lives as long as the page.
on_unload.forget();
}
struct Shell {
app: SolveApp,
files: Rc<Files>,
/// When the last autosave was (seconds), and the revision it holds (none stored: `None`).
autosaved_at: f64,
autosaved: Option<u64>,
/// A stored design being renamed: (its name, the new name).
renaming: Option<(String, String)>,
/// The rename field takes the keyboard when it appears.
rename_focus: bool,
/// The start page's recent designs were filled from the stored ones.
recents_synced: bool,
}
impl Shell {
/// Files from the computer, writes to store, messages and the autosave.
fn files_tick(&mut self, ctx: &egui::Context) {
if self.files.ctx.borrow().is_none() {
*self.files.ctx.borrow_mut() = Some(ctx.clone());
}
files::flush(&self.files);
for (text, error) in self.files.messages.borrow_mut().drain(..) {
self.app.set_status(text, error);
}
let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
for (name, bytes, intent) in incoming {
let design = name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
let path = if design && intent == Intent::Open {
// A design from the computer is kept in the browser (a copy), so Save works.
let path = files::free_name(&name);
if let Err(e) = vfs::write(&path, &bytes) {
self.app.set_status(e.to_string(), true);
}
files::flush(&self.files);
path
} else {
let path = format!("{}{name}", files::UPLOAD);
vfs::insert(&path, bytes);
path
};
match intent {
Intent::Open => self.app.open_path(&path),
Intent::Insert => self.app.drop_path(&path),
}
self.files.panel.set(false);
}
if self.files.ready.get() && !self.recents_synced {
self.recents_synced = true;
self.sync_recents();
}
let now = ctx.input(|i| i.time);
// Unsaved work is kept (at most every AUTOSAVE_S); saved work needs no autosave.
if self.files.ready.get() && self.files.recovery.borrow().is_none() {
let s = &self.app.session;
let changed = s.is_dirty() && self.autosaved != Some(s.revision);
let wait = AUTOSAVE_S - (now - self.autosaved_at);
if changed && wait <= 0.0 {
files::autosave(&self.files, Some((&s.doc, s.path.as_deref())));
self.autosaved = Some(s.revision);
self.autosaved_at = now;
} else if changed {
// Come back for it even when nothing else happens.
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
} else if !s.is_dirty() && self.autosaved.is_some() {
files::autosave(&self.files, None);
self.autosaved = None;
}
}
if !self.files.ready.get() || !self.files.incoming.borrow().is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
/// The start page's recent designs are the stored ones: the remembered order first (those
/// still stored), then the others, newest first.
fn sync_recents(&mut self) {
let mut entries = self.files.entries.borrow().clone();
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
let recent = &mut self.app.home.recent;
recent.retain(|p| vfs::exists(p));
for e in entries.iter().filter(|e| e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT))) {
let p = format!("{}{}", files::OPFS, e.name);
if !recent.contains(&p) {
recent.push(p);
}
}
recent.truncate(solvecraft_ui_egui::home::MAX_RECENT);
}
/// The stored designs: open, insert, rename, delete, download; open from the computer.
fn file_panel(&mut self, ctx: &egui::Context) {
if !self.files.panel.get() {
return;
}
let mut open = true;
let mut act: Option<(&'static str, String)> = None;
egui::Window::new("Files in this browser")
.open(&mut open)
.collapsible(false)
.resizable(true)
.default_width(520.0)
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.show(ctx, |ui| {
ui.horizontal(|ui| {
if ui.button("Open from computer…").clicked() {
files::pick_from_disk(&self.files, Intent::Open);
}
if ui.button("Insert from computer…").on_hover_text("STEP, 3MF or STL into the open design").clicked() {
files::pick_from_disk(&self.files, Intent::Insert);
}
if ui.button("Download open design").clicked() {
act = Some(("download_open", String::new()));
}
});
ui.separator();
if !self.files.ready.get() {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Reading the browser's storage…");
});
return;
}
let entries = self.files.entries.borrow().clone();
if entries.is_empty() {
ui.label("Designs you save appear here. They stay in this browser.");
}
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
egui::Grid::new("opfs_files").num_columns(3).spacing([12.0, 6.0]).show(ui, |ui| {
for e in &entries {
match &mut self.renaming {
Some((from, to)) if *from == e.name => {
let r = ui.add(egui::TextEdit::singleline(to).desired_width(200.0));
if std::mem::take(&mut self.rename_focus) {
r.request_focus();
}
if r.lost_focus() {
act = Some(if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
("rename", to.clone())
} else {
("rename_cancel", String::new())
});
}
}
_ => {
ui.label(&e.name);
}
}
ui.label(format!("{:.1} KB", e.size as f64 / 1024.0));
ui.horizontal(|ui| {
if ui.small_button("Open").clicked() {
act = Some(("open", e.name.clone()));
}
let design = e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
if !design && ui.small_button("Insert").on_hover_text("Into the open design (STEP, 3MF, STL)").clicked() {
act = Some(("insert", e.name.clone()));
}
if ui.small_button("Rename").clicked() {
self.renaming = Some((e.name.clone(), e.name.clone()));
self.rename_focus = true;
}
if ui.small_button("Download").clicked() {
act = Some(("download", e.name.clone()));
}
if ui.small_button("Delete").clicked() {
act = Some(("delete", e.name.clone()));
}
});
ui.end_row();
}
});
});
});
if !open {
self.files.panel.set(false);
self.renaming = None;
}
let Some((what, name)) = act else { return };
let path = format!("{}{name}", files::OPFS);
match what {
"open" => {
self.app.open_path(&path);
self.files.panel.set(false);
}
"insert" => {
self.app.insert_path(&path);
self.files.panel.set(false);
}
"download" => match vfs::read(&path) {
Ok(b) => {
if let Err(e) = files::download(&name, &b) {
self.app.set_status(e, true);
}
}
Err(e) => self.app.set_status(e.to_string(), true),
},
"download_open" => {
let d = &self.app.session.doc;
let bytes = solvecraft_engine::io::write_design(d);
if let Err(e) = files::download(&format!("{}.{}", d.name, solvecraft_engine::io::DESIGN_EXT), &bytes) {
self.app.set_status(e, true);
}
}
"delete" => {
files::delete(&self.files, &name);
self.app.home.recent.retain(|r| *r != path);
}
"rename_cancel" => self.renaming = None,
"rename" => {
if let Some((from, _)) = self.renaming.take() {
match files::rename(&self.files, &from, &name) {
Ok(()) => {
// Recent designs follow the new name.
let (old, new) = (format!("{}{from}", files::OPFS), format!("{}{name}", files::OPFS));
for r in &mut self.app.home.recent {
if *r == old {
*r = new.clone();
}
}
}
Err(e) => self.app.set_status(e, true),
}
}
}
_ => {}
}
}
/// Offer the autosave an earlier session left.
fn recovery_prompt(&mut self, ctx: &egui::Context) {
let Some((name, path)) = self.files.recovery.borrow().clone() else { return };
let mut answer = None;
egui::Window::new("Recover unsaved work?").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(
ctx,
|ui| {
ui.label(format!("“{name}” had unsaved changes when this page last closed."));
ui.horizontal(|ui| {
if ui.button("Recover").clicked() {
answer = Some(true);
}
if ui.button("Discard").clicked() {
answer = Some(false);
}
});
},
);
let Some(recover) = answer else { return };
*self.files.recovery.borrow_mut() = None;
if recover {
self.app.open_path(&files::recovery_path());
// It belongs to its own file again (saving goes there); it is unsaved.
self.app.session.path = path;
self.app.session.doc_mut().name = name;
self.app.session.mark_unsaved();
self.app.home.open = false;
}
files::autosave(&self.files, None);
}
}
struct Shell(SolveApp);
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.app.logic(ctx);
self.files_tick(ctx);
UNSAVED.with(|u| u.set(solvecraft_ui_egui::documents::any_dirty(&self.app)));
self.0.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
files::take_drops(&self.files, raw);
self.app.raw_input_hook(raw);
self.0.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
let ctx = ui.ctx().clone();
self.file_panel(&ctx);
self.recovery_prompt(&ctx);
self.0.ui(ui);
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string(PREFS_KEY, self.app.prefs());
storage.set_string(PREFS_KEY, self.0.prefs());
}
}
@@ -337,9 +43,7 @@ mod web {
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
let Some(window) = web_sys::window() else { return };
ask_before_unload(&window);
let Some(document) = window.document() else { return };
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
@@ -355,29 +59,12 @@ mod web {
canvas,
options,
Box::new(move |cc| {
let files = Rc::new(Files::default());
files::start(&files);
let panel = files.clone();
let services = Services {
// Open shows the browser's files (and the computer's, from there).
pick_open: Some(Box::new(move || {
panel.panel.set(true);
None
})),
// Designs are kept in the browser; everything else is downloaded.
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
let design = exts.contains(&solvecraft_engine::io::DESIGN_EXT);
Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))
})),
..Default::default()
};
let mut app = SolveApp::new(Session::default(), services);
let mut app = SolveApp::new(Session::default(), Services::default());
if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
// eframe's web painter has no stencil buffer.
app.set_wgpu(rs, DEPTH_BITS, 0, 1);
app.set_wgpu(rs, DEPTH_BITS, 1);
}
if query().contains("sample") {
if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) {
@@ -386,19 +73,7 @@ mod web {
app.session.doc_mut().name = "Sample Plate".into();
app.session.mark_saved();
}
if !query().contains("sample") {
let rev = app.session.revision;
app.home.show(rev);
}
Ok(Box::new(Shell {
app,
files,
autosaved_at: f64::NEG_INFINITY,
autosaved: None,
renaming: None,
rename_focus: false,
recents_synced: false,
}))
Ok(Box::new(Shell(app)))
}),
)
.await;
-3
View File
@@ -18,6 +18,3 @@ egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
+3 -46
View File
@@ -6,7 +6,7 @@
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
//!
//! `--control <port>` (or `SOLVECRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
//! server: `{"id":1,"method":"engine.execute","params":{"command":"PrimitiveBox","params":{…}}}`.
//! See `solvecraft_ui_egui::control` and docs/control-protocol.md.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -21,12 +21,6 @@ struct App(SolveApp);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
// designs first; the window closes once they are answered (`quit_requested`).
if ctx.input(|i| i.viewport().close_requested()) && !self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
solvecraft_ui_egui::documents::request_quit(&mut self.0);
}
self.0.logic(ctx);
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
@@ -53,15 +47,10 @@ fn services() -> Services {
Services {
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter(
"Designs, STEP, IGES and meshes",
&["solvecraft", "step", "stp", "STEP", "STP", "igs", "iges", "IGS", "IGES", "3mf", "3MF", "stl", "STL"],
)
.add_filter("Designs, STEP and meshes", &["solvecraft", "step", "stp", "STEP", "STP", "3mf", "3MF", "stl", "STL"])
.add_filter("SolveCraft design", &["solvecraft"])
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
.add_filter("All files", &["*"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
@@ -70,23 +59,6 @@ fn services() -> Services {
rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string())
})),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
show_in_folder: Some(Box::new(|path| {
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
"explorer"
} else if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
});
command.arg(folder);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000);
}
let _ = command.spawn();
})),
}
}
@@ -112,8 +84,6 @@ fn msaa() -> u16 {
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
const DEPTH_BITS: u8 = 24;
/// The section cap marks where it shows in the stencil.
const STENCIL_BITS: u8 = 8;
const MSAA: u16 = 4;
fn main() -> eframe::Result {
@@ -149,22 +119,9 @@ fn main() -> eframe::Result {
.with_title_shown(false)
.with_app_id("ai.storyteller.solvecraft"),
depth_buffer: DEPTH_BITS,
stencil_buffer: STENCIL_BITS,
multisampling: samples,
..Default::default()
};
#[cfg(all(unix, not(target_os = "macos")))]
let mut options = options;
#[cfg(all(unix, not(target_os = "macos")))]
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; SOLVECRAFT_WAYLAND=1
// keeps the native Wayland backend.
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("SOLVECRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
eframe::run_native(
"SolveCraft",
options,
@@ -176,7 +133,7 @@ fn main() -> eframe::Result {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, u32::from(samples));
app.set_wgpu(rs, DEPTH_BITS, u32::from(samples));
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
+8 -40
View File
@@ -47,33 +47,6 @@ pub struct FaceLook {
pub name: Option<String>,
}
/// The look a body was imported with, carried through later features (which build new kernel
/// bodies without it): its colour and opacity, and its faces' colours by persistent face name.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ImportedLook {
pub color: Option<[f32; 3]>,
pub opacity: Option<f32>,
pub faces: BTreeMap<String, ([f32; 3], f32)>,
}
impl ImportedLook {
/// A body's imported look: its own kernel paint, else what it carries.
pub fn of(b: &crate::ModelBody) -> Option<std::sync::Arc<ImportedLook>> {
if b.body.paint().is_none() && b.body.color().is_none() {
return b.imported.clone();
}
let names = crate::naming::face_names(b);
let faces = b
.body
.paint()
.map(|p| {
p.faces.iter().filter_map(|f| names.get(f.face).map(|n| (crate::naming::strip_piece(n).to_string(), (f.color, f.opacity)))).collect()
})
.unwrap_or_default();
Some(std::sync::Arc::new(ImportedLook { color: b.body.color(), opacity: b.body.paint().map(|p| p.opacity), faces }))
}
}
/// The design's appearances.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Appearances {
@@ -153,20 +126,15 @@ impl Document {
/// assignment to the same face wins.
pub fn face_colors(&self, b: &crate::ModelBody) -> Vec<(usize, Look)> {
// Face colours the body was imported with, under the design's own.
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
let mut out: Vec<(usize, Look)> = b
.body
.paint()
.map(|p| {
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
p.faces.iter().map(|f| (f.face, Look::custom(f.color.map(to8), f.opacity as f64))).collect()
})
.unwrap_or_default();
let names = crate::naming::face_names(b);
let mut out: Vec<(usize, Look)> = match b.body.paint() {
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.
None => match &b.imported {
Some(imp) => names
.iter()
.enumerate()
.filter_map(|(i, n)| imp.faces.get(crate::naming::strip_piece(n)).map(|(c, o)| (i, Look::custom(c.map(to8), *o as f64))))
.collect(),
None => Vec::new(),
},
};
for f in self.appearances.faces.iter().filter(|f| f.body == b.name) {
let (p, look) = (f.point, f.look.clone());
// By name (every piece of a split face), else by the point.
+6 -93
View File
@@ -144,64 +144,25 @@ fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
}
// Named origin planes and construction planes are the component's own.
PlaneRef::Origin { .. } | PlaneRef::Construction { .. } => {}
PlaneRef::Tangent { face, at } => {
*at = apply_point(m, *at);
map_geo_ref(m, face);
}
PlaneRef::Midplane { .. }
| PlaneRef::TwoEdges { .. }
| PlaneRef::ThreePoints { .. }
| PlaneRef::AlongPath { .. }
| PlaneRef::Perpendicular { .. } => {
for r in p.refs_mut() {
map_geo_ref(m, r);
}
}
}
}
fn map_geo_ref(m: &Mat, g: &mut crate::construct::GeoRef) {
g.map_points(&mut |p: &mut Vec3| *p = apply_point(m, *p), &mut |pl: &mut PlaneRef| map_plane_ref(m, pl));
}
impl FeatureKind {
/// Map the feature's world geometry (points, directions, explicit planes) by `m`.
pub fn transform_geometry(&mut self, m: &Mat) {
let pt = |p: &mut Vec3| *p = apply_point(m, *p);
let dir = |v: &mut Vec3| *v = apply_vector(m, *v);
match self {
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Mirror { plane, .. } => {
map_plane_ref(m, plane)
}
FeatureKind::ConstructionAxis { def } => def.refs_mut().into_iter().for_each(|r| map_geo_ref(m, r)),
FeatureKind::ConstructionPoint { def } => def.refs_mut().into_iter().for_each(|r| map_geo_ref(m, r)),
FeatureKind::Split { plane, tool, .. } => {
map_plane_ref(m, plane);
if let Some(t) = tool {
pt(&mut t.point);
}
}
FeatureKind::SplitFace { faces, plane, tool, .. } => {
faces.iter_mut().for_each(pt);
map_plane_ref(m, plane);
if let Some(t) = tool {
pt(&mut t.point);
}
}
FeatureKind::Sketch { plane, .. }
| FeatureKind::ConstructionPlane { plane }
| FeatureKind::Split { plane, .. }
| FeatureKind::Mirror { plane, .. } => map_plane_ref(m, plane),
FeatureKind::Revolve { axis: AxisRef::Line { origin, dir: d }, .. } => {
pt(origin);
dir(d);
}
FeatureKind::Fillet { edges, style, .. } => {
edges.iter_mut().for_each(pt);
if let crate::FilletStyle::Variable { start, .. } = style {
pt(start);
}
}
FeatureKind::Chamfer { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::Shell { faces, .. } | FeatureKind::OffsetFace { faces, .. } | FeatureKind::DeleteFace { faces, .. } => {
faces.iter_mut().for_each(pt)
}
FeatureKind::Fillet { edges, .. } | FeatureKind::Chamfer { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::Shell { faces, .. } | FeatureKind::OffsetFace { faces, .. } => faces.iter_mut().for_each(pt),
FeatureKind::Draft { faces, neutral, pull, .. } => {
faces.iter_mut().for_each(pt);
map_plane_ref(m, neutral);
@@ -244,7 +205,6 @@ impl FeatureKind {
dir(direction);
}
FeatureKind::Lip { face, .. } => pt(face),
FeatureKind::BoundaryFill { cells, .. } => cells.iter_mut().for_each(pt),
FeatureKind::Rest { position, direction, along, .. } => {
pt(position);
dir(direction);
@@ -292,18 +252,8 @@ impl FeatureKind {
| FeatureKind::Emboss { .. }
| FeatureKind::Rib { .. }
| FeatureKind::Remove { .. }
| FeatureKind::Stitch { .. }
| FeatureKind::Thicken { .. }
| FeatureKind::Import { .. }
| FeatureKind::MeshImport { .. } => {}
FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
map_plane_ref(m, plane);
if let Some(t) = tool {
pt(&mut t.point);
}
pt(keep);
}
}
}
}
@@ -348,43 +298,6 @@ impl Document {
out
}
/// Every body as the world shows it: its world name ("Body1", or "Body1 (Occ:2)" for a
/// further instance), its component, the occurrence path, and the transform from the frame
/// its geometry is in to the world.
pub fn body_frames(&self, st: &crate::ModelState) -> Vec<(String, u64, Vec<u64>, Mat)> {
let mut out = Vec::new();
for (comp, path, m) in self.placements() {
let primary = path == self.first_path(comp);
let label = || -> String {
path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::<Vec<_>>().join("/")
};
for b in st.bodies.iter().filter(|b| self.body_component(&b.name, b.feature) == comp) {
let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) };
let w = match self.body_offsets.get(&b.name) {
Some(o) => mat_mul(&m, o),
None => m,
};
out.push((name, comp, path.clone(), w));
}
}
out
}
/// The occurrence path of a component's first placement.
pub fn first_path(&self, component: u64) -> Vec<u64> {
let mut path = Vec::new();
let mut c = component;
for _ in 0..1000 {
if c == 0 {
break;
}
let Some(o) = self.occurrence_of(c) else { break };
path.insert(0, o.id);
c = o.parent;
}
path
}
/// Is `a` inside `b` (or `b` itself)?
pub fn component_within(&self, a: u64, b: u64) -> bool {
let mut c = a;
+1 -22
View File
@@ -34,14 +34,6 @@ pub struct Canvas {
pub flip: bool,
#[serde(default = "yes")]
pub visible: bool,
/// The component it belongs to (its plane is in that component's frame, so it moves with
/// the component's occurrence); 0 = the root.
#[serde(default, skip_serializing_if = "is_root")]
pub component: u64,
}
fn is_root(c: &u64) -> bool {
*c == 0
}
fn half() -> f64 {
@@ -65,7 +57,7 @@ impl Canvas {
let f = if self.flip { -1.0 } else { 1.0 };
[rot(-hw * f, -hh), rot(hw * f, -hh), rot(hw * f, hh), rot(-hw * f, hh)]
}
/// Corners on `plane` (in whatever frame the plane is given).
/// Corners in world coordinates.
pub fn world_corners(&self, plane: &Plane) -> [Vec3; 4] {
self.corners().map(|p| plane.to_world(p))
}
@@ -74,19 +66,6 @@ impl Canvas {
}
}
impl crate::Document {
/// A canvas's plane in the world: resolved in its component's frame, then placed where the
/// component's occurrence puts it.
pub fn canvas_plane(&self, vals: &std::collections::BTreeMap<String, crate::expr::Value>, c: &Canvas) -> crate::Result<Plane> {
let p = self.resolve_plane(vals, &c.plane, 0)?;
let m = self.component_transform(c.component);
if crate::is_identity(&m) {
return Ok(p);
}
Ok(Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)).unwrap_or(p))
}
}
/// Image kind and pixel size from the file header (PNG or JPEG).
pub fn image_info(b: &[u8]) -> Option<(&'static str, [u32; 2])> {
if b.len() > 24 && b.starts_with(b"\x89PNG\r\n\x1a\n") {
-6
View File
@@ -40,12 +40,6 @@ impl FeatureKind {
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Split { plane, .. } => {
plane_names(plane, names)
}
FeatureKind::SplitFace { plane, sketch, .. } => {
plane_names(plane, names);
if let Some(s) = sketch {
sk(s);
}
}
FeatureKind::Extrude { sketch, .. }
| FeatureKind::Revolve { sketch, .. }
| FeatureKind::Emboss { sketch, .. }
-653
View File
@@ -1,653 +0,0 @@
//! Construction geometry from the model: references to vertices, edges, faces, planes, axes,
//! points and sketch entities, resolved on each evaluation, and the axes, points and planes
//! built from them (Fusion's CONSTRUCT panel).
//!
//! Face and edge references carry the persistent name of what was picked (see `naming`), so
//! they follow the model when earlier features change; the picked point is the fallback.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use solvecraft_geom::{Plane, Vec2, Vec3};
use solvecraft_kernel::{self as kernel, FaceSurface};
use crate::eval::{ModelBody, ModelState};
use crate::expr::{Kind, Value};
use crate::{DocError, Document, PlaneRef, Result};
/// Something picked to build construction geometry from.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GeoRef {
/// A fixed point.
Point { p: Vec3 },
/// The design origin.
Origin,
/// A body vertex (the one nearest `at`).
Vertex { at: Vec3 },
/// A body edge: the one named `name`, else the one nearest `at`.
Edge {
at: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// A body face: the one named `name`, else the one at `at`.
Face {
at: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// A plane (origin, construction, offset…).
Plane { plane: PlaneRef },
/// World X, Y or Z axis.
World { axis: String },
/// A construction axis feature, by name.
Axis { name: String },
/// A construction point feature, by name.
ConstructPoint { name: String },
/// A sketch point (by sketch feature id and point id).
SketchPoint { sketch: u64, point: String },
/// A sketch curve (by sketch feature id and curve id).
SketchCurve { sketch: u64, curve: String },
}
/// How a construction axis is defined.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AxisDef {
/// The axis of a cylinder, cone or torus face (or a circular edge).
ThroughFace { face: GeoRef },
/// Perpendicular to a face, through a point (projected onto the face).
NormalToFace { face: GeoRef, point: GeoRef },
/// Where two planes meet.
TwoPlanes { a: GeoRef, b: GeoRef },
/// Through two points.
TwoPoints { a: GeoRef, b: GeoRef },
/// Along a straight edge (or sketch line).
Edge { edge: GeoRef },
}
/// How a construction point is defined.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PointDef {
/// At a vertex, sketch point or fixed point.
At { point: GeoRef },
/// Where two edges (lines) meet, or come closest.
TwoEdges { a: GeoRef, b: GeoRef },
/// Where three planes meet.
ThreePlanes { a: GeoRef, b: GeoRef, c: GeoRef },
/// The centre of a circular edge, a sphere or a torus.
Center { of: GeoRef },
/// Where an edge (line) crosses a plane.
EdgeAndPlane { edge: GeoRef, plane: GeoRef },
/// At a fraction `t` (0…1) of the way along an edge or sketch curve.
AlongPath { path: GeoRef, t: String },
}
/// Resolved construction geometry, kept in the model state by feature.
#[derive(Clone, Debug, PartialEq)]
pub enum ConstructGeom {
Plane(Plane),
Axis { origin: Vec3, dir: Vec3 },
Point(Vec3),
}
#[derive(Clone, Debug, PartialEq)]
pub struct Construct {
pub feature: u64,
pub name: String,
pub geom: ConstructGeom,
}
/// Everything a reference needs to be resolved.
pub(crate) struct Ctx<'a> {
pub doc: &'a Document,
pub vals: &'a BTreeMap<String, Value>,
pub st: &'a ModelState,
}
fn bad(m: impl Into<String>) -> DocError {
DocError::Invalid(m.into())
}
fn bodies(st: &ModelState) -> impl Iterator<Item = &ModelBody> {
st.bodies.iter().filter(|b| !b.body.is_mesh())
}
fn construct<'a>(st: &'a ModelState, name: &str) -> Result<&'a ConstructGeom> {
st.construct.iter().find(|c| c.name == name).map(|c| &c.geom).ok_or_else(|| DocError::Unknown(format!("construction `{name}`")))
}
/// The face picked: (body, face index, point on it).
fn face_of<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<(&'a ModelBody, usize, Vec3)> {
let at = match name.as_deref().and_then(|n| crate::naming::point_on_face(st, n, at)) {
Some((q, _)) => q,
None => at,
};
for b in bodies(st) {
if let Some(fi) = crate::appearance::face_index_at(b, at) {
return Ok((b, fi, at));
}
}
Err(bad(format!("no face at {at:?}")))
}
/// The edge picked, as a polyline.
fn edge_points(st: &ModelState, at: Vec3, name: &Option<String>) -> Result<Vec<Vec3>> {
let mut at = at;
if let Some(n) = name {
for b in bodies(st) {
if let Ok((q, _)) = crate::naming::find_edge(b, n, at) {
at = q;
break;
}
}
}
let mut best: Option<(f64, Vec<Vec3>)> = None;
for b in bodies(st) {
let tol = (b.body.size() * 1e-3).max(1e-3);
let Ok(edges) = b.body.edges(tol) else { continue };
for e in edges {
let d = e.points.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::MAX, f64::min);
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
best = Some((d, e.points));
}
}
}
match best {
Some((d, pts)) if pts.len() >= 2 && d <= 1e-3 + 1e-2 * pts.iter().fold(0.0, |m: f64, p| m.max(p.len())).max(1.0) => Ok(pts),
_ => Err(bad(format!("no edge at {at:?}"))),
}
}
/// A polyline that is straight: its line.
fn as_line(pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
let (a, b) = (*pts.first()?, *pts.last()?);
let d = (b - a).normalized()?;
let len = (b - a).len();
pts.iter().all(|p| p.dist_to_segment(a, b) <= len * 1e-6 + 1e-9).then_some((a, d))
}
/// A polyline on a circle: (centre, unit normal, radius).
fn as_circle(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64)> {
let n = pts.len();
if n < 4 {
return None;
}
let (a, b, c) = (*pts.first()?, *pts.get(n / 3)?, *pts.get(2 * n / 3)?);
let (ab, ac) = (b - a, c - a);
let nrm = ab.cross(ac);
let l2 = nrm.len2();
if l2 < 1e-24 {
return None;
}
// Circumcentre of abc.
let centre = a + (nrm.cross(ab) * ac.len2() + ac.cross(nrm) * ab.len2()) * (1.0 / (2.0 * l2));
let r = (a - centre).len();
let unit = nrm.normalized()?;
pts.iter()
.all(|p| ((*p - centre).len() - r).abs() <= r * 1e-3 + 1e-6 && (*p - centre).dot(unit).abs() <= r * 1e-3 + 1e-6)
.then_some((centre, unit, r))
}
fn sketch_world(ctx: &Ctx, sketch: u64, q: Vec2) -> Result<Vec3> {
let ss = ctx.st.sketch(sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
Ok(ss.plane.to_world(q))
}
fn sketch_curve_points(ctx: &Ctx, sketch: u64, curve: &str) -> Result<Vec<Vec3>> {
let ss = ctx.st.sketch(sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("curve `{curve}`")))?;
let pl = ss.sketch.polyline(ci);
if pl.len() < 2 {
return Err(bad(format!("curve `{curve}` has no length")));
}
Ok(pl.into_iter().map(|q| ss.plane.to_world(q)).collect())
}
/// A reference as a point.
pub(crate) fn point(ctx: &Ctx, g: &GeoRef) -> Result<Vec3> {
match g {
GeoRef::Point { p } => Ok(*p),
GeoRef::Origin => Ok(Vec3::ZERO),
GeoRef::Vertex { at } => {
let mut best: Option<(f64, Vec3)> = None;
for b in bodies(ctx.st) {
let tol = (b.body.size() * 1e-3).max(1e-3);
let Ok(edges) = b.body.edges(tol) else { continue };
for v in edges.iter().flat_map(|e| [e.points.first().copied(), e.points.last().copied()]).flatten() {
let d = v.dist(*at);
if best.is_none_or(|(bd, _)| d < bd) {
best = Some((d, v));
}
}
}
best.map(|(_, v)| v).ok_or_else(|| bad("there is no vertex"))
}
GeoRef::ConstructPoint { name } => match construct(ctx.st, name)? {
ConstructGeom::Point(p) => Ok(*p),
_ => Err(bad(format!("`{name}` is not a point"))),
},
GeoRef::SketchPoint { sketch, point } => {
let ss = ctx.st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
let q =
ss.sketch.point_index(point).and_then(|i| ss.sketch.point(i)).ok_or_else(|| DocError::Unknown(format!("sketch point `{point}`")))?;
sketch_world(ctx, *sketch, q)
}
// A round edge or face: its centre.
GeoRef::Edge { .. } | GeoRef::Face { .. } | GeoRef::SketchCurve { .. } => centre(ctx, g),
_ => Err(bad("pick a point, vertex or sketch point")),
}
}
/// A reference as a line (origin, unit direction).
pub(crate) fn line(ctx: &Ctx, g: &GeoRef) -> Result<(Vec3, Vec3)> {
match g {
GeoRef::Edge { at, name } => {
let pts = edge_points(ctx.st, *at, name)?;
as_line(&pts).or_else(|| as_circle(&pts).map(|(c, n, _)| (c, n))).ok_or_else(|| bad("the edge is neither straight nor circular"))
}
GeoRef::SketchCurve { sketch, curve } => {
let pts = sketch_curve_points(ctx, *sketch, curve)?;
as_line(&pts).ok_or_else(|| bad(format!("sketch curve `{curve}` is not a line")))
}
GeoRef::World { axis } => {
let d = match axis.to_ascii_uppercase().as_str() {
"X" => Vec3::X,
"Y" => Vec3::Y,
"Z" => Vec3::Z,
_ => return Err(DocError::Unknown(format!("axis `{axis}`"))),
};
Ok((Vec3::ZERO, d))
}
GeoRef::Axis { name } => match construct(ctx.st, name)? {
ConstructGeom::Axis { origin, dir } => Ok((*origin, *dir)),
_ => Err(bad(format!("`{name}` is not an axis"))),
},
GeoRef::Face { at, name } => {
let (b, fi, _) = face_of(ctx.st, *at, name)?;
match kernel::face_surface(&b.body, fi) {
Some(FaceSurface::Cylinder { origin, axis, .. }) => Ok((origin, axis)),
Some(FaceSurface::Cone { apex, axis, .. }) => Ok((apex, axis)),
Some(FaceSurface::Torus { center, axis, .. }) => Ok((center, axis)),
_ => Err(bad("the face is not a cylinder, cone or torus")),
}
}
_ => Err(bad("pick an edge, line or axis")),
}
}
/// A reference as a plane.
pub(crate) fn plane(ctx: &Ctx, g: &GeoRef) -> Result<Plane> {
match g {
GeoRef::Plane { plane } => ctx.doc.resolve_plane_in(ctx.vals, plane, 0, Some(ctx.st)),
GeoRef::Face { at, name } => {
let (b, fi, q) = face_of(ctx.st, *at, name)?;
match kernel::face_surface(&b.body, fi) {
Some(FaceSurface::Plane { normal, point }) => {
let o = q - normal * (q - point).dot(normal);
Plane::from_normal(o, normal).ok_or_else(|| bad("degenerate face"))
}
_ => Err(bad("the face is not planar")),
}
}
_ => Err(bad("pick a plane or planar face")),
}
}
/// The centre of a round edge, sketch circle, sphere or torus.
fn centre(ctx: &Ctx, g: &GeoRef) -> Result<Vec3> {
match g {
GeoRef::Edge { at, name } => {
let pts = edge_points(ctx.st, *at, name)?;
as_circle(&pts).map(|(c, _, _)| c).ok_or_else(|| bad("the edge is not circular"))
}
GeoRef::SketchCurve { sketch, curve } => {
let pts = sketch_curve_points(ctx, *sketch, curve)?;
as_circle(&pts).map(|(c, _, _)| c).ok_or_else(|| bad(format!("sketch curve `{curve}` is not a circle or arc")))
}
GeoRef::Face { at, name } => {
let (b, fi, _) = face_of(ctx.st, *at, name)?;
match kernel::face_surface(&b.body, fi) {
Some(FaceSurface::Sphere { center, .. }) | Some(FaceSurface::Torus { center, .. }) => Ok(center),
_ => Err(bad("the face is not a sphere or torus")),
}
}
_ => Err(bad("pick a circular edge, a sphere or a torus")),
}
}
/// Point and unit tangent at fraction `t` along a polyline.
fn along(pts: &[Vec3], t: f64) -> Option<(Vec3, Vec3)> {
let total: f64 = pts.windows(2).map(|w| (w[1] - w[0]).len()).sum();
if !(total > 0.0) {
return None;
}
let mut left = t.clamp(0.0, 1.0) * total;
for w in pts.windows(2) {
let l = (w[1] - w[0]).len();
if l <= 0.0 {
continue;
}
if left <= l {
return Some((w[0] + (w[1] - w[0]) * (left / l), (w[1] - w[0]) * (1.0 / l)));
}
left -= l;
}
let (a, b) = (pts.get(pts.len() - 2)?, pts.last()?);
Some((*b, (*b - *a).normalized()?))
}
fn path_points(ctx: &Ctx, g: &GeoRef) -> Result<Vec<Vec3>> {
match g {
GeoRef::Edge { at, name } => edge_points(ctx.st, *at, name),
GeoRef::SketchCurve { sketch, curve } => sketch_curve_points(ctx, *sketch, curve),
_ => Err(bad("pick an edge or sketch curve as the path")),
}
}
/// The surface's unit normal at (the point of the surface nearest) `p`, and that point.
fn surface_normal(s: FaceSurface, p: Vec3) -> Option<(Vec3, Vec3)> {
match s {
FaceSurface::Plane { normal, point } => Some((p - normal * (p - point).dot(normal), normal)),
FaceSurface::Cylinder { origin, axis, radius } => {
let w = p - origin;
let r = (w - axis * w.dot(axis)).normalized()?;
Some((origin + axis * w.dot(axis) + r * radius, r))
}
FaceSurface::Sphere { center, radius } => {
let r = (p - center).normalized()?;
Some((center + r * radius, r))
}
FaceSurface::Cone { apex, axis, half_angle } => {
let w = p - apex;
let radial = (w - axis * w.dot(axis)).normalized()?;
// The generator through p's side, and the normal square to it in the axial plane.
let line = axis * half_angle.cos() + radial * half_angle.sin();
let n = (radial * half_angle.cos() - axis * half_angle.sin()).normalized()?;
Some((apex + line * w.dot(line), n))
}
FaceSurface::Torus { center, axis, major, minor } => {
let w = p - center;
let radial = (w - axis * w.dot(axis)).normalized()?;
let tube = center + radial * major;
let n = (p - tube).normalized()?;
Some((tube + n * minor, n))
}
}
}
fn face_surface_at(ctx: &Ctx, g: &GeoRef) -> Result<(FaceSurface, Vec3)> {
match g {
GeoRef::Face { at, name } => {
let (b, fi, q) = face_of(ctx.st, *at, name)?;
kernel::face_surface(&b.body, fi).map(|s| (s, q)).ok_or_else(|| bad("the face's surface is not a plane, cylinder, cone, sphere or torus"))
}
GeoRef::Plane { .. } => {
let pl = plane(ctx, g)?;
Ok((FaceSurface::Plane { normal: pl.normal(), point: pl.origin }, pl.origin))
}
_ => Err(bad("pick a face")),
}
}
/// Where two planes meet.
fn plane_plane(a: &Plane, b: &Plane) -> Option<(Vec3, Vec3)> {
let (n1, n2) = (a.normal(), b.normal());
let d = n1.cross(n2);
let l2 = d.len2();
if l2 < 1e-18 {
return None;
}
let (h1, h2) = (n1.dot(a.origin), n2.dot(b.origin));
let o = (n2.cross(d) * h1 + d.cross(n1) * h2) * (1.0 / l2);
Some((o, d.normalized()?))
}
/// Closest points of two lines: their midpoint (where they meet, if they do).
fn line_line(a: (Vec3, Vec3), b: (Vec3, Vec3)) -> Option<Vec3> {
let ((p, u), (q, v)) = (a, b);
let w = p - q;
let (uu, uv, vv, uw, vw) = (u.dot(u), u.dot(v), v.dot(v), u.dot(w), v.dot(w));
let den = uu * vv - uv * uv;
if den.abs() < 1e-12 {
return None;
}
let s = (uv * vw - vv * uw) / den;
let t = (uu * vw - uv * uw) / den;
Some(((p + u * s) + (q + v * t)) * 0.5)
}
pub(crate) fn resolve_axis(ctx: &Ctx, d: &AxisDef) -> Result<(Vec3, Vec3)> {
match d {
AxisDef::ThroughFace { face } => line(ctx, face),
AxisDef::Edge { edge } => {
let (o, dir) = line(ctx, edge)?;
if let GeoRef::Edge { at, name } = edge
&& as_line(&edge_points(ctx.st, *at, name)?).is_none()
{
return Err(bad("the edge is not straight"));
}
Ok((o, dir))
}
AxisDef::NormalToFace { face, point: p } => {
let (s, _) = face_surface_at(ctx, face)?;
let q = point(ctx, p)?;
let (on, n) = surface_normal(s, q).ok_or_else(|| bad("the point is on the face's axis"))?;
Ok((on, n))
}
AxisDef::TwoPlanes { a, b } => plane_plane(&plane(ctx, a)?, &plane(ctx, b)?).ok_or_else(|| bad("the planes are parallel")),
AxisDef::TwoPoints { a, b } => {
let (p, q) = (point(ctx, a)?, point(ctx, b)?);
Ok((p, (q - p).normalized().ok_or_else(|| bad("the two points coincide"))?))
}
}
}
pub(crate) fn resolve_point(ctx: &Ctx, d: &PointDef) -> Result<Vec3> {
match d {
PointDef::At { point: p } => point(ctx, p),
PointDef::Center { of } => centre(ctx, of),
PointDef::TwoEdges { a, b } => line_line(line(ctx, a)?, line(ctx, b)?).ok_or_else(|| bad("the edges are parallel")),
PointDef::ThreePlanes { a, b, c } => {
let (pa, pb, pc) = (plane(ctx, a)?, plane(ctx, b)?, plane(ctx, c)?);
let (o, dir) = plane_plane(&pa, &pb).ok_or_else(|| bad("two of the planes are parallel"))?;
line_plane(o, dir, &pc).ok_or_else(|| bad("the planes don't meet in a point"))
}
PointDef::EdgeAndPlane { edge, plane: p } => {
let (o, dir) = line(ctx, edge)?;
line_plane(o, dir, &plane(ctx, p)?).ok_or_else(|| bad("the edge is parallel to the plane"))
}
PointDef::AlongPath { path, t } => {
let t = Document::eval_in(ctx.vals, t, Kind::Unitless)?;
along(&path_points(ctx, path)?, t).map(|(p, _)| p).ok_or_else(|| bad("the path has no length"))
}
}
}
fn line_plane(o: Vec3, d: Vec3, p: &Plane) -> Option<Vec3> {
let n = p.normal();
let den = d.dot(n);
if den.abs() < 1e-12 {
return None;
}
Some(o + d * ((p.origin - o).dot(n) / den))
}
/// Planes built from model geometry (the `PlaneRef` kinds that need the model).
pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
let degenerate = || bad("degenerate plane");
match p {
PlaneRef::Midplane { a, b } => {
let (pa, pb) = (plane(ctx, a)?, plane(ctx, b)?);
let (na, nb) = (pa.normal(), pb.normal());
if na.cross(nb).len() < 1e-9 {
// Parallel: halfway between, facing as the first.
let half = nb * ((pb.origin - pa.origin).dot(nb) * 0.5);
return Plane::new(pa.origin + half, pa.x, pa.y).ok_or_else(degenerate);
}
// Meeting at an angle: the plane through their line that halves the angle.
let (o, dir) = plane_plane(&pa, &pb).ok_or_else(degenerate)?;
let n = (na - nb).normalized().ok_or_else(degenerate)?;
Plane::new(o, dir, n.cross(dir)).ok_or_else(degenerate)
}
PlaneRef::TwoEdges { a, b } => {
let ((o1, d1), (o2, d2)) = (line(ctx, a)?, line(ctx, b)?);
let n = match d1.cross(d2).normalized() {
Some(n) if (o2 - o1).dot(n).abs() <= 1e-6 * (1.0 + (o2 - o1).len()) => n,
Some(_) => return Err(bad("the edges are not in one plane")),
None => d1.cross(o2 - o1).normalized().ok_or_else(|| bad("the edges are on one line"))?,
};
Plane::new(o1, d1, n.cross(d1)).ok_or_else(degenerate)
}
PlaneRef::ThreePoints { a, b, c } => {
let (pa, pb, pc) = (point(ctx, a)?, point(ctx, b)?, point(ctx, c)?);
if (pb - pa).cross(pc - pa).len() < 1e-9 {
return Err(bad("the three points are on one line"));
}
Plane::new(pa, pb - pa, pc - pa).ok_or_else(degenerate)
}
PlaneRef::Tangent { face, at } => {
let (s, _) = face_surface_at(ctx, face)?;
let (on, n) = surface_normal(s, *at).ok_or_else(|| bad("the point is on the face's axis"))?;
Plane::from_normal(on, n).ok_or_else(degenerate)
}
PlaneRef::Perpendicular { plane: base, line: l } => {
let pl = plane(ctx, base)?;
let (o, d) = line(ctx, l)?;
let n = d
.cross(pl.normal())
.normalized()
.ok_or_else(|| bad("the line is square to the plane: every plane through it is perpendicular; pick a line in or along the plane"))?;
Plane::new(o, d, n.cross(d)).ok_or_else(degenerate)
}
PlaneRef::AlongPath { path, t } => {
let t = Document::eval_in(ctx.vals, t, Kind::Unitless)?;
let (p, tan) = along(&path_points(ctx, path)?, t).ok_or_else(|| bad("the path has no length"))?;
Plane::from_normal(p, tan).ok_or_else(degenerate)
}
_ => ctx.doc.resolve_plane_in(ctx.vals, p, 0, Some(ctx.st)),
}
}
impl GeoRef {
/// Map the reference's world points (component frames).
pub fn map_points(&mut self, pt: &mut dyn FnMut(&mut Vec3), plane: &mut dyn FnMut(&mut PlaneRef)) {
match self {
GeoRef::Point { p } => pt(p),
GeoRef::Vertex { at } | GeoRef::Edge { at, .. } | GeoRef::Face { at, .. } => pt(at),
GeoRef::Plane { plane: p } => plane(p),
_ => {}
}
}
/// Picked faces and edges get their persistent names (and only those still missing one).
pub fn name_picks(&mut self, st: &ModelState) {
match self {
GeoRef::Face { at, name: name @ None } => {
*name = crate::naming::face_names_at(st, &[*at]).into_iter().next().filter(|n| !n.is_empty());
}
GeoRef::Edge { at, name: name @ None } => {
for b in bodies(st) {
let tol = (b.body.size() * 1e-3).max(1e-3);
if b.body.nearest_edge(*at, tol).ok().flatten().is_some_and(|(_, d)| d <= tol * 10.0) {
*name = crate::naming::edge_names_at(b, &[*at]).into_iter().next().filter(|n| !n.is_empty());
break;
}
}
}
GeoRef::Plane { plane } => plane.name_picks(st),
_ => {}
}
}
}
impl GeoRef {
fn plane_ref(&self) -> Option<&PlaneRef> {
match self {
GeoRef::Plane { plane } => Some(plane),
_ => None,
}
}
}
impl AxisDef {
pub fn refs(&self) -> Vec<&GeoRef> {
match self {
AxisDef::ThroughFace { face } => vec![face],
AxisDef::Edge { edge } => vec![edge],
AxisDef::NormalToFace { face, point } => vec![face, point],
AxisDef::TwoPlanes { a, b } | AxisDef::TwoPoints { a, b } => vec![a, b],
}
}
/// Planes it refers to (whose expressions it depends on).
pub fn planes(&self) -> Vec<&PlaneRef> {
self.refs().into_iter().filter_map(GeoRef::plane_ref).collect()
}
pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> {
match self {
AxisDef::ThroughFace { face } => vec![face],
AxisDef::Edge { edge } => vec![edge],
AxisDef::NormalToFace { face, point } => vec![face, point],
AxisDef::TwoPlanes { a, b } | AxisDef::TwoPoints { a, b } => vec![a, b],
}
}
}
impl PointDef {
pub fn refs(&self) -> Vec<&GeoRef> {
match self {
PointDef::At { point } => vec![point],
PointDef::Center { of } => vec![of],
PointDef::TwoEdges { a, b } => vec![a, b],
PointDef::ThreePlanes { a, b, c } => vec![a, b, c],
PointDef::EdgeAndPlane { edge, plane } => vec![edge, plane],
PointDef::AlongPath { path, .. } => vec![path],
}
}
pub fn planes(&self) -> Vec<&PlaneRef> {
self.refs().into_iter().filter_map(GeoRef::plane_ref).collect()
}
pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> {
match self {
PointDef::At { point } => vec![point],
PointDef::Center { of } => vec![of],
PointDef::TwoEdges { a, b } => vec![a, b],
PointDef::ThreePlanes { a, b, c } => vec![a, b, c],
PointDef::EdgeAndPlane { edge, plane } => vec![edge, plane],
PointDef::AlongPath { path, .. } => vec![path],
}
}
}
impl PlaneRef {
/// The references of a plane built from model geometry.
pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> {
match self {
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => vec![a.as_mut(), b.as_mut()],
PlaneRef::Perpendicular { plane, line } => vec![plane.as_mut(), line.as_mut()],
PlaneRef::ThreePoints { a, b, c } => vec![a.as_mut(), b.as_mut(), c.as_mut()],
PlaneRef::Tangent { face, .. } => vec![face.as_mut()],
PlaneRef::AlongPath { path, .. } => vec![path.as_mut()],
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => base.refs_mut(),
_ => Vec::new(),
}
}
pub fn name_picks(&mut self, st: &ModelState) {
for r in self.refs_mut() {
r.name_picks(st);
}
}
/// Does resolving it need the model (edges, faces, construction axes and points)?
pub fn needs_model(&self) -> bool {
let mut k = self.clone();
!k.refs_mut().is_empty()
}
}
+30 -375
View File
@@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize};
use solvecraft_geom::{Plane, Vec2, Vec3};
use solvecraft_sketch::Sketch;
use crate::construct::{AxisDef, GeoRef, PointDef};
use crate::expr::{self, Kind};
use crate::{DocError, Result};
@@ -44,18 +43,6 @@ pub enum PlaneRef {
AtAngle { base: Box<PlaneRef>, axis_origin: Vec3, axis_dir: Vec3, angle: String },
/// A construction plane feature (by name).
Construction { name: String },
/// Halfway between two planes (or planar faces); bisecting them when they meet at an angle.
Midplane { a: Box<GeoRef>, b: Box<GeoRef> },
/// Through two straight edges (lines) that lie in one plane.
TwoEdges { a: Box<GeoRef>, b: Box<GeoRef> },
/// Through three points.
ThreePoints { a: Box<GeoRef>, b: Box<GeoRef>, c: Box<GeoRef> },
/// Tangent to a face (cylinder, cone, sphere, torus) where the point `at` lies over it.
Tangent { face: Box<GeoRef>, at: Vec3 },
/// Square to an edge or sketch curve, a fraction `t` (expression, 0…1) of the way along it.
AlongPath { path: Box<GeoRef>, t: String },
/// Through a straight edge (or line, axis) and square to a plane (or planar face).
Perpendicular { plane: Box<GeoRef>, line: Box<GeoRef> },
/// A planar body face: found again on each evaluation (the face with this plane's normal
/// nearest `at`), so sketches on it follow when earlier features change; `plane` is the
/// plane as picked (its frame, and the fallback when the face is gone). `name` is the face's
@@ -125,8 +112,7 @@ pub struct Extent {
/// Optional second side distance (two-sided extrude), along the negative normal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub distance2: Option<String>,
/// Start offset from the sketch plane (expression), along the sketch normal whatever the
/// direction.
/// Start offset from the sketch plane (expression).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_offset: Option<String>,
/// Through all bodies in the direction(s) instead of `distance`.
@@ -135,16 +121,6 @@ pub struct Extent {
/// Taper angle (expression; positive grows the profile).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub taper: Option<String>,
/// To Object: end at the face (or vertex/point) at this point, instead of `distance`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<Vec3>,
/// Offset past the To Object face (expression; negative stops short).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to_offset: Option<String>,
/// From Object: start at the face (or point) here instead of the sketch plane (plus
/// `start_offset`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<Vec3>,
}
/// Revolve axis.
@@ -189,15 +165,6 @@ pub enum FeatureKind {
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<String>,
/// Two Sides: the angle on the other side.
#[serde(default, skip_serializing_if = "Option::is_none")]
angle2: Option<String>,
/// Symmetric: `angle` split evenly either side of the profile.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
symmetric: bool,
/// To Object: turn until the face, vertex or point here.
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<Vec3>,
},
Fillet {
/// Reference points on the edges to round (each re-finds the nearest edge).
@@ -205,10 +172,6 @@ pub enum FeatureKind {
radius: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
/// Constant radius (the default), chord length (`radius` is the width across), or a
/// radius varying along each edge.
#[serde(default, skip_serializing_if = "FilletStyle::is_constant")]
style: FilletStyle,
},
Chamfer {
edges: Vec<Vec3>,
@@ -299,13 +262,9 @@ pub enum FeatureKind {
/// A cosmetic thread in the holes (e.g. "M6x1").
#[serde(default, skip_serializing_if = "Option::is_none")]
thread: Option<String>,
/// To Object: drill down to the face (or point) here instead of `depth`.
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<Vec3>,
},
/// 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.
/// `designation` is an ISO metric size such as "M8" or "M8x1".
/// A cosmetic thread on a cylindrical face (no geometry change; drawn and exported as an
/// annotation). `designation` is an ISO metric size such as "M8" or "M8x1".
Thread {
face: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -313,8 +272,6 @@ pub enum FeatureKind {
/// Thread length from the face's end nearest `face`; `None` = full length.
#[serde(default, skip_serializing_if = "Option::is_none")]
length: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
modeled: bool,
},
/// Ruled loft through profiles of several sketches, in order.
Loft {
@@ -341,12 +298,6 @@ pub enum FeatureKind {
thickness: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
/// inside (default) | outside | both (the wall straddles the faces).
#[serde(default, skip_serializing_if = "String::is_empty")]
direction: String,
/// Open the faces smoothly joined to the picked ones too.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
tangent_chain: bool,
},
/// Tilt faces (at the given points) about a neutral plane, leaning toward `pull`.
Draft {
@@ -361,34 +312,10 @@ pub enum FeatureKind {
ConstructionPlane {
plane: PlaneRef,
},
/// Split a body into the parts on either side of a plane, or of a face of a body (the face
/// through `point`, its surface extended) when `tool` is set.
/// Split a body with a plane into two bodies.
Split {
body: String,
plane: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
},
/// A construction axis (Fusion's CONSTRUCT axes).
ConstructionAxis {
def: AxisDef,
},
/// A construction point (Fusion's CONSTRUCT points).
ConstructionPoint {
def: PointDef,
},
/// Split faces (at the given points) where a tool meets them, the solid unchanged: a plane,
/// a face of a body (`tool`, its surface extended) or a sketch's curves (`sketch`, swept
/// along its normal). The pieces are named after the face (`#k`).
SplitFace {
faces: Vec<Vec3>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
plane: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
#[serde(default, skip_serializing_if = "Option::is_none")]
sketch: Option<u64>,
},
/// Scale bodies about a point (uniform, or per axis).
Scale {
@@ -407,14 +334,6 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// Remove faces (at the given points) and heal the gap from the neighbouring planes, as when
/// a fillet, chamfer, hole or boss is taken off. A picked face brings the rest of its surface
/// (both halves of a hole's wall).
DeleteFace {
faces: Vec<Vec3>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// A box around bodies, grown by a margin.
BoundingSolid {
bodies: Vec<String>,
@@ -679,65 +598,10 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// Boundary Fill: solids from the cells the tool bodies enclose (a cell: inside some
/// tools and outside the rest), each picked by a point inside it.
BoundaryFill {
tools: Vec<String>,
cells: Vec<Vec3>,
#[serde(default)]
operation: Operation,
/// Remove the tool bodies afterwards.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
remove_tools: bool,
},
/// Remove bodies from the model (from here on in the timeline).
Remove {
bodies: Vec<String>,
},
/// Surface bodies: planar patches filling sketch regions (`profiles` of `sketch`), or a
/// closed loop of a body's edges (`edges`: points on them, on `body`).
Patch {
#[serde(default, skip_serializing_if = "Option::is_none")]
sketch: Option<u64>,
#[serde(default = "all_profiles")]
profiles: ProfileSel,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
edges: Vec<Vec3>,
},
/// Surface bodies sewn along shared edges into the first one (a solid when they close).
Stitch {
bodies: Vec<String>,
#[serde(default = "zero_expr")]
tolerance: String,
},
/// Surface bodies thickened into solids along their normals (by half each way when
/// `symmetric`).
Thicken {
bodies: Vec<String>,
thickness: String,
#[serde(default)]
symmetric: bool,
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<String>,
},
/// A planar surface body extended outward by `distance` past the edges through `edges`.
SurfaceExtend {
body: String,
edges: Vec<Vec3>,
distance: String,
},
/// A surface body trimmed by a plane, or by a face of a body (`tool`), keeping the side of
/// `keep`.
SurfaceTrim {
body: String,
plane: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
keep: Vec3,
},
Move {
bodies: Vec<String>,
translate: [String; 3],
@@ -766,39 +630,6 @@ pub enum FeatureKind {
},
}
/// How a fillet's size is given.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FilletStyle {
#[default]
Constant,
/// `radius` is the chord: the width across the blend.
Chord,
/// `radius` at the end of each edge nearest `start`, `radius2` at the other; `mid` radii at
/// positions between (0 to 1 from the start end); straight between them, or `smooth`.
Variable {
radius2: String,
start: Vec3,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
mid: Vec<(f64, String)>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
smooth: bool,
},
}
impl FilletStyle {
pub fn is_constant(&self) -> bool {
*self == FilletStyle::Constant
}
}
/// A face picked by a point on it, on a named body.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FaceAt {
pub body: String,
pub point: Vec3,
}
/// Triangles of one imported mesh body (millimetres).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MeshData {
@@ -926,10 +757,6 @@ fn plane_exprs<'a>(p: &'a PlaneRef, v: &mut Vec<&'a str>) {
v.push(angle);
p = base;
}
PlaneRef::AlongPath { t, .. } => {
v.push(t);
break;
}
_ => break,
}
}
@@ -940,66 +767,6 @@ fn z_axis() -> Vec3 {
}
impl FeatureKind {
/// Sketches the feature is built from (profiles, paths, points): deleting one of them
/// deletes the feature. Every kind is listed, so a new one has to say what it uses.
pub fn input_sketches(&self) -> Vec<u64> {
use FeatureKind as K;
match self {
K::Extrude { sketch, .. }
| K::Revolve { sketch, .. }
| K::Emboss { sketch, .. }
| K::Rib { sketch, .. }
| K::SheetBase { sketch, .. }
| K::SheetContour { sketch, .. } => vec![*sketch],
K::Sweep { sketch, path_sketch, .. } => vec![*sketch, *path_sketch],
K::Pipe { path_sketch, .. } | K::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } => vec![*path_sketch],
K::Loft { sections, .. } => sections.iter().map(|s| s.sketch).collect(),
K::SheetFold { sketch, .. } | K::SplitFace { sketch, .. } | K::Patch { sketch, .. } => sketch.iter().copied().collect(),
K::Hole { points, .. } => points.iter().map(|p| p.sketch).collect(),
K::Sketch { .. }
| K::Pattern { .. }
| K::Fillet { .. }
| K::Chamfer { .. }
| K::Box { .. }
| K::Cylinder { .. }
| K::Sphere { .. }
| K::Torus { .. }
| K::Combine { .. }
| K::Mirror { .. }
| K::Thread { .. }
| K::Shell { .. }
| K::Draft { .. }
| K::ConstructionPlane { .. }
| K::Split { .. }
| K::ConstructionAxis { .. }
| K::ConstructionPoint { .. }
| K::Scale { .. }
| K::OffsetFace { .. }
| K::DeleteFace { .. }
| K::BoundingSolid { .. }
| K::ReplaceFace { .. }
| K::Align { .. }
| K::Coil { .. }
| K::SheetFlange { .. }
| K::SheetHem { .. }
| K::SheetUnfold { .. }
| K::SheetConvert { .. }
| K::Boss { .. }
| K::Lip { .. }
| K::SnapFit { .. }
| K::Rest { .. }
| K::BoundaryFill { .. }
| K::Remove { .. }
| K::Stitch { .. }
| K::Thicken { .. }
| K::SurfaceExtend { .. }
| K::SurfaceTrim { .. }
| K::Move { .. }
| K::Import { .. }
| K::MeshImport { .. } => Vec::new(),
}
}
/// A sheet metal feature (its result depends on the sheet metal rules).
pub fn is_plastic(&self) -> bool {
matches!(self, FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. })
@@ -1043,13 +810,9 @@ impl FeatureKind {
FeatureKind::Shell { .. } => "ShellFeature",
FeatureKind::Draft { .. } => "DraftFeature",
FeatureKind::Split { .. } => "SplitBodyFeature",
FeatureKind::SplitFace { .. } => "SplitFaceFeature",
FeatureKind::ConstructionAxis { .. } => "ConstructionAxis",
FeatureKind::ConstructionPoint { .. } => "ConstructionPoint",
FeatureKind::Move { .. } => "MoveFeature",
FeatureKind::Scale { .. } => "ScaleFeature",
FeatureKind::OffsetFace { .. } => "OffsetFacesFeature",
FeatureKind::DeleteFace { .. } => "DeleteFaceFeature",
FeatureKind::BoundingSolid { .. } => "BoundingSolidFeature",
FeatureKind::Pipe { .. } => "PipeFeature",
FeatureKind::Emboss { .. } => "EmbossFeature",
@@ -1058,7 +821,6 @@ impl FeatureKind {
FeatureKind::Lip { .. } => "GrooveFeature",
FeatureKind::SnapFit { .. } => "SnapFitFeature",
FeatureKind::Rest { .. } => "RestFeature",
FeatureKind::BoundaryFill { .. } => "BoundaryFillFeature",
FeatureKind::SheetBase { .. } => "BaseFlangeFeature",
FeatureKind::SheetContour { .. } => "ContourFlangeFeature",
FeatureKind::SheetFlange { .. } => "EdgeFlangeFeature",
@@ -1073,11 +835,6 @@ impl FeatureKind {
FeatureKind::ReplaceFace { .. } => "ReplaceFaceFeature",
FeatureKind::Align { .. } => "AlignFeature",
FeatureKind::Remove { .. } => "RemoveFeature",
FeatureKind::Patch { .. } => "PatchFeature",
FeatureKind::Stitch { .. } => "StitchFeature",
FeatureKind::Thicken { .. } => "ThickenFeature",
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
FeatureKind::SurfaceExtend { .. } => "ExtendFeature",
FeatureKind::Import { .. } => "BaseFeature",
FeatureKind::MeshImport { .. } => "MeshFeature",
}
@@ -1107,13 +864,9 @@ impl FeatureKind {
FeatureKind::Shell { .. } => "Shell",
FeatureKind::Draft { .. } => "Draft",
FeatureKind::Split { .. } => "Split",
FeatureKind::SplitFace { .. } => "SplitFace",
FeatureKind::ConstructionAxis { .. } => "Axis",
FeatureKind::ConstructionPoint { .. } => "Point",
FeatureKind::Move { .. } => "Move",
FeatureKind::Scale { .. } => "Scale",
FeatureKind::OffsetFace { .. } => "OffsetFace",
FeatureKind::DeleteFace { .. } => "DeleteFace",
FeatureKind::BoundingSolid { .. } => "BoundingSolid",
FeatureKind::Pipe { .. } => "Pipe",
FeatureKind::Emboss { .. } => "Emboss",
@@ -1122,7 +875,6 @@ impl FeatureKind {
FeatureKind::Lip { .. } => "Groove",
FeatureKind::SnapFit { .. } => "SnapFit",
FeatureKind::Rest { .. } => "Rest",
FeatureKind::BoundaryFill { .. } => "BoundaryFill",
FeatureKind::SheetBase { .. } => "BaseFlange",
FeatureKind::SheetContour { .. } => "ContourFlange",
FeatureKind::SheetFlange { .. } => "EdgeFlange",
@@ -1137,11 +889,6 @@ impl FeatureKind {
FeatureKind::ReplaceFace { .. } => "ReplaceFace",
FeatureKind::Align { .. } => "Align",
FeatureKind::Remove { .. } => "Remove",
FeatureKind::Patch { .. } => "Patch",
FeatureKind::Stitch { .. } => "Stitch",
FeatureKind::Thicken { .. } => "Thicken",
FeatureKind::SurfaceTrim { .. } => "Trim",
FeatureKind::SurfaceExtend { .. } => "Extend",
FeatureKind::Import { .. } => "Import",
FeatureKind::MeshImport { .. } => "Mesh",
}
@@ -1149,34 +896,13 @@ impl FeatureKind {
/// Every expression the feature uses.
/// Points on faces the feature refers to (shell, draft, offset and replace faces, a hole's
/// placement), for persistent face names.
/// The feature's profile selections, each with its sketch.
pub fn profile_sels_mut(&mut self) -> Vec<(u64, &mut ProfileSel)> {
match self {
FeatureKind::Extrude { sketch, profiles, .. }
| FeatureKind::Revolve { sketch, profiles, .. }
| FeatureKind::Sweep { sketch, profiles, .. }
| FeatureKind::Emboss { sketch, profiles, .. }
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
FeatureKind::Patch { sketch: Some(sk), profiles, .. } => vec![(*sk, profiles)],
FeatureKind::Loft { sections, .. } => sections.iter_mut().filter(|s| s.point.is_none()).map(|s| (s.sketch, &mut s.profiles)).collect(),
_ => Vec::new(),
}
}
pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> {
match self {
FeatureKind::Shell { faces, .. }
| FeatureKind::Draft { faces, .. }
| FeatureKind::OffsetFace { faces, .. }
| FeatureKind::DeleteFace { faces, .. }
| FeatureKind::SplitFace { faces, .. }
| FeatureKind::ReplaceFace { faces, .. } => faces.iter_mut().collect(),
FeatureKind::Hole { position, points: None, to, .. } => std::iter::once(position).chain(to.iter_mut()).collect(),
FeatureKind::Hole { to: Some(t), .. } => vec![t],
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
FeatureKind::Thread { face, .. } => vec![face],
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
FeatureKind::Hole { position, points: None, .. } => vec![position],
_ => Vec::new(),
}
}
@@ -1189,17 +915,9 @@ impl FeatureKind {
pub fn expressions(&self) -> Vec<&str> {
let mut v: Vec<&str> = Vec::new();
match self {
FeatureKind::ConstructionAxis { def } => def.planes().into_iter().for_each(|p| plane_exprs(p, &mut v)),
FeatureKind::ConstructionPoint { def } => {
if let PointDef::AlongPath { t, .. } = def {
v.push(t);
}
def.planes().into_iter().for_each(|p| plane_exprs(p, &mut v));
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Split { plane, .. } => {
plane_exprs(plane, &mut v)
}
FeatureKind::Sketch { plane, .. }
| FeatureKind::ConstructionPlane { plane }
| FeatureKind::Split { plane, .. }
| FeatureKind::SplitFace { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::Extrude { extent, .. } => {
v.push(&extent.distance);
if let Some(d) = &extent.distance2 {
@@ -1212,17 +930,8 @@ impl FeatureKind {
v.push(d);
}
}
FeatureKind::Revolve { angle, angle2, .. } => {
v.push(angle);
v.extend(angle2.iter().map(String::as_str));
}
FeatureKind::Fillet { radius, style, .. } => {
v.push(radius);
if let FilletStyle::Variable { radius2, mid, .. } = style {
v.push(radius2);
v.extend(mid.iter().map(|(_, r)| r.as_str()));
}
}
FeatureKind::Revolve { angle, .. } => v.push(angle),
FeatureKind::Fillet { radius, .. } => v.push(radius),
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
v.push(distance);
v.extend(distance2.iter().map(String::as_str));
@@ -1317,12 +1026,7 @@ impl FeatureKind {
v.extend(depth.iter().map(String::as_str));
}
FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v),
FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } | FeatureKind::DeleteFace { .. } => {}
FeatureKind::Stitch { tolerance, .. } => v.push(tolerance),
FeatureKind::Thicken { thickness, .. } => v.push(thickness),
FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::SurfaceExtend { distance, .. } => v.push(distance),
FeatureKind::Patch { .. } => {}
FeatureKind::Align { .. } | FeatureKind::Remove { .. } => {}
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {}
}
v
@@ -1354,10 +1058,6 @@ pub struct Feature {
/// Persistent names of the faces at the feature's face points (`FeatureKind::face_points`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub face_names: Vec<String>,
/// Bodies a join/cut/intersect may change (any component); empty = the feature's own
/// component's bodies.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub participants: Vec<String>,
#[serde(flatten)]
pub kind: FeatureKind,
}
@@ -1366,10 +1066,6 @@ fn inside_str() -> String {
"inside".into()
}
fn all_profiles() -> ProfileSel {
ProfileSel::All
}
fn zero_expr() -> String {
"0".into()
}
@@ -1378,15 +1074,6 @@ fn is_root(c: &u64) -> bool {
*c == 0
}
/// A standard part (fastener, pin, bearing) inserted as a component.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StandardPart {
pub family: String,
pub size: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub length: Option<f64>,
}
/// The document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
@@ -1417,9 +1104,6 @@ pub struct Document {
/// Plastic rules.
#[serde(default, skip_serializing_if = "crate::plastic::PlasticSettings::is_empty")]
pub plastic: crate::plastic::PlasticSettings,
/// Components that are standard parts (by component id): what to rebuild them from.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub parts: std::collections::BTreeMap<u64, StandardPart>,
/// Configurations (rows of parameter values and suppressions).
#[serde(default, skip_serializing_if = "crate::config::ConfigTable::is_empty")]
pub configs: crate::config::ConfigTable,
@@ -1441,10 +1125,6 @@ pub struct Document {
/// Bodies moved into another component than their feature's (by body name).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub body_components: std::collections::BTreeMap<String, u64>,
/// Bodies moved into another component keep their place in the world: the transform from
/// the new component's frame to the frame the body was made in (by body name).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub body_offsets: std::collections::BTreeMap<String, crate::Mat>,
/// Browser groups (folders of bodies, sketches or construction planes).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub browser_groups: Vec<BrowserGroup>,
@@ -1511,9 +1191,7 @@ impl Document {
sheet: Default::default(),
plastic: Default::default(),
configs: Default::default(),
parts: Default::default(),
body_components: Default::default(),
body_offsets: Default::default(),
materials: Default::default(),
appearances: Default::default(),
favorites: Default::default(),
@@ -1529,7 +1207,7 @@ impl Document {
/// else its component's, else its material's look, else the colour it was imported with.
/// (Faces may have their own: `face_looks`.)
pub fn body_color(&self, b: &crate::ModelBody) -> Option<[f32; 3]> {
self.body_look(&b.name, b.feature).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color))
self.body_look(&b.name, b.feature).map(|l| l.rgb()).or_else(|| b.body.color())
}
/// The model with each body carrying the colour, opacity and face colours it is shown with
@@ -1544,11 +1222,7 @@ impl Document {
.into_iter()
.map(|(face, l)| solvecraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32 })
.collect();
let opacity = look
.map(|l| l.opacity as f32)
.or_else(|| b.body.paint().map(|p| p.opacity))
.or_else(|| b.imported.as_ref().and_then(|i| i.opacity))
.unwrap_or(1.0);
let opacity = look.map(|l| l.opacity as f32).or_else(|| b.body.paint().map(|p| p.opacity)).unwrap_or(1.0);
let paint = solvecraft_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));
@@ -1663,7 +1337,6 @@ impl Document {
edge_refs: Vec::new(),
edge_names: Vec::new(),
face_names: Vec::new(),
participants: Vec::new(),
kind,
};
match self.marker {
@@ -1681,7 +1354,18 @@ impl Document {
pub fn delete_feature(&mut self, id: u64) -> Result<Vec<u64>> {
let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?;
let mut gone = vec![id];
gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id));
for f in &self.features {
match &f.kind {
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::Sweep { sketch, path_sketch, .. } if *sketch == id || *path_sketch == id => gone.push(f.id),
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.sketch == id) => gone.push(f.id),
FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id),
FeatureKind::Emboss { sketch, .. } | FeatureKind::Rib { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::SheetFold { sketch: Some(s), .. } if *s == id => gone.push(f.id),
_ => {}
}
}
self.features.retain(|f| !gone.contains(&f.id));
// Configuration columns of deleted features go with them.
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect();
@@ -1822,18 +1506,6 @@ impl Document {
/// Resolve a plane reference.
pub fn resolve_plane(&self, vals: &std::collections::BTreeMap<String, expr::Value>, p: &PlaneRef, depth: usize) -> Result<Plane> {
self.resolve_plane_in(vals, p, depth, None)
}
/// Resolve a plane; planes built from model geometry (edges, faces, construction axes and
/// points) need the model state `st`, and construction planes are read from it when given.
pub fn resolve_plane_in(
&self,
vals: &std::collections::BTreeMap<String, expr::Value>,
p: &PlaneRef,
depth: usize,
st: Option<&crate::ModelState>,
) -> Result<Plane> {
if depth > 32 {
return Err(DocError::Invalid("plane offsets nested too deeply".into()));
}
@@ -1843,38 +1515,21 @@ impl Document {
Plane::new(plane.origin, plane.x, plane.y).ok_or_else(|| DocError::Invalid("degenerate plane".into()))
}
PlaneRef::Offset { base, distance } => {
let b = self.resolve_plane_in(vals, base, depth + 1, st)?;
let b = self.resolve_plane(vals, base, depth + 1)?;
Ok(b.offset(Self::eval_in(vals, distance, Kind::Length)?))
}
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => {
let b = self.resolve_plane_in(vals, base, depth + 1, st)?;
let b = self.resolve_plane(vals, base, depth + 1)?;
let a = Self::eval_in(vals, angle, Kind::Angle)?;
let d = axis_dir.normalized().ok_or_else(|| DocError::Invalid("rotation axis".into()))?;
let rot = |v: Vec3| v * a.cos() + d.cross(v) * a.sin() + d * (d.dot(v) * (1.0 - a.cos()));
let o = *axis_origin + rot(b.origin - *axis_origin);
Plane::new(o, rot(b.x), rot(b.y)).ok_or_else(|| DocError::Invalid("degenerate plane".into()))
}
PlaneRef::Construction { name } => {
// Evaluated already: as it came out.
if let Some(c) = st.and_then(|st| st.construct.iter().find(|c| &c.name == name))
&& let crate::construct::ConstructGeom::Plane(pl) = &c.geom
{
return Ok(*pl);
}
match self.find_feature(name).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPlane { plane }) => self.resolve_plane_in(vals, plane, depth + 1, st),
_ => Err(DocError::Unknown(format!("construction plane `{name}`"))),
}
}
PlaneRef::Midplane { .. }
| PlaneRef::TwoEdges { .. }
| PlaneRef::ThreePoints { .. }
| PlaneRef::Tangent { .. }
| PlaneRef::AlongPath { .. }
| PlaneRef::Perpendicular { .. } => {
let st = st.ok_or_else(|| DocError::Invalid("this plane is built from the model: evaluate first".into()))?;
crate::construct::resolve_geo_plane(&crate::construct::Ctx { doc: self, vals, st }, p)
}
PlaneRef::Construction { name } => match self.find_feature(name).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPlane { plane }) => self.resolve_plane(vals, plane, depth + 1),
_ => Err(DocError::Unknown(format!("construction plane `{name}`"))),
},
}
}
+47 -757
View File
File diff suppressed because it is too large. Load diff
+9 -88
View File
@@ -45,7 +45,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
apply_op(st, f, tools, Operation::Join, &[])
}
FeatureKind::ReplaceFace { faces, target, body } => {
let tp = doc.resolve_plane_in(vals, target, 0, Some(st))?;
let tp = doc.resolve_plane(vals, target, 0)?;
let tn = tp.normal();
let i = body_at(st, body, faces)?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
@@ -288,13 +288,7 @@ fn mirror_y(r: &Region2) -> Region2 {
Region2 { outer: m(&r.outer), holes: r.holes.iter().map(|h| m(h).reversed()).collect() }
}
pub(super) fn sheet_eval(
doc: &Document,
vals: &BTreeMap<String, Value>,
f: &Feature,
st: &mut ModelState,
warning: &mut Option<String>,
) -> Result<()> {
pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
use crate::sheet;
match &f.kind {
FeatureKind::SheetBase { sketch, profiles, rule, flip } => {
@@ -309,22 +303,7 @@ pub(super) fn sheet_eval(
(ss.plane, region)
};
let name = new_name(st, f, k);
let mut sh = sheet::base_flange(&name, &r.name, &rv, plane, region);
// The sketch's lines name the base panel's edges.
sh.base_curves = ss
.sketch
.curves
.iter()
.enumerate()
.filter(|(_, c)| !c.construction)
.flat_map(|(ci, c)| {
ss.sketch.segs(ci).into_iter().filter_map(move |sg| match sg {
Seg2::Line { a, b } => Some((c.id.clone(), a, b)),
_ => None,
})
})
.map(|(id, a, b)| if *flip { (id, Vec2::new(a.x, -a.y), Vec2::new(b.x, -b.y)) } else { (id, a, b) })
.collect();
let sh = sheet::base_flange(&name, &r.name, &rv, plane, region);
let solid = sh.solid()?;
st.bodies.push(ModelBody::new(name, solid, f.id));
st.sheets.push(sh);
@@ -370,10 +349,7 @@ pub(super) fn sheet_eval(
if sh.flat {
return Err(DocError::Invalid("refold the sheet before adding flanges".into()));
}
let picks = sheet_picks(sh, f, edges, warning);
let n0 = sh.flanges.len();
sh.add_flanges(&picks, h, a, rr, position, *flip)?;
tag_flanges(sh, n0, f.id);
sh.add_flanges(edges, h, a, rr, position, *flip)?;
rebuild_sheet(st, i)
}
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
@@ -387,12 +363,9 @@ pub(super) fn sheet_eval(
}
};
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
let picks = sheet_picks(sh, f, edges, warning);
let n0 = sh.flanges.len();
for e in &picks {
for e in edges {
sh.add_hem(*e, l, g, *flip)?;
}
tag_flanges(sh, n0, f.id);
rebuild_sheet(st, i)
}
FeatureKind::SheetFold { sketch, curve, a, b, angle, radius, position, flip, fixed, body } => {
@@ -421,9 +394,7 @@ pub(super) fn sheet_eval(
if sh.flat {
return Err(DocError::Invalid("refold the sheet before folding it".into()));
}
let n0 = sh.flanges.len();
sh.add_fold(a, b, ang, rr, position, *flip, *fixed)?;
tag_flanges(sh, n0, f.id);
rebuild_sheet(st, i)
}
FeatureKind::SheetUnfold { body, refold } => {
@@ -613,12 +584,7 @@ fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, le
/// The body a placed plastic feature joins: by name, else the one under the point.
fn body_under(st: &ModelState, body: &Option<String>, p: Vec3) -> Result<String> {
let i = body_at(st, body, &[p])?;
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("there is no body".into()))?;
let tolerance = (b.body.size() * 1e-5).max(1e-4);
if crate::project::nearest_face(&b.mesh(), p).is_none_or(|(_, _, distance)| distance > tolerance) {
return Err(DocError::Invalid("the plastic feature's placement is no longer on its body face".into()));
}
Ok(b.name.clone())
st.bodies.get(i).map(|b| b.name.clone()).ok_or_else(|| DocError::Invalid("there is no body".into()))
}
/// Axes with z along `z` (and x along `x` when given).
@@ -788,28 +754,11 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
return Err(DocError::Invalid("pick the rim face of a shelled body (a face with an inner and an outer edge)".into()));
};
let bw = if *groove { w + g } else { w };
// A lip: the band raised from the rim's own faces (its walls continue the walls
// below, which a join can't do: they would coincide).
if !*groove
&& let Ok(b) = kernel::raise_band(&mb.body, *face, w, h, *outside)
&& let Some(slot) = st.bodies.iter_mut().find(|x| x.name == target)
{
*slot = ModelBody::new(target.clone(), b, slot.feature);
return Ok(());
}
// The band along the chosen edge, inside the rim face. A groove's cutter reaches past
// the edge into the free side (the cavity, or outside), so no wall coincides.
let free = if *groove { bw } else { 0.0 };
// The band along the chosen edge, inside the rim face.
let band = if *outside {
Region2 {
outer: Loop2::polygon(&offset_left(&outer, -free)).ccw(),
holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()],
}
Region2 { outer: Loop2::polygon(&outer).ccw(), holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()] }
} else {
Region2 {
outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(),
holes: vec![Loop2::polygon(&offset_left(&inner, free)).ccw().reversed()],
}
Region2 { outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(), holes: vec![Loop2::polygon(&inner).ccw().reversed()] }
};
let delta = (h * 0.05).clamp(1e-3, 0.5);
if *groove {
@@ -894,31 +843,3 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
_ => Err(DocError::Invalid(format!("{} is not a plastic feature", f.name))),
}
}
/// Edge picks of a sheet feature, moved onto their named edges (a name that is gone keeps the
/// point, with a warning).
fn sheet_picks(sh: &crate::sheet::SheetBody, f: &Feature, edges: &[Vec3], warning: &mut Option<String>) -> Vec<Vec3> {
let mut gone = Vec::new();
let out = edges
.iter()
.enumerate()
.map(|(i, p)| match f.edge_names.get(i).filter(|n| !n.is_empty()) {
Some(n) => sh.edge_by_name(n, *p).unwrap_or_else(|| {
gone.push(n.clone());
*p
}),
None => *p,
})
.collect();
if !gone.is_empty() {
*warning = Some(format!("sheet edge(s) {} no longer exist; using the edges at the picked points", gone.join(", ")));
}
out
}
/// Tag the flanges a feature just added (`F<feature>.<k>`).
fn tag_flanges(sh: &mut crate::sheet::SheetBody, from: usize, feature: u64) {
for (k, fl) in sh.flanges.iter_mut().enumerate().skip(from) {
fl.tag = format!("F{feature}.{}", k - from);
}
}
-89
View File
@@ -1,89 +0,0 @@
//! Evaluation of the surface features: Patch, Stitch, Thicken and Trim (kernel `surfaces`).
use super::*;
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
match &f.kind {
FeatureKind::Patch { sketch, profiles, body, edges } => {
let mut made = Vec::new();
if let Some(sk) = sketch {
let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
let regions = solvecraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
for r in &regions {
made.push(kernel::patch_region(&ss.plane, r)?);
}
}
if !edges.is_empty() {
let i = body_at(st, body, edges)?;
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("body".into()))?;
made.push(kernel::patch_edges(&b.body, edges)?);
}
if made.is_empty() {
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);
st.bodies.push(ModelBody::new(name, b, f.id));
}
Ok(())
}
FeatureKind::Stitch { bodies, tolerance } => {
let tol = val(vals, tolerance, Kind::Length)?;
let idx: Vec<usize> = bodies
.iter()
.map(|n| st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`"))))
.collect::<Result<_>>()?;
let Some(&first) = idx.first() else { return Err(DocError::Invalid("pick the surfaces to stitch".into())) };
let refs: Vec<&kernel::Body> = idx.iter().filter_map(|i| st.bodies.get(*i).map(|b| &b.body)).collect();
let sewn = kernel::stitch(&refs, tol)?;
let Some(mb) = st.bodies.get(first).cloned() else { return Err(DocError::Invalid("body".into())) };
if let Some(slot) = st.bodies.get_mut(first) {
*slot = ModelBody::new(mb.name, sewn, mb.feature);
}
// The others are now part of the first.
let gone: Vec<String> = idx.iter().skip(1).filter_map(|i| st.bodies.get(*i).map(|b| b.name.clone())).collect();
st.bodies.retain(|b| !gone.contains(&b.name));
Ok(())
}
FeatureKind::Thicken { bodies, thickness, symmetric, operation, targets } => {
let t = val(vals, thickness, Kind::Length)?;
let mut tools = Vec::new();
for n in bodies {
let b = st.body(n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
tools.push(kernel::thicken(&b.body, t, *symmetric)?);
}
if tools.is_empty() {
return Err(DocError::Invalid("pick the surfaces to thicken".into()));
}
apply_op(st, f, tools, *operation, targets)
}
FeatureKind::SurfaceTrim { body, plane, tool, keep } => {
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let trimmed = match tool {
Some(t) => {
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
let face = crate::appearance::face_index_at(tb, t.point)
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?;
kernel::trim(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face }, *keep)?
}
None => kernel::trim(&mb.body, &kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?), *keep)?,
};
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, trimmed, mb.feature);
}
Ok(())
}
FeatureKind::SurfaceExtend { body, edges, distance } => {
let d = val(vals, distance, Kind::Length)?;
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let out = kernel::extend(&mb.body, edges, d)?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, out, mb.feature);
}
Ok(())
}
_ => Err(DocError::Invalid("not a surface feature".into())),
}
}
+2 -75
View File
@@ -13,20 +13,15 @@
//! - 3: appearances per body, face and component with names and opacity
//! (`"appearances": {"bodies": {name: {name, color, opacity}}, "faces": […], "components": {…}}`);
//! before, `"appearances"` mapped body names to bare `[r, g, b]` colours.
//! - 5: joins, cuts and intersects change only their own component's bodies unless bodies are
//! named as `participants`; older features in designs with components get `participants:
//! ["*"]` (every body), which is what they did, so those designs keep their shape.
//! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become
//! explicit planes retaining their previous axes, including offset-plane expressions.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 6;
pub const FORMAT: u32 = 4;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -39,49 +34,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 5] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6];
/// Old XZ references retain their exact frame, including nested offset and angled planes.
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
/// dimensions, profiles, extrude directions or parameter expressions in saved designs.
fn v5_to_v6(v: &mut Value) -> Result<()> {
fn preserve(v: &mut Value) {
match v {
Value::Object(o) => {
let old_xz = o.get("type").and_then(Value::as_str) == Some("origin")
&& o.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case("XZ") || n.eq_ignore_ascii_case("FRONT"));
if old_xz {
*v = serde_json::json!({"type": "custom", "plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}});
} else {
o.values_mut().for_each(preserve);
}
}
Value::Array(a) => a.iter_mut().for_each(preserve),
_ => {}
}
}
preserve(v);
Ok(())
}
/// Features in designs with components keep reaching every body.
fn v4_to_v5(v: &mut Value) -> Result<()> {
let has_components = v.get("components").and_then(Value::as_array).is_some_and(|c| !c.is_empty());
if !has_components {
return Ok(());
}
if let Some(Value::Array(fs)) = v.get_mut("features") {
for f in fs.iter_mut() {
if let Some(o) = f.as_object_mut()
&& o.get("type").and_then(Value::as_str) != Some("sketch")
&& o.get("participants").is_none()
{
o.insert("participants".into(), serde_json::json!(["*"]));
}
}
}
Ok(())
}
const JSON_STEPS: [JsonStep; 3] = [|_| Ok(()), v2_to_v3, v3_to_v4];
/// Joints placed with the old convention (aligned unless flipped) keep their placement.
fn v3_to_v4(v: &mut Value) -> Result<()> {
@@ -223,32 +176,6 @@ mod tests {
assert_eq!(back, d);
}
#[test]
fn old_xz_and_nested_offsets_keep_their_frame() {
let mut doc = Document::new("old XZ");
let id = doc
.add_feature(
crate::FeatureKind::ConstructionPlane {
plane: crate::PlaneRef::Offset { base: Box::new(crate::PlaneRef::Origin { name: "XZ".into() }), distance: "7 mm".into() },
},
None,
)
.unwrap();
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
json["format"] = Value::from("solvecraft/5");
let migrated = read(&json.to_string()).unwrap();
let crate::FeatureKind::ConstructionPlane { plane } = &migrated.feature(id).unwrap().kind else { panic!("plane") };
let (values, errors) = migrated.param_values();
assert!(errors.is_empty());
let p = migrated.resolve_plane(&values, plane, 0).unwrap();
assert_eq!(p.y, solvecraft_geom::Vec3::Z);
assert_eq!(p.normal(), -solvecraft_geom::Vec3::Y);
assert_eq!(p.origin, solvecraft_geom::Vec3::new(0.0, -7.0, 0.0));
assert_eq!(read(&migrated.to_json()).unwrap(), migrated);
let fresh = doc.resolve_plane(&values, &crate::PlaneRef::Origin { name: "XZ".into() }, 0).unwrap();
assert_eq!(fresh.normal(), solvecraft_geom::Vec3::Y);
}
#[test]
fn older_upgraded_newer_refused_others_rejected() {
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
+1 -2
View File
@@ -10,7 +10,6 @@ mod assembly;
pub mod canvas;
mod clipboard;
pub mod config;
pub mod construct;
mod document;
mod eval;
pub mod expr;
@@ -29,7 +28,7 @@ pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_iden
pub use document::*;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices,
state_in_frame, world_state,
world_state,
};
pub use params::{ParamRow, ParamUser, cycles};
pub use solvecraft_kernel as kernel;
+1 -88
View File
@@ -34,12 +34,6 @@ impl NameCell {
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
NameCell { origin, names: Arc::new(OnceLock::new()) }
}
/// Names already known (a moved copy of a body keeps its faces' names).
pub fn known(names: Arc<Vec<String>>) -> Self {
let cell = OnceLock::new();
let _ = cell.set(names);
NameCell { origin: None, names: Arc::new(cell) }
}
}
/// Deeper chains than this are named by position only (bounded work and stack).
@@ -232,14 +226,7 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
.enumerate()
.map(|(fi, n)| {
n.unwrap_or_else(|| {
let (ps, nn) = (pts.get(fi).map(Vec::as_slice).unwrap_or(&[]), ns.get(fi).unwrap_or(&empty));
// A shell's inner face: the face its wall runs back to.
if matches!(o.feature.kind, FeatureKind::Shell { .. })
&& let Some(n) = behind(ps, nn, &inputs)
{
return format!("F{fid}:inner:{}", strip_piece(&n));
}
role_name(&o.feature, &o.before, ps, nn, fi, fid)
role_name(&o.feature, &o.before, pts.get(fi).map(Vec::as_slice).unwrap_or(&[]), ns.get(fi).unwrap_or(&empty), fi, fid)
})
})
.collect();
@@ -247,59 +234,6 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
names
}
/// The input face a face looks back at through the material (along minus its normal): the
/// face a shell wall's inner side offsets.
fn behind(ps: &[Vec3], ns: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<String>>)]) -> Option<String> {
let n = ns.first().copied()?;
let d = n * -1.0;
let mut votes: Vec<(String, usize)> = Vec::new();
for p in ps {
let mut best: Option<(f64, String)> = None;
for (_, idx, nm) in inputs {
for (fi, (_, _, tris)) in idx.faces.iter().enumerate() {
for [a, b, c] in tris {
if let Some(t) = ray_hit(*p, d, *a, *b, *c)
&& t > 1e-9
&& best.as_ref().is_none_or(|(bt, _)| t < *bt)
&& let Some(name) = nm.get(fi)
{
best = Some((t, name.clone()));
}
}
}
}
if let Some((_, n)) = best {
match votes.iter_mut().find(|(v, _)| *v == n) {
Some(v) => v.1 += 1,
None => votes.push((n, 1)),
}
}
}
votes.sort_by_key(|v| std::cmp::Reverse(v.1));
votes.first().filter(|(_, k)| *k * 2 >= ps.len().max(1)).map(|(n, _)| n.clone())
}
fn ray_hit(o: Vec3, d: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Option<f64> {
let (e1, e2) = (b - a, c - a);
let h = d.cross(e2);
let det = e1.dot(h);
if det.abs() < 1e-14 {
return None;
}
let f = 1.0 / det;
let s = o - a;
let u = f * s.dot(h);
if !(0.0..=1.0).contains(&u) {
return None;
}
let q = s.cross(e1);
let v = f * d.dot(q);
if v < 0.0 || u + v > 1.0 {
return None;
}
Some(f * e2.dot(q))
}
/// The input face most of a face's sample points lie on (mapped by `map` first), if it holds
/// at least half of them.
fn vote(ps: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<String>>)], tol: f64, map: Option<&crate::Mat>) -> Option<String> {
@@ -448,27 +382,6 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
.min_by(|a, b| a.0.total_cmp(&b.0));
best.map(|(_, id)| format!("F{fid}:side:{id}")).unwrap_or_else(|| format!("F{fid}:face:{fi}"))
}
FeatureKind::Sweep { .. } | FeatureKind::Loft { .. } => {
let sk = match &f.kind {
FeatureKind::Sweep { sketch, .. } => Some(*sketch),
FeatureKind::Loft { sections, .. } => sections.first().map(|x| x.sketch),
_ => None,
};
let Some(ss) = sk.and_then(|k| before.sketch(k)) else { return format!("F{fid}:face:{fi}") };
let n = ss.plane.normal();
let h = |p: &Vec3| (*p - ss.plane.origin).dot(n);
let flat = !ns.is_empty() && ns.windows(2).all(|w| w[0].cross(w[1]).len() < 1e-6);
if flat && ps.iter().all(|p| h(p).abs() < 1e-6 * (1.0 + p.len())) {
return format!("F{fid}:start");
}
if flat && ns.iter().all(|m| m.cross(n).len() < 1e-6) {
return format!("F{fid}:end");
}
// A side: the profile curve under its sample nearest the start.
let Some(p0) = ps.iter().min_by(|a, b| h(a).abs().total_cmp(&h(b).abs())) else { return format!("F{fid}:face:{fi}") };
let q = ss.plane.to_local(*p0);
nearest_curve(ss, Vec2::new(q.x, q.y)).map(|id| format!("F{fid}:side:{id}")).unwrap_or_else(|| format!("F{fid}:face:{fi}"))
}
FeatureKind::Box { .. } => {
let n = ns.first().copied().unwrap_or(Vec3::Z);
let axis = |v: f64, p: &str, m: &str| {
+4 -83
View File
@@ -21,7 +21,6 @@ fn plane_inputs_mut<'a>(p: &'a mut PlaneRef, v: &mut Vec<(&'static str, &'a mut
v.push(("Angle", angle, Kind::Angle));
plane_inputs_mut(base, v);
}
PlaneRef::AlongPath { t, .. } => v.push(("Distance (0 to 1)", t, Kind::Unitless)),
_ => {}
}
}
@@ -35,25 +34,7 @@ impl FeatureKind {
FeatureKind::Sketch { plane, .. }
| FeatureKind::ConstructionPlane { plane }
| FeatureKind::Split { plane, .. }
| FeatureKind::SplitFace { plane, .. }
| FeatureKind::Mirror { plane, .. } => plane_inputs_mut(plane, &mut v),
FeatureKind::ConstructionAxis { def } => {
for r in def.refs_mut() {
if let crate::construct::GeoRef::Plane { plane } = r {
plane_inputs_mut(plane, &mut v);
}
}
}
FeatureKind::ConstructionPoint { def } => match def {
crate::construct::PointDef::AlongPath { t, .. } => v.push(("Distance (0 to 1)", t, U)),
other => {
for r in other.refs_mut() {
if let crate::construct::GeoRef::Plane { plane } = r {
plane_inputs_mut(plane, &mut v);
}
}
}
},
FeatureKind::Extrude { extent, .. } => {
v.push(("Distance", &mut extent.distance, L));
if let Some(d) = &mut extent.distance2 {
@@ -66,21 +47,8 @@ impl FeatureKind {
v.push(("Taper angle", d, A));
}
}
FeatureKind::Revolve { angle, angle2, .. } => {
v.push(("Angle", angle, A));
if let Some(a) = angle2 {
v.push(("Angle 2", a, A));
}
}
FeatureKind::Fillet { radius, style, .. } => match style {
crate::FilletStyle::Constant => v.push(("Radius", radius, L)),
crate::FilletStyle::Chord => v.push(("Chord length", radius, L)),
crate::FilletStyle::Variable { radius2, mid, .. } => {
v.push(("Start radius", radius, L));
v.push(("End radius", radius2, L));
v.extend(mid.iter_mut().map(|(_, r)| ("Mid radius", r, L)));
}
},
FeatureKind::Revolve { angle, .. } => v.push(("Angle", angle, A)),
FeatureKind::Fillet { radius, .. } => v.push(("Radius", radius, L)),
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
v.push(("Distance", distance, L));
if let Some(d) = distance2 {
@@ -255,19 +223,12 @@ impl FeatureKind {
}
}
FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v),
FeatureKind::Stitch { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
FeatureKind::Thicken { thickness, .. } => v.push(("Thickness", thickness, L)),
FeatureKind::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v),
FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)),
FeatureKind::Patch { .. } => {}
FeatureKind::Combine { .. }
| FeatureKind::SheetBase { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::SheetConvert { .. }
| FeatureKind::Align { .. }
| FeatureKind::Remove { .. }
| FeatureKind::DeleteFace { .. }
| FeatureKind::BoundaryFill { .. }
| FeatureKind::Loft { .. }
| FeatureKind::Sweep { .. }
| FeatureKind::Import { .. }
@@ -373,35 +334,6 @@ impl Document {
}
/// Change a parameter: a stored one, or a feature input by its name.
/// A typed length that is a bare number means the design's units: it gets that unit written
/// in (feature inputs are stored in mm otherwise). Anything else is returned unchanged.
pub fn with_design_unit(&self, e: &str, kind: Kind) -> String {
let u = self.units.trim();
let bare = expr::is_literal(e) && e.trim().trim_start_matches(['-', '+']).trim().parse::<f64>().is_ok();
if kind == Kind::Length && bare && !u.is_empty() && u != "mm" && expr::unit_info(u).is_some_and(|(k, _)| k == Kind::Length) {
format!("{} {u}", e.trim())
} else {
e.to_string()
}
}
/// Every length input of every feature as the geometry reads it (mm; a unit-less result is
/// in the design's units), for checking that an edit moves nothing.
pub fn length_inputs(&self) -> Vec<Option<f64>> {
let (vals, _) = self.param_values();
let scale = expr::unit_info(self.units.trim()).filter(|(k, _)| *k == Kind::Length).map(|(_, s)| s).unwrap_or(1.0);
let look = |n: &str| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")));
self.features
.iter()
.flat_map(|f| f.kind.inputs())
.filter(|(_, _, k)| *k == Kind::Length)
.map(|(_, e, _)| {
let d = if expr::is_literal(&e) { expr::Defaults::default() } else { expr::Defaults { len: scale, ..expr::Defaults::default() } };
expr::eval_with(&e, &look).and_then(|v| v.to_kind_in(Kind::Length, d)).ok()
})
.collect()
}
pub fn change_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> {
let hit = self.features.iter().enumerate().find_map(|(i, f)| f.param_names.iter().position(|n| n == name).map(|k| (i, k)));
let Some((i, k)) = hit else { return self.set_param(name, expr_s, unit, comment) };
@@ -409,12 +341,10 @@ impl Document {
return Err(DocError::Expr("expression too long".into()));
}
let mut next = self.clone();
let kind = self.features.get(i).and_then(|f| f.kind.inputs().get(k).map(|x| x.2)).unwrap_or(Kind::Unitless);
let expr_s = self.with_design_unit(expr_s, kind);
if let Some(f) = next.features.get_mut(i)
&& let Some((_, e, _)) = f.kind.inputs_mut().into_iter().nth(k)
{
*e = expr_s.clone();
*e = expr_s.to_string();
}
if let Some(c) = comment {
next.param_comments.insert(name.to_string(), c.to_string());
@@ -593,16 +523,7 @@ impl Document {
for f in &self.features {
for (name, (_, e, k)) in f.param_names.iter().zip(f.kind.inputs()) {
if !name.is_empty() {
// A bare number stored in an input is mm (the design's units are written in
// when it is typed); anything else that comes out unit-less (a unit-less
// parameter used as a length) is in the design's units.
let unit = match k {
Kind::Length if !expr::is_literal(&e) && expr::unit_info(self.units.trim()).is_some_and(|(k, _)| k == Kind::Length) => {
self.units.trim().to_string()
}
_ => unit_of(k).to_string(),
};
v.push(ParamDef { name: name.clone(), expr: e, unit });
v.push(ParamDef { name: name.clone(), expr: e, unit: unit_of(k).into() });
}
}
}
+3 -16
View File
@@ -257,7 +257,7 @@ fn resolve_uncached(
LinkSource::Plane { name } => {
let pr =
if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } };
let other = doc.resolve_plane_in(vals, &pr, 0, Some(st)).map_err(|_| lost(&format!("plane {name}")))?;
let other = doc.resolve_plane(vals, &pr, 0).map_err(|_| lost(&format!("plane {name}")))?;
plane_trace(plane, &other).into_iter().collect()
}
};
@@ -273,20 +273,7 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelS
let mut warn = Vec::new();
let links = sk.links.clone();
for l in &links {
let mut source = l.source.clone();
if let (Some(name), LinkSource::Face { at, .. }) = (&l.face_name, &mut source) {
if let Some((p, split)) = crate::naming::point_on_face(st, name, *at) {
*at = p;
if split {
warn.push(format!("projected face `{name}` was split; using the nearest piece"));
}
} else {
sk.set_link_lost(&l.id, true);
warn.push(format!("projected face `{name}` no longer exists"));
continue;
}
}
match resolve(doc, vals, st, plane, l.kind, &source) {
match resolve(doc, vals, st, plane, l.kind, &l.source) {
Ok(r) => {
sk.set_link_source(&l.id, r.source);
let _ = sk.set_link_wires(&l.id, &r.wires);
@@ -485,7 +472,7 @@ fn tilted_circle(plane: &Plane, pts: &[Vec3]) -> Option<Vec<LinkGeom>> {
let (c, n, r, closed) = circle3(pts)?;
let sn = plane.normal();
let cosang = n.dot(sn).abs();
if !(1e-9..=1.0 - 1e-12).contains(&cosang) {
if cosang > 1.0 - 1e-12 || cosang < 1e-9 {
return None;
}
if closed {
+3 -93
View File
@@ -160,9 +160,6 @@ pub struct SheetFlange {
/// instead of a rectangle `leg` long.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shape: Option<Vec<Vec2>>,
/// Which feature made it: `F<feature>.<k>` (persistent edge names).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub tag: String,
}
/// A sheet metal body (see the module docs).
@@ -182,9 +179,6 @@ pub struct SheetBody {
pub flat: bool,
/// Gap between flanges that meet at a corner.
pub gap: f64,
/// The base sketch's lines (id, ends in flat coordinates), naming the base panel's edges.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub base_curves: Vec<(String, Vec2, Vec2)>,
}
/// A piece of the sheet: a panel (None = the base) or a flange's bend zone.
@@ -845,7 +839,6 @@ impl SheetBody {
radius,
leg: leg.max(0.0),
shape: None,
tag: String::new(),
ext: (0.0, 0.0),
});
made.push(self.flanges.len() - 1);
@@ -899,7 +892,7 @@ impl SheetBody {
return Err(DocError::Invalid("the hem is shorter than its bend".into()));
}
// A hem folds back over the face it starts from (toward the bottom unless flipped).
let up = top != flip;
let up = !top == flip;
let up = !up;
self.flanges.push(SheetFlange {
parent: panel,
@@ -912,7 +905,6 @@ impl SheetBody {
leg,
ext: (0.0, 0.0),
shape: None,
tag: String::new(),
});
Ok(self.flanges.len() - 1)
}
@@ -953,8 +945,7 @@ impl SheetBody {
let mut n = Vec2::new(-d.y, d.x);
let l0 = a2.dot(n);
// Which side moves. A flange keeps the side its own bend is on.
let on_line = n * l0;
let side_area = |m: Vec2| signed_area(&clip(&pts, on_line, m)).abs();
let side_area = |n: Vec2| signed_area(&clip(&pts, n * l0, n)).abs();
let moves_n = match (panel, fixed) {
(Some(j), _) => {
let f = self.flanges.get(j).ok_or_else(|| DocError::Invalid("flange".into()))?;
@@ -977,7 +968,7 @@ impl SheetBody {
n = n * -1.0;
}
let l = a2.dot(n);
let probe = SheetFlange { parent: panel, p: a2, d, len: 1.0, n, angle, radius, leg: 0.0, ext: (0.0, 0.0), shape: None, tag: String::new() };
let probe = SheetFlange { parent: panel, p: a2, d, len: 1.0, n, angle, radius, leg: 0.0, ext: (0.0, 0.0), shape: None };
let ba = self.allowance(&probe);
let s0 = match position {
"centerline" | "center" | "centre" => l - ba / 2.0,
@@ -1027,7 +1018,6 @@ impl SheetBody {
leg,
ext: (0.0, 0.0),
shape: Some(ccw(moving)),
tag: String::new(),
});
let i = self.flanges.len() - 1;
// Flanges on the moving part's edges now hang off the fold and move with it.
@@ -1050,69 +1040,6 @@ impl SheetBody {
Ok(i)
}
/// Name a panel edge (flat coordinates a→b on `panel`, top or bottom face): base edges by the
/// base sketch line they lie on, flange edges by the flange (its tag) and side.
fn edge_name_of(&self, panel: Option<usize>, a: Vec2, b: Vec2, top: bool) -> Option<String> {
let face = if top { "top" } else { "bottom" };
let d = (b - a).normalized()?;
match panel {
None => {
let on = |p: Vec2, s: Vec2, e: Vec2| {
let t = e - s;
t.len() > 1e-9 && ((p - s).cross(t) / t.len()).abs() < 1e-6
};
self.base_curves.iter().find(|(_, s, e)| on(a, *s, *e) && on(b, *s, *e)).map(|(id, _, _)| format!("base:{id}:{face}"))
}
Some(i) => {
let f = self.flanges.get(i)?;
if f.tag.is_empty() {
return None;
}
let side = if d.cross(f.d).abs() < 1e-6 {
// Along the bend: the far edge (or the bend side, never picked).
if ((a - f.p).dot(f.n)) > self.allowance(f) + 1e-6 { "far" } else { "bend" }
} else if ((a + b) * 0.5 - f.p).dot(f.d) < f.len / 2.0 {
"side0"
} else {
"side1"
};
Some(format!("{}:{side}:{face}", f.tag))
}
}
}
/// The persistent name of the sheet edge nearest a world point.
pub fn edge_name(&self, pick: Vec3) -> Option<String> {
let (panel, a, b, top, _) = self.edge_at(pick)?;
self.edge_name_of(panel, a, b, top)
}
/// A world point on the sheet edge with this name (the one nearest `near` if several).
pub fn edge_by_name(&self, name: &str, near: Vec3) -> Option<Vec3> {
let mut best: Option<(f64, Vec3)> = None;
for (pi, r) in self.panels() {
let pts = r.outer.ccw().polyline(1e-3);
let m = pts.len();
let w = self.world(pi);
for i in 0..m {
let (Some(&a), Some(&b)) = (pts.get(i), pts.get((i + 1) % m)) else { continue };
if a.dist(b) < 1e-9 {
continue;
}
for (z, top) in [(0.0, false), (self.t, true)] {
if self.edge_name_of(pi, a, b, top).as_deref() == Some(name) {
let mid = (a + b) * 0.5;
let q = apply_point(&w, Vec3::new(mid.x, mid.y, z));
if best.is_none_or(|(d, _)| q.dist(near) < d) {
best = Some((q.dist(near), q));
}
}
}
}
}
best.map(|(_, q)| q)
}
/// Map a region in a world plane to flat coordinates through a panel's placement.
pub fn region_to_flat(&self, panel: Option<usize>, plane: &Plane, r: &Region2) -> Option<Region2> {
let inv = mat_inverse(&self.world(panel))?;
@@ -1312,7 +1239,6 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
holes: Vec::new(),
flat: false,
gap: rv.gap,
base_curves: Vec::new(),
}
}
@@ -1363,7 +1289,6 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
holes: Vec::new(),
flat: false,
gap: rv.gap,
base_curves: Vec::new(),
};
let (y0, y1) = if ysign > 0.0 { (0.0, width) } else { (-width, 0.0) };
// First segment: the base panel.
@@ -1395,7 +1320,6 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
leg: leg.max(0.0),
ext: (0.0, 0.0),
shape: None,
tag: String::new(),
};
let ba = sheet.allowance(&f);
sheet.flanges.push(f);
@@ -1405,20 +1329,6 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
Ok(sheet)
}
/// Persistent names of the sheet edges nearest each world point (empty when none).
pub fn sheet_edge_names(st: &crate::ModelState, pts: &[Vec3]) -> Vec<String> {
pts.iter()
.map(|p| {
st.sheets
.iter()
.filter_map(|sh| sh.edge_at(*p).map(|e| (e.4, sh)))
.min_by(|a, b| a.0.total_cmp(&b.0))
.and_then(|(_, sh)| sh.edge_name(*p))
.unwrap_or_default()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
+2 -83
View File
@@ -38,9 +38,6 @@ fn plate_doc() -> (Document, u64) {
start_offset: None,
through_all: false,
taper: None,
to: None,
to_offset: None,
from: None,
},
operation: Operation::NewBody,
targets: vec![],
@@ -81,31 +78,10 @@ fn parametric_plate_reevaluates() {
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
}
/// Setting a parameter back takes the earlier results from the cache: nothing is recomputed, but
/// the evaluation reports the features it restored (their geometry did change back).
#[test]
fn set_back_reports_restored_features() {
let (mut doc, _) = plate_doc();
let mut m = Model::new();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
doc.set_param("width", "50 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
doc.set_param("width", "40 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (0, 2));
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 24000.0) < 1e-9);
// No change: nothing recomputed and nothing restored.
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (0, 0));
}
#[test]
fn box_fillet_cut_timeline() {
let (mut doc, _) = plate_doc();
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 10.0)], radius: "3".into(), body: None, style: Default::default() }, None)
.unwrap();
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 10.0)], radius: "3".into(), body: None }, None).unwrap();
let mut sk = Sketch::new();
sk.add_circle(Vec2::new(20.0, 15.0), 5.0, None, None).unwrap();
let s2 = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
@@ -120,9 +96,6 @@ fn box_fillet_cut_timeline() {
start_offset: None,
through_all: false,
taper: None,
to: None,
to_offset: None,
from: None,
},
operation: Operation::Cut,
targets: vec![],
@@ -155,15 +128,11 @@ fn errors_are_reported_not_fatal() {
angle: "90 deg".into(),
operation: Operation::NewBody,
targets: vec![],
angle2: None,
symmetric: false,
to: None,
},
None,
)
.unwrap();
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None, style: Default::default() }, None)
.unwrap();
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None }, None).unwrap();
doc.add_feature(
FeatureKind::Box { corner: Vec3::ZERO, length: "nope".into(), width: "1".into(), height: "1".into(), operation: Operation::NewBody },
None,
@@ -295,53 +264,3 @@ fn parameter_cycles_are_found() {
assert!(errs["a"].contains("circular reference: a → b → c → a"), "{}", errs["a"]);
assert!(vals.contains_key("d") && errs.contains_key("a") && errs.contains_key("e"));
}
/// Features that use a sketch's points or regions go with it (not only profile and path users).
#[test]
fn input_sketches_cover_points_and_patches() {
let hole: FeatureKind = serde_json::from_value(serde_json::json!({
"type": "hole", "position": [0, 0, 0], "direction": [0, 0, -1], "diameter": "3",
"points": {"sketch": 7, "ids": ["p1"]},
}))
.unwrap();
assert_eq!(hole.input_sketches(), vec![7]);
let patch: FeatureKind = serde_json::from_value(serde_json::json!({"type": "patch", "sketch": 9})).unwrap();
assert_eq!(patch.input_sketches(), vec![9]);
let pipe: FeatureKind = serde_json::from_value(serde_json::json!({"type": "pipe", "path_sketch": 72, "path": ["l1"], "diameter": "4"})).unwrap();
assert_eq!(pipe.input_sketches(), vec![72]);
}
/// Guard for `FeatureKind::input_sketches`: a variant with a field that refers to a sketch
/// (a field named `*sketch*` holding an id, sketch points or loft sections) must not sit in the
/// arm that returns no sketches. New variants are caught by the compiler (the match has no
/// wildcard); this catches a sketch field added to an existing variant.
#[test]
fn input_sketches_lists_every_variant_with_a_sketch_field() {
let src = include_str!("document.rs");
let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap();
let mut with_sketch = Vec::new();
let mut variant = "";
for line in body.lines() {
let t = line.trim();
if line.starts_with(" ") && !line.starts_with(" ") && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or("");
continue;
}
let Some((name, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue };
let refers = (name.contains("sketch") && (ty.contains("u64") || ty.contains("SketchPoints")))
|| ty.contains("SketchPoints")
|| ty.contains("LoftSection");
if refers && !line.trim_start().starts_with("//") && !with_sketch.contains(&variant) {
with_sketch.push(variant);
}
}
assert!(with_sketch.len() >= 10, "the FeatureKind parse found too few sketch users: {with_sketch:?}");
let f = src.split("pub fn input_sketches(&self)").nth(1).unwrap();
let empty_arm = f.split("=> Vec::new()").next().and_then(|s| s.rsplit("=> ").next()).unwrap();
for v in with_sketch {
assert!(
!empty_arm.contains(&format!("K::{v} {{")),
"FeatureKind::{v} has a sketch field but input_sketches says it uses no sketch: deleting the sketch would leave it broken"
);
}
}
@@ -1,66 +0,0 @@
//! Booleans that failed, panicked or hung, replayed from the scripts in
//! `examples/regressions/` (each script's description says what went wrong).
use serde_json::{Value, json};
use crate::Session;
fn replay(script: &str) -> Session {
let mut s = Session::default();
let script: Value = serde_json::from_str(script).unwrap();
s.run_script(&script).unwrap();
s
}
fn volume(s: &Session, body: &str) -> f64 {
s.model.state().body(body).map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0)
}
/// Slab less a 557.68 × 100 × 120 pocket and the loft below its floor (556.68 × 99 down 4 mm to
/// 80 × 80; prismatoid: h/6 · (A_top + A_bottom + 4 A_mid)).
fn slab_pocket_loft() -> (f64, f64) {
let slab = 900.0 * 480.0 * 160.0;
let loft = 4.0 / 6.0 * (556.68 * 99.0 + 80.0 * 80.0 + 4.0 * (556.68 + 80.0) / 2.0 * (99.0 + 80.0) / 2.0);
(slab - 557.68 * 100.0 * 120.0, loft)
}
#[test]
fn loft_cut_beside_a_round_hole() {
let s = replay(include_str!("../../../examples/regressions/loft_cut_beside_round_hole.json"));
let (pocketed, loft) = slab_pocket_loft();
let hole = std::f64::consts::PI * 17.5 * 17.5 * 25.0;
let want = pocketed - loft - hole;
let got = volume(&s, "Body1");
assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
}
#[test]
fn shallow_loft_cut_from_the_top_face() {
let s = replay(include_str!("../../../examples/regressions/shallow_loft_cut_from_top_face.json"));
let (_, loft) = slab_pocket_loft();
let want = 900.0 * 480.0 * 160.0 - loft;
let got = volume(&s, "Body1");
assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}");
}
#[test]
fn oblique_cut_through_a_small_arc_matches_a_split() {
let scripts = [
include_str!("../../../examples/regressions/oblique_cut_of_cylinder.json"),
include_str!("../../../examples/regressions/oblique_cut_of_cylinder_hang.json"),
];
// The same bar split by the prism's tilted top: the piece above it is the cut's result.
let mut split = Session::default();
let script: Value = serde_json::from_str(scripts[0]).unwrap();
let steps = script.get("commands").and_then(Value::as_array).unwrap();
split.run_script(&Value::Array(steps[..5].to_vec())).unwrap();
let whole = volume(&split, "Tool");
split.execute("solid.split_body", &json!({"body": "Tool", "plane": {"origin": [0, -472, 853.91], "normal": [0, 15.44, 40]}})).unwrap();
let above = volume(&split, "Tool");
assert!(above < whole - 10.0, "{above} of {whole}");
for script in scripts {
let s = replay(script);
let got = volume(&s, "Tool");
assert!((got - above).abs() < 1e-6 * above, "{got} vs {above}");
}
}
+33 -195
View File
@@ -9,7 +9,7 @@ use serde_json::{Value, json};
use solvecraft_doc::FeatureKind;
use super::CommandSpec;
use crate::params::{bad, bool_, str_};
use crate::params::{bad, str_};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
@@ -34,10 +34,6 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
CommandSpec::new("view.list", "List Named Views", view_list).noundo(),
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
or folder?: bodies|sketches|construction|origin (all of it). View state: not saved, not an undo step",
),
];
const FOLDERS: [&str; 3] = ["bodies", "sketches", "construction"];
@@ -159,83 +155,6 @@ fn order(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({}))
}
const ORIGIN: [&str; 7] = ["O", "X", "Y", "Z", "XY", "XZ", "YZ"];
/// A browser item whose visibility can change.
enum Shown {
Sketch(u64),
Body(String),
/// Origin items and construction geometry, by name.
Origin(String),
}
fn is_construction(k: &FeatureKind) -> bool {
matches!(k, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. })
}
fn shown_item(s: &Session, key: &str) -> Option<Shown> {
if let Some(f) = s.doc.find_feature(key) {
if matches!(f.kind, FeatureKind::Sketch { .. }) {
return Some(Shown::Sketch(f.id));
}
if is_construction(&f.kind) {
return Some(Shown::Origin(f.name.clone()));
}
}
if s.world_state().bodies.iter().any(|b| b.name == key) {
return Some(Shown::Body(key.to_string()));
}
ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
}
fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "browser.visibility";
let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?;
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
(Some(_), Some(_)) => return Err(bad(cmd, "give `items` or `folder`, not both")),
(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) => ORIGIN.iter().map(|o| Shown::Origin(o.to_string())).collect(),
(Some(f), None) => return Err(bad(cmd, format!("unknown folder `{f}` (bodies, sketches, construction, origin)"))),
(None, Some(_)) => {
let keys = keys(p, "items", cmd)?;
if keys.is_empty() {
return Err(bad(cmd, "`items` must list sketches, bodies or construction geometry"));
}
// Every item is found before anything changes.
keys.iter()
.map(|k| shown_item(s, k).ok_or_else(|| bad(cmd, format!("no sketch, body or construction item `{k}`"))))
.collect::<Result<_>>()?
}
(None, None) => return Err(bad(cmd, "`items` or `folder` is required")),
};
let v = &mut s.visibility;
for it in &items {
match it {
Shown::Sketch(id) => {
v.hidden_sketches.retain(|x| x != id);
v.shown_sketches.retain(|x| x != id);
if visible { v.shown_sketches.push(*id) } else { v.hidden_sketches.push(*id) }
}
Shown::Body(n) => {
v.hidden_bodies.retain(|x| x != n);
if !visible {
v.hidden_bodies.push(n.clone());
}
}
Shown::Origin(n) => {
v.hidden_origin.retain(|x| x != n);
if !visible {
v.hidden_origin.push(n.clone());
}
}
}
}
s.revision += 1;
Ok(json!({ "visible": visible, "changed": items.len(), "visibility": s.visibility }))
}
fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "document.units";
let u = str_(p, "units").ok_or_else(|| bad(cmd, "`units` is required"))?;
@@ -246,17 +165,14 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
if old == u {
return Ok(json!({ "units": u, "kept": [] }));
}
// What the geometry reads: every feature's length inputs.
let before = s.doc.length_inputs();
let (before, _) = s.doc.param_values();
s.doc_mut().units = u.to_string();
// Unit-less user parameters holding a bare number read it in the design's units where they
// stand for lengths. If any input moved, those numbers keep the old unit, written out.
let same = |a: &[Option<f64>]| {
a.iter().zip(&before).filter(|(x, y)| matches!((x, y), (Some(x), Some(y)) if (x - y).abs() <= 1e-9 * y.abs().max(1.0))).count()
};
let after = s.doc.length_inputs();
// stand for lengths. If any value moved, those numbers keep the old unit, written out.
let (after, _) = s.doc.param_values();
let moved = before.iter().any(|(k, b)| after.get(k).is_none_or(|a| (a.v - b.v).abs() > 1e-9 * b.v.abs().max(1.0)));
let mut kept = Vec::new();
if same(&after) < before.len() {
if moved {
let bare: Vec<(String, String)> = s
.doc
.params
@@ -265,15 +181,16 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
.map(|p| (p.name.clone(), p.expr.trim().to_string()))
.collect();
for (name, e) in bare {
let now = same(&s.doc.length_inputs());
let probe = {
let mut d = (*s.doc).clone();
let _ = d.change_param(&name, &format!("{e} {old}"), Some(&old), None);
same(&d.length_inputs())
let _ = d.change_param(&name, &format!("{e} {old}"), None, None);
d.param_values().0
};
// Only when writing the old unit puts more inputs back where they were.
if probe > now {
s.doc_mut().change_param(&name, &format!("{e} {old}"), Some(&old), None)?;
// Only when writing the old unit puts the values back where they were.
let fixes = before.iter().filter(|(k, b)| probe.get(*k).is_some_and(|a| (a.v - b.v).abs() <= 1e-9 * b.v.abs().max(1.0))).count()
> before.iter().filter(|(k, b)| after.get(*k).is_some_and(|a| (a.v - b.v).abs() <= 1e-9 * b.v.abs().max(1.0))).count();
if fixes {
s.doc_mut().change_param(&name, &format!("{e} {old}"), None, None)?;
kept.push(name);
}
}
@@ -413,16 +330,10 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
for f in d.appearances.faces.iter_mut().filter(|f| f.body == old) {
f.body = name.to_string();
}
if let Some(o) = d.body_offsets.remove(&old) {
d.body_offsets.insert(name.to_string(), o);
}
if let Some(c) = d.body_components.remove(&old) {
d.body_components.insert(name.to_string(), c);
}
rename_key(d, "bodies", &old, name);
for b in s.visibility.hidden_bodies.iter_mut().filter(|b| **b == old) {
*b = name.to_string();
}
Ok(json!({"body": old, "name": name}))
}
@@ -442,7 +353,7 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
}
let plane = super::sketch::plane_ref(s, p, cmd)?;
let (vals, _) = s.doc.param_values();
s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
s.doc.resolve_plane(&vals, &plane, 0)?;
if let Some(FeatureKind::Sketch { plane: slot, .. }) = s.doc_mut().feature_mut(id).map(|f| &mut f.kind) {
*slot = plane;
}
@@ -469,10 +380,10 @@ mod tests {
#[test]
fn renamed_body_keeps_its_users() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
let [a, b] = [names(&s)[0].clone(), names(&s)[1].clone()];
run(&mut s, "solid.move", json!({"bodies": [b.clone()], "translate": [0, 0, 5]}));
run(&mut s, "FusionMoveCommand", json!({"bodies": [b.clone()], "translate": [0, 0, 5]}));
run(&mut s, "body.rename", json!({"body": b, "name": "Lid"}));
assert_eq!(names(&s), vec![a.clone(), "Lid".to_string()]);
assert!(s.model.results.iter().all(|r| r.error.is_none()), "the move still finds its body");
@@ -480,24 +391,22 @@ mod tests {
assert!(s.execute("body.rename", &json!({"body": "Lid", "name": a})).is_err());
assert!(s.execute("body.rename", &json!({"body": "Nope", "name": "X"})).is_err());
assert!(s.execute("body.rename", &json!({"body": "Lid", "name": " "})).is_err());
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert_eq!(names(&s)[1], b);
}
#[test]
fn redefined_sketch_moves_its_extrude() {
let mut s = Session::default();
let sk = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
let zmax = |s: &mut Session| run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap();
let sk = run(&mut s, "SketchCreate", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
let zmax = |s: &mut Session| run(s, "MeasureCommand", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap();
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
let z = zmax(&mut s);
assert!(z.abs() < 1e-6, "positive sketch Y on XZ extends toward -Z: {z}");
let ymin = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap();
assert!((ymin + 20.0).abs() < 1e-6);
assert!(z > 9.0, "the profile stands up on XZ: {z}");
assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err());
assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err());
}
@@ -505,8 +414,8 @@ mod tests {
#[test]
fn design_units_change_without_moving_geometry() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5}));
let vol = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
run(&mut s, "PrimitiveBox", json!({"length": 20, "width": 10, "height": 5}));
let vol = |s: &mut Session| run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let v0 = vol(&mut s);
run(&mut s, "document.units", json!({"units": "in"}));
assert_eq!(s.doc.units, "in");
@@ -516,34 +425,17 @@ mod tests {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "w", "expression": "5", "unit": ""}));
run(&mut s, "parameters.add", json!({"name": "n", "expression": "3", "unit": ""}));
run(&mut s, "solid.box", json!({"length": "w", "width": 10, "height": 5}));
run(&mut s, "PrimitiveBox", json!({"length": "w", "width": 10, "height": 5}));
let b = s.model.state().bodies[0].name.clone();
run(&mut s, "solid.pattern.rectangular", json!({"bodies": [b], "dir1": [1, 0, 0], "count1": "n", "spacing1": 20}));
run(&mut s, "PatternRectangular", json!({"bodies": [b], "dir1": [1, 0, 0], "count1": "n", "spacing1": 20}));
let v0 = vol(&mut s);
let r = run(&mut s, "document.units", json!({"units": "cm"}));
assert!((vol(&mut s) - v0).abs() < 1e-6, "{r}");
assert!(s.doc.param("n").is_some_and(|p| p.expr == "3"), "the count stays bare");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert_eq!(s.doc.units, "mm");
}
#[test]
fn a_bare_width_keeps_its_inches_when_the_design_goes_metric() {
let mut s = Session::default();
run(&mut s, "document.units", json!({"units": "in"}));
run(&mut s, "parameters.add", json!({"name": "w", "expression": "2", "unit": ""}));
run(&mut s, "solid.box", json!({"length": 10, "width": "w", "height": 5}));
let width = |s: &mut Session| {
let b = &run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"];
b["max"][1].as_f64().unwrap() - b["min"][1].as_f64().unwrap()
};
assert!((width(&mut s) - 50.8).abs() < 1e-6, "2 in an inch design");
let r = run(&mut s, "document.units", json!({"units": "mm"}));
assert!((width(&mut s) - 50.8).abs() < 1e-6, "still 50.8 mm: {r}");
assert_eq!(r["kept"], json!(["w"]));
assert_eq!(s.doc.param("w").map(|p| p.expr.as_str()), Some("2 in"));
}
#[test]
fn named_views_are_saved_renamed_and_deleted() {
let mut s = Session::default();
@@ -560,69 +452,15 @@ mod tests {
for p in [json!({}), json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": -1}}), json!({"camera": "x"})] {
assert!(s.execute("view.save", &p).is_err(), "{p}");
}
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert_eq!(s.doc.named_views.len(), 2);
}
#[test]
fn helper_sketches_hide_and_show() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let floor = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [50, 0]], "construction": true}));
run(&mut s, "sketch.finish", json!({}));
let drain = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.point", json!({"point": [25, 5]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Floor"}));
let body = s.model.state().bodies[0].name.clone();
let drain_name = s.doc.feature(drain).unwrap().name.clone();
let cam = solvecraft_render::Camera::default();
let lines = |s: &Session| crate::view::scene(s, &cam).lines.len();
let shown = |s: &mut Session| {
let d = run(s, "document.inspect", json!({}));
d["sketches"].as_array().unwrap().iter().filter(|x| x["visible"] == true).count()
};
let (n0, undo0, doc0) = (lines(&s), s.undo.len(), s.doc.clone());
assert_eq!(shown(&mut s), 2);
// By id and by name; the view loses their lines, the design and its history don't change.
let r = run(&mut s, "browser.visibility", json!({"items": [floor, drain_name], "visible": false}));
assert_eq!(r["changed"], 2);
assert_eq!(shown(&mut s), 0);
assert!(lines(&s) < n0);
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc0) && s.undo.len() == undo0, "view state, not a design change");
run(&mut s, "browser.visibility", json!({"folder": "sketches", "visible": true}));
assert_eq!(lines(&s), n0);
// Bodies (following a rename) and construction planes.
run(&mut s, "browser.visibility", json!({"items": [body.clone(), "Floor", "xy"], "visible": false}));
assert!(crate::view::scene(&s, &cam).meshes.is_empty());
assert_eq!(s.visibility.hidden_origin, vec!["Floor".to_string(), "XY".to_string()]);
run(&mut s, "body.rename", json!({"body": body, "name": "Tray"}));
assert_eq!(s.visibility.hidden_bodies, vec!["Tray".to_string()]);
let d = run(&mut s, "document.inspect", json!({}));
assert_eq!(d["bodies"][0]["visible"], false);
// Hostile input is refused and changes nothing.
let before = s.visibility.clone();
for p in [
json!({"items": [floor]}),
json!({"items": [floor], "visible": "no"}),
json!({"items": [floor, "Nope"], "visible": false}),
json!({"items": [], "visible": false}),
json!({"items": "Floor", "visible": false}),
json!({"folder": "sketches", "items": [floor], "visible": false}),
json!({"folder": "nope", "visible": false}),
json!({"visible": false}),
] {
assert!(s.execute("browser.visibility", &p).is_err(), "{p}");
}
assert_eq!(s.visibility, before);
}
#[test]
fn groups_hold_items_and_follow_renames() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
let [a, b] = [names(&s)[0].clone(), names(&s)[1].clone()];
let g = run(&mut s, "browser.group", json!({"folder": "bodies", "items": [a.clone()]}))["group"].as_u64().unwrap();
assert_eq!(s.doc.browser_groups[0].name, "Group1");
@@ -652,7 +490,7 @@ mod tests {
] {
assert!(s.execute(c, &p).is_err(), "{c} {p}");
}
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert_eq!(s.doc.browser_groups.len(), 1, "ungroup undoes");
}
}
+12 -12
View File
@@ -122,32 +122,32 @@ mod tests {
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
#[test]
fn copy_paste_sketch_and_extrude() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "w", "expression": "10 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "w"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5}));
run(&mut s, "ChangeParameterCommand", json!({"name": "w", "expression": "10 mm"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "S"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "ConstraintCoincident", json!({"a": "p1", "b": "origin"}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": "w"}));
run(&mut s, "SketchDimension", json!({"entities": ["l2"], "value": 10}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "S", "distance": 5}));
assert!((volume(&mut s) - 500.0).abs() < 1e-6);
run(&mut s, "timeline.copy", json!({"features": ["S", "Extrude1"]}));
let r = run(&mut s, "timeline.paste", json!({"translate": [30, 0, 0]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
assert_eq!(run(&mut s, "inspect.measure", json!({}))["body_count"], 2);
assert_eq!(run(&mut s, "MeasureCommand", json!({}))["body_count"], 2);
assert!((volume(&mut s) - 1000.0).abs() < 1e-6);
// The copy's dimensions are its own, still driven by the user parameter.
run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "w", "expression": "20 mm"}));
assert!((volume(&mut s) - 2000.0).abs() < 1e-6);
let pasted_extrude = r["pasted"][1]["name"].as_str().unwrap_or_default().to_string();
let d = s.doc.find_feature(&pasted_extrude).and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "parameters.change", json!({"name": d, "expression": "10"}));
run(&mut s, "ChangeParameterCommand", json!({"name": d, "expression": "10"}));
assert!((volume(&mut s) - 3000.0).abs() < 1e-6, "only the copy got thicker");
assert!(s.execute("timeline.paste", &json!({"translate": "x"})).is_err());
let mut t = Session::default();
+9 -25
View File
@@ -12,11 +12,11 @@ use crate::params::{bad, bool_, num, str_, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.create", "New Component", new_component)
CommandSpec::new("FusionCreateNewComponentCommand", "New Component", new_component)
.at("SOLID", "ASSEMBLE")
.icon("component")
.params("name?, parent?: component id or name (default: the active one); activate?: bool (default true)"),
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
CommandSpec::new("FusionCreateComponentsFromBodiesCommand", "Create Components from Bodies", from_bodies)
.icon("component")
.params("bodies: [names] → one new component per body, named after it"),
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design)"),
@@ -31,8 +31,8 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence)
.params("occurrence; translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position)"),
CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"),
CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("drops pending occurrence moves"),
CommandSpec::new("SnapshotCmd", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
CommandSpec::new("AsBuiltPositionsCmd", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("drops pending occurrence moves"),
];
fn key(v: Option<&Value>) -> Option<String> {
@@ -58,7 +58,7 @@ fn translation(t: Vec3) -> Mat {
}
fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.create";
let cmd = "FusionCreateNewComponentCommand";
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component };
let id = s.doc_mut().add_component(str_(p, "name"), parent)?;
if p.get("activate").and_then(Value::as_bool).unwrap_or(true) {
@@ -70,7 +70,7 @@ fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
}
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.from_bodies";
let cmd = "FusionCreateComponentsFromBodiesCommand";
let bodies = string_list(p, "bodies");
if bodies.is_empty() || bodies.len() > 1000 {
return Err(bad(cmd, "`bodies` must list 1…1000 body names"));
@@ -156,9 +156,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
d.components.retain(|c| !gone.contains(&c.id));
d.occurrences.retain(|o| !gone.contains(&o.component) && !gone.contains(&o.parent));
let moved_out: Vec<String> = d.body_components.iter().filter(|(_, c)| gone.contains(c)).map(|(b, _)| b.clone()).collect();
d.body_components.retain(|_, c| !gone.contains(c));
d.body_offsets.retain(|b, _| !moved_out.contains(b));
if gone.contains(&s.active_component) {
s.active_component = 0;
}
@@ -176,23 +174,10 @@ fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
for b in &bodies {
let feature = st.body(b).map(|x| x.feature).unwrap_or(0);
let own = s.doc.feature(feature).map(|f| f.component).unwrap_or(0);
// The body keeps its place in the world: where it is shown now (its component's frame and
// any earlier offset), seen from the new component's frame.
let from = s.doc.body_component(b, feature);
let now = solvecraft_doc::mat_mul(&s.doc.component_transform(from), s.doc.body_offsets.get(b).unwrap_or(&solvecraft_doc::IDENTITY));
let offset = solvecraft_doc::mat_inverse(&s.doc.component_transform(to))
.map(|inv| solvecraft_doc::mat_mul(&inv, &now))
.unwrap_or(solvecraft_doc::IDENTITY);
let doc = s.doc_mut();
if own == to {
doc.body_components.remove(b);
s.doc_mut().body_components.remove(b);
} else {
doc.body_components.insert(b.clone(), to);
}
if solvecraft_doc::is_identity(&offset) {
doc.body_offsets.remove(b);
} else {
doc.body_offsets.insert(b.clone(), offset);
s.doc_mut().body_components.insert(b.clone(), to);
}
}
Ok(json!({"bodies": bodies, "component": to}))
@@ -370,8 +355,7 @@ fn revert(s: &mut Session, _p: &Value) -> Result<Value> {
/// The active component's frame from world coordinates (for new features' geometry).
pub fn to_active_frame(s: &Session, kind: &mut FeatureKind) {
// Commands whose picks `frames` maps are already in their frames.
if s.active_component == 0 || crate::frames::mapped() {
if s.active_component == 0 {
return;
}
let m = s.doc.component_transform(s.active_component);
+14 -14
View File
@@ -9,10 +9,10 @@ use crate::params::{bad, bool_, str_, string_list};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("config.configure", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params(
CommandSpec::new("FusionStartDesignConfigModeCmd", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params(
"params?: [parameter names]; features?: [feature names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
),
CommandSpec::new("config.table", "Display Configuration Table", show)
CommandSpec::new("FusionShowDesignConfigPanelCmd", "Display Configuration Table", show)
.at("SOLID", "CONFIGURE")
.icon("params")
.noundo()
@@ -54,7 +54,7 @@ fn add_column(s: &mut Session, c: Column, cmd: &str) -> Result<()> {
}
fn configure(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "config.configure";
let cmd = "FusionStartDesignConfigModeCmd";
for n in string_list(p, "params") {
let c = column_of(s, &json!({ "param": n }), cmd)?;
add_column(s, c, cmd)?;
@@ -196,17 +196,17 @@ mod tests {
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
#[test]
fn configurations_switch_sizes_and_suppressions() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "len", "expression": "40 mm"}));
run(&mut s, "solid.box", json!({"length": "len", "width": 20, "height": 10}));
run(&mut s, "solid.fillet", json!({"edges": [[0, 0, 5]], "radius": 3}));
run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "40 mm"}));
run(&mut s, "PrimitiveBox", json!({"length": "len", "width": 20, "height": 10}));
run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[0, 0, 5]], "radius": 3}));
let fillet = s.doc.features.last().map(|f| f.name.clone()).unwrap_or_default();
let t = run(&mut s, "config.configure", json!({"params": ["len"], "features": [fillet]}));
let t = run(&mut s, "FusionStartDesignConfigModeCmd", json!({"params": ["len"], "features": [fillet]}));
assert_eq!(t["rows"][0]["name"], "Default");
run(&mut s, "config.row", json!({"name": "Long", "values": {"len": "80 mm"}}));
run(&mut s, "config.row", json!({"name": "Sharp", "from": "Long", "values": {fillet.clone(): true}}));
@@ -216,17 +216,17 @@ mod tests {
assert!((volume(&mut s) - (16000.0 - (9.0 - std::f64::consts::PI * 9.0 / 4.0) * 10.0)).abs() < 0.5, "{}", volume(&mut s));
run(&mut s, "config.activate", json!({"row": "Sharp"}));
assert!((volume(&mut s) - 16000.0).abs() < 1e-6);
let t = run(&mut s, "config.table", json!({}));
let t = run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}));
assert_eq!((t["active"].as_str(), t["matches"].as_bool()), (Some("Sharp"), Some(true)), "{t}");
// Undo goes back to the previous configuration's values.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert!(volume(&mut s) > 15000.0 && volume(&mut s) < 16000.0);
// Renaming the parameter keeps the column; editing it by hand breaks the match.
run(&mut s, "parameters.rename", json!({"name": "len", "new_name": "length"}));
let t = run(&mut s, "config.table", json!({}));
let t = run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}));
assert_eq!(t["columns"][0], "param:length", "{t}");
run(&mut s, "parameters.change", json!({"name": "length", "expression": "50 mm"}));
assert_eq!(run(&mut s, "config.table", json!({}))["matches"], false);
run(&mut s, "ChangeParameterCommand", json!({"name": "length", "expression": "50 mm"}));
assert_eq!(run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}))["matches"], false);
// Bad input.
assert!(s.execute("config.row", &json!({"name": "X", "values": {"nope": 1}})).is_err());
assert!(s.execute("config.activate", &json!({"row": "Missing"})).is_err());
@@ -234,6 +234,6 @@ mod tests {
assert!(s.execute("config.activate", &json!({"row": "Broken"})).is_err());
run(&mut s, "config.row", json!({"name": "Broken", "delete": true}));
run(&mut s, "config.column", json!({"feature": fillet, "delete": true}));
assert_eq!(run(&mut s, "config.table", json!({}))["columns"].as_array().map(Vec::len), Some(1));
assert_eq!(run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}))["columns"].as_array().map(Vec::len), Some(1));
}
}
-260
View File
@@ -1,260 +0,0 @@
//! SOLID ▸ CONSTRUCT: construction axes, points and the planes built from model geometry.
//!
//! References are given as `{face: [x,y,z]}`, `{edge: [x,y,z]}`, `{vertex: [x,y,z]}`,
//! `{point: [x,y,z]}`, `{plane: XY|name|{origin, normal}}`, `{axis: X|Y|Z|name}`,
//! `{construct_point: name}`, `{sketch, point}` or `{sketch, curve}`, or as a tagged
//! `GeoRef` (`{"type": "edge", "at": …}`). Picked faces and edges are stored with their
//! persistent names.
use serde_json::Value;
use solvecraft_doc::construct::{AxisDef, GeoRef, PointDef};
use solvecraft_doc::{FeatureKind, PlaneRef};
use super::CommandSpec;
use super::features::{add_feature, check_expr, plane_param, sketch_id};
use crate::params::{bad, req_expr, str_, vec3};
use crate::{Result, Session};
const REF: &str =
"{face|edge|vertex|point: [x,y,z]} | {plane: XY|name|{origin, normal}} | {axis: X|Y|Z|name} | {construct_point: name} | {sketch, point|curve}";
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("construct.axis.through_cylinder", "Axis Through Cylinder/Cone/Torus", axis_face)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.params("face: ref (a cylinder, cone or torus face, or a circular edge); name?"),
CommandSpec::new("construct.axis.normal_to_face", "Axis Perpendicular To Face", axis_normal)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.params("face: ref; point: ref (the axis goes through it, square to the face); name?"),
CommandSpec::new("construct.axis.two_planes", "Axis Through Two Planes", axis_planes)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.params("a, b: plane or planar face refs; name?"),
CommandSpec::new("construct.axis.two_points", "Axis Through Two Points", axis_points)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.params("a, b: point refs (vertices, sketch points, points); name?"),
CommandSpec::new("construct.axis.edge", "Axis Through Edge", axis_edge)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.params("edge: ref (a straight edge or sketch line); name?"),
CommandSpec::new("construct.point.vertex", "Point At Vertex", point_at)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("point: ref (vertex, sketch point, point); name?"),
CommandSpec::new("construct.point.two_edges", "Point Through Two Edges", point_edges)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("a, b: edge or line refs (where they meet, or come closest); name?"),
CommandSpec::new("construct.point.three_planes", "Point Through Three Planes", point_planes)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("a, b, c: plane or planar face refs; name?"),
CommandSpec::new("construct.point.center", "Point At Center Of Circle/Sphere/Torus", point_center)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("of: ref (circular edge, sketch circle or arc, sphere or torus face); name?"),
CommandSpec::new("construct.point.edge_plane", "Point At Edge And Plane", point_edge_plane)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("edge: edge or line ref; plane: plane or planar face ref; name?"),
CommandSpec::new("construct.point.along_path", "Point Along Path", point_along)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name?"),
CommandSpec::new("construct.plane.midplane", "Midplane", plane_mid)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("a, b: plane or planar face refs (halfway between; bisecting when they meet at an angle); name?"),
CommandSpec::new("construct.plane.two_edges", "Plane Through Two Edges", plane_edges)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("a, b: straight edge or line refs in one plane; name?"),
CommandSpec::new("construct.plane.three_points", "Plane Through Three Points", plane_points)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("a, b, c: point refs; name?"),
CommandSpec::new("construct.plane.tangent", "Tangent Plane", plane_tangent)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("face: ref (cylinder, cone, sphere or torus); at?: [x,y,z] where it touches (default: the face pick); name?"),
CommandSpec::new("construct.plane.along_path", "Plane Along Path", plane_along)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name? The plane is square to the path there"),
CommandSpec::new("construct.plane.perpendicular", "Perpendicular Plane", plane_perpendicular).at("SOLID", "CONSTRUCT").icon("plane").params(
"plane: plane or planar face ref; line: straight edge, sketch line or axis ref; name? The plane holds the line and is square to the plane",
),
];
/// A reference from its JSON form.
fn geo(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result<GeoRef> {
let v = v.ok_or_else(|| bad(cmd, format!("`{what}` is required: {REF}")))?;
let p3 = |k: &str| v.get(k).and_then(vec3);
let mut g = if v.get("type").is_some() {
serde_json::from_value::<GeoRef>(v.clone()).map_err(|e| bad(cmd, format!("`{what}`: {e}")))?
} else if let Some(at) = p3("face") {
GeoRef::Face { at, name: None }
} else if let Some(at) = p3("edge") {
GeoRef::Edge { at, name: None }
} else if let Some(at) = p3("vertex") {
GeoRef::Vertex { at }
} else if let Some(sk) = v.get("sketch") {
let sketch = sketch_id(s, Some(sk), cmd, what)?;
match (str_(v, "point"), str_(v, "curve")) {
(Some(point), _) => GeoRef::SketchPoint { sketch, point: point.to_string() },
(None, Some(curve)) => GeoRef::SketchCurve { sketch, curve: curve.to_string() },
_ => return Err(bad(cmd, format!("`{what}`: a sketch reference needs `point` or `curve`"))),
}
} else if let Some(p) = p3("point") {
GeoRef::Point { p }
} else if v.get("plane").is_some() {
GeoRef::Plane { plane: plane_param(s, v.get("plane"), cmd)? }
} else if let Some(a) = str_(v, "axis") {
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.to_string() },
_ => return Err(bad(cmd, format!("no axis `{a}`"))),
}
}
} else if let Some(n) = str_(v, "construct_point") {
match s.doc.find_feature(n).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.to_string() },
_ => return Err(bad(cmd, format!("no construction point `{n}`"))),
}
} else if v.as_str().is_some() {
// A bare string: a plane.
GeoRef::Plane { plane: plane_param(s, Some(v), cmd)? }
} else {
return Err(bad(cmd, format!("`{what}` must be {REF}")));
};
g.name_picks(&s.model.state());
Ok(g)
}
fn t_param(s: &Session, p: &Value, cmd: &str) -> Result<String> {
let t = if p.get("t").is_some() { req_expr(cmd, p, "t")? } else { "0.5".to_string() };
check_expr(s, &t, solvecraft_doc::expr::Kind::Unitless, cmd, "t")?;
Ok(t)
}
fn axis(s: &mut Session, p: &Value, def: AxisDef) -> Result<Value> {
add_feature(s, p, FeatureKind::ConstructionAxis { def })
}
fn point(s: &mut Session, p: &Value, def: PointDef) -> Result<Value> {
add_feature(s, p, FeatureKind::ConstructionPoint { def })
}
fn plane(s: &mut Session, p: &Value, plane: PlaneRef) -> Result<Value> {
add_feature(s, p, FeatureKind::ConstructionPlane { plane })
}
fn axis_face(s: &mut Session, p: &Value) -> Result<Value> {
let face = geo(s, p.get("face"), "construct.axis.through_cylinder", "face")?;
axis(s, p, AxisDef::ThroughFace { face })
}
fn axis_normal(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.axis.normal_to_face";
let face = geo(s, p.get("face"), cmd, "face")?;
let point = geo(s, p.get("point"), cmd, "point")?;
axis(s, p, AxisDef::NormalToFace { face, point })
}
fn axis_planes(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.axis.two_planes";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
axis(s, p, AxisDef::TwoPlanes { a, b })
}
fn axis_points(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.axis.two_points";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
axis(s, p, AxisDef::TwoPoints { a, b })
}
fn axis_edge(s: &mut Session, p: &Value) -> Result<Value> {
let edge = geo(s, p.get("edge"), "construct.axis.edge", "edge")?;
axis(s, p, AxisDef::Edge { edge })
}
fn point_at(s: &mut Session, p: &Value) -> Result<Value> {
let at = geo(s, p.get("point"), "construct.point.vertex", "point")?;
point(s, p, PointDef::At { point: at })
}
fn point_edges(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.point.two_edges";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
point(s, p, PointDef::TwoEdges { a, b })
}
fn point_planes(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.point.three_planes";
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")?);
point(s, p, PointDef::ThreePlanes { a, b, c })
}
fn point_center(s: &mut Session, p: &Value) -> Result<Value> {
let of = geo(s, p.get("of"), "construct.point.center", "of")?;
point(s, p, PointDef::Center { of })
}
fn point_edge_plane(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.point.edge_plane";
let (edge, pl) = (geo(s, p.get("edge"), cmd, "edge")?, geo(s, p.get("plane"), cmd, "plane")?);
point(s, p, PointDef::EdgeAndPlane { edge, plane: pl })
}
fn point_along(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.point.along_path";
let path = geo(s, p.get("path"), cmd, "path")?;
let t = t_param(s, p, cmd)?;
point(s, p, PointDef::AlongPath { path, t })
}
fn plane_mid(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.midplane";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
plane(s, p, PlaneRef::Midplane { a: Box::new(a), b: Box::new(b) })
}
fn plane_edges(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.two_edges";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
plane(s, p, PlaneRef::TwoEdges { a: Box::new(a), b: Box::new(b) })
}
fn plane_points(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.three_points";
let (a, b, c) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?, geo(s, p.get("c"), cmd, "c")?);
plane(s, p, PlaneRef::ThreePoints { a: Box::new(a), b: Box::new(b), c: Box::new(c) })
}
fn plane_tangent(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.tangent";
let face = geo(s, p.get("face"), cmd, "face")?;
let at = match (p.get("at").and_then(vec3), &face) {
(Some(a), _) => a,
(None, GeoRef::Face { at, .. }) => *at,
_ => return Err(bad(cmd, "`at` is required: where the plane touches the face")),
};
plane(s, p, PlaneRef::Tangent { face: Box::new(face), at })
}
fn plane_along(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.along_path";
let path = geo(s, p.get("path"), cmd, "path")?;
let t = t_param(s, p, cmd)?;
plane(s, p, PlaneRef::AlongPath { path: Box::new(path), t })
}
fn plane_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.perpendicular";
let (base, line) = (geo(s, p.get("plane"), cmd, "plane")?, geo(s, p.get("line"), cmd, "line")?);
plane(s, p, PlaneRef::Perpendicular { plane: Box::new(base), line: Box::new(line) })
}
+15 -26
View File
@@ -6,7 +6,6 @@
use serde_json::{Value, json};
use solvecraft_doc::FeatureKind;
use solvecraft_geom::Vec3;
use super::CommandSpec;
use crate::params::{bad, bool_};
@@ -64,7 +63,6 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let mut entities = Vec::new();
let mut features: Vec<u64> = Vec::new();
let mut bodies: Vec<String> = Vec::new();
let mut faces: Vec<(String, Vec<Vec3>)> = Vec::new();
for x in &items {
match x {
Sel::SketchCurve { id } | Sel::SketchPoint { id } | Sel::SketchConstraint { id } => entities.push(id.clone()),
@@ -80,10 +78,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
},
Sel::Axis { .. } => skip("the origin axes can't be deleted".into()),
Sel::Body { name } => bodies.push(name.clone()),
Sel::Face { body, point, .. } => match faces.iter_mut().find(|(b, _)| b == body) {
Some((_, ps)) => ps.push(*point),
None => faces.push((body.clone(), vec![*point])),
},
Sel::Face { .. } => skip("Delete Face: not available yet".into()),
Sel::Edge { .. } | Sel::Vertex { .. } => skip("edges and vertices are deleted with their body or face".into()),
Sel::Profile { .. } => skip("a profile goes with its sketch".into()),
}
@@ -98,14 +93,9 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
done = true;
}
}
// Faces of a body that is going anyway are left to it; the rest become Delete Face features.
for (body, points) in faces.iter().filter(|(b, _)| !bodies.contains(b)) {
sub(s, "face.delete", json!({ "faces": points, "body": body }))?;
done = true;
}
if !features.is_empty() {
let ids: Vec<String> = features.iter().map(u64::to_string).collect();
sub(s, "timeline.delete", json!({ "features": ids }))?;
sub(s, "FusionDeleteCommand", json!({ "features": ids }))?;
done = true;
}
// Bodies still there (not made by a feature deleted above) get a Remove feature.
@@ -113,7 +103,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
bodies.retain(|b| st.body(b).is_some());
bodies.dedup();
if !bodies.is_empty() {
sub(s, "solid.remove", json!({ "bodies": bodies }))?;
sub(s, "SoftDeleteCommand", json!({ "bodies": bodies }))?;
done = true;
}
// An occurrence goes alone while its component has others; the last one takes the component.
@@ -184,12 +174,12 @@ mod tests {
/// A sketch with a rectangle extruded into a body, a second box, and an offset plane.
fn part() -> (Session, u64) {
let mut s = Session::default();
let sk = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [50, 0, 0]}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 10}));
let sk = run(&mut s, "SketchCreate", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
run(&mut s, "PrimitiveBox", json!({"length": 5, "width": 5, "height": 5, "corner": [50, 0, 0]}));
run(&mut s, "ConstructionPlaneOffsetFromPlaneCommand", json!({"base": "XY", "offset": 10}));
(s, sk)
}
@@ -202,7 +192,7 @@ mod tests {
assert!(dry["deleted"].as_array().unwrap().len() >= 2, "the extrude goes with its sketch: {dry}");
run(&mut s, "selection.delete", json!({"items": [{"type": "feature", "id": sk}]}));
assert_eq!(bodies(&s), 1);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert_eq!(s.doc.features.len(), n);
assert_eq!(bodies(&s), 2);
}
@@ -222,7 +212,7 @@ mod tests {
assert!(s.doc.find_feature("Plane1").is_none());
assert_eq!(r["skipped"][0], "the origin planes can't be deleted");
assert_eq!(s.undo.len(), depth + 1);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert_eq!((bodies(&s), s.doc.features.len()), (2, n));
}
@@ -231,8 +221,7 @@ mod tests {
let (mut s, _) = part();
let b = s.model.state().bodies[0].name.clone();
let e = s.execute("selection.delete", &json!({"items": [{"type": "face", "body": b, "index": 0, "point": [0, 0, 0]}]})).unwrap_err();
// A side of the part its neighbours can't close: Delete Face refuses it.
assert!(e.to_string().contains("Delete Face"), "{e}");
assert!(e.to_string().contains("not available yet"), "{e}");
for p in [
json!({}),
json!({"items": "x"}),
@@ -248,7 +237,7 @@ mod tests {
fn occurrences_components_and_sketch_entities() {
let (mut s, _) = part();
let b = s.model.state().bodies[1].name.clone();
run(&mut s, "component.from_bodies", json!({"bodies": [b]}));
run(&mut s, "FusionCreateComponentsFromBodiesCommand", json!({"bodies": [b]}));
let c = s.doc.components[0].id;
let o1 = s.doc.occurrence_of(c).unwrap().id;
run(&mut s, "occurrence.copy", json!({"component": c, "translate": [0, 30, 0]}));
@@ -260,8 +249,8 @@ mod tests {
run(&mut s, "selection.delete", json!({"occurrences": [o2]}));
assert!(s.doc.components.is_empty());
// Sketch entities while sketching.
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]]}));
let sk = s.active_sketch.unwrap();
let id = s.model.state().sketch(sk).unwrap().sketch.curves[0].id.clone();
run(&mut s, "selection.delete", json!({"items": [{"type": "sketch_curve", "id": id}]}));
+14 -37
View File
@@ -8,23 +8,17 @@ use crate::params::{bad, bool_, num, str_, string_list};
use crate::{EngineError, Result, Sel, Session, Snapshot};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
.noundo()
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint)
.noundo()
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"),
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
CommandSpec::new("timeline.delete", "Delete", delete)
CommandSpec::new("UndoCommand", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
CommandSpec::new("RedoCommand", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
CommandSpec::new("FusionComputeAllCommand", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
CommandSpec::new("FusionDeleteCommand", "Delete", delete)
.at("SOLID", "MODIFY")
.icon("delete")
.key("Delete")
.params("features: [id or name] (a sketch takes its dependent features with it)"),
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name"),
CommandSpec::new("FusionRenameTimelineEntryCommand", "Rename", rename).params("feature: id|name, name"),
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
CommandSpec::new("timeline.rollTo", "Roll History Marker", rollback)
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder)
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
@@ -34,10 +28,8 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.params("feature: id|name → features deleted with it, and features that would fail without it"),
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
"feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \
restored: features whose earlier result for the same inputs was reused",
),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature)
.params("feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}}"),
CommandSpec::new("select.set", "Select", select_set)
.noundo()
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"),
@@ -66,21 +58,6 @@ fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"redone": label}))
}
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"checkpoint": s.checkpoint()}))
}
fn restore_checkpoint(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "edit.restore_checkpoint";
let id = p.get("checkpoint").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`checkpoint` must be an id from edit.checkpoint"))?;
if bool_(p, "forget") == Some(true) {
s.drop_checkpoint(id);
return Ok(json!({"checkpoint": id, "restored": false}));
}
s.restore_checkpoint(id)?;
Ok(json!({"checkpoint": id, "restored": true}))
}
fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
s.model = solvecraft_doc::Model::new();
s.refresh();
@@ -98,7 +75,7 @@ fn feature_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64> {
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.delete";
let cmd = "FusionDeleteCommand";
let mut keys = string_list(p, "features");
if let Some(Value::Array(a)) = p.get("features") {
keys.extend(a.iter().filter_map(Value::as_u64).map(|n| n.to_string()));
@@ -131,7 +108,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.rename";
let cmd = "FusionRenameTimelineEntryCommand";
let id = feature_id(s, p.get("feature"), cmd)?;
let name =
str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
@@ -148,7 +125,7 @@ fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
let n = s.doc.features.len();
let pos = match p.get("feature") {
Some(f) => {
let id = feature_id(s, Some(f), "timeline.roll_to")?;
let id = feature_id(s, Some(f), "timeline.rollTo")?;
s.doc.feature_index(id).map(|i| i + 1)
}
None => num(p, "position").map(|x| x.max(0.0) as usize),
@@ -227,7 +204,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
}
fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
@@ -301,7 +278,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
let id = feature_id(s, p.get("feature"), cmd)?;
let cid = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name the feature's command"))?;
let spec = super::find_command(cid).ok_or_else(|| bad(cmd, format!("no command `{cid}`")))?;
if spec.id.starts_with("timeline.") || spec.id.starts_with("select.") || spec.id.starts_with("sketch.") {
if cid.starts_with("timeline.") || cid.starts_with("select.") || cid.starts_with("Sketch") {
return Err(bad(cmd, format!("`{cid}` does not make a feature")));
}
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
@@ -330,7 +307,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
}
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
+4 -4
View File
@@ -118,7 +118,7 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
let spec = crate::find_command(id).ok_or_else(|| bad(cmd, id))?;
(spec.run)(s, &p)
};
let r = run(s, "sketch.create", json!({"plane": {"face": [inside.x, inside.y, inside.z]}, "project_edges": true}))?;
let r = run(s, "SketchCreate", json!({"plane": {"face": [inside.x, inside.y, inside.z]}, "project_edges": true}))?;
let sid = r.get("sketch").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
s.refresh();
let plane = s.model.state().sketch(sid).map(|ss| ss.plane).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
@@ -142,10 +142,10 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
None
};
match circle {
Some((o, r)) if closed => run(s, "sketch.circle.center", json!({"center": xy(o), "radius": r}))?,
Some((o, r)) if closed => run(s, "CircleCenterRadius", json!({"center": xy(o), "radius": r}))?,
Some(_) => {
let mid = pts.get(pts.len() / 2).copied().unwrap_or(first);
run(s, "sketch.arc.three_point", json!({"start": xy(first), "end": xy(last), "through": xy(mid)}))?
run(s, "ArcThreePoint", json!({"start": xy(first), "end": xy(last), "through": xy(mid)}))?
}
None => {
let mut list: Vec<Value> = Vec::new();
@@ -159,7 +159,7 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
if closed_poly {
list.pop();
}
run(s, "sketch.line", json!({"points": list, "closed": closed_poly}))?
run(s, "DrawPolyline", json!({"points": list, "closed": closed_poly}))?
}
};
}
+78 -307
View File
@@ -9,105 +9,92 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec2, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.extrude", "Extrude", extrude)
CommandSpec::new("Extrude", "Extrude", extrude)
.at("SOLID", "CREATE")
.icon("extrude")
.key("E")
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
CommandSpec::new("Revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.sweep", "Sweep", sweep)
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
CommandSpec::new("Sweep", "Sweep", sweep)
.at("SOLID", "CREATE")
.icon("sweep")
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?"),
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"),
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z]; operation?"),
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z]; axis?: [x,y,z]; operation?"),
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z]; operation?"),
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?; operation?"),
CommandSpec::new("solid.fillet", "Fillet", fillet)
CommandSpec::new("SolidLoft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"),
CommandSpec::new("PrimitiveBox", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z]; operation?"),
CommandSpec::new("PrimitiveCylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z]; axis?: [x,y,z]; operation?"),
CommandSpec::new("PrimitiveSphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z]; operation?"),
CommandSpec::new("PrimitiveTorus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?; operation?"),
CommandSpec::new("FusionFilletEdgesCommand", "Fillet", fillet)
.at("SOLID", "MODIFY")
.icon("fillet")
.key("F")
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?"),
CommandSpec::new("FusionChamferCommand", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("FusionCombineCommand", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool"),
CommandSpec::new("PatternRectangular", "Rectangular Pattern", pattern_rect)
.at("SOLID", "CREATE")
.icon("pattern_rect")
.params("features: [names] | bodies: [names] | components: [names]; dir1: [x,y,z]; count1; spacing1; dir2?, count2?, spacing2?"),
CommandSpec::new("solid.pattern.circular", "Circular Pattern", pattern_circ)
CommandSpec::new("PatternCircular", "Circular Pattern", pattern_circ)
.at("SOLID", "CREATE")
.icon("pattern_circ")
.params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg)"),
CommandSpec::new("solid.pattern.path", "Pattern on Path", pattern_path)
CommandSpec::new("PatternOnPath", "Pattern on Path", pattern_path)
.at("SOLID", "CREATE")
.icon("pattern_rect")
.params("features: [names] | bodies: [names]; path_sketch: sketch; path: [curve ids in order]; count; spacing (between instances) | distance (first to last); orientation?: identical|path; flip?: bool"),
CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ | {origin, x_dir, y_dir}"),
CommandSpec::new("solid.hole", "Hole", hole)
CommandSpec::new("MirrorCommand", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ | {origin, x_dir, y_dir}"),
CommandSpec::new("FusionHoleCommand", "Hole", hole)
.at("SOLID", "CREATE")
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch) | sketch alone (every sketch point: standalone points and circle centres); direction?: [x,y,z] (default: into the face); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
CommandSpec::new("solid.pipe", "Pipe", pipe)
.params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch); direction?: [x,y,z] (default: into the face); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
CommandSpec::new("PrimitivePipe", "Pipe", pipe)
.at("SOLID", "CREATE")
.icon("pipe")
.params("path_sketch: sketch; path: [curve ids in order]; diameter: expr; wall?: expr (hollow); operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.bounding_solid", "Bounding Solid", bounding_solid)
CommandSpec::new("StockModelCommand", "Bounding Solid", bounding_solid)
.at("SOLID", "CREATE")
.icon("box")
.params("bodies?: [names] (default: all); margin?: expr; name?, body_name?"),
CommandSpec::new("solid.scale", "Scale", scale)
CommandSpec::new("ModifyScale", "Scale", scale)
.at("SOLID", "MODIFY")
.icon("scale")
.params("bodies: [names]; factor: expr | factors: [x, y, z] exprs; origin?: [x,y,z]"),
CommandSpec::new("face.delete", "Delete Face", delete_face)
.at("SOLID", "MODIFY")
.icon("delete")
.params("faces: [[x,y,z] points on the faces]; body?. The neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off"),
CommandSpec::new("solid.offset_face", "Offset Face", offset_face)
CommandSpec::new("FusionOffsetFacesCommand", "Offset Face", offset_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
.params("faces: [[x,y,z] points on planar faces]; distance: expr (positive: outward); body?"),
CommandSpec::new("solid.thread", "Thread", thread)
CommandSpec::new("FusionThreadCommand", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face). Cosmetic: the model is unchanged, model.threads lists it"),
CommandSpec::new("solid.shell", "Shell", shell)
CommandSpec::new("FusionShellBodyCommand", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
.params("faces: [[x,y,z] points on the faces to remove]; thickness: expr; direction?: inside|outside|both; tangent_chain?: bool (also open faces joined smoothly to the picked ones); body?"),
CommandSpec::new("solid.draft", "Draft", draft)
.params("faces: [[x,y,z] points on the faces to remove]; thickness: expr (inside); body?"),
CommandSpec::new("FusionDraftCommand", "Draft", draft)
.at("SOLID", "MODIFY")
.icon("draft")
.params("faces: [[x,y,z]]; angle: expr; neutral: XY|XZ|YZ|plane name|{origin, normal}; pull?: [x,y,z] (default: neutral plane normal); body?"),
CommandSpec::new("construct.plane.offset", "Offset Plane", plane_offset)
CommandSpec::new("ConstructionPlaneOffsetFromPlaneCommand", "Offset Plane", plane_offset)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("base: XY|XZ|YZ|plane name; offset: expr; name?"),
CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle)
CommandSpec::new("ConstructionPlaneAtAngleCommand", "Plane at Angle", plane_angle)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("base: XY|XZ|YZ|plane name; axis: X|Y|Z|{origin, dir}; angle: expr; name?"),
CommandSpec::new("solid.split_face", "Split Face", split_face)
.at("SOLID", "MODIFY")
.icon("split")
.params(
"faces: [[x,y,z] points on the faces to split]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
),
CommandSpec::new("solid.split_body", "Split Body", split_body)
.at("SOLID", "MODIFY")
.icon("split")
.params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended)"),
CommandSpec::new("solid.press_pull", "Press Pull", press_pull)
CommandSpec::new("FusionSplitBodyCommand", "Split Body", split_body).at("SOLID", "MODIFY").icon("split").params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal}"),
CommandSpec::new("FusionPressPullCommand", "Press Pull", press_pull)
.at("SOLID", "MODIFY")
.icon("presspull")
.key("Q")
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"),
CommandSpec::new("FusionMoveCommand", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"),
];
/// Where an extrude starts and which way is positive: a point inside the (first) profile or
@@ -152,14 +139,7 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
if st.bodies.is_empty() || !d.is_finite() {
return Some("new");
}
// Only the active component's bodies take part (or the ones named as participants).
let parts = string_list(p, "participants");
let inside = |q: Vec3| {
st.bodies
.iter()
.filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) })
.any(|b| b.mesh().contains(q))
};
let inside = |q: Vec3| st.bodies.iter().any(|b| b.mesh().contains(q));
let eps = (d.abs() * 1e-3).max(1e-3);
let sign = if d < 0.0 { -1.0 } else { 1.0 };
if symmetric {
@@ -209,37 +189,6 @@ pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
.ok_or_else(|| bad(cmd, "there is no sketch to use (create one first)"))
}
/// Profile indices become the curve ids of each profile's loops as the sketch is now solved.
fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> {
let st = s.model.state();
'sels: for (sketch, sel) in kind.profile_sels_mut() {
let ProfileSel::Indices { indices } = sel else { continue };
let Some(ss) = st.sketch(sketch) else { continue };
let mut loops = Vec::with_capacity(indices.len());
for i in indices.iter() {
let pr = ss
.profiles
.get(*i)
.ok_or_else(|| EngineError::Other(format!("no profile {i} in sketch `{}` (it has {})", ss.name, ss.profiles.len())))?;
// Two profiles with the same outer curves (one inside the other's holes cannot be):
// keep the index then.
if pr.outer_curves.is_empty() || ss.profiles.iter().filter(|q| same_loop(&q.outer_curves, &pr.outer_curves)).count() != 1 {
continue 'sels;
}
loops.push(pr.outer_curves.clone());
}
*sel = ProfileSel::Curves { loops };
}
Ok(())
}
fn same_loop(a: &[String], b: &[String]) -> bool {
let (mut a, mut b) = (a.to_vec(), b.to_vec());
a.sort();
b.sort();
a == b
}
pub(super) fn profiles(p: &Value, cmd: &str) -> Result<ProfileSel> {
let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) };
match v {
@@ -294,39 +243,20 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
// Commands speak world coordinates; the feature is authored in the active component.
let mut kind = kind;
super::component::to_active_frame(s, &mut kind);
// Bare lengths typed in an inch (or cm…) design mean that unit.
if s.doc.units.trim() != "mm" {
let doc = s.doc.clone();
for (_, e, k) in kind.inputs_mut() {
*e = doc.with_design_unit(e, k);
}
}
// Profiles picked by index are kept by the curves around them: a recompute can renumber a
// sketch's profiles (a face sketch's projected outline rebuilt), never the curves.
stable_profiles(s, &mut kind)?;
// Blends remember where their edges sat, to find them again after upstream edits.
let (refs, names) = match &kind {
FeatureKind::Fillet { edges, body, .. } | FeatureKind::Chamfer { edges, body, .. } => {
let st = s.model.state();
(solvecraft_doc::edge_refs(&st, body, edges), solvecraft_doc::edge_names_for(&st, body, edges))
}
// Sheet metal edges are named by the sheet model.
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => {
(Vec::new(), solvecraft_doc::sheet::sheet_edge_names(&s.model.state(), edges))
}
_ => (Vec::new(), Vec::new()),
};
let face_names = {
let pts = kind.face_points();
if pts.is_empty() { Vec::new() } else { solvecraft_doc::naming::face_names_at(&s.model.state(), &pts) }
};
let participants = string_list(p, "participants");
if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) {
return Err(EngineError::Other("`participants` must list existing bodies".into()));
}
let id = s.doc_mut().add_feature(kind, name)?;
if let Some(f) = s.doc_mut().feature_mut(id) {
f.participants = participants;
f.edge_refs = refs;
f.edge_names = names;
f.face_names = face_names;
@@ -360,7 +290,7 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
}
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.extrude";
let cmd = "Extrude";
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
Some(fp) => {
@@ -370,14 +300,7 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
None => (feature_sketch(s, p, cmd)?, None),
};
let through_all = bool_(p, "through_all").unwrap_or(false) || str_(p, "extent").is_some_and(|e| e.eq_ignore_ascii_case("through_all"));
let to_object = p.get("to").is_some();
if to_object && p.get("to").and_then(vec3).is_none() {
return Err(bad(cmd, "`to` must be a point [x, y, z] on the face, vertex or point to extrude to"));
}
if p.get("from").is_some() && p.get("from").and_then(vec3).is_none() {
return Err(bad(cmd, "`from` must be a point [x, y, z] on the face or point to start from"));
}
let distance = if through_all || to_object { expr(p, "distance").unwrap_or_else(|| "0".into()) } else { req_expr(cmd, p, "distance")? };
let distance = if through_all { expr(p, "distance").unwrap_or_else(|| "0".into()) } else { req_expr(cmd, p, "distance")? };
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
let taper = expr(p, "taper");
if let Some(t) = &taper {
@@ -403,17 +326,7 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
Some(f) => f,
None => profiles(p, cmd)?,
},
extent: Extent {
distance,
direction,
distance2,
start_offset,
through_all,
taper,
to: p.get("to").and_then(vec3),
to_offset: expr(p, "to_offset"),
from: p.get("from").and_then(vec3),
},
extent: Extent { distance, direction, distance2, start_offset, through_all, taper },
operation: match str_(p, "operation") {
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
_ => operation(p, cmd)?,
@@ -424,7 +337,7 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
}
fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.revolve";
let cmd = "Revolve";
// A planar body face revolves like a profile (its edges projected into a sketch on it).
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
Some(fp) => {
@@ -452,27 +365,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
Some(f) => f,
None => profiles(p, cmd)?,
};
let angle2 = expr(p, "angle2");
if let Some(a) = &angle2 {
check_expr(s, a, Kind::Angle, cmd, "angle2")?;
}
let to = match p.get("to") {
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] to turn to"))?),
None => None,
};
let symmetric =
matches!(str_(p, "direction").map(str::to_ascii_lowercase).as_deref(), Some("symmetric")) || bool_(p, "symmetric").unwrap_or(false);
let kind = FeatureKind::Revolve {
sketch,
profiles,
axis,
angle,
operation: operation(p, cmd)?,
targets: string_list(p, "targets"),
angle2,
symmetric,
to,
};
let kind = FeatureKind::Revolve { sketch, profiles, axis, angle, operation: operation(p, cmd)?, targets: string_list(p, "targets") };
add_feature(s, p, kind)
}
@@ -485,7 +378,7 @@ fn radius_expr(p: &Value, cmd: &str) -> Result<String> {
}
fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.box";
let cmd = "PrimitiveBox";
let (l, w, h) = (req_expr(cmd, p, "length")?, req_expr(cmd, p, "width")?, req_expr(cmd, p, "height")?);
for (e, n) in [(&l, "length"), (&w, "width"), (&h, "height")] {
check_expr(s, e, Kind::Length, cmd, n)?;
@@ -502,7 +395,7 @@ fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
}
fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.cylinder";
let cmd = "PrimitiveCylinder";
let radius = radius_expr(p, cmd)?;
let height = req_expr(cmd, p, "height")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
@@ -513,7 +406,7 @@ fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
}
fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.sphere";
let cmd = "PrimitiveSphere";
let radius = radius_expr(p, cmd)?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
let center = p.get("center").and_then(vec3).unwrap_or(Vec3::ZERO);
@@ -521,7 +414,7 @@ fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
}
fn prim_torus(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.torus";
let cmd = "PrimitiveTorus";
let (major, minor) = (req_expr(cmd, p, "major")?, req_expr(cmd, p, "minor")?);
check_expr(s, &major, Kind::Length, cmd, "major")?;
check_expr(s, &minor, Kind::Length, cmd, "minor")?;
@@ -555,7 +448,7 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
/// Press Pull: a face moves along its normal (an extrude that joins outward or cuts inward); an
/// edge is rounded (a fillet).
fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.press_pull";
let cmd = "FusionPressPullCommand";
let distance = req_expr(cmd, p, "distance")?;
if p.get("edges").is_some() {
return fillet(s, &json!({"edges": p.get("edges"), "radius": distance}));
@@ -581,10 +474,10 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
let new = format!("({old}) + ({distance})");
let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?;
let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?;
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed"), "restored": out.get("restored")}));
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed")}));
}
// Another curved face moves along its normal.
let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?;
let r = super::find_command("FusionOffsetFacesCommand").ok_or_else(|| bad(cmd, "FusionOffsetFacesCommand"))?;
return (r.run)(s, &json!({"faces": [face], "distance": distance}));
}
let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (format!("-({distance})"), "negative", "cut") };
@@ -592,44 +485,15 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
}
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.fillet";
let cmd = "FusionFilletEdgesCommand";
let edges = edge_points(s, p, cmd)?;
let radius = req_expr(cmd, p, "radius")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
let style = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() {
None | Some("constant") | Some("rolling_ball") => solvecraft_doc::FilletStyle::Constant,
Some("chord") | Some("chord_length") => solvecraft_doc::FilletStyle::Chord,
Some("variable") => {
let radius2 = req_expr(cmd, p, "radius2")?;
check_expr(s, &radius2, Kind::Length, cmd, "radius2")?;
let start =
p.get("start").and_then(vec3).ok_or_else(|| bad(cmd, "a variable fillet needs `start`: [x,y,z] near the end that takes `radius`"))?;
let mut mid = Vec::new();
for m in p.get("mid").and_then(Value::as_array).into_iter().flatten().take(100) {
let (Some(t), Some(r)) = (m.get(0).and_then(Value::as_f64), m.get(1)) else {
return Err(bad(cmd, "`mid` entries are [position, radius]"));
};
if !(t.is_finite() && t > 0.0 && t < 1.0) {
return Err(bad(cmd, "`mid` positions lie between 0 and 1 (from the start end)"));
}
let r = match r {
Value::String(x) => x.clone(),
Value::Number(n) => n.to_string(),
_ => return Err(bad(cmd, "a `mid` radius is a number or an expression")),
};
check_expr(s, &r, Kind::Length, cmd, "mid radius")?;
mid.push((t, r));
}
let smooth = p.get("smooth").and_then(Value::as_bool).unwrap_or(false);
solvecraft_doc::FilletStyle::Variable { radius2, start, mid, smooth }
}
Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord or variable)"))),
};
add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string), style })
add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string) })
}
fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.chamfer";
let cmd = "FusionChamferCommand";
let edges = edge_points(s, p, cmd)?;
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
@@ -648,7 +512,7 @@ fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
}
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.combine";
let cmd = "FusionCombineCommand";
let target = str_(p, "target").ok_or_else(|| bad(cmd, "`target` must be a body name"))?.to_string();
let tools = string_list(p, "tools");
if tools.is_empty() {
@@ -658,23 +522,11 @@ fn combine(s: &mut Session, p: &Value) -> Result<Value> {
None => Operation::Join,
Some(o) => Operation::parse(o).filter(|o| *o != Operation::NewBody).ok_or_else(|| bad(cmd, "operation must be join, cut or intersect"))?,
};
let mut out = add_feature(
s,
p,
FeatureKind::Combine { target: target.clone(), tools, operation: op, keep_tools: bool_(p, "keep_tools").unwrap_or(false) },
)?;
// New Component: the result goes into a component of its own.
if bool_(p, "new_component").unwrap_or(false) {
let name = str_(p, "component_name").map(str::to_string).unwrap_or_else(|| format!("{target} Combined"));
let c = s.execute("component.create", &json!({"name": name, "activate": false}))?;
s.execute("component.move_bodies", &json!({"bodies": [target], "component": c["component"]}))?;
out["component"] = c["component"].clone();
}
Ok(out)
add_feature(s, p, FeatureKind::Combine { target, tools, operation: op, keep_tools: bool_(p, "keep_tools").unwrap_or(false) })
}
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.move";
let cmd = "FusionMoveCommand";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
@@ -768,7 +620,7 @@ fn pattern_sources(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<String>, Ve
}
fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.pattern.rectangular";
let cmd = "PatternRectangular";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let dir1 = p.get("dir1").and_then(vec3).ok_or_else(|| bad(cmd, "`dir1` must be [x, y, z]"))?;
let count1 = req_expr(cmd, p, "count1")?;
@@ -788,7 +640,7 @@ fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
}
fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.pattern.circular";
let cmd = "PatternCircular";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let (origin, axis) = axis_line(p, cmd)?;
let count = req_expr(cmd, p, "count")?;
@@ -800,7 +652,7 @@ fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
}
fn pattern_path(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.pattern.path";
let cmd = "PatternOnPath";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let path_sketch = sketch_id(s, p.get("path_sketch").or_else(|| p.get("sketch")), cmd, "path_sketch")?;
let path = string_list(p, "path");
@@ -826,7 +678,7 @@ fn pattern_path(s: &mut Session, p: &Value) -> Result<Value> {
}
fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.mirror";
let cmd = "MirrorCommand";
let bodies = string_list(p, "bodies");
let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() };
let plane = match p.get("plane") {
@@ -848,7 +700,7 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
/// A plane reference from a parameter: an origin plane, a construction plane name, or an
/// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`.
pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solvecraft_doc::PlaneRef> {
pub fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solvecraft_doc::PlaneRef> {
use solvecraft_doc::PlaneRef;
match v {
Some(Value::String(n)) if solvecraft_geom::Plane::named(n).is_some() => Ok(PlaneRef::Origin { name: n.to_ascii_uppercase() }),
@@ -870,7 +722,7 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
}
fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.offset";
let cmd = "ConstructionPlaneOffsetFromPlaneCommand";
let base = plane_param(s, p.get("base"), cmd)?;
let d = req_expr(cmd, p, "offset")?;
check_expr(s, &d, Kind::Length, cmd, "offset")?;
@@ -878,7 +730,7 @@ fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
}
fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.at_angle";
let cmd = "ConstructionPlaneAtAngleCommand";
let base = plane_param(s, p.get("base"), cmd)?;
let (axis_origin, axis_dir) = axis_line(p, cmd)?;
let angle = req_expr(cmd, p, "angle")?;
@@ -890,52 +742,14 @@ fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
)
}
fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.split_face";
let faces = face_points(p, cmd)?;
let body = str_(p, "body").map(str::to_string);
let xy = || solvecraft_doc::PlaneRef::Origin { name: "XY".into() };
let kind = if let Some(t) = p.get("tool").filter(|t| !t.is_null()) {
let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`tool.body` must name a body"))?.to_string();
let point = t.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "`tool.point` must be [x, y, z] on the tool face"))?;
FeatureKind::SplitFace { faces, body, plane: xy(), tool: Some(solvecraft_doc::FaceAt { body: tb, point }), sketch: None }
} else if p.get("sketch").is_some_and(|v| !v.is_null()) {
let sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?;
FeatureKind::SplitFace { faces, body, plane: xy(), tool: None, sketch: Some(sketch) }
} else {
let plane = plane_param(s, p.get("plane"), cmd)?;
FeatureKind::SplitFace { faces, body, plane, tool: None, sketch: None }
};
add_feature(s, p, kind)
}
fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.split_body";
let cmd = "FusionSplitBodyCommand";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
// A face as the tool: `tool: {body, point}` (its surface extended); else a plane.
if let Some(t) = p.get("tool").filter(|t| !t.is_null()) {
let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`tool.body` must name a body"))?.to_string();
let point = t.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "`tool.point` must be [x, y, z] on the tool face"))?;
let st = s.model.state();
let tbody = st.body(&tb).ok_or_else(|| bad(cmd, format!("no body `{tb}`")))?;
if solvecraft_doc::appearance::face_index_at(tbody, point).is_none() {
return Err(bad(cmd, "`tool.point` is not on a face of the tool body"));
}
return add_feature(
s,
p,
FeatureKind::Split {
body,
plane: solvecraft_doc::PlaneRef::Origin { name: "XY".into() },
tool: Some(solvecraft_doc::FaceAt { body: tb, point }),
},
);
}
let plane = plane_param(s, p.get("plane"), cmd)?;
add_feature(s, p, FeatureKind::Split { body, plane, tool: None })
add_feature(s, p, FeatureKind::Split { body, plane })
}
/// ISO 273 clearance hole diameters (close, normal, loose fit) for metric bolts.
@@ -975,14 +789,12 @@ fn clearance_diameter(size: &str, fit: &str) -> Option<f64> {
}
fn hole(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.hole";
let cmd = "FusionHoleCommand";
// At sketch points: the holes go perpendicular to the sketch and follow its points.
let points = match (p.get("sketch"), p.get("points"), p.get("position")) {
(Some(_), Some(_), _) | (Some(_), None, None) => {
let points = match (p.get("sketch"), p.get("points")) {
(Some(_), Some(_)) => {
let sketch = feature_sketch(s, p, cmd)?;
// No points named: every sketch point (standalone points and circle centres, not
// line or arc ends, not the origin).
let ids = if p.get("points").is_some() { string_list(p, "points") } else { sketch_hole_points(s, sketch) };
let ids = string_list(p, "points");
if ids.is_empty() || ids.len() > 10_000 {
return Err(bad(cmd, "`points` must list 1…10000 sketch point ids"));
}
@@ -1070,28 +882,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
},
other => return Err(bad(cmd, format!("unknown hole type `{other}`"))),
};
let to = match p.get("to") {
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] on the face to drill to"))?),
None => None,
};
add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread, to })
}
/// The points of a sketch a hole drills at when only the sketch is given.
fn sketch_hole_points(s: &Session, sketch: u64) -> Vec<String> {
let st = s.model.state();
let Some(ss) = st.sketch(sketch) else { return Vec::new() };
let mut ends: Vec<usize> = Vec::new();
for c in &ss.sketch.curves {
match &c.kind {
solvecraft_sketch::CurveKind::Circle { .. } => {}
solvecraft_sketch::CurveKind::Arc { a, b, .. } | solvecraft_sketch::CurveKind::Line { a, b } => ends.extend([*a, *b]),
solvecraft_sketch::CurveKind::Ellipse { m, .. } => ends.push(*m),
solvecraft_sketch::CurveKind::Spline { pts, .. } => ends.extend(pts.iter().copied()),
solvecraft_sketch::CurveKind::Conic { a, b, apex, .. } => ends.extend([*a, *b, *apex]),
}
}
ss.sketch.points.iter().enumerate().filter(|(i, p)| p.id != "origin" && !ends.contains(i)).map(|(_, p)| p.id.clone()).collect()
add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread })
}
fn face_points(p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
@@ -1103,35 +894,21 @@ fn face_points(p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
}
fn shell(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.shell";
let cmd = "FusionShellBodyCommand";
let faces = face_points(p, cmd)?;
let thickness = req_expr(cmd, p, "thickness")?;
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
let direction = str_(p, "direction").map(str::to_ascii_lowercase).unwrap_or_default();
if !matches!(direction.as_str(), "" | "inside" | "outside" | "both") {
return Err(bad(cmd, "`direction` must be inside, outside or both"));
}
add_feature(
s,
p,
FeatureKind::Shell {
faces,
thickness,
body: str_(p, "body").map(str::to_string),
direction: if direction == "inside" { String::new() } else { direction },
tangent_chain: bool_(p, "tangent_chain").unwrap_or(false),
},
)
add_feature(s, p, FeatureKind::Shell { faces, thickness, body: str_(p, "body").map(str::to_string) })
}
fn draft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.draft";
let cmd = "FusionDraftCommand";
let faces = face_points(p, cmd)?;
let angle = req_expr(cmd, p, "angle")?;
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
let neutral = plane_param(s, p.get("neutral"), cmd)?;
let (vals, _) = s.doc.param_values();
let pl = s.doc.resolve_plane_in(&vals, &neutral, 0, Some(&s.model.state()))?;
let pl = s.doc.resolve_plane(&vals, &neutral, 0)?;
let pull = p.get("pull").and_then(vec3).unwrap_or_else(|| pl.normal());
add_feature(s, p, FeatureKind::Draft { faces, angle, neutral, pull, body: str_(p, "body").map(str::to_string) })
}
@@ -1149,7 +926,7 @@ pub(super) fn sketch_id(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -
}
fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.sweep";
let cmd = "Sweep";
let sketch = feature_sketch(s, p, cmd)?;
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
let path = string_list(p, "path");
@@ -1171,7 +948,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
}
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.loft";
let cmd = "SolidLoft";
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
if list.len() < 2 || list.len() > 50 {
return Err(bad(cmd, "a loft needs 2…50 sections"));
@@ -1194,7 +971,7 @@ fn loft(s: &mut Session, p: &Value) -> Result<Value> {
}
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.thread";
let cmd = "FusionThreadCommand";
let face = p
.get("face")
.and_then(|v| vec3(v).or_else(|| v.get("point").and_then(vec3)))
@@ -1209,15 +986,14 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(l) = &length {
check_expr(s, l, Kind::Length, cmd, "length")?;
}
let modeled = p.get("modeled").and_then(Value::as_bool).unwrap_or(false);
let v = add_feature(s, p, FeatureKind::Thread { face, designation, length, modeled })?;
let v = add_feature(s, p, FeatureKind::Thread { face, designation, length })?;
let st = s.model.state();
let t = v.get("feature").and_then(Value::as_u64).and_then(|id| st.threads.iter().find(|t| t.feature == id).cloned());
Ok(json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t}))
}
fn pipe(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.pipe";
let cmd = "PrimitivePipe";
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
let path = string_list(p, "path");
if path.is_empty() {
@@ -1233,14 +1009,14 @@ fn pipe(s: &mut Session, p: &Value) -> Result<Value> {
}
fn bounding_solid(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.bounding_solid";
let cmd = "StockModelCommand";
let margin = expr(p, "margin").unwrap_or_else(|| "0".into());
check_expr(s, &margin, Kind::Length, cmd, "margin")?;
add_feature(s, p, FeatureKind::BoundingSolid { bodies: string_list(p, "bodies"), margin })
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.scale";
let cmd = "ModifyScale";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
@@ -1266,13 +1042,8 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::Scale { bodies, origin, factor, factors })
}
fn delete_face(s: &mut Session, p: &Value) -> Result<Value> {
let faces = face_points(p, "face.delete")?;
add_feature(s, p, FeatureKind::DeleteFace { faces, body: str_(p, "body").map(str::to_string) })
}
fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.offset_face";
let cmd = "FusionOffsetFacesCommand";
let faces = face_points(p, cmd)?;
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
+15 -74
View File
@@ -11,38 +11,35 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("SurfaceSculpt", "Boundary Fill", boundary_fill).at("SOLID", "CREATE").icon("boundary_fill").params(
"tools: [bodies]; cells: [[x,y,z] a point inside each cell to fill (a cell: inside some tools, outside the rest)]; operation?: new|join|cut|intersect; remove_tools?: bool",
),
CommandSpec::new("solid.coil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params(
CommandSpec::new("PrimitiveCoil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params(
"diameter; two of revolutions (or turns), height, pitch; section_size; section?: circular|square; section_position?: inside|center|outside; \
base?: [x,y,z]; axis?: X|Y|Z|[x,y,z] (default Z); start_angle?; clockwise?: bool; operation?, targets?, name?, body_name?",
),
CommandSpec::new("solid.rib", "Rib", rib)
CommandSpec::new("FusionRibCommand", "Rib", rib)
.at("SOLID", "CREATE")
.icon("rib")
.params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane); depth? (default: to the next face); flip?: bool (fill to the other side)"),
CommandSpec::new("solid.web", "Web", web)
CommandSpec::new("FusionWebCommand", "Web", web)
.at("SOLID", "CREATE")
.icon("rib")
.params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool"),
CommandSpec::new("solid.emboss", "Emboss", emboss)
CommandSpec::new("EmbossCmd", "Emboss", emboss)
.at("SOLID", "CREATE")
.icon("emboss")
.params("sketch (on a planar face), profiles?; depth; mode?: emboss|deboss (default emboss); targets?"),
CommandSpec::new("solid.replace_face", "Replace Face", replace_face)
CommandSpec::new("FusionReplaceFaceCommand", "Replace Face", replace_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
.params("faces: [[x,y,z] points on planar faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
CommandSpec::new("solid.align", "Align", align)
CommandSpec::new("AlignCmd", "Align", align)
.at("SOLID", "MODIFY")
.icon("move")
.params("bodies: [names]; from: [x,y,z] | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z]; to: (the same) | to_face: [x,y,z] (faces also turn to meet); flip?: bool"),
CommandSpec::new("solid.remove", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
.params("bodies: [names]; from: [x,y,z] | from_face: [x,y,z]; to: [x,y,z] | to_face: [x,y,z] (faces also turn to meet); flip?: bool"),
CommandSpec::new("SoftDeleteCommand", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
];
fn coil(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.coil";
let cmd = "PrimitiveCoil";
let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO);
let axis = match p.get("axis") {
None => Vec3::Z,
@@ -134,15 +131,15 @@ fn rib_like(s: &mut Session, p: &Value, cmd: &str, web: bool) -> Result<Value> {
}
fn rib(s: &mut Session, p: &Value) -> Result<Value> {
rib_like(s, p, "solid.rib", false)
rib_like(s, p, "FusionRibCommand", false)
}
fn web(s: &mut Session, p: &Value) -> Result<Value> {
rib_like(s, p, "solid.web", true)
rib_like(s, p, "FusionWebCommand", true)
}
fn emboss(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.emboss";
let cmd = "EmbossCmd";
let sketch = feature_sketch(s, p, cmd)?;
let profiles = profiles(p, cmd)?;
let depth = req_expr(cmd, p, "depth")?;
@@ -169,7 +166,7 @@ fn face_points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
}
fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.replace_face";
let cmd = "FusionReplaceFaceCommand";
let faces = face_points(p, "faces", cmd)?;
for f in &faces {
if super::face::face_normal(s, *f).is_none() {
@@ -189,7 +186,7 @@ fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.align";
let cmd = "AlignCmd";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list the bodies to move"));
@@ -205,12 +202,6 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
let n = super::face::face_normal(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
return Ok((fp, Some(n)));
}
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
if let Some(o) = p.get(pk).filter(|v| v.is_object()) {
let at = o.get("at").and_then(vec3).ok_or_else(|| bad(cmd, format!("`{pk}.at` must be [x, y, z]")))?;
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex");
return snap_point(s, kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")));
}
p.get(pk).and_then(vec3).map(|v| (v, None)).ok_or_else(|| bad(cmd, format!("give `{pk}` or `{fk}` as [x, y, z]")))
};
let (from, from_normal) = pick(s, "from", "from_face")?;
@@ -224,7 +215,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.remove";
let cmd = "SoftDeleteCommand";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
@@ -239,53 +230,3 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
#[path = "features_more_tests.rs"]
mod tests;
/// A snap point near `at` on the model: the nearest vertex, edge midpoint, planar face's centre
/// or circular edge's centre (the end of a cylindrical face).
pub(super) fn snap_point(s: &Session, kind: &str, at: Vec3) -> Option<Vec3> {
let st = s.world_state();
let near = |pts: Vec<Vec3>| pts.into_iter().min_by(|a, b| a.dist(at).total_cmp(&b.dist(at)));
match kind {
"vertex" => near(
st.bodies
.iter()
.flat_map(|b| b.mesh().edges.iter().flat_map(|e| [e.first().copied(), e.last().copied()]).flatten().collect::<Vec<_>>())
.collect(),
),
"edge_mid" => {
let d = |e: &solvecraft_doc::kernel::EdgeInfo| e.points.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::MAX, f64::min);
st.bodies
.iter()
.filter_map(|b| b.body.edges((b.body.size() * 2e-3).max(1e-3)).ok())
.flatten()
.min_by(|a, b| d(a).total_cmp(&d(b)))
.map(|e| e.mid)
}
"face_center" => st.bodies.iter().find_map(|b| {
let i = solvecraft_doc::appearance::face_index_at(b, at)?;
b.body.faces((b.body.size() * 2e-3).max(1e-3)).ok()?.into_iter().find(|f| f.index == i).map(|f| f.centroid)
}),
"circle_center" => st.bodies.iter().find_map(|b| {
let c = solvecraft_doc::kernel::cylinder_face_at(&b.body, at)?;
let along = (at - c.axis_point).dot(c.axis);
let end = if (along - c.start).abs() <= (c.end - along).abs() { c.start } else { c.end };
Some(c.axis_point + c.axis * end)
}),
_ => None,
}
}
fn boundary_fill(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SurfaceSculpt";
let tools = string_list(p, "tools");
let st = s.model.state();
if tools.is_empty() || tools.len() > 64 || tools.iter().any(|t| st.body(t).is_none()) {
return Err(bad(cmd, "`tools` must list 1…64 bodies"));
}
let cells: Vec<Vec3> = p.get("cells").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if cells.is_empty() || cells.len() > 1000 {
return Err(bad(cmd, "`cells` must list 1…1000 points [x, y, z], one inside each cell"));
}
let operation = super::features::operation(p, cmd)?;
add_feature(s, p, FeatureKind::BoundaryFill { tools, cells, operation, remove_tools: bool_(p, "remove_tools").unwrap_or(false) })
}
+43 -43
View File
@@ -10,7 +10,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
}
fn measure(s: &mut Session) -> Value {
run(s, "inspect.measure", json!({}))
run(s, "MeasureCommand", json!({}))
}
fn volume(s: &mut Session) -> f64 {
@@ -24,76 +24,76 @@ fn rel(a: f64, b: f64) -> f64 {
/// An L bracket: a 40 x 20 x 5 base and a 5 x 20 x 40 wall at x = 0..5.
fn bracket() -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 5, "corner": [0, 0, 0], "body_name": "L"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 20, "height": 40, "corner": [0, 0, 0], "operation": "join"}));
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 20, "height": 5, "corner": [0, 0, 0], "body_name": "L"}));
run(&mut s, "PrimitiveBox", json!({"length": 5, "width": 20, "height": 40, "corner": [0, 0, 0], "operation": "join"}));
assert!(rel(volume(&mut s), 4000.0 + 3500.0) < 1e-9);
// Mid-plane of the bracket (y = 10): sketch x = X, sketch y = Z.
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 10, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "RibSketch"}));
run(&mut s, "sketch.line", json!({"points": [[5, 25], [25, 5]], "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 10, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "RibSketch"}));
run(&mut s, "DrawPolyline", json!({"points": [[5, 25], [25, 5]], "ids": ["l1"]}));
run(&mut s, "SketchStop", json!({}));
s
}
#[test]
fn rib_to_the_next_face_fills_the_corner() {
let mut s = bracket();
run(&mut s, "solid.rib", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
run(&mut s, "FusionRibCommand", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
// The triangle (5,5)-(5,25)-(25,5) of area 200, 4 thick.
assert!(rel(volume(&mut s), 7500.0 + 800.0) < 1e-6, "{}", volume(&mut s));
assert_eq!(measure(&mut s)["body_count"], 1);
// The thickness is a parameter.
let t = s.doc.features.last().and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "parameters.change", json!({"name": t, "expression": "2"}));
run(&mut s, "ChangeParameterCommand", json!({"name": t, "expression": "2"}));
assert!(rel(volume(&mut s), 7500.0 + 400.0) < 1e-6);
}
#[test]
fn rib_with_a_depth_and_web() {
let mut s = bracket();
run(&mut s, "solid.rib", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4, "depth": 2}));
run(&mut s, "FusionRibCommand", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4, "depth": 2}));
let v = volume(&mut s);
assert!(v > 7500.0 + 100.0 && v < 7500.0 + 800.0, "{v}");
let mut s = bracket();
run(&mut s, "solid.web", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
run(&mut s, "FusionWebCommand", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
assert!(rel(volume(&mut s), 8300.0) < 1e-6);
// Curves that face no body.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Far"}));
run(&mut s, "sketch.line", json!({"points": [[50, 50], [60, 60]], "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
let e = s.execute("solid.rib", &json!({"sketch": "Far", "curves": ["l1"], "thickness": 1}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Far"}));
run(&mut s, "DrawPolyline", json!({"points": [[50, 50], [60, 60]], "ids": ["l1"]}));
run(&mut s, "SketchStop", json!({}));
let e = s.execute("FusionRibCommand", &json!({"sketch": "Far", "curves": ["l1"], "thickness": 1}));
assert!(e.is_err_and(|e| e.to_string().contains("do not face a body")));
}
#[test]
fn emboss_and_deboss_on_a_face() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 10]}, "name": "Logo"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch": "Logo", "depth": 2}));
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 10]}, "name": "Logo"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [5, 5], "p1": [15, 15]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "EmbossCmd", json!({"sketch": "Logo", "depth": 2}));
assert!(rel(volume(&mut s), 12000.0 + 200.0) < 1e-6, "{}", volume(&mut s));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.emboss", json!({"sketch": "Logo", "depth": 3, "mode": "deboss"}));
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "EmbossCmd", json!({"sketch": "Logo", "depth": 3, "mode": "deboss"}));
assert!(rel(volume(&mut s), 12000.0 - 300.0) < 1e-6);
assert!(s.execute("solid.emboss", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err());
assert!(s.execute("EmbossCmd", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err());
}
#[test]
fn replace_face_align_remove() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.replace_face", json!({"faces": [[5, 5, 10]], "target": {"origin": [0, 0, 15], "normal": [0, 0, 1]}}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "FusionReplaceFaceCommand", json!({"faces": [[5, 5, 10]], "target": {"origin": [0, 0, 15], "normal": [0, 0, 1]}}));
assert!(rel(volume(&mut s), 1500.0) < 1e-6);
let e = s.execute("solid.replace_face", &json!({"faces": [[5, 5, 15]], "target": {"origin": [0, 0, 15], "normal": [1, 0, 1]}}));
let e = s.execute("FusionReplaceFaceCommand", &json!({"faces": [[5, 5, 15]], "target": {"origin": [0, 0, 15], "normal": [1, 0, 1]}}));
assert!(e.is_err() || s.model.results.last().is_some_and(|r| r.error.as_deref().unwrap_or("").contains("parallel")));
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 0], "body_name": "B"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 0], "body_name": "B"}));
// B's top face onto A's top face: B turns over and sits on A.
run(&mut s, "solid.align", json!({"bodies": ["B"], "from_face": [55, 5, 10], "to_face": [5, 5, 10]}));
run(&mut s, "AlignCmd", json!({"bodies": ["B"], "from_face": [55, 5, 10], "to_face": [5, 5, 10]}));
let m = measure(&mut s);
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == "B").unwrap().clone();
let (lo, hi) = (b["bbox"]["min"].clone(), b["bbox"]["max"].clone());
@@ -101,11 +101,11 @@ fn replace_face_align_remove() {
assert!((got.as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{b}");
}
// Points only: a plain move.
run(&mut s, "solid.align", json!({"bodies": ["B"], "from": [0, 0, 10], "to": [0, 0, 30]}));
run(&mut s, "AlignCmd", json!({"bodies": ["B"], "from": [0, 0, 10], "to": [0, 0, 30]}));
let m = measure(&mut s);
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == "B").unwrap().clone();
assert!((b["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 30.0).abs() < 1e-6, "{b}");
run(&mut s, "solid.remove", json!({"bodies": ["B"]}));
run(&mut s, "SoftDeleteCommand", json!({"bodies": ["B"]}));
assert_eq!(measure(&mut s)["body_count"], 1);
}
@@ -113,27 +113,27 @@ fn replace_face_align_remove() {
fn coil_and_loft_to_a_point() {
use std::f64::consts::{PI, TAU};
let mut s = Session::default();
run(&mut s, "solid.coil", json!({"diameter": 40, "revolutions": 2, "pitch": 8, "section_size": 4}));
run(&mut s, "PrimitiveCoil", json!({"diameter": 40, "revolutions": 2, "pitch": 8, "section_size": 4}));
assert!(rel(volume(&mut s), PI * 4.0 * TAU * 20.0 * 2.0) < 1e-3, "{}", volume(&mut s));
// Height and revolutions give the pitch; a square section outside the diameter.
let mut s = Session::default();
run(
&mut s,
"solid.coil",
"PrimitiveCoil",
json!({"diameter": 40, "revolutions": 1.5, "height": 15, "section": "square", "section_size": 2, "section_position": "outside", "axis": "X"}),
);
assert!(rel(volume(&mut s), 4.0 * TAU * 21.0 * 1.5) < 1e-3, "{}", volume(&mut s));
assert!(s.execute("solid.coil", &json!({"diameter": 40, "pitch": 1, "revolutions": 3, "section_size": 4})).is_err(), "runs into itself");
assert!(s.execute("solid.coil", &json!({"diameter": 40, "section_size": 4, "pitch": 8})).is_err(), "two of three");
assert!(s.execute("PrimitiveCoil", &json!({"diameter": 40, "pitch": 1, "revolutions": 3, "section_size": 4})).is_err(), "runs into itself");
assert!(s.execute("PrimitiveCoil", &json!({"diameter": 40, "section_size": 4, "pitch": 8})).is_err(), "two of three");
// Pyramid: a square lofted to a point.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-10, -10], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Top"}));
run(&mut s, "sketch.point", json!({"point": [0, 0], "id": "apex"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "apex"}]}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [-10, -10], "p1": [10, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Top"}));
run(&mut s, "DrawPoint", json!({"point": [0, 0], "id": "apex"}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "SolidLoft", json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "apex"}]}));
assert!(rel(volume(&mut s), 400.0 * 30.0 / 3.0) < 1e-6);
assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
assert!(s.execute("SolidLoft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
}
+23 -23
View File
@@ -9,22 +9,22 @@ use crate::params::{bad, bool_, str_, string_list};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("file.new", "New Design", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"),
CommandSpec::new("NewDocumentCommand", "New Design", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"),
CommandSpec::new("doc.open", "Open", open)
.icon("open")
.key("Ctrl+O")
.noundo()
.params("path: .solvecraft design, or a .step/.stp file (opens as a new design)"),
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
CommandSpec::new("FusionImportCommandFromToolbar", "Insert STEP", insert_step)
.at("SOLID", "INSERT")
.icon("import")
.params("path: .step/.stp file (its bodies join the design as an Import base feature); name?"),
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
CommandSpec::new("ParaMeshInsertAlignCommand", "Insert Mesh", insert_mesh)
.at("SOLID", "INSERT")
.icon("import")
.params("path: .3mf or .stl file (its meshes join the design as mesh bodies); name?"),
CommandSpec::new("file.save", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"),
CommandSpec::new("file.save_as", "Save As", save_as).icon("save").noundo().params("path"),
CommandSpec::new("SaveDocumentCommand", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"),
CommandSpec::new("SaveDocumentAsCommand", "Save As", save_as).icon("save").noundo().params("path"),
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list)
.noundo()
.params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"),
@@ -32,11 +32,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.params("id (from doc.recovery_list); dir?; discard?: bool (delete the entry once open) — opens it with its file path, unsaved"),
CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).noundo().params("id, or all: true; dir?"),
CommandSpec::new("file.export", "Export", export)
CommandSpec::new("ExportCommand", "Export", export)
.icon("export")
.noundo()
.params("path; format?: stl|stla|obj|step|3mf (default from extension); bodies?: [names]"),
CommandSpec::new("file.save_mesh", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"),
CommandSpec::new("FusionSaveAsSTLCommand", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"),
];
fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
@@ -51,11 +51,11 @@ fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
/// Read a STEP file as an Import feature.
fn read_step(path: &str) -> Result<solvecraft_io::StepFeature> {
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta > solvecraft_io::MAX_STEP_BYTES as u64 {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > solvecraft_io::MAX_STEP_BYTES as u64 {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
solvecraft_io::step_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
@@ -80,11 +80,11 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
/// Add a MeshImport feature for a 3MF or STL file.
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta > solvecraft_io::MAX_3MF_BYTES as u64 {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > solvecraft_io::MAX_3MF_BYTES as u64 {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mf = solvecraft_io::mesh_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string();
let id = s.doc_mut().add_feature(mf.kind, Some(&fname))?;
@@ -103,7 +103,7 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
}
fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.insert_mesh";
let cmd = "ParaMeshInsertAlignCommand";
let path = path_arg(p, cmd)?;
if !solvecraft_io::is_mesh_path(path) {
return Err(bad(cmd, "only 3MF and STL files (.3mf, .stl) can be inserted as meshes"));
@@ -112,7 +112,7 @@ fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
}
fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.insert_step";
let cmd = "FusionImportCommandFromToolbar";
let path = path_arg(p, cmd)?;
if !solvecraft_io::is_step_path(path) {
return Err(bad(cmd, "only STEP files (.step, .stp) can be inserted"));
@@ -132,11 +132,11 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
}
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta as usize > solvecraft_io::MAX_DESIGN_BYTES {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() as usize > solvecraft_io::MAX_DESIGN_BYTES {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let doc = solvecraft_io::read_design(&bytes)?;
*s = Session::new(doc);
s.path = Some(path.to_string());
@@ -156,13 +156,13 @@ fn write(s: &mut Session, path: &str) -> Result<Value> {
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let path = match str_(p, "path") {
Some(x) if !x.trim().is_empty() => x.to_string(),
_ => s.path.clone().ok_or_else(|| bad("file.save", "the design has no file yet: give a `path`"))?,
_ => s.path.clone().ok_or_else(|| bad("SaveDocumentCommand", "the design has no file yet: give a `path`"))?,
};
write(s, &path)
}
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "file.save_as")?.to_string();
let path = path_arg(p, "SaveDocumentAsCommand")?.to_string();
write(s, &path)
}
@@ -175,18 +175,18 @@ fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Resu
// Bodies carry the colour they are shown in (appearance, material or imported).
solvecraft_io::export(&s.doc.painted(&s.world_state()), bodies, format, &name)?
};
solvecraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")}))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "file.export")?;
let path = path_arg(p, "ExportCommand")?;
let format = Format::from_name(str_(p, "format").unwrap_or(path))?;
export_to(s, path, format, &string_list(p, "bodies"))
}
fn save_stl(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "file.save_mesh")?;
let path = path_arg(p, "FusionSaveAsSTLCommand")?;
let f = if bool_(p, "ascii").unwrap_or(false) { Format::StlAscii } else { Format::StlBinary };
export_to(s, path, f, &string_list(p, "bodies"))
}
+11 -24
View File
@@ -8,7 +8,7 @@ use crate::params::{bad, bool_, str_, string_list};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("inspect.measure", "Measure", measure)
CommandSpec::new("MeasureCommand", "Measure", measure)
.at("SOLID", "INSPECT")
.icon("measure")
.key("I")
@@ -19,11 +19,11 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("model.edges", "List Edges", model_edges).noundo().params("body: name"),
CommandSpec::new("model.faces", "List Faces", model_faces).noundo().params("body: name"),
CommandSpec::new("model.threads", "List Threads", model_threads).noundo(),
CommandSpec::new("material.assign", "Physical Material", physical_material)
CommandSpec::new("PhysicalMaterialCommand", "Physical Material", physical_material)
.at("SOLID", "MODIFY")
.icon("material")
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …; `material.list`)"),
CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
CommandSpec::new("AppearanceCommand", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
neither (or color: null): clear, so the face follows its body, the body its component, the component its material",
@@ -74,7 +74,7 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
}
}
if !want.is_empty() && bodies.len() != want.len() {
return Err(bad("inspect.measure", "unknown body name"));
return Err(bad("MeasureCommand", "unknown body name"));
}
// Sheet metal keeps the edges where bends meet flat faces (as Fusion does): count its
// B-rep faces as they are, without merging coplanar neighbours.
@@ -102,9 +102,6 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
fn sketch_json(s: &Session, id: u64) -> Option<Value> {
let state = s.model.state();
let ss = state.sketch(id)?;
// Where the sketch is in the world (its component's placement).
let world = s.world_state();
let plane = world.sketch(id).map(|w| w.plane).unwrap_or(ss.plane);
let sk = &ss.sketch;
let curves: Vec<Value> = sk
.curves
@@ -132,7 +129,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
.points
.iter()
.enumerate()
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": ss.plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
.collect();
let constraints: Vec<Value> = sk
.constraints
@@ -170,7 +167,6 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
"profiles": profiles,
"links": sk.links,
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link})).collect::<Vec<_>>(),
"visible": crate::view::sketch_shown(s, id),
}))
}
@@ -208,27 +204,18 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let with_measure = bool_(p, "measure").unwrap_or(false);
let bodies: Vec<Value> = st
.bodies
.iter()
.map(|b| {
let mut v = if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) };
if let Some(o) = v.as_object_mut() {
o.insert("visible".into(), json!(!s.visibility.hidden_bodies.contains(&b.name)));
}
v
})
.collect();
let bodies: Vec<Value> =
st.bodies.iter().map(|b| if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) }).collect();
let sketches: Vec<Value> = st
.sketches
.iter()
.map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len(), "visible": crate::view::sketch_shown(s, ss.feature)}))
.map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len()}))
.collect();
Ok(json!({
"name": s.doc.name, "units": s.doc.units, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
"active_sketch": s.active_sketch, "marker": marker,
"params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches,
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(), "hidden_origin": s.visibility.hidden_origin,
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(),
}))
}
@@ -275,7 +262,7 @@ fn model_threads(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "material.assign";
let cmd = "PhysicalMaterialCommand";
let bodies = string_list(p, "bodies");
let material = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
let canonical = solvecraft_doc::MATERIALS
@@ -314,7 +301,7 @@ fn color_param(v: &Value) -> Option<[u8; 3]> {
fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
use solvecraft_doc::appearance::{FaceLook, Look, library};
let cmd = "appearance.assign";
let cmd = "AppearanceCommand";
let look: Option<Look> = match (str_(p, "appearance"), p.get("color")) {
// A colour with a name: that colour, called that (a name not in the library is fine).
(Some(n), Some(v)) if !v.is_null() => {
+17 -32
View File
@@ -13,21 +13,21 @@ use crate::params::{bad, bool_, num, str_, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.origin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
CommandSpec::new("JointOrigin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
"name?, occurrence?: id|name (default: from the pick), face: [x,y,z] (centre of a planar face) | circle: [x,y,z] (centre of a round end) | point: [x,y,z], z?: [x,y,z]",
),
CommandSpec::new("joint.create", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
CommandSpec::new("JointAssembleCmdNew", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball; a, b: joint origins ({occurrence, face|circle|point: [x,y,z] in world} | origin name); \
values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool; offset?; angle?; name?",
),
CommandSpec::new("joint.as_built", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params(
CommandSpec::new("JointAsBuiltCmd", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params(
"type, a: occurrence, b: occurrence (stay where they are); at?: joint origin (default: b's origin) — the joint's frame",
),
CommandSpec::new("joint.rigid_group", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
CommandSpec::new("joint.drive", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
CommandSpec::new("RigidGroupCmd", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
CommandSpec::new("FusionMoveJointsCommand", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
"joint: id|name; value | values: [angles in deg or expressions, distances] (clamped to the limits)",
),
CommandSpec::new("joint.motion_link", "Motion Link", motion_link)
CommandSpec::new("FusionMotionRelationshipCommand", "Motion Link", motion_link)
.at("SOLID", "ASSEMBLE")
.icon("motion_link")
.params("a, b: joints; ratio (b = ratio · a + offset); offset?; ia?, ib?: value indices (default 0)"),
@@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.delete", "Delete Joint", delete).params("joint: id|name (or origin: name, link: index)"),
CommandSpec::new("joint.list", "List Joints", list).noundo().params("→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts"),
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).params("re-place the occurrences from the joints → conflicts, errors, dof"),
CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
CommandSpec::new("InterferenceCheckCommand", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
"bodies?: [names] (default: all, in world placement) → pairs that overlap with the overlap volume, area, bounding box and centre",
),
];
@@ -81,7 +81,7 @@ pub(crate) fn occurrence_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result
}
/// The occurrence whose bodies are nearest a world point (0 = root bodies).
pub(crate) fn occurrence_at(s: &Session, p: Vec3) -> Option<u64> {
fn occurrence_at(s: &Session, p: Vec3) -> Option<u64> {
let st = s.model.state();
let mut best: Option<(f64, u64)> = None;
for b in &st.bodies {
@@ -133,13 +133,7 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<JointOrigin
let snap = match what {
"face" | "circle" => {
let pick = local(world);
// A circle's name is its cylindrical wall's (a rim pick also touches the end face).
let named_at = if what == "circle" {
s.model.state().bodies.iter().filter_map(|b| cylinder_wall_point(&b.body, pick)).next().unwrap_or(pick)
} else {
pick
};
let face = solvecraft_doc::naming::face_names_at(&s.model.state(), &[named_at]).into_iter().next().filter(|n| !n.is_empty());
let face = solvecraft_doc::naming::face_names_at(&s.model.state(), &[pick]).into_iter().next().filter(|n| !n.is_empty());
if what == "face" { Snap::FaceCenter { pick, face } } else { Snap::CircleCenter { pick, face } }
}
"point" => Snap::Point { point: local(world), z: v.get("z").and_then(vec3).map(local_dir) },
@@ -221,7 +215,7 @@ fn add_joint(s: &mut Session, j: Joint) -> Result<Value> {
return Err(EngineError::Other("too many joints".into()));
}
if j.a.occurrence == j.b.occurrence {
return Err(bad("joint.create", "a joint needs two different occurrences"));
return Err(bad("JointAssembleCmdNew", "a joint needs two different occurrences"));
}
let id = j.id;
let name = j.name.clone();
@@ -234,7 +228,7 @@ fn add_joint(s: &mut Session, j: Joint) -> Result<Value> {
}
fn joint_origin(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.origin";
let cmd = "JointOrigin";
let origin = origin_param(s, Some(p), cmd)?;
let name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => n.to_string(),
@@ -254,7 +248,7 @@ fn kind_param(p: &Value, cmd: &str) -> Result<JointKind> {
}
fn joint(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.create";
let cmd = "JointAssembleCmdNew";
let kind = kind_param(p, cmd)?;
let a = origin_param(s, p.get("a"), cmd)?;
let b = origin_param(s, p.get("b"), cmd)?;
@@ -285,7 +279,7 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
}
fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.as_built";
let cmd = "JointAsBuiltCmd";
let kind = kind_param(p, cmd)?;
let (oa, ob) = (occurrence_id(s, p.get("a"), cmd)?, occurrence_id(s, p.get("b"), cmd)?);
// The joint's frame in the world: the given origin, or B's component origin.
@@ -318,7 +312,7 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rigid_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.rigid_group";
let cmd = "RigidGroupCmd";
let list = p.get("occurrences").and_then(Value::as_array).cloned().unwrap_or_default();
if list.len() < 2 || list.len() > 1000 {
return Err(bad(cmd, "`occurrences` must list 2…1000 occurrences"));
@@ -334,7 +328,7 @@ fn rigid_group(s: &mut Session, p: &Value) -> Result<Value> {
}
fn drive(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.drive";
let cmd = "FusionMoveJointsCommand";
let id = joint_id(s, p.get("joint"), cmd)?;
let kind = s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.kind).ok_or_else(|| bad(cmd, "joint"))?;
if kind == JointKind::Rigid {
@@ -364,7 +358,7 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
}
fn motion_link(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.motion_link";
let cmd = "FusionMotionRelationshipCommand";
let (a, b) = (joint_id(s, p.get("a"), cmd)?, joint_id(s, p.get("b"), cmd)?);
if a == b {
return Err(bad(cmd, "link two different joints"));
@@ -523,7 +517,7 @@ fn solve_cmd(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn interference(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.interference";
let cmd = "InterferenceCheckCommand";
let want = string_list(p, "bodies");
let st = s.world_state();
let bodies: Vec<&solvecraft_doc::ModelBody> =
@@ -557,12 +551,3 @@ fn interference(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
#[path = "joints_tests.rs"]
mod tests;
/// A point in the middle of the cylindrical face at `pick` (on its wall, halfway along).
fn cylinder_wall_point(b: &solvecraft_doc::kernel::Body, pick: Vec3) -> Option<Vec3> {
let c = solvecraft_doc::kernel::cylinder_face_at(b, pick)?;
let v = pick - c.axis_point;
let along = v.dot(c.axis);
let radial = (v - c.axis * along).normalized()?;
Some(c.axis_point + c.axis * ((c.start + c.end) / 2.0) + radial * c.radius)
}
+32 -32
View File
@@ -29,9 +29,9 @@ fn near(a: Vec3, b: Vec3) -> bool {
/// at y = 33, z = 4.
fn leaf(s: &mut Session, name: &str) {
run(s, "component.activate", json!({"component": "root"}));
run(s, "component.create", json!({"name": name}));
run(s, "solid.box", json!({"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}));
run(s, "solid.cylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
run(s, "FusionCreateNewComponentCommand", json!({"name": name}));
run(s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}));
run(s, "PrimitiveCylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
}
#[test]
@@ -43,7 +43,7 @@ fn revolute_hinge_turns_about_the_knuckle() {
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
let r = run(
&mut s,
"joint.create",
"JointAssembleCmdNew",
json!({
"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "flip": true, "name": "Hinge"}),
);
@@ -54,7 +54,7 @@ fn revolute_hinge_turns_about_the_knuckle() {
let dof = l["dof"].as_array().unwrap().iter().find(|d| d["occurrence"] == occ(&s, "LeafB")).unwrap()["dof"].clone();
assert_eq!(dof, 1, "{l}");
// Drive 90°: B turns about the knuckle axis (through (0, 33, 4), along the frame's z = −X).
run(&mut s, "joint.drive", json!({"joint": "Hinge", "value": 90}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Hinge", "value": 90}));
let want = solvecraft_doc::rigid(Vec3::ZERO, Vec3::new(0.0, 33.0, 4.0), Vec3::new(-1.0, 0.0, 0.0), FRAC_PI_2);
let p = Vec3::new(20.0, 0.0, 0.0);
assert!(near(place(&s, "LeafB", p), solvecraft_doc::apply_point(&want, p)), "{:?}", place(&s, "LeafB", p));
@@ -63,9 +63,9 @@ fn revolute_hinge_turns_about_the_knuckle() {
assert!(near(place(&s, "LeafA", p), p));
assert!(near(place(&s, "LeafB", Vec3::new(10.0, 33.0, 4.0)), Vec3::new(10.0, 33.0, 4.0)));
// Expressions drive too, and undo puts it back.
run(&mut s, "joint.drive", json!({"joint": "Hinge", "value": "pi rad"}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Hinge", "value": "pi rad"}));
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 66.0, 8.0)));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 29.0, 37.0)));
}
@@ -74,8 +74,8 @@ fn two_cubes() -> Session {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": name}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
}
let o = occ(&s, "A");
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
@@ -88,19 +88,19 @@ fn slider_limits_clamp() {
// B's bottom face onto A's top face (faces meet), sliding along their common normal.
let r = run(
&mut s,
"joint.create",
"JointAssembleCmdNew",
json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "limits": [[0, 10]], "name": "Rail"}),
);
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
// B now sits on A.
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 0.0)));
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 25}));
let r = run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 25}));
assert_eq!(r["values"], json!([10.0]), "{r}");
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 20.0)));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)));
// Changing the limits clamps the current value.
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 8}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 8}));
run(&mut s, "joint.limits", json!({"joint": "Rail", "min": 0, "max": 4}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 14.0)));
// Joints follow edits: A gets taller, B rides on its new top.
@@ -111,7 +111,7 @@ fn slider_limits_clamp() {
.find(|f| matches!(f.kind, solvecraft_doc::FeatureKind::Box { .. }))
.and_then(|f| f.param_names.get(2).cloned())
.unwrap_or_default();
run(&mut s, "parameters.change", json!({"name": h, "expression": "15"}));
run(&mut s, "ChangeParameterCommand", json!({"name": h, "expression": "15"}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 19.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 0.0)));
}
@@ -119,12 +119,12 @@ fn slider_limits_clamp() {
fn rigid_cycles_solve_and_conflicts_report() {
let mut s = two_cubes();
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "C"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0]}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "C"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
// A rigid group and a closing as-built joint: a consistent loop.
run(&mut s, "joint.rigid_group", json!({"occurrences": ["A", "B", "C"]}));
let r = run(&mut s, "joint.as_built", json!({"type": "rigid", "a": "B", "b": "C"}));
run(&mut s, "RigidGroupCmd", json!({"occurrences": ["A", "B", "C"]}));
let r = run(&mut s, "JointAsBuiltCmd", json!({"type": "rigid", "a": "B", "b": "C"}));
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
assert!(near(place(&s, "C", Vec3::new(40.0, 0.0, 0.0)), Vec3::new(40.0, 0.0, 0.0)));
let l = run(&mut s, "joint.list", json!({}));
@@ -132,7 +132,7 @@ fn rigid_cycles_solve_and_conflicts_report() {
// A joint that wants C elsewhere conflicts and says so.
let r = run(
&mut s,
"joint.create",
"JointAssembleCmdNew",
json!({"type": "rigid", "a": {"occurrence": "A", "point": [0, 0, 0]}, "b": {"occurrence": "C", "point": [40, 0, 0]}, "name": "Wrong"}),
);
let c = r["conflicts"].as_array().cloned().unwrap_or_default();
@@ -143,37 +143,37 @@ fn rigid_cycles_solve_and_conflicts_report() {
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0));
assert!(
s.execute(
"joint.create",
"JointAssembleCmdNew",
&json!({"type": "rigid", "a": {"occurrence": "A", "point": [0, 0, 0]}, "b": {"occurrence": "A", "point": [1, 0, 0]}})
)
.is_err()
);
assert!(s.execute("joint.create", &json!({"type": "hinge"})).is_err());
assert!(s.execute("JointAssembleCmdNew", &json!({"type": "hinge"})).is_err());
}
#[test]
fn motion_links_and_interference() {
let mut s = two_cubes();
run(&mut s, "joint.create", json!({"type": "cylindrical", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Screw"}));
run(&mut s, "joint.drive", json!({"joint": "Screw", "values": [0, 0]}));
run(&mut s, "JointAssembleCmdNew", json!({"type": "cylindrical", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Screw"}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Screw", "values": [0, 0]}));
// No overlap while B sits on A; then push B 4 mm into A.
let i = run(&mut s, "inspect.interference", json!({}));
let i = run(&mut s, "InterferenceCheckCommand", json!({}));
assert_eq!(i["count"], 0, "{i}");
run(&mut s, "joint.drive", json!({"joint": "Screw", "values": [0, -4]}));
let i = run(&mut s, "inspect.interference", json!({}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Screw", "values": [0, -4]}));
let i = run(&mut s, "InterferenceCheckCommand", json!({}));
assert_eq!(i["count"], 1, "{i}");
assert!((i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0) - 400.0).abs() < 1e-3, "{i}");
// A motion link: a second joint's value follows.
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "D"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0]}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "D"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0]}));
run(
&mut s,
"joint.create",
"JointAssembleCmdNew",
json!({"type": "slider", "a": {"occurrence": "A", "face": [5, 5, 10]}, "b": {"occurrence": "D", "face": [65, 5, 0]}, "name": "Follower"}),
);
run(&mut s, "joint.motion_link", json!({"a": "Screw", "ia": 1, "b": "Follower", "ratio": 2}));
run(&mut s, "joint.drive", json!({"joint": "Screw", "values": [0, 3]}));
run(&mut s, "FusionMotionRelationshipCommand", json!({"a": "Screw", "ia": 1, "b": "Follower", "ratio": 2}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Screw", "values": [0, 3]}));
assert!(near(place(&s, "D", Vec3::new(65.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 16.0)), "{:?}", place(&s, "D", Vec3::new(65.0, 5.0, 0.0)));
// Saved and reopened, joints come back.
let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
@@ -188,7 +188,7 @@ fn motion_links_and_interference() {
#[test]
fn faces_mate_by_default() {
let mut s = two_cubes();
run(&mut s, "joint.create", json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Stack"}));
run(&mut s, "JointAssembleCmdNew", json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Stack"}));
// B's bottom centre (25, 5, 0) at A's top centre, B above it.
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 20.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0)));
@@ -1,65 +0,0 @@
//! Delete Face and shells/offsets on cones and spheres, through the engine.
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
}
/// A box with a drilled hole (cone tip) shells: the cavity is the box shrunk by the wall and the
/// hole grown by it (its cone slid down its axis), measured against that analytic volume.
#[test]
fn shell_a_box_with_a_drilled_hole() {
use std::f64::consts::PI;
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.hole", json!({"position": [20, 15, 20], "diameter": 8, "depth": 10, "type": "drilled", "tip_angle": 118}));
let v_body = volume(&mut s);
let (r, t) = (4.0, 2.0);
let a = (59.0f64).to_radians();
// Cylinder depth from the measured body: 24000 − π r² h − π r² (r / tan α) / 3.
let h = (24000.0 - v_body - PI * r * r * (r / a.tan()) / 3.0) / (PI * r * r);
assert!(h > 9.0 && h < 11.0, "{h}");
run(&mut s, "solid.shell", json!({"faces": [[5, 5, 20]], "thickness": t}));
let v_shell = volume(&mut s);
let apex = 20.0 - h - r / a.tan() - t / a.sin();
let big_r = r + t;
let joint = apex + big_r / a.tan();
let grown = PI * big_r * big_r * (20.0 - joint) + PI * big_r * big_r * (joint - apex) / 3.0;
let cavity = 36.0 * 26.0 * 18.0 - grown;
let want = v_body - cavity;
assert!((v_shell - want).abs() < 2e-4 * want, "{v_shell} vs {want}");
}
/// Offset Face on a cone and a sphere move them exactly.
#[test]
fn offset_cone_and_sphere_faces() {
use std::f64::consts::PI;
let mut s = Session::default();
run(&mut s, "solid.sphere", json!({"center": [0, 0, 0], "radius": 10}));
let r = run(&mut s, "solid.offset_face", json!({"faces": [[10, 0, 0]], "distance": 2}));
let v = volume(&mut s);
assert!((v - 4.0 / 3.0 * PI * 1728.0).abs() < 1e-3 * v, "{v} {r}");
}
/// Offset Face on a torus changes its tube radius and keeps the major radius.
#[test]
fn offset_torus_face() {
use std::f64::consts::PI;
let mut s = Session::default();
run(&mut s, "solid.torus", json!({"major": 20, "minor": 5}));
let v0 = volume(&mut s);
assert!((v0 - 2.0 * PI * PI * 20.0 * 25.0).abs() < 1e-3 * v0, "{v0}");
run(&mut s, "solid.offset_face", json!({"faces": [[25, 0, 0]], "distance": 1}));
let v = volume(&mut s);
assert!((v - 2.0 * PI * PI * 20.0 * 36.0).abs() < 1e-3 * v, "{v}");
}
+13 -52
View File
@@ -1,4 +1,4 @@
//! Measuring picked geometry (`inspect.measure` with `items`): one item gives its length, area,
//! Measuring picked geometry (`MeasureCommand` with `items`): one item gives its length, area,
//! volume or position; two give the minimum distance between them (with the two closest
//! points) and, where both have a direction (planar faces, straight edges), the angle.
@@ -55,13 +55,13 @@ fn densify(pts: &[Vec3]) -> Vec<Vec3> {
/// The mesh edge a selection means: by index if it still passes through the point, otherwise
/// the nearest.
fn edge_index(m: &Mesh, index: usize, point: Vec3) -> Option<usize> {
fn edge_points(m: &Mesh, index: usize, point: Vec3) -> Option<Vec<Vec3>> {
let near = |e: &Vec<Vec3>| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
let tol = m.bounds().diagonal().max(1.0) * 1e-6;
if m.edges.get(index).is_some_and(|e| near(e) < tol) {
return Some(index);
if let Some(e) = m.edges.get(index).filter(|e| near(e) < tol) {
return Some(e.clone());
}
m.edges.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b))).map(|(i, _)| i)
m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).cloned()
}
fn face_tris(m: &Mesh, f: usize) -> Vec<[Vec3; 3]> {
@@ -75,23 +75,15 @@ fn describe(s: &Session, sel: &Sel) -> Option<(Geo, Option<Vec3>, Value)> {
Sel::Vertex { point, .. } => (Geo::Points(vec![*point]), None, json!({"type": "vertex", "position": point})),
Sel::Edge { body, index, point } => {
let m = st.body(body)?.mesh();
let index = edge_index(&m, *index, *point)?;
let pts = m.edges.get(index)?;
let rim = m.circular_rim(index);
if rim.len() > 1 {
let pieces: Vec<&Vec<Vec3>> = rim.iter().filter_map(|&i| m.edges.get(i)).collect();
let len: f64 = pieces.iter().map(|p| polyline_len(p)).sum();
let points = pieces.iter().flat_map(|p| densify(p)).collect();
return Some((Geo::Points(points), None, json!({"type": "edge", "length_mm": len})));
}
let pts = edge_points(&m, *index, *point)?;
let (a, b) = (*pts.first()?, *pts.last()?);
let straight = (b - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(b))));
let len = polyline_len(pts);
(Geo::Points(densify(pts)), straight, json!({"type": "edge", "length_mm": len}))
let len = polyline_len(&pts);
(Geo::Points(densify(&pts)), straight, json!({"type": "edge", "length_mm": len}))
}
Sel::Face { body, index, .. } => {
let m = st.body(body)?.mesh();
let tris = m.surface_patch(*index).into_iter().flat_map(|f| face_tris(&m, f)).collect::<Vec<_>>();
let tris = face_tris(&m, *index);
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
let normals: Vec<Vec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect();
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
@@ -154,45 +146,14 @@ fn closest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> {
}
}
/// `inspect.measure` with `items` (one or two selections).
/// `MeasureCommand` with `items` (one or two selections).
pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
let cmd = "inspect.measure";
let cmd = "MeasureCommand";
let list = items.as_array().ok_or_else(|| bad(cmd, "`items` must be a list of selections"))?;
if list.is_empty() || list.len() > 1000 {
if list.is_empty() || list.len() > 2 {
return Err(bad(cmd, "measure one or two items"));
}
let st = s.world_state();
let mut sels = Vec::new();
for value in list {
let mut sel = serde_json::from_value::<Sel>(value.clone()).map_err(|e| bad(cmd, e.to_string()))?;
// Two kernel halves of one visible item are one measurement target.
match &mut sel {
Sel::Edge { body, index, point } => {
if let Some(b) = st.body(body) {
let m = b.mesh();
let resolved = edge_index(&m, *index, *point).unwrap_or(*index);
*index = m.circular_rim(resolved).first().copied().unwrap_or(resolved);
}
}
Sel::Face { body, index, .. } => {
if let Some(b) = st.body(body) {
*index = b.mesh().surface_patch(*index).first().copied().unwrap_or(*index);
}
}
_ => {}
}
let duplicate = sels.iter().any(|x| match (x, &sel) {
(Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. })
| (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j,
_ => false,
});
if !duplicate {
sels.push(sel);
}
if sels.len() > 2 {
return Err(bad(cmd, "measure one or two items"));
}
}
let sels: Vec<Sel> = list.iter().map(|v| serde_json::from_value::<Sel>(v.clone()).map_err(|e| bad(cmd, e.to_string()))).collect::<Result<_>>()?;
let mut described = Vec::new();
for x in &sels {
described.push(describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces or bodies)"))?);
+3 -13
View File
@@ -1,11 +1,10 @@
//! The command registry. Ids are SolveCraft's own dotted ids (`solid.extrude`,
//! `sketch.rectangle.two_point`); the toolbar placement (`tab`, `panel`) follows the familiar
//! Design workspace layout. Ids used before the rename still resolve (`legacy_ids`).
//! The command registry. Ids follow Fusion's command ids where Fusion has the command; the
//! toolbar placement (`tab`, `panel`) follows Fusion's Design workspace so the registry doubles
//! as the parity metric (`cargo xtask parity`).
mod browser;
mod clipboard;
mod component;
mod construct;
mod delete;
mod edit;
mod face;
@@ -18,11 +17,8 @@ mod inspect;
pub(crate) mod joints;
mod measure_sel;
pub(crate) mod motion;
mod parts;
mod section;
pub(crate) use measure_sel::measure_items;
#[cfg(test)]
mod kernel_heal_tests;
mod parameters;
mod plastic;
mod sheet;
@@ -36,7 +32,6 @@ mod sketch_import;
mod sketch_inspect;
mod sketch_modify;
mod sketch_project;
mod surface;
mod surface_analysis;
use serde::Serialize;
@@ -141,19 +136,16 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(sketch_canvas::COMMANDS.iter());
v.extend(sketch_3d::COMMANDS.iter());
v.extend(surface_analysis::COMMANDS.iter());
v.extend(surface::COMMANDS.iter());
v.extend(sketch_modify::COMMANDS.iter());
v.extend(sketch_project::COMMANDS.iter());
v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter());
v.extend(construct::COMMANDS.iter());
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
v.extend(component::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(config::COMMANDS.iter());
v.extend(parts::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
@@ -167,7 +159,5 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
let all = command_specs();
// Ids from before the rename still find their command.
let id = crate::legacy_ids::current_id(id);
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
}
+8 -10
View File
@@ -13,22 +13,22 @@ use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
CommandSpec::new("EnableContactSetsCmd", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
"enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other",
),
CommandSpec::new("contact.enable_all", "Enable All Contact", enable_all)
CommandSpec::new("EnableAllContactCmd", "Enable All Contact", enable_all)
.at("SOLID", "ASSEMBLE")
.icon("joint")
.params("every occurrence takes part in contact (one set of all)"),
CommandSpec::new("contact.disable_all", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("joint").params("contact off"),
CommandSpec::new("contact.create", "New Contact Set", new_set)
CommandSpec::new("DisableAllContactCmd", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("joint").params("contact off"),
CommandSpec::new("ContactSetCmd", "New Contact Set", new_set)
.at("SOLID", "ASSEMBLE")
.icon("joint")
.params("occurrences: [ids or names] (two or more); name?"),
CommandSpec::new("contact.edit", "Edit Contact Set", edit_set).params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"),
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().params("→ sets, enabled, all"),
CommandSpec::new("contact.check", "Check Contact", check).noundo().params("→ pairs in contact sets that pass through each other now"),
CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params(
CommandSpec::new("FusionMotionStudyCommand", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params(
"name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \
(values: degrees for angles, mm for distances, or expressions)",
),
@@ -179,8 +179,6 @@ fn deepest(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, tol: f64) -> f6
/// Ray parity along a skew direction (no axis-aligned edge hits).
fn inside(p: Vec3, tris: &[[Vec3; 3]]) -> bool {
// Fixed ray coordinates, not an approximation used to compute Euler's constant.
#[allow(clippy::approx_constant)]
let dir = Vec3::new(0.577_215_664_9, 0.327_118_441_9, 0.751_891_438_7);
let mut n = 0;
for [a, b, c] in tris {
@@ -287,7 +285,7 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)]) -> Result<Value
Ok(json!({"values": values, "stopped_by_contact": stopped, "conflicts": sol.conflicts.len()}))
}
/// Drive one joint (joint.drive), honouring contact sets.
/// Drive one joint (FusionMoveJointsCommand), honouring contact sets.
pub fn drive_joint(s: &mut Session, id: u64, q: Vec<f64>) -> Result<Value> {
drive_with_contact(s, &[(id, q)])
}
@@ -313,7 +311,7 @@ fn disable_all(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn new_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "contact.create";
let cmd = "ContactSetCmd";
let occ = occurrences(s, p, cmd)?;
if occ.len() < 2 {
return Err(bad(cmd, "a contact set needs two or more occurrences"));
@@ -389,7 +387,7 @@ fn study_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
}
fn study(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "motion.study";
let cmd = "FusionMotionStudyCommand";
let existing = if p.get("study").is_some() { Some(study_index(s, p, cmd)?) } else { None };
let steps = match p.get("steps") {
Some(v) => {
+24 -23
View File
@@ -26,14 +26,14 @@ fn rail() -> Session {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": name}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
}
run(&mut s, "component.activate", json!({"component": "root"}));
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, "JointAssembleCmdNew", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
assert!((b_x(&s) - 20.0).abs() < 1e-6, "{}", b_x(&s));
s
}
@@ -42,44 +42,45 @@ fn rail() -> Session {
fn contact_sets_stop_a_driven_joint_at_touching() {
let mut s = rail();
// No contact: B passes into A.
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 5.0).abs() < 1e-6);
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
run(&mut s, "ContactSetCmd", json!({"occurrences": ["A", "B"]}));
let r = run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
assert!(r["stopped_by_contact"].is_object(), "{r}");
// Stopped where the faces touch (to the search's resolution, 15 mm / 2^14).
assert!((b_x(&s) - 10.0).abs() < 2e-3, "{}", b_x(&s));
assert!(b_x(&s) >= 10.0 - 2e-3, "a touch, not a pass");
assert_eq!(run(&mut s, "contact.check", json!({}))["collisions"].as_array().map(Vec::len), Some(0));
// Away is free; contact off lets it through again.
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 3}));
let r = run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 3}));
assert!(r["stopped_by_contact"].is_null() && (b_x(&s) - 13.0).abs() < 1e-6, "{r}");
run(&mut s, "contact.disable_all", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
run(&mut s, "DisableAllContactCmd", json!({}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 5.0).abs() < 1e-6);
// Suppressed sets don't count; Enable All Contact puts every occurrence in one.
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.enable_sets", json!({}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
run(&mut s, "EnableContactSetsCmd", json!({}));
run(&mut s, "contact.edit", json!({"set": "Contact Set1", "suppressed": true}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 5.0).abs() < 1e-6);
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.enable_all", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
run(&mut s, "EnableAllContactCmd", json!({}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 10.0).abs() < 2e-3);
assert!(s.execute("contact.create", &json!({"occurrences": ["A"]})).is_err());
assert!(s.execute("ContactSetCmd", &json!({"occurrences": ["A"]})).is_err());
}
#[test]
fn motion_study_plays_and_exports() {
let mut s = rail();
let r = run(&mut s, "motion.study", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]}));
let r =
run(&mut s, "FusionMotionStudyCommand", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]}));
assert_eq!(r["keys"], 1);
run(&mut s, "motion.play", json!({"study": "Push", "step": 50}));
assert!((b_x(&s) - 12.5).abs() < 1e-6, "{}", b_x(&s));
// With contact, the end of the study stops at touching.
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
run(&mut s, "ContactSetCmd", json!({"occurrences": ["A", "B"]}));
let r = run(&mut s, "motion.play", json!({"study": "Push", "step": 100}));
assert!(r["stopped_by_contact"].is_object() && (b_x(&s) - 10.0).abs() < 2e-3, "{r}");
// Export: positions per sample; the design keeps its joint value.
@@ -103,7 +104,7 @@ fn motion_study_plays_and_exports() {
let text = std::fs::read_to_string(&csv).unwrap();
assert!(text.starts_with("step,occurrence,x,y,z") && text.lines().count() == 1 + 3 * 2, "{text}");
let _ = std::fs::remove_dir_all(&dir);
assert!(s.execute("motion.study", &json!({"keys": [{"joint": "Rail", "index": 3, "points": [[0, 1]]}]})).is_err());
assert!(s.execute("FusionMotionStudyCommand", &json!({"keys": [{"joint": "Rail", "index": 3, "points": [[0, 1]]}]})).is_err());
run(&mut s, "motion.delete", json!({"study": "Push"}));
assert_eq!(run(&mut s, "motion.list", json!({}))["studies"].as_array().map(Vec::len), Some(0));
}
@@ -116,7 +117,7 @@ fn exploded_views_show_without_changing_the_design() {
assert_eq!(r["moves"].as_array().map(Vec::len), Some(2), "{r}");
run(&mut s, "explode.show", json!({"view": "Apart"}));
// Centres at x 5 and 25 → middle 15: each moves 10 mm further out.
let bb = |s: &mut Session, b: &str| run(s, "inspect.measure", json!({"bodies": [b]}))["bodies"][0]["bbox"]["min"][0].as_f64().unwrap_or(f64::NAN);
let bb = |s: &mut Session, b: &str| run(s, "MeasureCommand", json!({"bodies": [b]}))["bodies"][0]["bbox"]["min"][0].as_f64().unwrap_or(f64::NAN);
let names: Vec<String> = s.world_state().bodies.iter().map(|b| b.name.clone()).collect();
assert!((bb(&mut s, &names[0]) + 10.0).abs() < 1e-6, "{names:?}");
assert!((bb(&mut s, &names[1]) - 30.0).abs() < 1e-6);
@@ -126,7 +127,7 @@ fn exploded_views_show_without_changing_the_design() {
run(&mut s, "explode.create", json!({"name": "Hand", "moves": [{"occurrence": "B", "translate": [0, 0, 50]}]}));
run(&mut s, "explode.show", json!({"view": "Hand"}));
assert!(
(run(&mut s, "inspect.measure", json!({"bodies": [names[1]]}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 50.0).abs() < 1e-6
(run(&mut s, "MeasureCommand", json!({"bodies": [names[1]]}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 50.0).abs() < 1e-6
);
assert_eq!(run(&mut s, "explode.list", json!({}))["views"].as_array().map(Vec::len), Some(2));
}
+7 -9
View File
@@ -10,10 +10,9 @@ use crate::params::{bad, bool_, str_};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
CommandSpec::new("ChangeParameterCommand", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\" (new parameters default to their value's unit), comment?, \
favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \
restored: features whose earlier result for the same inputs was reused (e.g. a value set back)",
favorite?: bool, new_name?: rename (references follow); or delete: name",
),
CommandSpec::new("parameters.list", "List Parameters", list).noundo().params(
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
@@ -66,7 +65,7 @@ fn known(s: &Session, name: &str) -> bool {
}
fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parameters.change";
let cmd = "ChangeParameterCommand";
if let Some(n) = str_(p, "delete") {
s.doc_mut().remove_param(n.trim())?;
return Ok(json!({"deleted": n.trim()}));
@@ -108,7 +107,6 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
out["value"] = json!(vals.get(&name).map(|v| v.v));
out["display_value"] = json!(s.doc.param_display_value(&vals, &name));
out["recomputed"] = json!(s.model.last_recomputed);
out["restored"] = json!(s.model.last_restored);
out["errors"] = json!(failing(s));
Ok(out)
}
@@ -221,7 +219,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
other => return Err(bad(cmd, format!("unknown format `{other}` (csv or json)"))),
};
if let Some(path) = path {
solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
}
Ok(json!({"format": format, "path": path, "text": text}))
}
@@ -231,11 +229,11 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let text = match (str_(p, "text"), str_(p, "path")) {
(Some(t), _) => t.to_string(),
(None, Some(path)) => {
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta > 16 << 20 {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > 16 << 20 {
return Err(bad(cmd, "file too large"));
}
solvecraft_io::vfs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
std::fs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
}
_ => return Err(bad(cmd, "give `text` or `path`")),
};
+21 -40
View File
@@ -17,7 +17,7 @@ fn err(s: &mut Session, id: &str, p: Value) -> String {
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -36,13 +36,13 @@ fn expr_of(s: &mut Session, name: &str) -> String {
/// A sketched block 40 x (len / 2) x 10 whose width is an expression of a user parameter.
fn block(s: &mut Session) {
run(s, "parameters.add", json!({"name": "len", "expression": "60 mm", "comment": "overall length", "favorite": true}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": 40}));
run(s, "sketch.dimension", json!({"entities": ["l2"], "value": "len/2"}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 10, "body_name": "Block"}));
run(s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "ConstraintCoincident", json!({"a": "p1", "b": "origin"}));
run(s, "SketchDimension", json!({"entities": ["l1"], "value": 40}));
run(s, "SketchDimension", json!({"entities": ["l2"], "value": "len/2"}));
run(s, "SketchStop", json!({}));
run(s, "Extrude", json!({"distance": 10, "body_name": "Block"}));
}
#[test]
@@ -50,42 +50,23 @@ fn width_follows_an_expression() {
let mut s = Session::default();
block(&mut s);
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
let r = run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "100 mm"}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
assert!(rel(volume(&mut s), 40.0 * 50.0 * 10.0) < 1e-9);
// Units convert: 4 in = 101.6 mm.
run(&mut s, "parameters.change", json!({"name": "len", "expression": "4 in"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "4 in"}));
assert!(rel(volume(&mut s), 40.0 * 50.8 * 10.0) < 1e-9);
// A parameter in inches reads bare numbers as inches.
run(&mut s, "parameters.change", json!({"name": "len", "expression": "5", "unit": "in"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "5", "unit": "in"}));
assert!(rel(volume(&mut s), 40.0 * 63.5 * 10.0) < 1e-9);
let l = run(&mut s, "parameters.list", json!({}));
assert_eq!(row(&l, "len")["value"], 5.0, "{l}");
assert_eq!(row(&l, "len")["unit"], "in");
// A failing parameter fails the features using it, with its error.
let e = err(&mut s, "parameters.change", json!({"name": "len", "expression": "5 deg"}));
let e = err(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "5 deg"}));
assert!(e.contains("expected a length, got an angle"), "{e}");
}
/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the
/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken
/// for "nothing changed". timeline.edit reports the same.
#[test]
fn setting_a_parameter_back_reports_restored_features() {
let mut s = Session::default();
block(&mut s);
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}");
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}");
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "10"}}}));
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(1)), "{r}");
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
}
#[test]
fn feature_inputs_are_named_and_editable() {
let mut s = Session::default();
@@ -94,14 +75,14 @@ fn feature_inputs_are_named_and_editable() {
let dist = l["parameters"].as_array().unwrap().iter().find(|r| r["input"] == "Distance").unwrap().clone();
assert_eq!(dist["feature"], "Extrude1", "{l}");
let d = dist["name"].as_str().unwrap().to_string();
run(&mut s, "parameters.change", json!({"name": d, "expression": "len / 3"}));
run(&mut s, "ChangeParameterCommand", json!({"name": d, "expression": "len / 3"}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 20.0) < 1e-9);
// Editing the feature keeps its parameter name.
run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "12 mm"}}}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 12.0) < 1e-9);
assert_eq!(expr_of(&mut s, &d), "12 mm");
// Another feature can use it.
run(&mut s, "solid.box", json!({"length": d, "width": 5, "height": 5, "corner": [100, 0, 0]}));
run(&mut s, "PrimitiveBox", json!({"length": d, "width": 5, "height": 5, "corner": [100, 0, 0]}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 12.0 + 12.0 * 25.0) < 1e-9);
let u = run(&mut s, "parameters.users", json!({"name": d}));
assert_eq!(u["users"].as_array().map(Vec::len), Some(1), "{u}");
@@ -120,7 +101,7 @@ fn unit_errors_and_cycles_are_refused() {
assert!(e.contains("expected an angle, got a length"), "{e}");
assert!(err(&mut s, "parameters.add", json!({"name": "x", "expression": "1", "unit": "furlong"})).contains("unknown unit"));
run(&mut s, "parameters.add", json!({"name": "b", "expression": "a * 2"}));
let e = err(&mut s, "parameters.change", json!({"name": "a", "expression": "b + 1"}));
let e = err(&mut s, "ChangeParameterCommand", json!({"name": "a", "expression": "b + 1"}));
assert!(e.contains("circular reference: a → b → a"), "{e}");
assert!(err(&mut s, "parameters.add", json!({"name": "selfref", "expression": "selfref"})).contains("circular"));
assert!(err(&mut s, "parameters.add", json!({"name": "a", "expression": "1"})).contains("already"));
@@ -151,7 +132,7 @@ fn rename_updates_every_reference() {
let l = run(&mut s, "parameters.list", json!({"favorites": true}));
assert_eq!(l["count"], 1, "the favourite follows the rename");
assert_eq!(row(&l, "length")["comment"], "overall length");
run(&mut s, "parameters.change", json!({"name": "length", "expression": "80"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "length", "expression": "80"}));
assert!(rel(volume(&mut s), 40.0 * 40.0 * 10.0) < 1e-9);
// Model and feature parameters can be renamed too: dimensions and features follow.
let dim = s.doc.features.first().and_then(|f| match &f.kind {
@@ -159,17 +140,17 @@ fn rename_updates_every_reference() {
_ => None,
});
let dim = dim.unwrap_or_default();
run(&mut s, "parameters.change", json!({"name": dim, "new_name": "base_w", "expression": "50"}));
run(&mut s, "ChangeParameterCommand", json!({"name": dim, "new_name": "base_w", "expression": "50"}));
assert!(rel(volume(&mut s), 50.0 * 40.0 * 10.0) < 1e-9);
let ext = s.doc.features.get(1).and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "parameters.rename", json!({"name": ext, "new_name": "thick"}));
run(&mut s, "parameters.change", json!({"name": "thick", "expression": "base_w / 10"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "thick", "expression": "base_w / 10"}));
assert!(rel(volume(&mut s), 50.0 * 40.0 * 5.0) < 1e-9);
assert!(err(&mut s, "parameters.rename", json!({"name": "twice", "new_name": "length"})).contains("already"));
assert!(err(&mut s, "parameters.rename", json!({"name": "nope", "new_name": "x"})).contains("unknown"));
// Undo puts the old name back.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert!(s.doc.features.get(1).is_some_and(|f| f.param_names.first() == Some(&ext)));
}
@@ -184,7 +165,7 @@ fn deleting_a_used_parameter_lists_its_users() {
assert_eq!(u["users"].as_array().map(Vec::len), Some(2), "{u}");
assert_eq!(u["drives"][0]["name"], "Base", "{u}");
let dim = u["users"].as_array().unwrap().iter().find(|x| x["name"] != "spare").unwrap()["name"].as_str().unwrap().to_string();
let e = err(&mut s, "parameters.change", json!({"delete": dim}));
let e = err(&mut s, "ChangeParameterCommand", json!({"delete": dim}));
assert!(e.contains("Base dimension"), "{e}");
run(&mut s, "parameters.delete", json!({"name": "spare"}));
assert!(s.doc.param("spare").is_none());
-518
View File
@@ -1,518 +0,0 @@
//! Standard parts: ISO metric fasteners, dowel pins and ball bearings, generated in code from
//! the dimensions in the public standards tables (no vendor geometry). Each part is inserted as
//! its own component, built by ordinary features (so it stays parametric and editable), with a
//! cosmetic thread where it has one; at a hole it gets a rigid joint that puts its axis on the
//! hole's and seats it on the face.
//!
//! Part frame: the seating plane is z = 0 (the underside of a head, the bottom of a nut or
//! washer, the top of a set screw or pin); the shank runs down −z.
use serde_json::{Value, json};
use solvecraft_geom::Vec3;
use super::CommandSpec;
use crate::params::{bad, num, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("parts.library", "Standard Parts", library)
.noundo()
.params("family? → families (name, standard, sizes, lengths) and, with family, each size's dimensions (mm)"),
CommandSpec::new("parts.fastener", "Insert Fastener", insert).at("SOLID", "INSERT").icon("fastener").params(
"the Insert Part dialog's command (same as parts.insert): family, size, length?, at?|point?+direction?, name?",
),
CommandSpec::new("parts.insert", "Insert Part", insert).icon("fastener").params(
"family (socket_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing); size (\"M6\", or \"608\" for bearings); \
length? (mm; screws, bolts, set screws, pins; default a standard length near 2.5 d); \
at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint) | point?: [x,y,z] with direction?: [x,y,z] (no joint); name?",
),
CommandSpec::new("parts.resize", "Change Part Size", resize)
.params("component: id|name of an inserted part; size?; length? — rebuilds it (joints keep it seated)"),
];
/// A family: (id, name, standard).
const FAMILIES: [(&str, &str, &str); 7] = [
("socket_head_cap_screw", "Socket head cap screw", "ISO 4762"),
("hex_bolt", "Hex head bolt / screw", "ISO 4014 / ISO 4017"),
("hex_nut", "Hex nut", "ISO 4032"),
("washer", "Plain washer", "ISO 7089"),
("set_screw", "Hex socket set screw, flat point", "ISO 4026"),
("dowel_pin", "Parallel pin (dowel)", "ISO 8734"),
("bearing", "Deep groove ball bearing (envelope)", "ISO 15 (6000/6200/600 series)"),
];
/// ISO metric coarse: (size, d, pitch).
const METRIC: [(&str, f64, f64); 9] = [
("M3", 3.0, 0.5),
("M4", 4.0, 0.7),
("M5", 5.0, 0.8),
("M6", 6.0, 1.0),
("M8", 8.0, 1.25),
("M10", 10.0, 1.5),
("M12", 12.0, 1.75),
("M16", 16.0, 2.0),
("M20", 20.0, 2.5),
];
/// ISO 4762: (size, head diameter dk, head height k, socket s, socket depth t).
const SHCS: [(&str, f64, f64, f64, f64); 9] = [
("M3", 5.5, 3.0, 2.5, 1.3),
("M4", 7.0, 4.0, 3.0, 2.0),
("M5", 8.5, 5.0, 4.0, 2.5),
("M6", 10.0, 6.0, 5.0, 3.0),
("M8", 13.0, 8.0, 6.0, 4.0),
("M10", 16.0, 10.0, 8.0, 5.0),
("M12", 18.0, 12.0, 10.0, 6.0),
("M16", 24.0, 16.0, 14.0, 8.0),
("M20", 30.0, 20.0, 17.0, 10.0),
];
/// ISO 4014/4017 hex head: (size, across flats s, head height k).
const HEX_BOLT: [(&str, f64, f64); 9] = [
("M3", 5.5, 2.0),
("M4", 7.0, 2.8),
("M5", 8.0, 3.5),
("M6", 10.0, 4.0),
("M8", 13.0, 5.3),
("M10", 16.0, 6.4),
("M12", 18.0, 7.5),
("M16", 24.0, 10.0),
("M20", 30.0, 12.5),
];
/// ISO 4032: (size, across flats s, height m).
const HEX_NUT: [(&str, f64, f64); 9] = [
("M3", 5.5, 2.4),
("M4", 7.0, 3.2),
("M5", 8.0, 4.7),
("M6", 10.0, 5.2),
("M8", 13.0, 6.8),
("M10", 16.0, 8.4),
("M12", 18.0, 10.8),
("M16", 24.0, 14.8),
("M20", 30.0, 18.0),
];
/// ISO 7089: (size, inner d1, outer d2, thickness h).
const WASHER: [(&str, f64, f64, f64); 9] = [
("M3", 3.2, 7.0, 0.5),
("M4", 4.3, 9.0, 0.8),
("M5", 5.3, 10.0, 1.0),
("M6", 6.4, 12.0, 1.6),
("M8", 8.4, 16.0, 1.6),
("M10", 10.5, 20.0, 2.0),
("M12", 13.0, 24.0, 2.5),
("M16", 17.0, 30.0, 3.0),
("M20", 21.0, 37.0, 3.0),
];
/// ISO 4026: (size, socket s, socket depth t).
const SET_SCREW: [(&str, f64, f64); 7] =
[("M3", 1.5, 1.2), ("M4", 2.0, 1.5), ("M5", 2.5, 2.0), ("M6", 3.0, 2.0), ("M8", 4.0, 3.0), ("M10", 5.0, 4.0), ("M12", 6.0, 4.8)];
/// ISO 8734 diameters.
const DOWEL: [f64; 9] = [2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0, 12.0, 16.0];
/// Bearings: (designation, bore, outside diameter, width).
const BEARINGS: [(&str, f64, f64, f64); 13] = [
("625", 5.0, 16.0, 5.0),
("626", 6.0, 19.0, 6.0),
("608", 8.0, 22.0, 7.0),
("6000", 10.0, 26.0, 8.0),
("6001", 12.0, 28.0, 8.0),
("6002", 15.0, 32.0, 9.0),
("6003", 17.0, 35.0, 10.0),
("6004", 20.0, 42.0, 12.0),
("6200", 10.0, 30.0, 9.0),
("6201", 12.0, 32.0, 10.0),
("6202", 15.0, 35.0, 11.0),
("6203", 17.0, 40.0, 12.0),
("6204", 20.0, 47.0, 14.0),
];
/// Standard lengths for screws, bolts and pins (mm).
const LENGTHS: [f64; 22] =
[4.0, 5.0, 6.0, 8.0, 10.0, 12.0, 16.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 65.0, 70.0, 80.0, 90.0, 100.0, 120.0];
fn metric(size: &str) -> Option<(f64, f64)> {
METRIC.iter().find(|(s, _, _)| s.eq_ignore_ascii_case(size)).map(|(_, d, p)| (*d, *p))
}
fn default_length(d: f64) -> f64 {
LENGTHS.iter().copied().find(|l| *l >= 2.5 * d).unwrap_or(100.0)
}
/// One part's shape, in its own frame.
#[derive(Clone, Debug, PartialEq)]
enum Shape {
/// A head (round or hex), a shank down −z, an optional hex socket in the head's top.
Screw { d: f64, pitch: f64, len: f64, head: Head, socket: Option<(f64, f64)> },
/// A hex prism with a through hole (nut), z 0…m.
Nut { d: f64, pitch: f64, s: f64, m: f64 },
/// A ring (washer, bearing envelope), z 0…h.
Ring { d_in: f64, d_out: f64, h: f64 },
/// A cylinder down −z with a socket in its top (set screw), or plain (pin).
Rod { d: f64, pitch: Option<f64>, len: f64, socket: Option<(f64, f64)> },
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Head {
Round { dk: f64, k: f64 },
Hex { s: f64, k: f64 },
}
fn shape(family: &str, size: &str, length: Option<f64>) -> std::result::Result<Shape, String> {
let need = |what: &str| format!("no {what} size `{size}` (parts.library lists them)");
let len = |d: f64| length.unwrap_or_else(|| default_length(d));
Ok(match family {
"socket_head_cap_screw" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, dk, k, s, t) = SHCS.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4762"))?;
Shape::Screw { d, pitch, len: len(d), head: Head::Round { dk, k }, socket: Some((s, t)) }
}
"hex_bolt" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, s, k) = HEX_BOLT.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4014"))?;
Shape::Screw { d, pitch, len: len(d), head: Head::Hex { s, k }, socket: None }
}
"hex_nut" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, s, m) = HEX_NUT.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4032"))?;
Shape::Nut { d, pitch, s, m }
}
"washer" => {
let (_, d1, d2, h) = WASHER.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 7089"))?;
Shape::Ring { d_in: d1, d_out: d2, h }
}
"set_screw" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, s, t) = SET_SCREW.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4026"))?;
Shape::Rod {
d,
pitch: Some(pitch),
len: length.unwrap_or_else(|| LENGTHS.iter().copied().find(|l| *l >= d).unwrap_or(d)),
socket: Some((s, t)),
}
}
"dowel_pin" => {
let d: f64 = size.trim_start_matches(['d', 'D', 'Ø']).trim().parse().map_err(|_| need("dowel pin"))?;
if !DOWEL.contains(&d) {
return Err(need("ISO 8734"));
}
Shape::Rod { d, pitch: None, len: len(d * 1.6), socket: None }
}
"bearing" => {
let (_, bore, od, w) = BEARINGS.iter().find(|r| r.0 == size.trim()).copied().ok_or_else(|| need("bearing"))?;
Shape::Ring { d_in: bore, d_out: od, h: w }
}
o => return Err(format!("unknown part family `{o}` (parts.library lists them)")),
})
}
fn run(s: &mut Session, id: &str, p: Value) -> Result<Value> {
s.execute(id, &p)
}
/// A hex prism (across flats `af`) from z0 to z1, joined or cut.
fn hex(s: &mut Session, af: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
run(s, "sketch.create", json!({"plane": "XY", "offset": z0}))?;
run(s, "sketch.polygon.circumscribed", json!({"center": [0, 0], "radius": af / 2.0, "sides": 6}))?;
run(s, "sketch.finish", json!({}))?;
let (dist, dir) = if z1 >= z0 { (z1 - z0, "positive") } else { (z0 - z1, "negative") };
run(s, "solid.extrude", json!({"distance": dist, "direction": dir, "operation": op}))?;
Ok(())
}
fn cyl(s: &mut Session, d: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
run(s, "solid.cylinder", json!({"diameter": d, "height": z1 - z0, "base": [0, 0, z0], "axis": [0, 0, 1], "operation": op}))?;
Ok(())
}
/// A solid of revolution about z from a closed (r, z) outline, as one body or joined.
fn revolve(s: &mut Session, pts: &[(f64, f64)], op: &str) -> Result<()> {
run(s, "sketch.create", json!({"plane": "XZ"}))?;
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, -*z]).collect();
run(s, "sketch.line", json!({"points": points, "closed": true}))?;
run(s, "sketch.finish", json!({}))?;
run(s, "solid.revolve", json!({"axis": "y", "operation": op}))?;
Ok(())
}
fn thread(s: &mut Session, d: f64, pitch: f64, at: Vec3) {
let _ = run(s, "solid.thread", json!({"face": at, "designation": format!("M{}x{}", d, pitch)}));
let _ = d;
}
/// Build a part in the active component.
fn build(s: &mut Session, sh: &Shape) -> Result<()> {
match *sh {
Shape::Screw { d, pitch, len, head, socket } => {
let k = match head {
// One revolved outline: shank and round head.
Head::Round { dk, k } => {
revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, 0.0), (dk / 2.0, 0.0), (dk / 2.0, k), (0.0, k)], "new")?;
k
}
// A hex head joined over a shank that runs up into it.
Head::Hex { s: af, k } => {
hex(s, af, 0.0, k, "new")?;
revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, k / 2.0), (0.0, k / 2.0)], "join")?;
k
}
};
if let Some((af, t)) = socket {
hex(s, af, k, k - t, "cut")?;
}
thread(s, d, pitch, Vec3::new(d / 2.0, 0.0, -len / 2.0));
}
Shape::Nut { d, pitch, s: af, m } => {
hex(s, af, 0.0, m, "new")?;
cyl(s, d, -1.0, m + 1.0, "cut")?;
thread(s, d, pitch, Vec3::new(d / 2.0, 0.0, m / 2.0));
}
Shape::Ring { d_in, d_out, h } => {
revolve(s, &[(d_in / 2.0, 0.0), (d_out / 2.0, 0.0), (d_out / 2.0, h), (d_in / 2.0, h)], "new")?;
}
Shape::Rod { d, pitch, len, socket } => {
revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, 0.0), (0.0, 0.0)], "new")?;
if let Some((af, t)) = socket {
hex(s, af, 0.0, -t, "cut")?;
}
if let Some(p) = pitch {
thread(s, d, p, Vec3::new(d / 2.0, 0.0, -len / 2.0));
}
}
}
Ok(())
}
fn library(_s: &mut Session, p: &Value) -> Result<Value> {
let fam = |id: &str| -> Value {
let sizes: Vec<Value> = match id {
"socket_head_cap_screw" => SHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
"hex_bolt" => HEX_BOLT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "head_height": r.2})).collect(),
"hex_nut" => HEX_NUT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "height": r.2})).collect(),
"washer" => WASHER.iter().map(|r| json!({"size": r.0, "inner": r.1, "outer": r.2, "thickness": r.3})).collect(),
"set_screw" => SET_SCREW.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "socket": r.1, "socket_depth": r.2})).collect(),
"dowel_pin" => DOWEL.iter().map(|d| json!({"size": format!("{d}"), "d": d})).collect(),
"bearing" => BEARINGS.iter().map(|r| json!({"size": r.0, "bore": r.1, "outer": r.2, "width": r.3})).collect(),
_ => Vec::new(),
};
let (_, name, standard) = FAMILIES.iter().find(|f| f.0 == id).copied().unwrap_or(("", "", ""));
let lengths = matches!(id, "socket_head_cap_screw" | "hex_bolt" | "set_screw" | "dowel_pin").then_some(LENGTHS.to_vec());
json!({"family": id, "name": name, "standard": standard, "sizes": sizes, "lengths": lengths})
};
match str_(p, "family") {
Some(f) if FAMILIES.iter().any(|x| x.0 == f) => Ok(fam(f)),
Some(f) => Err(bad("parts.library", format!("unknown part family `{f}`"))),
None => Ok(json!({"families": FAMILIES.iter().map(|f| fam(f.0)).collect::<Vec<_>>()})),
}
}
fn part_name(family: &str, size: &str, sh: &Shape) -> String {
match sh {
Shape::Screw { len, .. } | Shape::Rod { len, .. } => format!("{} {size}x{len}", label(family)),
_ => format!("{} {size}", label(family)),
}
}
fn label(family: &str) -> &'static str {
match family {
"socket_head_cap_screw" => "SHCS",
"hex_bolt" => "Hex Bolt",
"hex_nut" => "Hex Nut",
"washer" => "Washer",
"set_screw" => "Set Screw",
"dowel_pin" => "Dowel Pin",
_ => "Bearing",
}
}
fn length_param(p: &Value, cmd: &str) -> Result<Option<f64>> {
match p.get("length") {
None | Some(Value::Null) => Ok(None),
Some(_) => {
let l = num(p, "length").filter(|l| l.is_finite() && *l > 0.0 && *l <= 2000.0).ok_or_else(|| bad(cmd, "`length` must be 0…2000 mm"))?;
Ok(Some(l))
}
}
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parts.insert (Insert Fastener)";
let family = str_(p, "family").ok_or_else(|| bad(cmd, "`family` is required (parts.library)"))?.to_string();
let size = str_(p, "size").ok_or_else(|| bad(cmd, "`size` is required"))?.to_string();
let sh = shape(&family, &size, length_param(p, cmd)?).map_err(|e| bad(cmd, e))?;
// A pick on a hole's rim also touches its end face: step onto the wall just inside the rim.
let at = p.get("at").and_then(vec3).map(|a| hole_wall(s, a).unwrap_or(a));
// The occurrence the hole belongs to, found before the part exists.
let host = match at {
Some(a) => Some(super::joints::occurrence_at(s, a).ok_or_else(|| bad(cmd, "no body at `at` (pick a hole's rim or wall)"))?),
None => None,
};
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| part_name(&family, &size, &sh));
let active = s.active_component;
let c = run(s, "component.create", json!({"name": name}))?;
let (comp, occ) = (c["component"].as_u64().unwrap_or(0), c["occurrence"].as_u64().unwrap_or(0));
build(s, &sh)?;
s.doc_mut().parts.insert(comp, solvecraft_doc::StandardPart { family: family.clone(), size: size.clone(), length: shape_len(&sh) });
run(s, "component.activate", json!({"component": active}))?;
let mut out = json!({"component": comp, "occurrence": occ, "name": name});
if let (Some(a), Some(h)) = (at, host) {
// Seat it: its axis on the hole's, its seating plane on the hole's face, the shank going
// into the hole (the joint mates the part's −z with the face's outward z).
let j = run(
s,
"joint.create",
json!({"type": "rigid", "a": {"occurrence": h, "circle": a}, "b": {"occurrence": occ, "point": [0, 0, 0], "z": [0, 0, -1]}, "name": format!("{name} seat")}),
)?;
out["joint"] = j["joint"].clone();
} else if let Some(pt) = p.get("point").and_then(vec3) {
// Placed at a point, its +z along `direction`.
let dir = p.get("direction").and_then(vec3).and_then(|d| d.normalized()).unwrap_or(Vec3::Z);
let m = solvecraft_doc::rigid(
Vec3::ZERO,
Vec3::ZERO,
Vec3::Z.cross(dir).normalized().unwrap_or(Vec3::X),
Vec3::Z.dot(dir).clamp(-1.0, 1.0).acos(),
);
let mut m = m;
m[3] = [pt.x, pt.y, pt.z, 1.0];
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == occ) {
o.transform = m;
}
}
Ok(out)
}
/// A point on the cylindrical wall of the hole at `a`, a little in from the picked rim.
fn hole_wall(s: &Session, a: Vec3) -> Option<Vec3> {
let st = s.model.state();
let dirs = [Vec3::Z * -1.0, Vec3::Z, Vec3::X, Vec3::X * -1.0, Vec3::Y, Vec3::Y * -1.0];
for step in [0.2, 0.5, 1.0, 2.0] {
for b in &st.bodies {
for d in dirs {
let q = a + d * step;
if let Some(c) = solvecraft_doc::kernel::cylinder_face_at(&b.body, q) {
let v = q - c.axis_point;
let along = v.dot(c.axis);
let on_wall = ((v - c.axis * along).len() - c.radius).abs() < 1e-3 && along > c.start && along < c.end;
if on_wall {
return Some(q);
}
}
}
}
}
None
}
fn shape_len(sh: &Shape) -> Option<f64> {
match sh {
Shape::Screw { len, .. } | Shape::Rod { len, .. } => Some(*len),
_ => None,
}
}
fn resize(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parts.resize";
let key = match p.get("component") {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => return Err(bad(cmd, "`component` must be an id or a name")),
};
let comp = s.doc.find_component(&key).ok_or_else(|| bad(cmd, format!("no component `{key}`")))?;
let part = s.doc.parts.get(&comp).cloned().ok_or_else(|| bad(cmd, "that component is not a standard part"))?;
let size = str_(p, "size").map(str::to_string).unwrap_or(part.size.clone());
let length = length_param(p, cmd)?.or(if size == part.size { part.length } else { None });
let sh = shape(&part.family, &size, length).map_err(|e| bad(cmd, e))?;
// Rebuild: the component's features go, the new shape is built in their place.
let ids: Vec<u64> = s.doc.features.iter().filter(|f| f.component == comp).map(|f| f.id).collect();
for id in ids.iter().rev() {
let _ = s.doc_mut().delete_feature(*id);
}
let active = s.active_component;
run(s, "component.activate", json!({"component": comp}))?;
build(s, &sh)?;
let name = part_name(&part.family, &size, &sh);
s.doc_mut().parts.insert(comp, solvecraft_doc::StandardPart { family: part.family.clone(), size: size.clone(), length: shape_len(&sh) });
if let Some(c) = s.doc_mut().components.iter_mut().find(|c| c.id == comp) {
c.name = name.clone();
}
run(s, "component.activate", json!({"component": active}))?;
s.refresh();
if let Some(e) = s.model.results.iter().find_map(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
Ok(json!({"component": comp, "name": name, "size": size, "length": shape_len(&sh)}))
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn bbox(s: &Session, prefix: &str) -> (solvecraft_geom::Vec3, solvecraft_geom::Vec3) {
let st = s.world_state();
let comp = s.doc.components.iter().find(|c| c.name.starts_with(prefix)).map(|c| c.id).unwrap_or(0);
let (mut lo, mut hi) = (solvecraft_geom::Vec3::new(f64::MAX, f64::MAX, f64::MAX), solvecraft_geom::Vec3::new(f64::MIN, f64::MIN, f64::MIN));
for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp) {
let m = b.mesh().bounds();
lo = solvecraft_geom::Vec3::new(lo.x.min(m.min.x), lo.y.min(m.min.y), lo.z.min(m.min.z));
hi = solvecraft_geom::Vec3::new(hi.x.max(m.max.x), hi.y.max(m.max.y), hi.z.max(m.max.z));
}
(lo, hi)
}
#[test]
fn dimensions_follow_the_standard_tables() {
let l = run(&mut Session::default(), "parts.library", json!({"family": "socket_head_cap_screw"}));
let m6 = l["sizes"].as_array().and_then(|a| a.iter().find(|x| x["size"] == "M6")).cloned().unwrap_or_default();
assert_eq!((m6["head_diameter"].as_f64(), m6["head_height"].as_f64(), m6["socket"].as_f64()), (Some(10.0), Some(6.0), Some(5.0)));
// An M6 x 20 cap screw: head Ø10 x 6, shank 20 long.
let mut s = Session::default();
run(&mut s, "parts.insert", json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20}));
let (lo, hi) = bbox(&s, "SHCS M6x20");
assert!((hi.x - lo.x - 10.0).abs() < 1e-6 && (lo.z + 20.0).abs() < 1e-6 && (hi.z - 6.0).abs() < 1e-6, "{lo:?} {hi:?}");
assert!(s.model.state().threads.iter().any(|t| t.designation.starts_with("M6")), "{:?}", s.model.state().threads);
// An M8 nut: 13 across flats (14.43 across corners), 6.8 high.
let mut n = Session::default();
run(&mut n, "parts.insert", json!({"family": "hex_nut", "size": "M8"}));
let (lo, hi) = bbox(&n, "Hex Nut M8");
let (w, h) = ((hi.x - lo.x).max(hi.y - lo.y), hi.z - lo.z);
assert!((w - 13.0 / (std::f64::consts::PI / 6.0).cos()).abs() < 1e-6 && (h - 6.8).abs() < 1e-6, "{w} {h}");
// A 608 bearing: 8 x 22 x 7.
let mut b = Session::default();
run(&mut b, "parts.insert", json!({"family": "bearing", "size": "608"}));
let (lo, hi) = bbox(&b, "Bearing 608");
assert!((hi.x - lo.x - 22.0).abs() < 1e-6 && (hi.z - lo.z - 7.0).abs() < 1e-6);
assert!(b.execute("parts.insert", &json!({"family": "hex_nut", "size": "M7"})).is_err());
}
#[test]
fn a_screw_seats_in_a_hole_and_resizes() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
run(&mut s, "solid.hole", json!({"position": [20, 15, 10], "diameter": 6.4}));
// Pick the hole's rim at the top face.
let r = run(&mut s, "parts.insert", json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20, "at": [23.2, 15, 10]}));
assert!(r["joint"].is_number(), "{r}");
let (lo, hi) = bbox(&s, "SHCS M6x20");
// The head sits flush on the top face, the shank down the hole's axis.
assert!((hi.z - 16.0).abs() < 1e-6 && (lo.z + 10.0).abs() < 1e-6, "{lo:?} {hi:?}");
assert!(((lo.x + hi.x) / 2.0 - 20.0).abs() < 1e-6 && ((lo.y + hi.y) / 2.0 - 15.0).abs() < 1e-6);
// M8 x 25: rebuilt in place, still seated.
let comp = r["component"].as_u64().unwrap_or(0);
run(&mut s, "parts.resize", json!({"component": comp, "size": "M8", "length": 25}));
let (lo, hi) = bbox(&s, "SHCS M8x25");
assert!((hi.z - 18.0).abs() < 1e-6 && (lo.z + 15.0).abs() < 1e-6, "{lo:?} {hi:?}");
}
}
+28 -56
View File
@@ -12,7 +12,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -22,12 +22,12 @@ fn rel(a: f64, b: f64) -> f64 {
/// A 100 x 50 x 5 plate with a hole at the start of a straight path (10,10) → (90,10).
fn plate_with_hole() -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 100, "width": 50, "height": 5, "corner": [0, 0, 0]}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [90, 10]], "ids": ["l1"]}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 2, "id": "c1"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Path", "through_all": true, "operation": "cut", "name": "Hole1"}));
run(&mut s, "PrimitiveBox", json!({"length": 100, "width": 50, "height": 5, "corner": [0, 0, 0]}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [90, 10]], "ids": ["l1"]}));
run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 2, "id": "c1"}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "Path", "through_all": true, "operation": "cut", "name": "Hole1"}));
s
}
@@ -38,25 +38,25 @@ const HOLE: f64 = PI * 4.0 * 5.0;
fn pattern_on_path_spacing_extent_and_flip() {
let mut s = plate_with_hole();
assert!(rel(volume(&mut s), PLATE - HOLE) < 1e-4);
run(&mut s, "solid.pattern.path", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 5, "spacing": 20}));
run(&mut s, "PatternOnPath", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 5, "spacing": 20}));
assert!(rel(volume(&mut s), PLATE - 5.0 * HOLE) < 1e-4);
// The count is a parameter like any other input.
let n = s.doc.features.last().and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "parameters.change", json!({"name": n, "expression": "3"}));
run(&mut s, "ChangeParameterCommand", json!({"name": n, "expression": "3"}));
assert!(rel(volume(&mut s), PLATE - 3.0 * HOLE) < 1e-4);
// Instances past the end of the path are an error.
run(&mut s, "parameters.change", json!({"name": n, "expression": "6"}));
run(&mut s, "ChangeParameterCommand", json!({"name": n, "expression": "6"}));
let err = s.model.results.last().and_then(|r| r.error.clone()).unwrap_or_default();
assert!(err.contains("past the end of the path"), "{err}");
// Distance = first to last instance.
let mut s = plate_with_hole();
run(&mut s, "solid.pattern.path", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 3, "distance": 80}));
run(&mut s, "PatternOnPath", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 3, "distance": 80}));
assert!(rel(volume(&mut s), PLATE - 3.0 * HOLE) < 1e-4);
// Flipped, the path runs from (90,10) back toward (10,10).
let mut s = plate_with_hole();
run(&mut s, "solid.pattern.path", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 2, "spacing": 30, "flip": true}));
run(&mut s, "PatternOnPath", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 2, "spacing": 30, "flip": true}));
// The copy moves by path(30) − path(0) = (60,10) − (90,10) = −30: off the plate side → only 1 hole.
assert!(rel(volume(&mut s), PLATE - HOLE) < 1e-4);
}
@@ -64,47 +64,47 @@ fn pattern_on_path_spacing_extent_and_flip() {
#[test]
fn patterns_of_bodies() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cube"}));
run(&mut s, "solid.pattern.circular", json!({"bodies": ["Cube"], "axis": "Z", "count": 4}));
let m = run(&mut s, "inspect.measure", json!({}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cube"}));
run(&mut s, "PatternCircular", json!({"bodies": ["Cube"], "axis": "Z", "count": 4}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert_eq!(m["body_count"], 4, "{m}");
assert!(rel(volume(&mut s), 4000.0) < 1e-4);
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["Cube"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
run(&mut s, "PatternRectangular", json!({"bodies": ["Cube"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
assert!(rel(volume(&mut s), 6000.0) < 1e-4);
assert!(s.execute("solid.pattern.rectangular", &json!({"bodies": ["Nope"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 5})).is_err());
assert!(s.execute("PatternRectangular", &json!({"bodies": ["Nope"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 5})).is_err());
}
#[test]
fn patterns_of_components_place_occurrences() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Peg"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "Peg"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let r = run(&mut s, "solid.pattern.rectangular", json!({"components": ["Peg"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
let r = run(&mut s, "PatternRectangular", json!({"components": ["Peg"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
assert_eq!(r["occurrences"].as_array().map(Vec::len), Some(2), "{r}");
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert_eq!(m["body_count"], 3, "{m}");
let zs: Vec<f64> = m["bodies"].as_array().unwrap().iter().filter_map(|b| b["bbox"]["min"][2].as_f64()).collect();
for z in [0.0, 20.0, 40.0] {
assert!(zs.iter().any(|x| (x - z).abs() < 1e-6), "{zs:?}");
}
assert!(s.execute("solid.pattern.circular", &json!({"components": ["Nope"], "axis": "Z", "count": 2})).is_err());
assert!(s.execute("PatternCircular", &json!({"components": ["Nope"], "axis": "Z", "count": 2})).is_err());
}
#[test]
fn path_pattern_orientation_follows_the_path() {
let width_of_copy = |orient: &str| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 2, "width": 4, "height": 2, "corner": [19, -2, 0], "body_name": "Tab"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Arc"}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [20, 0], "sweep": 90, "id": "a1"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "PrimitiveBox", json!({"length": 2, "width": 4, "height": 2, "corner": [19, -2, 0], "body_name": "Tab"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Arc"}));
run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 0], "start": [20, 0], "sweep": 90, "id": "a1"}));
run(&mut s, "SketchStop", json!({}));
run(
&mut s,
"solid.pattern.path",
"PatternOnPath",
json!({"bodies": ["Tab"], "path_sketch": "Arc", "path": ["a1"], "count": 2, "distance": "pi * 10", "orientation": orient}),
);
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] != "Tab").unwrap().clone();
let (lo, hi) = (b["bbox"]["min"].clone(), b["bbox"]["max"].clone());
// The copy sits at the arc's end (0, 20).
@@ -114,31 +114,3 @@ fn path_pattern_orientation_follows_the_path() {
assert!((width_of_copy("identical") - 2.0).abs() < 1e-6);
assert!((width_of_copy("path") - 4.0).abs() < 1e-6);
}
/// Patterns of a blind hole drill every copy to the full depth from the face at the copy
/// (QA s30): n copies remove n times one hole.
#[test]
fn patterns_of_a_blind_hole_drill_every_copy() {
let one = {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 25, "height": 15}));
let v0 = volume(&mut s);
run(&mut s, "solid.hole", json!({"position": [10, 12, 15], "diameter": "5 mm", "depth": "10 mm"}));
v0 - volume(&mut s)
};
let patterned = |p: (&str, Value)| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 25, "height": 15}));
let v0 = volume(&mut s);
run(&mut s, "solid.hole", json!({"position": [10, 12, 15], "diameter": "5 mm", "depth": "10 mm"}));
run(&mut s, p.0, p.1);
v0 - volume(&mut s)
};
let r = patterned(("solid.pattern.rectangular", json!({"features": ["Hole1"], "dir1": [1, 0, 0], "count1": 3, "spacing1": "15 mm"})));
assert!((r - 3.0 * one).abs() < 1e-3 * one, "{r} vs 3 × {one}");
let c = patterned((
"solid.pattern.circular",
json!({"features": ["Hole1"], "axis": {"origin": [25, 12, 0], "dir": [0, 0, 1]}, "count": 2, "angle": 360}),
));
assert!((c - 2.0 * one).abs() < 1e-3 * one, "{c} vs 2 × {one}");
}
+50 -50
View File
@@ -12,26 +12,26 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("plastic.boss", "Boss", boss).at("PLASTIC", "CREATE").icon("boss").params(
CommandSpec::new("FusionBossCommand", "Boss", boss).at("PLASTIC", "CREATE").icon("boss").params(
"position: [x,y,z] on a face; direction? (default: out of the face); diameter, height; hole_diameter?, hole_depth? (default: to the face); \
draft? (angle), fillet? (root radius); ribs? (count), rib_thickness?, rib_length? (out from the wall), rib_offset? (below the top); body?",
),
CommandSpec::new("plastic.lip", "Lip", lip).at("PLASTIC", "CREATE").icon("lip").params(
CommandSpec::new("FusionLipCommand", "Lip", lip).at("PLASTIC", "CREATE").icon("lip").params(
"face: [x,y,z] on the rim face of a shelled body; width, height; type?: lip|groove; gap? (groove clearance); side?: inside|outside (which rim edge); body?",
),
CommandSpec::new("plastic.snap_fit", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap").params(
CommandSpec::new("FusionSnapFitCommand", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap").params(
"position: [x,y,z] on a face; direction? (default: out of the face); hook: [x,y,z] (the catch's direction); length, thickness, width, catch_depth, catch_length; body?",
),
CommandSpec::new("plastic.rest", "Rest", rest).at("PLASTIC", "CREATE").icon("rest").params(
CommandSpec::new("FusionRestCommand", "Rest", rest).at("PLASTIC", "CREATE").icon("rest").params(
"position: [x,y,z] on a face (the rest's centre); direction? (default: out of the face); width (round: the diameter); length? (a rectangle, along `along`); along?: [x,y,z]; \
height; draft? (angle; default: the body's plastic rule); thickness? (wall of a hollow rest); body?",
),
CommandSpec::new("plastic.manage_rules", "Manage Plastic Rules", manage_rules).at("PLASTIC", "SETUP").icon("plastic_rule").params(
CommandSpec::new("FusionManagePlasticRuleCommand", "Manage Plastic Rules", manage_rules).at("PLASTIC", "SETUP").icon("plastic_rule").params(
"name (library or new rule; a new one starts from `from`, default ABS (1.5mm)); from?; material?; thickness?, nominal_radius?, clearance?, knife_edge?, \
reveal_height?, thickness_variation?, draft?, max_thickness?, min_thickness?, min_draft? (expressions; `Thickness` is the rule's); active?: bool (rule for bodies without one); \
delete?: bool (a design rule; a library rule goes back to the library values)",
),
CommandSpec::new("plastic.assign_rule", "Assign Plastic Rule", assign_rule).at("PLASTIC", "SETUP").icon("plastic_assign").params(
CommandSpec::new("FusionAssignPlasticRuleCommand", "Assign Plastic Rule", assign_rule).at("PLASTIC", "SETUP").icon("plastic_assign").params(
"bodies: [names]; rule: name (empty: no rule) — plastic features on these bodies take its draft and clearance as defaults",
),
CommandSpec::new("plastic.rules", "List Plastic Rules", list_rules).noundo().params("→ rules with their expressions and values, the active rule and the rules assigned to bodies"),
@@ -53,7 +53,7 @@ fn rule_json(s: &Session, r: &PlasticRule) -> Value {
}
fn manage_rules(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plastic.manage_rules";
let cmd = "FusionManagePlasticRuleCommand";
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();
if bool_(p, "delete").unwrap_or(false) {
let doc = s.doc_mut();
@@ -116,7 +116,7 @@ fn manage_rules(s: &mut Session, p: &Value) -> Result<Value> {
}
fn assign_rule(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plastic.assign_rule";
let cmd = "FusionAssignPlasticRuleCommand";
let bodies = string_list(p, "bodies");
if bodies.is_empty() || bodies.len() > 10_000 {
return Err(bad(cmd, "`bodies` must list bodies"));
@@ -147,7 +147,7 @@ fn list_rules(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn rest(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plastic.rest";
let cmd = "FusionRestCommand";
let (position, direction) = placed(s, p, cmd)?;
let along = match p.get("along") {
Some(v) => Some(
@@ -199,7 +199,7 @@ fn placed(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3)> {
}
fn boss(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plastic.boss";
let cmd = "FusionBossCommand";
let (position, direction) = placed(s, p, cmd)?;
let kind = FeatureKind::Boss {
position,
@@ -220,7 +220,7 @@ fn boss(s: &mut Session, p: &Value) -> Result<Value> {
}
fn lip(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plastic.lip";
let cmd = "FusionLipCommand";
let face = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on the rim face"))?;
let groove = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() {
None | Some("lip") => false,
@@ -245,7 +245,7 @@ fn lip(s: &mut Session, p: &Value) -> Result<Value> {
}
fn snap_fit(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plastic.snap_fit";
let cmd = "FusionSnapFitCommand";
let (position, direction) = placed(s, p, cmd)?;
let hook = p.get("hook").and_then(vec3).and_then(|h| h.normalized()).ok_or_else(|| bad(cmd, "`hook` must be the catch's direction [x, y, z]"))?;
if hook.cross(direction).len() < 1e-6 {
@@ -281,7 +281,7 @@ mod tests {
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -291,17 +291,17 @@ mod tests {
#[test]
fn boss_with_hole_fillet_and_ribs() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 60, "width": 60, "height": 3, "body_name": "Plate"}));
run(&mut s, "plastic.boss", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 4}));
run(&mut s, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3, "body_name": "Plate"}));
run(&mut s, "FusionBossCommand", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 4}));
let want = 10800.0 + PI * 25.0 * 12.0 - PI * 4.0 * 12.0;
assert!(rel(volume(&mut s), want) < 1e-3, "{} {want}", volume(&mut s));
assert_eq!(run(&mut s, "inspect.measure", json!({}))["body_count"], 1);
assert_eq!(run(&mut s, "MeasureCommand", json!({}))["body_count"], 1);
// Ribs add t·l·h each.
let mut t = Session::default();
run(&mut t, "solid.box", json!({"length": 60, "width": 60, "height": 3}));
run(&mut t, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3}));
run(
&mut t,
"plastic.boss",
"FusionBossCommand",
json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 6, "rib_offset": 2}),
);
let ribs = 4.0 * 1.5 * 6.0 * 10.0;
@@ -309,52 +309,52 @@ mod tests {
let v = volume(&mut t) - 10800.0 - PI * 25.0 * 12.0;
assert!(v > ribs && v < ribs * 1.05, "{v} {ribs}");
let mut u = Session::default();
run(&mut u, "solid.box", json!({"length": 60, "width": 60, "height": 3}));
run(&mut u, "plastic.boss", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "fillet": 2, "draft": 1}));
run(&mut u, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3}));
run(&mut u, "FusionBossCommand", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "fillet": 2, "draft": 1}));
assert!(volume(&mut u) > 10800.0 + PI * 25.0 * 10.0);
assert!(u.execute("plastic.boss", &json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 20})).is_err());
assert!(u.execute("FusionBossCommand", &json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 20})).is_err());
}
#[test]
fn lip_and_groove_on_a_shelled_box() {
let shelled = || {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
run(&mut s, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
run(&mut s, "PrimitiveBox", json!({"length": 80, "width": 50, "height": 30}));
run(&mut s, "FusionShellBodyCommand", json!({"faces": [[40, 25, 30]], "thickness": 2}));
s
};
let mut s = shelled();
let v0 = volume(&mut s);
run(&mut s, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2}));
run(&mut s, "FusionLipCommand", json!({"face": [1, 25, 30], "width": 1, "height": 2}));
// Inside band 1 wide along the inner edge (76 x 46): (76+2)(46+2) − 76·46 = 248, 2 high.
assert!(rel(volume(&mut s) - v0, 248.0 * 2.0) < 1e-6, "{}", volume(&mut s) - v0);
let mut g = shelled();
run(&mut g, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
run(&mut g, "FusionLipCommand", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
// Outside band along the outer edge (80 x 50): 80·50 − 78·48 = 256, 2 deep.
assert!(rel(v0 - volume(&mut g), 256.0 * 2.0) < 1e-6, "{}", v0 - volume(&mut g));
assert!(g.execute("plastic.lip", &json!({"face": [40, 25, 0], "width": 1, "height": 2})).is_err(), "a face without an inner edge");
assert!(g.execute("FusionLipCommand", &json!({"face": [40, 25, 0], "width": 1, "height": 2})).is_err(), "a face without an inner edge");
}
#[test]
fn snap_fit_arm_and_catch() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 4}));
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 40, "height": 4}));
let v0 = volume(&mut s);
run(
&mut s,
"plastic.snap_fit",
"FusionSnapFitCommand",
json!({"position": [20, 20, 4], "hook": [1, 0, 0], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3}),
);
let want = 1.5 * 15.0 * 6.0 + 0.5 * 1.0 * 3.0 * 6.0;
assert!(rel(volume(&mut s) - v0, want) < 1e-6, "{}", volume(&mut s) - v0);
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert!((m["bodies"][0]["bbox"]["max"][0].as_f64().unwrap_or(0.0) - 40.0).abs() < 1e-9);
assert!(s.execute("plastic.snap_fit", &json!({"position": [5, 5, 4], "direction": [0, 0, 1], "hook": [0, 0, 1], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3})).is_err());
assert!(s.execute("FusionSnapFitCommand", &json!({"position": [5, 5, 4], "direction": [0, 0, 1], "hook": [0, 0, 1], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3})).is_err());
}
fn plate() -> (Session, f64) {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 60, "width": 60, "height": 3}));
run(&mut s, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3}));
let v0 = volume(&mut s);
(s, v0)
}
@@ -363,27 +363,27 @@ mod tests {
fn rest_pads() {
// Rectangle 20 x 10, 4 high: 800.
let (mut s, v0) = plate();
run(&mut s, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "length": 20, "along": [1, 0, 0], "height": 4}));
run(&mut s, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "length": 20, "along": [1, 0, 0], "height": 4}));
assert!(rel(volume(&mut s) - v0, 800.0) < 1e-6, "{}", volume(&mut s) - v0);
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert!((m["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 7.0).abs() < 1e-9);
// Round, diameter 10: π·25·4.
let (mut r, v0) = plate();
run(&mut r, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "height": 4}));
run(&mut r, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "height": 4}));
assert!(rel(volume(&mut r) - v0, PI * 25.0 * 4.0) < 1e-3, "{}", volume(&mut r) - v0);
// Hollow, wall 1: (20·10 − 18·8)·4 = 224.
let (mut h, v0) = plate();
run(&mut h, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "thickness": 1}));
run(&mut h, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "thickness": 1}));
assert!(rel(volume(&mut h) - v0, 224.0) < 1e-6, "{}", volume(&mut h) - v0);
// Drafted 2°: the section shrinks by 2·s·tan 2° each way at height s.
let (mut d, v0) = plate();
run(&mut d, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "draft": "2 deg"}));
run(&mut d, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "draft": "2 deg"}));
let a = 2f64.to_radians().tan();
let want = 800.0 - a * 30.0 * 16.0 + 4.0 / 3.0 * a * a * 64.0;
assert!(rel(volume(&mut d) - v0, want) < 1e-6, "{} {want}", volume(&mut d) - v0);
// A wall thicker than half the width, or a draft that closes the top, is refused.
assert!(d.execute("plastic.rest", &json!({"position": [10, 10, 3], "width": 4, "height": 4, "thickness": 2})).is_err());
assert!(d.execute("plastic.rest", &json!({"position": [10, 10, 3], "width": 4, "height": 40, "draft": 10})).is_err());
assert!(d.execute("FusionRestCommand", &json!({"position": [10, 10, 3], "width": 4, "height": 4, "thickness": 2})).is_err());
assert!(d.execute("FusionRestCommand", &json!({"position": [10, 10, 3], "width": 4, "height": 40, "draft": 10})).is_err());
}
#[test]
@@ -395,31 +395,31 @@ mod tests {
assert_eq!(list["rules"][0]["values"]["nominal_radius"], 0.75);
assert_eq!(list["rules"][3]["values"]["thickness"].as_f64().map(|t| (t - 2.54).abs() < 1e-9), Some(true));
// A new rule from ABS: Thickness-based values follow its thickness.
let r = run(&mut s, "plastic.manage_rules", json!({"name": "Thick ABS", "thickness": 3, "draft": "1.5 deg"}));
let r = run(&mut s, "FusionManagePlasticRuleCommand", json!({"name": "Thick ABS", "thickness": 3, "draft": "1.5 deg"}));
assert_eq!(r["rule"]["values"]["nominal_radius"], 1.5);
assert!(s.execute("plastic.manage_rules", &json!({"name": "Bad", "draft": "3 mm"})).is_err());
assert!(s.execute("FusionManagePlasticRuleCommand", &json!({"name": "Bad", "draft": "3 mm"})).is_err());
// Assigned to the plate: a boss without a draft takes the rule's 1.5°.
let body = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["name"].as_str().unwrap_or_default().to_string();
run(&mut s, "plastic.assign_rule", json!({"bodies": [body], "rule": "Thick ABS"}));
run(&mut s, "plastic.boss", json!({"position": [30, 30, 3], "diameter": 10, "height": 12}));
let body = run(&mut s, "MeasureCommand", json!({}))["bodies"][0]["name"].as_str().unwrap_or_default().to_string();
run(&mut s, "FusionAssignPlasticRuleCommand", json!({"bodies": [body], "rule": "Thick ABS"}));
run(&mut s, "FusionBossCommand", json!({"position": [30, 30, 3], "diameter": 10, "height": 12}));
let rt = 5.0 - 12.0 * 1.5f64.to_radians().tan();
let want = PI * 12.0 / 3.0 * (25.0 + 5.0 * rt + rt * rt);
assert!(rel(volume(&mut s) - v0, want) < 1e-3, "{} {want}", volume(&mut s) - v0);
// Editing the rule rebuilds the boss.
run(&mut s, "plastic.manage_rules", json!({"name": "Thick ABS", "draft": "0 deg"}));
run(&mut s, "FusionManagePlasticRuleCommand", json!({"name": "Thick ABS", "draft": "0 deg"}));
assert!(rel(volume(&mut s) - v0, PI * 25.0 * 12.0) < 1e-3, "{}", volume(&mut s) - v0);
assert!(s.execute("plastic.assign_rule", &json!({"bodies": [body], "rule": "Nope"})).is_err());
assert!(s.execute("plastic.assign_rule", &json!({"bodies": ["Nope"], "rule": "Thick ABS"})).is_err());
assert!(s.execute("FusionAssignPlasticRuleCommand", &json!({"bodies": [body], "rule": "Nope"})).is_err());
assert!(s.execute("FusionAssignPlasticRuleCommand", &json!({"bodies": ["Nope"], "rule": "Thick ABS"})).is_err());
}
#[test]
fn groove_takes_the_rule_clearance() {
let mut g = Session::default();
run(&mut g, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
run(&mut g, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
run(&mut g, "PrimitiveBox", json!({"length": 80, "width": 50, "height": 30}));
run(&mut g, "FusionShellBodyCommand", json!({"faces": [[40, 25, 30]], "thickness": 2}));
let v0 = volume(&mut g);
run(&mut g, "plastic.manage_rules", json!({"name": "ABS (1.5mm)", "active": true}));
run(&mut g, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
run(&mut g, "FusionManagePlasticRuleCommand", json!({"name": "ABS (1.5mm)", "active": true}));
run(&mut g, "FusionLipCommand", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
// Width and depth grow by the 0.1 clearance: (80·50 − 77.8·47.8)·2.1.
let want = (4000.0 - 77.8 * 47.8) * 2.1;
assert!(rel(v0 - volume(&mut g), want) < 1e-6, "{} {want}", v0 - volume(&mut g));
+13 -40
View File
@@ -8,48 +8,34 @@ use super::CommandSpec;
use crate::params::{bad, bool_, expr, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] =
&[CommandSpec::new("inspect.section", "Section Analysis", section).at("SOLID", "INSPECT").icon("section").noundo().params(
"plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: +Y, YZ: +X); \
at?: expr, the cut's world coordinate on the named plane's axis (XY: Z, XZ: Y, YZ: X), instead of offset; \
flip?: bool; or clear: true. Returns the cut's origin and normal, and for a named plane its axis and at",
)];
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("FusionHalfSectionViewCommand", "Section Analysis", section)
.at("SOLID", "INSPECT")
.icon("section")
.noundo()
.params("plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the normal; flip?: bool; or clear: true")];
fn section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.section";
let cmd = "FusionHalfSectionViewCommand";
if bool_(p, "clear").unwrap_or(false) {
s.section = None;
s.revision += 1;
return Ok(json!({"section": null}));
}
// A named plane's world axis: which component of a point lies along its normal.
let (origin, normal, axis) = match p.get("plane") {
let (origin, normal) = match p.get("plane") {
Some(Value::String(n)) => {
let pl = Plane::named(n).ok_or_else(|| bad(cmd, format!("unknown plane `{n}` (XY, XZ, YZ)")))?;
let normal = pl.normal();
let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5);
(pl.origin, normal, axis)
(pl.origin, pl.normal())
}
Some(o @ Value::Object(_)) => {
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
let n = o.get("normal").and_then(vec3).and_then(|n| n.normalized()).ok_or_else(|| bad(cmd, "the plane needs a non-zero `normal`"))?;
(origin, n, None)
(origin, n)
}
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or {origin, normal}")),
};
let length = |key: &str| -> Result<Option<f64>> {
match expr(p, key) {
Some(e) => s.doc.eval(&e, solvecraft_doc::expr::Kind::Length).map(Some).map_err(|e| bad(cmd, format!("{key}: {e}"))),
None => Ok(None),
}
};
let (offset, at) = (length("offset")?, length("at")?);
let offset = match (offset, at, axis) {
(Some(_), Some(_), _) => return Err(bad(cmd, "give either `offset` (along the normal) or `at` (a world coordinate), not both")),
(_, Some(_), None) => return Err(bad(cmd, "`at` needs a named plane (XY, XZ, YZ); move an {origin, normal} plane by its origin")),
// The named planes pass through the world origin, so the offset is `at` signed by the normal.
(_, Some(at), Some(i)) => at * axis_component(normal, i).signum(),
(offset, None, _) => offset.unwrap_or(0.0),
let offset = match expr(p, "offset") {
Some(e) => s.doc.eval(&e, solvecraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("offset: {e}")))?,
None => 0.0,
};
if !origin.is_finite() || !offset.is_finite() {
return Err(bad(cmd, "the plane must be finite"));
@@ -58,18 +44,5 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
let origin = origin + normal * offset * if bool_(p, "flip").unwrap_or(false) { -1.0 } else { 1.0 };
s.section = Some((origin, normal));
s.revision += 1;
let mut section = json!({"origin": origin, "normal": normal});
if let Some(i) = axis {
section["axis"] = json!(["X", "Y", "Z"].get(i).copied().unwrap_or("?"));
section["at"] = json!(axis_component(origin, i));
}
Ok(json!({"section": section}))
}
fn axis_component(v: Vec3, i: usize) -> f64 {
match i {
0 => v.x,
1 => v.y,
_ => v.z,
}
Ok(json!({"section": {"origin": origin, "normal": normal}}))
}
+15 -15
View File
@@ -13,31 +13,31 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sheet.flange", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params(
CommandSpec::new("FusionSheetMetalFlangeCommand", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params(
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \
edge: edges: [[x,y,z] on top/bottom edges] + height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs)",
),
CommandSpec::new("sheet.hem", "Hem", hem)
CommandSpec::new("FusionSheetMetalHemFlangeCommand", "Hem", hem)
.at("SHEET METAL", "CREATE")
.icon("hem")
.params("edges: [[x,y,z] on sheet edges]; length (to the outer face); gap? (inner radius, default: rule); flip?; body?"),
CommandSpec::new("sheet.fold", "Fold", fold).at("SHEET METAL", "CREATE").icon("fold").params(
CommandSpec::new("SheetMetalFoldCmd", "Fold", fold).at("SHEET METAL", "CREATE").icon("fold").params(
"line: sketch + curve (a sketch line on the sheet's base face; followed when the sketch changes) or points: [[x,y,z], [x,y,z]]; \
angle? (default 90 deg); radius? (default: rule); position?: centerline|start|end|mould (where the line sits in the bend, default centerline); \
fixed?: [x,y,z] on the side that stays (default: the larger side); flip? (fold the other way); body?",
),
CommandSpec::new("sheet.unfold", "Unfold", unfold).at("SHEET METAL", "MODIFY").icon("unfold").params("body? (default: the last sheet body) — all bends"),
CommandSpec::new("FusionSheetmetalUnfoldCommand", "Unfold", unfold).at("SHEET METAL", "MODIFY").icon("unfold").params("body? (default: the last sheet body) — all bends"),
CommandSpec::new("sheet.refold", "Refold", refold).at("SHEET METAL", "MODIFY").icon("refold").params("body? (default: the last unfolded sheet)"),
CommandSpec::new("sheet.convert", "Convert to Sheet Metal", convert)
CommandSpec::new("ConvertToSheetMetalCmd", "Convert to Sheet Metal", convert)
.at("SHEET METAL", "CREATE")
.icon("convert_sheet")
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
CommandSpec::new("FusionSheetMetalRulesCommand", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's); active?: bool; delete?: bool",
),
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().params("→ rules with their expressions and values, and the active one"),
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
CommandSpec::new("FusionSheetMetalFlatPatternCmd", "Create Flat Pattern", flat_pattern)
.at("SHEET METAL", "CREATE")
.icon("flat")
.noundo()
@@ -60,13 +60,13 @@ fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
match str_(p, "rule") {
Some(r) if s.doc.sheet.rules.iter().any(|x| x.name == r) || r == SheetRule::steel().name => Ok(Some(r.to_string())),
Some(r) => Err(bad(cmd, format!("no sheet metal rule `{r}` (sheet.manage_rules makes one)"))),
Some(r) => Err(bad(cmd, format!("no sheet metal rule `{r}` (FusionSheetMetalRulesCommand makes one)"))),
None => Ok(None),
}
}
fn flange(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.flange";
let cmd = "FusionSheetMetalFlangeCommand";
let ty = match str_(p, "type") {
Some(t) => t.to_ascii_lowercase(),
None if p.get("edges").is_some() => "edge".into(),
@@ -123,7 +123,7 @@ fn flange(s: &mut Session, p: &Value) -> Result<Value> {
}
fn hem(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.hem";
let cmd = "FusionSheetMetalHemFlangeCommand";
let edges = points(p, "edges", cmd)?;
let length = req_expr(cmd, p, "length")?;
check_expr(s, &length, Kind::Length, cmd, "length")?;
@@ -139,7 +139,7 @@ fn hem(s: &mut Session, p: &Value) -> Result<Value> {
}
fn fold(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.fold";
let cmd = "SheetMetalFoldCmd";
let (sketch, curve, a, b) = match p.get("points") {
Some(v) => {
let pts = v.as_array().map(|a| a.iter().filter_map(vec3).collect::<Vec<Vec3>>()).unwrap_or_default();
@@ -189,7 +189,7 @@ fn refold(s: &mut Session, p: &Value) -> Result<Value> {
}
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.convert";
let cmd = "ConvertToSheetMetalCmd";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
@@ -200,7 +200,7 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rules(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.manage_rules";
let cmd = "FusionSheetMetalRulesCommand";
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();
if bool_(p, "delete").unwrap_or(false) {
let doc = s.doc_mut();
@@ -289,7 +289,7 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
}
fn flat_pattern(s: &mut Session, p: &Value) -> Result<Value> {
let sh = sheet_of(s, p, "sheet.flat_pattern")?;
let sh = sheet_of(s, p, "FusionSheetMetalFlatPatternCmd")?;
flat_json(&sh)
}
@@ -369,7 +369,7 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
let sh = sheet_of(s, p, cmd)?;
let text = flat_dxf(&sh)?;
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "bends": sh.bends().len()}));
}
Ok(json!({"dxf": text, "bends": sh.bends().len()}))
+61 -107
View File
@@ -12,7 +12,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -20,15 +20,15 @@ fn rel(a: f64, b: f64) -> f64 {
}
fn flat(s: &mut Session) -> Value {
run(s, "sheet.flat_pattern", json!({}))
run(s, "FusionSheetMetalFlatPatternCmd", json!({}))
}
/// A w x h base flange on XY.
fn base(s: &mut Session, w: f64, h: f64) {
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [w, h]}));
run(s, "sketch.finish", json!({}));
run(s, "sheet.flange", json!({"sketch": "Base"}));
run(s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [w, h]}));
run(s, "SketchStop", json!({}));
run(s, "FusionSheetMetalFlangeCommand", json!({"sketch": "Base"}));
}
#[test]
@@ -36,70 +36,70 @@ fn base_and_edge_flange_follow_the_k_factor() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
assert!(rel(volume(&mut s), 15000.0) < 1e-9);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20}));
// Oracle 68: flat = 60 − 5 + (π/2)(2.5 + 0.44·2.5) + 20 − 5.
let want = 60.0 - 5.0 + FRAC_PI_2 * (2.5 + 0.44 * 2.5) + 15.0;
let f = flat(&mut s);
assert!((f["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6, "{f}");
assert_eq!(f["bends"][0]["direction"], "up");
assert!(rel(volume(&mut s), 18972.622) < 1e-4, "{}", volume(&mut s));
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert_eq!(m["total"]["faces"], 14, "{m}");
// The angle is a parameter: 45° (oracle 70 uses height 15).
let names = s.doc.features.last().map(|f| f.param_names.clone()).unwrap_or_default();
run(&mut s, "parameters.change", json!({"name": names[0], "expression": "15"}));
run(&mut s, "parameters.change", json!({"name": names[1], "expression": "45 deg"}));
run(&mut s, "ChangeParameterCommand", json!({"name": names[0], "expression": "15"}));
run(&mut s, "ChangeParameterCommand", json!({"name": names[1], "expression": "45 deg"}));
assert!(rel(volume(&mut s), 18450.777) < 1e-4, "{}", volume(&mut s));
assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - 73.685298).abs() < 1e-5);
// DXF: outline and an up bend line.
let d = run(&mut s, "sheet.export_dxf", json!({}));
let text = d["dxf"].as_str().unwrap_or_default();
assert!(text.contains("BEND_UP") && text.contains("OUTLINE"), "{text}");
assert!(s.execute("sheet.flange", &json!({"edges": [[50, 30, 40]], "height": 20})).is_err(), "not on an edge");
assert!(s.execute("FusionSheetMetalFlangeCommand", &json!({"edges": [[50, 30, 40]], "height": 20})).is_err(), "not on an edge");
}
#[test]
fn rules_are_parameters() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "Thick", "expression": "1.5 mm"}));
run(&mut s, "sheet.manage_rules", json!({"name": "Custom", "thickness": "Thick", "k_factor": 0.33, "bend_radius": "3 mm"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "Thick", "expression": "1.5 mm"}));
run(&mut s, "FusionSheetMetalRulesCommand", json!({"name": "Custom", "thickness": "Thick", "k_factor": 0.33, "bend_radius": "3 mm"}));
base(&mut s, 80.0, 50.0);
run(&mut s, "sheet.flange", json!({"edges": [[40, 50, 1.5], [40, 0, 1.5]], "height": 20}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[40, 50, 1.5], [40, 0, 1.5]], "height": 20}));
// Oracle 71: 50 − 9 + 2·(π/2)(3 + 0.495) + 2·15.5 = 82.980.
let want = 50.0 - 2.0 * 4.5 + 2.0 * FRAC_PI_2 * (3.0 + 0.33 * 1.5) + 2.0 * 15.5;
assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6);
assert!(rel(volume(&mut s), 10053.717) < 1e-4, "{}", volume(&mut s));
// Thicker: everything follows.
run(&mut s, "parameters.change", json!({"name": "Thick", "expression": "2 mm"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "Thick", "expression": "2 mm"}));
let want = 50.0 - 2.0 * 5.0 + 2.0 * FRAC_PI_2 * (3.0 + 0.33 * 2.0) + 2.0 * 15.0;
assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6);
let l = run(&mut s, "sheet.rules", json!({}));
assert_eq!(l["active"], "Custom", "{l}");
assert!(s.execute("sheet.manage_rules", &json!({"name": "Bad", "k_factor": 3})).is_err());
assert!(s.execute("FusionSheetMetalRulesCommand", &json!({"name": "Bad", "k_factor": 3})).is_err());
}
#[test]
fn contour_flange_hem_and_tray() {
// Oracle 72.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1]}, "name": "Contour"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, -30], [70, -30]], "ids": ["l1", "l2", "l3"]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Contour", "curves": ["l1", "l2", "l3"], "distance": 50}));
run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1]}, "name": "Contour"}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [40, 0], [40, -30], [70, -30]], "ids": ["l1", "l2", "l3"]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"sketch": "Contour", "curves": ["l1", "l2", "l3"], "distance": 50}));
assert!(rel(volume(&mut s), 12097.622) < 1e-4, "{}", volume(&mut s));
assert!((flat(&mut s)["flat_size_mm"][0].as_f64().unwrap_or(0.0) - 96.309734).abs() < 1e-5);
// Oracle 73: flange, then a flat hem of 8.
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "sheet.hem", json!({"edges": [[50, 0, 20]], "length": 8}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "FusionSheetMetalHemFlangeCommand", json!({"edges": [[50, 0, 20]], "length": 8}));
assert!(rel(volume(&mut s), 21334.723) < 1e-4, "{}", volume(&mut s));
let b = flat(&mut s)["bends"].clone();
assert!(b.as_array().is_some_and(|a| a.iter().any(|x| (x["angle_deg"].as_f64().unwrap_or(0.0) - 180.0).abs() < 1e-9)), "{b}");
// A tray: four flanges at once stop at each other's bends.
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.flange", json!({"edges": [[50, 60, 2.5], [100, 30, 2.5], [50, 0, 2.5], [0, 30, 2.5]], "height": 25}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 60, 2.5], [100, 30, 2.5], [50, 0, 2.5], [0, 30, 2.5]], "height": 25}));
let f = flat(&mut s);
// Oracle 69: 100 − 10 + 2·5.655 + 2·20 = 141.310 across.
assert!((f["flat_size_mm"][0].as_f64().unwrap_or(0.0) - (90.0 + 2.0 * FRAC_PI_2 * 3.6 + 40.0)).abs() < 1e-6, "{f}");
@@ -117,15 +117,15 @@ fn unfold_cut_refold_and_convert() {
// Oracle 74: two flanges, unfold, a slot across the bend, refold.
let mut s = Session::default();
base(&mut s, 80.0, 50.0);
run(&mut s, "sheet.flange", json!({"edges": [[40, 50, 2.5], [40, 0, 2.5]], "height": 20}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[40, 50, 2.5], [40, 0, 2.5]], "height": 20}));
let folded = volume(&mut s);
run(&mut s, "sheet.unfold", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
run(&mut s, "FusionSheetmetalUnfoldCommand", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert!((m["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 2.5).abs() < 1e-6, "{m}");
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [35, -12], "p1": [45, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [35, -12], "p1": [45, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut s, "sheet.refold", json!({}));
let v = volume(&mut s);
assert!(v < folded - 300.0, "{v} {folded}");
@@ -133,24 +133,24 @@ fn unfold_cut_refold_and_convert() {
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1));
// Convert a plate.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2.5, "body_name": "Plate"}));
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [50, 30, 2.5]}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "PrimitiveBox", json!({"length": 100, "width": 60, "height": 2.5, "body_name": "Plate"}));
run(&mut s, "ConvertToSheetMetalCmd", json!({"body": "Plate", "face": [50, 30, 2.5]}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20}));
assert!(rel(volume(&mut s), 18972.622) < 1e-4, "{}", volume(&mut s));
let mut t = Session::default();
run(&mut t, "solid.cylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
assert!(t.execute("sheet.convert", &json!({"body": "Rod", "face": [0, 0, 30]})).is_err());
run(&mut t, "PrimitiveCylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
assert!(t.execute("ConvertToSheetMetalCmd", &json!({"body": "Rod", "face": [0, 0, 30]})).is_err());
}
/// A sketch line across a 100 x 60 plate's top face at x = 40.
fn fold_line(s: &mut Session) {
run(s, "sketch.create", json!({"plane": "XY", "offset": 2.5, "name": "FoldLine"}));
run(s, "sketch.line", json!({"points": [[40, 0], [40, 60]], "ids": ["fl"]}));
run(s, "sketch.finish", json!({}));
run(s, "SketchCreate", json!({"plane": "XY", "offset": 2.5, "name": "FoldLine"}));
run(s, "DrawPolyline", json!({"points": [[40, 0], [40, 60]], "ids": ["fl"]}));
run(s, "SketchStop", json!({}));
}
fn bbox(s: &mut Session) -> Value {
run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"].clone()
run(s, "MeasureCommand", json!({}))["bodies"][0]["bbox"].clone()
}
#[test]
@@ -160,7 +160,7 @@ fn fold_takes_the_bend_out_of_the_flat_sheet() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
fold_line(&mut s);
run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"}));
run(&mut s, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl"}));
// The flat pattern keeps its size; the bend's centre line is the sketch line.
let f = flat(&mut s);
assert!((f["flat_size_mm"][0].as_f64().unwrap_or(0.0) - 100.0).abs() < 1e-6, "{f}");
@@ -189,7 +189,7 @@ fn fold_positions_fixed_side_and_flip() {
for (k, v) in p.as_object().into_iter().flatten() {
q[k] = v.clone();
}
run(&mut s, "sheet.fold", q);
run(&mut s, "SheetMetalFoldCmd", q);
bbox(&mut s)
};
let x0 = |b: &Value| b["min"][0].as_f64().unwrap_or(f64::NAN);
@@ -215,10 +215,10 @@ fn fold_carries_flanges_on_the_moving_side() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
// A flange on the left edge, then a fold between it and the rest.
run(&mut s, "sheet.flange", json!({"edges": [[0, 30, 2.5]], "height": 20}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[0, 30, 2.5]], "height": 20}));
let v0 = volume(&mut s);
fold_line(&mut s);
run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl", "fixed": [90, 30, 2.5]}));
run(&mut s, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl", "fixed": [90, 30, 2.5]}));
// The flange rode along: it is above the plate now, nothing left at z < 0.
let b = bbox(&mut s);
assert!(b["min"][2].as_f64().unwrap_or(-1.0) > -1e-6, "{b}");
@@ -229,9 +229,9 @@ fn fold_carries_flanges_on_the_moving_side() {
// Errors: off the sheet, through the flange's bend.
let mut e = Session::default();
base(&mut e, 100.0, 60.0);
assert!(e.execute("sheet.fold", &json!({"points": [[40, 0, 30], [40, 60, 30]]})).is_err());
assert!(e.execute("sheet.fold", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "position": "sideways"})).is_err());
assert!(e.execute("sheet.fold", &json!({"points": [[0.5, 0, 2.5], [0.5, 60, 2.5]]})).is_err(), "no material past the bend");
assert!(e.execute("SheetMetalFoldCmd", &json!({"points": [[40, 0, 30], [40, 60, 30]]})).is_err());
assert!(e.execute("SheetMetalFoldCmd", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "position": "sideways"})).is_err());
assert!(e.execute("SheetMetalFoldCmd", &json!({"points": [[0.5, 0, 2.5], [0.5, 60, 2.5]]})).is_err(), "no material past the bend");
}
#[test]
@@ -240,12 +240,12 @@ fn fold_on_a_flange_panel() {
let ba = FRAC_PI_2 * (r + k * t);
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 30}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 30}));
let flat0 = flat(&mut s)["flat_size_mm"].clone();
let v0 = volume(&mut s);
// The flange stands up at the plate's y = 0 edge: its outer face is the body's min y.
let y = bbox(&mut s)["min"][1].as_f64().unwrap_or(f64::NAN);
run(&mut s, "sheet.fold", json!({"points": [[10, y, 18], [90, y, 18]]}));
run(&mut s, "SheetMetalFoldCmd", json!({"points": [[10, y, 18], [90, y, 18]]}));
let f = flat(&mut s);
for i in 0..2 {
assert!(
@@ -262,7 +262,7 @@ fn fold_on_a_flange_panel() {
let b = bbox(&mut s);
assert!(b["max"][2].as_f64().unwrap_or(99.0) < 25.0, "{b}");
// Folding across the flange's own bend is refused.
assert!(s.execute("sheet.fold", &json!({"points": [[50, y, 2], [50, y, 25]]})).is_err());
assert!(s.execute("SheetMetalFoldCmd", &json!({"points": [[50, y, 2], [50, y, 25]]})).is_err());
}
#[test]
@@ -270,77 +270,31 @@ fn fold_through_cut_outs() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
// A slot across the fold line (edges along and across the bend).
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [30, 20], "p1": [50, 40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [30, 20], "p1": [50, 40]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
let mut plain = Session::default();
base(&mut plain, 100.0, 60.0);
fold_line(&mut plain);
run(&mut plain, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"}));
run(&mut plain, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl"}));
fold_line(&mut s);
run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"}));
run(&mut s, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl"}));
let f = flat(&mut s);
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1), "{f}");
let (v, vp) = (volume(&mut s), volume(&mut plain));
// The slot takes 20 x 20 of the flat sheet; across the bend it removes the bend's share.
assert!(v < vp - 900.0 && v > vp - 1100.0, "{v} {vp}");
run(&mut s, "sheet.unfold", json!({}));
run(&mut s, "FusionSheetmetalUnfoldCommand", json!({}));
assert!((volume(&mut s) - (15000.0 - 1000.0)).abs() < 1e-3, "{}", volume(&mut s));
// A round hole across the bend is not supported yet, and says so.
let mut c = Session::default();
base(&mut c, 100.0, 60.0);
run(&mut c, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
run(&mut c, "sketch.circle.center", json!({"center": [40, 30], "radius": 5}));
run(&mut c, "sketch.finish", json!({}));
run(&mut c, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut c, "SketchCreate", json!({"plane": "XY", "name": "Hole"}));
run(&mut c, "CircleCenterRadius", json!({"center": [40, 30], "radius": 5}));
run(&mut c, "SketchStop", json!({}));
run(&mut c, "Extrude", json!({"sketch": "Hole", "through_all": true, "operation": "cut", "direction": "symmetric"}));
fold_line(&mut c);
let e = c.execute("sheet.fold", &json!({"sketch": "FoldLine", "curve": "fl"})).map(|_| ()).unwrap_err().to_string();
let e = c.execute("SheetMetalFoldCmd", &json!({"sketch": "FoldLine", "curve": "fl"})).map(|_| ()).unwrap_err().to_string();
assert!(e.contains("not supported yet"), "{e}");
}
#[test]
fn flange_edges_are_named_and_follow_the_base_sketch() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 100}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
run(&mut s, "sheet.flange", json!({"edges": [[100, 30, 2.5]], "height": 20, "name": "Side"}));
let side = s.doc.find_feature("Side").cloned().unwrap();
assert_eq!(side.edge_names, vec!["base:l2:top".to_string()]);
let v0 = volume(&mut s);
// Narrower base: the flange stays on the right edge (now at x = 40).
let name = d["param"].as_str().or_else(|| d["name"].as_str()).map(str::to_string).unwrap_or_else(|| "d1".into());
run(&mut s, "parameters.change", json!({"name": name, "expression": "40"}));
let b = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"].clone();
let w = b["max"][0].as_f64().unwrap_or(0.0) - b["min"][0].as_f64().unwrap_or(0.0);
assert!((w - 40.0).abs() < 1e-3 && b["max"][2].as_f64().unwrap_or(0.0) > 19.0, "{b}");
assert!(volume(&mut s) < v0);
let id = side.id;
assert_eq!(s.model.result(id).and_then(|r| r.warning.clone()), None);
// A hem on the flange's far edge is named after the flange.
let top = b["max"][2].as_f64().unwrap_or(0.0);
let x = b["max"][0].as_f64().unwrap_or(0.0);
run(&mut s, "sheet.hem", json!({"edges": [[x, 30, top]], "length": 6, "name": "H"}));
let h = s.doc.find_feature("H").cloned().unwrap();
assert!(h.edge_names[0].starts_with(&format!("F{id}.0:far")), "{:?}", h.edge_names);
}
/// With no fixed side, the larger side stays whichever side of the line it is on (QA s22).
#[test]
fn fold_keeps_the_larger_side_either_way() {
for (w, line, moving_right) in [(60.0, 40.0, true), (100.0, 40.0, false)] {
let mut s = Session::default();
base(&mut s, w, 40.0);
run(&mut s, "sheet.fold", json!({"points": [[line, 0, 2.5], [line, 40, 2.5]]}));
let b = bbox(&mut s);
let (x0, x1) = (b["min"][0].as_f64().unwrap_or(0.0), b["max"][0].as_f64().unwrap_or(0.0));
if moving_right {
assert!(x0.abs() < 1e-6 && x1 < line + 5.0, "the left (larger) part stays: {b}");
} else {
assert!((x1 - w).abs() < 1e-6 && x0 > line - 5.0, "the right (larger) part stays: {b}");
}
}
}
+110 -231
View File
@@ -1,7 +1,7 @@
//! Sketch commands: create/edit/finish sketches, draw curves, constraints and dimensions.
use serde_json::{Value, json};
use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind};
use solvecraft_doc::{FeatureKind, PlaneRef};
use solvecraft_geom::{Plane, Vec2, Vec3};
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
@@ -9,144 +9,137 @@ use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3};
use crate::{EngineError, Result, Session};
/// The `connect` parameter every drawing command takes (see `connect_points`).
macro_rules! connect_doc {
() => {
"; connect?: bool (default true: a new point landing exactly on an existing point, the origin too, is made coincident with it)"
};
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
CommandSpec::new("SketchCreate", "Create Sketch", create_sketch)
.at("SOLID", "CREATE")
.icon("sketch")
.params("plane: XY|XZ|YZ | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"),
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"),
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("sketch.line", "Line", draw_line)
CommandSpec::new("SketchActivate", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"),
CommandSpec::new("SketchStop", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("DrawPolyline", "Line", draw_line)
.at("SKETCH", "CREATE")
.icon("line")
.key("L")
.enabled(in_sketch)
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!())),
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point)
.params("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)"),
CommandSpec::new("ShapeRectangleTwoPoint", "2-Point Rectangle", rect_two_point)
.at("SKETCH", "CREATE")
.icon("rect")
.key("R")
.enabled(in_sketch)
.params(concat!("p0, p1: opposite corners [x,y]; construction?. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!())),
CommandSpec::new("sketch.rectangle.three_point", "3-Point Rectangle", rect_three_point)
.params("p0, p1: opposite corners [x,y]; construction?"),
CommandSpec::new("ShapeRectangleThreePoint", "3-Point Rectangle", rect_three_point)
.at("SKETCH", "CREATE")
.icon("rect3")
.enabled(in_sketch)
.params(concat!("p0, p1: first edge; p2: point on the opposite edge", connect_doc!())),
CommandSpec::new("sketch.rectangle.center", "Center Rectangle", rect_center)
.params("p0, p1: first edge; p2: point on the opposite edge"),
CommandSpec::new("ShapeRectangleCenter", "Center Rectangle", rect_center)
.at("SKETCH", "CREATE")
.icon("rect_center")
.enabled(in_sketch)
.params(concat!("center, corner. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!())),
CommandSpec::new("sketch.circle.center", "Center Diameter Circle", circle_center)
.params("center, corner"),
CommandSpec::new("CircleCenterRadius", "Center Diameter Circle", circle_center)
.at("SKETCH", "CREATE")
.icon("circle")
.key("C")
.enabled(in_sketch)
.params(concat!("center: [x,y] or point ref, radius | diameter: number", connect_doc!())),
CommandSpec::new("sketch.circle.two_point", "2-Point Circle", circle_two)
.params("center: [x,y] or point ref, radius | diameter: number"),
CommandSpec::new("CircleTwoPoint", "2-Point Circle", circle_two)
.at("SKETCH", "CREATE")
.icon("circle2")
.enabled(in_sketch)
.params(concat!("p0, p1: diameter ends", connect_doc!())),
CommandSpec::new("sketch.circle.three_point", "3-Point Circle", circle_three)
.params("p0, p1: diameter ends"),
CommandSpec::new("CircleThreePoint", "3-Point Circle", circle_three)
.at("SKETCH", "CREATE")
.icon("circle3")
.enabled(in_sketch)
.params(concat!("p0, p1, p2", connect_doc!())),
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params(concat!("start, end, through", connect_doc!())),
CommandSpec::new("sketch.arc.center_point", "Center Point Arc", arc_center)
.params("p0, p1, p2"),
CommandSpec::new("ArcThreePoint", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params("start, end, through"),
CommandSpec::new("ArcCenterTwoPoint", "Center Point Arc", arc_center)
.at("SKETCH", "CREATE")
.icon("arc_center")
.enabled(in_sketch)
.params(concat!("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees", connect_doc!())),
CommandSpec::new("sketch.polygon.inscribed", "Inscribed Polygon", polygon_inscribed)
.params("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees"),
CommandSpec::new("ShapePolygonInscribed", "Inscribed Polygon", polygon_inscribed)
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!())),
CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed)
.params("center, radius (to vertices), sides, angle?: deg"),
CommandSpec::new("ShapePolygonCircumscribed", "Circumscribed Polygon", polygon_circumscribed)
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!())),
CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge)
.params("center, radius (to edge midpoints), sides, angle?: deg"),
CommandSpec::new("ShapePolygonEdge", "Edge Polygon", polygon_edge)
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params(concat!("p0, p1: one edge, sides", connect_doc!())),
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c)
.params("p0, p1: one edge, sides"),
CommandSpec::new("ShapeSlotCenterToCenter", "Center to Center Slot", slot_c2c)
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params(concat!("p0, p1: arc centres, width", connect_doc!())),
CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall)
.params("p0, p1: arc centres, width"),
CommandSpec::new("ShapeSlotOverall", "Overall Slot", slot_overall)
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params(concat!("p0, p1: overall ends, width", connect_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
.params("p0, p1: overall ends, width"),
CommandSpec::new("DrawPoint", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params("point: [x,y], id?"),
CommandSpec::new("SketchDimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current)",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
CommandSpec::new("ConstraintHorizontalVertical", "Horizontal/Vertical", c_horizontal_vertical)
.at("SKETCH", "CONSTRAINTS")
.icon("c_hv")
.enabled(in_sketch)
.params("line: id | points: [a, b]; mode?: horizontal|vertical"),
CommandSpec::new("sketch.constraint.coincident", "Coincident", c_coincident)
CommandSpec::new("ConstraintCoincident", "Coincident", c_coincident)
.at("SKETCH", "CONSTRAINTS")
.icon("c_coincident")
.enabled(in_sketch)
.params("a: point, b: point or curve"),
CommandSpec::new("sketch.constraint.tangent", "Tangent", c_tangent)
CommandSpec::new("ConstraintTangent", "Tangent", c_tangent)
.at("SKETCH", "CONSTRAINTS")
.icon("c_tangent")
.enabled(in_sketch)
.params("a, b: curves"),
CommandSpec::new("sketch.constraint.equal", "Equal", c_equal)
CommandSpec::new("ConstraintEqual", "Equal", c_equal)
.at("SKETCH", "CONSTRAINTS")
.icon("c_equal")
.enabled(in_sketch)
.params("a, b: lines or circles/arcs"),
CommandSpec::new("sketch.constraint.parallel", "Parallel", c_parallel)
CommandSpec::new("ConstraintParallel", "Parallel", c_parallel)
.at("SKETCH", "CONSTRAINTS")
.icon("c_parallel")
.enabled(in_sketch)
.params("a, b: lines"),
CommandSpec::new("sketch.constraint.perpendicular", "Perpendicular", c_perpendicular)
CommandSpec::new("ConstraintPerpendicular", "Perpendicular", c_perpendicular)
.at("SKETCH", "CONSTRAINTS")
.icon("c_perpendicular")
.enabled(in_sketch)
.params("a, b: lines"),
CommandSpec::new("sketch.constraint.fix", "Fix/UnFix", c_fix)
CommandSpec::new("ConstraintFix", "Fix/UnFix", c_fix)
.at("SKETCH", "CONSTRAINTS")
.icon("c_fix")
.enabled(in_sketch)
.params("entity: point or curve | entities: [points and curves]; fixed?: bool (default: fix them all unless all are fixed, then unfix)"),
CommandSpec::new("sketch.constraint.midpoint", "MidPoint", c_midpoint)
CommandSpec::new("ConstraintMidPoint", "MidPoint", c_midpoint)
.at("SKETCH", "CONSTRAINTS")
.icon("c_midpoint")
.enabled(in_sketch)
.params("point, line"),
CommandSpec::new("sketch.constraint.concentric", "Concentric", c_concentric)
CommandSpec::new("ConstraintConcentric", "Concentric", c_concentric)
.at("SKETCH", "CONSTRAINTS")
.icon("c_concentric")
.enabled(in_sketch)
.params("a, b: circles/arcs"),
CommandSpec::new("sketch.constraint.collinear", "Collinear", c_collinear)
CommandSpec::new("ConstraintCollinear", "Collinear", c_collinear)
.at("SKETCH", "CONSTRAINTS")
.icon("c_collinear")
.enabled(in_sketch)
.params("a, b: lines"),
CommandSpec::new("sketch.constraint.symmetry", "Symmetry", c_symmetry)
CommandSpec::new("ConstraintSymmetry", "Symmetry", c_symmetry)
.at("SKETCH", "CONSTRAINTS")
.icon("c_symmetry")
.enabled(in_sketch)
@@ -225,47 +218,6 @@ pub(super) fn edit<T>(
Ok((out, json!({"solved": rep.ok(), "dof": rep.dof})))
}
/// How close (mm) a new point must land to an existing one to be connected to it.
const CONNECT_TOL: f64 = 1e-6;
/// New points (index `first` on) that land on an existing point, the origin included, get a
/// coincident constraint to it, as when a click snaps there; `connect: false` leaves them free.
fn connect_points(sk: &mut Sketch, first: usize, p: &Value) -> Result<Vec<String>> {
if !bool_(p, "connect").unwrap_or(true) {
return Ok(Vec::new());
}
// The existing points by x, for a range lookup per new point.
let mut old: Vec<(Vec2, usize)> = (0..first.min(sk.points.len())).filter_map(|i| Some((sk.point(i)?, i))).collect();
old.sort_by(|a, b| a.0.x.total_cmp(&b.0.x));
let mut ties = Vec::new();
for q in first..sk.points.len() {
let Some(at) = sk.point(q) else { continue };
let from = old.partition_point(|(o, _)| o.x < at.x - CONNECT_TOL);
let hit =
old.iter().skip(from).take_while(|(o, _)| o.x <= at.x + CONNECT_TOL).filter(|(o, _)| o.dist(at) < CONNECT_TOL).map(|(_, i)| *i).min();
if let Some(o) = hit {
ties.push((o, q));
}
}
ties.into_iter().map(|(o, q)| add_c(sk, ConstraintKind::Coincident { p: o, q })).collect()
}
/// `edit` for drawing commands whose step returns (curve ids, constraint ids): the points it
/// adds are connected to the existing points they land on (see [`connect_points`]).
pub(super) fn draw(
s: &mut Session,
p: &Value,
cmd: &str,
f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result<(Vec<String>, Vec<String>)>,
) -> Result<((Vec<String>, Vec<String>), Value)> {
edit(s, p, cmd, false, |sk, doc| {
let first = sk.points.len();
let (curves, mut cons) = f(sk, doc)?;
cons.extend(connect_points(sk, first, p)?);
Ok((curves, cons))
})
}
/// A point argument: `[x, y]` (new point), a point reference string, or `{at, ref}`.
#[derive(Clone)]
pub(super) enum PArg {
@@ -359,10 +311,8 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
}
Some(v @ Value::Object(o)) => {
if let Some(fp) = o.get("face").and_then(vec3) {
// A planar face of a body at this point, as the active component sees the model
// (another component's face where it is placed).
let model = s.model.state();
let st = solvecraft_doc::state_in_frame(&s.doc, &model, s.active_component);
// A planar face of a body at this point.
let st = s.model.state();
let mut found = None;
let mut face_name = None;
for b in &st.bodies {
@@ -408,19 +358,16 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo
let s1 = (b - a).cross(p - a).dot(n);
let s2 = (c - b).cross(p - b).dot(n);
let s3 = (a - c).cross(p - c).dot(n);
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0)
|| (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
// Exact curved boundaries can sit just outside their tessellated chords.
|| p.dist_to_segment(a, b).min(p.dist_to_segment(b, c)).min(p.dist_to_segment(c, a)) <= tol * 0.1
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0) || (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
})
}
fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.create";
let cmd = "SketchCreate";
let plane = plane_ref(s, p, cmd)?;
// Validate the plane resolves.
let (vals, _) = s.doc.param_values();
s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
s.doc.resolve_plane(&vals, &plane, 0)?;
let name = str_(p, "name");
let mut kind = FeatureKind::Sketch { plane, sketch: Sketch::new() };
super::component::to_active_frame(s, &mut kind);
@@ -446,11 +393,11 @@ fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
let key = match p.get("sketch") {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => return Err(bad("sketch.edit", "`sketch` must be an id or a name")),
_ => return Err(bad("SketchActivate", "`sketch` must be an id or a name")),
};
let f = s.doc.find_feature(&key).ok_or_else(|| bad("sketch.edit", format!("no sketch `{key}`")))?;
let f = s.doc.find_feature(&key).ok_or_else(|| bad("SketchActivate", format!("no sketch `{key}`")))?;
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
return Err(bad("sketch.edit", format!("`{key}` is not a sketch")));
return Err(bad("SketchActivate", format!("`{key}` is not a sketch")));
}
let (id, name) = (f.id, f.name.clone());
s.active_sketch = Some(id);
@@ -469,7 +416,7 @@ fn finish_sketch(s: &mut Session, _p: &Value) -> Result<Value> {
// Drawing
fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.line";
let cmd = "DrawPolyline";
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let list = p.get("points").and_then(Value::as_array).cloned().ok_or_else(|| bad(cmd, "`points` must be a list"))?;
if list.len() < 2 || list.len() > 10_000 {
@@ -478,7 +425,7 @@ fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
let closed = bool_(p, "closed").unwrap_or(false);
let infer = bool_(p, "infer").unwrap_or(false);
let ids = string_list(p, "ids");
let ((curves, cons), info) = draw(s, p, cmd, |sk, _| {
let ((curves, cons), info) = edit(s, p, cmd, false, |sk, _| {
let args: Vec<PArg> = list.iter().map(|v| parg(sk, v, cmd)).collect::<Result<_>>()?;
let n = args.len();
let segs = if closed { n } else { n - 1 };
@@ -579,79 +526,48 @@ fn rect_constraints(sk: &mut Sketch, l: &[usize]) -> Result<Vec<String>> {
Ok(cons)
}
/// Which side and corner of an axis-aligned rectangle is which, from its lines and corner
/// points as `polygon_lines` made them (counter-clockwise from the bottom-left corner), so a
/// caller can dimension it without inspecting the sketch first.
fn rect_names(sk: &Sketch, l: &[usize], pts: &[usize]) -> Value {
let curve = |i: usize| l.get(i).and_then(|c| sk.curves.get(*c)).map(|c| c.id.clone());
let point = |i: usize| pts.get(i).and_then(|q| sk.points.get(*q)).map(|q| q.id.clone());
json!({
"sides": {"bottom": curve(0), "right": curve(1), "top": curve(2), "left": curve(3)},
"corners": {"bottom_left": point(0), "bottom_right": point(1), "top_right": point(2), "top_left": point(3)},
})
}
/// `result` plus the rectangle's named sides and corners.
fn rect_result(sk_out: (Vec<String>, Vec<String>), names: Value, info: Value) -> Value {
let mut out = result(sk_out, info);
if let (Some(o), Value::Object(n)) = (out.as_object_mut(), names) {
o.extend(n);
}
out
}
fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.rectangle.two_point";
let cmd = "ShapeRectangleTwoPoint";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
if (a.x - b.x).abs() < 1e-9 || (a.y - b.y).abs() < 1e-9 {
return Err(bad(cmd, "the corners must differ in x and y"));
}
let (lo, hi) = (Vec2::new(a.x.min(b.x), a.y.min(b.y)), Vec2::new(a.x.max(b.x), a.y.max(b.y)));
let ((out, names), info) = edit(s, p, cmd, false, |sk, _| {
let first = sk.points.len();
let (l, pts) = polygon_lines(sk, &[lo, Vec2::new(hi.x, lo.y), hi, Vec2::new(lo.x, hi.y)])?;
let mut cons = rect_constraints(sk, &l)?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (l, _) = polygon_lines(sk, &[lo, Vec2::new(hi.x, lo.y), hi, Vec2::new(lo.x, hi.y)])?;
let cons = rect_constraints(sk, &l)?;
mark_construction(sk, &l, p);
cons.extend(connect_points(sk, first, p)?);
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
Ok((ids_of(sk, &l), cons))
})?;
Ok(rect_result(out, names, info))
Ok(result(out, info))
}
fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.rectangle.center";
let cmd = "ShapeRectangleCenter";
let (c, k) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "corner")?);
let h = Vec2::new((k.x - c.x).abs(), (k.y - c.y).abs());
if h.x < 1e-9 || h.y < 1e-9 {
return Err(bad(cmd, "the corner must differ from the centre in x and y"));
}
let ((out, names), info) = edit(s, p, cmd, false, |sk, _| {
let first = sk.points.len();
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (l, pts) = polygon_lines(sk, &[c - h, Vec2::new(c.x + h.x, c.y - h.y), c + h, Vec2::new(c.x - h.x, c.y + h.y)])?;
let mut cons = rect_constraints(sk, &l)?;
// Both diagonals (construction), the centre at their crossing. Only the first carries
// the midpoint constraint: diagonals of a parallelogram bisect each other, so a second
// one would be redundant (over-constrained).
if let (Some(p0), Some(p1), Some(p2), Some(p3)) = (pts.first(), pts.get(1), pts.get(2), pts.get(3)) {
if let (Some(p0), Some(p2)) = (pts.first(), pts.get(2)) {
let diag = sk.add_line_pts(*p0, *p2, None)?;
let diag2 = sk.add_line_pts(*p1, *p3, None)?;
for d in [diag, diag2] {
if let Some(cu) = sk.curves.get_mut(d) {
cu.construction = true;
}
if let Some(cu) = sk.curves.get_mut(diag) {
cu.construction = true;
}
let cp = sk.add_point(c, None)?;
cons.push(add_c(sk, ConstraintKind::Midpoint { p: cp, l: diag })?);
}
mark_construction(sk, &l, p);
cons.extend(connect_points(sk, first, p)?);
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
Ok((ids_of(sk, &l), cons))
})?;
Ok(rect_result(out, names, info))
Ok(result(out, info))
}
fn rect_three_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.rectangle.three_point";
let cmd = "ShapeRectangleThreePoint";
let (a, b, c) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?, req_vec2(cmd, p, "p2")?);
let d = (b - a).normalized().ok_or_else(|| bad(cmd, "p0 and p1 must differ"))?;
let n = d.perp();
@@ -659,7 +575,7 @@ fn rect_three_point(s: &mut Session, p: &Value) -> Result<Value> {
if w.abs() < 1e-9 {
return Err(bad(cmd, "p2 must not be on the first edge"));
}
let (out, info) = draw(s, p, cmd, |sk, _| {
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (l, _) = polygon_lines(sk, &[a, b, b + n * w, a + n * w])?;
let mut cons = Vec::new();
if let [l0, l1, l2, l3] = l[..] {
@@ -683,11 +599,11 @@ pub(super) fn radius_arg(p: &Value, cmd: &str) -> Result<f64> {
}
fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.circle.center";
let cmd = "CircleCenterRadius";
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let r = radius_arg(p, cmd)?;
let id = str_(p, "id").map(str::to_string);
let (out, info) = draw(s, p, cmd, |sk, _| {
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let c = req_parg(sk, p, "center", cmd)?;
let ci = sk.add_circle(c.pos(), r, c.idx(), id.as_deref())?;
mark_construction(sk, &[ci], p);
@@ -697,13 +613,13 @@ fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
}
fn circle_two(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.circle.two_point";
let cmd = "CircleTwoPoint";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let r = a.dist(b) / 2.0;
if r < 1e-9 {
return Err(bad(cmd, "the points must differ"));
}
let (out, info) = draw(s, p, cmd, |sk, _| {
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let ci = sk.add_circle((a + b) * 0.5, r, None, None)?;
Ok((ids_of(sk, &[ci]), Vec::new()))
})?;
@@ -725,10 +641,10 @@ pub(super) fn circumcircle(a: Vec2, b: Vec2, c: Vec2) -> Option<(Vec2, f64)> {
}
fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.circle.three_point";
let cmd = "CircleThreePoint";
let (a, b, c) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?, req_vec2(cmd, p, "p2")?);
let (o, r) = circumcircle(a, b, c).ok_or_else(|| bad(cmd, "the points are collinear"))?;
let (out, info) = draw(s, p, cmd, |sk, _| {
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let ci = sk.add_circle(o, r, None, None)?;
Ok((ids_of(sk, &[ci]), Vec::new()))
})?;
@@ -736,10 +652,10 @@ fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
}
fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.arc.three_point";
let cmd = "ArcThreePoint";
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let id = str_(p, "id").map(str::to_string);
let (out, info) = draw(s, p, cmd, |sk, _| {
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (a, b) = (req_parg(sk, p, "start", cmd)?, req_parg(sk, p, "end", cmd)?);
let t = req_vec2(cmd, p, "through")?;
let (o, _) = circumcircle(a.pos(), t, b.pos()).ok_or_else(|| bad(cmd, "the points are collinear"))?;
@@ -758,11 +674,11 @@ fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
}
fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.arc.center_point";
let cmd = "ArcCenterTwoPoint";
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let id = str_(p, "id").map(str::to_string);
let sweep = num(p, "sweep");
let (out, info) = draw(s, p, cmd, |sk, _| {
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let c = req_parg(sk, p, "center", cmd)?;
let a = req_parg(sk, p, "start", cmd)?;
if let Some(sw) = sweep {
@@ -799,7 +715,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
}
fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, start: f64, n: usize, inscribed: bool) -> Result<Value> {
let (out, info) = draw(s, p, cmd, |sk, _| {
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let corners: Vec<Vec2> = (0..n).map(|i| center + Vec2::from_angle(start + std::f64::consts::TAU * i as f64 / n as f64) * r_vertex).collect();
let (l, pts) = polygon_lines(sk, &corners)?;
let mut cons = Vec::new();
@@ -837,7 +753,7 @@ fn sides(p: &Value, cmd: &str) -> Result<usize> {
}
fn polygon_inscribed(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.polygon.inscribed";
let cmd = "ShapePolygonInscribed";
let c = req_vec2(cmd, p, "center")?;
let r = radius_arg(p, cmd)?;
let n = sides(p, cmd)?;
@@ -846,7 +762,7 @@ fn polygon_inscribed(s: &mut Session, p: &Value) -> Result<Value> {
}
fn polygon_circumscribed(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.polygon.circumscribed";
let cmd = "ShapePolygonCircumscribed";
let c = req_vec2(cmd, p, "center")?;
let r = radius_arg(p, cmd)?;
let n = sides(p, cmd)?;
@@ -855,7 +771,7 @@ fn polygon_circumscribed(s: &mut Session, p: &Value) -> Result<Value> {
}
fn polygon_edge(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.polygon.edge";
let cmd = "ShapePolygonEdge";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let n = sides(p, cmd)?;
let e = a.dist(b);
@@ -875,7 +791,7 @@ pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w:
let h = w / 2.0;
let d = (c1 - c0).normalized().ok_or_else(|| bad(cmd, "the slot ends must differ"))?;
let n = d.perp();
let (out, info) = draw(s, p, cmd, |sk, _| {
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let pts: Vec<usize> =
[c0 - n * h, c1 - n * h, c1 + n * h, c0 + n * h].iter().map(|q| sk.add_point(*q, None)).collect::<std::result::Result<_, _>>()?;
let (p0, p1, p2, p3) = (pts[0], pts[1], pts[2], pts[3]);
@@ -894,14 +810,14 @@ pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w:
}
fn slot_c2c(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.slot.center_to_center";
let cmd = "ShapeSlotCenterToCenter";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let w = num(p, "width").filter(|w| *w > 1e-9).ok_or_else(|| bad(cmd, "`width` must be positive"))?;
slot(s, p, cmd, a, b, w)
}
fn slot_overall(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.slot.overall";
let cmd = "ShapeSlotOverall";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let w = num(p, "width").filter(|w| *w > 1e-9).ok_or_else(|| bad(cmd, "`width` must be positive"))?;
let d = (b - a).normalized().ok_or_else(|| bad(cmd, "the slot ends must differ"))?;
@@ -912,15 +828,14 @@ fn slot_overall(s: &mut Session, p: &Value) -> Result<Value> {
}
fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.point";
let cmd = "DrawPoint";
let at = req_vec2(cmd, p, "point")?;
let id = str_(p, "id").map(str::to_string);
let ((pid, cons), info) = edit(s, p, cmd, false, |sk, _| {
let (pid, info) = edit(s, p, cmd, false, |sk, _| {
let i = sk.add_point(at, id.as_deref())?;
let cons = connect_points(sk, i, p)?;
Ok((sk.points.get(i).map(|q| q.id.clone()).unwrap_or_default(), cons))
Ok(sk.points.get(i).map(|q| q.id.clone()).unwrap_or_default())
})?;
Ok(json!({"point": pid, "constraints": cons, "sketch": info}))
Ok(json!({"point": pid, "sketch": info}))
}
// ---------------------------------------------------------------------------------------------
@@ -1097,7 +1012,7 @@ pub(super) fn reject_redundant(before: Option<usize>, info: &Value, cmd: &str) -
}
fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.horizontal_vertical";
let cmd = "ConstraintHorizontalVertical";
let mode = str_(p, "mode").map(str::to_ascii_lowercase);
constrain(s, p, cmd, |sk| {
if let Some(pts) = p.get("points").and_then(Value::as_array) {
@@ -1127,16 +1042,7 @@ fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
}
fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.coincident";
// Already tied (e.g. connected when it was drawn there): nothing to add.
let sk = s.doc.sketch(target_sketch(s, p, cmd)?)?;
let pt = |k: &str| p.get(k).and_then(Value::as_str).and_then(|r| sk.resolve_point(r));
if let (Some(a), Some(b)) = (pt("a"), pt("b"))
&& let Some(c) = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a)))
{
let rep = solve(&mut sk.clone());
return Ok(json!({"constraint": c.id, "existing": true, "sketch": {"solved": rep.ok(), "dof": rep.dof}}));
}
let cmd = "ConstraintCoincident";
constrain(s, p, cmd, |sk| {
let aref = p.get("a").and_then(Value::as_str).unwrap_or_default();
let bref = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?;
@@ -1158,39 +1064,31 @@ fn two_curves(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> {
}
fn c_tangent(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.tangent", |sk| two_curves(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b }))
constrain(s, p, "ConstraintTangent", |sk| two_curves(sk, p, "ConstraintTangent").map(|(a, b)| ConstraintKind::Tangent { a, b }))
}
fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.equal", |sk| two_curves(sk, p, "sketch.constraint.equal").map(|(a, b)| ConstraintKind::Equal { a, b }))
constrain(s, p, "ConstraintEqual", |sk| two_curves(sk, p, "ConstraintEqual").map(|(a, b)| ConstraintKind::Equal { a, b }))
}
fn c_parallel(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.parallel", |sk| {
two_curves(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b })
})
constrain(s, p, "ConstraintParallel", |sk| two_curves(sk, p, "ConstraintParallel").map(|(a, b)| ConstraintKind::Parallel { a, b }))
}
fn c_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.perpendicular", |sk| {
two_curves(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })
})
constrain(s, p, "ConstraintPerpendicular", |sk| two_curves(sk, p, "ConstraintPerpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b }))
}
fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.concentric", |sk| {
two_curves(sk, p, "sketch.constraint.concentric").map(|(a, b)| ConstraintKind::Concentric { a, b })
})
constrain(s, p, "ConstraintConcentric", |sk| two_curves(sk, p, "ConstraintConcentric").map(|(a, b)| ConstraintKind::Concentric { a, b }))
}
fn c_collinear(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.collinear", |sk| {
two_curves(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b })
})
constrain(s, p, "ConstraintCollinear", |sk| two_curves(sk, p, "ConstraintCollinear").map(|(a, b)| ConstraintKind::Collinear { a, b }))
}
fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.midpoint";
let cmd = "ConstraintMidPoint";
constrain(s, p, cmd, |sk| {
Ok(ConstraintKind::Midpoint { p: point_ref(sk, p.get("point"), cmd, "point")?, l: curve_ref(sk, p.get("line"), cmd, "line")? })
})
}
fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.symmetry";
let cmd = "ConstraintSymmetry";
let (ids, info) = edit(s, p, cmd, true, |sk, _| {
let l = curve_ref(sk, p.get("line"), cmd, "line")?;
let ra = p.get("a").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`a` must be a point or curve"))?;
@@ -1237,7 +1135,7 @@ fn round_center(sk: &Sketch, c: usize) -> Option<usize> {
}
fn c_fix(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.fix";
let cmd = "ConstraintFix";
let want = bool_(p, "fixed");
let mut refs = string_list(p, "entities");
if let Some(e) = str_(p, "entity") {
@@ -1303,7 +1201,7 @@ pub(super) fn line_pts(sk: &Sketch, l: usize) -> Option<(Vec2, Vec2)> {
}
fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.dimension";
let cmd = "SketchDimension";
let ents = string_list(p, "entities");
if ents.is_empty() || ents.len() > 2 {
return Err(bad(cmd, "`entities` must list one or two sketch entities"));
@@ -1337,9 +1235,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
};
let is_line = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. }));
let is_circle = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. }));
// The other reading of an angle between two lines (see below).
let mut alt: Option<(ConstraintKind, f64)> = None;
let (mut k, mut cur): (ConstraintKind, f64) = match (e0, e1) {
let (k, cur): (ConstraintKind, f64) = match (e0, e1) {
((None, Some(c)), None) if is_line(c) => {
let (a, b) = line_pts(sk, c).unwrap_or_default();
match ty.as_str() {
@@ -1416,14 +1312,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
ang = -ang;
std::mem::swap(&mut ka, &mut kb);
}
// Two lines make two angles, θ and 180° − θ, whichever way they were
// drawn: the acute one unless the value asks for the other.
let direct = (ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang);
let supplement =
(ConstraintKind::Angle { a: kb, b: ka, value: std::f64::consts::PI - ang, flip: true }, std::f64::consts::PI - ang);
let (acute, obtuse) = if ang <= std::f64::consts::FRAC_PI_2 { (direct, supplement) } else { (supplement, direct) };
alt = Some(obtuse);
acute
(ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang)
}
}
}
@@ -1440,7 +1329,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
c.driven = true;
}
before = None;
return Ok((String::new(), name, measured(is_angle, cur)));
return Ok((String::new(), name, cur));
}
let (unit, default_expr) =
if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) };
@@ -1451,30 +1340,20 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(err) = errs.get(&pname) {
return Err(bad(cmd, format!("value: {err}")));
}
// An angle between two lines dimensions whichever of its two readings is nearer the value.
if let (Some((ka, ca)), Some(want)) = (alt, vals.get(&pname).and_then(|v| v.to_kind(Kind::Angle).ok()))
&& (want - ca).abs() < (want - cur).abs()
{
(k, cur) = (ka, ca);
}
let _ = vals;
let name = k.name();
let id = sk.add_constraint(k, Some(pname.clone()))?;
place_text(sk, &id, text_at);
Ok((pname, name, measured(is_angle, cur)))
Ok((pname, name, cur))
})?;
if driven {
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}));
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current, "sketch": info}));
}
if reject_redundant(before, &info, cmd).is_err() {
return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true"));
}
let v = s.doc.param(&param).map(|p| p.expr.clone()).unwrap_or_default();
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info}))
}
/// A dimension's measured value as reported, in the unit its expression is written in.
fn measured(is_angle: bool, v: f64) -> (f64, &'static str) {
if is_angle { (round6(v.to_degrees()), "deg") } else { (round6(v), "mm") }
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current, "sketch": info}))
}
/// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the
+13 -16
View File
@@ -9,11 +9,11 @@ use crate::params::{bad, bool_, num, str_, vec2};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("canvas.insert", "Canvas", add_canvas)
CommandSpec::new("FusionAddCanvasCommand", "Canvas", add_canvas)
.at("SKETCH", "INSERT")
.icon("canvas")
.params("path | data (base64 PNG or JPEG); plane?: XY|XZ|YZ|{face: [x,y,z]}|… (default XY); center?: [x,y] on the plane, or at?: [x,y,z]; width?: mm (default 100); angle?: deg; opacity?: 0…1 (default 0.5); flip?; name?"),
CommandSpec::new("canvas.decal", "Decal", add_decal)
CommandSpec::new("FusionAddEditDecalCommand", "Decal", add_decal)
.at("SKETCH", "INSERT")
.icon("decal")
.params("path | data; face: [x,y,z] on a planar face (the image is centred there); width?: mm; angle?: deg; opacity?: (default 1)"),
@@ -31,11 +31,11 @@ fn image_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
base64_decode(d).ok_or_else(|| bad(cmd, "`data` is not base64"))?
} else {
let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `data`"))?;
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta as usize > MAX_CANVAS_BYTES {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() as usize > MAX_CANVAS_BYTES {
return Err(bad(cmd, "the image is too large"));
}
solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
};
if b.len() > MAX_CANVAS_BYTES {
return Err(bad(cmd, "the image is too large"));
@@ -48,7 +48,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
let (format, pixels) = image_info(&bytes).ok_or_else(|| bad(cmd, "only PNG and JPEG images can be inserted"))?;
let plane = super::sketch::plane_ref(s, p, cmd)?;
let (vals, _) = s.doc.param_values();
let pl = s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
let pl = s.doc.resolve_plane(&vals, &plane, 0)?;
// A decal sits where the face was picked.
let at = p.get("at").or_else(|| p.get("plane").and_then(|v| v.get("face"))).and_then(crate::params::vec3).map(|q| pl.to_local(q));
let center = p.get("center").and_then(vec2).or(at).unwrap_or(Vec2::ZERO);
@@ -57,8 +57,6 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
return Err(bad(cmd, "`width` must be positive"));
}
let opacity = num(p, "opacity").unwrap_or(if decal { 1.0 } else { 0.5 }).clamp(0.0, 1.0);
// It belongs to the active component (its plane is in that frame) and moves with it.
let component = s.active_component;
let doc = s.doc_mut();
let id = doc.canvases.iter().map(|c| c.id).max().unwrap_or(0) + 1;
let base = if decal { "Decal" } else { "Canvas" };
@@ -76,17 +74,16 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
opacity,
flip: bool_(p, "flip").unwrap_or(false),
visible: true,
component,
});
Ok(json!({"canvas": id, "name": name, "pixels": pixels}))
}
fn add_canvas(s: &mut Session, p: &Value) -> Result<Value> {
make(s, p, "canvas.insert", false)
make(s, p, "FusionAddCanvasCommand", false)
}
fn add_decal(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "canvas.decal";
let cmd = "FusionAddEditDecalCommand";
let face = p.get("face").cloned().ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on a planar face"))?;
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
@@ -177,7 +174,7 @@ mod tests {
#[test]
fn canvas_insert_edit_calibrate_save() {
let mut s = Session::default();
let r = s.execute("canvas.insert", &json!({"data": png(400, 200), "plane": "XZ", "width": 80})).unwrap();
let r = s.execute("FusionAddCanvasCommand", &json!({"data": png(400, 200), "plane": "XZ", "width": 80})).unwrap();
let id = r["canvas"].as_u64().unwrap();
let c = s.doc.canvases[0].clone();
assert_eq!(c.pixels, [400, 200]);
@@ -191,12 +188,12 @@ mod tests {
assert_eq!(back.canvases, s.doc.canvases);
s.execute("canvas.delete", &json!({"canvas": id})).unwrap();
assert!(s.doc.canvases.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
s.execute("UndoCommand", &json!({})).unwrap();
assert_eq!(s.doc.canvases.len(), 1);
assert!(s.execute("canvas.insert", &json!({"data": "bm9wZQ=="})).is_err());
assert!(s.execute("FusionAddCanvasCommand", &json!({"data": "bm9wZQ=="})).is_err());
// A decal on the top face of a box.
s.execute("solid.box", &json!({"length": 30, "width": 30, "height": 10})).unwrap();
let r = s.execute("canvas.decal", &json!({"data": png(10, 10), "face": [15, 15, 10], "width": 20})).unwrap();
s.execute("PrimitiveBox", &json!({"length": 30, "width": 30, "height": 10})).unwrap();
let r = s.execute("FusionAddEditDecalCommand", &json!({"data": png(10, 10), "face": [15, 15, 10], "width": 20})).unwrap();
assert!(r["canvas"].is_u64());
}
}
+12 -12
View File
@@ -11,32 +11,32 @@ use crate::params::{bad, num, req_vec2, str_, string_list, vec2};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.constraint.curvature", "Curvature", c_smooth)
CommandSpec::new("ConstraintSmooth", "Curvature", c_smooth)
.at("SKETCH", "CONSTRAINTS")
.icon("c_smooth")
.enabled(in_sketch)
.params("a, b: two curves sharing an end point (curvature continuous, G2)"),
CommandSpec::new("sketch.constraint.polygon", "Polygon", c_polygon)
CommandSpec::new("SketchPolygonConstraintCmd", "Polygon", c_polygon)
.at("SKETCH", "CONSTRAINTS")
.icon("c_polygon")
.enabled(in_sketch)
.params("lines: [line ids] of a closed chain: equal sides with every corner on one circle (a regular polygon)"),
CommandSpec::new("sketch.auto_constrain", "AutoConstrain", auto_constrain)
CommandSpec::new("SketchAutoConstraintAndDimCmd", "AutoConstrain", auto_constrain)
.at("SKETCH", "CONSTRAINTS")
.icon("auto_constrain")
.enabled(in_sketch)
.params("tolerance?: mm (default 0.01), angle_tolerance?: deg (default 1), dimensions?: bool (default true): infer constraints, then dimension until fully constrained"),
CommandSpec::new("sketch.auto_constrain_from_datum", "AutoConstrain from datum", auto_constrain_datum)
CommandSpec::new("SketchAutoConstraintAndDimFromDatumCmd", "AutoConstrain from datum", auto_constrain_datum)
.at("SKETCH", "CONSTRAINTS")
.icon("auto_constrain")
.enabled(in_sketch)
.params("datum: point ref (default origin); like AutoConstrain with positions measured from the datum"),
CommandSpec::new("sketch.finish_auto_constrain", "Finish with AutoConstrain", auto_constrain_finish)
CommandSpec::new("SketchAutoConstrainAndFinish", "Finish with AutoConstrain", auto_constrain_finish)
.at("SKETCH", "FINISH SKETCH")
.icon("finish")
.enabled(in_sketch)
.params("like AutoConstrain, then Finish Sketch"),
CommandSpec::new("sketch.constrainer", "Sketch Constrainer", smart_constrain)
CommandSpec::new("SketchConstrainer", "SketchConstrainer", smart_constrain)
.at("SKETCH", "CREATE")
.icon("constrainer")
.enabled(in_sketch)
@@ -55,7 +55,7 @@ pub static COMMANDS: &[CommandSpec] = &[
];
fn c_smooth(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.curvature";
let cmd = "ConstraintSmooth";
let mut before = None;
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let a = curve(sk, p.get("a"), cmd)?;
@@ -73,7 +73,7 @@ fn curve(sk: &Sketch, v: Option<&Value>, cmd: &str) -> Result<usize> {
}
fn c_polygon(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.polygon";
let cmd = "SketchPolygonConstraintCmd";
let ids = string_list(p, "lines");
if ids.len() < 3 || ids.len() > 200 {
return Err(bad(cmd, "`lines` must list 3 to 200 lines of a closed chain"));
@@ -258,13 +258,13 @@ fn auto(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
}
fn auto_constrain(s: &mut Session, p: &Value) -> Result<Value> {
auto(s, p, "sketch.auto_constrain")
auto(s, p, "SketchAutoConstraintAndDimCmd")
}
fn auto_constrain_datum(s: &mut Session, p: &Value) -> Result<Value> {
auto(s, p, "sketch.auto_constrain_from_datum")
auto(s, p, "SketchAutoConstraintAndDimFromDatumCmd")
}
fn auto_constrain_finish(s: &mut Session, p: &Value) -> Result<Value> {
let r = auto(s, p, "sketch.finish_auto_constrain")?;
let r = auto(s, p, "SketchAutoConstrainAndFinish")?;
s.active_sketch = None;
s.revision += 1;
Ok(r)
@@ -343,7 +343,7 @@ fn guess(sk: &Sketch, ents: &[String], cmd: &str) -> Result<ConstraintKind> {
}
fn smart_constrain(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constrainer";
let cmd = "SketchConstrainer";
let ents = string_list(p, "entities");
let mut before = None;
let mut name = "";
+42 -105
View File
@@ -16,7 +16,7 @@ fn ids(v: &Value) -> Vec<String> {
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
s
}
@@ -27,21 +27,21 @@ fn sketch(s: &Session) -> solvecraft_sketch::Sketch {
#[test]
fn redundant_constraints_and_dimensions_are_refused_driven_ones_measure() {
let mut s = new_sketch();
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"])[0].clone();
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"])[0].clone();
// Already horizontal (inferred).
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": l})).unwrap_err().to_string();
let e = s.execute("ConstraintHorizontalVertical", &json!({"line": l})).unwrap_err().to_string();
assert!(e.contains("over-constrain"), "{e}");
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{l}.start"), "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": 10}));
run(&mut s, "ConstraintCoincident", json!({"a": format!("{l}.start"), "b": "origin"}));
run(&mut s, "SketchDimension", json!({"entities": [l], "value": 10}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 0, "{si}");
// A second length on it over-constrains: refused, unless driven.
assert!(s.execute("sketch.dimension", &json!({"entities": [format!("{l}.start"), format!("{l}.end")]})).is_err());
let d = run(&mut s, "sketch.dimension", json!({"entities": [format!("{l}.start"), format!("{l}.end")], "driven": true}));
assert!(s.execute("SketchDimension", &json!({"entities": [format!("{l}.start"), format!("{l}.end")]})).is_err());
let d = run(&mut s, "SketchDimension", json!({"entities": [format!("{l}.start"), format!("{l}.end")], "driven": true}));
assert_eq!(d["driven"], true);
// The driven dimension follows the driving one.
let pname = si["constraints"].as_array().unwrap().iter().find_map(|c| c["param"].as_str().map(str::to_string)).unwrap();
run(&mut s, "parameters.change", json!({"name": pname, "expression": "25 mm"}));
run(&mut s, "ChangeParameterCommand", json!({"name": pname, "expression": "25 mm"}));
let g = run(&mut s, "sketch.glyphs", json!({}));
let driven = g["glyphs"].as_array().unwrap().iter().find(|x| x["driven"] == true).unwrap().clone();
assert!((driven["value"].as_f64().unwrap() - 25.0).abs() < 1e-7, "{driven}");
@@ -53,10 +53,9 @@ fn curvature_polygon_arc_length_linear_diameter() {
let mut s = new_sketch();
// A horizontal line ending where a control-point spline starts: G2 keeps the spline's
// start straight (zero curvature) and tangent.
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-10, 0], [0, 0]], "infer": true}))["curves"])[0].clone();
let sp =
ids(&run(&mut s, "sketch.spline.control_point", json!({"points": [format!("{l}.end"), [5, 2], [10, 6], [15, 15]]}))["curves"])[0].clone();
run(&mut s, "sketch.constraint.curvature", json!({"a": l, "b": sp}));
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-10, 0], [0, 0]], "infer": true}))["curves"])[0].clone();
let sp = ids(&run(&mut s, "DrawCVMSpline3D", json!({"points": [format!("{l}.end"), [5, 2], [10, 6], [15, 15]]}))["curves"])[0].clone();
run(&mut s, "ConstraintSmooth", json!({"a": l, "b": sp}));
let sk = sketch(&s);
let pts: Vec<Vec2> = sk.curves[sk.curve_index(&sp).unwrap()].kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect();
// Tangent: first leg along +x; zero curvature: the second leg too (collinear first three).
@@ -64,16 +63,16 @@ fn curvature_polygon_arc_length_linear_diameter() {
assert!((pts[2] - pts[1]).cross(pts[1] - pts[0]).abs() < 1e-3, "{pts:?}");
// Polygon constraint on a rough quadrilateral: becomes a square-ish regular polygon.
let q = run(&mut s, "sketch.line", json!({"points": [[100, 0], [111, 1], [110, 9], [99, 10]], "closed": true}));
let q = run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [111, 1], [110, 9], [99, 10]], "closed": true}));
let ql = ids(&q["curves"]);
run(&mut s, "sketch.constraint.polygon", json!({"lines": ql}));
run(&mut s, "SketchPolygonConstraintCmd", json!({"lines": ql}));
let sk = sketch(&s);
let lens: Vec<f64> = ql.iter().map(|i| sk.polyline(sk.curve_index(i).unwrap())).map(|p| p[0].dist(p[p.len() - 1])).collect();
assert!(lens.iter().all(|x| (x - lens[0]).abs() < 1e-6), "{lens:?}");
// Arc length.
let a = ids(&run(&mut s, "sketch.arc.center_point", json!({"center": [0, 50], "start": [10, 50], "end": [0, 60]}))["curves"])[0].clone();
run(&mut s, "sketch.dimension", json!({"entities": [a], "type": "arc_length", "value": 20}));
let a = ids(&run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 50], "start": [10, 50], "end": [0, 60]}))["curves"])[0].clone();
run(&mut s, "SketchDimension", json!({"entities": [a], "type": "arc_length", "value": 20}));
let sk = sketch(&s);
let ai = sk.curve_index(&a).unwrap();
let len = match sk.segs(ai)[0] {
@@ -83,10 +82,10 @@ fn curvature_polygon_arc_length_linear_diameter() {
assert!((len - 20.0).abs() < 1e-6, "{len}");
// Linear diameter about a centerline.
let cl = ids(&run(&mut s, "sketch.line", json!({"points": [[200, 0], [200, 50]]}))["curves"])[0].clone();
let cl = ids(&run(&mut s, "DrawPolyline", json!({"points": [[200, 0], [200, 50]]}))["curves"])[0].clone();
run(&mut s, "sketch.centerline", json!({"curves": [cl]}));
run(&mut s, "sketch.point", json!({"point": [207, 10], "id": "rim"}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["rim", cl], "value": 30}));
run(&mut s, "DrawPoint", json!({"point": [207, 10], "id": "rim"}));
let d = run(&mut s, "SketchDimension", json!({"entities": ["rim", cl], "value": 30}));
assert_eq!(d["type"], "LinearDiameterDimension", "{d}");
let sk = sketch(&s);
let rim = sk.point(sk.point_index("rim").unwrap()).unwrap();
@@ -99,7 +98,7 @@ fn curvature_polygon_arc_length_linear_diameter() {
#[test]
fn glyphs_and_snaps() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10], "connect": false}));
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
let l = ids(&r["curves"]);
let g = run(&mut s, "sketch.glyphs", json!({}));
assert_eq!(g["glyphs"].as_array().unwrap().len(), 4);
@@ -117,9 +116,9 @@ fn glyphs_and_snaps() {
fn auto_constrain_fully_constrains_a_rough_sketch() {
let mut s = new_sketch();
// A slightly crooked closed rectangle and a circle inside.
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0.004], [40.003, 30], [0, 30.002]], "closed": true}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 15], "radius": 5}));
let r = run(&mut s, "sketch.auto_constrain", json!({}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [40, 0.004], [40.003, 30], [0, 30.002]], "closed": true}));
run(&mut s, "CircleCenterRadius", json!({"center": [20, 15], "radius": 5}));
let r = run(&mut s, "SketchAutoConstraintAndDimCmd", json!({}));
assert_eq!(r["dof"], 0, "{r}");
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["fully_constrained"], true, "{si}");
@@ -128,9 +127,9 @@ fn auto_constrain_fully_constrains_a_rough_sketch() {
assert!(names.iter().any(|n| n == "DiameterDimension"), "{names:?}");
// From a datum, and finishing.
let mut s = new_sketch();
run(&mut s, "sketch.point", json!({"point": [5, 5], "id": "d"}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [20, 10]]}));
let r = run(&mut s, "sketch.finish_auto_constrain", json!({"datum": "d"}));
run(&mut s, "DrawPoint", json!({"point": [5, 5], "id": "d"}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [20, 10]]}));
let r = run(&mut s, "SketchAutoConstrainAndFinish", json!({"datum": "d"}));
assert!(s.active_sketch.is_none());
assert!(r["added"].as_array().unwrap().len() >= 3, "{r}");
}
@@ -138,11 +137,11 @@ fn auto_constrain_fully_constrains_a_rough_sketch() {
#[test]
fn drawing_infers_tangent_and_perpendicular() {
let mut s = new_sketch();
let l1 = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 10]], "infer": true}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("{l1}.end"), [20, 0]], "infer": true}));
let l1 = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 10]], "infer": true}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("{l1}.end"), [20, 0]], "infer": true}));
assert_eq!(r["constraints"].as_array().unwrap().len(), 1, "{r}");
let a = ids(&run(&mut s, "sketch.arc.center_point", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("{a}.end"), [30, 10]], "infer": true}));
let a = ids(&run(&mut s, "ArcCenterTwoPoint", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("{a}.end"), [30, 10]], "infer": true}));
let si = run(&mut s, "sketch.inspect", json!({}));
let names: Vec<&str> = si["constraints"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect();
assert!(names.contains(&"Perpendicular") && names.contains(&"Tangent"), "{names:?} {r}");
@@ -151,18 +150,18 @@ fn drawing_infers_tangent_and_perpendicular() {
#[test]
fn smart_constrain_and_driven_toggle() {
let mut s = new_sketch();
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0.5]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 5], [10, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.constrainer", json!({"entities": [a]}));
let a = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0.5]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 5], [10, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "SketchConstrainer", json!({"entities": [a]}));
assert_eq!(r["type"], "Horizontal");
let r = run(&mut s, "sketch.constrainer", json!({"entities": [a, b]}));
let r = run(&mut s, "SketchConstrainer", json!({"entities": [a, b]}));
assert_eq!(r["type"], "Parallel");
let c1 = ids(&run(&mut s, "sketch.circle.center", json!({"center": [50, 0], "radius": 5}))["curves"])[0].clone();
let c2 = ids(&run(&mut s, "sketch.circle.center", json!({"center": [50.3, 0.2], "radius": 3}))["curves"])[0].clone();
let r = run(&mut s, "sketch.constrainer", json!({"entities": [c1, c2]}));
let c1 = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [50, 0], "radius": 5}))["curves"])[0].clone();
let c2 = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [50.3, 0.2], "radius": 3}))["curves"])[0].clone();
let r = run(&mut s, "SketchConstrainer", json!({"entities": [c1, c2]}));
assert_eq!(r["type"], "Concentric");
// Driven toggle.
let d = run(&mut s, "sketch.dimension", json!({"entities": [c1], "value": 10}));
let d = run(&mut s, "SketchDimension", json!({"entities": [c1], "value": 10}));
let g = run(&mut s, "sketch.glyphs", json!({}));
let did = g["glyphs"].as_array().unwrap().iter().find(|x| x["param"] == d["param"]).unwrap()["id"].as_str().unwrap().to_string();
let r = run(&mut s, "sketch.toggle_driven", json!({"constraint": did}));
@@ -175,8 +174,8 @@ fn smart_constrain_and_driven_toggle() {
#[test]
fn drawing_from_a_line_midpoint_keeps_it_there() {
let mut s = new_sketch();
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("mid:{l}"), [10, 15]]}));
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("mid:{l}"), [10, 15]]}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert!(si["constraints"].as_array().unwrap().iter().any(|c| c["name"] == "MidPoint"), "{si}");
// Lengthen the first line: the second one's start follows the middle.
@@ -192,74 +191,12 @@ fn drawing_from_a_line_midpoint_keeps_it_there() {
#[test]
fn drawing_onto_a_curve_keeps_the_point_on_it() {
let mut s = new_sketch();
let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("on:{c}:7.2,7.1"), [30, 30]]}));
let c = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("on:{c}:7.2,7.1"), [30, 30]]}));
let n = ids(&r["curves"])[0].clone();
run(&mut s, "sketch.dimension", json!({"entities": [c], "value": 16}));
run(&mut s, "SketchDimension", json!({"entities": [c], "value": 16}));
let sk = sketch(&s);
let st = sk.resolve_point(&format!("{n}.start")).and_then(|i| sk.point(i)).unwrap();
let cc = sk.center(sk.curve_index(&c).unwrap()).unwrap();
assert!((st.dist(cc) - 8.0).abs() < 1e-7, "{st:?} {cc:?}");
}
/// A faucet spout path: a vertical riser, a horizontal top and an outlet sloping down, joined
/// by fillets. The riser points up and the outlet down-left, so their directions are 150° apart
/// while the lines meet at 30°.
fn spout() -> Session {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[795, 955], [795, 1068], [689, 1068], [661, 1020]], "ids": ["l1", "l2", "l3"]}));
run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 50}));
run(&mut s, "sketch.fillet", json!({"point": "l2.end", "radius": 35}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "vertical"}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "horizontal"}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 795}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 955}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l2.start"], "type": "vertical", "value": 1068}));
run(&mut s, "sketch.dimension", json!({"entities": ["l3.end", "l1.start"], "type": "horizontal", "value": 134}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l3.end"], "type": "vertical", "value": 1020}));
assert_eq!(d["sketch"]["dof"], 1, "{d}");
s
}
/// The angle (degrees, 0..=90) between two lines of the active sketch.
fn line_angle(s: &Session, a: &str, b: &str) -> f64 {
let sk = sketch(s);
let dir = |l: &str| {
let c = sk.curve_index(l).unwrap();
let (p, q) = crate::cmd::sketch::line_pts(&sk, c).unwrap();
q - p
};
let (u, v) = (dir(a), dir(b));
let t = u.cross(v).abs().atan2(u.dot(v)).to_degrees();
t.min(180.0 - t)
}
#[test]
fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degrees() {
// The riser and the outlet are not adjacent and meet at 30°; their directions are 150°
// apart. Asking for 30° used to swing the outlet round and was refused as over-constraining.
for ents in [["l1", "l3"], ["l3", "l1"]] {
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ents, "type": "angle", "value": "30 deg"}));
assert_eq!(d["sketch"]["dof"], 0, "{d}");
assert_eq!(d["unit"], "deg", "{d}");
assert!((d["measured"].as_f64().unwrap() - 30.3).abs() < 0.1, "{d}");
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
}
// The other reading still works, and the adjacent top line measures 60° the same way.
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"], "value": "150 deg"}));
assert_eq!(d["sketch"]["dof"], 0, "{d}");
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2", "l3"], "value": "60 deg"}));
assert!((d["measured"].as_f64().unwrap() - 59.74).abs() < 0.01, "{d}");
// Without a value the acute angle is kept as it is.
let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"]}));
let e = d["expression"].as_str().unwrap();
assert!(e.ends_with(" deg") && (e.trim_end_matches(" deg").parse::<f64>().unwrap() - 30.3).abs() < 0.1, "{d}");
// Lengths report millimetres.
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "driven": true}));
assert_eq!(d["unit"], "mm", "{d}");
}
@@ -1,98 +0,0 @@
//! Sketch Fillet and Chamfer at corners (#46): line–line, line–arc and arc–arc corners, the
//! corners of a constrained rectangle, several corners at once, and radii too large for the
//! corner (a clear error that leaves the sketch as it was).
use serde_json::{Value, json};
use crate::Session;
fn new_sketch() -> Session {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn curves(r: &Value) -> Vec<String> {
r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
fn sk(s: &Session) -> solvecraft_sketch::Sketch {
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.clone()
}
fn solved(s: &Session) -> bool {
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().report.ok()
}
fn area(s: &Session) -> f64 {
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().profiles.iter().map(|p| p.area).sum()
}
#[test]
fn fillet_and_chamfer_corners() {
use std::f64::consts::PI;
// A constrained 40 × 20 rectangle: a fillet r5 on one corner keeps it solved and closed,
// with the area of the corner taken off.
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
let c = curves(&r);
let a0 = area(&s);
run(&mut s, "sketch.fillet", json!({"a": c[0], "b": c[1], "radius": 5}));
assert!(solved(&s));
assert!((area(&s) - (a0 - (25.0 - PI * 25.0 / 4.0))).abs() < 1e-6, "{}", area(&s));
// Several corners at once: the rest of the rectangle's corners.
let pts: Vec<String> = {
let k = sk(&s);
let li = |id: &str| k.curve_index(id).unwrap();
[(1, 2), (2, 3), (3, 0)]
.iter()
.map(|(x, y)| {
let (cx, cy) = (&k.curves[li(&c[*x])].kind, &k.curves[li(&c[*y])].kind);
let shared = cx.point_ids().into_iter().find(|p| cy.point_ids().contains(p)).unwrap();
k.points[shared].id.clone()
})
.collect()
};
run(&mut s, "sketch.fillet", json!({"points": pts, "radius": 5}));
assert!(solved(&s));
assert!((area(&s) - (a0 - 4.0 * (25.0 - PI * 25.0 / 4.0))).abs() < 1e-6, "{}", area(&s));
// Too large for a corner: a clear error, nothing changed.
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
let c = curves(&r);
let n = sk(&s).curves.len();
let e = s.execute("sketch.fillet", &json!({"a": c[0], "b": c[1], "radius": 30})).unwrap_err().to_string();
assert!(e.contains("too large") || e.contains("does not fit"), "{e}");
assert_eq!(sk(&s).curves.len(), n);
let e = s.execute("sketch.chamfer.equal_distance", &json!({"a": c[0], "b": c[1], "distance": 25})).unwrap_err().to_string();
assert!(e.contains("too large") || e.contains("does not fit") || e.contains("longer"), "{e}");
assert_eq!(sk(&s).curves.len(), n);
// A line meeting an arc, and two arcs (a slot's end rounded into a line).
let mut s = new_sketch();
let l = curves(&run(&mut s, "sketch.line", json!({"points": [[-20, 10], [0, 10]]})))[0].clone();
let a = curves(&run(&mut s, "sketch.arc.three_point", json!({"start": format!("{l}.end"), "end": [10, 0], "through": [7.0710678, 7.0710678]})))
[0]
.clone();
run(&mut s, "sketch.fillet", json!({"a": l, "b": a, "radius": 2}));
assert!(solved(&s));
let mut s = new_sketch();
let a1 = curves(&run(&mut s, "sketch.arc.three_point", json!({"start": [-10, 0], "end": [0, 0], "through": [-5, -5]})))[0].clone();
let a2 = curves(&run(&mut s, "sketch.arc.three_point", json!({"start": format!("{a1}.end"), "end": [10, 0], "through": [5, 2]})))[0].clone();
run(&mut s, "sketch.fillet", json!({"a": a1, "b": a2, "radius": 1}));
assert!(solved(&s));
// Chamfers keep a closed profile.
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
let c = curves(&r);
let a0 = area(&s);
run(&mut s, "sketch.chamfer.equal_distance", json!({"a": c[0], "b": c[1], "distance": 4}));
assert!(solved(&s));
assert!((area(&s) - (a0 - 8.0)).abs() < 1e-6, "{}", area(&s));
}
+28 -28
View File
@@ -12,65 +12,65 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] =
&[
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
CommandSpec::new("SketchMidpointLine", "Midpoint Line", midpoint_line)
.at("SKETCH", "CREATE")
.icon("line_mid")
.enabled(in_sketch)
.params("mid: [x,y] or point ref, end: [x,y]; construction?"),
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(
CommandSpec::new("ArcTangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(
"start: end point of a line or arc (\"l1.end\"), end: [x,y]; curve?: the curve to be tangent to (default: the one ending at start)",
),
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan)
CommandSpec::new("CircleTanTanRadius", "2-Tangent Circle", circle_tan_tan)
.at("SKETCH", "CREATE")
.icon("circle_tt")
.enabled(in_sketch)
.params("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)"),
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", circle_three_tan)
CommandSpec::new("CircleThreeTangent", "3-Tangent Circle", circle_three_tan)
.at("SKETCH", "CREATE")
.icon("circle_ttt")
.enabled(in_sketch)
.params("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)"),
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point)
CommandSpec::new("ShapeSlotCenterPoint", "Center Point Slot", slot_center_point)
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params("center: slot centre, end: one arc centre, width"),
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three)
CommandSpec::new("ShapeArcSlotThreePoint", "Three Point Arc Slot", arc_slot_three)
.at("SKETCH", "CREATE")
.icon("arc_slot")
.enabled(in_sketch)
.params("start, through, end: points on the centre arc, width"),
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center)
CommandSpec::new("ShapeArcSlotCenterTwoPoint", "Center Point Arc Slot", arc_slot_center)
.at("SKETCH", "CREATE")
.icon("arc_slot")
.enabled(in_sketch)
.params("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width"),
CommandSpec::new("sketch.ellipse", "Ellipse", ellipse)
CommandSpec::new("CircleElipse", "Ellipse", ellipse)
.at("SKETCH", "CREATE")
.icon("ellipse")
.enabled(in_sketch)
.params("center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius"),
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit)
CommandSpec::new("DrawSpline", "Fit Point Spline", spline_fit)
.at("SKETCH", "CREATE")
.icon("spline")
.enabled(in_sketch)
.params("points: [[x,y] or point refs…] (2…500): the spline passes through them"),
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3)
CommandSpec::new("DrawCVMSpline3D", "Control Point Spline", spline_cv3)
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.params("points: control points (2…500), cubic"),
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", spline_cv5)
CommandSpec::new("DrawCVMSpline5D", "Control Point Spline (Degree 5)", spline_cv5)
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.params("points: control points (2…500), degree 5"),
CommandSpec::new("sketch.conic", "Conic Curve", conic)
CommandSpec::new("ConicCurveCmd", "Conic Curve", conic)
.at("SKETCH", "CREATE")
.icon("conic")
.enabled(in_sketch)
.params("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)"),
CommandSpec::new("sketch.blend_curve", "Blend Curve", blend)
CommandSpec::new("BlendG1CurveSketchCmd", "Blend Curve", blend)
.at("SKETCH", "MODIFY")
.icon("blend")
.enabled(in_sketch)
@@ -78,7 +78,7 @@ pub static COMMANDS: &[CommandSpec] =
CommandSpec::new("sketch.centerline", "Centerline", centerline)
.enabled(in_sketch)
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
CommandSpec::new("sketch.text", "Text", text)
CommandSpec::new("MTextCmd", "Text", text)
.at("SKETCH", "CREATE")
.icon("text")
.enabled(in_sketch)
@@ -107,7 +107,7 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> R
}
fn text(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.text";
let cmd = "MTextCmd";
if p.get("at").is_none() {
return Err(bad(cmd, "`at` must be the baseline start [x, y]"));
}
@@ -153,7 +153,7 @@ fn end_of(sk: &Sketch, pi: usize) -> Option<(usize, Vec2)> {
}
fn blend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.blend_curve";
let cmd = "BlendG1CurveSketchCmd";
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let pa = req_parg(sk, p, "a", cmd)?.idx().ok_or_else(|| bad(cmd, "`a` must be the end point of a curve"))?;
let pb = req_parg(sk, p, "b", cmd)?.idx().ok_or_else(|| bad(cmd, "`b` must be the end point of a curve"))?;
@@ -200,7 +200,7 @@ fn centerline(s: &mut Session, p: &Value) -> Result<Value> {
}
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.ellipse";
let cmd = "CircleElipse";
let (c, m) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "major")?);
let u = (m - c).normalized().ok_or_else(|| bad(cmd, "the major axis end must differ from the centre"))?;
let a = c.dist(m);
@@ -263,17 +263,17 @@ fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> R
}
fn spline_fit(s: &mut Session, p: &Value) -> Result<Value> {
spline(s, p, "sketch.spline.fit_point", false, 3)
spline(s, p, "DrawSpline", false, 3)
}
fn spline_cv3(s: &mut Session, p: &Value) -> Result<Value> {
spline(s, p, "sketch.spline.control_point", true, 3)
spline(s, p, "DrawCVMSpline3D", true, 3)
}
fn spline_cv5(s: &mut Session, p: &Value) -> Result<Value> {
spline(s, p, "sketch.spline.control_point_5", true, 5)
spline(s, p, "DrawCVMSpline5D", true, 5)
}
fn conic(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.conic";
let cmd = "ConicCurveCmd";
let rho = num(p, "rho").unwrap_or(0.5);
if !(rho > 1e-6 && rho < 1.0 - 1e-6) {
return Err(bad(cmd, "`rho` must be between 0 and 1"));
@@ -300,7 +300,7 @@ fn conic(s: &mut Session, p: &Value) -> Result<Value> {
}
fn midpoint_line(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.line.midpoint";
let cmd = "SketchMidpointLine";
let end = req_vec2(cmd, p, "end")?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let m = req_parg(sk, p, "mid", cmd)?;
@@ -369,7 +369,7 @@ fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option<Vec2> {
}
fn arc_tangent(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.arc.tangent";
let cmd = "ArcTangent";
let end = req_vec2(cmd, p, "end")?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let st = req_parg(sk, p, "start", cmd)?;
@@ -458,7 +458,7 @@ fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Optio
}
fn circle_tan_tan(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.circle.two_tangent";
let cmd = "CircleTanTanRadius";
let r = match (crate::params::expr(p, "radius"), num(p, "diameter")) {
(Some(r), _) => r,
(None, Some(d)) => format!("{}", d / 2.0),
@@ -468,7 +468,7 @@ fn circle_tan_tan(s: &mut Session, p: &Value) -> Result<Value> {
}
fn circle_three_tan(s: &mut Session, p: &Value) -> Result<Value> {
tangent_circle(s, p, "sketch.circle.three_tangent", 3, None)
tangent_circle(s, p, "CircleThreeTangent", 3, None)
}
fn width(p: &Value, cmd: &str) -> Result<f64> {
@@ -476,7 +476,7 @@ fn width(p: &Value, cmd: &str) -> Result<f64> {
}
fn slot_center_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.slot.center_point";
let cmd = "ShapeSlotCenterPoint";
let (c, e) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "end")?);
let w = width(p, cmd)?;
slot(s, p, cmd, c * 2.0 - e, e, w)
@@ -518,7 +518,7 @@ fn arc_slot(s: &mut Session, p: &Value, cmd: &str, o: Vec2, r: f64, t0: f64, swe
}
fn arc_slot_three(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.slot.arc_three_point";
let cmd = "ShapeArcSlotThreePoint";
let (a, t, b) = (req_vec2(cmd, p, "start")?, req_vec2(cmd, p, "through")?, req_vec2(cmd, p, "end")?);
let w = width(p, cmd)?;
let (o, r) = circumcircle(a, t, b).ok_or_else(|| bad(cmd, "the points are collinear"))?;
@@ -530,7 +530,7 @@ fn arc_slot_three(s: &mut Session, p: &Value) -> Result<Value> {
}
fn arc_slot_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.slot.arc_center";
let cmd = "ShapeArcSlotCenterTwoPoint";
let (o, a, b) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "start")?, req_vec2(cmd, p, "end")?);
let w = width(p, cmd)?;
let r = o.dist(a);
+33 -33
View File
@@ -44,53 +44,53 @@ fn random_sketch_editing_never_breaks() {
for seed in 1..=2u64 {
let mut r = Rng(0x9E37_79B9_7F4A_7C15 ^ seed);
let mut s = Session::default();
let _ = s.execute("solid.box", &json!({"length": 30, "width": 20, "height": 10}));
let _ = s.execute("sketch.create", &json!({"plane": {"face": [15, 10, 10]}}));
let _ = s.execute("PrimitiveBox", &json!({"length": 30, "width": 20, "height": 10}));
let _ = s.execute("SketchCreate", &json!({"plane": {"face": [15, 10, 10]}}));
for step in 0..120 {
let (curves, points, cons) = entities(&s);
let c = |r: &mut Rng| r.pick(&curves).cloned().unwrap_or_default();
let p = |r: &mut Rng| r.pick(&points).cloned().unwrap_or_default();
let (cmd, params) = match r.next() % 34 {
0 => ("sketch.line", json!({"points": [r.pt(), r.pt(), r.pt()], "infer": true})),
1 => ("sketch.circle.center", json!({"center": r.pt(), "radius": r.f(0.5, 20.0)})),
2 => ("sketch.arc.center_point", json!({"center": r.pt(), "start": r.pt(), "end": r.pt()})),
3 => ("sketch.rectangle.two_point", json!({"p0": r.pt(), "p1": r.pt()})),
4 => ("sketch.spline.fit_point", json!({"points": [r.pt(), r.pt(), r.pt(), r.pt()]})),
5 => ("sketch.ellipse", json!({"center": r.pt(), "major": r.pt(), "minor_radius": r.f(0.5, 10.0)})),
6 => ("sketch.conic", json!({"start": r.pt(), "end": r.pt(), "apex": r.pt(), "rho": r.f(0.1, 0.9)})),
7 => ("sketch.trim", json!({"curve": c(&mut r), "at": r.pt()})),
8 => ("sketch.extend", json!({"curve": c(&mut r), "at": r.pt()})),
9 => ("sketch.break", json!({"curve": c(&mut r), "at": r.pt()})),
10 => ("sketch.fillet", json!({"point": p(&mut r), "radius": r.f(0.1, 5.0)})),
11 => ("sketch.chamfer.equal_distance", json!({"point": p(&mut r), "distance": r.f(0.1, 5.0)})),
12 => ("sketch.offset", json!({"curves": [c(&mut r)], "distance": r.f(-5.0, 5.0), "side": r.pt()})),
13 => ("sketch.mirror", json!({"entities": [c(&mut r)], "line": c(&mut r)})),
14 => ("sketch.pattern.circular", json!({"entities": [c(&mut r)], "center": r.pt(), "count": 3})),
15 => ("sketch.constraint.coincident", json!({"a": p(&mut r), "b": p(&mut r)})),
16 => ("sketch.constraint.tangent", json!({"a": c(&mut r), "b": c(&mut r)})),
17 => ("sketch.constraint.parallel", json!({"a": c(&mut r), "b": c(&mut r)})),
18 => ("sketch.constraint.curvature", json!({"a": c(&mut r), "b": c(&mut r)})),
19 => ("sketch.dimension", json!({"entities": [c(&mut r)]})),
20 => ("sketch.dimension", json!({"entities": [p(&mut r), p(&mut r)], "driven": r.next().is_multiple_of(2)})),
0 => ("DrawPolyline", json!({"points": [r.pt(), r.pt(), r.pt()], "infer": true})),
1 => ("CircleCenterRadius", json!({"center": r.pt(), "radius": r.f(0.5, 20.0)})),
2 => ("ArcCenterTwoPoint", json!({"center": r.pt(), "start": r.pt(), "end": r.pt()})),
3 => ("ShapeRectangleTwoPoint", json!({"p0": r.pt(), "p1": r.pt()})),
4 => ("DrawSpline", json!({"points": [r.pt(), r.pt(), r.pt(), r.pt()]})),
5 => ("CircleElipse", json!({"center": r.pt(), "major": r.pt(), "minor_radius": r.f(0.5, 10.0)})),
6 => ("ConicCurveCmd", json!({"start": r.pt(), "end": r.pt(), "apex": r.pt(), "rho": r.f(0.1, 0.9)})),
7 => ("TrimSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})),
8 => ("ExtendSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})),
9 => ("BreakSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})),
10 => ("FilletSketchCmd", json!({"point": p(&mut r), "radius": r.f(0.1, 5.0)})),
11 => ("ChamferSketchEqualDistance", json!({"point": p(&mut r), "distance": r.f(0.1, 5.0)})),
12 => ("Offset", json!({"curves": [c(&mut r)], "distance": r.f(-5.0, 5.0), "side": r.pt()})),
13 => ("MirrorSketchCommand", json!({"entities": [c(&mut r)], "line": c(&mut r)})),
14 => ("CircularSketchPatternCommand", json!({"entities": [c(&mut r)], "center": r.pt(), "count": 3})),
15 => ("ConstraintCoincident", json!({"a": p(&mut r), "b": p(&mut r)})),
16 => ("ConstraintTangent", json!({"a": c(&mut r), "b": c(&mut r)})),
17 => ("ConstraintParallel", json!({"a": c(&mut r), "b": c(&mut r)})),
18 => ("ConstraintSmooth", json!({"a": c(&mut r), "b": c(&mut r)})),
19 => ("SketchDimension", json!({"entities": [c(&mut r)]})),
20 => ("SketchDimension", json!({"entities": [p(&mut r), p(&mut r)], "driven": r.next().is_multiple_of(2)})),
21 => ("sketch.delete", json!({"entities": [r.pick(&cons).cloned().unwrap_or_else(|| c(&mut r))]})),
22 => ("sketch.move_point", json!({"point": p(&mut r), "to": r.pt()})),
23 => ("sketch.project", json!({"refs": [{"edge": [r.f(0.0, 30.0), 0.0, 10.0]}]})),
23 => ("ProjectNewCmd", json!({"refs": [{"edge": [r.f(0.0, 30.0), 0.0, 10.0]}]})),
24 => ("sketch.break_link", json!({"entities": [c(&mut r)]})),
25 => ("sketch.constrainer", json!({"entities": [c(&mut r), c(&mut r)]})),
26 => ("sketch.auto_constrain", json!({})),
27 => ("sketch.text", json!({"text": "Ab", "at": r.pt(), "height": r.f(1.0, 8.0)})),
25 => ("SketchConstrainer", json!({"entities": [c(&mut r), c(&mut r)]})),
26 => ("SketchAutoConstraintAndDimCmd", json!({})),
27 => ("MTextCmd", json!({"text": "Ab", "at": r.pt(), "height": r.f(1.0, 8.0)})),
28 => ("sketch.toggle_driven", json!({"constraint": r.pick(&cons).cloned().unwrap_or_default()})),
29 => ("sketch.arc.tangent", json!({"start": format!("{}.end", c(&mut r)), "end": r.pt()})),
30 => ("sketch.scale", json!({"entities": [c(&mut r)], "base": r.pt(), "factor": r.f(0.5, 2.0)})),
31 => ("edit.undo", json!({})),
32 => ("sketch.slot.arc_three_point", json!({"start": r.pt(), "through": r.pt(), "end": r.pt(), "width": r.f(0.5, 4.0)})),
_ => ("edit.redo", json!({})),
29 => ("ArcTangent", json!({"start": format!("{}.end", c(&mut r)), "end": r.pt()})),
30 => ("SketchScaleCmd", json!({"entities": [c(&mut r)], "base": r.pt(), "factor": r.f(0.5, 2.0)})),
31 => ("UndoCommand", json!({})),
32 => ("ShapeArcSlotThreePoint", json!({"start": r.pt(), "through": r.pt(), "end": r.pt(), "width": r.f(0.5, 4.0)})),
_ => ("RedoCommand", json!({})),
};
if let Err(EngineError::Internal(id, msg)) = s.execute(cmd, &params) {
panic!("seed {seed} step {step}: {id} {params}: {msg}");
}
if s.active_sketch.is_none() {
let _ = s.execute("sketch.create", &json!({"plane": "XY"}));
let _ = s.execute("SketchCreate", &json!({"plane": "XY"}));
}
}
// The design round-trips.
+20 -20
View File
@@ -13,11 +13,11 @@ use crate::params::{bad, num, str_, vec2};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.insert_dxf", "Insert DXF", insert_dxf)
CommandSpec::new("ImportDxfFileCommand", "Insert DXF", insert_dxf)
.at("SKETCH", "INSERT")
.icon("dxf")
.params("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)"),
CommandSpec::new("sketch.insert_svg", "Insert SVG", insert_svg)
CommandSpec::new("SketchImportSVG", "Insert SVG", insert_svg)
.at("SKETCH", "INSERT")
.icon("svg")
.params("path | text: SVG; at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)"),
@@ -34,11 +34,11 @@ fn source_text(p: &Value, cmd: &str) -> Result<String> {
return Ok(t.to_string());
}
let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `text`"))?;
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta as usize > MAX_DRAWING_BYTES {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() as usize > MAX_DRAWING_BYTES {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(String::from_utf8_lossy(&bytes).to_string())
}
@@ -123,7 +123,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
if let Some(n) = str_(p, "name") {
cp["name"] = json!(n);
}
let r = (super::find_command("sketch.create").ok_or_else(|| bad(cmd, "sketch.create"))?.run)(s, &cp)?;
let r = (super::find_command("SketchCreate").ok_or_else(|| bad(cmd, "SketchCreate"))?.run)(s, &cp)?;
created = r.get("sketch").and_then(Value::as_u64);
}
let tol = num(p, "tolerance").filter(|t| t.is_finite() && *t > 0.0).unwrap_or(1e-4).clamp(1e-9, 10.0);
@@ -150,11 +150,11 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
}
fn insert_dxf(s: &mut Session, p: &Value) -> Result<Value> {
insert(s, p, "sketch.insert_dxf", false)
insert(s, p, "ImportDxfFileCommand", false)
}
fn insert_svg(s: &mut Session, p: &Value) -> Result<Value> {
insert(s, p, "sketch.insert_svg", true)
insert(s, p, "SketchImportSVG", true)
}
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
@@ -186,7 +186,7 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
}
let text = solvecraft_io::write_dxf(&geom);
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "entities": geom.len()}));
}
Ok(json!({"dxf": text, "entities": geom.len()}))
@@ -201,14 +201,14 @@ mod tests {
#[test]
fn dxf_and_svg_round_trip_into_sketches() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [30, 20]})).unwrap();
s.execute("sketch.circle.center", &json!({"center": [10, 10], "radius": 4})).unwrap();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
s.execute("ShapeRectangleTwoPoint", &json!({"p0": [0, 0], "p1": [30, 20]})).unwrap();
s.execute("CircleCenterRadius", &json!({"center": [10, 10], "radius": 4})).unwrap();
let out = s.execute("sketch.export_dxf", &json!({})).unwrap();
let dxf = out["dxf"].as_str().unwrap().to_string();
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("SketchStop", &json!({})).unwrap();
// Insert it again as a new sketch, offset: same profiles.
let r = s.execute("sketch.insert_dxf", &json!({"text": dxf, "at": [100, 0]})).unwrap();
let r = s.execute("ImportDxfFileCommand", &json!({"text": dxf, "at": [100, 0]})).unwrap();
assert_eq!(r["curves"], 5, "{r}");
let id = r["sketch"].as_u64().unwrap();
let ss = s.model.state().sketch(id).unwrap().clone();
@@ -219,19 +219,19 @@ mod tests {
// SVG with a path and text, into the active sketch.
let svg =
r#"<svg width="40mm" height="40mm" viewBox="0 0 40 40"><path d="M0 0 H40 V40 H0 Z"/><text x="5" y="20" font-size="10">Hi</text></svg>"#;
let r = s.execute("sketch.insert_svg", &json!({"text": svg})).unwrap();
let r = s.execute("SketchImportSVG", &json!({"text": svg})).unwrap();
assert_eq!(r["curves"], 4, "{r}");
assert_eq!(r["texts"], 1, "{r}");
assert!(s.execute("sketch.insert_svg", &json!({"text": "<svg></svg>"})).is_err());
assert!(s.execute("sketch.insert_dxf", &json!({"path": "/nonexistent.dxf"})).is_err());
s.execute("sketch.finish", &json!({})).unwrap();
assert!(s.execute("SketchImportSVG", &json!({"text": "<svg></svg>"})).is_err());
assert!(s.execute("ImportDxfFileCommand", &json!({"path": "/nonexistent.dxf"})).is_err());
s.execute("SketchStop", &json!({})).unwrap();
// Ends that miss by 0.00005 still close the square; with a tighter tolerance they don't.
let gappy = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n10\n21\n0\n0\nLINE\n10\n10.00005\n20\n0\n11\n10\n21\n10\n0\nLINE\n10\n10\n20\n10\n11\n0\n21\n10\n0\nLINE\n10\n0\n20\n10.00003\n11\n0\n21\n0.00004\n0\nENDSEC\n0\nEOF\n";
for (tol, n) in [(1e-4, 1), (1e-6, 0)] {
let r = s.execute("sketch.insert_dxf", &json!({"text": gappy, "tolerance": tol})).unwrap();
let r = s.execute("ImportDxfFileCommand", &json!({"text": gappy, "tolerance": tol})).unwrap();
let ss = s.model.state().sketch(r["sketch"].as_u64().unwrap()).unwrap().clone();
assert_eq!(ss.profiles.len(), n, "tolerance {tol}");
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("SketchStop", &json!({})).unwrap();
}
}
}
+28 -28
View File
@@ -9,22 +9,22 @@ use crate::params::{bad, num, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("inspect.curvature_comb", "Curvature Comb Analysis", curvature_comb)
CommandSpec::new("FusionCurvatureCombAnalysisCommand", "Curvature Comb Analysis", curvature_comb)
.at("SKETCH", "INSPECT")
.icon("curvature_comb")
.noundo()
.params("curves?: [sketch curve ids] (sketch?: id|name, default active) | edges?: [[x,y,z] points on body edges]; density?: teeth per curve (default 40), scale?: comb length per unit curvature (default auto)"),
CommandSpec::new("inspect.minimum_radius", "Minimum Radius Analysis", minimum_radius)
CommandSpec::new("FusionMinimumRadiusAnalysisCommand", "Minimum Radius Analysis", minimum_radius)
.at("SKETCH", "INSPECT")
.icon("min_radius")
.noundo()
.params("curves?: [sketch curve ids] (sketch?) | edges?: [[x,y,z]…] | faces?: [[x,y,z]…] (default: every face of every body); the smallest radius of curvature of each and where it is"),
CommandSpec::new("inspect.isocurve", "Isocurve Analysis", isocurves)
CommandSpec::new("FusionIsoCurveAnalysisCommand", "Isocurve Analysis", isocurves)
.at("SKETCH", "INSPECT")
.icon("iso_analysis")
.noundo()
.params("faces: [[x,y,z]…] points on faces; count?: curves each way (default 8): the faces' isoparametric curves"),
CommandSpec::new("inspect.center_of_mass", "Center of Mass", center_of_mass)
CommandSpec::new("FusionCenterOfMassCommand", "Center of Mass", center_of_mass)
.at("SKETCH", "INSPECT")
.icon("center_of_mass")
.noundo()
@@ -73,7 +73,7 @@ fn teeth_3d(pts: &[Vec3]) -> Vec<(Vec3, f64, Vec3)> {
}
fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.curvature_comb";
let cmd = "FusionCurvatureCombAnalysisCommand";
let density = num(p, "density").map(|d| d.clamp(2.0, 2000.0) as usize).unwrap_or(40);
let st = s.model.state();
let mut combs: Vec<(String, Vec<(Vec3, f64, Vec3)>)> = Vec::new();
@@ -210,7 +210,7 @@ fn face_min_radius(m: &solvecraft_geom::Mesh, face: u32) -> Option<(f64, Vec3)>
}
fn minimum_radius(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.minimum_radius";
let cmd = "FusionMinimumRadiusAnalysisCommand";
let st = s.model.state();
let mut items: Vec<(String, Option<(f64, Vec3)>)> = Vec::new();
let curves = string_list(p, "curves");
@@ -282,7 +282,7 @@ fn minimum_radius(s: &mut Session, p: &Value) -> Result<Value> {
}
fn isocurves(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.isocurve";
let cmd = "FusionIsoCurveAnalysisCommand";
let count = num(p, "count").map(|c| c.clamp(1.0, 100.0) as usize).unwrap_or(8);
let faces = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?;
if faces.is_empty() {
@@ -302,7 +302,7 @@ fn isocurves(s: &mut Session, p: &Value) -> Result<Value> {
}
fn center_of_mass(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.center_of_mass";
let cmd = "FusionCenterOfMassCommand";
let names = string_list(p, "bodies");
let st = s.model.state();
let mut out = Vec::new();
@@ -332,10 +332,10 @@ mod tests {
#[test]
fn curvature_combs() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let c = s.execute("sketch.circle.center", &json!({"center": [0, 0], "radius": 5})).unwrap()["curves"][0].clone();
let l = s.execute("sketch.line", &json!({"points": [[0, 20], [10, 20]]})).unwrap()["curves"][0].clone();
let r = s.execute("inspect.curvature_comb", &json!({"curves": [c, l], "density": 20})).unwrap();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
let c = s.execute("CircleCenterRadius", &json!({"center": [0, 0], "radius": 5})).unwrap()["curves"][0].clone();
let l = s.execute("DrawPolyline", &json!({"points": [[0, 20], [10, 20]]})).unwrap()["curves"][0].clone();
let r = s.execute("FusionCurvatureCombAnalysisCommand", &json!({"curves": [c, l], "density": 20})).unwrap();
let circle = &r["combs"][0];
assert!((circle["min_radius"].as_f64().unwrap() - 5.0).abs() < 1e-3, "{circle}");
assert!(r["combs"][1]["min_radius"].is_null(), "a line is straight");
@@ -343,39 +343,39 @@ mod tests {
let t = &circle["teeth"][3];
let (a, b) = ((t[0][0].as_f64().unwrap(), t[0][1].as_f64().unwrap()), (t[1][0].as_f64().unwrap(), t[1][1].as_f64().unwrap()));
assert!(b.0 * b.0 + b.1 * b.1 > a.0 * a.0 + a.1 * a.1);
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.cylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
let r = s.execute("inspect.curvature_comb", &json!({"edges": [[58, 0, 10]]})).unwrap();
s.execute("SketchStop", &json!({})).unwrap();
s.execute("PrimitiveCylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
let r = s.execute("FusionCurvatureCombAnalysisCommand", &json!({"edges": [[58, 0, 10]]})).unwrap();
assert!((r["combs"][0]["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.5, "{r}");
assert!(s.execute("inspect.curvature_comb", &json!({})).is_err());
assert!(s.execute("FusionCurvatureCombAnalysisCommand", &json!({})).is_err());
}
#[test]
fn minimum_radius_and_center_of_mass() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let e = s.execute("sketch.ellipse", &json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4})).unwrap()["curves"][0].clone();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
let e = s.execute("CircleElipse", &json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4})).unwrap()["curves"][0].clone();
// An ellipse's tightest bend is at the major axis ends: b²/a.
let r = s.execute("inspect.minimum_radius", &json!({"curves": [e]})).unwrap();
let r = s.execute("FusionMinimumRadiusAnalysisCommand", &json!({"curves": [e]})).unwrap();
assert!((r["min_radius"].as_f64().unwrap() - 1.6).abs() < 1e-3, "{r}");
assert!((r["at"][0].as_f64().unwrap().abs() - 10.0).abs() < 0.1, "{r}");
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.cylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
s.execute("SketchStop", &json!({})).unwrap();
s.execute("PrimitiveCylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
// The cylinder's side face: radius 8 (the mesh estimate is close).
let r = s.execute("inspect.minimum_radius", &json!({"faces": [[58, 0, 5]]})).unwrap();
let r = s.execute("FusionMinimumRadiusAnalysisCommand", &json!({"faces": [[58, 0, 5]]})).unwrap();
assert!((r["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.4, "{r}");
let r = s.execute("inspect.minimum_radius", &json!({})).unwrap();
let r = s.execute("FusionMinimumRadiusAnalysisCommand", &json!({})).unwrap();
assert!((r["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.4, "{r}");
// Centre of mass: a box beside the cylinder.
s.execute("solid.box", &json!({"corner": [0, 0, 0], "length": 10, "width": 10, "height": 10})).unwrap();
let r = s.execute("inspect.center_of_mass", &json!({})).unwrap();
s.execute("PrimitiveBox", &json!({"corner": [0, 0, 0], "length": 10, "width": 10, "height": 10})).unwrap();
let r = s.execute("FusionCenterOfMassCommand", &json!({})).unwrap();
let (vb, vc) = (1000.0, std::f64::consts::PI * 640.0);
let want = (5.0 * vb + 50.0 * vc) / (vb + vc);
assert!((r["center"][0].as_f64().unwrap() - want).abs() < 0.1, "{r}");
assert!((r["center"][2].as_f64().unwrap() - 5.0).abs() < 0.05, "{r}");
assert!(s.execute("inspect.center_of_mass", &json!({"bodies": ["nope"]})).is_err());
assert!(s.execute("FusionCenterOfMassCommand", &json!({"bodies": ["nope"]})).is_err());
// Isocurves on the cylinder's side: rings around it and lines along it.
let r = s.execute("inspect.isocurve", &json!({"faces": [[58, 0, 5]], "count": 4})).unwrap();
let r = s.execute("FusionIsoCurveAnalysisCommand", &json!({"faces": [[58, 0, 5]], "count": 4})).unwrap();
let curves = r["curves"].as_array().unwrap();
let ring =
|c: &Value| c.as_array().unwrap().iter().all(|q| ((q[0].as_f64().unwrap() - 50.0).hypot(q[1].as_f64().unwrap()) - 8.0).abs() < 0.2);
@@ -386,6 +386,6 @@ mod tests {
z.iter().cloned().fold(f64::NEG_INFINITY, f64::max) - z.iter().cloned().fold(f64::INFINITY, f64::min) < 1e-6
};
assert!(curves.iter().filter(|c| flat(c)).count() >= 4, "rings at constant height");
assert!(s.execute("inspect.isocurve", &json!({"faces": []})).is_err());
assert!(s.execute("FusionIsoCurveAnalysisCommand", &json!({"faces": []})).is_err());
}
}
+62 -115
View File
@@ -13,49 +13,49 @@ use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.fillet", "Fillet", fillet)
CommandSpec::new("FilletSketchCmd", "Fillet", fillet)
.at("SKETCH", "MODIFY")
.icon("sketch_fillet")
.enabled(in_sketch)
.params("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal) | a, b: two lines or arcs meeting at a corner; radius: expr"),
CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal)
.params("point: corner shared by two curves (\"l1.end\") | a, b: two lines or arcs meeting at a corner; radius: expr"),
CommandSpec::new("ChamferSketchEqualDistance", "Equal Distance Chamfer", chamfer_equal)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.enabled(in_sketch)
.params("point | a, b: a corner of two lines; distance"),
CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", chamfer_dist_angle)
CommandSpec::new("ChamferSketchDistanceAngle", "Distance and Angle Chamfer", chamfer_dist_angle)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.enabled(in_sketch)
.params("point | a, b: a corner of two lines; distance: along a, angle: deg from a"),
CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", chamfer_two)
CommandSpec::new("ChamferSketchDistanceDistance", "Two Distance Chamfer", chamfer_two)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.enabled(in_sketch)
.params("point | a, b: a corner of two lines; distance: along a, distance2: along b"),
CommandSpec::new("sketch.offset", "Offset", offset)
CommandSpec::new("Offset", "Offset", offset)
.at("SKETCH", "MODIFY")
.icon("offset")
.key("O")
.enabled(in_sketch)
.params("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)"),
CommandSpec::new("sketch.trim", "Trim", trim)
CommandSpec::new("TrimSketchCmd", "Trim", trim)
.at("SKETCH", "MODIFY")
.icon("trim")
.key("T")
.enabled(in_sketch)
.params("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it)"),
CommandSpec::new("sketch.extend", "Extend", extend)
CommandSpec::new("ExtendSketchCmd", "Extend", extend)
.at("SKETCH", "MODIFY")
.icon("extend")
.enabled(in_sketch)
.params("curve: id, at: [x,y] near the end to extend (to the nearest curve)"),
CommandSpec::new("sketch.break", "Break", break_cmd)
CommandSpec::new("BreakSketchCmd", "Break", break_cmd)
.at("SKETCH", "MODIFY")
.icon("break")
.enabled(in_sketch)
.params("curve: id, at: [x,y] (split at the nearest intersections around it) | params: split at points: [[x,y]…]"),
CommandSpec::new("sketch.scale", "Sketch Scale", scale)
CommandSpec::new("SketchScaleCmd", "Sketch Scale", scale)
.at("SKETCH", "MODIFY")
.icon("scale")
.enabled(in_sketch)
@@ -63,17 +63,17 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy)
.enabled(in_sketch)
.params("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool"),
CommandSpec::new("sketch.mirror", "Mirror", mirror)
CommandSpec::new("MirrorSketchCommand", "Mirror", mirror)
.at("SKETCH", "CREATE")
.icon("sketch_mirror")
.enabled(in_sketch)
.params("entities: [curve/point ids], line: mirror line id (symmetry constraints are added)"),
CommandSpec::new("sketch.pattern.circular", "Circular Pattern", pattern_circular)
CommandSpec::new("CircularSketchPatternCommand", "Circular Pattern", pattern_circular)
.at("SKETCH", "CREATE")
.icon("sketch_circ_pattern")
.enabled(in_sketch)
.params("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360)"),
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
CommandSpec::new("RectangularSketchPatternCommand", "Rectangular Pattern", pattern_rect)
.at("SKETCH", "CREATE")
.icon("sketch_rect_pattern")
.enabled(in_sketch)
@@ -253,7 +253,7 @@ fn bracket(sk: &Sketch, ci: usize, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) {
}
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.trim";
let cmd = "TrimSketchCmd";
let at = req_vec2(cmd, p, "at")?;
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let ci = curve_arg(sk, p, cmd)?;
@@ -313,7 +313,7 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
}
fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.break";
let cmd = "BreakSketchCmd";
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let ci = curve_arg(sk, p, cmd)?;
if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) {
@@ -349,7 +349,7 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
}
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.extend";
let cmd = "ExtendSketchCmd";
let at = req_vec2(cmd, p, "at")?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let ci = curve_arg(sk, p, cmd)?;
@@ -376,13 +376,11 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
if oi == ci {
continue;
}
// Lines and circles exactly; free-form curves (splines, ellipses, conics) by the
// pieces of their polyline.
let pieces: Vec<Shape> = match sk.shape(oi) {
Some(other) => vec![other],
None => sk.polyline(oi).windows(2).map(|w| Shape::Line { a: w[0], b: w[1] }).collect(),
};
for q in pieces.iter().flat_map(|other| intersections(&unbounded, other).into_iter().filter(|q| on_bounded(other, *q).is_some())) {
let Some(other) = sk.shape(oi) else { continue };
for q in intersections(&unbounded, &other) {
if on_bounded(&other, q).is_none() {
continue;
}
let t = sh.param(q);
let gap = match sh {
Shape::Line { a, b } => {
@@ -478,69 +476,50 @@ fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: us
}
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.fillet";
let cmd = "FilletSketchCmd";
let r_expr = expr(p, "radius").ok_or_else(|| bad(cmd, "`radius` must be a number or an expression"))?;
// Several corners at once (`points`), each like a single `point`.
let corners: Vec<Value> = match p.get("points").and_then(Value::as_array) {
Some(a) if !a.is_empty() && a.len() <= 500 => a.iter().map(|q| json!({ "point": q })).collect(),
Some(_) => return Err(bad(cmd, "`points` must list 1…500 corners")),
None => vec![p.clone()],
};
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let r = doc.eval(&r_expr, solvecraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("radius: {e}")))?;
if !(r > 1e-9 && r < 1e8) {
return Err(bad(cmd, "radius must be positive"));
}
let pname = doc.new_model_param(&r_expr, "mm");
let (mut arcs, mut cons) = (Vec::new(), Vec::new());
for q in &corners {
let (arc, mut c) = fillet_corner(sk, q, r, cmd)?;
// One radius for them all: the first carries the dimension, the rest are equal to it.
match arcs.first() {
None => c.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?),
Some(first) => c.push(add_c(sk, ConstraintKind::Equal { a: *first, b: arc })?),
let (la, lb, c, fa, fb) = match corner(sk, p, cmd) {
Ok(x) => x,
// Not two lines: a corner with an arc.
Err(e) => {
let Some((arc, cons)) = round_corner(sk, p, r, cmd)? else { return Err(e) };
let mut cons = cons;
let pname = doc.new_model_param(&r_expr, "mm");
cons.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?);
return Ok((ids_of(sk, &[arc]), cons, pname));
}
cons.extend(c);
arcs.push(arc);
};
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs();
if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-9 {
return Err(bad(cmd, "the lines are parallel"));
}
Ok((ids_of(sk, &arcs), cons, pname))
let d = r / half.tan();
let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?;
let center = pc + bis * (r / half.sin());
let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?;
let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default());
let ccw = (qa - center).cross(qb - center) > 0.0;
let arc = if ccw {
sk.add_arc(center, qa, qb, [None, Some(ta), Some(tb)], None)?
} else {
sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)?
};
let mut cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?];
let pname = doc.new_model_param(&r_expr, "mm");
cons.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?);
Ok((ids_of(sk, &[arc]), cons, pname))
})?;
Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "sketch": info}))
}
/// Round one corner (`point` or `a`, `b`) with an arc of radius `r` tangent to both curves:
/// the arc and the tangent constraints.
fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec<String>)> {
let (la, lb, c, fa, fb) = match corner(sk, p, cmd) {
Ok(x) => x,
// Not two lines: a corner with an arc.
Err(e) => return round_corner(sk, p, r, cmd)?.ok_or(e),
};
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs();
if !(1e-6..=std::f64::consts::FRAC_PI_2 - 1e-9).contains(&half) {
return Err(bad(cmd, "the lines are parallel"));
}
let d = r / half.tan();
if d >= pa.dist(pc) - 1e-9 || d >= pb.dist(pc) - 1e-9 {
return Err(bad(cmd, format!("the radius is too large for that corner (it needs {d:.3} mm of each line)")));
}
let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?;
let center = pc + bis * (r / half.sin());
let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?;
let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default());
let ccw = (qa - center).cross(qb - center) > 0.0;
let arc = if ccw {
sk.add_arc(center, qa, qb, [None, Some(ta), Some(tb)], None)?
} else {
sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)?
};
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?];
Ok((arc, cons))
}
/// Fillet a corner where a line meets an arc, or two arcs meet: the arc of radius `r` touching
/// both, nearest the corner; both curves are cut back to it. None when the corner is not of
/// that kind.
@@ -631,20 +610,20 @@ fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Res
}
fn chamfer_equal(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.chamfer.equal_distance";
let cmd = "ChamferSketchEqualDistance";
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
chamfer(s, p, cmd, |_| Ok((d, d)))
}
fn chamfer_two(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.chamfer.two_distance";
let cmd = "ChamferSketchDistanceDistance";
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
let d2 = num(p, "distance2").ok_or_else(|| bad(cmd, "`distance2` must be a number"))?;
chamfer(s, p, cmd, |_| Ok((d, d2)))
}
fn chamfer_dist_angle(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.chamfer.distance_angle";
let cmd = "ChamferSketchDistanceAngle";
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
let a = num(p, "angle").ok_or_else(|| bad(cmd, "`angle` must be a number (degrees)"))?.to_radians();
chamfer(s, p, cmd, |phi| {
@@ -787,7 +766,7 @@ fn piece_dist(p: &Piece, q: Vec2) -> f64 {
}
fn offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.offset";
let cmd = "Offset";
let ids = string_list(p, "curves");
if ids.is_empty() {
return Err(bad(cmd, "`curves` must list curves"));
@@ -813,12 +792,6 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
}
let ci = sk.add_circle(cc, nr, Some(c), None)?;
made.push(ci);
sk.offsets.push(solvecraft_sketch::OffsetChain {
sources: vec![(ids[0].clone(), true)],
outputs: ids_of(sk, &made),
distance: r - nr,
closed: true,
});
return Ok((ids_of(sk, &made), cons));
}
let pool: Vec<usize> = if use_chain {
@@ -874,18 +847,6 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
if !closed {
joints.push(end_of(&pieces[n - 1]));
}
// A straight piece whose joints come out reversed (or meet) was overrun by its
// neighbours: the offset is larger than the shape allows there.
for (k, pc) in pieces.iter().enumerate() {
if let Piece::Line(a, b) = pc {
let (ja, jb) = (joints.get(k).copied(), joints.get(if closed { (k + 1) % n } else { k + 1 }).copied());
if let (Some(ja), Some(jb)) = (ja, jb)
&& (jb - ja).dot(*b - *a) <= TOL * a.dist(*b)
{
return Err(bad(cmd, "the offset is larger than the shape allows (the offset curves would cross)"));
}
}
}
let pts: Vec<usize> = joints.iter().map(|q| sk.add_point(*q, None)).collect::<std::result::Result<_, _>>()?;
for (k, pc) in pieces.iter().enumerate() {
let (pa, pb) = (pts[k], if closed { pts[(k + 1) % n] } else { pts[k + 1] });
@@ -922,13 +883,7 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
let outputs = ids_of(sk, &made);
if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) {
let sources = order.iter().filter_map(|(i, forward)| sk.curves.get(*i).map(|c| (c.id.clone(), *forward))).collect();
sk.offsets.push(solvecraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed });
sk.refresh_offsets()?;
}
Ok((outputs, cons))
Ok((ids_of(sk, &made), cons))
})?;
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
}
@@ -1033,7 +988,7 @@ fn copy_through(
}
fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.mirror";
let cmd = "MirrorSketchCommand";
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (cs, ps) = entities(sk, p, cmd)?;
let lid = str_(p, "line").ok_or_else(|| bad(cmd, "`line` must be the mirror line"))?;
@@ -1088,7 +1043,7 @@ fn rotate(c: Vec2, ang: f64) -> impl Fn(Vec2) -> Vec2 {
}
fn pattern_circular(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.pattern.circular";
let cmd = "CircularSketchPatternCommand";
let n = count(p, "count", cmd)?;
let total = num(p, "angle").unwrap_or(360.0);
if !(total.abs() > 1e-9 && total.abs() <= 360.0) {
@@ -1111,7 +1066,7 @@ fn pattern_circular(s: &mut Session, p: &Value) -> Result<Value> {
}
fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.pattern.rectangular";
let cmd = "RectangularSketchPatternCommand";
let n1 = count(p, "count", cmd)?;
let s1 = num(p, "spacing").ok_or_else(|| bad(cmd, "`spacing` must be a number"))?;
let n2 = if p.get("count2").is_some() { count(p, "count2", cmd)? } else { 1 };
@@ -1179,7 +1134,7 @@ fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.scale";
let cmd = "SketchScaleCmd";
let k = num(p, "factor").filter(|k| *k > 1e-9 && *k < 1e6).ok_or_else(|| bad(cmd, "`factor` must be a positive number"))?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (cs, ps) = entities(sk, p, cmd)?;
@@ -1194,11 +1149,3 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
#[path = "sketch_modify_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "sketch_robust_tests.rs"]
mod robust_tests;
#[cfg(test)]
#[path = "sketch_corner_tests.rs"]
mod corner_tests;
+105 -108
View File
@@ -22,7 +22,7 @@ fn sketch(s: &Session) -> solvecraft_sketch::Sketch {
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
s
}
@@ -41,10 +41,10 @@ fn close(a: f64, b: f64) -> bool {
fn trim_extend_break() {
let mut s = new_sketch();
// A cross: horizontal line crossed by a vertical one.
let h = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let v = ids(&run(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 5]]}))["curves"])[0].clone();
let h = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let v = ids(&run(&mut s, "DrawPolyline", json!({"points": [[10, -5], [10, 5]]}))["curves"])[0].clone();
// Trim the right half of the horizontal line.
run(&mut s, "sketch.trim", json!({"curve": h, "at": [15, 0]}));
run(&mut s, "TrimSketchCmd", json!({"curve": h, "at": [15, 0]}));
let sk = sketch(&s);
let sh = sk.shape(sk.curve_index(&h).unwrap()).unwrap();
assert!(
@@ -52,32 +52,32 @@ fn trim_extend_break() {
"{sh:?}"
);
// Trim the lower half of the vertical line: the original id survives on the upper piece.
run(&mut s, "sketch.trim", json!({"curve": v, "at": [10, -3]}));
run(&mut s, "TrimSketchCmd", json!({"curve": v, "at": [10, -3]}));
let sk = sketch(&s);
assert_eq!(sk.curves.len(), 2);
// Extend: a short line reaching toward the vertical one.
let e = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 3], [4, 3]]}))["curves"])[0].clone();
run(&mut s, "sketch.extend", json!({"curve": e, "at": [4, 3]}));
let e = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 3], [4, 3]]}))["curves"])[0].clone();
run(&mut s, "ExtendSketchCmd", json!({"curve": e, "at": [4, 3]}));
let sk = sketch(&s);
let end = sk.resolve_point(&format!("{e}.end")).and_then(|i| sk.point(i)).unwrap();
assert!(end.dist(Vec2::new(10.0, 3.0)) < 1e-7, "{end:?}");
// Break the horizontal line where... nothing crosses it inside: error.
assert!(s.execute("sketch.break", &json!({"curve": h, "at": [5, 0]})).is_err());
assert!(s.execute("BreakSketchCmd", &json!({"curve": h, "at": [5, 0]})).is_err());
// Break the vertical at the extended line: two pieces.
let r = run(&mut s, "sketch.break", json!({"curve": v, "at": [10, 1]}));
let r = run(&mut s, "BreakSketchCmd", json!({"curve": v, "at": [10, 1]}));
assert_eq!(ids(&r["curves"]).len(), 2, "{r}");
// A lone curve trims away entirely.
let lone = ids(&run(&mut s, "sketch.line", json!({"points": [[50, 50], [60, 50]]}))["curves"])[0].clone();
run(&mut s, "sketch.trim", json!({"curve": lone, "at": [55, 50]}));
let lone = ids(&run(&mut s, "DrawPolyline", json!({"points": [[50, 50], [60, 50]]}))["curves"])[0].clone();
run(&mut s, "TrimSketchCmd", json!({"curve": lone, "at": [55, 50]}));
assert!(sketch(&s).curve_index(&lone).is_none());
}
#[test]
fn trim_circle_makes_an_arc_and_a_profile() {
let mut s = new_sketch();
let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]]}));
run(&mut s, "sketch.trim", json!({"curve": c, "at": [0, -10]}));
let c = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [[-20, 0], [20, 0]]}));
run(&mut s, "TrimSketchCmd", json!({"curve": c, "at": [0, -10]}));
let sk = sketch(&s);
let ci = sk.curve_index(&c).unwrap();
assert!(matches!(sk.curves[ci].kind, CurveKind::Arc { .. }));
@@ -90,65 +90,65 @@ fn trim_circle_makes_an_arc_and_a_profile() {
#[test]
fn fillet_and_chamfers() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
let l = ids(&r["curves"]);
// Pin the rectangle so a dimension change only moves the fillet.
run(&mut s, "sketch.constraint.fix", json!({"entity": l[2]}));
run(&mut s, "sketch.constraint.fix", json!({"entity": l[3]}));
let f = run(&mut s, "sketch.fillet", json!({"a": l[0], "b": l[1], "radius": 5}));
run(&mut s, "ConstraintFix", json!({"entity": l[2]}));
run(&mut s, "ConstraintFix", json!({"entity": l[3]}));
let f = run(&mut s, "FilletSketchCmd", json!({"a": l[0], "b": l[1], "radius": 5}));
assert!(f["param"].is_string());
let a = profiles(&s);
assert!(close(a[0], 1200.0 - (25.0 - std::f64::consts::PI * 25.0 / 4.0)), "{a:?}");
// The fillet radius is a dimension: change it.
let pname = f["param"].as_str().unwrap().to_string();
run(&mut s, "parameters.change", json!({"name": pname, "expression": "8 mm"}));
run(&mut s, "ChangeParameterCommand", json!({"name": pname, "expression": "8 mm"}));
let a = profiles(&s);
assert!(close(a[0], 1200.0 - (64.0 - std::f64::consts::PI * 64.0 / 4.0)), "{a:?}");
run(&mut s, "sketch.chamfer.equal_distance", json!({"a": l[2], "b": l[3], "distance": 4}));
run(&mut s, "ChamferSketchEqualDistance", json!({"a": l[2], "b": l[3], "distance": 4}));
let a2 = profiles(&s)[0];
assert!(close(a[0] - a2, 8.0), "{a2}");
run(&mut s, "sketch.chamfer.two_distance", json!({"a": l[1], "b": l[2], "distance": 2, "distance2": 6}));
run(&mut s, "ChamferSketchDistanceDistance", json!({"a": l[1], "b": l[2], "distance": 2, "distance2": 6}));
let a3 = profiles(&s)[0];
assert!(close(a2 - a3, 6.0), "{a3}");
// Distance-angle at the last corner (origin, 90 degrees): 45 deg gives an isosceles cut.
run(&mut s, "sketch.chamfer.distance_angle", json!({"a": l[3], "b": l[0], "distance": 3, "angle": 45}));
run(&mut s, "ChamferSketchDistanceAngle", json!({"a": l[3], "b": l[0], "distance": 3, "angle": 45}));
let a4 = profiles(&s)[0];
assert!(close(a3 - a4, 4.5), "{a4}");
assert!(s.execute("sketch.fillet", &json!({"a": l[0], "b": l[2], "radius": 1})).is_err(), "no shared corner");
assert!(s.execute("FilletSketchCmd", &json!({"a": l[0], "b": l[2], "radius": 1})).is_err(), "no shared corner");
}
#[test]
fn offset_chain_mirror_patterns_move_scale() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
let l = ids(&r["curves"]);
// Outward offset by 2 (toward a point outside).
let o = run(&mut s, "sketch.offset", json!({"curves": [l[0]], "distance": 2, "side": [20, -10]}));
let o = run(&mut s, "Offset", json!({"curves": [l[0]], "distance": 2, "side": [20, -10]}));
assert_eq!(ids(&o["curves"]).len(), 4, "{o}");
let a = profiles(&s);
assert!(close(a[1], 44.0 * 34.0 - 1200.0) || close(a.iter().sum::<f64>(), 44.0 * 34.0), "{a:?}");
// Circle offset inward.
let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": [100, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "sketch.offset", json!({"curves": [c], "distance": 3, "side": [100, 0]}));
let c = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [100, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "Offset", json!({"curves": [c], "distance": 3, "side": [100, 0]}));
// Mirror a circle about a vertical line.
let m = ids(&run(&mut s, "sketch.line", json!({"points": [[120, -50], [120, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.mirror", json!({"entities": [c], "line": m}));
let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[120, -50], [120, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "MirrorSketchCommand", json!({"entities": [c], "line": m}));
let mc = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
assert!(sk.center(sk.curve_index(&mc).unwrap()).unwrap().dist(Vec2::new(140.0, 0.0)) < 1e-7);
// Circular pattern of the mirrored circle: 4 around (120, 0).
let r = run(&mut s, "sketch.pattern.circular", json!({"entities": [mc], "center": [120, 0], "count": 4}));
let r = run(&mut s, "CircularSketchPatternCommand", json!({"entities": [mc], "center": [120, 0], "count": 4}));
assert_eq!(ids(&r["curves"]).len(), 3);
let sk = sketch(&s);
let c2 = sk.center(sk.curve_index(&ids(&r["curves"])[0]).unwrap()).unwrap();
assert!(c2.dist(Vec2::new(120.0, 20.0)) < 1e-7, "{c2:?}");
// Rectangular pattern 3 x 2.
let small = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0, 100], "radius": 1}))["curves"])[0].clone();
let r = run(&mut s, "sketch.pattern.rectangular", json!({"entities": [small], "count": 3, "spacing": 5, "count2": 2, "spacing2": 4}));
let small = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [0, 100], "radius": 1}))["curves"])[0].clone();
let r = run(&mut s, "RectangularSketchPatternCommand", json!({"entities": [small], "count": 3, "spacing": 5, "count2": 2, "spacing2": 4}));
assert_eq!(ids(&r["curves"]).len(), 5);
// Move and scale.
run(&mut s, "sketch.move", json!({"entities": [small], "translate": [0, 10]}));
run(&mut s, "sketch.scale", json!({"entities": [small], "base": [0, 110], "factor": 3}));
run(&mut s, "SketchScaleCmd", json!({"entities": [small], "base": [0, 110], "factor": 3}));
let sk = sketch(&s);
let ci = sk.curve_index(&small).unwrap();
assert!(sk.center(ci).unwrap().dist(Vec2::new(0.0, 110.0)) < 1e-7);
@@ -162,31 +162,31 @@ fn offset_chain_mirror_patterns_move_scale() {
#[test]
fn new_creation_tools() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.line.midpoint", json!({"mid": [0, 0], "end": [10, 5]}));
let r = run(&mut s, "SketchMidpointLine", json!({"mid": [0, 0], "end": [10, 5]}));
let l = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let start = sk.resolve_point(&format!("{l}.start")).and_then(|i| sk.point(i)).unwrap();
assert!(start.dist(Vec2::new(-10.0, -5.0)) < 1e-9);
// Tangent arc off the end of a horizontal line.
let h = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 20], [10, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.arc.tangent", json!({"start": format!("{h}.end"), "end": [20, 30]}));
let h = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 20], [10, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "ArcTangent", json!({"start": format!("{h}.end"), "end": [20, 30]}));
let a = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let ai = sk.curve_index(&a).unwrap();
assert!(sk.center(ai).unwrap().dist(Vec2::new(10.0, 30.0)) < 1e-7, "{:?}", sk.center(ai));
assert!(close(sk.radius(ai).unwrap(), 10.0));
// 2-tangent circle in the corner of two lines.
let x = ids(&run(&mut s, "sketch.line", json!({"points": [[100, 0], [150, 0]]}))["curves"])[0].clone();
let y = ids(&run(&mut s, "sketch.line", json!({"points": [[100, 0], [100, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.circle.two_tangent", json!({"curves": [x, y], "radius": 5, "near": [106, 104 - 100]}));
let x = ids(&run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [150, 0]]}))["curves"])[0].clone();
let y = ids(&run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [100, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "CircleTanTanRadius", json!({"curves": [x, y], "radius": 5, "near": [106, 104 - 100]}));
let c = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let ci = sk.curve_index(&c).unwrap();
assert!(sk.center(ci).unwrap().dist(Vec2::new(105.0, 5.0)) < 1e-6, "{:?}", sk.center(ci));
// 3-tangent circle inside a triangle: the incircle of the 3-4-5 triangle (r = 1).
let t = run(&mut s, "sketch.line", json!({"points": [[200, 0], [203, 0], [200, 4]], "closed": true}));
let t = run(&mut s, "DrawPolyline", json!({"points": [[200, 0], [203, 0], [200, 4]], "closed": true}));
let tl = ids(&t["curves"]);
let r = run(&mut s, "sketch.circle.three_tangent", json!({"curves": tl, "near": [201, 1.2]}));
let r = run(&mut s, "CircleThreeTangent", json!({"curves": tl, "near": [201, 1.2]}));
let c = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let ci = sk.curve_index(&c).unwrap();
@@ -194,9 +194,9 @@ fn new_creation_tools() {
assert!(sk.center(ci).unwrap().dist(Vec2::new(201.0, 1.0)) < 1e-6);
// Slots.
let before = profiles(&s).len();
run(&mut s, "sketch.slot.center_point", json!({"center": [0, -100], "end": [10, -100], "width": 4}));
run(&mut s, "sketch.slot.arc_three_point", json!({"start": [100, -100], "through": [110, -90], "end": [120, -100], "width": 2}));
run(&mut s, "sketch.slot.arc_center", json!({"center": [200, -100], "start": [210, -100], "end": [200, -90], "width": 2}));
run(&mut s, "ShapeSlotCenterPoint", json!({"center": [0, -100], "end": [10, -100], "width": 4}));
run(&mut s, "ShapeArcSlotThreePoint", json!({"start": [100, -100], "through": [110, -90], "end": [120, -100], "width": 2}));
run(&mut s, "ShapeArcSlotCenterTwoPoint", json!({"center": [200, -100], "start": [210, -100], "end": [200, -90], "width": 2}));
let a = profiles(&s);
assert_eq!(a.len(), before + 3, "{a:?}");
let pi = std::f64::consts::PI;
@@ -210,13 +210,13 @@ fn new_creation_tools() {
fn ellipse_splines_conic() {
let pi = std::f64::consts::PI;
let mut s = new_sketch();
let e = run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor": [0, 4]}));
let e = run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor": [0, 4]}));
let eid = ids(&e["curves"])[0].clone();
let a = profiles(&s);
assert!((a[0] - pi * 40.0).abs() / (pi * 40.0) < 2e-3, "{a:?}");
// A point constrained onto the ellipse lands on it.
run(&mut s, "sketch.point", json!({"point": [3, 5], "id": "q"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "q", "b": eid}));
run(&mut s, "DrawPoint", json!({"point": [3, 5], "id": "q"}));
run(&mut s, "ConstraintCoincident", json!({"a": "q", "b": eid}));
let sk = sketch(&s);
let q = sk.point(sk.point_index("q").unwrap()).unwrap();
let poly = sk.polyline(sk.curve_index(&eid).unwrap());
@@ -227,9 +227,9 @@ fn ellipse_splines_conic() {
});
assert!(d.fold(f64::INFINITY, f64::min) < 1e-6, "{q:?}");
// Fit spline closed by a line: one profile.
let sp = run(&mut s, "sketch.spline.fit_point", json!({"points": [[100, 0], [110, 8], [120, 3], [130, 0]]}));
let sp = run(&mut s, "DrawSpline", json!({"points": [[100, 0], [110, 8], [120, 3], [130, 0]]}));
let sid = ids(&sp["curves"])[0].clone();
run(&mut s, "sketch.line", json!({"points": [format!("{sid}.end"), format!("{sid}.start")]}));
run(&mut s, "DrawPolyline", json!({"points": [format!("{sid}.end"), format!("{sid}.start")]}));
assert_eq!(profiles(&s).len(), 2);
// Moving a fit point reshapes the spline (it is a sketch point).
let sk = sketch(&s);
@@ -240,31 +240,31 @@ fn ellipse_splines_conic() {
let sk = sketch(&s);
assert!(sk.polyline(si).iter().any(|q| q.dist(Vec2::new(110.0, 12.0)) < 1e-9));
// Control point splines (degree 3 and 5) and a conic.
run(&mut s, "sketch.spline.control_point", json!({"points": [[0, 50], [0, 60], [10, 60], [10, 50]]}));
run(&mut s, "sketch.spline.control_point_5", json!({"points": [[20, 50], [20, 60], [25, 65], [30, 60], [35, 62], [40, 50]]}));
run(&mut s, "DrawCVMSpline3D", json!({"points": [[0, 50], [0, 60], [10, 60], [10, 50]]}));
run(&mut s, "DrawCVMSpline5D", json!({"points": [[20, 50], [20, 60], [25, 65], [30, 60], [35, 62], [40, 50]]}));
let w = std::f64::consts::FRAC_1_SQRT_2;
let k = run(&mut s, "sketch.conic", json!({"start": [210, 0], "end": [200, 10], "apex": [210, 10], "rho": w / (1.0 + w)}));
let k = run(&mut s, "ConicCurveCmd", json!({"start": [210, 0], "end": [200, 10], "apex": [210, 10], "rho": w / (1.0 + w)}));
let kid = ids(&k["curves"])[0].clone();
// A line tangent to the conic at its end: the tangent arc tool continues it.
let r = run(&mut s, "sketch.arc.tangent", json!({"start": format!("{kid}.end"), "end": [190, 0]}));
let r = run(&mut s, "ArcTangent", json!({"start": format!("{kid}.end"), "end": [190, 0]}));
let aid = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
// The conic is a quarter circle about (200, 0) leaving (200, 10) heading -x: the tangent
// arc from there to (190, 0) continues the same circle.
let ai = sk.curve_index(&aid).unwrap();
assert!(sk.center(ai).unwrap().dist(Vec2::new(200.0, 0.0)) < 1e-6, "{:?}", sk.center(ai));
assert!(s.execute("sketch.spline.fit_point", &json!({"points": [[0, 0]]})).is_err());
assert!(s.execute("sketch.conic", &json!({"start": [0, 0], "end": [1, 0], "apex": [0, 1], "rho": 1.5})).is_err());
assert!(s.execute("DrawSpline", &json!({"points": [[0, 0]]})).is_err());
assert!(s.execute("ConicCurveCmd", &json!({"start": [0, 0], "end": [1, 0], "apex": [0, 1], "rho": 1.5})).is_err());
// Mirror and pattern copy free-form curves too.
let m = ids(&run(&mut s, "sketch.line", json!({"points": [[-50, -50], [-50, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.mirror", json!({"entities": [eid, sid], "line": m}));
let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-50, -50], [-50, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "MirrorSketchCommand", json!({"entities": [eid, sid], "line": m}));
assert_eq!(ids(&r["curves"]).len(), 2);
}
#[test]
fn text_makes_profiles_that_extrude_and_edit() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.text", json!({"text": "SO", "at": [0, 0], "height": 10}));
let r = run(&mut s, "MTextCmd", json!({"text": "SO", "at": [0, 0], "height": 10}));
let link = r["link"].as_str().unwrap().to_string();
let a = profiles(&s);
// S (one region) + O (ring = outer with a hole, plus the counter as its own region).
@@ -276,21 +276,21 @@ fn text_makes_profiles_that_extrude_and_edit() {
assert_eq!(r2["link"], json!(link));
let a = profiles(&s);
assert_eq!(a.len(), 1, "{a:?}");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2}));
let m = run(&mut s, "inspect.measure", json!({}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 2}));
let m = run(&mut s, "MeasureCommand", json!({}));
let v = m["total"]["volume_mm3"].as_f64().unwrap();
// An "I" 20 mm tall is a bar a couple of mm wide.
assert!(v > 40.0 && v < 200.0, "{v}");
assert!(s.execute("sketch.text", &json!({"text": "", "at": [0, 0]})).is_err());
assert!(s.execute("MTextCmd", &json!({"text": "", "at": [0, 0]})).is_err());
}
#[test]
fn blend_curve_joins_tangentially() {
let mut s = new_sketch();
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 10], [20, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.blend_curve", json!({"a": format!("{a}.end"), "b": format!("{b}.start")}));
let a = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "DrawPolyline", json!({"points": [[20, 10], [20, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "BlendG1CurveSketchCmd", json!({"a": format!("{a}.end"), "b": format!("{b}.start")}));
assert_eq!(r["sketch"]["solved"], true, "{r}");
let sp = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
@@ -305,9 +305,9 @@ fn blend_curve_joins_tangentially() {
#[test]
fn centerline_is_left_out_of_profiles() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 10]}));
let l = ids(&r["curves"]);
let m = ids(&run(&mut s, "sketch.line", json!({"points": [[5, -5], [5, 15]]}))["curves"])[0].clone();
let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[5, -5], [5, 15]]}))["curves"])[0].clone();
assert_eq!(profiles(&s).len(), 2);
run(&mut s, "sketch.centerline", json!({"curves": [m]}));
assert_eq!(profiles(&s).len(), 1);
@@ -318,59 +318,57 @@ fn centerline_is_left_out_of_profiles() {
#[test]
fn free_form_profiles_extrude_exactly() {
let pi = std::f64::consts::PI;
let vol = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let vol = |s: &mut Session| run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
// Ellipse a = 10, b = 4: π a b h.
let mut s = new_sketch();
run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}));
run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}));
let a = profiles(&s)[0];
assert!((a - pi * 40.0).abs() < 1e-9, "{a}");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
let v = vol(&mut s);
assert!((v - pi * 40.0 * 5.0).abs() / (pi * 200.0) < 2e-4, "{v}");
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert_eq!(m["bodies"][0]["faces"], 3, "an exact ellipse: top, bottom and one side ({m})");
// A region closed by a fit spline and a line.
let mut s = new_sketch();
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 8], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.line", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 8], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let a = profiles(&s)[0];
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 2}));
let v = vol(&mut s);
assert!((v - a * 2.0).abs() / (a * 2.0) < 2e-4, "{v} vs {}", a * 2.0);
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert!(m["bodies"][0]["faces"].as_u64().unwrap() <= 5, "{m}");
}
#[test]
fn degree_five_and_cut_free_form_profiles_extrude_exactly() {
let vol = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let vol = |s: &mut Session| run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
// A degree-5 control spline closed by a line: one B-spline side face.
let mut s = new_sketch();
let sp =
ids(&run(&mut s, "sketch.spline.control_point_5", json!({"points": [[0, 0], [4, 10], [10, 12], [16, 12], [22, 10], [26, 0]]}))["curves"])[0]
.clone();
run(&mut s, "sketch.line", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let sp = ids(&run(&mut s, "DrawCVMSpline5D", json!({"points": [[0, 0], [4, 10], [10, 12], [16, 12], [22, 10], [26, 0]]}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let a = profiles(&s)[0];
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 3}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 3}));
let v = vol(&mut s);
assert!((v - a * 3.0).abs() / (a * 3.0) < 2e-4, "{v} vs {}", a * 3.0);
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert_eq!(m["bodies"][0]["faces"], 4, "{m}");
// A fit spline arch cut by a line: both sides extrude with exact curved faces.
let mut s = new_sketch();
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [6, 9], [14, 9], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.line", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
run(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 20]]}));
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [6, 9], [14, 9], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
run(&mut s, "DrawPolyline", json!({"points": [[10, -5], [10, 20]]}));
let areas = profiles(&s);
assert_eq!(areas.len(), 2, "{areas:?}");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2, "profiles": [0]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 2, "profiles": [0]}));
let v = vol(&mut s);
assert!((v - areas[0] * 2.0).abs() / (areas[0] * 2.0) < 2e-4, "{v} vs {}", areas[0] * 2.0);
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert!(m["bodies"][0]["faces"].as_u64().unwrap() <= 7, "no polyline facets: {m}");
}
@@ -380,9 +378,9 @@ fn trim_and_break_free_form_curves_exactly() {
let mut s = new_sketch();
// An ellipse cut by a line through its centre: trimming the lower half leaves the upper
// half, which closes with the line into half the ellipse.
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-15, 0], [15, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.trim", json!({"curve": e, "at": [0, -4]}));
let e = ids(&run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-15, 0], [15, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "TrimSketchCmd", json!({"curve": e, "at": [0, -4]}));
assert!(!r["result"]["pieces"].as_array().unwrap().is_empty(), "{r}");
let a = profiles(&s);
assert_eq!(a.len(), 1, "{a:?}");
@@ -390,10 +388,10 @@ fn trim_and_break_free_form_curves_exactly() {
let _ = l;
// A fit spline broken where a line crosses it: two pieces that still trace the curve.
let mut s = new_sketch();
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 10], [20, 0], [30, 5]]}))["curves"])[0].clone();
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 10], [20, 0], [30, 5]]}))["curves"])[0].clone();
let before = sketch(&s).polyline(sketch(&s).curve_index(&sp).unwrap());
run(&mut s, "sketch.line", json!({"points": [[15, -10], [15, 20]]}));
let r = run(&mut s, "sketch.break", json!({"curve": sp, "at": [15, 5]}));
run(&mut s, "DrawPolyline", json!({"points": [[15, -10], [15, 20]]}));
let r = run(&mut s, "BreakSketchCmd", json!({"curve": sp, "at": [15, 5]}));
let pieces = ids(&r["curves"]);
assert_eq!(pieces.len(), 4, "three Béziers, the middle one split: {r}");
let sk = sketch(&s);
@@ -417,9 +415,9 @@ fn trim_and_break_free_form_curves_exactly() {
#[test]
fn ellipse_radii_dimensions() {
let mut s = new_sketch();
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
run(&mut s, "sketch.dimension", json!({"entities": [e], "type": "major", "value": 12}));
run(&mut s, "sketch.dimension", json!({"entities": [e], "value": 5}));
let e = ids(&run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
run(&mut s, "SketchDimension", json!({"entities": [e], "type": "major", "value": 12}));
run(&mut s, "SketchDimension", json!({"entities": [e], "value": 5}));
let a = profiles(&s)[0];
assert!((a - std::f64::consts::PI * 60.0).abs() < 1e-6, "{a}");
}
@@ -428,11 +426,10 @@ fn ellipse_radii_dimensions() {
fn fillet_a_line_and_an_arc() {
let mut s = new_sketch();
// A line into a quarter arc meeting at a sharp corner.
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let a =
ids(&run(&mut s, "sketch.arc.center_point", json!({"center": [30, 0], "start": format!("{l}.end"), "end": [30, -10]}))["curves"])[0].clone();
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let a = ids(&run(&mut s, "ArcCenterTwoPoint", json!({"center": [30, 0], "start": format!("{l}.end"), "end": [30, -10]}))["curves"])[0].clone();
let _ = a;
let f = run(&mut s, "sketch.fillet", json!({"point": format!("{l}.end"), "radius": 2}));
let f = run(&mut s, "FilletSketchCmd", json!({"point": format!("{l}.end"), "radius": 2}));
let arc = ids(&f["curves"])[0].clone();
let sk = sketch(&s);
let ai = sk.curve_index(&arc).unwrap();
@@ -447,8 +444,8 @@ fn fillet_a_line_and_an_arc() {
#[test]
fn offset_free_form_curves() {
let mut s = new_sketch();
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 8], [20, 0], [30, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.offset", json!({"curves": [sp], "distance": 2, "side": [10, 20]}));
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 8], [20, 0], [30, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "Offset", json!({"curves": [sp], "distance": 2, "side": [10, 20]}));
let made = ids(&r["curves"]);
assert_eq!(made.len(), 1, "{r}");
let sk = sketch(&s);
@@ -467,7 +464,7 @@ fn offset_free_form_curves() {
assert!((dist(*q) - 2.0).abs() < 0.08, "{q:?} {}", dist(*q));
}
// An ellipse grows by the offset all round.
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [100, 0], "major": [110, 0], "minor_radius": 5}))["curves"])[0].clone();
let r = run(&mut s, "sketch.offset", json!({"curves": [e], "distance": 1, "side": [100, 20]}));
let e = ids(&run(&mut s, "CircleElipse", json!({"center": [100, 0], "major": [110, 0], "minor_radius": 5}))["curves"])[0].clone();
let r = run(&mut s, "Offset", json!({"curves": [e], "distance": 1, "side": [100, 20]}));
assert_eq!(ids(&r["curves"]).len(), 1, "{r}");
}
+2 -2
View File
@@ -15,7 +15,7 @@ fn ms(t: Instant) -> f64 {
/// them, built straight into the document.
fn big(n: usize) -> Session {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
let id = s.active_sketch.unwrap();
let mut doc = (*s.doc).clone();
let sk = doc.sketch_mut(id).unwrap();
@@ -67,7 +67,7 @@ fn sketch_perf() {
s.execute("sketch.snap", &json!({"at": [10.0, 0.2], "from": [0, 0], "radius": 1})).unwrap();
let snap = ms(t);
let t = Instant::now();
s.execute("sketch.line", &json!({"points": [[-50, -50], [-40, -45]]})).unwrap();
s.execute("DrawPolyline", &json!({"points": [[-50, -50], [-40, -45]]})).unwrap();
let add = ms(t);
let t = Instant::now();
s.execute("sketch.move_point", &json!({"point": "p1", "to": [1, 1]})).unwrap();
+17 -29
View File
@@ -14,36 +14,36 @@ use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(REFS_DOC),
CommandSpec::new("sketch.intersect", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
CommandSpec::new("ProjectNewCmd", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(REFS_DOC),
CommandSpec::new("IntersectCmd", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
"refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane, linked",
),
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", include_cmd)
CommandSpec::new("Include3DGeometry", "Include 3D Geometry", include_cmd)
.at("SKETCH", "CREATE")
.icon("include")
.enabled(in_sketch)
.params("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it"),
CommandSpec::new("sketch.project_to_surface", "Project To Surface", project_to_surface)
CommandSpec::new("ProjectToSurface", "Project To Surface", project_to_surface)
.at("SKETCH", "CREATE")
.icon("project_surface")
.enabled(in_sketch)
.params("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves"),
CommandSpec::new("sketch.intersection_curve", "Intersection Curve", intersection_curve)
CommandSpec::new("IntersectionCurve", "Intersection Curve", intersection_curve)
.at("SKETCH", "CREATE")
.icon("intersection_curve")
.enabled(in_sketch)
.params("a, b: {body} | {face: [x,y,z], body?}: where they meet, as linked 3D curves"),
CommandSpec::new("sketch.spun_profile", "Spun Profile", spun_profile)
CommandSpec::new("SpunProfileCmd", "Spun Profile", spun_profile)
.at("SKETCH", "CREATE")
.icon("spun")
.enabled(in_sketch)
.params("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane"),
CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", iso_curve)
CommandSpec::new("SketchIsoparametricCurve", "Isoparametric Curve", iso_curve)
.at("SKETCH", "CREATE")
.icon("iso_curve")
.enabled(in_sketch)
.params("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve"),
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", fit_section)
CommandSpec::new("FitCurvesToSectionCommand", "Fit Curves to Mesh Section", fit_section)
.at("SKETCH", "CREATE")
.icon("fit_section")
.enabled(in_sketch)
@@ -140,20 +140,8 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki
let plane = sketch_plane(doc, id)?;
let (vals, _) = doc.param_values();
let st = s.model.state_before(id);
// The model as the sketch's component sees it.
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
let st = solvecraft_doc::state_in_frame(doc, &st, comp);
let r = resolve(doc, &vals, &st, &plane, kind, &src)?;
let name = if let LinkSource::Face { at, .. } = &r.source {
solvecraft_doc::naming::face_names_at(&st, &[*at]).into_iter().next().filter(|n| !n.is_empty())
} else {
None
};
let link = sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?;
if let Some(l) = sk.links.iter_mut().find(|l| l.id == link) {
l.face_name = name;
}
Ok(link)
Ok(sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?)
}
fn link_json(sk: &Sketch, link: &str) -> Value {
@@ -188,15 +176,15 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind) -> Result<Va
}
fn project_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "sketch.project", LinkKind::Project)
run_links(s, p, "ProjectNewCmd", LinkKind::Project)
}
fn intersect_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "sketch.intersect", LinkKind::Intersect)
run_links(s, p, "IntersectCmd", LinkKind::Intersect)
}
fn include_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "sketch.include_3d", LinkKind::Include)
run_links(s, p, "Include3DGeometry", LinkKind::Include)
}
/// Add one link from `src` to the active sketch and report it.
@@ -216,7 +204,7 @@ fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Resu
}
fn project_to_surface(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.project_to_surface";
let cmd = "ProjectToSurface";
let at = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on the target face"))?;
let body = str_(p, "body").unwrap_or("").to_string();
let list = p.get("curves").and_then(Value::as_array).cloned().ok_or_else(|| bad(cmd, "`curves` must list {sketch, curve}"))?;
@@ -234,7 +222,7 @@ fn project_to_surface(s: &mut Session, p: &Value) -> Result<Value> {
}
fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.intersection_curve";
let cmd = "IntersectionCurve";
let side = |k: &str| -> Result<LinkSource> {
let v = p.get(k).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?;
match source(s, v, cmd)? {
@@ -247,7 +235,7 @@ fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
}
fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.spun_profile";
let cmd = "SpunProfileCmd";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must name a body"))?.to_string();
let axis = str_(p, "axis").ok_or_else(|| bad(cmd, "`axis` must be a sketch line or X|Y|Z"))?;
let (origin, dir) = match axis.to_ascii_uppercase().as_str() {
@@ -268,7 +256,7 @@ fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
}
fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.isoparametric_curve";
let cmd = "SketchIsoparametricCurve";
let at = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on the face"))?;
let body = str_(p, "body").unwrap_or("").to_string();
let dir = match p.get("direction") {
@@ -283,7 +271,7 @@ fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
}
fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.fit_curves_to_section";
let cmd = "FitCurvesToSectionCommand";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must name a body"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
+89 -131
View File
@@ -15,20 +15,20 @@ fn ids(v: &Value) -> Vec<String> {
/// A 40 x 30 x 20 box from a sketch whose width is the user parameter `width`.
fn box_part(s: &mut Session) {
run(s, "parameters.change", json!({"name": "width", "expression": "40 mm"}));
run(s, "sketch.create", json!({"plane": "XY"}));
let r = run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "ChangeParameterCommand", json!({"name": "width", "expression": "40 mm"}));
run(s, "SketchCreate", json!({"plane": "XY"}));
let r = run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
let l = ids(&r["curves"]);
let sk = s.doc.sketch(s.active_sketch.unwrap()).unwrap();
let start = match sk.curves[0].kind {
solvecraft_sketch::CurveKind::Line { a, .. } => sk.points[a].id.clone(),
_ => panic!(),
};
run(s, "sketch.constraint.coincident", json!({"a": start, "b": "origin"}));
run(s, "sketch.dimension", json!({"entities": [l[0]], "value": "width"}));
run(s, "sketch.dimension", json!({"entities": [l[1]], "value": 30}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 20}));
run(s, "ConstraintCoincident", json!({"a": start, "b": "origin"}));
run(s, "SketchDimension", json!({"entities": [l[0]], "value": "width"}));
run(s, "SketchDimension", json!({"entities": [l[1]], "value": 30}));
run(s, "SketchStop", json!({}));
run(s, "Extrude", json!({"distance": 20}));
}
fn inspect(s: &mut Session) -> Value {
@@ -43,7 +43,7 @@ fn linked_curves(si: &Value) -> Vec<Value> {
fn sketch_on_face_projects_its_edges_and_follows_edits() {
let mut s = Session::default();
box_part(&mut s);
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
assert!(r["projected"].is_string(), "{r}");
let si = inspect(&mut s);
let lc = linked_curves(&si);
@@ -55,16 +55,16 @@ fn sketch_on_face_projects_its_edges_and_follows_edits() {
// Projected geometry is fixed: the sketch has no degrees of freedom.
assert_eq!(si["dof"], 0, "{si}");
// A circle on the face, dimensioned to a projected edge.
let c = run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 3}));
let c = run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 3}));
let cid = ids(&c["curves"])[0].clone();
let e = lc.iter().find(|c| c["start_at"][1].as_f64().unwrap().abs() < 1e-9 && c["end_at"][1].as_f64().unwrap().abs() < 1e-9).unwrap();
run(&mut s, "sketch.dimension", json!({"entities": [format!("{cid}.center"), e["id"]], "value": 8}));
run(&mut s, "SketchDimension", json!({"entities": [format!("{cid}.center"), e["id"]], "value": 8}));
let si = inspect(&mut s);
let center = si["points"].as_array().unwrap().iter().find(|p| p["id"] == format!("{cid}.center")).unwrap();
assert!((center["at"][1].as_f64().unwrap() - 8.0).abs() < 1e-7, "{center}");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
// Widen the box: the projected loop follows.
run(&mut s, "parameters.change", json!({"name": "width", "expression": "50 mm"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "width", "expression": "50 mm"}));
let st = s.model.state();
let ss = st.sketches.last().unwrap();
assert_eq!(ss.profiles.len(), 2);
@@ -78,11 +78,11 @@ fn sketch_on_face_projects_its_edges_and_follows_edits() {
fn auto_project_can_be_turned_off() {
let mut s = Session::default();
box_part(&mut s);
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}, "project_edges": false}));
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}, "project_edges": false}));
assert!(r["projected"].is_null());
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.auto_project", json!({"value": false}));
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
assert!(r["projected"].is_null());
}
@@ -90,10 +90,10 @@ fn auto_project_can_be_turned_off() {
fn project_edges_bodies_sketches_and_origin() {
let mut s = Session::default();
box_part(&mut s);
run(&mut s, "solid.cylinder", json!({"base": [80, 0, 5], "radius": 6, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "PrimitiveCylinder", json!({"base": [80, 0, 5], "radius": 6, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
// An edge on the top face projects to a line on XY.
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 30, 20]}]}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [20, 30, 20]}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "line");
@@ -101,7 +101,7 @@ fn project_edges_bodies_sketches_and_origin() {
assert!(ys.iter().all(|y| (y - 30.0).abs() < 1e-9), "{c}");
// The cylinder's silhouette seen from above is its circle, exactly.
let name = s.model.state().bodies[1].name.clone();
let r = run(&mut s, "sketch.project", json!({"refs": [{"body": name}]}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"body": name}]}));
let cid = r["links"][0]["curves"][0].clone();
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == cid).unwrap().clone();
@@ -109,34 +109,34 @@ fn project_edges_bodies_sketches_and_origin() {
assert!((c["radius"].as_f64().unwrap() - 6.0).abs() < 1e-6, "{c}");
assert!((c["center_at"][0].as_f64().unwrap() - 80.0).abs() < 1e-6, "{c}");
// Origin, the Y axis and the YZ plane.
run(&mut s, "sketch.project", json!({"refs": ["origin", {"axis": "Y"}, {"plane": "YZ"}]}));
run(&mut s, "ProjectNewCmd", json!({"refs": ["origin", {"axis": "Y"}, {"plane": "YZ"}]}));
// The box silhouette from above is its 40 x 30 outline.
let b0 = s.model.state().bodies[0].name.clone();
let r = run(&mut s, "sketch.project", json!({"refs": [{"body": b0}]}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"body": b0}]}));
assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
let first = s.active_sketch;
assert!(first.is_none());
let sk1 = s.doc.features.last().unwrap().id;
// Another sketch projects a curve of the first one.
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 50}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": sk1, "curve": cid}]}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"sketch": sk1, "curve": cid}]}));
let c2 = r["links"][0]["curves"][0].clone();
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == c2).unwrap().clone();
assert_eq!(c["type"], "circle");
// Bad references fail cleanly.
assert!(s.execute("sketch.project", &json!({"refs": [{"edge": [1000, 1000, 1000]}]})).is_err());
assert!(s.execute("sketch.project", &json!({"refs": [{"plane": "XY"}]})).is_err(), "parallel plane projects to nothing");
assert!(s.execute("ProjectNewCmd", &json!({"refs": [{"edge": [1000, 1000, 1000]}]})).is_err());
assert!(s.execute("ProjectNewCmd", &json!({"refs": [{"plane": "XY"}]})).is_err(), "parallel plane projects to nothing");
}
#[test]
fn intersect_cuts_a_body_with_the_sketch_plane() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 12}));
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 12}));
let name = s.model.state().bodies[0].name.clone();
let r = run(&mut s, "sketch.intersect", json!({"refs": [{"body": name}]}));
let r = run(&mut s, "IntersectCmd", json!({"refs": [{"body": name}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "circle", "{c}");
@@ -148,7 +148,7 @@ fn intersect_cuts_a_body_with_the_sketch_plane() {
fn break_link_makes_normal_geometry() {
let mut s = Session::default();
box_part(&mut s);
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
let link = r["projected"].as_str().unwrap().to_string();
run(&mut s, "sketch.break_link", json!({"link": link}));
let si = inspect(&mut s);
@@ -162,11 +162,11 @@ fn break_link_makes_normal_geometry() {
#[test]
fn lost_reference_keeps_geometry_with_a_warning() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
let boxf = s.doc.features.last().unwrap().id;
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [5, 0, 10]}]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [5, 0, 10]}]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "timeline.suppress", json!({"feature": boxf, "suppressed": true}));
let st = s.model.state();
let ss = st.sketches.last().unwrap();
@@ -180,8 +180,8 @@ fn lost_reference_keeps_geometry_with_a_warning() {
fn drawing_on_a_model_vertex_projects_it() {
let mut s = Session::default();
box_part(&mut s);
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.line", json!({"points": ["vertex:40,30,20", [60, 45]]}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "DrawPolyline", json!({"points": ["vertex:40,30,20", [60, 45]]}));
let l = ids(&r["curves"])[0].clone();
let si = inspect(&mut s);
let start = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == l).unwrap()["start"].clone();
@@ -190,7 +190,7 @@ fn drawing_on_a_model_vertex_projects_it() {
assert_eq!(p["fixed"], true);
assert!((p["at"][0].as_f64().unwrap() - 40.0).abs() < 1e-9);
// Snapping onto an edge projects it and keeps the point on it.
let r = run(&mut s, "sketch.line", json!({"points": [{"on_edge": [20, 0, 20]}, [20, -15]]}));
let r = run(&mut s, "DrawPolyline", json!({"points": [{"on_edge": [20, 0, 20]}, [20, -15]]}));
assert_eq!(ids(&r["curves"]).len(), 1);
let si = inspect(&mut s);
assert!(si["constraints"].as_array().unwrap().iter().any(|c| c["type"] == "point_on_curve"), "{si}");
@@ -199,41 +199,41 @@ fn drawing_on_a_model_vertex_projects_it() {
#[test]
fn fit_curves_to_a_mesh_section() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 10}));
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 10}));
let name = s.model.state().bodies[0].name.clone();
let r = run(&mut s, "sketch.fit_curves_to_section", json!({"body": name}));
let r = run(&mut s, "FitCurvesToSectionCommand", json!({"body": name}));
assert_eq!(r["curves"].as_array().unwrap().len(), 1, "{r}");
let si = inspect(&mut s);
assert!(si["links"].as_array().unwrap().is_empty());
let c = &si["curves"][0];
assert_eq!(c["type"], "circle");
assert!(c.get("link").is_none());
assert!(s.execute("sketch.fit_curves_to_section", &json!({"body": "nope"})).is_err());
assert!(s.execute("FitCurvesToSectionCommand", &json!({"body": "nope"})).is_err());
}
#[test]
fn three_d_sketch_curves() {
let mut s = Session::default();
// Include keeps an out-of-plane edge 3D.
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.include_3d", json!({"refs": [{"edge": [10, 10, 5]}]}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "Include3DGeometry", json!({"refs": [{"edge": [10, 10, 5]}]}));
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
assert_eq!(sk.wires.len(), 1, "{r}");
assert!((sk.wires[0].pts.iter().map(|p| p.z).fold(0.0, f64::max) - 10.0).abs() < 1e-9);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
// Project a line onto a sphere along the sketch normal.
let mut s = Session::default();
run(&mut s, "solid.sphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-10, 0], [10, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "PrimitiveSphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-10, 0], [10, 0]]}))["curves"])[0].clone();
run(&mut s, "SketchStop", json!({}));
let src = s.doc.features.last().unwrap().id;
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project_to_surface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectToSurface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
let wid = r["links"][0]["wires"][0].as_str().unwrap().to_string();
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
@@ -246,16 +246,16 @@ fn three_d_sketch_curves() {
// Delete the wire.
run(&mut s, "sketch.delete", json!({"entities": [wid]}));
assert!(s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.wires.is_empty());
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
// Two crossing cylinders meet in 3D curves.
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, -20], "radius": 5, "height": 40}));
run(&mut s, "solid.cylinder", json!({"base": [-20, 0, 0], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, -20], "radius": 5, "height": 40}));
run(&mut s, "PrimitiveCylinder", json!({"base": [-20, 0, 0], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names.len(), 2, "{names:?}");
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.intersection_curve", json!({"a": {"body": names[0]}, "b": {"body": names[1]}}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "IntersectionCurve", json!({"a": {"body": names[0]}, "b": {"body": names[1]}}));
assert!(!r["wires"].as_array().unwrap().is_empty(), "{r}");
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
@@ -265,14 +265,14 @@ fn three_d_sketch_curves() {
assert!(((p.y * p.y + p.z * p.z).sqrt() - 4.0).abs() < 0.1, "{p:?}");
}
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
// Spun profile of a cylinder about Z, on the XZ plane: a straight outline 6 from the axis.
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
let name = s.model.state().bodies[0].name.clone();
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
let r = run(&mut s, "sketch.spun_profile", json!({"body": name, "axis": "Z"}));
run(&mut s, "SketchCreate", json!({"plane": "XZ"}));
let r = run(&mut s, "SpunProfileCmd", json!({"body": name, "axis": "Z"}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "line", "{c}");
@@ -282,31 +282,30 @@ fn three_d_sketch_curves() {
#[test]
fn isoparametric_curves_of_a_cylinder() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
// u: around (a circle at z = 10); v: along (a line from z 0 to 20).
let r = run(&mut s, "sketch.isoparametric_curve", json!({"face": [6, 0, 10]}));
let r = run(&mut s, "SketchIsoparametricCurve", json!({"face": [6, 0, 10]}));
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
let w = &sk.wires[sk.wire_index(r["wires"][0].as_str().unwrap()).unwrap()];
assert!(w.pts.iter().all(|p| (p.z - 10.0).abs() < 1e-6 && ((p.x * p.x + p.y * p.y).sqrt() - 6.0).abs() < 1e-3), "{:?}", &w.pts[..3]);
let r = run(&mut s, "sketch.isoparametric_curve", json!({"face": [6, 0, 10], "direction": "v"}));
let r = run(&mut s, "SketchIsoparametricCurve", json!({"face": [6, 0, 10], "direction": "v"}));
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
let w = &sk.wires[sk.wire_index(r["wires"][0].as_str().unwrap()).unwrap()];
let zs: Vec<f64> = w.pts.iter().map(|p| p.z).collect();
assert!(zs.iter().cloned().fold(f64::MIN, f64::max) > 19.9 && zs.iter().cloned().fold(f64::MAX, f64::min) < 0.1, "{zs:?}");
// Within the mesh's sag: the cut runs across facets unless a seam happens to lie there.
assert!(w.pts.iter().all(|p| (p.x - 6.0).abs() < 1e-2 && p.y.abs() < 1e-2), "{:?}", w.pts);
assert!(w.pts.iter().all(|p| (p.x - 6.0).abs() < 1e-3 && p.y.abs() < 1e-3));
}
#[test]
fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "axis": [1, 0, 1], "radius": 5, "height": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": -10}));
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "axis": [1, 0, 1], "radius": 5, "height": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": -10}));
// The base circle (centred at the origin) seen from above.
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [0, 5, 0]}]}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [0, 5, 0]}]}));
// One seam half: exact conics (rational quadratic arcs).
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
@@ -321,7 +320,7 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
}
}
// The whole face (both halves of its rim, plus its seam) gives the full ellipse.
let r = run(&mut s, "sketch.project", json!({"refs": [{"face": [0, 0, 0]}]}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"face": [0, 0, 0]}]}));
let si = inspect(&mut s);
let types: Vec<String> = r["links"][0]["curves"]
.as_array()
@@ -330,7 +329,7 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
.map(|id| si["curves"].as_array().unwrap().iter().find(|c| c["id"] == *id).unwrap()["type"].as_str().unwrap().to_string())
.collect();
assert!(types.iter().all(|t| t == "conic" || t == "ellipse"), "{types:?}");
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [0, -5, 0]}]}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [0, -5, 0]}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
if c["type"] == "ellipse" {
@@ -338,14 +337,14 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
let major = &c["major_at"];
assert!(((major[0].as_f64().unwrap().powi(2) + major[1].as_f64().unwrap().powi(2)).sqrt() - 5.0).abs() < 1e-6, "{c}");
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
// A spline of a parallel sketch projects as the same spline.
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 30}));
let sp = run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 5], [20, 0]]}))["curves"][0].clone();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 30}));
let sp = run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 5], [20, 0]]}))["curves"][0].clone();
run(&mut s, "SketchStop", json!({}));
let sk1 = s.doc.features.last().unwrap().id;
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": sk1, "curve": sp}]}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"sketch": sk1, "curve": sp}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "spline", "{c}");
@@ -356,13 +355,13 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
fn sweeps_and_pipes_follow_3d_sketch_curves() {
// A line projected onto a sphere: a 3D arc on its top.
let mut s = Session::default();
run(&mut s, "solid.sphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-12, 0], [12, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "PrimitiveSphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-12, 0], [12, 0]]}))["curves"])[0].clone();
run(&mut s, "SketchStop", json!({}));
let src = s.doc.features.last().unwrap().id;
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project_to_surface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectToSurface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
let wid = r["links"][0]["wires"][0].as_str().unwrap().to_string();
let path_sketch = s.active_sketch.unwrap();
let pts = {
@@ -370,11 +369,11 @@ fn sweeps_and_pipes_follow_3d_sketch_curves() {
let sk = &st.sketch(path_sketch).unwrap().sketch;
sk.wires[sk.wire_index(&wid).unwrap()].pts.clone()
};
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
// A pipe along it: volume ≈ π r² × length.
let before = s.model.state().bodies.len();
run(&mut s, "solid.pipe", json!({"path_sketch": path_sketch, "path": [wid.clone()], "diameter": "2 mm", "operation": "new"}));
run(&mut s, "PrimitivePipe", json!({"path_sketch": path_sketch, "path": [wid.clone()], "diameter": "2 mm", "operation": "new"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), before + 1);
let v = st.bodies.last().unwrap().mesh().measure().volume;
@@ -386,54 +385,13 @@ fn sweeps_and_pipes_follow_3d_sketch_curves() {
let t = (b - a).normalized().unwrap();
let x = t.cross(solvecraft_geom::Vec3::Z).normalized().unwrap();
let y = t.cross(x);
run(&mut s, "sketch.create", json!({"plane": {"origin": [a.x, a.y, a.z], "x_dir": [x.x, x.y, x.z], "y_dir": [y.x, y.y, y.z]}}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-1, -1], "p1": [1, 1]}));
run(&mut s, "SketchCreate", json!({"plane": {"origin": [a.x, a.y, a.z], "x_dir": [x.x, x.y, x.z], "y_dir": [y.x, y.y, y.z]}}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [-1, -1], "p1": [1, 1]}));
let prof = s.active_sketch.unwrap();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.sweep", json!({"sketch": prof, "path_sketch": path_sketch, "path": [wid], "operation": "new"}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Sweep", json!({"sketch": prof, "path_sketch": path_sketch, "path": [wid], "operation": "new"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), before + 2);
let v = st.bodies.last().unwrap().mesh().measure().volume;
assert!((v - 4.0 * len).abs() < 0.05 * 4.0 * len, "{v} vs {}", 4.0 * len);
}
/// #47: references to other bodies: an edge, a face's loops and a silhouette of a second body
/// project into a sketch on the first; they follow an edit of that body (a parameter) and
/// are reported lost, not crashed on, when it goes away.
#[test]
fn project_and_include_from_other_bodies() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "w", "expression": "10 mm"}));
run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 40, "width": 30, "height": 5}));
run(&mut s, "solid.box", json!({"corner": [50, 0, 0], "length": "w", "width": 10, "height": 20, "operation": "new"}));
run(&mut s, "solid.cylinder", json!({"base": [80, 5, 0], "radius": 4, "height": 10, "operation": "new"}));
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 5]}, "project_edges": false}));
// An edge on the second box's top, its top face's loop, the cylinder's silhouette.
run(&mut s, "sketch.project", json!({"refs": [{"edge": [55, 0, 20]}]}));
run(&mut s, "sketch.project", json!({"refs": [{"face": [55, 5, 20]}]}));
run(&mut s, "sketch.include_3d", json!({"refs": [{"edge": [80, 1, 10]}]}));
run(&mut s, "sketch.project", json!({"refs": [{"body": "Body3"}]}));
let si = inspect(&mut s);
let lc = linked_curves(&si);
// (Geometry already there is not drawn twice: the edge lies on the face's loop, the included
// circle on the silhouette.)
assert!(lc.len() >= 6, "{}", lc.len());
// The projected edge of the second box runs from x 50 to 60 at y 0.
let xs = |si: &Value| -> Vec<f64> {
linked_curves(si).iter().flat_map(|c| [c["start_at"][0].as_f64().unwrap_or(0.0), c["end_at"][0].as_f64().unwrap_or(0.0)]).collect()
};
assert!(xs(&si).iter().any(|x| (x - 60.0).abs() < 1e-6), "{:?}", xs(&si));
run(&mut s, "sketch.finish", json!({}));
// Widen the second box: the projection follows (its far edge is at 70).
run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
let id = s.doc.features.iter().find(|f| matches!(f.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap().id;
let si = run(&mut s, "sketch.inspect", json!({"sketch": id}));
assert!(xs(&si).iter().any(|x| (x - 70.0).abs() < 1e-6), "{:?}", xs(&si));
// Remove the cylinder: its silhouette and edge are lost, with a warning and no error.
let cyl = s.doc.features.iter().find(|f| matches!(f.kind, solvecraft_doc::FeatureKind::Cylinder { .. })).unwrap().id;
run(&mut s, "timeline.delete", json!({"feature": cyl}));
let t = run(&mut s, "document.inspect", json!({}));
let row = t["timeline"].as_array().unwrap().iter().find(|r| r["id"] == json!(id)).cloned().unwrap();
assert!(row["error"].is_null(), "{row}");
assert!(row["warning"].as_str().is_some_and(|w| w.contains("lost")), "{row}");
}
+9 -25
View File
@@ -19,11 +19,11 @@ fn ids(v: &Value) -> Vec<String> {
/// A new sketch with one rectangle of the given kind; returns its four side ids.
fn rect(kind: &str) -> (Session, Vec<String>) {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = match kind {
"center" => run(&mut s, "sketch.rectangle.center", json!({"center": [3, 2], "corner": [13, 9]})),
"two" => run(&mut s, "sketch.rectangle.two_point", json!({"p0": [1, 1], "p1": [21, 15]})),
_ => run(&mut s, "sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15], "connect": false})),
"center" => run(&mut s, "ShapeRectangleCenter", json!({"center": [3, 2], "corner": [13, 9]})),
"two" => run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [1, 1], "p1": [21, 15]})),
_ => run(&mut s, "ShapeRectangleThreePoint", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15]})),
};
let sides = ids(&r["curves"]);
assert_eq!(sides.len(), 4, "{r}");
@@ -54,9 +54,9 @@ fn fresh_rectangles_have_the_expected_freedom() {
fn rectangles_take_dimensions_without_conflict() {
for k in KINDS {
let (mut s, l) = rect(k);
run(&mut s, "sketch.dimension", json!({"entities": [l[0]], "value": 30}));
run(&mut s, "SketchDimension", json!({"entities": [l[0]], "value": 30}));
healthy(&mut s, &format!("{k} width"));
run(&mut s, "sketch.dimension", json!({"entities": [l[1]], "value": 12}));
run(&mut s, "SketchDimension", json!({"entities": [l[1]], "value": 12}));
let dof = healthy(&mut s, &format!("{k} width+height"));
assert_eq!(dof, if k == "three" { 3 } else { 2 }, "{k}");
}
@@ -72,12 +72,12 @@ fn rectangles_can_be_dragged_pinned_and_made_square() {
run(&mut s, "sketch.move_point", json!({"point": format!("{}.end", l[2]), "to": [25, 22]}));
healthy(&mut s, &format!("{k} drag other corner"));
// Pin a corner to the origin.
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", l[0]), "b": "origin"}));
run(&mut s, "ConstraintCoincident", json!({"a": format!("{}.start", l[0]), "b": "origin"}));
healthy(&mut s, &format!("{k} corner on origin"));
// Square: equal sides.
run(&mut s, "sketch.constraint.equal", json!({"a": l[0], "b": l[1]}));
run(&mut s, "ConstraintEqual", json!({"a": l[0], "b": l[1]}));
healthy(&mut s, &format!("{k} square"));
run(&mut s, "sketch.dimension", json!({"entities": [l[0]], "value": 10}));
run(&mut s, "SketchDimension", json!({"entities": [l[0]], "value": 10}));
let dof = healthy(&mut s, &format!("{k} square sized"));
assert_eq!(dof, if k == "three" { 1 } else { 0 }, "{k}");
}
@@ -101,19 +101,3 @@ fn dragging_corners_never_collapses_a_side() {
}
}
}
#[test]
fn center_rectangle_draws_both_diagonals_like_fusion() {
let (mut s, _) = rect("center");
let si = run(&mut s, "sketch.inspect", json!({}));
let curves = si["curves"].as_array().cloned().unwrap_or_default();
// Four sides plus two construction diagonals; the other rectangles have no diagonals.
assert_eq!(curves.len(), 6, "{si}");
assert_eq!(curves.iter().filter(|c| c["construction"] == true).count(), 2, "{si}");
assert_eq!(healthy(&mut s, "center diagonals"), 4);
for k in ["two", "three"] {
let (mut s, _) = rect(k);
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["curves"].as_array().map(Vec::len), Some(4), "{k}: {si}");
}
}
@@ -1,175 +0,0 @@
//! Adversarial Trim, Extend and Offset cases (#45): each gives the expected result or a clear
//! error, and the sketch always stays sound (it solves, every point is finite, nothing
//! degenerate is left behind).
use serde_json::{Value, json};
use crate::Session;
fn new_sketch() -> Session {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s
}
fn curves(r: &Value) -> Vec<String> {
r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
fn add(s: &mut Session, id: &str, p: Value) -> Vec<String> {
match s.execute(id, &p) {
Ok(r) => curves(&r),
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// The sketch is sound: it solves, every point is finite, no curve has collapsed.
fn sound(s: &Session) {
let id = s.active_sketch.unwrap();
let st = s.model.state();
let ss = st.sketch(id).unwrap();
assert!(ss.report.ok(), "the sketch no longer solves: {:?}", ss.report.failing);
for p in &ss.sketch.points {
assert!(p.pos.is_finite(), "point {} is {:?}", p.id, p.pos);
}
for (i, c) in ss.sketch.curves.iter().enumerate() {
let len: f64 = ss.sketch.segs(i).iter().map(|g| g.length()).sum();
assert!(len.is_finite() && len > 1e-9, "curve {} has length {len}", c.id);
}
}
fn count(s: &Session) -> usize {
let id = s.active_sketch.unwrap();
s.model.state().sketch(id).unwrap().sketch.curves.len()
}
#[test]
fn trim_cases() {
// A line crossing a circle twice: trimming the chord leaves the two outer stubs.
let mut s = new_sketch();
add(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
let l = add(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]]}))[0].clone();
s.execute("sketch.trim", &json!({"curve": l, "at": [0, 0]})).unwrap();
sound(&s);
assert_eq!(count(&s), 3, "circle + two stubs");
// Trimming at a tangent touch, at a curve end, on a curve with no crossings.
let mut s = new_sketch();
let c = add(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}))[0].clone();
add(&mut s, "sketch.line", json!({"points": [[-20, 10], [20, 10]]}));
let r = s.execute("sketch.trim", &json!({"curve": c, "at": [0, -10]}));
sound(&s);
let _ = r;
let mut s = new_sketch();
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))[0].clone();
// No crossings: the whole line goes (Fusion deletes it).
let r = s.execute("sketch.trim", &json!({"curve": l, "at": [5, 0]}));
sound_or_empty(&s);
assert!(r.is_ok() || r.is_err());
// Overlapping collinear lines.
let mut s = new_sketch();
let a = add(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))[0].clone();
add(&mut s, "sketch.line", json!({"points": [[5, 0], [15, 0]]}));
let _ = s.execute("sketch.trim", &json!({"curve": a, "at": [10, 0]}));
sound(&s);
// A spline crossed by a line, trimmed between the crossings; an ellipse cut by a line.
let mut s = new_sketch();
let sp = add(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 10], [20, -10], [30, 0]]}))[0].clone();
add(&mut s, "sketch.line", json!({"points": [[-5, 2], [35, 2]]}));
s.execute("sketch.trim", &json!({"curve": sp, "at": [10, 9]})).unwrap();
sound(&s);
let mut s = new_sketch();
let e = add(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))[0].clone();
add(&mut s, "sketch.line", json!({"points": [[-15, 0], [15, 0]]}));
s.execute("sketch.trim", &json!({"curve": e, "at": [0, -4]})).unwrap();
sound(&s);
// Hostile points.
let mut s = new_sketch();
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))[0].clone();
for at in [json!([f64::MAX, 0]), json!([1e300, 1e300]), json!("x"), json!([0])] {
let _ = s.execute("sketch.trim", &json!({"curve": l, "at": at}));
sound(&s);
}
}
fn sound_or_empty(s: &Session) {
if count(s) > 0 {
sound(s);
}
}
#[test]
fn extend_cases() {
// A line extends to a circle; to a spline; with nothing to reach it is a clear error.
let mut s = new_sketch();
add(&mut s, "sketch.circle.center", json!({"center": [20, 0], "radius": 5}));
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 0]]}))[0].clone();
s.execute("sketch.extend", &json!({"curve": l, "at": [5, 0]})).unwrap();
sound(&s);
let id = s.active_sketch.unwrap();
let st = s.model.state();
let ss = st.sketch(id).unwrap();
let li = ss.sketch.curve_index(&l).unwrap();
let end = ss.sketch.segs(li).iter().map(|g| g.end().x.max(g.start().x)).fold(f64::NEG_INFINITY, f64::max);
assert!((end - 15.0).abs() < 1e-6, "{end}");
drop(st);
let mut s = new_sketch();
add(&mut s, "sketch.spline.fit_point", json!({"points": [[20, -10], [22, 0], [20, 10]]}));
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 0]]}))[0].clone();
s.execute("sketch.extend", &json!({"curve": l, "at": [5, 0]})).unwrap();
sound(&s);
let mut s = new_sketch();
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 0]]}))[0].clone();
assert!(s.execute("sketch.extend", &json!({"curve": l, "at": [5, 0]})).is_err());
sound(&s);
// An arc extends along its circle to a line.
let mut s = new_sketch();
add(&mut s, "sketch.line", json!({"points": [[-20, -3], [20, -3]]}));
let a = add(&mut s, "sketch.arc.three_point", json!({"start": [10, 0], "end": [0, 10], "through": [7.0710678, 7.0710678]}))[0].clone();
let _ = s.execute("sketch.extend", &json!({"curve": a, "at": [10, 0]}));
sound(&s);
}
#[test]
fn offset_cases() {
// A rectangle offset outward and inward; inward past its middle vanishes (clear error).
let mut s = new_sketch();
let r = add(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
let n0 = count(&s);
s.execute("sketch.offset", &json!({"curves": [r[0].clone()], "distance": 2, "side": [-5, -5]})).unwrap();
sound(&s);
assert!(count(&s) > n0);
let n1 = count(&s);
s.execute("sketch.offset", &json!({"curves": [r[0].clone()], "distance": 2, "side": [10, 5]})).unwrap();
sound(&s);
assert!(count(&s) > n1);
let n2 = count(&s);
assert!(
s.execute("sketch.offset", &json!({"curves": [r[0].clone()], "distance": 6, "side": [10, 5]})).is_err(),
"inward 6 on a 10 wide rectangle"
);
assert_eq!(count(&s), n2, "a failed offset adds nothing");
sound(&s);
// A slot (lines and arcs), a spline chain, an open zig-zag whose inner offset self-crosses.
let mut s = new_sketch();
let slot = add(&mut s, "sketch.slot.center_to_center", json!({"p0": [0, 0], "p1": [20, 0], "width": 6}));
if let Some(c) = slot.first() {
s.execute("sketch.offset", &json!({"curves": [c], "distance": 1, "side": [10, 10]})).unwrap();
sound(&s);
}
let mut s = new_sketch();
let sp = add(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 6], [20, -6], [30, 0]]}))[0].clone();
s.execute("sketch.offset", &json!({"curves": [sp], "distance": 1.5})).unwrap();
sound(&s);
let mut s = new_sketch();
let z = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0], [10.5, 5], [11, 0], [20, 0]]}));
let r = s.execute("sketch.offset", &json!({"curves": [z[0].clone()], "distance": 2, "side": [10, -5]}));
sound(&s);
let _ = r;
// Hostile distances.
let mut s = new_sketch();
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))[0].clone();
for d in [json!(0), json!(-1e308), json!(f64::MAX), json!("x"), json!(1e-12)] {
let _ = s.execute("sketch.offset", &json!({"curves": [l.clone()], "distance": d}));
sound(&s);
}
}
+102 -102
View File
@@ -20,7 +20,7 @@ fn sketch(s: &Session) -> Sketch {
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
s
}
@@ -30,11 +30,11 @@ fn new_sketch() -> Session {
#[test]
fn every_dimension_kind_has_a_drawable_frame() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [60, 40]}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 20], "radius": 8}));
run(&mut s, "sketch.arc.center_point", json!({"center": [100, 0], "start": [110, 0], "end": [100, 10]}));
run(&mut s, "sketch.line", json!({"points": [[-40, 0], [-10, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[-40, 0], [-20, 25]]}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [60, 40]}));
run(&mut s, "CircleCenterRadius", json!({"center": [30, 20], "radius": 8}));
run(&mut s, "ArcCenterTwoPoint", json!({"center": [100, 0], "start": [110, 0], "end": [100, 10]}));
run(&mut s, "DrawPolyline", json!({"points": [[-40, 0], [-10, 0]]}));
run(&mut s, "DrawPolyline", json!({"points": [[-40, 0], [-20, 25]]}));
for (ents, ty) in [
(json!(["l1"]), "auto"),
(json!(["l2"]), "vertical"),
@@ -44,9 +44,9 @@ fn every_dimension_kind_has_a_drawable_frame() {
(json!(["a1"]), "arc_length"),
(json!(["l5", "l6"]), "angle"),
] {
run(&mut s, "sketch.dimension", json!({"entities": ents, "type": ty}));
run(&mut s, "SketchDimension", json!({"entities": ents, "type": ty}));
}
run(&mut s, "sketch.dimension", json!({"entities": ["c1.center", "l2"], "driven": true}));
run(&mut s, "SketchDimension", json!({"entities": ["c1.center", "l2"], "driven": true}));
let sk = sketch(&s);
let dims: Vec<_> = sk.constraints.iter().filter(|c| c.kind.is_dimension()).collect();
assert_eq!(dims.len(), 8);
@@ -71,8 +71,8 @@ fn every_dimension_kind_has_a_drawable_frame() {
#[test]
fn dimension_text_is_stored_relative_to_the_dimension_and_follows_it() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]], "connect": false}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "text_at": [20, -8]}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [40, 0]]}));
let d = run(&mut s, "SketchDimension", json!({"entities": ["l1"], "text_at": [20, -8]}));
let param = d["param"].as_str().unwrap_or_default().to_string();
let text = |s: &Session| {
let sk = sketch(s);
@@ -102,8 +102,8 @@ fn dimension_text_is_stored_relative_to_the_dimension_and_follows_it() {
#[test]
fn deleting_a_dimension_frees_its_parameter() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["c1"]}));
run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 5}));
let d = run(&mut s, "SketchDimension", json!({"entities": ["c1"]}));
let param = d["param"].as_str().unwrap_or_default().to_string();
let id = sketch(&s).constraints.iter().find(|c| c.param.as_deref() == Some(param.as_str())).map(|c| c.id.clone()).expect("dim");
run(&mut s, "sketch.delete", json!({"entities": [id]}));
@@ -126,22 +126,22 @@ fn dof(s: &mut Session) -> i64 {
#[test]
fn fixed_points_survive_drags_and_solves_and_unfix_frees_them() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0], [30, 20]], "connect": false}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 0], [30, 20]]}));
let free = dof(&mut s);
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"}));
let r = run(&mut s, "ConstraintFix", json!({"entity": "l1.end"}));
assert_eq!(r["result"]["fixed"], true);
assert_eq!(dof(&mut s), free - 2);
let corner = pt(&s, "l1.end");
// Dragging the fixed point does nothing; dragging its neighbours never moves it.
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [50, 50]}));
assert_eq!(pt(&s, "l1.end"), corner);
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "vertical"}));
run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l2", "mode": "vertical"}));
run(&mut s, "sketch.move_point", json!({"point": "l2.end", "to": [40, 25]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 12}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 12}));
assert!(pt(&s, "l1.end").dist(corner) < 1e-12, "{:?}", pt(&s, "l1.end"));
assert!((pt(&s, "l1.start").dist(corner) - 12.0).abs() < 1e-6);
// Unfix: the point moves again.
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"}));
let r = run(&mut s, "ConstraintFix", json!({"entity": "l1.end"}));
assert_eq!(r["result"]["fixed"], false);
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [30, -5]}));
assert!(pt(&s, "l1.end").dist(corner) > 1.0);
@@ -150,13 +150,13 @@ fn fixed_points_survive_drags_and_solves_and_unfix_frees_them() {
#[test]
fn fixing_curves_holds_their_points_and_radius() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 5}));
run(&mut s, "sketch.line", json!({"points": [[30, 0], [50, 0]]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 40], "start": [10, 40], "end": [0, 50]}));
run(&mut s, "sketch.ellipse", json!({"center": [60, 40], "major": [70, 40], "minor_radius": 4}));
run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 5}));
run(&mut s, "DrawPolyline", json!({"points": [[30, 0], [50, 0]]}));
run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 40], "start": [10, 40], "end": [0, 50]}));
run(&mut s, "CircleElipse", json!({"center": [60, 40], "major": [70, 40], "minor_radius": 4}));
let free = dof(&mut s);
// Multi-select fixes them all (circle 3, line 4, arc 5, ellipse 5 degrees of freedom).
let r = run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
let r = run(&mut s, "ConstraintFix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
assert_eq!(r["result"]["changed"], 4);
assert_eq!(dof(&mut s), free - 17);
let si = run(&mut s, "sketch.inspect", json!({}));
@@ -164,18 +164,18 @@ fn fixing_curves_holds_their_points_and_radius() {
// A fixed circle keeps centre and radius; a dimension on it conflicts.
run(&mut s, "sketch.move_point", json!({"point": "c1.center", "to": [0, 0]}));
assert_eq!(pt(&s, "c1.center"), solvecraft_geom::Vec2::new(10.0, 10.0));
assert!(s.execute("sketch.dimension", &json!({"entities": ["c1"], "value": 20})).is_err());
assert!(s.execute("SketchDimension", &json!({"entities": ["c1"], "value": 20})).is_err());
let r0 = sketch(&s).radius(0);
assert_eq!(r0, Some(5.0));
// Toggle without `fixed`: all fixed, so all are unfixed.
let r = run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
let r = run(&mut s, "ConstraintFix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
assert_eq!(r["result"]["fixed"], false);
assert_eq!(dof(&mut s), free);
run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "type": "radius", "value": 7}));
run(&mut s, "SketchDimension", json!({"entities": ["c1"], "type": "radius", "value": 7}));
assert!((sketch(&s).radius(0).unwrap_or(0.0) - 7.0).abs() < 1e-9);
// Mixed selection: one fixed, one free → both end up fixed.
run(&mut s, "sketch.constraint.fix", json!({"entity": "l1"}));
let r = run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1", "l1.start", "a1"]}));
run(&mut s, "ConstraintFix", json!({"entity": "l1"}));
let r = run(&mut s, "ConstraintFix", json!({"entities": ["l1", "l1.start", "a1"]}));
assert_eq!(r["result"]["fixed"], true);
assert!(sketch(&s).curves.iter().filter(|c| c.id == "l1" || c.id == "a1").all(|c| c.fixed));
}
@@ -183,17 +183,17 @@ fn fixing_curves_holds_their_points_and_radius() {
#[test]
fn projected_geometry_counts_as_fixed() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [10, 0, 5]}]}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [10, 0, 5]}]}));
let sk = sketch(&s);
let ci = sk.curves.iter().position(|c| c.link.is_some()).unwrap_or_else(|| panic!("projected: {r}"));
let id = sk.curves[ci].id.clone();
assert!(sk.curve_locked(ci));
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": id}));
let r = run(&mut s, "ConstraintFix", json!({"entity": id}));
// Already fixed: the toggle unfixes, which projected geometry ignores.
assert_eq!(r["result"]["changed"], 0);
assert!(sketch(&s).curve_locked(ci));
@@ -213,11 +213,11 @@ fn curve_ids(v: &Value) -> Vec<String> {
#[test]
fn construction_toggle_takes_shapes_out_of_profiles_and_back() {
let mut s = new_sketch();
let rect = curve_ids(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})));
let slot = curve_ids(&run(&mut s, "sketch.slot.center_to_center", json!({"p0": [40, 5], "p1": [60, 5], "width": 6})));
let poly = curve_ids(&run(&mut s, "sketch.polygon.inscribed", json!({"center": [0, 40], "radius": 8, "sides": 6})));
let circ = curve_ids(&run(&mut s, "sketch.circle.center", json!({"center": [40, 40], "radius": 5})));
let ell = curve_ids(&run(&mut s, "sketch.ellipse", json!({"center": [70, 40], "major": [80, 40], "minor_radius": 4})));
let rect = curve_ids(&run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]})));
let slot = curve_ids(&run(&mut s, "ShapeSlotCenterToCenter", json!({"p0": [40, 5], "p1": [60, 5], "width": 6})));
let poly = curve_ids(&run(&mut s, "ShapePolygonInscribed", json!({"center": [0, 40], "radius": 8, "sides": 6})));
let circ = curve_ids(&run(&mut s, "CircleCenterRadius", json!({"center": [40, 40], "radius": 5})));
let ell = curve_ids(&run(&mut s, "CircleElipse", json!({"center": [70, 40], "major": [80, 40], "minor_radius": 4})));
assert_eq!(profiles(&mut s), 5);
for (shape, left) in [(&rect, 4), (&slot, 3), (&poly, 2), (&circ, 1), (&ell, 0)] {
let r = run(&mut s, "sketch.construction", json!({ "curves": shape }));
@@ -240,12 +240,12 @@ fn construction_toggle_takes_shapes_out_of_profiles_and_back() {
#[test]
fn projected_geometry_can_become_construction() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [10, 0, 5]}]}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [10, 0, 5]}]}));
let id = sketch(&s).curves.iter().find(|c| c.link.is_some()).map(|c| c.id.clone()).expect("projected");
let r = run(&mut s, "sketch.construction", json!({ "curves": [id.clone()] }));
assert_eq!(r["changed"], 1);
@@ -273,8 +273,8 @@ fn same(a: &(Vec<solvecraft_geom::Vec2>, Option<f64>), b: &(Vec<solvecraft_geom:
/// A free line l1 (5,3)–(45,3) and a circle c1 at (20,18) with radius 5.
fn line_and_circle() -> Session {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[5, 3], [45, 3]]}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 18], "radius": 5}));
run(&mut s, "DrawPolyline", json!({"points": [[5, 3], [45, 3]]}));
run(&mut s, "CircleCenterRadius", json!({"center": [20, 18], "radius": 5}));
s
}
@@ -291,40 +291,40 @@ fn tangent_moves_only_the_second_pick() {
// Line then circle: only the circle moves (and keeps its size).
let mut s = line_and_circle();
let l0 = snap(&s, "l1");
run(&mut s, "sketch.constraint.tangent", json!({"a": "l1", "b": "c1"}));
run(&mut s, "ConstraintTangent", json!({"a": "l1", "b": "c1"}));
assert!(same(&snap(&s, "l1"), &l0), "line moved: {:?}", snap(&s, "l1"));
let c = snap(&s, "c1");
assert!((c.1.unwrap_or(0.0) - 5.0).abs() < 1e-9 && (c.0[0].y - 8.0).abs() < 1e-6, "{c:?}");
// Circle then line: only the line moves.
check_order(line_and_circle(), "sketch.constraint.tangent", "c1", "l1");
check_order(line_and_circle(), "ConstraintTangent", "c1", "l1");
}
#[test]
fn a_fixed_second_pick_makes_the_first_move() {
let mut s = line_and_circle();
run(&mut s, "sketch.constraint.fix", json!({"entity": "l1"}));
run(&mut s, "ConstraintFix", json!({"entity": "l1"}));
let l0 = snap(&s, "l1");
let c0 = snap(&s, "c1");
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "l1"}));
run(&mut s, "ConstraintTangent", json!({"a": "c1", "b": "l1"}));
assert!(same(&snap(&s, "l1"), &l0));
assert!(!same(&snap(&s, "c1"), &c0));
// Fixed first: the second moves as usual.
let mut s = line_and_circle();
run(&mut s, "sketch.constraint.fix", json!({"entity": "c1"}));
run(&mut s, "ConstraintFix", json!({"entity": "c1"}));
let c0 = snap(&s, "c1");
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "l1"}));
run(&mut s, "ConstraintTangent", json!({"a": "c1", "b": "l1"}));
assert!(same(&snap(&s, "c1"), &c0));
}
#[test]
fn two_fully_constrained_entities_conflict_and_nothing_moves() {
let mut s = line_and_circle();
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l1.end", "c1.center"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "type": "radius", "value": 5}));
run(&mut s, "ConstraintFix", json!({"entities": ["l1.start", "l1.end", "c1.center"]}));
run(&mut s, "SketchDimension", json!({"entities": ["c1"], "type": "radius", "value": 5}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert!(si["curves"].as_array().is_some_and(|c| c.iter().all(|c| c["fully_constrained"] == true)), "{si}");
let before = sketch(&s);
let e = s.execute("sketch.constraint.tangent", &json!({"a": "l1", "b": "c1"}));
let e = s.execute("ConstraintTangent", &json!({"a": "l1", "b": "c1"}));
assert!(e.is_err(), "{e:?}");
assert_eq!(sketch(&s), before);
}
@@ -333,22 +333,22 @@ fn two_fully_constrained_entities_conflict_and_nothing_moves() {
fn line_pairs_keep_the_first_line() {
let lines = || {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 2]], "connect": false}));
run(&mut s, "sketch.line", json!({"points": [[5, 20], [25, 35]]}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 2]]}));
run(&mut s, "DrawPolyline", json!({"points": [[5, 20], [25, 35]]}));
s
};
let len = |s: &Session, l: &str| {
let p = snap(s, l).0;
p[0].dist(p[1])
};
for cmd in ["sketch.constraint.parallel", "sketch.constraint.perpendicular", "sketch.constraint.equal", "sketch.constraint.collinear"] {
for cmd in ["ConstraintParallel", "ConstraintPerpendicular", "ConstraintEqual", "ConstraintCollinear"] {
check_order(lines(), cmd, "l1", "l2");
check_order(lines(), cmd, "l2", "l1");
// The second line turns and slides; only Equal changes its length.
let mut s = lines();
let l0 = len(&s, "l2");
run(&mut s, cmd, json!({"a": "l1", "b": "l2"}));
if cmd != "sketch.constraint.equal" {
if cmd != "ConstraintEqual" {
assert!((len(&s, "l2") - l0).abs() < 1e-6, "{cmd}: {l0} -> {}", len(&s, "l2"));
} else {
assert!((len(&s, "l2") - len(&s, "l1")).abs() < 1e-6);
@@ -360,17 +360,17 @@ fn line_pairs_keep_the_first_line() {
fn circle_pairs_keep_the_first_circle() {
let circles = || {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5, "connect": false}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 10], "radius": 8}));
run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 5}));
run(&mut s, "CircleCenterRadius", json!({"center": [30, 10], "radius": 8}));
s
};
for cmd in ["sketch.constraint.concentric", "sketch.constraint.equal", "sketch.constraint.tangent"] {
for cmd in ["ConstraintConcentric", "ConstraintEqual", "ConstraintTangent"] {
check_order(circles(), cmd, "c1", "c2");
check_order(circles(), cmd, "c2", "c1");
}
// Equal resizes the second to the first.
let mut s = circles();
run(&mut s, "sketch.constraint.equal", json!({"a": "c2", "b": "c1"}));
run(&mut s, "ConstraintEqual", json!({"a": "c2", "b": "c1"}));
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
}
@@ -378,25 +378,25 @@ fn circle_pairs_keep_the_first_circle() {
fn point_constraints_keep_the_first_pick() {
let pts = || {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [20, 25]]}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [20, 25]]}));
s
};
check_order(pts(), "sketch.constraint.coincident", "l2.start", "l1.end");
check_order(pts(), "sketch.constraint.coincident", "l1.end", "l2.start");
check_order(pts(), "ConstraintCoincident", "l2.start", "l1.end");
check_order(pts(), "ConstraintCoincident", "l1.end", "l2.start");
// Curve picked first, point second: the point goes onto the line.
check_order(pts(), "sketch.constraint.coincident", "l1", "l2.start");
check_order(pts(), "ConstraintCoincident", "l1", "l2.start");
// Midpoint: the point stays, the line moves.
let mut s = pts();
let p0 = snap(&s, "l2.end");
run(&mut s, "sketch.constraint.midpoint", json!({"point": "l2.end", "line": "l1"}));
run(&mut s, "ConstraintMidPoint", json!({"point": "l2.end", "line": "l1"}));
assert!(same(&snap(&s, "l2.end"), &p0));
// Symmetry: the first point and the line stay, the second mirrors.
let mut s = pts();
run(&mut s, "sketch.point", json!({"point": [3, 7]}));
run(&mut s, "sketch.point", json!({"point": [4, -9]}));
run(&mut s, "DrawPoint", json!({"point": [3, 7]}));
run(&mut s, "DrawPoint", json!({"point": [4, -9]}));
let (a0, l0) = (snap(&s, "p1"), snap(&s, "l1"));
run(&mut s, "sketch.constraint.symmetry", json!({"a": "p1", "b": "p2", "line": "l1"}));
run(&mut s, "ConstraintSymmetry", json!({"a": "p1", "b": "p2", "line": "l1"}));
assert!(same(&snap(&s, "p1"), &a0) && same(&snap(&s, "l1"), &l0));
assert!(pt(&s, "p2").dist(solvecraft_geom::Vec2::new(3.0, -7.0)) < 1e-6, "{:?}", pt(&s, "p2"));
}
@@ -418,8 +418,8 @@ fn palette_options_are_kept_with_each_sketch() {
assert!(v.hide_dimensions && v.no_snap && v.slice && v.three_d && !v.hide_points);
// Another sketch starts with the defaults; the first keeps its options after finishing.
let first = s.active_sketch.expect("sketch");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "SketchCreate", json!({"plane": "XZ"}));
assert_eq!(sketch(&s).view, solvecraft_sketch::SketchView::default());
let o = run(&mut s, "sketch.options", json!({"sketch": first}));
assert_eq!(o["options"]["show_dimensions"], false);
@@ -430,7 +430,7 @@ fn palette_options_are_kept_with_each_sketch() {
assert!(s.execute("sketch.options", &json!({"bogus": true})).is_err());
// Undo restores the option.
run(&mut s, "sketch.options", json!({"show_points": false}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert!(!sketch(&s).view.hide_points);
}
@@ -448,7 +448,7 @@ fn v(x: f64, y: f64) -> solvecraft_geom::Vec2 {
#[test]
fn dragging_a_line_slides_it_and_an_end_keeps_the_other_end() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 20]]}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 20]]}));
drag(&mut s, "l1", [25.0, 15.0], [30.0, 25.0]);
assert!(pt(&s, "l1.start").dist(v(15.0, 20.0)) < 1e-9 && pt(&s, "l1.end").dist(v(45.0, 30.0)) < 1e-9);
drag(&mut s, "l1.end", [45.0, 30.0], [50.0, 0.0]);
@@ -459,7 +459,7 @@ fn dragging_a_line_slides_it_and_an_end_keeps_the_other_end() {
#[test]
fn dragging_a_rectangle_side_moves_that_side_only() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [40, 30]}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [10, 10], "p1": [40, 30]}));
// l2 is the right side (x = 40): dragged right, it stays vertical, the left side stays.
let left = snap(&s, "l4");
drag(&mut s, "l2", [40.0, 20.0], [50.0, 20.0]);
@@ -471,7 +471,7 @@ fn dragging_a_rectangle_side_moves_that_side_only() {
#[test]
fn dragging_circles_and_arcs() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5, "connect": false}));
run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 5}));
// The edge changes the radius about a fixed centre; the centre moves the circle.
drag(&mut s, "c1", [5.0, 0.0], [0.0, 8.0]);
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
@@ -479,7 +479,7 @@ fn dragging_circles_and_arcs() {
drag(&mut s, "c1.center", [0.0, 0.0], [20.0, 5.0]);
assert!(pt(&s, "c1.center").dist(v(20.0, 5.0)) < 1e-9);
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
run(&mut s, "sketch.arc.center_point", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}));
run(&mut s, "ArcCenterTwoPoint", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}));
drag(&mut s, "a1", [57.07, 7.07], [50.0 + 200f64.sqrt(), 200f64.sqrt()]);
assert!(pt(&s, "a1.center").dist(v(50.0, 0.0)) < 1e-9);
assert!((snap(&s, "a1").1.unwrap_or(0.0) - 20.0).abs() < 1e-9, "{:?}", snap(&s, "a1"));
@@ -489,16 +489,16 @@ fn dragging_circles_and_arcs() {
#[test]
fn constrained_geometry_resists_drags() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l1.end"]}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "ConstraintFix", json!({"entities": ["l1.start", "l1.end"]}));
let l0 = snap(&s, "l1");
drag(&mut s, "l1", [25.0, 10.0], [25.0, 30.0]);
assert!(same(&snap(&s, "l1"), &l0));
// A horizontal line dragged at its end stays horizontal; its length dimension holds.
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l1"}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 30}));
drag(&mut s, "l1.end", [40.0, 10.0], [45.0, 20.0]);
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
assert!((a.y - b.y).abs() < 1e-6 && (a.dist(b) - 30.0).abs() < 1e-6, "{a:?} {b:?}");
@@ -507,12 +507,12 @@ fn constrained_geometry_resists_drags() {
#[test]
fn fully_constrained_feedback_is_per_entity() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [50, 35]}));
run(&mut s, "sketch.circle.center", json!({"center": [80, 20], "radius": 8}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 40}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 25}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 10}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 10}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [10, 10], "p1": [50, 35]}));
run(&mut s, "CircleCenterRadius", json!({"center": [80, 20], "radius": 8}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 40}));
run(&mut s, "SketchDimension", json!({"entities": ["l2"], "value": 25}));
run(&mut s, "SketchDimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 10}));
run(&mut s, "SketchDimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 10}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 3);
for c in si["curves"].as_array().expect("curves") {
@@ -533,10 +533,10 @@ fn fully_constrained_feedback_is_per_entity() {
#[test]
fn chain_follows_shared_and_coincident_ends() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "sketch.line", json!({"points": [[40, 0], [50, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[50.5, 0], [60, 5]]}));
run(&mut s, "sketch.circle.center", json!({"center": [80, 0], "radius": 3}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "DrawPolyline", json!({"points": [[40, 0], [50, 0]]}));
run(&mut s, "DrawPolyline", json!({"points": [[50.5, 0], [60, 5]]}));
run(&mut s, "CircleCenterRadius", json!({"center": [80, 0], "radius": 3}));
let chain = |s: &mut Session, c: &str| -> Vec<String> {
let v = run(s, "sketch.chain", json!({"curve": c}));
v["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
@@ -545,7 +545,7 @@ fn chain_follows_shared_and_coincident_ends() {
assert_eq!(chain(&mut s, "l5"), ["l5"]);
assert_eq!(chain(&mut s, "c1"), ["c1"]);
// Joined by a coincident constraint, the two lines are one chain.
run(&mut s, "sketch.constraint.coincident", json!({"a": "l5.end", "b": "l6.start"}));
run(&mut s, "ConstraintCoincident", json!({"a": "l5.end", "b": "l6.start"}));
assert_eq!(chain(&mut s, "l6"), ["l6", "l5"]);
assert!(s.execute("sketch.chain", &json!({"curve": "nope"})).is_err());
}
@@ -553,9 +553,9 @@ fn chain_follows_shared_and_coincident_ends() {
#[test]
fn constraints_and_dimensions_can_be_selected_and_deleted_together() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]], "connect": false}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l1"}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 30}));
let ids: Vec<String> = sketch(&s).constraints.iter().map(|c| c.id.clone()).collect();
assert_eq!(ids.len(), 2);
let items: Vec<Value> = ids.iter().map(|id| json!({"type": "sketch_constraint", "id": id})).collect();
@@ -603,14 +603,14 @@ fn three_d_curves_need_the_option_and_move_joined() {
#[test]
fn pipe_and_sweep_follow_3d_sketch_curves() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.options", json!({"sketch_3d": true}));
let r = run(&mut s, "sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 30], [20, 0, 30]]}));
let mut path = curve_ids(&r);
let sp = run(&mut s, "sketch.spline3d", json!({"points": [[20, 0, 30], [35, 10, 35], [50, 0, 50]]}));
path.extend(curve_ids(&sp));
run(&mut s, "sketch.finish", json!({}));
let p = run(&mut s, "solid.pipe", json!({"path_sketch": "Path", "path": path, "diameter": 4}));
run(&mut s, "SketchStop", json!({}));
let p = run(&mut s, "PrimitivePipe", json!({"path_sketch": "Path", "path": path, "diameter": 4}));
let si = run(&mut s, "document.inspect", json!({"measure": true}));
let body = si["bodies"].as_array().and_then(|b| b.first()).cloned().unwrap_or_default();
// The tube runs from the first line's start to the spline's end (radius 2 around them; the
-154
View File
@@ -1,154 +0,0 @@
//! Surface commands: Patch, Stitch, Thicken and Trim (surface bodies and turning them into
//! solids).
use serde_json::Value;
use solvecraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, expr::Kind};
use super::CommandSpec;
use super::features::{add_feature, check_expr, feature_sketch, operation, plane_param, profiles};
use crate::params::{bad, bool_, expr, str_, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", patch)
.at("SURFACE", "CREATE")
.icon("patch")
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges) and body?; name?, body_name?"),
CommandSpec::new("surface.stitch", "Stitch", stitch)
.at("SURFACE", "MODIFY")
.icon("stitch")
.params("bodies: [names] (surfaces sewn into the first; a solid when they close); tolerance?: expr (default: tight)"),
CommandSpec::new("surface.thicken", "Thicken", thicken)
.at("SURFACE", "CREATE")
.icon("thicken")
.params("bodies: [surface names]; thickness: expr (negative: against the normal); symmetric?: bool; operation?: new|join|cut|intersect; targets?"),
CommandSpec::new("surface.extend", "Extend", extend)
.at("SURFACE", "MODIFY")
.icon("extend")
.params("body: planar surface name; edges: [[x,y,z]] (points on straight edges to push out); distance: expr"),
CommandSpec::new("surface.trim", "Trim", trim)
.at("SURFACE", "MODIFY")
.icon("trim")
.params("body: surface name; plane: XY|XZ|YZ|plane name|{origin, normal} or tool: {body, point} (a face, its surface extended); keep: [x,y,z] (a point on the side to keep)"),
];
fn patch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.patch";
let edges: Vec<solvecraft_geom::Vec3> = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if !edges.is_empty() {
if edges.len() > 10_000 {
return Err(bad(cmd, "too many edges"));
}
let body = str_(p, "body").map(str::to_string);
return add_feature(s, p, FeatureKind::Patch { sketch: None, profiles: ProfileSel::All, body, edges });
}
let sketch = feature_sketch(s, p, cmd)?;
let profiles = profiles(p, cmd)?;
add_feature(s, p, FeatureKind::Patch { sketch: Some(sketch), profiles, body: None, edges: Vec::new() })
}
fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
}
let st = s.model.state();
if let Some(b) = bodies.iter().find(|b| st.body(b).is_none()) {
return Err(bad(cmd, format!("no body `{b}`")));
}
Ok(bodies)
}
fn stitch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.stitch";
let bodies = bodies_param(s, p, cmd)?;
let tolerance = expr(p, "tolerance").unwrap_or_else(|| "0".into());
check_expr(s, &tolerance, Kind::Length, cmd, "tolerance")?;
add_feature(s, p, FeatureKind::Stitch { bodies, tolerance })
}
fn thicken(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.thicken";
let bodies = bodies_param(s, p, cmd)?;
let thickness = expr(p, "thickness").ok_or_else(|| bad(cmd, "`thickness` is required"))?;
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
let kind = FeatureKind::Thicken {
bodies,
thickness,
symmetric: bool_(p, "symmetric").unwrap_or(false),
operation: operation(p, cmd)?,
targets: string_list(p, "targets"),
};
add_feature(s, p, kind)
}
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.extend";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
let edges: Vec<solvecraft_geom::Vec3> = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if edges.is_empty() || edges.len() > 10_000 {
return Err(bad(cmd, "`edges` must list points on the edges to extend"));
}
let distance = expr(p, "distance").ok_or_else(|| bad(cmd, "`distance` is required"))?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
add_feature(s, p, FeatureKind::SurfaceExtend { body, edges, distance })
}
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.trim";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
let keep = p.get("keep").and_then(vec3).ok_or_else(|| bad(cmd, "`keep` must be [x, y, z] on the side to keep"))?;
let (plane, tool) = match p.get("tool").filter(|t| !t.is_null()) {
Some(t) => {
let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`tool.body` must name a body"))?.to_string();
let point = t.get("point").and_then(vec3).ok_or_else(|| bad(cmd, "`tool.point` must be [x, y, z] on the tool face"))?;
(PlaneRef::Origin { name: "XY".into() }, Some(FaceAt { body: tb, point }))
}
None => (plane_param(s, p.get("plane"), cmd)?, None),
};
add_feature(s, p, FeatureKind::SurfaceTrim { body, plane, tool, keep })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
use serde_json::json;
fn run(s: &mut Session, c: &str, p: Value) -> Value {
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
}
fn volume(s: &Session, name: &str) -> f64 {
let st = s.world_state();
solvecraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume
}
/// A rectangle patched, trimmed by a plane and thickened; a box's faces stitched back.
#[test]
fn patch_trim_thicken_and_stitch() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "surface.patch", json!({"sketch": "S", "body_name": "Sheet"}));
let st = s.world_state();
assert!(st.body("Sheet").unwrap().body.is_surface());
run(&mut s, "surface.trim", json!({"body": "Sheet", "plane": {"origin": [3, 0, 0], "normal": [1, 0, 0]}, "keep": [8, 5, 0]}));
run(&mut s, "surface.extend", json!({"body": "Sheet", "edges": [[10, 5, 0]], "distance": 5}));
run(&mut s, "surface.thicken", json!({"bodies": ["Sheet"], "thickness": "2 mm", "body_name": "Slab"}));
let v = volume(&s, "Slab");
assert!((v - 240.0).abs() < 1e-9, "{v}");
// Stitch the patch of a box's open top back on.
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cup"}));
run(&mut s, "surface.patch", json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup", "body_name": "Lid"}));
assert!(s.world_state().body("Lid").unwrap().body.is_surface());
assert!(s.execute("surface.thicken", &json!({"bodies": ["Nope"], "thickness": 1})).is_err());
assert!(s.execute("surface.trim", &json!({"body": "Sheet"})).is_err());
}
}
+20 -18
View File
@@ -9,27 +9,27 @@ use crate::params::{bad, bool_, num, vec3};
use crate::{Result, Session, SurfaceAnalysis};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("inspect.zebra", "Zebra Analysis", zebra)
CommandSpec::new("FusionZebraAnalysisCommand", "Zebra Analysis", zebra)
.at("SKETCH", "INSPECT")
.icon("zebra")
.noundo()
.params("stripes?: number across the view (default 12); clear?: true turns the analysis off"),
CommandSpec::new("inspect.draft", "Draft Analysis", draft)
CommandSpec::new("FusionDraftAnalysisCommand", "Draft Analysis", draft)
.at("SKETCH", "INSPECT")
.icon("draft_analysis")
.noundo()
.params("pull?: [x,y,z] direction (default Z); angle?: degrees (default 1); clear?: true turns the analysis off"),
CommandSpec::new("inspect.environment_map", "Environment Map Analysis", |s, p| set(s, p, SurfaceAnalysis::Environment))
CommandSpec::new("FusionEnvironmentMapAnalysisCommand", "Environment Map Analysis", |s, p| set(s, p, SurfaceAnalysis::Environment))
.at("SKETCH", "INSPECT")
.icon("environment_map")
.noundo()
.params("clear?: true turns the analysis off"),
CommandSpec::new("inspect.accessibility", "Accessibility Analysis", accessibility)
CommandSpec::new("FusionAccessibilityAnalysisCommand", "Accessibility Analysis", accessibility)
.at("SKETCH", "INSPECT")
.icon("accessibility")
.noundo()
.params("direction?: [x,y,z] the tool comes from (default Z, from above); clear?: true turns the analysis off"),
CommandSpec::new("inspect.curvature_map", "Curvature Map Analysis", curvature_map)
CommandSpec::new("FusionCurvatureMapAnalysisCommand", "Curvature Map Analysis", curvature_map)
.at("SKETCH", "INSPECT")
.icon("curvature_map")
.noundo()
@@ -49,13 +49,13 @@ fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result<Value> {
fn zebra(s: &mut Session, p: &Value) -> Result<Value> {
let stripes = num(p, "stripes").unwrap_or(12.0);
if !(stripes.is_finite() && (1.0..=200.0).contains(&stripes)) {
return Err(bad("inspect.zebra", "`stripes` must be 1…200"));
return Err(bad("FusionZebraAnalysisCommand", "`stripes` must be 1…200"));
}
set(s, p, SurfaceAnalysis::Zebra { stripes })
}
fn draft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.draft";
let cmd = "FusionDraftAnalysisCommand";
let pull = match p.get("pull") {
Some(v) => vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad(cmd, "`pull` must be a non-zero [x,y,z]"))?,
None => Vec3::Z,
@@ -69,14 +69,16 @@ fn draft(s: &mut Session, p: &Value) -> Result<Value> {
fn accessibility(s: &mut Session, p: &Value) -> Result<Value> {
let dir = match p.get("direction") {
Some(v) => vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad("inspect.accessibility", "`direction` must be a non-zero [x,y,z]"))?,
Some(v) => {
vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad("FusionAccessibilityAnalysisCommand", "`direction` must be a non-zero [x,y,z]"))?
}
None => Vec3::Z,
};
set(s, p, SurfaceAnalysis::Access { dir })
}
fn curvature_map(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.curvature_map";
let cmd = "FusionCurvatureMapAnalysisCommand";
let radius = match num(p, "radius") {
Some(r) if r.is_finite() && r > 1e-6 && r < 1e9 => r,
Some(_) => return Err(bad(cmd, "`radius` must be positive")),
@@ -98,22 +100,22 @@ mod tests {
#[test]
fn surface_analyses_are_view_state() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
s.execute("PrimitiveBox", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
let undo = s.undo.len();
s.execute("inspect.zebra", &json!({"stripes": 20})).unwrap();
s.execute("FusionZebraAnalysisCommand", &json!({"stripes": 20})).unwrap();
assert_eq!(s.analysis, Some(SurfaceAnalysis::Zebra { stripes: 20.0 }));
s.execute("inspect.draft", &json!({"pull": [0, 0, 2], "angle": 3})).unwrap();
s.execute("FusionDraftAnalysisCommand", &json!({"pull": [0, 0, 2], "angle": 3})).unwrap();
assert!(matches!(s.analysis, Some(SurfaceAnalysis::Draft { angle, .. }) if angle == 3.0));
s.execute("inspect.curvature_map", &json!({})).unwrap();
s.execute("FusionCurvatureMapAnalysisCommand", &json!({})).unwrap();
assert!(matches!(s.analysis, Some(SurfaceAnalysis::Curvature { radius }) if (radius - 300f64.sqrt() / 10.0).abs() < 1e-6));
s.execute("inspect.environment_map", &json!({})).unwrap();
s.execute("FusionEnvironmentMapAnalysisCommand", &json!({})).unwrap();
assert_eq!(s.analysis, Some(SurfaceAnalysis::Environment));
s.execute("inspect.accessibility", &json!({"direction": [0, 0, -3]})).unwrap();
s.execute("FusionAccessibilityAnalysisCommand", &json!({"direction": [0, 0, -3]})).unwrap();
assert_eq!(s.analysis, Some(SurfaceAnalysis::Access { dir: solvecraft_geom::Vec3::new(0.0, 0.0, -1.0) }));
s.execute("inspect.curvature_map", &json!({"clear": true})).unwrap();
s.execute("FusionCurvatureMapAnalysisCommand", &json!({"clear": true})).unwrap();
assert_eq!(s.analysis, None);
assert_eq!(s.undo.len(), undo, "no undo steps");
assert!(s.execute("inspect.zebra", &json!({"stripes": 0})).is_err());
assert!(s.execute("inspect.draft", &json!({"pull": [0, 0, 0]})).is_err());
assert!(s.execute("FusionZebraAnalysisCommand", &json!({"stripes": 0})).is_err());
assert!(s.execute("FusionDraftAnalysisCommand", &json!({"pull": [0, 0, 0]})).is_err());
}
}
-274
View File
@@ -1,274 +0,0 @@
//! Construction axes, points and planes (SOLID ▸ CONSTRUCT), one test per type, against a
//! 40 × 30 × 20 box at the origin, a cylinder, a sphere and a torus.
use serde_json::{Value, json};
use solvecraft_geom::{Plane, Vec3};
use crate::Session;
use crate::view::{construction_axes, construction_planes, construction_points};
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn part() -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Box"}));
run(&mut s, "solid.cylinder", json!({"base": [60, 0, 0], "radius": 5, "height": 10, "body_name": "Cyl"}));
run(&mut s, "solid.sphere", json!({"center": [0, 0, 60], "radius": 5, "body_name": "Ball"}));
run(&mut s, "solid.torus", json!({"center": [0, 60, 0], "major": 10, "minor": 2, "body_name": "Ring"}));
s
}
fn near(a: Vec3, b: Vec3) -> bool {
a.dist(b) < 1e-3
}
fn parallel(a: Vec3, b: Vec3) -> bool {
a.normalized().zip(b.normalized()).is_some_and(|(a, b)| a.cross(b).len() < 1e-6)
}
fn axis(s: &Session, name: &str) -> (Vec3, Vec3) {
construction_axes(s).into_iter().find(|a| a.1 == name).map(|a| (a.2, a.3)).unwrap_or_else(|| panic!("no axis {name}"))
}
fn point(s: &Session, name: &str) -> Vec3 {
construction_points(s).into_iter().find(|p| p.1 == name).map(|p| p.2).unwrap_or_else(|| panic!("no point {name}"))
}
fn plane(s: &Session, name: &str) -> Plane {
construction_planes(s).into_iter().find(|p| p.1 == name).map(|p| p.2).unwrap_or_else(|| panic!("no plane {name}"))
}
/// The line through `o` along `d` contains `p`.
fn on_line(o: Vec3, d: Vec3, p: Vec3) -> bool {
let d = d.normalized().unwrap_or(Vec3::Z);
(p - o - d * (p - o).dot(d)).len() < 1e-3
}
fn on_plane(pl: &Plane, p: Vec3) -> bool {
(p - pl.origin).dot(pl.normal()).abs() < 1e-3
}
#[test]
fn axis_through_cylinder_cone_or_torus() {
let mut s = part();
run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [65, 0, 5]}, "name": "A"}));
let (o, d) = axis(&s, "A");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(60.0, 0.0, 0.0)), "{o:?} {d:?}");
run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [12, 60, 0]}, "name": "T"}));
let (o, d) = axis(&s, "T");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(0.0, 60.0, 0.0)), "{o:?} {d:?}");
// A flat face has no axis.
assert!(s.execute("construct.axis.through_cylinder", &json!({"face": {"face": [20, 15, 20]}})).is_err());
}
#[test]
fn axis_perpendicular_to_a_face_at_a_point() {
let mut s = part();
run(&mut s, "construct.axis.normal_to_face", json!({"face": {"face": [10, 10, 20]}, "point": {"point": [10, 10, 25]}, "name": "N"}));
let (o, d) = axis(&s, "N");
assert!(near(o, Vec3::new(10.0, 10.0, 20.0)) && parallel(d, Vec3::Z), "{o:?} {d:?}");
// On the cylinder: radial through the point.
run(&mut s, "construct.axis.normal_to_face", json!({"face": {"face": [65, 0, 5]}, "point": {"point": [70, 0, 5]}, "name": "R"}));
let (o, d) = axis(&s, "R");
assert!(near(o, Vec3::new(65.0, 0.0, 5.0)) && parallel(d, Vec3::X), "{o:?} {d:?}");
}
#[test]
fn axis_through_two_planes() {
let mut s = part();
run(&mut s, "construct.axis.two_planes", json!({"a": {"plane": "XZ"}, "b": {"face": [40, 15, 10]}, "name": "L"}));
let (o, d) = axis(&s, "L");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(40.0, 0.0, 0.0)), "{o:?} {d:?}");
assert!(s.execute("construct.axis.two_planes", &json!({"a": "XY", "b": {"face": [20, 15, 20]}})).is_err(), "parallel");
}
#[test]
fn axis_through_two_points() {
let mut s = part();
run(&mut s, "construct.axis.two_points", json!({"a": {"vertex": [0.5, 0.5, 0.5]}, "b": {"vertex": [39, 29, 19]}, "name": "D"}));
let (o, d) = axis(&s, "D");
assert!(on_line(o, d, Vec3::ZERO) && parallel(d, Vec3::new(40.0, 30.0, 20.0)), "{o:?} {d:?}");
}
#[test]
fn axis_through_an_edge() {
let mut s = part();
run(&mut s, "construct.axis.edge", json!({"edge": {"edge": [20, 0, 0]}, "name": "E"}));
let (o, d) = axis(&s, "E");
assert!(parallel(d, Vec3::X) && on_line(o, d, Vec3::ZERO), "{o:?} {d:?}");
// A circular edge is not straight.
assert!(s.execute("construct.axis.edge", &json!({"edge": {"edge": [65, 0, 10]}})).is_err());
}
#[test]
fn point_at_a_vertex() {
let mut s = part();
run(&mut s, "construct.point.vertex", json!({"point": {"vertex": [39, 29, 19]}, "name": "P"}));
assert!(near(point(&s, "P"), Vec3::new(40.0, 30.0, 20.0)));
}
#[test]
fn point_through_two_edges() {
let mut s = part();
run(&mut s, "construct.point.two_edges", json!({"a": {"edge": [20, 0, 0]}, "b": {"edge": [40, 15, 0]}, "name": "P"}));
assert!(near(point(&s, "P"), Vec3::new(40.0, 0.0, 0.0)), "{:?}", point(&s, "P"));
}
#[test]
fn point_through_three_planes() {
let mut s = part();
run(
&mut s,
"construct.point.three_planes",
json!({"a": {"face": [40, 15, 10]}, "b": {"face": [20, 30, 10]}, "c": {"face": [20, 15, 20]}, "name": "P"}),
);
assert!(near(point(&s, "P"), Vec3::new(40.0, 30.0, 20.0)), "{:?}", point(&s, "P"));
}
#[test]
fn point_at_the_centre_of_a_circle_sphere_or_torus() {
let mut s = part();
run(&mut s, "construct.point.center", json!({"of": {"edge": [65, 0, 10]}, "name": "C"}));
assert!(near(point(&s, "C"), Vec3::new(60.0, 0.0, 10.0)), "{:?}", point(&s, "C"));
run(&mut s, "construct.point.center", json!({"of": {"face": [5, 0, 60]}, "name": "S"}));
assert!(near(point(&s, "S"), Vec3::new(0.0, 0.0, 60.0)), "{:?}", point(&s, "S"));
run(&mut s, "construct.point.center", json!({"of": {"face": [12, 60, 0]}, "name": "T"}));
assert!(near(point(&s, "T"), Vec3::new(0.0, 60.0, 0.0)), "{:?}", point(&s, "T"));
}
#[test]
fn point_at_edge_and_plane() {
let mut s = part();
run(
&mut s,
"construct.point.edge_plane",
json!({"edge": {"edge": [20, 0, 0]}, "plane": {"plane": {"origin": [12, 0, 0], "normal": [1, 0, 0]}}, "name": "P"}),
);
assert!(near(point(&s, "P"), Vec3::new(12.0, 0.0, 0.0)), "{:?}", point(&s, "P"));
}
#[test]
fn point_along_a_path() {
let mut s = part();
run(&mut s, "construct.point.along_path", json!({"path": {"edge": [20, 0, 0]}, "t": 0.25, "name": "P"}));
let p = point(&s, "P");
assert!(near(p, Vec3::new(10.0, 0.0, 0.0)) || near(p, Vec3::new(30.0, 0.0, 0.0)), "{p:?}");
}
#[test]
fn midplane_between_parallel_and_meeting_faces() {
let mut s = part();
run(&mut s, "construct.plane.midplane", json!({"a": "XY", "b": {"face": [20, 15, 20]}, "name": "M"}));
let m = plane(&s, "M");
assert!(parallel(m.normal(), Vec3::Z) && on_plane(&m, Vec3::new(5.0, 5.0, 10.0)), "{m:?}");
// Two sides meeting at the corner (40, 30): the diagonal plane through it.
run(&mut s, "construct.plane.midplane", json!({"a": {"face": [40, 15, 10]}, "b": {"face": [20, 30, 10]}, "name": "Q"}));
let q = plane(&s, "Q");
assert!(on_plane(&q, Vec3::new(40.0, 30.0, 0.0)) && on_plane(&q, Vec3::new(41.0, 31.0, 5.0)), "{q:?}");
}
#[test]
fn plane_through_two_edges() {
let mut s = part();
run(&mut s, "construct.plane.two_edges", json!({"a": {"edge": [20, 0, 0]}, "b": {"edge": [20, 30, 20]}, "name": "E"}));
let e = plane(&s, "E");
// The front-bottom and back-top edges: the slanted plane through both.
assert!(on_plane(&e, Vec3::new(5.0, 0.0, 0.0)) && on_plane(&e, Vec3::new(5.0, 30.0, 20.0)), "{e:?}");
assert!(s.execute("construct.plane.two_edges", &json!({"a": {"edge": [20, 0, 0]}, "b": {"edge": [40, 15, 20]}})).is_err(), "skew");
}
#[test]
fn plane_through_three_points() {
let mut s = part();
run(
&mut s,
"construct.plane.three_points",
json!({"a": {"vertex": [0, 0, 20]}, "b": {"vertex": [40, 0, 20]}, "c": {"vertex": [40, 30, 0]}, "name": "T"}),
);
let t = plane(&s, "T");
assert!(on_plane(&t, Vec3::new(0.0, 0.0, 20.0)) && on_plane(&t, Vec3::new(40.0, 30.0, 0.0)) && on_plane(&t, Vec3::new(0.0, 30.0, 0.0)), "{t:?}");
}
#[test]
fn plane_tangent_to_a_cylinder() {
let mut s = part();
run(&mut s, "construct.plane.tangent", json!({"face": {"face": [65, 0, 5]}, "name": "T"}));
let t = plane(&s, "T");
assert!(parallel(t.normal(), Vec3::X) && on_plane(&t, Vec3::new(65.0, 3.0, 9.0)), "{t:?}");
// And to the sphere, where the pick lies over it.
run(&mut s, "construct.plane.tangent", json!({"face": {"face": [0, 0, 65]}, "name": "U"}));
let u = plane(&s, "U");
assert!(parallel(u.normal(), Vec3::Z) && on_plane(&u, Vec3::new(3.0, 3.0, 65.0)), "{u:?}");
}
#[test]
fn plane_along_a_path() {
let mut s = part();
run(&mut s, "construct.plane.along_path", json!({"path": {"edge": [20, 0, 0]}, "t": 0.5, "name": "P"}));
let p = plane(&s, "P");
assert!(parallel(p.normal(), Vec3::X) && on_plane(&p, Vec3::new(20.0, 7.0, 3.0)), "{p:?}");
// A sketch can be placed on it.
run(&mut s, "sketch.create", json!({"plane": "P", "name": "OnPath"}));
}
/// Built from model geometry, they follow when the model changes: the box gets taller.
#[test]
fn construction_follows_the_model() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "h", "expression": "20 mm"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": "h", "body_name": "Box"}));
run(
&mut s,
"construct.point.three_planes",
json!({"a": {"face": [40, 15, 10]}, "b": {"face": [20, 30, 10]}, "c": {"face": [20, 15, 20]}, "name": "Corner"}),
);
run(&mut s, "construct.axis.edge", json!({"edge": {"edge": [20, 30, 20]}, "name": "TopBack"}));
run(&mut s, "construct.plane.midplane", json!({"a": "XY", "b": {"face": [20, 15, 20]}, "name": "Mid"}));
run(&mut s, "parameters.change", json!({"name": "h", "expression": "50 mm"}));
assert!(near(point(&s, "Corner"), Vec3::new(40.0, 30.0, 50.0)), "{:?}", point(&s, "Corner"));
let (o, d) = axis(&s, "TopBack");
assert!(parallel(d, Vec3::X) && on_line(o, d, Vec3::new(0.0, 30.0, 50.0)), "{o:?} {d:?}");
assert!(on_plane(&plane(&s, "Mid"), Vec3::new(1.0, 1.0, 25.0)));
// Construction from construction: an axis through two construction points.
run(&mut s, "construct.point.vertex", json!({"point": {"vertex": [0, 0, 0]}, "name": "Origin"}));
run(&mut s, "construct.axis.two_points", json!({"a": {"construct_point": "Origin"}, "b": {"construct_point": "Corner"}, "name": "Diag"}));
let (o, d) = axis(&s, "Diag");
assert!(on_line(o, d, Vec3::new(40.0, 30.0, 50.0)) && on_line(o, d, Vec3::ZERO));
}
/// In a component moved 50 mm along X, picks given in the world land on its geometry, and the
/// axis and point move with the occurrence.
#[test]
fn construction_lives_in_the_component_frame() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Part"}));
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "body_name": "Pin"}));
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [50, 0, 0]}));
run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [55, 0, 5]}, "name": "PinAxis"}));
run(&mut s, "construct.point.center", json!({"of": {"edge": [55, 0, 10]}, "name": "PinTop"}));
let (o, d) = axis(&s, "PinAxis");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(50.0, 0.0, 0.0)), "{o:?} {d:?}");
assert!(near(point(&s, "PinTop"), Vec3::new(50.0, 0.0, 10.0)), "{:?}", point(&s, "PinTop"));
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 20, 0]}));
assert!(near(point(&s, "PinTop"), Vec3::new(50.0, 20.0, 10.0)), "{:?}", point(&s, "PinTop"));
}
#[test]
fn plane_perpendicular_to_a_plane_through_a_line() {
let mut s = part();
// Through the box's front bottom edge (along X), square to the bottom: the front face's plane.
run(&mut s, "construct.plane.perpendicular", json!({"plane": {"face": [20, 15, 0]}, "line": {"edge": [20, 0, 0]}, "name": "F"}));
let f = plane(&s, "F");
assert!(parallel(f.normal(), Vec3::Y) && on_plane(&f, Vec3::new(7.0, 0.0, 13.0)), "{f:?}");
// Through the world Z axis, square to XZ: the YZ plane.
run(&mut s, "construct.plane.perpendicular", json!({"plane": "XZ", "line": {"axis": "Z"}, "name": "G"}));
let g = plane(&s, "G");
assert!(parallel(g.normal(), Vec3::X) && on_plane(&g, Vec3::new(0.0, 5.0, 9.0)), "{g:?}");
// A line square to the plane has no single answer.
assert!(s.execute("construct.plane.perpendicular", &json!({"plane": "XY", "line": {"axis": "Z"}})).is_err());
}
Loaded 100 of 375 files, more files were not shown because too many files have changed in this diff. Show more