mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 10:35:58 +00:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c90070c497 | |||
| 5ec6d3562e | |||
| 9508ee749c | |||
| a0f456d4f4 | |||
| 9a22d9df92 | |||
| b8bd43f797 | |||
| 5d5ff44db2 | |||
| 448ea769eb | |||
| 49d6682fe7 | |||
| d57e90a32a | |||
| 52d2936f94 | |||
| 365879c722 | |||
| ba0f683e89 | |||
| e5ef451214 | |||
| b314e3f175 | |||
| cf1ab075ec | |||
| 03790f5509 | |||
| be095132b9 | |||
| 8b60d77dad | |||
| 5f45f0789f | |||
| 4812ec6c0b | |||
| b5e74030c0 | |||
| 9d3cbb4acc | |||
| fd4d38d31e | |||
| 535bd5dfff | |||
| 794a394b28 | |||
| 9dd901649c | |||
| d1cdc4d061 | |||
| 62746045af | |||
| be18bd6573 | |||
| a7fe0429f0 | |||
| 72bc6fb010 | |||
| d1428ef27c | |||
| 7318b98b8d | |||
| 132acc9863 | |||
| 0e2d8efd5c | |||
| a5729f1f97 | |||
| eac7306d59 | |||
| 183301bce9 | |||
| 721c8b96a4 | |||
| 1e5356e056 | |||
| 1c23620bd9 | |||
| 2dd540397d | |||
| 7044399da1 | |||
| 7a98800699 | |||
| 88b4c5b883 | |||
| a3a1d15d02 | |||
| 61f86d073d | |||
| 307ef11c0a | |||
| ab574952b1 | |||
| 67c79ec91c | |||
| 80f5a58806 | |||
| 0e9ddef4d8 | |||
| f967b8d3ff | |||
| 6ab39679e7 | |||
| d7f9f0a516 | |||
| 1bf377c14b | |||
| ccde7a4837 | |||
| 5d69452db3 | |||
| 322a357dee | |||
| abb9d7460f | |||
| 141f3acc85 | |||
| a00153fc99 | |||
| 3a416ccb7d | |||
| 4c8f080691 | |||
| 06be3500f8 |
+174
-8
@@ -1,10 +1,18 @@
|
||||
# Every pull request must pass `cargo xtask ci`: fmt, clippy -D warnings, tests in release mode,
|
||||
# the asset and layer checks, and the wasm build check.
|
||||
# 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 }}
|
||||
@@ -14,14 +22,172 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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: Compile tests
|
||||
run: cargo test --workspace --profile ci --locked --no-run
|
||||
- name: Run tests
|
||||
run: cargo test --workspace --profile ci --locked
|
||||
|
||||
# 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
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo xtask ci
|
||||
- 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
|
||||
@@ -0,0 +1,162 @@
|
||||
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
|
||||
pages: write
|
||||
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
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
- name: Install mdbook and trunk
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
|
||||
+128
-47
@@ -1,9 +1,9 @@
|
||||
# Release pipeline, after PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
#
|
||||
# Push a tag `v<version>` (matching [workspace.package] version in Cargo.toml) and this builds and
|
||||
# packages SolveCraft for every platform and publishes the GitHub release "SolveCraft v<version>"
|
||||
# with SHA256SUMS.txt, .github/release-notes.md and GitHub's generated "What's Changed". A
|
||||
# version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
|
||||
# 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
|
||||
@@ -11,17 +11,16 @@
|
||||
# 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: Actions > Release > Run workflow (publish off) builds everything and keeps it as
|
||||
# the release-assets artifact instead of releasing.
|
||||
# A dry run: dispatch with draft=false (the default). Only the release branch may create drafts.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: "Publish the release (off: a dry run; the files become the release-assets artifact)"
|
||||
draft:
|
||||
description: "Create/update a draft (release branch only; off: build artifacts only)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
@@ -44,13 +43,19 @@ jobs:
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve version
|
||||
env:
|
||||
REQUEST_DRAFT: ${{ inputs.draft || false }}
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
if [ "$GITHUB_REF_TYPE" = tag ] && [ "${GITHUB_REF_NAME#v}" != "$VERSION" ]; then
|
||||
echo "::error::tag $GITHUB_REF_NAME doesn't match the Cargo.toml version $VERSION"
|
||||
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
|
||||
@@ -62,16 +67,16 @@ jobs:
|
||||
icons:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- 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@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: icons
|
||||
path: icons
|
||||
@@ -93,7 +98,7 @@ jobs:
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -102,7 +107,7 @@ jobs:
|
||||
"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@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
@@ -121,7 +126,7 @@ jobs:
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -143,8 +148,8 @@ jobs:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
@@ -156,12 +161,68 @@ jobs:
|
||||
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@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
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
|
||||
- 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: Build and package
|
||||
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" CRAFT_FONTS_REQUIRED=1
|
||||
export CROSS_ARCH=riscv64
|
||||
export CROSS_TARGET=riscv64gc-unknown-linux-gnu
|
||||
export DEB_ARCH=riscv64
|
||||
# We skip nfpm (deb/rpm) and AppImage for now on riscv64
|
||||
bash packaging/linux/package.sh --formats "tar"
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
@@ -172,7 +233,7 @@ jobs:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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
|
||||
@@ -196,7 +257,7 @@ jobs:
|
||||
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@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
@@ -210,16 +271,16 @@ jobs:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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@v2
|
||||
- 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@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
@@ -240,15 +301,15 @@ jobs:
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- 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@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Install WiX
|
||||
@@ -261,7 +322,7 @@ jobs:
|
||||
env:
|
||||
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -275,11 +336,11 @@ jobs:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
@@ -288,14 +349,14 @@ jobs:
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
- name: Build and zip
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: GitHub Release
|
||||
name: Draft GitHub Release or dry-run artifacts
|
||||
needs: [version, linux, flatpak, freebsd, macos, windows, web]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -306,8 +367,8 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
@@ -316,28 +377,48 @@ jobs:
|
||||
run: |
|
||||
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
|
||||
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
|
||||
- name: Publish the release
|
||||
if: github.event_name == 'push' || inputs.publish
|
||||
- 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"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); else pre=(--latest); fi
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
echo "::error::Release $tag already exists. Bump the version before tagging."
|
||||
# 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
|
||||
gh release create "$tag" --title "SolveCraft v$VERSION" --verify-tag --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
|
||||
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 "Published **SolveCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
|
||||
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.publish
|
||||
if: github.event_name == 'workflow_dispatch' && !inputs.draft
|
||||
run: cp notes.md artifacts/release-notes.md
|
||||
- if: github.event_name == 'workflow_dispatch' && !inputs.publish
|
||||
uses: actions/upload-artifact@v4
|
||||
- if: github.event_name == 'workflow_dispatch' && !inputs.draft
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-assets
|
||||
path: artifacts/*
|
||||
|
||||
@@ -71,6 +71,15 @@ 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`.
|
||||
|
||||
@@ -71,6 +71,15 @@ 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`.
|
||||
|
||||
+6
-4
@@ -18,7 +18,7 @@ build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](htt
|
||||
```sh
|
||||
git clone https://github.com/bherbruck/solvecraft && cd solvecraft
|
||||
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
|
||||
cargo test --workspace --release # unit, engine and UI-scenario tests
|
||||
cargo test --workspace --profile ci # unit, engine and UI-scenario tests
|
||||
cargo xtask ci # everything a PR must pass (see below)
|
||||
```
|
||||
|
||||
@@ -81,7 +81,7 @@ the most complete description of the house style.
|
||||
|
||||
| What | Where | Run |
|
||||
|---|---|---|
|
||||
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo test --workspace --release` |
|
||||
| Unit and engine tests | next to the code, or `*_tests.rs` files | `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) |
|
||||
|
||||
@@ -102,8 +102,10 @@ press keys, then assert on the result.
|
||||
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.
|
||||
|
||||
**Release mode matters.** CI runs the tests with `--release`. truck has debug-only assertions
|
||||
that can fail on valid geometry, so if a test fails only in debug, say so in the PR.
|
||||
**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.
|
||||
|
||||
**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
|
||||
|
||||
Generated
+14
-13
@@ -3059,7 +3059,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3068,11 +3068,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"solvecraft-ui-egui",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3081,7 +3082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3095,7 +3096,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3111,7 +3112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3119,7 +3120,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
@@ -3131,7 +3132,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3146,7 +3147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3154,7 +3155,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3163,7 +3164,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3174,7 +3175,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3188,7 +3189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4487,7 +4488,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+16
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -72,6 +72,21 @@ 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"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# SolveCraft
|
||||
|
||||
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.
|
||||
**Parametric 3D CAD; an open-source, [clean-room](CLAUDE.md) reimplementation of Autodesk
|
||||
Fusion 360, built in pure Rust.**
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! `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
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
#![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;
|
||||
@@ -35,7 +36,8 @@ const USAGE: &str = "usage:
|
||||
solvecraft-cli commands
|
||||
solvecraft-cli recipe <recipe.json>
|
||||
solvecraft-cli oracle <case-dir>... [--json]
|
||||
solvecraft-cli step-corpus <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 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
|
||||
@@ -58,6 +60,8 @@ 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") => {
|
||||
@@ -239,7 +243,7 @@ fn icon_png(size: usize) -> Result<Vec<u8>, String> {
|
||||
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) });
|
||||
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 });
|
||||
|
||||
@@ -170,9 +170,10 @@ 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.
|
||||
fn check_step_case(dir: &str) -> Value {
|
||||
/// 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 {
|
||||
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let sp = format!("{dir}/part.step");
|
||||
let sp = format!("{dir}/{}", if iges { "part.igs" } else { "part.step" });
|
||||
let want = match read_json(&format!("{dir}/measure.json")) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e}),
|
||||
@@ -191,7 +192,14 @@ fn check_step_case(dir: &str) -> 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| solvecraft_engine::kernel::step_import_shared(&String::from_utf8_lossy(&b)).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()
|
||||
}
|
||||
})
|
||||
.map(|imp| imp.bodies.iter().map(|b| b.file_faces).sum::<usize>() as f64);
|
||||
let mut checks = Vec::new();
|
||||
let mut pass = true;
|
||||
@@ -221,7 +229,8 @@ 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 results: Vec<Value> = dirs.iter().map(|d| check_step_case(d)).collect();
|
||||
let iges = args.iter().any(|a| a == "--iges");
|
||||
let results: Vec<Value> = dirs.iter().map(|d| check_step_case(d, iges)).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(()) }
|
||||
|
||||
@@ -18,3 +18,6 @@ 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"] }
|
||||
|
||||
@@ -132,6 +132,18 @@ fn main() -> eframe::Result {
|
||||
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,
|
||||
|
||||
@@ -148,7 +148,11 @@ fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
|
||||
*at = apply_point(m, *at);
|
||||
map_geo_ref(m, face);
|
||||
}
|
||||
PlaneRef::Midplane { .. } | PlaneRef::TwoEdges { .. } | PlaneRef::ThreePoints { .. } | PlaneRef::AlongPath { .. } => {
|
||||
PlaneRef::Midplane { .. }
|
||||
| PlaneRef::TwoEdges { .. }
|
||||
| PlaneRef::ThreePoints { .. }
|
||||
| PlaneRef::AlongPath { .. }
|
||||
| PlaneRef::Perpendicular { .. } => {
|
||||
for r in p.refs_mut() {
|
||||
map_geo_ref(m, r);
|
||||
}
|
||||
|
||||
@@ -513,6 +513,15 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
|
||||
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"))?;
|
||||
@@ -621,6 +630,7 @@ impl PlaneRef {
|
||||
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()],
|
||||
|
||||
@@ -54,6 +54,8 @@ pub enum PlaneRef {
|
||||
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
|
||||
@@ -771,8 +773,16 @@ pub enum FilletStyle {
|
||||
Constant,
|
||||
/// `radius` is the chord: the width across the blend.
|
||||
Chord,
|
||||
/// `radius` at the end of each edge nearest `start`, `radius2` at the other, linear between.
|
||||
Variable { radius2: String, start: Vec3 },
|
||||
/// `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 {
|
||||
@@ -1078,6 +1088,20 @@ 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, .. }
|
||||
@@ -1132,8 +1156,9 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::Fillet { radius, style, .. } => {
|
||||
v.push(radius);
|
||||
if let FilletStyle::Variable { radius2, .. } = style {
|
||||
if let FilletStyle::Variable { radius2, mid, .. } = style {
|
||||
v.push(radius2);
|
||||
v.extend(mid.iter().map(|(_, r)| r.as_str()));
|
||||
}
|
||||
}
|
||||
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
|
||||
@@ -1794,7 +1819,8 @@ impl Document {
|
||||
| PlaneRef::TwoEdges { .. }
|
||||
| PlaneRef::ThreePoints { .. }
|
||||
| PlaneRef::Tangent { .. }
|
||||
| PlaneRef::AlongPath { .. } => {
|
||||
| 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)
|
||||
}
|
||||
|
||||
+34
-6
@@ -283,6 +283,12 @@ impl ModelState {
|
||||
(key, &b.name).hash(&mut h);
|
||||
b.key = h.finish();
|
||||
b.names = crate::naming::NameCell::new(Some(origin.clone()));
|
||||
// Faces split on purpose stay split through later features (for export).
|
||||
if b.body.split_lines().is_none()
|
||||
&& let Some(lines) = before.bodies.iter().find(|x| x.name == b.name).and_then(|x| x.body.split_lines())
|
||||
{
|
||||
b.body = b.body.clone().with_split_lines(Some(lines.clone()));
|
||||
}
|
||||
// An imported look outlives the operations on the body.
|
||||
if b.body.paint().is_none()
|
||||
&& b.body.color().is_none()
|
||||
@@ -1942,11 +1948,29 @@ fn path_at(segs: &[kernel::PathSeg], d: f64) -> (Vec3, Vec3) {
|
||||
}
|
||||
|
||||
/// Apply transformed copies of the tools of `features` (patterns, mirrors).
|
||||
fn replay(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, features: &[String], mats: &[Mat]) -> Result<()> {
|
||||
fn replay(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
f: &Feature,
|
||||
st: &mut ModelState,
|
||||
features: &[String],
|
||||
mats: &[Mat],
|
||||
warning: &mut Option<String>,
|
||||
) -> Result<()> {
|
||||
if features.is_empty() {
|
||||
return Err(DocError::Invalid("no features selected".into()));
|
||||
}
|
||||
for name in features {
|
||||
// A suppressed feature has nothing to copy: its copies go with it.
|
||||
let suppressed: Vec<&String> = features.iter().filter(|n| doc.find_feature(n).is_some_and(|x| x.suppressed)).collect();
|
||||
if !suppressed.is_empty() {
|
||||
let list = suppressed.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(", ");
|
||||
*warning = Some(if suppressed.len() == features.len() {
|
||||
format!("nothing to copy: {list} suppressed")
|
||||
} else {
|
||||
format!("{list} suppressed and not copied")
|
||||
});
|
||||
}
|
||||
for name in features.iter().filter(|n| !suppressed.contains(n)) {
|
||||
// A sketch and its extrude often share a name; the feature that makes geometry wins.
|
||||
let src = doc
|
||||
.features
|
||||
@@ -2509,7 +2533,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
FeatureKind::Pattern { features, pattern, bodies } => {
|
||||
let mats = pattern_transforms(vals, st, pattern)?;
|
||||
if bodies.is_empty() {
|
||||
return replay(doc, vals, f, st, features, &mats);
|
||||
return replay(doc, vals, f, st, features, &mats, warning);
|
||||
}
|
||||
// Bodies: each copy is a new body named after its source.
|
||||
for n in bodies {
|
||||
@@ -2525,7 +2549,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
FeatureKind::Mirror { features, plane, bodies, combine } => {
|
||||
let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?;
|
||||
if bodies.is_empty() {
|
||||
return replay(doc, vals, f, st, features, &[mirror_matrix(&pl)]);
|
||||
return replay(doc, vals, f, st, features, &[mirror_matrix(&pl)], warning);
|
||||
}
|
||||
let m = mirror_matrix(&pl);
|
||||
for n in bodies {
|
||||
@@ -2596,11 +2620,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
};
|
||||
let nb = match &f.kind {
|
||||
FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => kernel::fillet_chord(&mb.body, &edges, r)?,
|
||||
FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start }, .. } => {
|
||||
FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start, mid, smooth }, .. } => {
|
||||
let r2 = val(vals, radius2, Kind::Length)?;
|
||||
let mut pts = vec![(0.0, r), (1.0, r2)];
|
||||
for (t, e) in mid {
|
||||
pts.push((*t, val(vals, e, Kind::Length)?));
|
||||
}
|
||||
let mut b = mb.body.clone();
|
||||
for p in &edges {
|
||||
b = kernel::fillet_variable(&b, *p, *start, r, r2)?;
|
||||
b = kernel::fillet_variable_points(&b, *p, *start, &pts, *smooth)?;
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
@@ -783,11 +783,28 @@ 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 };
|
||||
// The band along the chosen edge, inside the rim face.
|
||||
// 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 };
|
||||
let band = if *outside {
|
||||
Region2 { outer: Loop2::polygon(&outer).ccw(), holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()] }
|
||||
Region2 {
|
||||
outer: Loop2::polygon(&offset_left(&outer, -free)).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(&inner).ccw().reversed()] }
|
||||
Region2 {
|
||||
outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(),
|
||||
holes: vec![Loop2::polygon(&offset_left(&inner, free)).ccw().reversed()],
|
||||
}
|
||||
};
|
||||
let delta = (h * 0.05).clamp(1e-3, 0.5);
|
||||
if *groove {
|
||||
|
||||
@@ -75,9 +75,10 @@ impl FeatureKind {
|
||||
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, .. } => {
|
||||
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::Chamfer { distance, distance2, angle, .. } => {
|
||||
|
||||
@@ -441,6 +441,23 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -83,6 +83,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.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.
|
||||
@@ -249,3 +252,9 @@ fn plane_along(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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) })
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.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]"),
|
||||
.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)
|
||||
@@ -209,6 +209,37 @@ 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 {
|
||||
@@ -270,6 +301,9 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
|
||||
*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, .. } => {
|
||||
@@ -570,7 +604,24 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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`"))?;
|
||||
solvecraft_doc::FilletStyle::Variable { radius2, start }
|
||||
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)"))),
|
||||
};
|
||||
|
||||
@@ -179,6 +179,8 @@ 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 {
|
||||
|
||||
@@ -8,11 +8,12 @@ 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 normal; flip?: bool; or clear: true")];
|
||||
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",
|
||||
)];
|
||||
|
||||
fn section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.section";
|
||||
@@ -21,21 +22,34 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.revision += 1;
|
||||
return Ok(json!({"section": null}));
|
||||
}
|
||||
let (origin, normal) = match p.get("plane") {
|
||||
// A named plane's world axis: which component of a point lies along its normal.
|
||||
let (origin, normal, axis) = 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)")))?;
|
||||
(pl.origin, pl.normal())
|
||||
let normal = pl.normal();
|
||||
let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5);
|
||||
(pl.origin, normal, axis)
|
||||
}
|
||||
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)
|
||||
(origin, n, None)
|
||||
}
|
||||
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or {origin, normal}")),
|
||||
};
|
||||
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,
|
||||
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),
|
||||
};
|
||||
if !origin.is_finite() || !offset.is_finite() {
|
||||
return Err(bad(cmd, "the plane must be finite"));
|
||||
@@ -44,5 +58,18 @@ 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;
|
||||
Ok(json!({"section": {"origin": origin, "normal": normal}}))
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
+118
-35
@@ -9,6 +9,13 @@ 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)
|
||||
.at("SOLID", "CREATE")
|
||||
@@ -21,71 +28,71 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("line")
|
||||
.key("L")
|
||||
.enabled(in_sketch)
|
||||
.params("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)"),
|
||||
.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)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect")
|
||||
.key("R")
|
||||
.enabled(in_sketch)
|
||||
.params("p0, p1: opposite corners [x,y]; construction?"),
|
||||
.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)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect3")
|
||||
.enabled(in_sketch)
|
||||
.params("p0, p1: first edge; p2: point on the opposite edge"),
|
||||
.params(concat!("p0, p1: first edge; p2: point on the opposite edge", connect_doc!())),
|
||||
CommandSpec::new("sketch.rectangle.center", "Center Rectangle", rect_center)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect_center")
|
||||
.enabled(in_sketch)
|
||||
.params("center, corner"),
|
||||
.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)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle")
|
||||
.key("C")
|
||||
.enabled(in_sketch)
|
||||
.params("center: [x,y] or point ref, radius | diameter: number"),
|
||||
.params(concat!("center: [x,y] or point ref, radius | diameter: number", connect_doc!())),
|
||||
CommandSpec::new("sketch.circle.two_point", "2-Point Circle", circle_two)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle2")
|
||||
.enabled(in_sketch)
|
||||
.params("p0, p1: diameter ends"),
|
||||
.params(concat!("p0, p1: diameter ends", connect_doc!())),
|
||||
CommandSpec::new("sketch.circle.three_point", "3-Point Circle", circle_three)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle3")
|
||||
.enabled(in_sketch)
|
||||
.params("p0, p1, p2"),
|
||||
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params("start, end, through"),
|
||||
.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)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("arc_center")
|
||||
.enabled(in_sketch)
|
||||
.params("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees"),
|
||||
.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)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("polygon")
|
||||
.enabled(in_sketch)
|
||||
.params("center, radius (to vertices), sides, angle?: deg"),
|
||||
.params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!())),
|
||||
CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("polygon")
|
||||
.enabled(in_sketch)
|
||||
.params("center, radius (to edge midpoints), sides, angle?: deg"),
|
||||
.params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!())),
|
||||
CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("polygon")
|
||||
.enabled(in_sketch)
|
||||
.params("p0, p1: one edge, sides"),
|
||||
.params(concat!("p0, p1: one edge, sides", connect_doc!())),
|
||||
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("slot")
|
||||
.enabled(in_sketch)
|
||||
.params("p0, p1: arc centres, width"),
|
||||
.params(concat!("p0, p1: arc centres, width", connect_doc!())),
|
||||
CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("slot")
|
||||
.enabled(in_sketch)
|
||||
.params("p0, p1: overall ends, width"),
|
||||
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params("point: [x,y], id?"),
|
||||
.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)",
|
||||
),
|
||||
@@ -218,6 +225,47 @@ 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 {
|
||||
@@ -425,7 +473,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) = edit(s, p, cmd, false, |sk, _| {
|
||||
let ((curves, cons), info) = draw(s, p, cmd, |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 };
|
||||
@@ -526,6 +574,27 @@ 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 (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
|
||||
@@ -533,13 +602,15 @@ fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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, 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)?;
|
||||
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)?;
|
||||
mark_construction(sk, &l, p);
|
||||
Ok((ids_of(sk, &l), cons))
|
||||
cons.extend(connect_points(sk, first, p)?);
|
||||
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
Ok(rect_result(out, names, info))
|
||||
}
|
||||
|
||||
fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -549,7 +620,8 @@ fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let ((out, names), info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let first = sk.points.len();
|
||||
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
|
||||
@@ -567,9 +639,10 @@ fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
cons.push(add_c(sk, ConstraintKind::Midpoint { p: cp, l: diag })?);
|
||||
}
|
||||
mark_construction(sk, &l, p);
|
||||
Ok((ids_of(sk, &l), cons))
|
||||
cons.extend(connect_points(sk, first, p)?);
|
||||
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
Ok(rect_result(out, names, info))
|
||||
}
|
||||
|
||||
fn rect_three_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -581,7 +654,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) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |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[..] {
|
||||
@@ -609,7 +682,7 @@ fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |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);
|
||||
@@ -625,7 +698,7 @@ fn circle_two(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if r < 1e-9 {
|
||||
return Err(bad(cmd, "the points must differ"));
|
||||
}
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |sk, _| {
|
||||
let ci = sk.add_circle((a + b) * 0.5, r, None, None)?;
|
||||
Ok((ids_of(sk, &[ci]), Vec::new()))
|
||||
})?;
|
||||
@@ -650,7 +723,7 @@ fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.circle.three_point";
|
||||
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) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |sk, _| {
|
||||
let ci = sk.add_circle(o, r, None, None)?;
|
||||
Ok((ids_of(sk, &[ci]), Vec::new()))
|
||||
})?;
|
||||
@@ -661,7 +734,7 @@ fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.arc.three_point";
|
||||
let p = &super::sketch_project::snap_points(s, p, cmd)?;
|
||||
let id = str_(p, "id").map(str::to_string);
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |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"))?;
|
||||
@@ -684,7 +757,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |sk, _| {
|
||||
let c = req_parg(sk, p, "center", cmd)?;
|
||||
let a = req_parg(sk, p, "start", cmd)?;
|
||||
if let Some(sw) = sweep {
|
||||
@@ -721,7 +794,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) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |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();
|
||||
@@ -797,7 +870,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) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |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]);
|
||||
@@ -837,11 +910,12 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.point";
|
||||
let at = req_vec2(cmd, p, "point")?;
|
||||
let id = str_(p, "id").map(str::to_string);
|
||||
let (pid, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let ((pid, cons), info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let i = sk.add_point(at, id.as_deref())?;
|
||||
Ok(sk.points.get(i).map(|q| q.id.clone()).unwrap_or_default())
|
||||
let cons = connect_points(sk, i, p)?;
|
||||
Ok((sk.points.get(i).map(|q| q.id.clone()).unwrap_or_default(), cons))
|
||||
})?;
|
||||
Ok(json!({"point": pid, "sketch": info}))
|
||||
Ok(json!({"point": pid, "constraints": cons, "sketch": info}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -1049,6 +1123,15 @@ 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}}));
|
||||
}
|
||||
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"))?;
|
||||
|
||||
@@ -99,7 +99,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]}));
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10], "connect": false}));
|
||||
let l = ids(&r["curves"]);
|
||||
let g = run(&mut s, "sketch.glyphs", json!({}));
|
||||
assert_eq!(g["glyphs"].as_array().unwrap().len(), 4);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! 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));
|
||||
}
|
||||
@@ -17,7 +17,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_fillet")
|
||||
.enabled(in_sketch)
|
||||
.params("point: corner shared by two curves (\"l1.end\") | a, b: two lines or arcs meeting at a corner; radius: expr"),
|
||||
.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)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_chamfer")
|
||||
@@ -376,11 +376,13 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if oi == ci {
|
||||
continue;
|
||||
}
|
||||
let Some(other) = sk.shape(oi) else { continue };
|
||||
for q in intersections(&unbounded, &other) {
|
||||
if on_bounded(&other, q).is_none() {
|
||||
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 t = sh.param(q);
|
||||
let gap = match sh {
|
||||
Shape::Line { a, b } => {
|
||||
@@ -478,48 +480,67 @@ 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 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 (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));
|
||||
}
|
||||
};
|
||||
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"));
|
||||
}
|
||||
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))
|
||||
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 })?),
|
||||
}
|
||||
cons.extend(c);
|
||||
arcs.push(arc);
|
||||
}
|
||||
Ok((ids_of(sk, &arcs), 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 half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-9 {
|
||||
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.
|
||||
@@ -847,6 +868,18 @@ 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] });
|
||||
@@ -1149,3 +1182,11 @@ 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;
|
||||
|
||||
@@ -296,7 +296,8 @@ fn isoparametric_curves_of_a_cylinder() {
|
||||
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:?}");
|
||||
assert!(w.pts.iter().all(|p| (p.x - 6.0).abs() < 1e-3 && p.y.abs() < 1e-3));
|
||||
// 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -395,3 +396,44 @@ fn sweeps_and_pipes_follow_3d_sketch_curves() {
|
||||
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}");
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ fn rect(kind: &str) -> (Session, Vec<String>) {
|
||||
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]})),
|
||||
_ => run(&mut s, "sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15], "connect": false})),
|
||||
};
|
||||
let sides = ids(&r["curves"]);
|
||||
assert_eq!(sides.len(), 4, "{r}");
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ 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]]}));
|
||||
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]}));
|
||||
let param = d["param"].as_str().unwrap_or_default().to_string();
|
||||
let text = |s: &Session| {
|
||||
@@ -126,7 +126,7 @@ 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]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0], [30, 20]], "connect": false}));
|
||||
let free = dof(&mut s);
|
||||
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"}));
|
||||
assert_eq!(r["result"]["fixed"], true);
|
||||
@@ -333,7 +333,7 @@ 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]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 2]], "connect": false}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[5, 20], [25, 35]]}));
|
||||
s
|
||||
};
|
||||
@@ -360,7 +360,7 @@ 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}));
|
||||
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}));
|
||||
s
|
||||
};
|
||||
@@ -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}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5, "connect": false}));
|
||||
// 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);
|
||||
@@ -553,7 +553,7 @@ 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]]}));
|
||||
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}));
|
||||
let ids: Vec<String> = sketch(&s).constraints.iter().map(|c| c.id.clone()).collect();
|
||||
|
||||
@@ -257,3 +257,18 @@ fn construction_lives_in_the_component_frame() {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Random modelling sessions for robustness testing (`solvecraft-cli fuzz`, docs/robustness.md).
|
||||
//!
|
||||
//! A seeded generator builds realistic feature sequences: a base (box, extruded sketch profile
|
||||
//! with an arc, revolved section), then joins and cuts of boxes, cylinders and spheres placed on
|
||||
//! a coarse grid against the body (so coincident faces and tangencies come up often), fillets
|
||||
//! and chamfers on random edges, shells, holes and patterns. Every step is checked: the command
|
||||
//! succeeds, every body is a sound solid, and the volume moves the right way.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
use solvecraft_kernel::fuzz::Rng;
|
||||
pub use solvecraft_kernel::fuzz::{BoolCase, boolean_case, boolean_shapes};
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
/// One step: the commands it runs (a sketch and its feature go together).
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Step {
|
||||
pub kind: &'static str,
|
||||
pub commands: Vec<(String, Value)>,
|
||||
/// How the total volume must move.
|
||||
#[serde(skip)]
|
||||
expect: Expect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Expect {
|
||||
Any,
|
||||
Grow,
|
||||
Shrink,
|
||||
}
|
||||
|
||||
/// What happened at a step.
|
||||
#[derive(Clone, Debug, Serialize, PartialEq)]
|
||||
pub struct Outcome {
|
||||
/// "ok", "unsupported" (a feature the kernel doesn't do yet), "rejected" (the generator's
|
||||
/// parameters were refused), "failed", "panic" (one that escaped every guard), "invalid" (a
|
||||
/// body that isn't a sound solid), "volume".
|
||||
pub kind: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
fn ok() -> Outcome {
|
||||
Outcome { kind: "ok".into(), detail: String::new() }
|
||||
}
|
||||
fn new(kind: &str, detail: impl Into<String>) -> Outcome {
|
||||
Outcome { kind: kind.into(), detail: detail.into() }
|
||||
}
|
||||
/// A failure the kernel should not have (not a known limitation or a refused input).
|
||||
pub fn is_bug(&self) -> bool {
|
||||
matches!(self.kind.as_str(), "failed" | "panic" | "invalid" | "volume")
|
||||
}
|
||||
}
|
||||
|
||||
/// A design's run: its steps, and what happened at the last one run.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Run {
|
||||
pub seed: u64,
|
||||
pub steps: Vec<Step>,
|
||||
pub outcome: Outcome,
|
||||
/// Steps refused as not supported (kind, message): undone, not part of the design.
|
||||
pub unsupported: Vec<(String, String)>,
|
||||
/// Steps whose generated parameters a command refused.
|
||||
pub rejected: usize,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
/// The commands as a script for `solvecraft-cli run`.
|
||||
pub fn script(&self) -> Value {
|
||||
Value::Array(self.steps.iter().flat_map(|s| s.commands.iter().map(|(c, p)| json!({"command": c, "params": p}))).collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn total_volume(s: &Session) -> f64 {
|
||||
s.model.state().bodies.iter().filter_map(|b| solvecraft_kernel::measure(&b.body).ok()).map(|m| m.volume).sum()
|
||||
}
|
||||
|
||||
fn classify(e: &EngineError) -> Outcome {
|
||||
let msg = e.to_string();
|
||||
match e {
|
||||
EngineError::Internal(..) => Outcome::new("panic", msg),
|
||||
EngineError::BadParams { .. } | EngineError::Disabled(..) | EngineError::UnknownCommand(_) => Outcome::new("rejected", msg),
|
||||
// A failed boolean is a failure even when it says "not supported" (coincident faces on
|
||||
// curved bodies): those are the cases to fix. Features the kernel doesn't do yet (a
|
||||
// fillet between curved faces) are limitations.
|
||||
// Picks the generator got wrong (a centroid off its face).
|
||||
EngineError::Other(_) if msg.starts_with("invalid input") => Outcome::new("rejected", msg),
|
||||
EngineError::Other(_) if msg.contains("boolean") => Outcome::new("failed", msg),
|
||||
EngineError::Other(_) if msg.contains("not supported") || msg.contains("only ") => Outcome::new("unsupported", msg),
|
||||
EngineError::Other(_) => Outcome::new("failed", msg),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one step and check it.
|
||||
fn apply(s: &mut Session, step: &Step) -> Outcome {
|
||||
let before = total_volume(s);
|
||||
for (c, p) in &step.commands {
|
||||
if let Err(e) = s.execute(c, p) {
|
||||
return classify(&e);
|
||||
}
|
||||
}
|
||||
let st = s.model.state();
|
||||
if let Some(f) = s.model.results.iter().find(|r| r.error.is_some()) {
|
||||
return Outcome::new("failed", format!("{}: {}", f.name, f.error.clone().unwrap_or_default()));
|
||||
}
|
||||
for b in &st.bodies {
|
||||
let bad = b.body.validity();
|
||||
if !bad.is_empty() {
|
||||
return Outcome::new("invalid", format!("{}: {}", b.name, bad.join("; ")));
|
||||
}
|
||||
}
|
||||
let after = total_volume(s);
|
||||
let slack = 1e-4 * before.max(after) + 1e-6;
|
||||
let wrong = match step.expect {
|
||||
Expect::Any => false,
|
||||
Expect::Grow => after < before - slack,
|
||||
Expect::Shrink => after > before + slack,
|
||||
};
|
||||
if wrong || !after.is_finite() {
|
||||
return Outcome::new("volume", format!("{} moved the volume the wrong way: {before:.4} → {after:.4}", step.kind));
|
||||
}
|
||||
Outcome::ok()
|
||||
}
|
||||
|
||||
fn step(kind: &'static str, expect: Expect, commands: Vec<(&str, Value)>) -> Step {
|
||||
Step { kind, expect, commands: commands.into_iter().map(|(c, p)| (c.to_string(), p)).collect() }
|
||||
}
|
||||
|
||||
/// The bodies' bounds (min, max).
|
||||
fn bounds(s: &Session) -> Option<(Vec3, Vec3)> {
|
||||
let st = s.model.state();
|
||||
let mut it = st.bodies.iter().map(|b| b.mesh().bounds());
|
||||
let first = it.next()?;
|
||||
Some(it.fold((first.min, first.max), |(lo, hi), b| {
|
||||
(Vec3::new(lo.x.min(b.min.x), lo.y.min(b.min.y), lo.z.min(b.min.z)), Vec3::new(hi.x.max(b.max.x), hi.y.max(b.max.y), hi.z.max(b.max.z)))
|
||||
}))
|
||||
}
|
||||
|
||||
const G: f64 = 2.5;
|
||||
|
||||
fn snap(x: f64) -> f64 {
|
||||
(x / G).round() * G
|
||||
}
|
||||
|
||||
/// A coordinate in the bounds: often exactly on a side (coincident faces), else on the grid.
|
||||
fn coord(r: &mut Rng, lo: f64, hi: f64) -> f64 {
|
||||
match r.below(4) {
|
||||
0 => lo,
|
||||
1 => hi,
|
||||
_ => snap(lo + (hi - lo) * r.unit()),
|
||||
}
|
||||
}
|
||||
|
||||
fn base(r: &mut Rng) -> Step {
|
||||
match r.below(3) {
|
||||
0 => {
|
||||
let (l, w, h) = (r.grid(10.0, 60.0, G), r.grid(10.0, 60.0, G), r.grid(5.0, 40.0, G));
|
||||
step("box", Expect::Any, vec![("solid.box", json!({"length": l, "width": w, "height": h}))])
|
||||
}
|
||||
1 => {
|
||||
// A profile of lines closed by an arc, extruded.
|
||||
let (w, h) = (r.grid(15.0, 50.0, G), r.grid(10.0, 40.0, G));
|
||||
let bulge = r.grid(2.5, (w / 2.0).min(15.0), G);
|
||||
let notch = r.chance(0.5);
|
||||
let mut pts = vec![json!([0, 0]), json!([w, 0]), json!([w, h])];
|
||||
if notch {
|
||||
let nx = snap(w * 0.6);
|
||||
pts.push(json!([nx, h]));
|
||||
pts.push(json!([nx, snap(h * 0.5)]));
|
||||
pts.push(json!([snap(w * 0.3), snap(h * 0.5)]));
|
||||
pts.push(json!([snap(w * 0.3), h]));
|
||||
}
|
||||
pts.push(json!([0, h]));
|
||||
let d = r.grid(5.0, 40.0, G);
|
||||
step(
|
||||
"extruded profile",
|
||||
Expect::Any,
|
||||
vec![
|
||||
("sketch.create", json!({"plane": "XY"})),
|
||||
("sketch.line", json!({"points": pts})),
|
||||
("sketch.arc.three_point", json!({"start": [0, h], "end": [0, 0], "through": [-bulge, h / 2.0]})),
|
||||
("sketch.finish", json!({})),
|
||||
("Extrude", json!({"distance": d})),
|
||||
],
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
let (r0, r1, h) = (r.grid(0.0, 10.0, G), r.grid(12.5, 30.0, G), r.grid(5.0, 30.0, G));
|
||||
let angle = r.pick(&["360 deg", "180 deg", "90 deg", "270 deg"]).unwrap_or("360 deg");
|
||||
step(
|
||||
"revolved section",
|
||||
Expect::Any,
|
||||
vec![
|
||||
("sketch.create", json!({"plane": "XZ"})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [r0, 0], "p1": [r1, h]})),
|
||||
("sketch.finish", json!({})),
|
||||
("Revolve", json!({"axis": "Z", "angle": angle})),
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn random_point(r: &mut Rng, lo: Vec3, hi: Vec3) -> [f64; 3] {
|
||||
[coord(r, lo.x, hi.x), coord(r, lo.y, hi.y), coord(r, lo.z, hi.z)]
|
||||
}
|
||||
|
||||
/// The next step for the design as it stands (None: nothing fits, try another).
|
||||
fn next_step(r: &mut Rng, s: &Session, last_feature: &Option<String>) -> Option<Step> {
|
||||
let (lo, hi) = bounds(s)?;
|
||||
let st = s.model.state();
|
||||
let body = st.bodies.get(r.below(st.bodies.len()))?;
|
||||
let tol = (body.body.size() * 1e-3).max(1e-3);
|
||||
Some(match r.below(12) {
|
||||
0 | 1 => {
|
||||
let a = random_point(r, lo, hi);
|
||||
let size = [r.grid(2.5, 25.0, G), r.grid(2.5, 25.0, G), r.grid(2.5, 25.0, G)];
|
||||
let join = r.chance(0.5);
|
||||
let (kind, expect, op) = if join { ("join box", Expect::Grow, "join") } else { ("cut box", Expect::Shrink, "cut") };
|
||||
step(kind, expect, vec![("solid.box", json!({"corner": a, "length": size[0], "width": size[1], "height": size[2], "operation": op}))])
|
||||
}
|
||||
2 | 3 => {
|
||||
let base = random_point(r, lo, hi);
|
||||
let axis = r.pick(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, -1.0]])?;
|
||||
let join = r.chance(0.4);
|
||||
let (kind, expect, op) = if join { ("join cylinder", Expect::Grow, "join") } else { ("cut cylinder", Expect::Shrink, "cut") };
|
||||
let p = json!({"base": base, "axis": axis, "radius": r.grid(1.25, 10.0, 1.25), "height": r.grid(2.5, 30.0, G), "operation": op});
|
||||
step(kind, expect, vec![("solid.cylinder", p)])
|
||||
}
|
||||
4 => {
|
||||
let c = random_point(r, lo, hi);
|
||||
step("cut sphere", Expect::Shrink, vec![("solid.sphere", json!({"center": c, "radius": r.grid(2.5, 10.0, G), "operation": "cut"}))])
|
||||
}
|
||||
5 | 6 => {
|
||||
let edges = body.body.edges(tol).ok()?;
|
||||
let n = 1 + r.below(3);
|
||||
let picks: Vec<[f64; 3]> = (0..n).filter_map(|_| edges.get(r.below(edges.len()))).map(|e| [e.mid.x, e.mid.y, e.mid.z]).collect();
|
||||
let rad = r.pick(&[0.5, 1.0, 2.0, 2.5, 3.0])?;
|
||||
step("fillet", Expect::Any, vec![("solid.fillet", json!({"edges": picks, "radius": rad}))])
|
||||
}
|
||||
7 => {
|
||||
let edges = body.body.edges(tol).ok()?;
|
||||
let n = 1 + r.below(3);
|
||||
let picks: Vec<[f64; 3]> = (0..n).filter_map(|_| edges.get(r.below(edges.len()))).map(|e| [e.mid.x, e.mid.y, e.mid.z]).collect();
|
||||
step("chamfer", Expect::Shrink, vec![("solid.chamfer", json!({"edges": picks, "distance": r.pick(&[0.5, 1.0, 2.0])?}))])
|
||||
}
|
||||
8 => {
|
||||
let faces: Vec<_> = body.body.faces(tol).ok()?.into_iter().filter(|f| f.plane_normal.is_some()).collect();
|
||||
let f = faces.get(r.below(faces.len()))?;
|
||||
let p = [f.centroid.x, f.centroid.y, f.centroid.z];
|
||||
step("shell", Expect::Shrink, vec![("solid.shell", json!({"faces": [p], "thickness": r.pick(&[1.0, 1.5, 2.0])?}))])
|
||||
}
|
||||
9 => {
|
||||
let faces: Vec<_> = body.body.faces(tol).ok()?.into_iter().filter(|f| f.plane_normal.is_some()).collect();
|
||||
let f = faces.get(r.below(faces.len()))?;
|
||||
let p = [f.centroid.x, f.centroid.y, f.centroid.z];
|
||||
let mut h = json!({"position": p, "diameter": r.grid(2.0, 8.0, 1.0)});
|
||||
if r.chance(0.5) {
|
||||
h["depth"] = json!(r.grid(2.5, 20.0, G));
|
||||
if r.chance(0.5) {
|
||||
h["type"] = json!("drilled");
|
||||
}
|
||||
}
|
||||
step("hole", Expect::Shrink, vec![("solid.hole", h)])
|
||||
}
|
||||
10 => {
|
||||
let name = last_feature.clone()?;
|
||||
let dir = r.pick(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])?;
|
||||
let p = json!({"features": [name], "dir1": dir, "count1": 2 + r.below(2), "spacing1": r.grid(5.0, 20.0, G)});
|
||||
step("rectangular pattern", Expect::Any, vec![("solid.pattern.rectangular", p)])
|
||||
}
|
||||
_ => {
|
||||
let name = last_feature.clone()?;
|
||||
let p = json!({"features": [name], "axis": "Z", "count": 2 + r.below(5)});
|
||||
step("circular pattern", Expect::Any, vec![("solid.pattern.circular", p)])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate and run a design of up to `max_steps` steps, stopping at the first problem. Steps
|
||||
/// refused as unsupported (or with parameters the command rejects) are undone and counted.
|
||||
pub fn run(seed: u64, max_steps: usize) -> Run {
|
||||
run_with(seed, max_steps, &mut |_| {})
|
||||
}
|
||||
|
||||
/// [`run`], telling `on_step` about each step before it runs (to see where a hang happened).
|
||||
pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) -> Run {
|
||||
let mut r = Rng::new(seed);
|
||||
let mut s = Session::default();
|
||||
let mut run = Run { seed, steps: Vec::new(), outcome: Outcome::ok(), unsupported: Vec::new(), rejected: 0 };
|
||||
let mut last_feature: Option<String> = None;
|
||||
let n = 2 + r.below(max_steps.max(2) - 1);
|
||||
let mut tries = 0;
|
||||
while run.steps.len() < n && tries < n * 4 {
|
||||
tries += 1;
|
||||
let st = if run.steps.is_empty() { Some(base(&mut r)) } else { next_step(&mut r, &s, &last_feature) };
|
||||
let Some(st) = st else { continue };
|
||||
let before = s.doc.features.len();
|
||||
on_step(&st);
|
||||
let out = apply(&mut s, &st);
|
||||
if out.kind == "rejected" || out.kind == "unsupported" {
|
||||
// Not this one: undo what it added and try another.
|
||||
while s.doc.features.len() > before {
|
||||
s.doc_mut().features.pop();
|
||||
}
|
||||
s.refresh();
|
||||
if out.kind == "rejected" {
|
||||
run.rejected += 1;
|
||||
} else {
|
||||
run.unsupported.push((st.kind.to_string(), out.detail));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let kind = st.kind;
|
||||
run.steps.push(st);
|
||||
if out.kind != "ok" {
|
||||
run.outcome = out;
|
||||
return run;
|
||||
}
|
||||
if matches!(kind, "join box" | "cut box" | "join cylinder" | "cut cylinder" | "cut sphere" | "hole") {
|
||||
last_feature = s.doc.features.last().map(|f| f.name.clone());
|
||||
}
|
||||
}
|
||||
run
|
||||
}
|
||||
|
||||
/// Run fixed steps (a recorded or minimised design): the index and outcome of the first step
|
||||
/// that isn't ok (or the last step's, ok).
|
||||
pub fn replay(steps: &[Step]) -> (usize, Outcome) {
|
||||
let mut s = Session::default();
|
||||
for (i, st) in steps.iter().enumerate() {
|
||||
let out = apply(&mut s, st);
|
||||
if out.kind != "ok" {
|
||||
return (i, out);
|
||||
}
|
||||
}
|
||||
(steps.len().saturating_sub(1), Outcome::ok())
|
||||
}
|
||||
|
||||
/// The start of a message, to tell failures apart (numbers vary between versions of a case).
|
||||
fn gist(d: &str) -> String {
|
||||
d.chars().take_while(|c| !c.is_ascii_digit()).take(60).collect()
|
||||
}
|
||||
|
||||
/// A failing design cut down: steps are dropped while the last one still fails the same way.
|
||||
pub fn minimise(run: &Run) -> Run {
|
||||
let mut steps = run.steps.clone();
|
||||
let (kind, g) = (run.outcome.kind.clone(), gist(&run.outcome.detail));
|
||||
let same = |st: &[Step]| {
|
||||
let (i, o) = replay(st);
|
||||
i + 1 == st.len() && o.kind == kind && gist(&o.detail) == g
|
||||
};
|
||||
let mut changed = true;
|
||||
while changed && steps.len() > 1 {
|
||||
changed = false;
|
||||
let mut i = 0;
|
||||
while i + 1 < steps.len() {
|
||||
let mut t = steps.clone();
|
||||
t.remove(i);
|
||||
if same(&t) {
|
||||
steps = t;
|
||||
changed = true;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (_, outcome) = replay(&steps);
|
||||
Run { seed: run.seed, steps, outcome, unsupported: Vec::new(), rejected: 0 }
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod frames;
|
||||
pub mod fuzz;
|
||||
pub mod legacy_ids;
|
||||
pub mod licences;
|
||||
pub mod params;
|
||||
@@ -433,6 +434,10 @@ mod delete_face_tests;
|
||||
#[cfg(test)]
|
||||
mod extent_tests;
|
||||
#[cfg(test)]
|
||||
mod pattern_suppress_tests;
|
||||
#[cfg(test)]
|
||||
mod profile_refs_tests;
|
||||
#[cfg(test)]
|
||||
mod split_face_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
//! A pattern of a suppressed feature has nothing to copy (#42): its copies go too, and the
|
||||
//! pattern says so.
|
||||
|
||||
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: &Session, body: &str) -> f64 {
|
||||
s.model.state().body(body).map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppressing_a_patterned_feature_removes_its_copies() {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/acceptance/sensor_enclosure.json")).unwrap();
|
||||
s.run_script(&script).unwrap();
|
||||
let full = volume(&s, "Case");
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Standoff", "suppressed": true}));
|
||||
let without = volume(&s, "Case");
|
||||
// All four standoffs go (each 116.8 mm³), not just the seed.
|
||||
assert!((full - without - 4.0 * 116.8).abs() < 2.0, "{full} → {without}");
|
||||
let pattern = s.doc.find_feature("RectangularPattern1").unwrap().id;
|
||||
let r = s.model.result(pattern).unwrap();
|
||||
assert!(r.error.is_none() && r.warning.as_deref().is_some_and(|w| w.contains("Standoff")), "{r:?}");
|
||||
// Back on: the four standoffs return.
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Standoff", "suppressed": false}));
|
||||
assert!((volume(&s, "Case") - full).abs() < 1e-6 * full);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Profile picks survive recomputes (#30): picked by index, they are kept by the curves around
|
||||
//! them, so a face sketch whose projected outline is rebuilt still extrudes the same region.
|
||||
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The demo enclosure (80 × 60 × 30, rounded, shelled) with a USB port cut through a side, the
|
||||
/// port picked by index as the Extrude dialog does.
|
||||
fn enclosure() -> Session {
|
||||
let mut s = Session::default();
|
||||
let script = json!([
|
||||
{"command": "parameters.change", "params": {"name": "width", "expression": "80 mm", "comment": "outside, X"}},
|
||||
{"command": "parameters.change", "params": {"name": "depth", "expression": "60 mm", "comment": "outside, Y"}},
|
||||
{"command": "parameters.change", "params": {"name": "height", "expression": "30 mm"}},
|
||||
{"command": "parameters.change", "params": {"name": "wall", "expression": "2 mm"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Outline"}},
|
||||
{"command": "sketch.rectangle.center", "params": {"center": [0, 0], "corner": [40, 30]}},
|
||||
{"command": "sketch.constraint.coincident", "params": {"a": "p5", "b": "origin"}},
|
||||
{"command": "sketch.dimension", "params": {"entities": ["l1"], "value": "width"}},
|
||||
{"command": "sketch.dimension", "params": {"entities": ["l2"], "value": "depth"}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": "height", "body_name": "Enclosure"}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[40, 30, 15], [-40, 30, 15], [-40, -30, 15], [40, -30, 15]], "radius": 6}},
|
||||
{"command": "solid.shell", "params": {"faces": [[0, 0, 30]], "thickness": "wall"}},
|
||||
{"command": "sketch.create", "params": {"plane": {"face": [40, 10, 20]}, "name": "USB port"}},
|
||||
{"command": "sketch.rectangle.center", "params": {"center": [0, 12], "corner": [6, 15.5]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": "-wall", "operation": "cut", "profiles": [1]}}
|
||||
]);
|
||||
s.run_script(&script).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn bodies(s: &Session) -> Vec<(String, f64)> {
|
||||
s.model.state().bodies.iter().map(|b| (b.name.clone(), b.body.tessellate(1e-2).unwrap().measure().volume)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_port_picked_by_index_stays_a_cut_after_a_width_change() {
|
||||
let mut s = enclosure();
|
||||
let f = s.doc.features.last().unwrap();
|
||||
let solvecraft_doc::FeatureKind::Extrude { profiles, .. } = &f.kind else { panic!("{:?}", f.kind) };
|
||||
assert!(matches!(profiles, solvecraft_doc::ProfileSel::Curves { .. }), "stored by curves: {profiles:?}");
|
||||
let before = bodies(&s);
|
||||
assert_eq!(before.len(), 1, "{before:?}");
|
||||
run(&mut s, "parameters.change", json!({"name": "width", "expression": "100 mm"}));
|
||||
let after = bodies(&s);
|
||||
assert_eq!(after.len(), 1, "the port is still a cut, not a new body: {after:?}");
|
||||
// The longer box less the same port: more wall and floor, the port still taken out.
|
||||
assert!(after[0].1 > before[0].1 + 1000.0, "{before:?} {after:?}");
|
||||
let id = s.doc.features.last().unwrap().id;
|
||||
assert!(s.model.result(id).is_some_and(|r| r.error.is_none()), "{:?}", s.model.result(id));
|
||||
}
|
||||
|
||||
/// A pick the sketch no longer has is an error on the feature, never a different profile.
|
||||
#[test]
|
||||
fn a_lost_profile_is_an_error_not_another_pick() {
|
||||
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], "ids": ["a", "b", "c", "d"]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [30, 5], "radius": 3, "id": "ring"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let ring = s.model.state().sketches[0].profiles.iter().position(|p| p.outer_curves == ["ring"]).unwrap();
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "profiles": [ring], "distance": 5}));
|
||||
let n = s.model.state().bodies.len();
|
||||
assert_eq!(n, 1);
|
||||
// Delete the picked circle: the extrude fails with a message instead of taking the square.
|
||||
let sk = s.doc.find_feature("S").unwrap().id;
|
||||
let picked = match &s.doc.features.last().unwrap().kind {
|
||||
solvecraft_doc::FeatureKind::Extrude { profiles: solvecraft_doc::ProfileSel::Curves { loops }, .. } => loops[0].clone(),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
run(&mut s, "sketch.edit", json!({"sketch": sk}));
|
||||
run(&mut s, "sketch.delete", json!({"entities": picked}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(s.model.state().bodies.is_empty(), "no silent re-pick: {:?}", bodies(&s));
|
||||
let id = s.doc.features.last().unwrap().id;
|
||||
assert!(s.model.result(id).is_some_and(|r| r.error.is_some()), "{:?}", s.model.result(id));
|
||||
}
|
||||
@@ -62,3 +62,38 @@ fn a_tool_that_misses_is_refused() {
|
||||
assert!(s.execute("solid.split_face", &json!({"faces": [[10, 15, 20]], "plane": {"origin": [100, 0, 0], "normal": [1, 0, 0]}})).is_err());
|
||||
assert_eq!(s.doc.features.len(), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_keeps_split_faces_through_later_features() {
|
||||
let mut s = boxed();
|
||||
run(&mut s, "solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}}));
|
||||
// A later feature rebuilds the body; the split stays a split in the file.
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 0]], "radius": 2}));
|
||||
let (f, _) = faces_volume(&mut s, "B");
|
||||
assert_eq!(f, 8);
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-split-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let step = dir.join("split.step");
|
||||
run(&mut s, "file.export", json!({"path": step.to_string_lossy()}));
|
||||
let text = std::fs::read_to_string(&step).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// Six box faces, the top's second piece and the fillet.
|
||||
assert_eq!(text.matches("ADVANCED_FACE(").count(), 8, "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_pieces_count_and_move_apart() {
|
||||
let mut s = boxed();
|
||||
run(&mut s, "solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}}));
|
||||
// Measure counts the two halves of the top as two faces.
|
||||
let m = run(&mut s, "inspect.measure", json!({ "bodies": ["B"] }))["bodies"][0].clone();
|
||||
assert_eq!(m["faces"].as_u64(), Some(7), "{m}");
|
||||
// Offset Face on one half moves that half only (Fusion): a 20 × 30 step 5 high.
|
||||
run(&mut s, "solid.offset_face", json!({"faces": [[10, 15, 20]], "distance": 5}));
|
||||
let (_, v) = faces_volume(&mut s, "B");
|
||||
assert!((v - 27000.0).abs() < 1e-6 * 27000.0, "{v}");
|
||||
// And the other half down 3: a 20 × 30 pocket.
|
||||
run(&mut s, "solid.offset_face", json!({"faces": [[30, 15, 20]], "distance": -3}));
|
||||
let (_, v) = faces_volume(&mut s, "B");
|
||||
assert!((v - 25200.0).abs() < 1e-6 * 25200.0, "{v}");
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ fn rel(a: f64, b: f64) -> f64 {
|
||||
}
|
||||
|
||||
mod component_frames;
|
||||
mod robustness;
|
||||
|
||||
/// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole.
|
||||
#[test]
|
||||
@@ -636,6 +637,19 @@ fn section_analysis() {
|
||||
assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 0, 0]}})).is_err());
|
||||
run(&mut s, "inspect.section", json!({"clear": true}));
|
||||
assert!(s.section.is_none());
|
||||
// XZ's normal is -Y: an offset moves the cut to -Y, `at` places it at a world Y, and the
|
||||
// result says where the cut landed either way.
|
||||
let r = run(&mut s, "inspect.section", json!({"plane": "XZ", "offset": 10}));
|
||||
assert_eq!((r["section"]["axis"].as_str(), r["section"]["at"].as_f64()), (Some("Y"), Some(-10.0)), "{r}");
|
||||
for (plane, at, flip) in [("XZ", -282.5, false), ("XZ", 7.0, true), ("XY", 3.0, false), ("YZ", -4.0, true)] {
|
||||
let r = run(&mut s, "inspect.section", json!({"plane": plane, "at": at, "flip": flip}));
|
||||
assert_eq!(r["section"]["at"].as_f64(), Some(at), "{plane} {r}");
|
||||
let (o, _) = s.section.unwrap();
|
||||
assert!(((o.x + o.y + o.z) - at).abs() < 1e-12, "{plane} {o:?}");
|
||||
}
|
||||
assert!(s.execute("inspect.section", &json!({"plane": "XZ", "at": 1, "offset": 1})).is_err());
|
||||
assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 1, 0]}, "at": 1})).is_err());
|
||||
run(&mut s, "inspect.section", json!({"clear": true}));
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
@@ -1068,6 +1082,29 @@ fn chord_and_variable_fillets() {
|
||||
let removed = (1.0 - PI / 4.0) * 20.0 * (4.0 + 10.0 + 25.0) / 3.0;
|
||||
assert!(rel(v, 6000.0 - removed) < 1e-4, "{v}");
|
||||
assert!(s.execute("solid.fillet", &json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable"})).is_err());
|
||||
// A radius through a middle point (a user parameter), straight between: 2 → 4 → 2.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "B"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "waist", "expression": "4 mm"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.fillet",
|
||||
json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable", "radius2": 2, "start": [10, 0, 30], "mid": [[0.5, "waist"]]}),
|
||||
);
|
||||
let vol = |s: &Session| solvecraft_kernel::measure(&s.world_state().body("B").unwrap().body).unwrap().volume;
|
||||
let removed = 2.0 * (1.0 - PI / 4.0) * 10.0 * (4.0 + 8.0 + 16.0) / 3.0;
|
||||
assert!(rel(vol(&s), 6000.0 - removed) < 1e-4, "{}", vol(&s));
|
||||
// The middle radius follows its parameter.
|
||||
run(&mut s, "parameters.change", json!({"name": "waist", "expression": "3 mm"}));
|
||||
let removed = 2.0 * (1.0 - PI / 4.0) * 10.0 * (4.0 + 6.0 + 9.0) / 3.0;
|
||||
assert!(rel(vol(&s), 6000.0 - removed) < 1e-4, "{}", vol(&s));
|
||||
assert!(
|
||||
s.execute(
|
||||
"solid.fillet",
|
||||
&json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable", "radius2": 2, "start": [10, 0, 30], "mid": [[1.5, 3]]})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// Components export as a STEP assembly: products, occurrences, placements.
|
||||
@@ -1823,3 +1860,71 @@ fn boundary_fill_cells() {
|
||||
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap_or(0.0), 2.0 * 10.0 * 20.0 * 20.0) < 1e-9, "{m}");
|
||||
assert!(s.execute("SurfaceSculpt", &json!({"tools": ["A"], "cells": [[100, 100, 100]]})).is_err());
|
||||
}
|
||||
|
||||
/// Rectangles name their sides and corners, so they can be dimensioned without inspecting the
|
||||
/// sketch first, whichever way round the corners were given.
|
||||
#[test]
|
||||
fn rectangle_names_sides_and_corners() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [40, 30], "p1": [0, 0]}));
|
||||
let pos = |s: &Session, id: &Value| {
|
||||
let sk = s.doc.sketch(s.active_sketch.unwrap()).unwrap();
|
||||
sk.point(sk.resolve_point(id.as_str().unwrap()).unwrap()).unwrap()
|
||||
};
|
||||
for (corner, at) in [("bottom_left", [0.0, 0.0]), ("bottom_right", [40.0, 0.0]), ("top_right", [40.0, 30.0]), ("top_left", [0.0, 30.0])] {
|
||||
let p = pos(&s, &r["corners"][corner]);
|
||||
assert!((p.x - at[0]).abs() < 1e-9 && (p.y - at[1]).abs() < 1e-9, "{corner}: {p:?} in {r}");
|
||||
}
|
||||
for (side, from, to) in [
|
||||
("bottom", "bottom_left", "bottom_right"),
|
||||
("right", "bottom_right", "top_right"),
|
||||
("top", "top_right", "top_left"),
|
||||
("left", "top_left", "bottom_left"),
|
||||
] {
|
||||
let line = r["sides"][side].as_str().unwrap();
|
||||
assert!(r["curves"].as_array().unwrap().iter().any(|c| c == line), "{r}");
|
||||
assert_eq!(pos(&s, &json!(format!("{line}.start"))), pos(&s, &r["corners"][from]), "{side} in {r}");
|
||||
assert_eq!(pos(&s, &json!(format!("{line}.end"))), pos(&s, &r["corners"][to]), "{side} in {r}");
|
||||
}
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": r["corners"]["bottom_left"], "b": "origin"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [r["sides"]["bottom"]], "value": 50}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [r["sides"]["left"]], "value": 20}));
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(si["dof"], 0, "{si}");
|
||||
let p = pos(&s, &r["corners"]["top_right"]);
|
||||
assert!((p.x - 50.0).abs() < 1e-6 && (p.y - 20.0).abs() < 1e-6, "{p:?}");
|
||||
|
||||
let c = run(&mut s, "sketch.rectangle.center", json!({"center": [100, 0], "corner": [90, 5]}));
|
||||
let p = pos(&s, &c["corners"]["bottom_left"]);
|
||||
assert!((p.x - 90.0).abs() < 1e-9 && (p.y + 5.0).abs() < 1e-9, "{c}");
|
||||
assert_eq!(pos(&s, &json!(format!("{}.start", c["sides"]["top"].as_str().unwrap()))), pos(&s, &c["corners"]["top_right"]), "{c}");
|
||||
}
|
||||
|
||||
/// Points drawn exactly onto an existing point (the origin, a corner) are connected to it;
|
||||
/// `connect: false` leaves them free, and tying them again by hand is a no-op, not a conflict.
|
||||
#[test]
|
||||
fn drawing_on_existing_points_connects_to_them() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let n = |v: &Value| v["constraints"].as_array().map_or(0, Vec::len);
|
||||
// Horizontal/vertical x4, plus the corner on the origin.
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
assert_eq!(n(&r), 5, "{r}");
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [r["curves"][0]], "value": 40}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [r["curves"][1]], "value": 30}));
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(si["dof"], 0, "fully constrained without a hand-made coincident: {si}");
|
||||
let again = run(&mut s, "sketch.constraint.coincident", json!({"a": "origin", "b": "l1.start"}));
|
||||
assert_eq!(again["existing"], true, "{again}");
|
||||
|
||||
// A line from a corner, a circle on a corner and a point on the origin connect too.
|
||||
assert_eq!(n(&run(&mut s, "sketch.line", json!({"points": [[40, 30], [60, 30]]}))), 1);
|
||||
assert_eq!(n(&run(&mut s, "sketch.circle.center", json!({"center": [0, 30], "radius": 3}))), 1);
|
||||
assert_eq!(n(&run(&mut s, "sketch.point", json!({"point": [0, 0]}))), 1);
|
||||
// Close is not on it, and `connect: false` opts out.
|
||||
assert_eq!(n(&run(&mut s, "sketch.point", json!({"point": [40.001, 0]}))), 0);
|
||||
assert_eq!(n(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, -10]], "connect": false}))), 0);
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_ne!(si["status"], "conflict", "{si}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Cases found by the robustness runs (`solvecraft-cli fuzz`, docs/robustness.md), cut down to
|
||||
//! the steps that matter, and a fixed set of seeds that must keep running clean.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{run, volume};
|
||||
use crate::Session;
|
||||
use crate::fuzz;
|
||||
|
||||
/// Run a script; every command must succeed and every body be a sound solid.
|
||||
fn script(steps: Value) -> Session {
|
||||
let mut s = Session::default();
|
||||
for st in steps.as_array().into_iter().flatten() {
|
||||
let (c, p) = (st["command"].as_str().unwrap_or(""), &st["params"]);
|
||||
if let Err(e) = s.execute(c, p) {
|
||||
panic!("{c} {p}: {e}");
|
||||
}
|
||||
}
|
||||
for b in &s.model.state().bodies {
|
||||
let bad = b.body.validity();
|
||||
assert!(bad.is_empty(), "{}: {bad:?}", b.name);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Shell of a notched plate closed by a shallow arc, opening a notch wall: the arc's cylinder
|
||||
/// meets the plates at an angle, so its corners need more than one linear step to move.
|
||||
#[test]
|
||||
fn shell_with_a_shallow_arc_side() {
|
||||
script(json!([
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.line", "params": {"points": [[0, 0], [37.5, 0], [37.5, 37.5], [22.5, 37.5], [22.5, 20.0], [12.5, 20.0], [12.5, 37.5], [0, 37.5]]}},
|
||||
{"command": "sketch.arc.three_point", "params": {"end": [0, 0], "start": [0, 37.5], "through": [-5.0, 18.75]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 7.5}},
|
||||
{"command": "solid.shell", "params": {"faces": [[12.5, 28.75, 3.75]], "thickness": 2.0}}
|
||||
]));
|
||||
}
|
||||
|
||||
/// Shell of a plate with a hole whose wall meets the plate's arc side: the edge where the two
|
||||
/// cylinders meet is neither a line nor an arc, and is rebuilt through its moved points.
|
||||
#[test]
|
||||
fn shell_with_a_hole_through_a_curved_side() {
|
||||
script(json!([
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.line", "params": {"points": [[0, 0], [42.5, 0], [42.5, 20.0], [0, 20.0]]}},
|
||||
{"command": "sketch.arc.three_point", "params": {"end": [0, 0], "start": [0, 20.0], "through": [-10.0, 10.0]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 27.5}},
|
||||
{"command": "solid.cylinder", "params": {"axis": [1.0, 0.0, 0.0], "base": [-10.000000000000002, 12.5, 12.5], "height": 17.5, "operation": "cut", "radius": 5.0}},
|
||||
{"command": "solid.shell", "params": {"faces": [[42.5, 10.0, 13.75]], "thickness": 2.0}}
|
||||
]));
|
||||
}
|
||||
|
||||
/// Shell of a body in two pieces (a cylinder joined clear of a ring): each piece is shelled.
|
||||
#[test]
|
||||
fn shell_of_a_body_in_two_pieces() {
|
||||
script(json!([
|
||||
{"command": "sketch.create", "params": {"plane": "XZ"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [2.5, 0], "p1": [22.5, 17.5]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.revolve", "params": {"angle": "360 deg", "axis": "Z"}},
|
||||
{"command": "solid.cylinder", "params": {"axis": [0.0, 1.0, 0.0], "base": [-22.5, 22.5, 0.0], "height": 30.0, "operation": "join", "radius": 10.0}},
|
||||
{"command": "solid.shell", "params": {"faces": [[-22.5, 22.5, 0.0]], "thickness": 2.0}}
|
||||
]));
|
||||
}
|
||||
|
||||
/// A shell thicker than a step under a notch would turn the cavity inside out: a clear "not
|
||||
/// supported yet" error instead of a failed boolean.
|
||||
#[test]
|
||||
fn shell_thicker_than_a_step_says_so() {
|
||||
let mut s = Session::default();
|
||||
for (c, p) in [
|
||||
("solid.box", json!({"height": 7.5, "length": 30.0, "width": 35.0})),
|
||||
("solid.box", json!({"corner": [0.0, 30.0, 2.5], "height": 22.5, "length": 22.5, "operation": "cut", "width": 20.0})),
|
||||
] {
|
||||
s.execute(c, &p).unwrap();
|
||||
}
|
||||
let e = s.execute("solid.shell", &json!({"faces": [[18.75, 35.0, 2.5]], "thickness": 1.5})).unwrap_err();
|
||||
assert!(e.to_string().contains("not supported yet"), "{e}");
|
||||
}
|
||||
|
||||
/// Panics inside the kernel are caught natively, but abort the web build (wasm can't unwind).
|
||||
/// Building the samples must not panic at all (the Plastic Enclosure's bosses used to, 8 times).
|
||||
#[test]
|
||||
fn samples_build_without_panics() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let me = std::thread::current().id();
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let c2 = count.clone();
|
||||
let prev = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if std::thread::current().id() == me {
|
||||
c2.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
let _ = info;
|
||||
}));
|
||||
let mut failed = Vec::new();
|
||||
for sample in crate::sample::samples() {
|
||||
let mut s = Session::default();
|
||||
for c in (sample.script)()["commands"].as_array().into_iter().flatten() {
|
||||
let _ = s.execute(c["command"].as_str().unwrap_or(""), &c["params"]);
|
||||
}
|
||||
let n = count.swap(0, Ordering::SeqCst);
|
||||
if n > 0 {
|
||||
failed.push(format!("{}: {n} panic(s)", sample.name));
|
||||
}
|
||||
}
|
||||
std::panic::set_hook(prev);
|
||||
assert!(failed.is_empty(), "{failed:?}");
|
||||
}
|
||||
|
||||
fn total_volume(s: &Session) -> f64 {
|
||||
s.model.state().bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).map(|m| m.volume).unwrap_or(0.0)).sum()
|
||||
}
|
||||
|
||||
/// Volume of a box L×W×H with its vertical edges rounded (R) and its bottom loop filleted
|
||||
/// (ρ ≤ R): the straight runs lose (1 − π/4)ρ² each, the corners a quarter turn of that
|
||||
/// (Pappus, about the corner's axis).
|
||||
fn rounded_box(l: f64, w: f64, h: f64, r: f64, rho: f64) -> f64 {
|
||||
use std::f64::consts::PI;
|
||||
let a = (1.0 - PI / 4.0) * rho * rho;
|
||||
let xbar = rho * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI));
|
||||
l * w * h - (4.0 - PI) * r * r * h - a * (2.0 * (l - 2.0 * r) + 2.0 * (w - 2.0 * r)) - 2.0 * PI * (r - xbar) * a
|
||||
}
|
||||
|
||||
/// Box, fillets, shell: a 60×40×30 box with its vertical edges rounded r5 and its bottom loop
|
||||
/// r3, shelled 2 mm with the top open. The bottom round runs round the corners as spindle tori
|
||||
/// (the tube wider than its circle), which the offset now takes.
|
||||
#[test]
|
||||
fn shell_of_a_filleted_box() {
|
||||
let s = script(json!([
|
||||
{"command": "solid.box", "params": {"length": 60, "width": 40, "height": 30}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[0, 0, 15], [60, 0, 15], [60, 40, 15], [0, 40, 15]], "radius": 5}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[30, 0, 0], [60, 20, 0], [30, 40, 0], [0, 20, 0]], "radius": 3}},
|
||||
{"command": "solid.shell", "params": {"faces": [[30, 20, 30]], "thickness": 2}}
|
||||
]));
|
||||
let want = rounded_box(60.0, 40.0, 30.0, 5.0, 3.0) - rounded_box(56.0, 36.0, 28.0, 3.0, 1.0);
|
||||
let v = total_volume(&s);
|
||||
assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}");
|
||||
}
|
||||
|
||||
/// The first tutorial part: a 60×40×15 plate with its top front edge and front right edge
|
||||
/// rounded r2, shelled with the top open. At 2 mm the rounds' inner sides collapse to sharp
|
||||
/// edges (as in Fusion); at 1.5 mm the top's round, tangent to the open top, keeps its
|
||||
/// tangency (every face moves in, the opening is swept out through the top).
|
||||
#[test]
|
||||
fn shell_of_the_tutorial_plate() {
|
||||
let base = json!([
|
||||
{"command": "solid.box", "params": {"length": 60, "width": 40, "height": 15}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[30, 0, 15], [60, 0, 7]], "radius": 2}}
|
||||
]);
|
||||
let s0 = script(base.clone());
|
||||
let body = total_volume(&s0);
|
||||
// 2 mm: the cavity is a sharp 56 × 36 box from z = 2 up through the top.
|
||||
let mut steps = base.as_array().cloned().unwrap_or_default();
|
||||
steps.push(json!({"command": "solid.shell", "params": {"faces": [[30, 20, 15]], "thickness": 2}}));
|
||||
let v = total_volume(&script(Value::Array(steps)));
|
||||
let want = body - 56.0 * 36.0 * 13.0;
|
||||
assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}");
|
||||
// 1.5 mm: a 57 × 37 × 12 cavity with r0.5 rounds, opened by its top face (57 × 36.5)
|
||||
// swept up through the top (estimated: the rounds' mitre is left out, hence 1e-3).
|
||||
let mut steps = base.as_array().cloned().unwrap_or_default();
|
||||
steps.push(json!({"command": "solid.shell", "params": {"faces": [[30, 20, 15]], "thickness": 1.5}}));
|
||||
let v = total_volume(&script(Value::Array(steps)));
|
||||
let a = (1.0 - std::f64::consts::PI / 4.0) * 0.25;
|
||||
let want = body - (57.0 * 37.0 * 12.0 - a * (57.0 + 12.0)) - 57.0 * 36.5 * 1.5;
|
||||
assert!((v - want).abs() < 1e-3 * want, "{v} vs {want}");
|
||||
}
|
||||
|
||||
/// Shell after a drilled hole (its tip a cone): the cone moves like the other faces.
|
||||
#[test]
|
||||
fn shell_after_a_drilled_hole() {
|
||||
for open in [[30.0, 0.0, 7.5], [10.0, 10.0, 15.0]] {
|
||||
script(json!([
|
||||
{"command": "solid.box", "params": {"length": 60, "width": 40, "height": 15}},
|
||||
{"command": "solid.hole", "params": {"position": [30, 20, 15], "diameter": 5, "depth": 8, "type": "drilled"}},
|
||||
{"command": "solid.shell", "params": {"faces": [open], "thickness": 2}}
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rounding a box's edges one at a time: an edge running into an earlier round (of the same
|
||||
/// radius or another) takes off about what each round alone would (the corner where they meet
|
||||
/// is small).
|
||||
#[test]
|
||||
fn fillet_into_an_earlier_round() {
|
||||
let box_ = json!({"command": "solid.box", "params": {"length": 60, "width": 40, "height": 15}});
|
||||
for r2 in [2.0, 3.0] {
|
||||
let s = script(json!([
|
||||
box_,
|
||||
{"command": "solid.fillet", "params": {"edges": [[30, 0, 15]], "radius": 2}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[60, 0, 7]], "radius": r2}}
|
||||
]));
|
||||
let lost = |r: f64, len: f64| (1.0 - PI / 4.0) * r * r * len;
|
||||
let want = 36000.0 - lost(2.0, 60.0) - lost(r2, 15.0);
|
||||
let v = total_volume(&s);
|
||||
assert!((v - want).abs() < 2.0, "r2 {r2}: {v} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Seeds that run clean: no failure, no invalid body, no volume going the wrong way.
|
||||
#[test]
|
||||
fn fixed_seeds_run_clean() {
|
||||
for seed in FIXED_SEEDS {
|
||||
let r = fuzz::run(*seed, 8);
|
||||
assert!(!r.outcome.is_bug(), "seed {seed}: {:?}\n{}", r.outcome, r.script());
|
||||
}
|
||||
}
|
||||
|
||||
const FIXED_SEEDS: &[u64] = &[3, 5, 11, 16, 21, 29, 33, 35];
|
||||
|
||||
/// A drilled hole as deep as the plate: the drill point breaks through the bottom.
|
||||
#[test]
|
||||
fn hole_as_deep_as_the_plate() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 50, "width": 25, "height": 10}));
|
||||
let v0 = volume(&mut s);
|
||||
run(&mut s, "solid.hole", json!({"position": [25, 12.5, 10], "diameter": 6, "depth": 10, "type": "drilled"}));
|
||||
let v = volume(&mut s);
|
||||
assert!(v < v0 - PI * 9.0 * 9.0 && v > v0 - PI * 9.0 * 10.0 - 1.0, "{v0} {v}");
|
||||
// Other sizes and places, the defaults, and a tapped hole.
|
||||
for (i, p) in [
|
||||
json!({"position": [10, 10, 10], "diameter": 5, "depth": 10, "type": "drilled"}),
|
||||
json!({"position": [40, 15, 10], "diameter": 5, "depth": 10}),
|
||||
json!({"position": [12, 6, 10], "diameter": 8.5, "depth": 10}),
|
||||
json!({"position": [30, 7, 10], "thread": "M6", "depth": 10}),
|
||||
json!({"position": [20, 18, 10], "diameter": 4, "depth": 10, "tip_angle": 90}),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let before = volume(&mut s);
|
||||
let r = s.execute("solid.hole", &p);
|
||||
assert!(r.is_ok(), "{i} {p}: {r:?}");
|
||||
assert!(volume(&mut s) < before - 1.0, "{i} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An enclosure built in the usual order: shell, bosses, then the lip on the rim.
|
||||
#[test]
|
||||
fn lip_after_boss() {
|
||||
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, "plastic.boss", json!({"position": [10, 10, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}));
|
||||
run(&mut s, "plastic.boss", json!({"position": [70, 40, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}));
|
||||
let v0 = volume(&mut s);
|
||||
run(&mut s, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2}));
|
||||
let v = volume(&mut s);
|
||||
assert!(v > v0 + 1.0, "{v0} {v}");
|
||||
// Bosses up to the rim, with ribs, against a wall; lips, grooves and outside rims.
|
||||
let bosses = [
|
||||
json!({"position": [15, 15, 2], "diameter": 8, "height": 28, "hole_diameter": 3}),
|
||||
// Ribs out to the inner walls (15 − 4 − 9 = 2), and short of them.
|
||||
json!({"position": [15, 15, 2], "diameter": 8, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 9}),
|
||||
json!({"position": [15, 15, 2], "diameter": 8, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 6}),
|
||||
json!({"position": [5.5, 25, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}),
|
||||
json!({"position": [40, 25, 2], "diameter": 8, "height": 20, "draft": "1 deg", "fillet": 1}),
|
||||
];
|
||||
let lips = [
|
||||
json!({"face": [1, 25, 30], "width": 1, "height": 2}),
|
||||
json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove"}),
|
||||
json!({"face": [1, 25, 30], "width": 1, "height": 2, "side": "outside"}),
|
||||
];
|
||||
for (i, b) in bosses.iter().enumerate() {
|
||||
for (j, l) in lips.iter().enumerate() {
|
||||
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}));
|
||||
let r = s.execute("plastic.boss", b);
|
||||
assert!(r.is_ok(), "boss {i}: {r:?}");
|
||||
let v0 = volume(&mut s);
|
||||
let r = s.execute("plastic.lip", l);
|
||||
assert!(r.is_ok(), "boss {i} lip {j}: {r:?}");
|
||||
assert!((volume(&mut s) - v0).abs() > 1.0, "boss {i} lip {j}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A lip, a groove and an outside lip on the rim of a rounded, shelled enclosure (#29: the
|
||||
/// lip's walls continue the shell's walls, which a boolean join couldn't do).
|
||||
#[test]
|
||||
fn lip_on_a_rounded_shelled_rim() {
|
||||
use std::f64::consts::PI;
|
||||
let base = json!([
|
||||
{"command": "solid.box", "params": {"corner": [-40, -30, 0], "length": 80, "width": 60, "height": 30}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[40, 30, 15], [-40, 30, 15], [-40, -30, 15], [40, -30, 15]], "radius": 6}},
|
||||
{"command": "solid.shell", "params": {"faces": [[0, 0, 30]], "thickness": 2}}
|
||||
]);
|
||||
let v0 = total_volume(&script(base.clone()));
|
||||
let ar = |w: f64, h: f64, r: f64| w * h - (4.0 - PI) * r * r;
|
||||
let inside = ar(78.0, 58.0, 5.0) - ar(76.0, 56.0, 4.0);
|
||||
for (extra, want) in [
|
||||
(json!({}), v0 + inside * 1.5),
|
||||
(json!({"side": "outside"}), v0 + (ar(80.0, 60.0, 6.0) - ar(78.0, 58.0, 5.0)) * 1.5),
|
||||
// (The groove's band comes from the rim's meshed outline: within its sag.)
|
||||
(json!({"type": "groove"}), v0 - inside * 1.5),
|
||||
] {
|
||||
let mut p = json!({"face": [0, 29, 30], "width": 1, "height": 1.5});
|
||||
if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) {
|
||||
o.extend(e.clone());
|
||||
}
|
||||
let mut steps = base.as_array().cloned().unwrap_or_default();
|
||||
steps.push(json!({"command": "plastic.lip", "params": p}));
|
||||
let v = total_volume(&script(Value::Array(steps)));
|
||||
assert!((v - want).abs() < 1e-3 * want, "{extra}: {v} vs {want}");
|
||||
}
|
||||
}
|
||||
@@ -124,16 +124,28 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), ..Default::default() };
|
||||
let b = bounds(s);
|
||||
sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target);
|
||||
// Grid on XY.
|
||||
// Grid on the ground plane (XY with Z up, XZ with Y up).
|
||||
let (minor, major) = grid_step(cam.half_height());
|
||||
let ext = (cam.half_height() * 3.0 / major).ceil() * major;
|
||||
let c = Vec3::new((cam.target.x / major).round() * major, (cam.target.y / major).round() * major, 0.0);
|
||||
let tf = cam.to_frame(cam.target);
|
||||
let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0);
|
||||
let w = |p: Vec3| cam.from_frame(p);
|
||||
let n = ((ext / minor) as i64).clamp(1, 400);
|
||||
for i in -n..=n {
|
||||
let t = i as f64 * minor;
|
||||
let col = if (t / major).fract().abs() < 1e-9 { colors::GRID_MAJOR } else { colors::GRID };
|
||||
sc.lines.push(SceneLine { points: vec![c + Vec3::new(t, -ext, 0.0), c + Vec3::new(t, ext, 0.0)], color: col, width: 0.6, on_top: false });
|
||||
sc.lines.push(SceneLine { points: vec![c + Vec3::new(-ext, t, 0.0), c + Vec3::new(ext, t, 0.0)], color: col, width: 0.6, on_top: false });
|
||||
sc.lines.push(SceneLine {
|
||||
points: vec![w(c + Vec3::new(t, -ext, 0.0)), w(c + Vec3::new(t, ext, 0.0))],
|
||||
color: col,
|
||||
width: 0.6,
|
||||
on_top: false,
|
||||
});
|
||||
sc.lines.push(SceneLine {
|
||||
points: vec![w(c + Vec3::new(-ext, t, 0.0)), w(c + Vec3::new(ext, t, 0.0))],
|
||||
color: col,
|
||||
width: 0.6,
|
||||
on_top: false,
|
||||
});
|
||||
}
|
||||
let axis = cam.half_height() * 0.25;
|
||||
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
|
||||
@@ -142,7 +154,17 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
for body in &st.bodies {
|
||||
let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name));
|
||||
let mesh = body.mesh();
|
||||
sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color: if selected { colors::BODY_SELECTED } else { colors::BODY } });
|
||||
// The body's appearance (or imported colour) and faces with looks of their own, as in
|
||||
// the viewport.
|
||||
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let color =
|
||||
if selected { colors::BODY_SELECTED } else { s.doc.body_color(body).map_or(colors::BODY, |[r, g, b]| Rgb(to8(r), to8(g), to8(b))) };
|
||||
let face_colors = if selected {
|
||||
Vec::new()
|
||||
} else {
|
||||
s.doc.face_colors(body).into_iter().map(|(f, l)| (f as u32, Rgb(l.color[0], l.color[1], l.color[2]))).collect()
|
||||
};
|
||||
sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color, face_colors });
|
||||
for (i, e) in mesh.edges.iter().enumerate() {
|
||||
if mesh.seams.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
@@ -236,3 +258,39 @@ fn constructs(s: &Session) -> Vec<(u64, String, solvecraft_doc::construct::Const
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The CPU renderer (snapshots) shows appearances as the viewport does: a body's colour and
|
||||
/// faces with looks of their own.
|
||||
#[test]
|
||||
fn snapshot_scene_carries_appearances() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
s.execute("solid.cylinder", &json!({"diameter": 14, "height": 30, "base": [50, 15, 0], "operation": "new"})).unwrap();
|
||||
s.execute("appearance.assign", &json!({"bodies": ["Body2"], "color": [220, 60, 40]})).unwrap();
|
||||
s.execute("appearance.assign", &json!({"faces": [[20, 15, 20]], "body": "Body1", "color": [40, 170, 80]})).unwrap();
|
||||
let cam = home_camera(&s);
|
||||
let sc = scene(&s, &cam);
|
||||
assert!(sc.meshes.iter().any(|m| m.color == Rgb(220, 60, 40)));
|
||||
assert!(sc.meshes.iter().any(|m| m.face_colors.iter().any(|(_, c)| *c == Rgb(40, 170, 80))));
|
||||
// And the rendered picture has the green top.
|
||||
let img = solvecraft_render::render(&sc, &cam, 200, 140);
|
||||
assert!(img.rgba.chunks(4).any(|p| p[1] > 120 && p[0] < 90 && p[2] < 110), "no green pixels");
|
||||
}
|
||||
|
||||
/// Curved surfaces get a dark silhouette line in snapshots where they turn away.
|
||||
#[test]
|
||||
fn snapshots_draw_silhouettes_on_curved_surfaces() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.sphere", &json!({"diameter": 20})).unwrap();
|
||||
let cam = home_camera(&s);
|
||||
let img = solvecraft_render::render(&scene(&s, &cam), &cam, 400, 400);
|
||||
let dark = img.rgba.chunks(4).filter(|p| p[0] < 70 && p[1] < 70 && p[2] < 75).count();
|
||||
assert!(dark > 40, "silhouette pixels: {dark}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
return Err(KernelError::Invalid("too many edges".into()));
|
||||
}
|
||||
// Coplanar neighbours as one face (as after a join), so corners look as they should.
|
||||
let mut cur = Body::new(crate::heal::heal(body.deep_copy(), body.size()))?;
|
||||
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
|
||||
// Every edge of a convex planar body: rounded as a whole (sphere corners).
|
||||
if shape == Shape::Round && edges.len() >= 6 {
|
||||
let solid = cur.deep_copy();
|
||||
@@ -700,7 +700,7 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
|
||||
}
|
||||
}
|
||||
// Coplanar neighbours as one face (as after a join), so corners look as they should.
|
||||
let mut cur = Body::new(crate::heal::heal(body.deep_copy(), body.size()))?;
|
||||
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
|
||||
if let Some(groups) = loop_groups(&cur, edges)? {
|
||||
for g in groups {
|
||||
let size = cur.size();
|
||||
|
||||
@@ -33,7 +33,7 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result<Body> {
|
||||
if edges.iter().any(|(p, r)| !(p.is_finite() && r.is_finite() && *r > 1e-6 && *r < 1e6)) {
|
||||
return Err(KernelError::Invalid("fillet size must be positive".into()));
|
||||
}
|
||||
let mut cur = Body::new(crate::heal::heal(body.deep_copy(), body.size()))?;
|
||||
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
|
||||
let mut sel = edges.to_vec();
|
||||
let mut first: Option<KernelError> = None;
|
||||
// Earlier rounds at the edges' ends come off and are blended again with the new edges.
|
||||
|
||||
@@ -56,11 +56,78 @@ fn edge_frame(faces: &[&mt::Face], edge: &mt::Edge) -> Result<EdgeFrame> {
|
||||
|
||||
/// Fillet one straight edge with radius `r0` at its end nearest `start` and `r1` at the other.
|
||||
pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64) -> Result<Body> {
|
||||
fillet_variable_points(body, edge_at, start, &[(0.0, r0), (1.0, r1)], false)
|
||||
}
|
||||
|
||||
/// The radius along the edge as a B-spline of the edge parameter (0 at the edge's front):
|
||||
/// degree, knots and control radii.
|
||||
struct RadiusLaw {
|
||||
deg: usize,
|
||||
knots: Vec<f64>,
|
||||
radii: Vec<f64>,
|
||||
}
|
||||
|
||||
impl RadiusLaw {
|
||||
/// Through (position, radius) points sorted by position from 0 to 1: straight between them,
|
||||
/// or `smooth` (cubic pieces with Catmull-Rom slopes, C¹ through every point).
|
||||
fn through(pts: &[(f64, f64)], smooth: bool) -> RadiusLaw {
|
||||
if !smooth || pts.len() < 3 {
|
||||
let mut knots = vec![0.0];
|
||||
knots.extend(pts.iter().map(|p| p.0));
|
||||
knots.push(1.0);
|
||||
return RadiusLaw { deg: 1, knots, radii: pts.iter().map(|p| p.1).collect() };
|
||||
}
|
||||
let n = pts.len();
|
||||
let at = |i: usize| pts.get(i).copied().unwrap_or((0.0, 0.0));
|
||||
let slope = |i: usize| {
|
||||
let (a, b) = (at(i.saturating_sub(1)), at((i + 1).min(n - 1)));
|
||||
if b.0 > a.0 { (b.1 - a.1) / (b.0 - a.0) } else { 0.0 }
|
||||
};
|
||||
let mut knots = vec![0.0; 4];
|
||||
let mut radii = vec![at(0).1];
|
||||
for i in 0..n - 1 {
|
||||
let ((t0, r0), (t1, r1)) = (at(i), at(i + 1));
|
||||
let h = t1 - t0;
|
||||
radii.extend([r0 + slope(i) * h / 3.0, r1 - slope(i + 1) * h / 3.0, r1]);
|
||||
knots.extend(if i + 2 == n { vec![1.0; 4] } else { vec![t1; 3] });
|
||||
}
|
||||
RadiusLaw { deg: 3, knots, radii }
|
||||
}
|
||||
|
||||
/// Greville abscissae: where each control radius sits along the edge.
|
||||
fn greville(&self) -> Vec<f64> {
|
||||
(0..self.radii.len()).map(|j| self.knots.iter().skip(j + 1).take(self.deg).sum::<f64>() / self.deg as f64).collect()
|
||||
}
|
||||
|
||||
/// The radius at `t` (sampled for checks).
|
||||
fn eval(&self, t: f64) -> f64 {
|
||||
let kv = mt::KnotVec::from(self.knots.clone());
|
||||
let b = kv.try_bspline_basis_functions(self.deg, t.clamp(0.0, 1.0)).unwrap_or_default();
|
||||
b.iter().zip(&self.radii).map(|(x, r)| x * r).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fillet one straight edge with a radius that varies along it: (position, radius) points with
|
||||
/// positions 0 (the end nearest `start`) to 1 (the other end) and any between, the radius
|
||||
/// straight between them or `smooth`.
|
||||
pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &[(f64, f64)], smooth: bool) -> Result<Body> {
|
||||
body.require_brep("fillet")?;
|
||||
for r in [r0, r1] {
|
||||
if !(r.is_finite() && r > 1e-6 && r < 1e6) {
|
||||
if points.len() < 2 || points.len() > 100 {
|
||||
return Err(KernelError::Invalid("a variable radius needs 2…100 points".into()));
|
||||
}
|
||||
for (t, r) in points {
|
||||
if !(r.is_finite() && *r > 1e-6 && *r < 1e6) {
|
||||
return Err(KernelError::Invalid("fillet radii must be positive".into()));
|
||||
}
|
||||
if !(t.is_finite() && (0.0..=1.0).contains(t)) {
|
||||
return Err(KernelError::Invalid("radius positions run from 0 to 1 along the edge".into()));
|
||||
}
|
||||
}
|
||||
let mut pts = points.to_vec();
|
||||
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-9);
|
||||
if pts.first().map(|p| p.0) != Some(0.0) || pts.last().map(|p| p.0) != Some(1.0) || pts.len() < 2 {
|
||||
return Err(KernelError::Invalid("give the radius at both ends of the edge".into()));
|
||||
}
|
||||
let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?;
|
||||
let size = body.size();
|
||||
@@ -78,8 +145,17 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64
|
||||
let faces: Vec<&mt::Face> = shell.face_iter().collect();
|
||||
let fr = edge_frame(&faces, &edge)?;
|
||||
let (v0, v1) = (edge.absolute_front().clone(), edge.absolute_back().clone());
|
||||
// r0 at the end nearest `start` (the edge keeps its own direction).
|
||||
let (ra, rb) = if vtx(&v1).dist(start) < vtx(&v0).dist(start) { (r1, r0) } else { (r0, r1) };
|
||||
// Positions from the end nearest `start` (the edge keeps its own direction).
|
||||
if vtx(&v1).dist(start) < vtx(&v0).dist(start) {
|
||||
pts = pts.iter().rev().map(|(t, r)| (1.0 - t, *r)).collect();
|
||||
}
|
||||
let law = RadiusLaw::through(&pts, smooth);
|
||||
let (ra, rb) = (law.eval(0.0), law.eval(1.0));
|
||||
// The largest radius along the edge decides whether the blend fits.
|
||||
let rmax = (0..=200).map(|k| law.eval(k as f64 / 200.0)).fold(0.0, f64::max);
|
||||
if (0..=200).any(|k| law.eval(k as f64 / 200.0) <= 1e-6) {
|
||||
return Err(KernelError::Invalid("the radius must stay positive along the edge".into()));
|
||||
}
|
||||
let (p0, p1) = (vtx(&v0), vtx(&v1));
|
||||
let d = (p1 - p0).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?;
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
@@ -114,7 +190,7 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64
|
||||
}
|
||||
let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
|
||||
let along = (other - p).dot(t);
|
||||
if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= back(r) + tol {
|
||||
if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= back(r.max(rmax)) + tol {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
}
|
||||
@@ -133,8 +209,18 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64
|
||||
};
|
||||
let c0 = builder::circle_arc(&a0, &b0, p3(arc_mid(p0, s0, ra)));
|
||||
let c1 = builder::circle_arc(&a1, &b1, p3(arc_mid(p1, s1, rb)));
|
||||
let la = builder::line(&a0, &a1);
|
||||
let lb = builder::line(&b0, &b1);
|
||||
// Contact curves: the edge moved along each face by the setback of the radius there.
|
||||
let g = law.greville();
|
||||
let contact = |t: Vec3, a: &mt::Vertex, b: &mt::Vertex| -> Result<mt::Edge> {
|
||||
if law.deg == 1 && law.radii.len() == 2 {
|
||||
return Ok(builder::line(a, b));
|
||||
}
|
||||
let ctrl: Vec<mt::Point3> = g.iter().zip(&law.radii).map(|(gj, rj)| p3(p0 + (p1 - p0) * *gj + t * back(*rj))).collect();
|
||||
let c = mt::BSplineCurve::try_new(mt::KnotVec::from(law.knots.clone()), ctrl).map_err(|e| KernelError::Failed(format!("fillet: {e}")))?;
|
||||
mt::Edge::try_new(a, b, mt::Curve::BSplineCurve(c)).map_err(|e| KernelError::Failed(format!("fillet: {e}")))
|
||||
};
|
||||
let la = contact(fr.t1, &a0, &a1)?;
|
||||
let lb = contact(fr.t2, &b0, &b1)?;
|
||||
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
for (end, a, b) in [(end0, &a0, &b0), (end1, &a1, &b1)] {
|
||||
for (e, nv) in [(&end.e1, a), (&end.e2, b)] {
|
||||
@@ -166,8 +252,11 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64
|
||||
// weight sin(φ/2)) at each end, joined linearly.
|
||||
let w = (fr.phi / 2.0).sin();
|
||||
let h = |p: Vec3, wt: f64| mt::Vector4::new(p.x * wt, p.y * wt, p.z * wt, wt);
|
||||
let ctrl = vec![vec![h(pa0, 1.0), h(pa1, 1.0)], vec![h(p0, w), h(p1, w)], vec![h(pb0, 1.0), h(pb1, 1.0)]];
|
||||
let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::bezier_knot(1)), ctrl);
|
||||
let row = |t: Vec3, wt: f64, off: bool| -> Vec<mt::Vector4> {
|
||||
g.iter().zip(&law.radii).map(|(gj, rj)| h(p0 + (p1 - p0) * *gj + if off { t * back(*rj) } else { Vec3::ZERO }, wt)).collect()
|
||||
};
|
||||
let ctrl = vec![row(fr.t1, 1.0, true), row(fr.t1, w, false), row(fr.t2, 1.0, true)];
|
||||
let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::from(law.knots.clone())), ctrl);
|
||||
let mut surf = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bsp));
|
||||
// Outward: away from the arc centre on a convex edge, toward it on a concave one.
|
||||
let (c, mid) = (p0 + fr.t1 * s0 + fr.n1 * (ra * fr.side), arc_mid(p0, s0, ra));
|
||||
@@ -260,6 +349,36 @@ mod tests {
|
||||
assert!(fillet_variable(&b, Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0), 2.0, 50.0).is_err());
|
||||
}
|
||||
|
||||
/// A radius through a middle point: straight pieces (exact: each piece removes
|
||||
/// (1 − π/4) L (a² + ab + b²) / 3), and smooth (the removed volume integrates the law).
|
||||
#[test]
|
||||
fn variable_radius_through_a_middle_point() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
|
||||
let (at, start) = (Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0));
|
||||
let k = 1.0 - std::f64::consts::FRAC_PI_4;
|
||||
let pts = [(0.0, 2.0), (0.5, 4.0), (1.0, 2.0)];
|
||||
let f = fillet_variable_points(&b, at, start, &pts, false).unwrap();
|
||||
let removed = 2.0 * k * 10.0 * (4.0 + 8.0 + 16.0) / 3.0;
|
||||
let v = measure(&f).unwrap().volume;
|
||||
assert!(rel(v, 6000.0 - removed) < 1e-4, "{v} vs {}", 6000.0 - removed);
|
||||
assert_eq!(f.unmeshed_faces(), 0);
|
||||
let s = fillet_variable_points(&b, at, start, &pts, true).unwrap();
|
||||
let law = RadiusLaw::through(&pts, true);
|
||||
let n = 20000;
|
||||
let integral: f64 = (0..n).map(|i| law.eval((i as f64 + 0.5) / n as f64).powi(2)).sum::<f64>() / n as f64 * 20.0;
|
||||
assert!((law.eval(0.5) - 4.0).abs() < 1e-12 && (law.eval(0.0) - 2.0).abs() < 1e-12);
|
||||
let v = measure(&s).unwrap().volume;
|
||||
assert!(rel(v, 6000.0 - k * integral) < 1e-4, "{v} vs {}", 6000.0 - k * integral);
|
||||
// Unsorted, from the other end: the same.
|
||||
let r = fillet_variable_points(&b, at, Vec3::new(10.0, 20.0, 30.0), &[(1.0, 2.0), (0.0, 2.0), (0.5, 4.0)], true).unwrap();
|
||||
assert!(rel(measure(&r).unwrap().volume, v) < 1e-9);
|
||||
// A middle radius too big for the faces, missing ends, bad positions: refused.
|
||||
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (0.5, 40.0), (1.0, 2.0)], false).is_err());
|
||||
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (0.5, 4.0)], false).is_err());
|
||||
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (f64::NAN, 4.0), (1.0, 2.0)], false).is_err());
|
||||
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (1.0, -1.0)], false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chord_length_on_a_right_angle() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
|
||||
|
||||
@@ -19,6 +19,9 @@ pub struct Body {
|
||||
pub(crate) color: Option<[f32; 3]>,
|
||||
/// Opacity and face colours for export (set by the document; operations drop it).
|
||||
pub(crate) paint: Option<std::sync::Arc<Paint>>,
|
||||
/// Lines the user split faces along (Split Face), as polylines: export keeps the faces on
|
||||
/// either side apart where it would otherwise join pieces of one surface.
|
||||
pub(crate) splits: Option<std::sync::Arc<Vec<Vec<Vec3>>>>,
|
||||
}
|
||||
|
||||
/// How a body looks beyond its colour, for export: its opacity and its faces' own colours.
|
||||
@@ -278,6 +281,7 @@ impl Body {
|
||||
}
|
||||
&& self.color == other.color
|
||||
&& self.paint == other.paint
|
||||
&& self.splits == other.splits
|
||||
}
|
||||
|
||||
pub(crate) fn new(solid: Solid) -> Result<Body> {
|
||||
@@ -302,7 +306,7 @@ impl Body {
|
||||
if v < 0.0 {
|
||||
solid.not();
|
||||
}
|
||||
Ok(Body { solid: std::sync::Arc::new(solid), mesh: None, color: None, paint: None })
|
||||
Ok(Body { solid: std::sync::Arc::new(solid), mesh: None, color: None, paint: None, splits: None })
|
||||
}
|
||||
|
||||
/// A deep copy of the solid that can be mutated without affecting other bodies.
|
||||
@@ -335,6 +339,21 @@ impl Body {
|
||||
self
|
||||
}
|
||||
|
||||
/// Lines faces were split along on purpose (Split Face), as polylines.
|
||||
pub fn split_lines(&self) -> Option<&std::sync::Arc<Vec<Vec<Vec3>>>> {
|
||||
self.splits.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn split_keep(&self) -> &[Vec<Vec3>] {
|
||||
self.splits.as_ref().map(|l| l.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// The same body with the lines its faces were split along on purpose.
|
||||
pub fn with_split_lines(mut self, s: Option<std::sync::Arc<Vec<Vec<Vec3>>>>) -> Body {
|
||||
self.splits = s.filter(|l| !l.is_empty());
|
||||
self
|
||||
}
|
||||
|
||||
/// Opacity and face colours for export, if any.
|
||||
pub fn paint(&self) -> Option<&Paint> {
|
||||
self.paint.as_deref()
|
||||
@@ -597,6 +616,31 @@ impl Body {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
self.solid.vertex_iter().filter(|v| seen.insert(v.id())).count()
|
||||
}
|
||||
/// What is wrong with the body as a solid: shells that aren't closed and consistently
|
||||
/// oriented, faces that don't mesh, a volume that isn't positive. Empty when it is sound.
|
||||
pub fn validity(&self) -> Vec<String> {
|
||||
if let Some(m) = &self.mesh {
|
||||
return if crate::meshbody::is_closed(m) { Vec::new() } else { vec!["the mesh is not closed".into()] };
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (i, sh) in self.solid.boundaries().iter().enumerate() {
|
||||
let c = sh.shell_condition();
|
||||
if c != truck_topology::shell::ShellCondition::Closed {
|
||||
out.push(format!("shell {i} is {c:?}, not closed"));
|
||||
}
|
||||
}
|
||||
let missing = self.unmeshed_faces();
|
||||
if missing > 0 {
|
||||
out.push(format!("{missing} face(s) don't mesh"));
|
||||
}
|
||||
match crate::measure(self) {
|
||||
Ok(m) if m.volume.is_finite() && m.volume > 0.0 => {}
|
||||
Ok(m) => out.push(format!("volume {}", m.volume)),
|
||||
Err(e) => out.push(format!("no measure: {e}")),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn shell_count(&self) -> usize {
|
||||
match &self.mesh {
|
||||
Some(_) => 1,
|
||||
|
||||
+139
-1
@@ -205,6 +205,13 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
|
||||
let radial = (c - o) - axis * (c - o).dot(axis);
|
||||
let toward = axis.cross(radial).dot(plane.normal());
|
||||
let angle = if toward < 0.0 { -angle } else { angle };
|
||||
// A profile with an edge on the axis: no zero-area face where that edge turns on itself.
|
||||
if r.holes.is_empty()
|
||||
&& let Some(b) = revolve_partial_on_axis(plane, &r.outer, axis_origin, d, angle)?
|
||||
{
|
||||
out.push(b);
|
||||
continue;
|
||||
}
|
||||
let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?;
|
||||
out.push(Body::new(solid)?);
|
||||
}
|
||||
@@ -226,6 +233,9 @@ pub fn box_solid(a: Vec3, b: Vec3) -> Result<Body> {
|
||||
v.pop().ok_or_else(|| KernelError::Failed("box".into()))
|
||||
}
|
||||
|
||||
/// Where primitives start their circles (radians from the frame's x axis).
|
||||
pub(crate) const SEAM_ANGLE: f64 = 0.6131;
|
||||
|
||||
/// Cylinder with base centre `base`, axis direction `axis`, radius and height.
|
||||
pub fn cylinder(base: Vec3, axis: Vec3, radius: f64, height: f64) -> Result<Body> {
|
||||
let plane = Plane::from_normal(base, axis).ok_or_else(|| KernelError::Invalid("cylinder axis".into()))?;
|
||||
@@ -233,7 +243,9 @@ pub fn cylinder(base: Vec3, axis: Vec3, radius: f64, height: f64) -> Result<Body
|
||||
if radius <= 1e-6 {
|
||||
return Err(KernelError::Invalid("radius must be positive".into()));
|
||||
}
|
||||
let r = Region2 { outer: Loop2::circle(Vec2::ZERO, radius), holes: vec![] };
|
||||
// The seams at an odd angle: on the frame's axes they would lie exactly where faces placed
|
||||
// on a grid cut the cylinder (along a seam edge, which booleans don't survive).
|
||||
let r = Region2 { outer: Loop2::circle_from(Vec2::ZERO, radius, SEAM_ANGLE), holes: vec![] };
|
||||
let mut v = extrude(&plane, &[r], 0.0, finite(height, "height")?)?;
|
||||
v.pop().ok_or_else(|| KernelError::Failed("cylinder".into()))
|
||||
}
|
||||
@@ -367,6 +379,132 @@ fn half_disc(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Option<(Vec2, f64)> {
|
||||
(same && (total - std::f64::consts::PI).abs() < 1e-6).then_some((c, r))
|
||||
}
|
||||
|
||||
/// A partial revolve of a loop with one edge on the axis: the other edges sweep their faces,
|
||||
/// the two caps (the profile at the start and at the end) share the axis edge. `None` when the
|
||||
/// loop isn't like that.
|
||||
fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2, angle: f64) -> Result<Option<Body>> {
|
||||
use mt::{BoundedCurve, Invertible, ParametricCurve, ParametricSurface3D, SearchNearestParameter, Transformed};
|
||||
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
|
||||
let n = lp.segs.len();
|
||||
let axis_segs: Vec<usize> =
|
||||
(0..n).filter(|i| lp.segs.get(*i).is_some_and(|s| matches!(s, Seg2::Line { a, b } if on_axis(*a) && on_axis(*b)))).collect();
|
||||
let [k] = axis_segs[..] else { return Ok(None) };
|
||||
// The open chain after the axis segment, back around to it: only its ends on the axis.
|
||||
let chain: Vec<Seg2> = (1..n).filter_map(|j| lp.segs.get((k + j) % n).copied()).collect();
|
||||
if chain.is_empty() || chain.iter().skip(1).any(|s| on_axis(s.start())) || chain.iter().any(|s| on_axis(s.mid())) {
|
||||
return Ok(None);
|
||||
}
|
||||
// Built with the axis through the world origin, then moved into place.
|
||||
let shift = plane.to_world(axis_origin);
|
||||
let local = Plane { origin: plane.origin - shift, ..*plane };
|
||||
let plane = &local;
|
||||
let axis = plane.dir_to_world(d);
|
||||
let rot = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle));
|
||||
let half = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle * 0.5));
|
||||
let turn = |m: &mt::Matrix4, p: Vec3| from_p3(mt::EuclideanSpace::from_vec((m * p3(p).to_homogeneous()).truncate()));
|
||||
guard("revolve", || {
|
||||
let fail = |m: &str| KernelError::Failed(format!("revolve: {m}"));
|
||||
let pts: Vec<Vec3> = chain.iter().map(|s| plane.to_world(s.start())).chain(chain.last().map(|s| plane.to_world(s.end()))).collect();
|
||||
let last = pts.len() - 1;
|
||||
let pv: Vec<mt::Vertex> = pts.iter().map(|p| builder::vertex(p3(*p))).collect();
|
||||
// The ends stay where they are (on the axis).
|
||||
let qv: Vec<mt::Vertex> = pts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
if i == 0 || i == last { pv.get(i).cloned().unwrap_or_else(|| builder::vertex(p3(*p))) } else { builder::vertex(p3(turn(&rot, *p))) }
|
||||
})
|
||||
.collect();
|
||||
let mut e: Vec<mt::Edge> = Vec::new();
|
||||
let mut er: Vec<mt::Edge> = Vec::new();
|
||||
for (i, sg) in chain.iter().enumerate() {
|
||||
let (Some(a), Some(b), Some(qa), Some(qb)) = (pv.get(i), pv.get(i + 1), qv.get(i), qv.get(i + 1)) else { return Err(fail("chain")) };
|
||||
let edge = match *sg {
|
||||
Seg2::Line { .. } => builder::line(a, b),
|
||||
Seg2::Arc { .. } => builder::circle_arc(a, b, p3(plane.to_world(sg.mid()))),
|
||||
Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => {
|
||||
crate::freeform::edge(plane, sg, a, b).unwrap_or_else(|| builder::line(a, b))
|
||||
}
|
||||
};
|
||||
let curve = edge.oriented_curve().transformed(rot);
|
||||
er.push(mt::Edge::new(qa, qb, curve));
|
||||
e.push(edge);
|
||||
}
|
||||
// Arcs swept by the vertices off the axis.
|
||||
let mut arcs: Vec<Option<mt::Edge>> = vec![None; pts.len()];
|
||||
for i in 1..last {
|
||||
let (Some(a), Some(b), Some(p)) = (pv.get(i), qv.get(i), pts.get(i)) else { continue };
|
||||
if let Some(slot) = arcs.get_mut(i) {
|
||||
*slot = Some(builder::circle_arc(a, b, p3(turn(&half, *p))));
|
||||
}
|
||||
}
|
||||
// Side faces: each edge's swept surface, oriented to agree with its boundary.
|
||||
let mut faces: Vec<mt::Face> = Vec::new();
|
||||
for (i, sg) in chain.iter().enumerate() {
|
||||
let (Some(ei), Some(eri)) = (e.get(i), er.get(i)) else { return Err(fail("edges")) };
|
||||
let mut ws: Vec<mt::Edge> = vec![ei.clone()];
|
||||
if let Some(Some(c)) = arcs.get(i + 1) {
|
||||
ws.push(c.clone());
|
||||
}
|
||||
ws.push(eri.inverse());
|
||||
if let Some(Some(c)) = arcs.get(i) {
|
||||
ws.push(c.inverse());
|
||||
}
|
||||
let wire: mt::Wire = ws.into();
|
||||
let radial = matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9));
|
||||
let f = if radial {
|
||||
builder::try_attach_plane(&[wire]).map_err(|er| fail(&er.to_string()))?
|
||||
} else {
|
||||
let template = builder::rsweep(ei, mt::Point3::new(0.0, 0.0, 0.0), v3(axis), mt::Rad(angle));
|
||||
let mut surface = template.face_iter().next().map(|f| f.oriented_surface()).ok_or_else(|| fail("swept face"))?;
|
||||
// The loop's turning (Newell) against the surface normal halfway round.
|
||||
let mut nw = Vec3::ZERO;
|
||||
let poly: Vec<Vec3> = wire
|
||||
.edge_iter()
|
||||
.flat_map(|ed| {
|
||||
let c = ed.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
(0..8).map(move |j| from_p3(c.subs(t0 + (t1 - t0) * j as f64 / 8.0))).collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
for (j, p) in poly.iter().enumerate() {
|
||||
if let Some(q) = poly.get((j + 1) % poly.len()) {
|
||||
nw += p.cross(*q);
|
||||
}
|
||||
}
|
||||
let probe = turn(&half, plane.to_world(sg.mid()));
|
||||
let sn = surface.search_nearest_parameter(p3(probe), None, 100).map(|(u, v)| surface.normal(u, v));
|
||||
if sn.is_some_and(|m| nw.dot(Vec3::new(m.x, m.y, m.z)) < 0.0) {
|
||||
surface = surface.inverse();
|
||||
}
|
||||
mt::Face::try_new(vec![wire], surface).map_err(|er| fail(&er.to_string()))?
|
||||
};
|
||||
faces.push(f);
|
||||
}
|
||||
// Caps: the profile at the start and at the end, sharing the axis edge.
|
||||
let (Some(a0), Some(an)) = (pv.first(), pv.get(last)) else { return Err(fail("ends")) };
|
||||
let axis_edge = builder::line(an, a0);
|
||||
let start: mt::Wire = e.iter().rev().map(|x| x.inverse()).chain(std::iter::once(axis_edge.inverse())).collect::<Vec<_>>().into();
|
||||
let end: mt::Wire = er.iter().cloned().chain(std::iter::once(axis_edge.clone())).collect::<Vec<_>>().into();
|
||||
let cs = builder::try_attach_plane(&[start]).map_err(|er| fail(&er.to_string()))?;
|
||||
let ce = builder::try_attach_plane(&[end]).map_err(|er| fail(&er.to_string()))?;
|
||||
let mut last_err = String::new();
|
||||
for flip in 0..4 {
|
||||
let mut all = faces.clone();
|
||||
all.push(if flip & 1 == 0 { cs.clone() } else { cs.inverse() });
|
||||
all.push(if flip & 2 == 0 { ce.clone() } else { ce.inverse() });
|
||||
match Solid::try_new(vec![all.into()]) {
|
||||
Ok(solid) => {
|
||||
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
|
||||
return Body::new(solid).map(Some);
|
||||
}
|
||||
Err(er) => last_err = er.to_string(),
|
||||
}
|
||||
}
|
||||
Err(fail(&last_err))
|
||||
})
|
||||
}
|
||||
|
||||
fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Result<Option<Body>> {
|
||||
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
|
||||
let n = lp.segs.len();
|
||||
|
||||
@@ -42,8 +42,11 @@ fn planes(b: &Body) -> Vec<(usize, PlaneFace)> {
|
||||
|
||||
/// Push the planar faces of `b` lying in the plane (n, d) — n their outward normal — along n
|
||||
/// by `delta` (negative: inward). Their neighbours must stand perpendicular to the plane.
|
||||
fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
|
||||
match push_faces_exact(b, n, d, delta) {
|
||||
pub(crate) fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
|
||||
// The exact rebuild, unless it came out unsound (a curved side it couldn't re-sweep).
|
||||
match push_faces_exact(b, n, d, delta)
|
||||
.and_then(|r| if r.validity().is_empty() { Ok(r) } else { Err(KernelError::Failed("pushing a face: an unsound result".into())) })
|
||||
{
|
||||
Ok(r) => Ok(r),
|
||||
// Faces of planes and cylinders: every face follows (any neighbours).
|
||||
Err(e) => {
|
||||
@@ -66,7 +69,7 @@ fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
|
||||
}
|
||||
}
|
||||
|
||||
fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
|
||||
pub(crate) fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
|
||||
let size = b.size();
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
let fail = |m: &str| KernelError::Failed(format!("pushing a face: {m}"));
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Robustness checks on random inputs (`solvecraft-cli fuzz`, docs/robustness.md): a small
|
||||
//! seeded random generator, and the boolean volume identity on random pairs of primitives
|
||||
//! placed on a coarse grid, so coincident faces and tangencies come up often.
|
||||
|
||||
use serde::Serialize;
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::{Body, BoolOp, boolean, box_solid, cylinder, measure, sphere, torus};
|
||||
|
||||
/// xorshift64*: small, fast, and the same everywhere (wasm included).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
pub fn new(seed: u64) -> Rng {
|
||||
// Spread nearby seeds apart (splitmix64), never zero.
|
||||
let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
Rng((z ^ (z >> 31)) | 1)
|
||||
}
|
||||
pub fn next_u64(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
/// 0…n−1 (0 when n is 0).
|
||||
pub fn below(&mut self, n: usize) -> usize {
|
||||
if n == 0 { 0 } else { (self.next_u64() % n as u64) as usize }
|
||||
}
|
||||
/// In [0, 1).
|
||||
pub fn unit(&mut self) -> f64 {
|
||||
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
pub fn chance(&mut self, p: f64) -> bool {
|
||||
self.unit() < p
|
||||
}
|
||||
pub fn pick<T: Copy>(&mut self, xs: &[T]) -> Option<T> {
|
||||
xs.get(self.below(xs.len())).copied()
|
||||
}
|
||||
/// A multiple of `step` in [lo, hi].
|
||||
pub fn grid(&mut self, lo: f64, hi: f64, step: f64) -> f64 {
|
||||
let n = ((hi - lo) / step).floor().max(0.0) as usize;
|
||||
lo + step * self.below(n + 1) as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// A primitive used by the boolean checks, as plain data (so a failure can be written down).
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub enum Shape {
|
||||
Box { a: [f64; 3], b: [f64; 3] },
|
||||
Cylinder { base: [f64; 3], axis: [f64; 3], radius: f64, height: f64 },
|
||||
Sphere { center: [f64; 3], radius: f64 },
|
||||
Torus { center: [f64; 3], major: f64, minor: f64 },
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
pub fn body(&self) -> crate::Result<Body> {
|
||||
let v = |p: &[f64; 3]| Vec3::new(p[0], p[1], p[2]);
|
||||
match self {
|
||||
Shape::Box { a, b } => box_solid(v(a), v(b)),
|
||||
Shape::Cylinder { base, axis, radius, height } => cylinder(v(base), v(axis), *radius, *height),
|
||||
Shape::Sphere { center, radius } => sphere(v(center), *radius),
|
||||
Shape::Torus { center, major, minor } => torus(v(center), *major, *minor),
|
||||
}
|
||||
}
|
||||
|
||||
fn random(r: &mut Rng) -> Shape {
|
||||
let g = 2.5;
|
||||
let mut p = || [r.grid(0.0, 30.0, g), r.grid(0.0, 30.0, g), r.grid(0.0, 30.0, g)];
|
||||
let at = p();
|
||||
match r.below(10) {
|
||||
0..=4 => {
|
||||
let size = [r.grid(5.0, 30.0, g), r.grid(5.0, 30.0, g), r.grid(5.0, 30.0, g)];
|
||||
Shape::Box { a: at, b: [at[0] + size[0], at[1] + size[1], at[2] + size[2]] }
|
||||
}
|
||||
5..=7 => {
|
||||
let axis = r.pick(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]).unwrap_or([0.0, 0.0, 1.0]);
|
||||
Shape::Cylinder { base: at, axis, radius: r.grid(2.5, 10.0, g), height: r.grid(5.0, 30.0, g) }
|
||||
}
|
||||
8 => Shape::Sphere { center: at, radius: r.grid(2.5, 12.5, g) },
|
||||
_ => {
|
||||
let minor = r.grid(2.5, 5.0, g);
|
||||
Shape::Torus { center: at, major: minor + r.grid(2.5, 10.0, g), minor }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One boolean check: the shapes, what went wrong (none when the identity holds).
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct BoolCase {
|
||||
pub seed: u64,
|
||||
pub a: Shape,
|
||||
pub b: Shape,
|
||||
/// "ok", or the kind of failure: "error", "invalid", "identity".
|
||||
pub outcome: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
fn vol(b: &Option<Body>) -> crate::Result<f64> {
|
||||
match b {
|
||||
Some(b) => Ok(measure(b)?.volume),
|
||||
None => Ok(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two primitives [`boolean_case`] uses for a seed.
|
||||
pub fn boolean_shapes(seed: u64) -> (Shape, Shape) {
|
||||
let mut r = Rng::new(seed);
|
||||
(Shape::random(&mut r), Shape::random(&mut r))
|
||||
}
|
||||
|
||||
/// Union, intersection and cut of two random primitives: each result is a sound solid, and
|
||||
/// V(A∪B) + V(A∩B) = V(A) + V(B) and V(A−B) = V(A) − V(A∩B), within 0.2 %.
|
||||
pub fn boolean_case(seed: u64) -> BoolCase {
|
||||
let (a, b) = boolean_shapes(seed);
|
||||
let mut case = BoolCase { seed, a: a.clone(), b: b.clone(), outcome: "ok".into(), detail: String::new() };
|
||||
let fail = |c: &mut BoolCase, kind: &str, d: String| {
|
||||
c.outcome = kind.into();
|
||||
c.detail = d;
|
||||
};
|
||||
let (Ok(ba), Ok(bb)) = (a.body(), b.body()) else {
|
||||
fail(&mut case, "error", "a primitive could not be built".into());
|
||||
return case;
|
||||
};
|
||||
let mut res = Vec::new();
|
||||
for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] {
|
||||
match boolean(&ba, &bb, op) {
|
||||
Ok(x) => {
|
||||
if let Some(body) = &x {
|
||||
let bad = body.validity();
|
||||
if !bad.is_empty() {
|
||||
fail(&mut case, "invalid", format!("{op:?}: {}", bad.join("; ")));
|
||||
return case;
|
||||
}
|
||||
}
|
||||
res.push(x);
|
||||
}
|
||||
Err(e) => {
|
||||
fail(&mut case, "error", format!("{op:?}: {e}"));
|
||||
return case;
|
||||
}
|
||||
}
|
||||
}
|
||||
let v = |b: &Option<Body>| vol(b).unwrap_or(f64::NAN);
|
||||
let (va, vb) = (v(&Some(ba)), v(&Some(bb)));
|
||||
let [u, i, c] = &res[..] else { return case };
|
||||
let (vu, vi, vc) = (v(u), v(i), v(c));
|
||||
let tol = 2e-3 * (va + vb);
|
||||
let e1 = (vu + vi - va - vb).abs();
|
||||
let e2 = (vc - (va - vi)).abs();
|
||||
if !(e1 <= tol && e2 <= tol) {
|
||||
fail(&mut case, "identity", format!("A {va:.3}, B {vb:.3}, A∪B {vu:.3}, A∩B {vi:.3}, A−B {vc:.3}: off by {e1:.3} and {e2:.3}"));
|
||||
}
|
||||
case
|
||||
}
|
||||
@@ -79,7 +79,26 @@ fn inside(p: Vec3, edges: &[mt::Edge], n: Vec3) -> bool {
|
||||
|
||||
/// Merge each group of edge-connected coplanar faces into one face. Returns None when nothing
|
||||
/// changed or the result would not be a valid solid.
|
||||
fn merge_coplanar(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
|
||||
/// Do the points lie along one of the lines faces were split along on purpose (polylines;
|
||||
/// chords of a curved line sag by under a tenth of their length)?
|
||||
pub(crate) fn on_split_line(pts: &[Vec3], keep: &[Vec<Vec3>]) -> bool {
|
||||
!pts.is_empty()
|
||||
&& keep.iter().any(|line| {
|
||||
let seg = line.windows(2).filter_map(|w| Some(w.first()?.dist(*w.get(1)?))).fold(0.0, f64::max);
|
||||
let tol = 1e-6 * (1.0 + seg) + if line.len() > 2 { seg * 0.1 } else { 0.0 };
|
||||
pts.iter().all(|q| line.windows(2).any(|w| w.first().zip(w.get(1)).is_some_and(|(a, b)| q.dist_to_segment(*a, *b) <= tol)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Points along an edge (ends and three inside).
|
||||
pub(crate) fn edge_points(e: &mt::Edge) -> Vec<Vec3> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
(0..=4).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 4.0))).collect()
|
||||
}
|
||||
|
||||
fn merge_coplanar(shell: &mt::Shell, tol: f64, keep: &[Vec<Vec3>]) -> Option<mt::Shell> {
|
||||
let faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
|
||||
let planes: Vec<Option<(Vec3, f64)>> = faces.iter().map(plane_of).collect();
|
||||
let mut parent: Vec<usize> = (0..faces.len()).collect();
|
||||
@@ -90,9 +109,11 @@ fn merge_coplanar(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
|
||||
}
|
||||
}
|
||||
let mut merged_any = false;
|
||||
for fs in by_edge.values() {
|
||||
let edges: HashMap<mt::EdgeID, mt::Edge> = shell.edge_iter().map(|e| (e.id(), e)).collect();
|
||||
for (id, fs) in &by_edge {
|
||||
if let [a, b] = fs[..]
|
||||
&& a != b
|
||||
&& (keep.is_empty() || !edges.get(id).is_some_and(|e| on_split_line(&edge_points(e), keep)))
|
||||
&& let (Some(Some((na, da))), Some(Some((nb, db)))) = (planes.get(a), planes.get(b))
|
||||
&& na.dot(*nb) > 1.0 - 1e-9
|
||||
&& (da - db).abs() < tol
|
||||
@@ -378,6 +399,11 @@ fn planar_surfaces(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
|
||||
|
||||
/// Heal a solid; returns the input unchanged when nothing applies or healing fails.
|
||||
pub fn heal(solid: Solid, size: f64) -> Solid {
|
||||
heal_keep(solid, size, &[])
|
||||
}
|
||||
|
||||
/// [`heal`], keeping faces apart along lines they were split along on purpose.
|
||||
pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> Solid {
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
let mut shells = Vec::new();
|
||||
let mut changed = false;
|
||||
@@ -391,7 +417,7 @@ pub fn heal(solid: Solid, size: f64) -> Solid {
|
||||
cur = m;
|
||||
changed = true;
|
||||
}
|
||||
if let Some(m) = merge_coplanar(&cur, tol) {
|
||||
if let Some(m) = merge_coplanar(&cur, tol, keep) {
|
||||
cur = m;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -141,3 +141,18 @@ fn trimmed_surfaces_sew_into_a_solid() {
|
||||
let m = measure(&imp.bodies[0].body).unwrap();
|
||||
assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drill_point_round_trips_through_iges() {
|
||||
// A blind hole with a cone tip: the tip's face closes at its apex (its seam runs out to the
|
||||
// apex and straight back).
|
||||
use solvecraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let plate = box_solid(Vec3::new(-20.0, -15.0, 0.0), Vec3::new(20.0, 15.0, 10.0)).unwrap();
|
||||
let profile =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 2.0), Vec2::new(4.0, 5.0), Vec2::new(4.0, 11.0), Vec2::new(0.0, 11.0)]), holes: vec![] };
|
||||
let hole = crate::revolve(&Plane::XZ, &[profile], Vec2::ZERO, Vec2::new(0.0, 1.0), std::f64::consts::TAU).unwrap().pop().unwrap();
|
||||
let drilled = boolean(&plate, &hole, BoolOp::Cut).unwrap().unwrap();
|
||||
let v = 12000.0 - std::f64::consts::PI * 16.0 * (5.0 + 1.0);
|
||||
assert!(rel(measure(&drilled).unwrap().volume, v) < 1e-3);
|
||||
round_trip(&drilled);
|
||||
}
|
||||
|
||||
@@ -21,10 +21,12 @@ mod coplanar;
|
||||
mod curveblend;
|
||||
mod delete_face;
|
||||
mod freeform;
|
||||
pub mod fuzz;
|
||||
mod heal;
|
||||
mod helix;
|
||||
mod iges_in;
|
||||
mod iges_out;
|
||||
mod lip;
|
||||
mod loopblend;
|
||||
mod measure;
|
||||
mod meshbody;
|
||||
@@ -48,13 +50,14 @@ mod topo;
|
||||
|
||||
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet};
|
||||
pub use blend_corner::fillet_radii;
|
||||
pub use blend_variable::{fillet_chord, fillet_variable};
|
||||
pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points};
|
||||
pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint};
|
||||
pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
|
||||
pub use delete_face::delete_faces;
|
||||
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh};
|
||||
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
|
||||
pub use iges_out::iges_export_bodies;
|
||||
pub use lip::raise_band;
|
||||
pub use measure::{BodyMeasure, measure};
|
||||
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||
pub use offset::with_same_surface;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Raising a band of a planar face (a plastic part's lip): the band along one of the face's
|
||||
//! boundary loops, a set width into the face, is pushed out along the face's normal. Built
|
||||
//! directly from the body's faces (the band's walls continue the walls below, which a boolean
|
||||
//! union can't join: their faces would coincide).
|
||||
|
||||
use solvecraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::body::{Body, Solid, from_p3, p3, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
fn fail(m: &str) -> KernelError {
|
||||
KernelError::Failed(format!("raising a band: {m}"))
|
||||
}
|
||||
|
||||
/// A boundary edge of the face, as its ends and (for an arc) a point halfway.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Piece {
|
||||
a: Vec3,
|
||||
b: Vec3,
|
||||
mid: Option<Vec3>,
|
||||
}
|
||||
|
||||
/// The planar face of `body` at `at`: its index and outward normal.
|
||||
fn face_at(body: &Body, at: Vec3) -> Result<(usize, Vec3)> {
|
||||
let size = body.size();
|
||||
let mesh = body.tessellate((size * 1e-3).max(1e-3))?;
|
||||
let near = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter_map(|(t, f)| {
|
||||
let [a, b, c] = mesh.tri(t)?;
|
||||
let n = (b - a).cross(c - a).normalized()?;
|
||||
let q = at - n * (at - a).dot(n);
|
||||
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-9);
|
||||
inside.then_some(((at - a).dot(n).abs(), *f as usize))
|
||||
})
|
||||
.min_by(|x, y| x.0.total_cmp(&y.0));
|
||||
let (d, f) = near.ok_or_else(|| fail("no face there"))?;
|
||||
if d > size * 1e-3 {
|
||||
return Err(fail("no face there"));
|
||||
}
|
||||
let face = body.solid.face_iter().nth(f).ok_or_else(|| fail("face"))?;
|
||||
let mt::Surface::Plane(pl) = face.oriented_surface() else { return Err(fail("the face must be planar")) };
|
||||
let n = pl.normal();
|
||||
Ok((f, Vec3::new(n.x, n.y, n.z).normalized().ok_or_else(|| fail("normal"))?))
|
||||
}
|
||||
|
||||
/// The face's loop moved `w` into the face (to the left of its edges, as a face's boundary has
|
||||
/// its inside on the left), corners mitred where the offsets don't already meet.
|
||||
fn offset_loop(pieces: &[Piece], n: Vec3, w: f64, tol: f64) -> Result<Vec<Piece>> {
|
||||
let left = |a: Vec3, b: Vec3| n.cross(b - a).normalized();
|
||||
let mut out: Vec<Piece> = Vec::new();
|
||||
for p in pieces {
|
||||
out.push(match p.mid {
|
||||
None => {
|
||||
let l = left(p.a, p.b).ok_or_else(|| fail("zero edge"))?;
|
||||
Piece { a: p.a + l * w, b: p.b + l * w, mid: None }
|
||||
}
|
||||
Some(m) => {
|
||||
// Each point moves square to the arc (along its radius), to the left.
|
||||
let (la, lm, lb) =
|
||||
(left(p.a, m).ok_or_else(|| fail("arc"))?, left(p.a, p.b).ok_or_else(|| fail("arc"))?, left(m, p.b).ok_or_else(|| fail("arc"))?);
|
||||
let c = circle_centre(p.a, m, p.b).ok_or_else(|| fail("arc"))?;
|
||||
let radial = |q: Vec3, hint: Vec3| {
|
||||
let r = (q - c).normalized().unwrap_or(hint);
|
||||
if r.dot(hint) >= 0.0 { r } else { -r }
|
||||
};
|
||||
let _ = (la, lb);
|
||||
Piece { a: p.a + radial(p.a, lm) * w, b: p.b + radial(p.b, lm) * w, mid: Some(m + radial(m, lm) * w) }
|
||||
}
|
||||
});
|
||||
}
|
||||
// Joints: where the offsets don't meet (sharp corners of lines), the lines are extended or
|
||||
// cut back to their crossing.
|
||||
let k = out.len();
|
||||
for i in 0..k {
|
||||
let j = (i + 1) % k;
|
||||
let (Some(pi), Some(pj)) = (out.get(i).copied(), out.get(j).copied()) else { continue };
|
||||
if pi.b.dist(pj.a) < tol {
|
||||
if let Some(x) = out.get_mut(j) {
|
||||
x.a = pi.b;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match (pi.mid, pj.mid) {
|
||||
(None, None) => {
|
||||
let (d1, d2) = (pi.b - pi.a, pj.b - pj.a);
|
||||
let w0 = pj.a - pi.a;
|
||||
let cr = d1.cross(d2);
|
||||
let den = cr.len2();
|
||||
if den < 1e-24 {
|
||||
return Err(fail("parallel edges at a corner"));
|
||||
}
|
||||
let t = w0.cross(d2).dot(cr) / den;
|
||||
let x = pi.a + d1 * t;
|
||||
if let Some(e) = out.get_mut(i) {
|
||||
e.b = x;
|
||||
}
|
||||
if let Some(e) = out.get_mut(j) {
|
||||
e.a = x;
|
||||
}
|
||||
}
|
||||
_ => return Err(fail("an arc meeting another edge at a corner")),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn circle_centre(a: Vec3, m: Vec3, b: Vec3) -> Option<Vec3> {
|
||||
let (u, v) = (b - a, m - a);
|
||||
let w = u.cross(v);
|
||||
let d = 2.0 * w.len2();
|
||||
if d < 1e-300 {
|
||||
return None;
|
||||
}
|
||||
Some(a + (v.cross(w) * u.len2() + w.cross(u) * v.len2()) * (1.0 / d))
|
||||
}
|
||||
|
||||
/// Raise the band of the planar face at `at` that runs `width` wide along its outer loop
|
||||
/// (`outside`) or its inner loop, by `height` along the face's normal.
|
||||
pub fn raise_band(body: &Body, at: Vec3, width: f64, height: f64, outside: bool) -> Result<Body> {
|
||||
body.require_brep("a lip")?;
|
||||
if !(width > 1e-6 && height > 1e-6 && width.is_finite() && height.is_finite()) {
|
||||
return Err(KernelError::Invalid("the band's width and height must be positive".into()));
|
||||
}
|
||||
if body.solid.boundaries().len() != 1 {
|
||||
return Err(fail("a body in one piece"));
|
||||
}
|
||||
let size = body.size();
|
||||
let tol = (size * 1e-7).max(1e-9) * 100.0;
|
||||
let (fi, n) = face_at(body, at)?;
|
||||
let faces: Vec<mt::Face> = body.solid.face_iter().cloned().collect();
|
||||
let f = faces.get(fi).ok_or_else(|| fail("face"))?;
|
||||
let wires = f.boundaries();
|
||||
if wires.len() != 2 {
|
||||
return Err(fail("the face must have an outer and one inner loop (a rim)"));
|
||||
}
|
||||
// Outer loop: turning positively about n.
|
||||
let turn = |w: &mt::Wire| -> f64 {
|
||||
let pts: Vec<Vec3> = w.vertex_iter().map(|v| from_p3(v.point())).collect();
|
||||
let mut s = Vec3::ZERO;
|
||||
for (k, p) in pts.iter().enumerate() {
|
||||
if let Some(q) = pts.get((k + 1) % pts.len()) {
|
||||
s += p.cross(*q);
|
||||
}
|
||||
}
|
||||
s.dot(n)
|
||||
};
|
||||
let (w0, w1) = (wires.first().ok_or_else(|| fail("loop"))?, wires.get(1).ok_or_else(|| fail("loop"))?);
|
||||
let (outer, inner) = if turn(w0) >= turn(w1) { (w0, w1) } else { (w1, w0) };
|
||||
let (along, other) = if outside { (outer, inner) } else { (inner, outer) };
|
||||
let pieces: Vec<Piece> = along
|
||||
.edge_iter()
|
||||
.map(|e| {
|
||||
let (a, b) = (from_p3(e.front().point()), from_p3(e.back().point()));
|
||||
match crate::offset::arc_mid(e, size * 1e-6) {
|
||||
Some(None) => Ok(Piece { a, b, mid: None }),
|
||||
Some(Some(m)) => Ok(Piece { a, b, mid: Some(m) }),
|
||||
None => Err(fail("edges must be lines or arcs")),
|
||||
}
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let moved = offset_loop(&pieces, n, width, tol)?;
|
||||
guard("lip", || {
|
||||
// The band's other edge, on the face.
|
||||
let verts: Vec<mt::Vertex> = moved.iter().map(|p| builder::vertex(p3(p.a))).collect();
|
||||
let k = verts.len();
|
||||
let mut cedges: Vec<mt::Edge> = Vec::with_capacity(k);
|
||||
for (i, p) in moved.iter().enumerate() {
|
||||
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % k)) else { return Err(fail("vertex")) };
|
||||
cedges.push(match p.mid {
|
||||
None => builder::line(a, b),
|
||||
Some(m) => builder::circle_arc(a, b, p3(m)),
|
||||
});
|
||||
}
|
||||
let c: mt::Wire = cedges.into();
|
||||
// The rest of the face: its other loop, and the band's edge as a loop of its own.
|
||||
// (The band's edge runs the face's way round: a hole when the band follows the inner
|
||||
// loop, the outer loop when it follows the outer.)
|
||||
let rest = mt::Face::try_new(vec![other.clone(), c.clone()], f.oriented_surface()).map_err(|e| fail(&e.to_string()))?;
|
||||
// Walls: the face's loop and the band's edge swept out; the band's top.
|
||||
let up = v3(n * height);
|
||||
let wall_along: mt::Shell = builder::tsweep(along, up);
|
||||
let wall_c: mt::Shell = builder::tsweep(&c, up);
|
||||
let top_of = |sh: &mt::Shell| -> Vec<mt::Wire> {
|
||||
sh.extract_boundaries().into_iter().filter(|w| w.vertex_iter().all(|v| (from_p3(v.point()) - at).dot(n) > height * 0.5)).collect()
|
||||
};
|
||||
let flipped = |sh: &mt::Shell, yes: bool| -> mt::Shell {
|
||||
if yes { sh.face_iter().map(|x| x.inverse()).collect::<Vec<_>>().into() } else { sh.clone() }
|
||||
};
|
||||
let mut last = String::new();
|
||||
for flip in 0..8u32 {
|
||||
let (wa, wc) = (flipped(&wall_along, flip & 1 == 1), flipped(&wall_c, flip & 2 == 2));
|
||||
let mut tops = top_of(&wa);
|
||||
tops.extend(top_of(&wc));
|
||||
if tops.len() != 2 {
|
||||
return Err(fail("the band's top"));
|
||||
}
|
||||
// The top closes both walls: its loops run against theirs.
|
||||
let tops: Vec<mt::Wire> = tops.iter().map(|w| w.inverse()).collect();
|
||||
let Ok(top) = builder::try_attach_plane(&tops) else { continue };
|
||||
let top = if flip & 4 == 4 { top.inverse() } else { top };
|
||||
let mut all: Vec<mt::Face> = faces.iter().enumerate().filter(|(i, _)| *i != fi).map(|(_, x)| x.clone()).collect();
|
||||
all.push(rest.clone());
|
||||
all.extend(wa.face_iter().cloned());
|
||||
all.extend(wc.face_iter().cloned());
|
||||
all.push(top);
|
||||
match Solid::try_new(vec![all.into()]) {
|
||||
Ok(s) => {
|
||||
let b = Body::new(s)?;
|
||||
if b.validity().is_empty() {
|
||||
return Ok(b);
|
||||
}
|
||||
last = b.validity().join("; ");
|
||||
}
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
}
|
||||
Err(fail(&last))
|
||||
})
|
||||
}
|
||||
@@ -101,7 +101,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
|
||||
t.swap(1, 2);
|
||||
}
|
||||
}
|
||||
Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None, paint: None })
|
||||
Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None, paint: None, splits: None })
|
||||
}
|
||||
|
||||
/// Wind every connected piece one way: a triangle that runs a side in the same direction as
|
||||
@@ -147,7 +147,7 @@ fn unify_winding(m: &mut TriMesh) {
|
||||
}
|
||||
|
||||
/// Every triangle side is shared by exactly two triangles in opposite directions.
|
||||
fn is_closed(m: &TriMesh) -> bool {
|
||||
pub(crate) fn is_closed(m: &TriMesh) -> bool {
|
||||
let mut count: HashMap<(u32, u32), i32> = HashMap::new();
|
||||
for t in &m.triangles {
|
||||
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
|
||||
@@ -185,6 +185,6 @@ impl Body {
|
||||
t.swap(1, 2);
|
||||
}
|
||||
}
|
||||
Some(Body { solid: self.solid.clone(), mesh: Some(Arc::new(out)), color: self.color, paint: self.paint.clone() })
|
||||
Some(Body { solid: self.solid.clone(), mesh: Some(Arc::new(out)), color: self.color, paint: self.paint.clone(), splits: self.splits.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
+333
-22
@@ -64,6 +64,15 @@ impl Surf {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The gradient of [`Surf::dist`] (the normal before orientation).
|
||||
fn grad(&self, p: Vec3) -> Option<Vec3> {
|
||||
match *self {
|
||||
Surf::Plane { n, .. } => Some(n),
|
||||
Surf::Cylinder { convex, .. } | Surf::Sphere { convex, .. } | Surf::Cone { convex, .. } | Surf::Torus { convex, .. } => {
|
||||
self.normal(p).map(|n| if convex { n } else { -n })
|
||||
}
|
||||
}
|
||||
}
|
||||
fn moved(&self, s: f64) -> Option<Surf> {
|
||||
match *self {
|
||||
Surf::Plane { n, d } => Some(Surf::Plane { n, d: d + s }),
|
||||
@@ -222,6 +231,10 @@ pub(crate) fn surf_of(f: &mt::Face, tol: f64) -> Option<Surf> {
|
||||
return Some(t);
|
||||
}
|
||||
let (p0, n0) = *s.first()?;
|
||||
// Flat though not built as a plane (a radial line swept round an axis).
|
||||
if s.iter().all(|(p, n)| n.dot(n0) > 1.0 - 1e-9 && (*p - p0).dot(n0).abs() < tol) {
|
||||
return Some(Surf::Plane { n: n0, d: n0.dot(p0) });
|
||||
}
|
||||
// The normal most square to the first (a half cylinder's two ends are opposite).
|
||||
let far = s.iter().map(|x| x.1).min_by(|a, b| a.dot(n0).abs().total_cmp(&b.dot(n0).abs()))?;
|
||||
let Some(a) = n0.cross(far).normalized() else { return sphere_or_cone(&s, tol) };
|
||||
@@ -277,7 +290,9 @@ fn torus_of(f: &mt::Face, s: &[(Vec3, Vec3)], tol: f64) -> Option<Surf> {
|
||||
}
|
||||
let x = crate::polyhedron::solve3_pub(m, rhs)?;
|
||||
let (big, h, r) = (x[0], x[1], x[2]);
|
||||
if !(big > 1e-9 && r.abs() > 1e-9 && r.abs() < big) {
|
||||
// (A tube wider than its circle, a spindle torus, is fine: blends use its outer part, where
|
||||
// each point's tube centre is the one on its own side of the axis.)
|
||||
if !(big > 1e-9 && r.abs() > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let t = Surf::Torus { o, a, big, h, r: r.abs(), convex: r > 0.0 };
|
||||
@@ -354,9 +369,26 @@ fn sphere_or_cone(s: &[(Vec3, Vec3)], tol: f64) -> Option<Surf> {
|
||||
/// n_i · δ = s_i for each face's normal n_i at p, then checked against the moved surfaces.
|
||||
fn move_point(p: Vec3, old: &[Surf], shifts: &[f64], new: &[Surf], tol: f64) -> Option<Vec3> {
|
||||
let ns: Vec<Vec3> = old.iter().map(|sf| sf.normal(p)).collect::<Option<_>>()?;
|
||||
let mut q = p + span_step(&ns, shifts)?;
|
||||
// The step is exact for planes; a curved face meeting another at an angle needs a few
|
||||
// Newton steps onto the moved surfaces.
|
||||
for _ in 0..30 {
|
||||
let d: Vec<f64> = new.iter().map(|sf| sf.dist(q)).collect();
|
||||
if d.iter().all(|x| x.abs() < tol) {
|
||||
return Some(q);
|
||||
}
|
||||
let gs: Vec<Vec3> = new.iter().map(|sf| sf.grad(q)).collect::<Option<_>>()?;
|
||||
let rhs: Vec<f64> = d.iter().map(|x| -x).collect();
|
||||
q += span_step(&gs, &rhs)?;
|
||||
}
|
||||
new.iter().all(|sf| sf.dist(q).abs() < tol).then_some(q)
|
||||
}
|
||||
|
||||
/// The smallest move `v` (in the span of `ns`) with `n_i · v = s_i` (least squares).
|
||||
fn span_step(ns: &[Vec3], s: &[f64]) -> Option<Vec3> {
|
||||
// Orthonormal basis of the normals' span (tangent faces share a normal).
|
||||
let mut basis: Vec<Vec3> = Vec::new();
|
||||
for n in &ns {
|
||||
for n in ns {
|
||||
let mut v = *n;
|
||||
for e in &basis {
|
||||
v = v - *e * v.dot(*e);
|
||||
@@ -369,13 +401,13 @@ fn move_point(p: Vec3, old: &[Surf], shifts: &[f64], new: &[Surf], tol: f64) ->
|
||||
let r = basis.len();
|
||||
let mut m = [[0.0f64; 3]; 3];
|
||||
let mut rhs = [0.0f64; 3];
|
||||
for (n, s) in ns.iter().zip(shifts) {
|
||||
for (n, si) in ns.iter().zip(s) {
|
||||
let row: Vec<f64> = basis.iter().map(|e| n.dot(*e)).collect();
|
||||
for i in 0..r {
|
||||
for j in 0..r {
|
||||
m[i][j] += row[i] * row[j];
|
||||
}
|
||||
rhs[i] += row[i] * s;
|
||||
rhs[i] += row[i] * si;
|
||||
}
|
||||
}
|
||||
// Pad unused dimensions with the identity.
|
||||
@@ -383,15 +415,79 @@ fn move_point(p: Vec3, old: &[Surf], shifts: &[f64], new: &[Surf], tol: f64) ->
|
||||
row[i] = 1.0;
|
||||
}
|
||||
let c = crate::polyhedron::solve3_pub(m, rhs)?;
|
||||
let mut q = p;
|
||||
let mut v = Vec3::ZERO;
|
||||
for (k, e) in basis.iter().enumerate() {
|
||||
q += *e * c[k];
|
||||
v += *e * c[k];
|
||||
}
|
||||
new.iter().all(|sf| sf.dist(q).abs() < tol).then_some(q)
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// A line or circular arc through an edge (by samples): `None` for other curves.
|
||||
fn arc_mid(e: &mt::Edge, tol: f64) -> Option<Option<Vec3>> {
|
||||
/// Maximum straight edges per face checked pairwise for crossings.
|
||||
const MAX_CROSSING_EDGES: usize = 2000;
|
||||
|
||||
/// Where the moved straight edges of a planar face cross each other (or a loop of straight
|
||||
/// edges turns round), if they do. Faces next to a collapsed round are left to the other checks.
|
||||
fn crossed_loops(
|
||||
faces: &[mt::Face],
|
||||
old: &[Surf],
|
||||
newpos: &HashMap<mt::VertexID, Vec3>,
|
||||
edge_collapsed: &dyn Fn(&mt::Edge) -> bool,
|
||||
size: f64,
|
||||
) -> Option<Vec3> {
|
||||
let eps = size * 1e-9;
|
||||
for (f, sf) in faces.iter().zip(old) {
|
||||
let Surf::Plane { n, .. } = *sf else { continue };
|
||||
if f.edge_iter().any(|e| edge_collapsed(&e)) {
|
||||
continue;
|
||||
}
|
||||
let (u, w) = (n.any_perp(), n.cross(n.any_perp()));
|
||||
let flat = |p: Vec3| (p.dot(u), p.dot(w));
|
||||
// Straight edges as (vertex ids, old ends, new ends) in the plane.
|
||||
let mut segs: Vec<([mt::VertexID; 2], [(f64, f64); 2])> = Vec::new();
|
||||
for wire in f.absolute_boundaries() {
|
||||
let (mut area_old, mut area_new, mut straight) = (0.0, 0.0, true);
|
||||
for e in wire.edge_iter() {
|
||||
let (v0, v1) = (e.front(), e.back());
|
||||
let (Some(q0), Some(q1)) = (newpos.get(&v0.id()), newpos.get(&v1.id())) else { return None };
|
||||
let (o0, o1, n0, n1) = (flat(from_p3(v0.point())), flat(from_p3(v1.point())), flat(*q0), flat(*q1));
|
||||
area_old += o0.0 * o1.1 - o1.0 * o0.1;
|
||||
area_new += n0.0 * n1.1 - n1.0 * n0.1;
|
||||
if arc_mid(e, size * 1e-6) == Some(None) {
|
||||
segs.push(([v0.id(), v1.id()], [n0, n1]));
|
||||
} else {
|
||||
straight = false;
|
||||
}
|
||||
}
|
||||
if straight && area_old.abs() > eps && area_old * area_new <= 0.0 {
|
||||
return wire.vertex_iter().next().and_then(|v| newpos.get(&v.id()).copied());
|
||||
}
|
||||
}
|
||||
if segs.len() > MAX_CROSSING_EDGES {
|
||||
continue;
|
||||
}
|
||||
let side = |a: (f64, f64), c: (f64, f64), p: (f64, f64)| (c.0 - a.0) * (p.1 - a.1) - (c.1 - a.1) * (p.0 - a.0);
|
||||
for (i, (ia, [a0, a1])) in segs.iter().enumerate() {
|
||||
for (ib, [b0, b1]) in segs.iter().skip(i + 1) {
|
||||
if ia.iter().any(|v| ib.contains(v)) {
|
||||
continue;
|
||||
}
|
||||
let (s0, s1) = (side(*a0, *a1, *b0), side(*a0, *a1, *b1));
|
||||
let (t0, t1) = (side(*b0, *b1, *a0), side(*b0, *b1, *a1));
|
||||
let tol = eps * size;
|
||||
if ((s0 > tol && s1 < -tol) || (s0 < -tol && s1 > tol)) && ((t0 > tol && t1 < -tol) || (t0 < -tol && t1 > tol)) {
|
||||
let k = t0 / (t0 - t1);
|
||||
let (x, y) = (a0.0 + (a1.0 - a0.0) * k, a0.1 + (a1.1 - a0.1) * k);
|
||||
let d = n.dot(newpos.get(&ia[0]).copied().unwrap_or_default());
|
||||
return Some(u * x + w * y + n * d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn arc_mid(e: &mt::Edge, tol: f64) -> Option<Option<Vec3>> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
@@ -423,8 +519,13 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
let mut old = Vec::with_capacity(faces.len());
|
||||
let mut new = Vec::with_capacity(faces.len());
|
||||
let mut shifts = Vec::with_capacity(faces.len());
|
||||
// Faces moved to nothing (a round thinner than the move): they go, their neighbours meet.
|
||||
let mut collapsed: Vec<bool> = Vec::with_capacity(faces.len());
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let sf = surf_of(f, tol * 100.0).ok_or_else(|| fail("only bodies with planes, cylinders, cones, spheres and tori"))?;
|
||||
let sf = surf_of(f, tol * 100.0).ok_or_else(|| {
|
||||
let at = samples(f).first().map(|(p, _)| format!(" (a face at [{:.2}, {:.2}, {:.2}])", p.x, p.y, p.z)).unwrap_or_default();
|
||||
fail(&format!("only bodies with planes, cylinders, cones, spheres and tori{at}"))
|
||||
})?;
|
||||
let n = match sf {
|
||||
Surf::Plane { n, .. } => n,
|
||||
Surf::Cylinder { a, .. } | Surf::Cone { a, .. } | Surf::Torus { a, .. } => a.any_perp(),
|
||||
@@ -432,7 +533,16 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
};
|
||||
let s = shift(i, n);
|
||||
old.push(sf);
|
||||
new.push(sf.moved(s).ok_or_else(|| fail("a cylinder shrinks to nothing"))?);
|
||||
match sf.moved(s) {
|
||||
Some(x) => {
|
||||
new.push(x);
|
||||
collapsed.push(false);
|
||||
}
|
||||
None => {
|
||||
new.push(sf);
|
||||
collapsed.push(true);
|
||||
}
|
||||
}
|
||||
shifts.push(s);
|
||||
}
|
||||
let pick = |ids: &[usize]| -> (Vec<Surf>, Vec<f64>, Vec<Surf>) {
|
||||
@@ -459,11 +569,102 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
}
|
||||
}
|
||||
}
|
||||
let is_collapsed = |i: usize| collapsed.get(i).copied().unwrap_or(false);
|
||||
let any_collapsed = collapsed.iter().any(|c| *c);
|
||||
// A collapsed face's place is taken by the faces around it (through other collapsed ones).
|
||||
let mut nbrs: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
for fs in efaces.values() {
|
||||
for a in fs {
|
||||
for b2 in fs {
|
||||
if a != b2 {
|
||||
let e = nbrs.entry(*a).or_default();
|
||||
if !e.contains(b2) {
|
||||
e.push(*b2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let expand = |fs: &[usize]| -> Vec<usize> {
|
||||
let mut out: Vec<usize> = fs.iter().copied().filter(|i| !is_collapsed(*i)).collect();
|
||||
let mut queue: Vec<usize> = fs.iter().copied().filter(|i| is_collapsed(*i)).collect();
|
||||
let mut seen = queue.clone();
|
||||
while let Some(c) = queue.pop() {
|
||||
for nb in nbrs.get(&c).into_iter().flatten() {
|
||||
if is_collapsed(*nb) {
|
||||
if !seen.contains(nb) {
|
||||
seen.push(*nb);
|
||||
queue.push(*nb);
|
||||
}
|
||||
} else if !out.contains(nb) {
|
||||
out.push(*nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
// Each edge's ends and middle, and the faces either side.
|
||||
let edge_pts: Vec<(Vec<usize>, [Vec3; 3])> = b
|
||||
.solid
|
||||
.edge_iter()
|
||||
.filter_map(|e| {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
Some((
|
||||
efaces.get(&e.id())?.clone(),
|
||||
[from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point()), from_p3(c.subs((t0 + t1) / 2.0))],
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let mut newpos: HashMap<mt::VertexID, Vec3> = HashMap::new();
|
||||
for v in b.solid.vertex_iter() {
|
||||
let fs = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?;
|
||||
let (o, s, n) = pick(fs);
|
||||
let at = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?;
|
||||
let p = from_p3(v.point());
|
||||
let fs: Vec<usize> = if at.iter().any(|i| is_collapsed(*i)) {
|
||||
// On a collapsed round: its own faces, then the faces along the rounds it is on,
|
||||
// nearest first, until they fix a point.
|
||||
let group = expand(at);
|
||||
let mut own: Vec<usize> = at.iter().copied().filter(|i| !is_collapsed(*i)).collect();
|
||||
let mut cand: Vec<(f64, usize)> = group
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|nb| !own.contains(nb))
|
||||
.map(|nb| {
|
||||
let d = edge_pts
|
||||
.iter()
|
||||
.filter(|(fs, _)| fs.contains(&nb) && fs.iter().any(|i| is_collapsed(*i)))
|
||||
.flat_map(|(_, q)| q.iter().map(|x| x.dist(p)))
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
(d, nb)
|
||||
})
|
||||
.collect();
|
||||
cand.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||||
let rank = |ids: &[usize]| -> usize {
|
||||
let mut basis: Vec<Vec3> = Vec::new();
|
||||
for i in ids {
|
||||
let Some(nv) = old.get(*i).and_then(|sf| sf.normal(p)) else { continue };
|
||||
let mut w = nv;
|
||||
for e in &basis {
|
||||
w = w - *e * w.dot(*e);
|
||||
}
|
||||
if let Some(u) = w.normalized().filter(|_| w.len() > 1e-6) {
|
||||
basis.push(u);
|
||||
}
|
||||
}
|
||||
basis.len()
|
||||
};
|
||||
for (_, c) in cand {
|
||||
if rank(&own) >= 3 {
|
||||
break;
|
||||
}
|
||||
own.push(c);
|
||||
}
|
||||
own
|
||||
} else {
|
||||
at.clone()
|
||||
};
|
||||
let (o, s, n) = pick(&fs);
|
||||
// A cone's apex goes with the cone (its normal is not defined there).
|
||||
let apex = o.iter().zip(&n).find_map(|(a, b)| match (a, b) {
|
||||
(Surf::Cone { v: va, .. }, Surf::Cone { v: vb, .. }) if va.dist(p) < size * 1e-6 => Some(*vb),
|
||||
@@ -475,18 +676,109 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
};
|
||||
newpos.insert(v.id(), q);
|
||||
}
|
||||
// An edge turned round: faces crossed over (a wall thinner than the move), which would
|
||||
// leave the body inside out.
|
||||
let edge_collapsed = |e: &mt::Edge| efaces.get(&e.id()).is_some_and(|fs| fs.iter().any(|i| is_collapsed(*i)));
|
||||
for e in b.solid.edge_iter() {
|
||||
if edge_collapsed(&e) {
|
||||
continue;
|
||||
}
|
||||
let (f0, f1) = (e.absolute_front(), e.absolute_back());
|
||||
let (Some(q0), Some(q1)) = (newpos.get(&f0.id()), newpos.get(&f1.id())) else { continue };
|
||||
let (old_v, new_v) = (from_p3(f1.point()) - from_p3(f0.point()), *q1 - *q0);
|
||||
if old_v.len() > size * 1e-6 && old_v.dot(new_v) < 0.0 {
|
||||
return Err(fail("not supported yet: the move is larger than a wall or step is thick (faces would cross)"));
|
||||
}
|
||||
}
|
||||
// A plane's loops crossing after the move: a rim or ledge narrower than the move (the hole
|
||||
// in a top face sliding past its outer edge) turns part of the face inside out, though no
|
||||
// edge turns round.
|
||||
if let Some(at) = crossed_loops(&faces, &old, &newpos, &edge_collapsed, size) {
|
||||
return Err(fail(&format!(
|
||||
"not supported yet: the move is larger than a rim or ledge near [{:.1}, {:.1}, {:.1}] is wide (a face would turn inside out)",
|
||||
at.x, at.y, at.z
|
||||
)));
|
||||
}
|
||||
guard("offset", || {
|
||||
let verts: HashMap<mt::VertexID, mt::Vertex> = newpos.iter().map(|(k, p)| (*k, builder::vertex(p3(*p)))).collect();
|
||||
let mut edges: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
// Vertices of collapsed faces that land together become one.
|
||||
let on_collapsed: std::collections::HashSet<mt::VertexID> = faces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| is_collapsed(*i))
|
||||
.flat_map(|(_, f)| f.vertex_iter().map(|v| v.id()).collect::<Vec<_>>())
|
||||
.collect();
|
||||
let mut verts: HashMap<mt::VertexID, mt::Vertex> = HashMap::new();
|
||||
let mut welded: Vec<(Vec3, mt::Vertex)> = Vec::new();
|
||||
for v in b.solid.vertex_iter() {
|
||||
let Some(p) = newpos.get(&v.id()).copied() else { continue };
|
||||
let nv = if any_collapsed && on_collapsed.contains(&v.id()) {
|
||||
match welded.iter().find(|(q, _)| q.dist(p) < size * 1e-6) {
|
||||
Some((_, w)) => w.clone(),
|
||||
None => {
|
||||
let w = builder::vertex(p3(p));
|
||||
welded.push((p, w.clone()));
|
||||
w
|
||||
}
|
||||
}
|
||||
} else {
|
||||
builder::vertex(p3(p))
|
||||
};
|
||||
verts.insert(v.id(), nv);
|
||||
}
|
||||
// Old edge → new edge, and whether it runs the other way (merged with its twin).
|
||||
let mut edges: HashMap<mt::EdgeID, (mt::Edge, bool)> = HashMap::new();
|
||||
let mut merged: HashMap<(mt::VertexID, mt::VertexID), mt::Edge> = HashMap::new();
|
||||
for e in b.solid.edge_iter() {
|
||||
if edges.contains_key(&e.id()) {
|
||||
continue;
|
||||
}
|
||||
let (Some(a), Some(c)) = (verts.get(&e.absolute_front().id()), verts.get(&e.absolute_back().id())) else { return Err(fail("edge")) };
|
||||
if from_p3(a.point()).dist(from_p3(c.point())) < size * 1e-9 {
|
||||
let touches = edge_collapsed(&e);
|
||||
if a == c || from_p3(a.point()).dist(from_p3(c.point())) < size * 1e-9 {
|
||||
if touches {
|
||||
continue;
|
||||
}
|
||||
return Err(fail("an edge vanishes (the offset is too large)"));
|
||||
}
|
||||
let kind = arc_mid(&e, size * 1e-6).ok_or_else(|| fail("edges must be lines or arcs"))?;
|
||||
if efaces.get(&e.id()).is_some_and(|fs| fs.iter().all(|i| is_collapsed(*i))) {
|
||||
continue;
|
||||
}
|
||||
if touches {
|
||||
// Along a collapsed round: a straight edge, shared with its twin from the
|
||||
// round's other side.
|
||||
let ne = match merged.get(&(a.id(), c.id())).or_else(|| merged.get(&(c.id(), a.id()))) {
|
||||
Some(x) => x.clone(),
|
||||
None => {
|
||||
let x = builder::line(a, c);
|
||||
merged.insert((a.id(), c.id()), x.clone());
|
||||
x
|
||||
}
|
||||
};
|
||||
let flipped = ne.front() != a;
|
||||
edges.insert(e.id(), (ne, flipped));
|
||||
continue;
|
||||
}
|
||||
let Some(kind) = arc_mid(&e, size * 1e-6) else {
|
||||
// Another curve (where two cylinders meet): its points moved onto the moved
|
||||
// faces, through a smooth curve.
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let fs = efaces.get(&e.id()).ok_or_else(|| fail("edge faces"))?;
|
||||
let (o, s, n) = pick(fs);
|
||||
let cv = e.curve();
|
||||
let (t0, t1) = cv.range_tuple();
|
||||
let k = 16;
|
||||
let mut pts = vec![from_p3(a.point())];
|
||||
for j in 1..k {
|
||||
let p = from_p3(cv.subs(t0 + (t1 - t0) * j as f64 / k as f64));
|
||||
pts.push(
|
||||
move_point(p, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("an edge whose faces don't meet the same way after the move"))?,
|
||||
);
|
||||
}
|
||||
pts.push(from_p3(c.point()));
|
||||
let curve = crate::build::interpolate_cubic(&pts).ok_or_else(|| fail("edge curve"))?;
|
||||
edges.insert(e.id(), (mt::Edge::new(a, c, mt::Curve::BSplineCurve(curve)), false));
|
||||
continue;
|
||||
};
|
||||
let ne = match kind {
|
||||
None => builder::line(a, c),
|
||||
Some(m) => {
|
||||
@@ -497,16 +789,21 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
builder::circle_arc(a, c, p3(m2))
|
||||
}
|
||||
};
|
||||
edges.insert(e.id(), ne);
|
||||
edges.insert(e.id(), (ne, false));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
if is_collapsed(i) {
|
||||
continue;
|
||||
}
|
||||
let wires: Vec<mt::Wire> = f
|
||||
.absolute_boundaries()
|
||||
.iter()
|
||||
.map(|w| {
|
||||
w.edge_iter()
|
||||
.filter_map(|e| edges.get(&e.id()).map(|ne| if e.front() == e.absolute_front() { ne.clone() } else { ne.inverse() }))
|
||||
.filter_map(|e| {
|
||||
edges.get(&e.id()).map(|(ne, flip)| if (e.front() == e.absolute_front()) != *flip { ne.clone() } else { ne.inverse() })
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
})
|
||||
@@ -540,8 +837,16 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
}
|
||||
out.push(nf);
|
||||
}
|
||||
let shell: mt::Shell = out.into();
|
||||
let solid = Solid::try_new(vec![shell]).map_err(|e| fail(&e.to_string()))?;
|
||||
// One shell per shell of the body (pieces, voids), less the collapsed faces.
|
||||
let mut shells: Vec<mt::Shell> = Vec::new();
|
||||
let mut it = out.into_iter();
|
||||
let mut k = 0;
|
||||
for sh in b.solid.boundaries() {
|
||||
let keep = (k..k + sh.len()).filter(|i| !is_collapsed(*i)).count();
|
||||
k += sh.len();
|
||||
shells.push(it.by_ref().take(keep).collect::<Vec<_>>().into());
|
||||
}
|
||||
let solid = Solid::try_new(shells).map_err(|e| fail(&e.to_string()))?;
|
||||
Body::new(solid)
|
||||
})
|
||||
}
|
||||
@@ -746,7 +1051,7 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solvecraft_geom:
|
||||
}
|
||||
|
||||
/// The chosen faces and every face on the same surface joined to them (the halves of a hole's
|
||||
/// wall), as face indices.
|
||||
/// wall), as face indices. Pieces of a face split on purpose (Split Face) stay apart.
|
||||
pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec<usize> {
|
||||
let faces: Vec<mt::Face> = b.solid.face_iter().cloned().collect();
|
||||
let size = b.size();
|
||||
@@ -766,6 +1071,7 @@ pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec<usize> {
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let keep = b.split_keep();
|
||||
let mut out: Vec<usize> = chosen.to_vec();
|
||||
let mut grew = true;
|
||||
while grew {
|
||||
@@ -777,7 +1083,12 @@ pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec<usize> {
|
||||
let Some(Some(si)) = surfs.get(i) else { continue };
|
||||
let joins = out.iter().any(|j| {
|
||||
surfs.get(*j).and_then(|x| x.as_ref()).is_some_and(|sj| same(si, sj))
|
||||
&& faces.get(*j).is_some_and(|g| g.edge_iter().any(|e| f.edge_iter().any(|x| x.id() == e.id())))
|
||||
&& faces.get(*j).is_some_and(|g| {
|
||||
g.edge_iter().any(|e| {
|
||||
f.edge_iter().any(|x| x.id() == e.id())
|
||||
&& (keep.is_empty() || !crate::heal::on_split_line(&crate::heal::edge_points(&e), keep))
|
||||
})
|
||||
})
|
||||
});
|
||||
if joins {
|
||||
out.push(i);
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3, p3, v3};
|
||||
use crate::body::{Body, Solid, from_p3, p3, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -162,6 +162,25 @@ thread_local! {
|
||||
/// everything or an intersection of disjoint bodies. Results are checked against volume bounds
|
||||
/// and retried with shifted copies and other tolerances when they fail or look wrong.
|
||||
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
let r = boolean_whole(a, b, op);
|
||||
// Bodies in several pieces: when the whole failed (or came out empty), a piece at a time.
|
||||
let retry = match &r {
|
||||
Err(_) => true,
|
||||
Ok(None) => op != BoolOp::Intersect,
|
||||
Ok(Some(_)) => false,
|
||||
};
|
||||
if retry && (a.solid.boundaries().len() > 1 || b.solid.boundaries().len() > 1) {
|
||||
let (la, lb) = (a.lumps()?, b.lumps()?);
|
||||
if (la.len() > 1 || lb.len() > 1)
|
||||
&& let Ok(Some(x)) = by_lumps(la, lb, op)
|
||||
{
|
||||
return Ok(Some(x));
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
a.require_brep("a boolean")?;
|
||||
b.require_brep("a boolean")?;
|
||||
let size = a.size().max(b.size());
|
||||
@@ -241,6 +260,7 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
}
|
||||
}
|
||||
let mut empty_votes = 0;
|
||||
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
|
||||
for (attempt, j) in JITTER.iter().enumerate() {
|
||||
let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01);
|
||||
for k in [2e-3, 5e-4] {
|
||||
@@ -267,7 +287,7 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
empty_votes += 1;
|
||||
last = "empty result".into();
|
||||
}
|
||||
Ok(s) => match Body::new(crate::heal::heal(s, size)) {
|
||||
Ok(s) => match Body::new(crate::heal::heal_keep(s, size, &keep)) {
|
||||
Ok(body) => {
|
||||
let (ok, v) = check(&body);
|
||||
if ok {
|
||||
@@ -338,6 +358,80 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
Err(KernelError::Failed(format!("boolean {op:?}: {last}")))
|
||||
}
|
||||
|
||||
/// A boolean of bodies given as their pieces; the result's pieces in one body.
|
||||
fn by_lumps(la: Vec<Body>, lb: Vec<Body>, op: BoolOp) -> Result<Option<Body>> {
|
||||
let boxes_meet = |x: &Body, y: &Body| -> bool {
|
||||
let (Ok(mx), Ok(my)) = (x.tessellate(x.size() * 0.05 + 1e-3), y.tessellate(y.size() * 0.05 + 1e-3)) else { return true };
|
||||
let (bx, by) = (mx.bounds(), my.bounds());
|
||||
let g = (x.size() + y.size()) * 0.01;
|
||||
bx.min.x <= by.max.x + g
|
||||
&& by.min.x <= bx.max.x + g
|
||||
&& bx.min.y <= by.max.y + g
|
||||
&& by.min.y <= bx.max.y + g
|
||||
&& bx.min.z <= by.max.z + g
|
||||
&& by.min.z <= bx.max.z + g
|
||||
};
|
||||
let mut out: Vec<Body> = Vec::new();
|
||||
match op {
|
||||
BoolOp::Cut => {
|
||||
for x in la {
|
||||
let mut cur = Some(x);
|
||||
for y in &lb {
|
||||
cur = match cur {
|
||||
Some(c) if boxes_meet(&c, y) => boolean(&c, y, BoolOp::Cut)?,
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
out.extend(cur);
|
||||
}
|
||||
}
|
||||
BoolOp::Intersect => {
|
||||
for x in &la {
|
||||
for y in &lb {
|
||||
if boxes_meet(x, y)
|
||||
&& let Some(r) = boolean(x, y, BoolOp::Intersect)?
|
||||
{
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BoolOp::Union => {
|
||||
out = la;
|
||||
for y in lb {
|
||||
let mut cur = y;
|
||||
let mut rest = Vec::new();
|
||||
for x in out {
|
||||
if boxes_meet(&x, &cur) {
|
||||
match boolean(&x, &cur, BoolOp::Union)? {
|
||||
Some(u) if u.solid.boundaries().len() == 1 => cur = u,
|
||||
_ => rest.push(x),
|
||||
}
|
||||
} else {
|
||||
rest.push(x);
|
||||
}
|
||||
}
|
||||
rest.push(cur);
|
||||
out = rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
join_pieces(out)
|
||||
}
|
||||
|
||||
/// Bodies (apart from each other) as one body of several pieces.
|
||||
pub(crate) fn join_pieces(pieces: Vec<Body>) -> Result<Option<Body>> {
|
||||
match pieces.len() {
|
||||
0 => Ok(None),
|
||||
1 => Ok(pieces.into_iter().next()),
|
||||
_ => {
|
||||
let shells: Vec<mt::Shell> = pieces.iter().flat_map(|p| p.solid.boundaries().clone()).collect();
|
||||
let solid = guard("pieces", || Solid::try_new(shells).map_err(|e| KernelError::Failed(e.to_string())))?;
|
||||
Body::new(solid).map(Some)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Affine transform by a column-major 4×4 matrix (rotations, translations, reflections).
|
||||
pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
|
||||
if m.iter().flatten().any(|x| !x.is_finite()) {
|
||||
|
||||
@@ -192,7 +192,7 @@ pub fn draft(b: &Body, faces: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64)
|
||||
|
||||
/// Draft of a non-convex body: walls (planes, cylinders) next to caps square to `pull`.
|
||||
fn draft_walls(b: &Body, at: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) -> Result<Body> {
|
||||
let healed = Body::new(crate::heal::heal(b.deep_copy(), b.size()))?;
|
||||
let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?;
|
||||
let size = healed.size();
|
||||
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
|
||||
let mut chosen: Vec<usize> = Vec::new();
|
||||
@@ -338,7 +338,7 @@ pub fn round_all_edges(b: &Body, r: f64) -> Result<Body> {
|
||||
/// by the thickness (open faces outward, past the body) keeping the body's topology, which
|
||||
/// gives the cavity; the shell is the body minus the cavity.
|
||||
fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
||||
let healed = Body::new(crate::heal::heal(b.deep_copy(), b.size()))?;
|
||||
let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?;
|
||||
let size = healed.size();
|
||||
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
|
||||
// Faces to open: the face of the triangle nearest each point.
|
||||
@@ -361,21 +361,77 @@ fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
||||
return Err(KernelError::Invalid("select at least one face to remove".into()));
|
||||
}
|
||||
let margin = thickness + size * 0.05;
|
||||
let cavity = offset_planar(&healed, |fi, _| if opened.contains(&fi) { margin } else { -thickness })?;
|
||||
match crate::ops::boolean(&healed, &cavity, crate::BoolOp::Cut) {
|
||||
// Open faces pushed out past the body: the simple way, unless a face tangent to an open
|
||||
// one (a fillet along its edge) can't follow; then every face moves in (tangencies keep)
|
||||
// and the opening is the open face's copy swept out through the body.
|
||||
let cavity = match offset_planar(&healed, |fi, _| if opened.contains(&fi) { margin } else { -thickness }) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return shell_by_opening(&healed, &opened, thickness, margin).map_err(|_| e),
|
||||
};
|
||||
let r = crate::ops::boolean(&healed, &cavity, crate::BoolOp::Cut);
|
||||
if r.is_err()
|
||||
&& let Ok(x) = shell_by_opening(&healed, &opened, thickness, margin)
|
||||
{
|
||||
return Ok(x);
|
||||
}
|
||||
match r {
|
||||
Ok(Some(r)) => Ok(r),
|
||||
Ok(None) => Err(KernelError::Failed("the shell removed everything".into())),
|
||||
// Healing can leave faces the boolean dislikes (revolved patches): the body as it was.
|
||||
Err(e) if healed.face_count() == b.face_count() => {
|
||||
// Healing can leave faces the boolean dislikes (revolved patches): the body as it was,
|
||||
// its faces to open found again.
|
||||
Err(e) => {
|
||||
let raw = b.tessellate((size * 1e-3).max(1e-3)).map_err(|_| e.clone())?;
|
||||
let mut opened_raw: Vec<usize> = Vec::new();
|
||||
for p in open {
|
||||
let near = raw
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&raw.tri_face)
|
||||
.filter_map(|(t, f)| raw.tri(t).map(|[x, y, z]| (point_tri(*p, x, y, z), *f as usize)))
|
||||
.min_by(|a, c| a.0.total_cmp(&c.0));
|
||||
if let Some((d, f)) = near
|
||||
&& d < size * 1e-3 + 1e-6
|
||||
&& !opened_raw.contains(&f)
|
||||
{
|
||||
opened_raw.push(f);
|
||||
}
|
||||
}
|
||||
let opened = opened_raw;
|
||||
let cavity = offset_planar(b, |fi, _| if opened.contains(&fi) { margin } else { -thickness }).map_err(|_| e.clone())?;
|
||||
crate::ops::boolean(b, &cavity, crate::BoolOp::Cut)
|
||||
.map_err(|_| e)?
|
||||
.ok_or_else(|| KernelError::Failed("the shell removed everything".into()))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// A shell made from the cavity with every face moved in by the thickness (open faces too),
|
||||
/// joined with each open face's moved copy swept out through the body.
|
||||
fn shell_by_opening(b: &Body, opened: &[usize], thickness: f64, reach: f64) -> Result<Body> {
|
||||
let closed = offset_planar(b, |_, _| -thickness)?;
|
||||
let faces: Vec<mt::Face> = closed.solid.face_iter().cloned().collect();
|
||||
let mut cavity = closed.clone();
|
||||
for fi in opened {
|
||||
let f = faces.get(*fi).ok_or_else(|| KernelError::Failed("shell: open face".into()))?;
|
||||
let mt::Surface::Plane(pl) = f.oriented_surface() else {
|
||||
return Err(unsupported_shell("the open face must be planar here"));
|
||||
};
|
||||
let n = pl.normal();
|
||||
let n = Vec3::new(n.x, n.y, n.z).normalized().ok_or_else(|| KernelError::Failed("shell: open face normal".into()))?;
|
||||
let prism = guard("shell opening", || {
|
||||
let solid: Solid = builder::tsweep(f, v3(n * (thickness + reach)));
|
||||
Ok(solid)
|
||||
})?;
|
||||
let prism = Body::new(prism)?;
|
||||
cavity = crate::ops::boolean(&cavity, &prism, crate::BoolOp::Union)?.ok_or_else(|| KernelError::Failed("shell: opening".into()))?;
|
||||
}
|
||||
crate::ops::boolean(b, &cavity, crate::BoolOp::Cut)?.ok_or_else(|| KernelError::Failed("the shell removed everything".into()))
|
||||
}
|
||||
|
||||
fn unsupported_shell(m: &str) -> KernelError {
|
||||
KernelError::Failed(format!("not supported yet: {m}"))
|
||||
}
|
||||
|
||||
fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
let n = (b - a).cross(c - a);
|
||||
let Some(nn) = n.normalized() else { return f64::INFINITY };
|
||||
@@ -422,7 +478,7 @@ pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result<Body> {
|
||||
if !distance.is_finite() || distance.abs() > 1e6 {
|
||||
return Err(KernelError::Invalid("offset distance".into()));
|
||||
}
|
||||
let healed = Body::new(crate::heal::heal(b.deep_copy(), b.size()))?;
|
||||
let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?.with_split_lines(b.splits.clone());
|
||||
let size = healed.size();
|
||||
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
|
||||
let mut chosen: Vec<usize> = Vec::new();
|
||||
@@ -443,5 +499,24 @@ pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result<Body> {
|
||||
}
|
||||
}
|
||||
let chosen = crate::offset::with_same_surface(&healed, &chosen);
|
||||
offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 })
|
||||
match offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 }) {
|
||||
Ok(r) => Ok(r),
|
||||
// A piece of a split face (its neighbour stays): the piece swept along its normal
|
||||
// joins on (outward) or cuts in, with walls along the split lines.
|
||||
Err(e) if !healed.split_keep().is_empty() => press_pieces(&healed, &chosen, distance).map_err(|_| e),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn press_pieces(b: &Body, chosen: &[usize], distance: f64) -> Result<Body> {
|
||||
let faces: Vec<mt::Face> = b.solid.face_iter().cloned().collect();
|
||||
let mut cur = b.clone();
|
||||
for fi in chosen {
|
||||
let f = faces.get(*fi).ok_or_else(|| KernelError::Failed("offset: face".into()))?;
|
||||
let n = crate::blend::plane_normal(f).ok_or_else(|| KernelError::Failed("offset: only planar pieces".into()))?;
|
||||
let tool = guard("offset face", || Body::new(builder::tsweep(f, crate::body::v3(n * distance))))?;
|
||||
let op = if distance > 0.0 { crate::BoolOp::Union } else { crate::BoolOp::Cut };
|
||||
cur = crate::ops::boolean(&cur, &tool, op)?.ok_or_else(|| KernelError::Failed("offset: the piece moved off the body".into()))?;
|
||||
}
|
||||
Ok(cur.with_split_lines(b.splits.clone()))
|
||||
}
|
||||
|
||||
@@ -845,6 +845,16 @@ pub fn split_faces(body: &Body, faces: &[usize], tool: &SplitTool) -> Result<Bod
|
||||
/// Like [`split_faces`], with the body face each face of the result came from (in face order).
|
||||
pub fn split_faces_with_map(body: &Body, faces: &[usize], tool: &SplitTool) -> Result<(Body, Vec<usize>)> {
|
||||
let (b, imp) = split_any(body, faces, tool)?;
|
||||
// The new edges, remembered so that export keeps the pieces apart.
|
||||
let mut lines: Vec<Vec<Vec3>> = body.splits.as_ref().map(|l| l.to_vec()).unwrap_or_default();
|
||||
lines.extend(imp.new_edges.iter().filter_map(|e| {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let n = if matches!(c, mt::Curve::Line(_)) { 1 } else { 64 };
|
||||
let pts: Vec<Vec3> = (0..=n).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / n as f64))).collect();
|
||||
pts.iter().all(|p| p.is_finite()).then_some(pts)
|
||||
}));
|
||||
let b = b.with_split_lines(Some(std::sync::Arc::new(lines)));
|
||||
if let Some(missed) = faces.iter().find(|f| imp.made.get(f).copied().unwrap_or(0) == 0) {
|
||||
return Err(KernelError::Invalid(format!("the tool does not cross face {missed}")));
|
||||
}
|
||||
@@ -867,7 +877,7 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
|
||||
}
|
||||
let shells = std::mem::take(&mut imp.shells);
|
||||
let solid = mt::Solid::new_unchecked(shells.into_iter().map(mt::Shell::from).collect());
|
||||
Ok((Body { solid: std::sync::Arc::new(solid), mesh: None, color: body.color, paint: None }, imp))
|
||||
Ok((Body { solid: std::sync::Arc::new(solid), mesh: None, color: body.color, paint: None, splits: body.splits.clone() }, imp))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1156,6 +1166,42 @@ mod tests {
|
||||
assert!(rel(v, 6000.0 - std::f64::consts::PI * 4.0 * 30.0) < 1e-3, "{v}");
|
||||
}
|
||||
|
||||
/// Faces after a STEP round trip.
|
||||
fn round_trip_faces(b: &Body) -> usize {
|
||||
let ex = crate::ExportBody { name: "B".into(), body: b, color: None };
|
||||
let text = crate::step_export_bodies(&[ex], &crate::StepHeader::default()).unwrap();
|
||||
let imp = crate::step_import(&text).unwrap();
|
||||
imp.bodies.iter().map(|x| x.body.face_count()).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_keeps_faces_split_on_purpose() {
|
||||
// A box top split by a plane, and a cylinder's side split by a plane across its axis.
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
|
||||
let pl = Plane::new(Vec3::new(4.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
|
||||
let s = split_faces(&b, &[top(&b, 30.0)], &SplitTool::Plane(pl)).unwrap();
|
||||
assert_eq!(s.face_count(), 7);
|
||||
assert_eq!(round_trip_faces(&s), 7);
|
||||
let c = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
|
||||
let side = c.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_none()).unwrap();
|
||||
let mid = Plane::new(Vec3::new(0.0, 0.0, 5.0), Vec3::X, Vec3::Y).unwrap();
|
||||
let cs = split_faces(&c, &[side], &SplitTool::Plane(mid)).unwrap();
|
||||
let n = cs.face_count();
|
||||
assert!(n > c.face_count());
|
||||
// The pieces stay apart; the cylinder's incidental seam pieces still join as before.
|
||||
assert_eq!(round_trip_faces(&cs), round_trip_faces(&c) + 1);
|
||||
// Without the record the export joins the top's pieces again (as it does for pieces a
|
||||
// boolean left).
|
||||
assert_eq!(round_trip_faces(&s.clone().with_split_lines(None)), 6);
|
||||
// A split after a split keeps both lines.
|
||||
let pl2 = Plane::new(Vec3::new(0.0, 12.0, 0.0), Vec3::X, Vec3::Z).unwrap();
|
||||
let tops: Vec<usize> =
|
||||
s.faces(0.01).unwrap().iter().enumerate().filter(|(_, f)| (f.centroid.z - 30.0).abs() < 1e-6).map(|(i, _)| i).collect();
|
||||
let s2 = split_faces(&s, &tops, &SplitTool::Plane(pl2)).unwrap();
|
||||
assert_eq!(s2.face_count(), 9);
|
||||
assert_eq!(round_trip_faces(&s2), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_curve_splits_a_cylinder_side_and_a_circle_splits_a_top() {
|
||||
let c = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
|
||||
@@ -1184,7 +1230,8 @@ mod tests {
|
||||
let mut tops: Vec<f64> = s.faces(0.01).unwrap().iter().filter(|f| (f.centroid.z - 20.0).abs() < 1e-6).map(|f| f.area).collect();
|
||||
tops.sort_by(f64::total_cmp);
|
||||
let pi = std::f64::consts::PI;
|
||||
assert!(rel(tops[0], pi * 16.0) < 1e-3 && rel(tops[1], pi * 84.0) < 1e-3, "{tops:?}");
|
||||
// (Face areas come from meshes: within their sag, which depends on where seams lie.)
|
||||
assert!(rel(tops[0], pi * 16.0) < 2e-3 && rel(tops[1], pi * 84.0) < 2e-3, "{tops:?}");
|
||||
}
|
||||
|
||||
/// A plane through a hole in a plate's top: the top splits in two, each piece holding
|
||||
|
||||
@@ -299,6 +299,10 @@ fn plan(face: &mt::Face) -> R<Option<Plan>> {
|
||||
_ => chains.push((seam, vec![e])),
|
||||
}
|
||||
}
|
||||
// A cone's tip: the seam runs from the ring to the apex and straight back.
|
||||
if let [(true, sm), (false, ring)] = chains.as_slice() {
|
||||
return apex_plan(face, &s, (d, mid, period), sm, ring, &wires, outer_idx).map(Some);
|
||||
}
|
||||
let [(true, s1), (false, r1), (true, s2), (false, r2)] = chains.as_slice() else {
|
||||
return Err(format!("unsupported seam loop layout ({} runs)", chains.len()));
|
||||
};
|
||||
@@ -358,6 +362,72 @@ fn plan(face: &mt::Face) -> R<Option<Plan>> {
|
||||
Ok(Some(Plan { cuts: c1.into_iter().chain(c2).collect(), faces: (fa, fb) }))
|
||||
}
|
||||
|
||||
/// Split a face closing at an apex (loop: seam out to the apex, seam back, the ring) along the
|
||||
/// iso-line opposite the seam, from the ring to the apex.
|
||||
fn apex_plan(
|
||||
face: &mt::Face,
|
||||
s: &mt::Surface,
|
||||
(d, mid, period): (Dir, f64, f64),
|
||||
sm: &[mt::Edge],
|
||||
ring: &[mt::Edge],
|
||||
wires: &[mt::Wire],
|
||||
outer_idx: usize,
|
||||
) -> R<Plan> {
|
||||
let h = sm.len() / 2;
|
||||
if !sm.len().is_multiple_of(2) || h == 0 {
|
||||
return Err("seam edges are not used back and forth".into());
|
||||
}
|
||||
let (out, back) = (sm.get(..h).ok_or("seam")?, sm.get(h..).ok_or("seam")?);
|
||||
if out.iter().map(mt::Edge::id).collect::<Vec<_>>() != back.iter().rev().map(mt::Edge::id).collect::<Vec<_>>() {
|
||||
return Err("seam edges are not used back and forth".into());
|
||||
}
|
||||
let apex = out.last().ok_or("seam")?.back().clone();
|
||||
let (k, t, q) = crossing(s, d, ring, mid, period)?;
|
||||
let seam_line: Vec<mt::Point3> = out.iter().flat_map(|e| samples(&e.oriented_curve(), 16).into_iter().map(|x| x.1)).collect();
|
||||
let iso = iso_curve(s, d, mid, q, apex.point(), &seam_line)?;
|
||||
let (ra, rb, c, vq) = split_chain(ring, k, t, q)?;
|
||||
let e = mt::Edge::new_unchecked(&vq, &apex, iso);
|
||||
// Face A: out to the apex, down the split line, the ring's second part; face B the rest.
|
||||
let mut wa: Vec<mt::Edge> = out.to_vec();
|
||||
wa.push(e.inverse());
|
||||
wa.extend(rb.iter().cloned());
|
||||
let mut wb: Vec<mt::Edge> = vec![e];
|
||||
wb.extend(back.iter().cloned());
|
||||
wb.extend(ra);
|
||||
let (wire_a, wire_b): (mt::Wire, mt::Wire) = (wa.into(), wb.into());
|
||||
if !wire_a.is_closed() || !wire_b.is_closed() {
|
||||
return Err("split loops do not close".into());
|
||||
}
|
||||
let side_a = rb
|
||||
.iter()
|
||||
.flat_map(|e| samples(&e.oriented_curve(), 4))
|
||||
.filter_map(|(_, p)| uv_of(s, p).map(|uv| coord(d, uv)))
|
||||
.find(|w| (w - mid).abs() > period * 1e-3)
|
||||
.map(|w| w < mid)
|
||||
.ok_or("cannot place the split halves")?;
|
||||
let (mut ha, mut hb) = (vec![wire_a], vec![wire_b]);
|
||||
for (i, w) in wires.iter().enumerate() {
|
||||
if i == outer_idx {
|
||||
continue;
|
||||
}
|
||||
let ws: Vec<f64> = w.edge_iter().flat_map(|e| samples(&e.curve(), 4)).filter_map(|(_, p)| uv_of(s, p).map(|uv| coord(d, uv))).collect();
|
||||
if ws.iter().all(|x| (*x < mid) == side_a) {
|
||||
ha.push(w.clone());
|
||||
} else if ws.iter().all(|x| (*x < mid) != side_a) {
|
||||
hb.push(w.clone());
|
||||
} else {
|
||||
return Err("a hole crosses the split line".into());
|
||||
}
|
||||
}
|
||||
let mut fa = mt::Face::new_unchecked(ha, s.clone());
|
||||
let mut fb = mt::Face::new_unchecked(hb, s.clone());
|
||||
if !face.orientation() {
|
||||
fa.invert();
|
||||
fb.invert();
|
||||
}
|
||||
Ok(Plan { cuts: c.into_iter().collect(), faces: (fa, fb) })
|
||||
}
|
||||
|
||||
/// Replace cut edges in a face's loops.
|
||||
fn recut(face: &mt::Face, cuts: &[(mt::Edge, (mt::Edge, mt::Edge))]) -> mt::Face {
|
||||
let touches = face.absolute_boundaries().iter().flat_map(|w| w.edge_iter()).any(|e| cuts.iter().any(|(c, _)| c.id() == e.id()));
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::step_in::p21::{self, Param};
|
||||
use crate::{KernelError, Result};
|
||||
@@ -832,7 +834,13 @@ fn face_edges(ex: &p21::Exchange, face: &p21::Entity) -> Option<Vec<Vec<(u64, bo
|
||||
/// Faces that truck splits (a revolution in two or three turns) joined back into one face per
|
||||
/// analytic surface, by dropping the edges between them. Faces whose union would lose all its
|
||||
/// edges, or whose leftover edges do not close into loops, stay apart.
|
||||
fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>) -> HashMap<u64, V3> {
|
||||
fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>, keep: &[Vec<Vec3>]) -> HashMap<u64, V3> {
|
||||
// An edge on a line the user split along: every sample within a hair of the polyline.
|
||||
let on_split = |ex: &p21::Exchange, e: u64| -> bool {
|
||||
let Some(pts) = edge_samples(ex, e) else { return false };
|
||||
let pts: Vec<Vec3> = pts.iter().map(|q| Vec3::new(q[0], q[1], q[2])).collect();
|
||||
crate::heal::on_split_line(&pts, keep)
|
||||
};
|
||||
let mut merged_surfaces = std::collections::HashSet::new();
|
||||
let mut next = ex.entities.keys().max().copied().unwrap_or(0) + 1;
|
||||
let shells: Vec<u64> = ex.entities.iter().filter(|(_, e)| matches!(e.name(), "CLOSED_SHELL" | "OPEN_SHELL")).map(|(i, _)| *i).collect();
|
||||
@@ -923,7 +931,7 @@ fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>)
|
||||
let all: Vec<(u64, bool)> = ea.into_iter().chain(eb).flatten().collect();
|
||||
let shared: std::collections::HashSet<u64> =
|
||||
all.iter().filter(|(e, d)| all.iter().any(|(e2, d2)| e2 == e && d2 != d)).map(|(e, _)| *e).collect();
|
||||
if shared.is_empty() {
|
||||
if shared.is_empty() || shared.iter().any(|e| on_split(ex, *e)) {
|
||||
continue;
|
||||
}
|
||||
// Chain oriented edges into loops by vertices (None: they do not close).
|
||||
@@ -1200,7 +1208,8 @@ fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap<u64, V
|
||||
}
|
||||
}
|
||||
let before = shell_face_count(&ex);
|
||||
let merged = if merge { merge_split_faces(&mut ex, &mut paint) } else { Default::default() };
|
||||
let keep = b.split_lines().map(|l| l.as_slice()).unwrap_or(&[]);
|
||||
let merged = if merge { merge_split_faces(&mut ex, &mut paint, keep) } else { Default::default() };
|
||||
MERGES.with(|m| m.set(m.get() + before.saturating_sub(shell_face_count(&ex))));
|
||||
Ok((ex, merged, paint))
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ impl Body {
|
||||
/// A body of faces as they are (no orientation fix: a surface has no inside).
|
||||
fn surface_body(faces: Vec<mt::Face>) -> Body {
|
||||
let shell = mt::Shell::from(faces);
|
||||
Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None }
|
||||
Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None, splits: None }
|
||||
}
|
||||
|
||||
/// A body from faces: a solid when they close, else a surface.
|
||||
@@ -31,7 +31,7 @@ fn body_of(faces: Vec<mt::Face>) -> Result<Body> {
|
||||
if shell.shell_condition() == mt::ShellCondition::Closed {
|
||||
Body::new(mt::Solid::new_unchecked(vec![shell]))
|
||||
} else {
|
||||
Ok(Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None })
|
||||
Ok(Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None, splits: None })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -782,6 +782,27 @@ fn shell_non_convex() {
|
||||
assert!(rel(v, outer - cavity) < 1e-6, "{v} vs {}", outer - cavity);
|
||||
}
|
||||
|
||||
/// A basin: a slab with a tapered pocket and a 4 mm front rim, shelled from below. A wall
|
||||
/// thicker than the rim (with the pocket's slope) would turn the top face inside out: refused,
|
||||
/// not a body with wrong mass properties.
|
||||
#[test]
|
||||
fn shell_thicker_than_a_rim_is_refused() {
|
||||
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
|
||||
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
|
||||
let at = |z: f64| Plane::new(Vec3::new(0.0, 0.0, z), Vec3::X, Vec3::Y).unwrap();
|
||||
let pocket = loft(&[(at(30.0), rect(20.0, 4.0, 80.0, 50.0)), (at(10.0), rect(30.0, 20.0, 70.0, 40.0))]).unwrap();
|
||||
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
|
||||
let open = [Vec3::new(50.0, 30.0, 0.0)];
|
||||
let e = shell(&basin, &open, 6.0).unwrap_err().to_string();
|
||||
assert!(e.contains("rim or ledge"), "{e}");
|
||||
let (whole, thin) = (measure(&basin).unwrap(), measure(&shell(&basin, &open, 1.0).unwrap()).unwrap());
|
||||
assert!(thin.volume > 0.0 && thin.volume < whole.volume, "{} vs {}", thin.volume, whole.volume);
|
||||
let (lo, hi) = (thin.bbox.min, thin.bbox.max);
|
||||
let c = thin.centroid;
|
||||
assert!(c.x > lo.x && c.x < hi.x && c.y > lo.y && c.y < hi.y && c.z > lo.z && c.z < hi.z, "{c:?}");
|
||||
assert!((c.x - 50.0).abs() < 1e-3, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_drilled_hole() {
|
||||
@@ -1297,3 +1318,120 @@ fn tight_bend_counts() {
|
||||
let m = measure(&b).unwrap().merged;
|
||||
assert_eq!((m.faces, m.edges, m.vertices), (5, 6, 4), "{m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn boolean_identity_survey() {
|
||||
let n: u64 = std::env::var("N").ok().and_then(|x| x.parse().ok()).unwrap_or(100);
|
||||
let seeds: Vec<u64> = match std::env::var("SEEDS") {
|
||||
Ok(s) => s.split(',').filter_map(|x| x.parse().ok()).collect(),
|
||||
Err(_) => (0..n).collect(),
|
||||
};
|
||||
let mut bad = 0;
|
||||
for seed in seeds {
|
||||
let c = crate::fuzz::boolean_case(seed);
|
||||
if c.outcome != "ok" {
|
||||
bad += 1;
|
||||
println!("{seed}: {} {} | {:?} | {:?}", c.outcome, c.detail, c.a, c.b);
|
||||
}
|
||||
}
|
||||
println!("{bad}/{n} failed");
|
||||
}
|
||||
|
||||
/// A quarter turn of a rectangle with an edge on the axis: no zero-area face along the axis, so
|
||||
/// a cylinder can be cut through the corner (it failed in every boolean attempt before).
|
||||
#[test]
|
||||
fn partial_revolve_on_the_axis() {
|
||||
let sq =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0), Vec2::new(20.0, 7.5), Vec2::new(0.0, 7.5)]), holes: vec![] };
|
||||
let pie = revolve(&Plane::XZ, &[sq], Vec2::ZERO, Vec2::Y, std::f64::consts::FRAC_PI_2).unwrap().pop().unwrap();
|
||||
assert!(pie.validity().is_empty(), "{:?}", pie.validity());
|
||||
let v = measure(&pie).unwrap().volume;
|
||||
assert!((v - PI * 400.0 / 4.0 * 7.5).abs() < 1e-3 * v, "{v}");
|
||||
assert_eq!(measure(&pie).unwrap().merged.faces, 5);
|
||||
let cyl = cylinder(Vec3::new(2.5, 0.0, 5.0), Vec3::Z, 3.75, 22.5).unwrap();
|
||||
let cut = boolean(&pie, &cyl, BoolOp::Cut).unwrap().unwrap();
|
||||
assert!(cut.validity().is_empty());
|
||||
let vc = measure(&cut).unwrap().volume;
|
||||
assert!(vc < v - 1.0 && vc > v - PI * 3.75 * 3.75 * 2.5, "{vc}");
|
||||
}
|
||||
|
||||
/// Boolean identities on primitives the robustness runs found failing: cylinders tangent to box
|
||||
/// faces from inside and out, crossing cylinders touching along a line (their seams used to lie
|
||||
/// exactly where the other body cut them).
|
||||
#[test]
|
||||
fn boolean_identity_on_tangent_primitives() {
|
||||
for seed in [1, 32, 169] {
|
||||
let c = crate::fuzz::boolean_case(seed);
|
||||
assert_eq!(c.outcome, "ok", "seed {seed}: {} {:?} {:?}", c.detail, c.a, c.b);
|
||||
}
|
||||
}
|
||||
|
||||
/// A body in two pieces: booleans go a piece at a time.
|
||||
#[test]
|
||||
fn boolean_of_a_body_in_pieces() {
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
|
||||
let b = box_solid(Vec3::new(20.0, 0.0, 0.0), Vec3::new(30.0, 10.0, 10.0)).unwrap();
|
||||
let two = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
|
||||
assert_eq!(two.shell_count(), 2);
|
||||
let tool = cylinder(Vec3::new(-5.0, 5.0, 5.0), Vec3::X, 2.0, 40.0).unwrap();
|
||||
let cut = boolean(&two, &tool, BoolOp::Cut).unwrap().unwrap();
|
||||
assert_eq!(cut.shell_count(), 2);
|
||||
assert!(cut.validity().is_empty(), "{:?}", cut.validity());
|
||||
let v = measure(&cut).unwrap().volume;
|
||||
assert!((v - (2000.0 - 2.0 * PI * 4.0 * 10.0)).abs() < 1.0, "{v}");
|
||||
}
|
||||
|
||||
/// Measuring a body of 5000 faces (a modelled thread is like that) counts right (it took 13 s,
|
||||
/// a per-face recomputation of the body's size; now under a second).
|
||||
#[test]
|
||||
fn measure_of_a_body_with_many_faces() {
|
||||
let n = 5000;
|
||||
let pts: Vec<Vec2> = (0..n)
|
||||
.map(|i| {
|
||||
let a = std::f64::consts::TAU * i as f64 / n as f64;
|
||||
Vec2::new(20.0 * a.cos(), 20.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap();
|
||||
let m = measure(&b).unwrap();
|
||||
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (n + 2, 3 * n, 2 * n), "{:?}", m.merged);
|
||||
}
|
||||
|
||||
/// The time it takes (run alone, the suite's other tests share the machine):
|
||||
/// `cargo test --release -p solvecraft-kernel perf_measure_5000_faces -- --ignored`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn perf_measure_5000_faces() {
|
||||
let pts: Vec<Vec2> = (0..5000)
|
||||
.map(|i| {
|
||||
let a = std::f64::consts::TAU * i as f64 / 5000.0;
|
||||
Vec2::new(20.0 * a.cos(), 20.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap();
|
||||
let t = std::time::Instant::now();
|
||||
measure(&b).unwrap();
|
||||
assert!(t.elapsed().as_secs_f64() < 1.5, "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// A lip on a rounded, shelled rim (#29): the band along the rim's inner (or outer) edge raised,
|
||||
/// its walls continuing the walls below. Exactly the band's area times its height is added.
|
||||
#[test]
|
||||
fn raise_band_on_a_rounded_rim() {
|
||||
let b = box_solid(Vec3::new(-40.0, -30.0, 0.0), Vec3::new(40.0, 30.0, 30.0)).unwrap();
|
||||
let f =
|
||||
fillet(&b, &[Vec3::new(40.0, 30.0, 15.0), Vec3::new(-40.0, 30.0, 15.0), Vec3::new(-40.0, -30.0, 15.0), Vec3::new(40.0, -30.0, 15.0)], 6.0)
|
||||
.unwrap();
|
||||
let sh = shell(&f, &[Vec3::new(0.0, 0.0, 30.0)], 2.0).unwrap();
|
||||
let v0 = measure(&sh).unwrap().volume;
|
||||
// A rounded rectangle's area.
|
||||
let ar = |w: f64, h: f64, r: f64| w * h - (4.0 - PI) * r * r;
|
||||
for outside in [false, true] {
|
||||
let lip = crate::lip::raise_band(&sh, Vec3::new(0.0, 29.0, 30.0), 1.0, 1.5, outside).unwrap();
|
||||
assert!(lip.validity().is_empty(), "{:?}", lip.validity());
|
||||
let band = if outside { ar(80.0, 60.0, 6.0) - ar(78.0, 58.0, 5.0) } else { ar(78.0, 58.0, 5.0) - ar(76.0, 56.0, 4.0) };
|
||||
let v = measure(&lip).unwrap().volume;
|
||||
assert!((v - (v0 + band * 1.5)).abs() < 1e-5 * v, "outside {outside}: {v} vs {}", v0 + band * 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ impl Surf {
|
||||
}
|
||||
fn same(&self, o: &Surf, tol: f64) -> bool {
|
||||
match (*self, *o) {
|
||||
(Surf::Plane { n, d }, Surf::Plane { n: n2, d: d2 }) => n.dot(n2) > 1.0 - 1e-6 && (d - d2).abs() < tol,
|
||||
(Surf::Plane { n, d }, Surf::Plane { n: n2, d: d2 }) => n.dot(n2) > 1.0 - 1e-9 && (d - d2).abs() < tol,
|
||||
(Surf::Cylinder { axis, p, r }, Surf::Cylinder { axis: a2, p: p2, r: r2 }) => {
|
||||
let off = p2 - p;
|
||||
axis.dot(a2).abs() > 1.0 - 1e-6 && (r - r2).abs() < tol && (off - axis * off.dot(axis)).len() < tol
|
||||
@@ -382,13 +382,14 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
*a += (q - p).cross(r - p).len() * 0.5;
|
||||
}
|
||||
}
|
||||
let degenerate: Vec<bool> = area.iter().map(|a| *a < (b.size() * 1e-6).powi(2)).collect();
|
||||
let tiny = (b.size() * 1e-6).powi(2);
|
||||
let degenerate: Vec<bool> = area.iter().map(|a| *a < tiny).collect();
|
||||
let is_deg = |f: usize| degenerate.get(f).copied().unwrap_or(false);
|
||||
|
||||
// Edge → faces, edge → vertices. Vertices and edges lying on each other count once (two
|
||||
// tangent holes touch along a line that both bodies' pieces carry): welded by position,
|
||||
// edges by their ends and the point halfway along them.
|
||||
let q = tol * 10.0;
|
||||
let q = (b.size() * 1e-6).max(1e-9);
|
||||
let cell = |p: Vec3| ((p.x / q).round() as i64, (p.y / q).round() as i64, (p.z / q).round() as i64);
|
||||
let mut edge_faces: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
let mut edge_verts: HashMap<usize, (usize, usize)> = HashMap::new();
|
||||
@@ -447,10 +448,14 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
});
|
||||
smooth && seen > 0
|
||||
};
|
||||
// Pieces of a face split on purpose count apart.
|
||||
let b_keep = b.split_keep();
|
||||
let keep_split = !b_keep.is_empty();
|
||||
let mut fp: Vec<usize> = (0..nf).collect();
|
||||
for (ei, faces) in &edge_faces {
|
||||
let other = |f: usize| matches!(surfs.get(f), Some(Surf::Other(_)));
|
||||
if let [a, b] = faces[..]
|
||||
&& !(keep_split && poly(*ei).is_some_and(|pl| crate::heal::on_split_line(pl, b_keep)))
|
||||
&& (same_surface(&surfs, &verts, poly(*ei), a, b, tol * 10.0)
|
||||
|| (other(a) && other(b) && exact_smooth(a, b, poly(*ei)) && {
|
||||
let (Some(va), Some(vb)) = (verts.get(a), verts.get(b)) else { return Ok(TopoCounts::default()) };
|
||||
@@ -524,7 +529,7 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
}
|
||||
}
|
||||
let mut ep: Vec<usize> = (0..remaining.len()).collect();
|
||||
let mut merge_vertex: Vec<usize> = Vec::new();
|
||||
let mut merge_vertex: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||
for (v, es) in ° {
|
||||
if let [i, j] = es[..]
|
||||
&& let (Some(a), Some(b)) = (remaining.get(i), remaining.get(j))
|
||||
@@ -534,7 +539,7 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
if let Some(s) = ep.get_mut(ri) {
|
||||
*s = rj;
|
||||
}
|
||||
merge_vertex.push(*v);
|
||||
merge_vertex.insert(*v);
|
||||
}
|
||||
}
|
||||
let mut edge_groups: BTreeMap<usize, bool> = BTreeMap::new(); // root → has a real end vertex
|
||||
|
||||
@@ -181,7 +181,42 @@ impl CameraAnim {
|
||||
}
|
||||
}
|
||||
|
||||
/// Orbit camera around `target` (Z up).
|
||||
/// Which world axis points up in the view (#3): Z (the default) or Y, as most STEP files from
|
||||
/// other systems are modelled.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UpAxis {
|
||||
#[default]
|
||||
Z,
|
||||
Y,
|
||||
}
|
||||
|
||||
impl UpAxis {
|
||||
/// The world's up direction.
|
||||
pub fn dir(self) -> Vec3 {
|
||||
match self {
|
||||
UpAxis::Z => Vec3::Z,
|
||||
UpAxis::Y => Vec3::Y,
|
||||
}
|
||||
}
|
||||
/// A world vector in the camera's Z-up frame (yaw turns about the frame's Z).
|
||||
pub fn to_frame(self, v: Vec3) -> Vec3 {
|
||||
match self {
|
||||
UpAxis::Z => v,
|
||||
UpAxis::Y => Vec3::new(v.x, -v.z, v.y),
|
||||
}
|
||||
}
|
||||
/// A Z-up frame vector in the world.
|
||||
pub fn from_frame(self, v: Vec3) -> Vec3 {
|
||||
match self {
|
||||
UpAxis::Z => v,
|
||||
UpAxis::Y => Vec3::new(v.x, v.z, -v.y),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Orbit camera around `target`. Yaw and pitch are about the up axis (Z, or Y for a Y-up
|
||||
/// world): with Y up, the front view looks along −Z and the top view down −Y.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Camera {
|
||||
pub target: Vec3,
|
||||
@@ -193,12 +228,15 @@ pub struct Camera {
|
||||
pub distance: f64,
|
||||
/// Vertical field of view (radians); 0 = orthographic.
|
||||
pub fov: f64,
|
||||
/// The world's up axis.
|
||||
#[serde(default)]
|
||||
pub up: UpAxis,
|
||||
}
|
||||
|
||||
impl Default for Camera {
|
||||
fn default() -> Self {
|
||||
let (yaw, pitch) = StandardView::Iso.angles();
|
||||
Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0 }
|
||||
Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0, up: UpAxis::Z }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +244,19 @@ impl Camera {
|
||||
/// Direction from the target toward the eye.
|
||||
pub fn back(&self) -> Vec3 {
|
||||
let (cp, sp) = (self.pitch.cos(), self.pitch.sin());
|
||||
Vec3::new(-self.yaw.sin() * cp, -self.yaw.cos() * cp, sp).normalized().unwrap_or(Vec3::Z)
|
||||
self.up.from_frame(Vec3::new(-self.yaw.sin() * cp, -self.yaw.cos() * cp, sp).normalized().unwrap_or(Vec3::Z))
|
||||
}
|
||||
/// The world's up direction.
|
||||
pub fn up_dir(&self) -> Vec3 {
|
||||
self.up.dir()
|
||||
}
|
||||
/// A world vector in the camera's Z-up frame (where yaw and pitch are measured).
|
||||
pub fn to_frame(&self, v: Vec3) -> Vec3 {
|
||||
self.up.to_frame(v)
|
||||
}
|
||||
/// A Z-up frame vector in the world.
|
||||
pub fn from_frame(&self, v: Vec3) -> Vec3 {
|
||||
self.up.from_frame(v)
|
||||
}
|
||||
pub fn eye(&self) -> Vec3 {
|
||||
self.target + self.back() * self.distance
|
||||
@@ -214,8 +264,8 @@ impl Camera {
|
||||
/// Camera basis: right, up, back (orthonormal).
|
||||
pub fn basis(&self) -> (Vec3, Vec3, Vec3) {
|
||||
let b = self.back();
|
||||
let right = Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0);
|
||||
let up = b.cross(right).normalized().unwrap_or(Vec3::Z);
|
||||
let right = self.up.from_frame(Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0));
|
||||
let up = b.cross(right).normalized().unwrap_or(self.up.dir());
|
||||
(right, up, b)
|
||||
}
|
||||
pub fn view(&self) -> Mat4 {
|
||||
@@ -317,7 +367,7 @@ impl Camera {
|
||||
/// The same camera looking from direction `back` (target → eye). Straight up or down
|
||||
/// keeps the yaw, rounded to the nearest quarter turn so the view stays square.
|
||||
pub fn looking_from(&self, back: Vec3) -> Camera {
|
||||
let Some(b) = back.normalized() else { return *self };
|
||||
let Some(b) = self.up.to_frame(back).normalized() else { return *self };
|
||||
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
|
||||
let pitch = b.z.clamp(-1.0, 1.0).asin().clamp(-lim, lim);
|
||||
let yaw = if b.x.hypot(b.y) < 1e-6 {
|
||||
@@ -355,6 +405,7 @@ impl Camera {
|
||||
pitch,
|
||||
distance: a.distance + (b.distance - a.distance) * s,
|
||||
fov: a.fov + (b.fov - a.fov) * s,
|
||||
up: b.up,
|
||||
}
|
||||
}
|
||||
pub fn is_valid(&self) -> bool {
|
||||
@@ -508,4 +559,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// With Y up: the front view looks along −Z (eye on +Z), the top view looks down −Y with X
|
||||
/// to the right, orbiting turns about Y, and the picture's up is the world's up.
|
||||
#[test]
|
||||
fn y_up_views() {
|
||||
let mut c = Camera { up: UpAxis::Y, distance: 100.0, ..Default::default() };
|
||||
c.set_view(StandardView::Front);
|
||||
assert!(c.back().dist(Vec3::Z) < 1e-9, "front eye {:?}", c.back());
|
||||
let (r, u, b) = c.basis();
|
||||
assert!(r.dist(Vec3::X) < 1e-9 && u.dist(Vec3::Y) < 1e-9 && b.dist(Vec3::Z) < 1e-9);
|
||||
c.set_view(StandardView::Top);
|
||||
assert!(c.back().dist(Vec3::Y) < 1e-5, "top eye {:?}", c.back());
|
||||
assert!(c.basis().0.dist(Vec3::X) < 1e-9);
|
||||
// Orbiting sideways keeps the eye's height above the XZ ground.
|
||||
c.set_view(StandardView::Iso);
|
||||
let h = c.back().y;
|
||||
c.orbit(120.0, 0.0);
|
||||
assert!((c.back().y - h).abs() < 1e-12);
|
||||
// Looking from a world direction lands on it.
|
||||
let d = Vec3::new(1.0, 1.0, 1.0).normalized().unwrap();
|
||||
assert!(c.looking_from(d).back().dist(d) < 1e-9);
|
||||
// Frames round-trip.
|
||||
let v = Vec3::new(1.0, 2.0, 3.0);
|
||||
assert!(UpAxis::Y.from_frame(UpAxis::Y.to_frame(v)).dist(v) < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
mod camera;
|
||||
mod raster;
|
||||
|
||||
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, ease_in_out};
|
||||
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out};
|
||||
pub use raster::{Canvas, Rgb, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent};
|
||||
|
||||
@@ -19,6 +19,8 @@ impl Rgb {
|
||||
pub struct SceneMesh {
|
||||
pub mesh: Arc<Mesh>,
|
||||
pub color: Rgb,
|
||||
/// Faces in colours of their own: (B-rep face index, colour).
|
||||
pub face_colors: Vec<(u32, Rgb)>,
|
||||
}
|
||||
|
||||
pub struct SceneLine {
|
||||
@@ -102,13 +104,18 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
let view_dir = cam.back();
|
||||
for sm in &scene.meshes {
|
||||
let m = &sm.mesh;
|
||||
let base = sm.color.f();
|
||||
let body = sm.color.f();
|
||||
let pts: Vec<Option<[f64; 3]>> = m.positions.iter().map(|p| project(*p)).collect();
|
||||
for t in &m.triangles {
|
||||
for (ti, t) in m.triangles.iter().enumerate() {
|
||||
let face = m.tri_face.get(ti).copied();
|
||||
let base = sm.face_colors.iter().find(|(f, _)| Some(*f) == face).map_or(body, |(_, c)| c.f());
|
||||
let (Some(Some(a)), Some(Some(b)), Some(Some(cc))) = (pts.get(t[0] as usize), pts.get(t[1] as usize), pts.get(t[2] as usize)) else {
|
||||
continue;
|
||||
};
|
||||
let ns: Vec<Vec3> = t.iter().map(|k| m.normals.get(*k as usize).copied().unwrap_or(Vec3::Z)).collect();
|
||||
// A curved surface (its normals turn across the triangle) gets a silhouette line
|
||||
// where it turns away from the eye.
|
||||
let curved = ns.iter().any(|n| n.dist(ns[0]) > 1e-6);
|
||||
let (x0, x1) = (a[0].min(b[0]).min(cc[0]).floor().max(0.0), a[0].max(b[0]).max(cc[0]).ceil().min(sw as f64 - 1.0));
|
||||
let (y0, y1) = (a[1].min(b[1]).min(cc[1]).floor().max(0.0), a[1].max(b[1]).max(cc[1]).ceil().min(sh as f64 - 1.0));
|
||||
if !(x0 <= x1 && y0 <= y1) {
|
||||
@@ -134,7 +141,15 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
*d = z;
|
||||
}
|
||||
let n = (ns[0] * w0 + ns[1] * w1 + ns[2] * w2).normalized().unwrap_or(Vec3::Z);
|
||||
c.put(x, y, shade(n, view_dir, base), 1.0);
|
||||
let mut col = shade(n, view_dir, base);
|
||||
let ndv = n.dot(view_dir).abs();
|
||||
if curved && ndv < 0.14 {
|
||||
let a = 1.0 - ndv / 0.14;
|
||||
for (k, e) in col.iter_mut().zip([0.07, 0.08, 0.10]) {
|
||||
*k = *k * (1.0 - a) + e * a;
|
||||
}
|
||||
}
|
||||
c.put(x, y, col, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,8 +263,12 @@ mod tests {
|
||||
seams: vec![],
|
||||
edge_faces: vec![],
|
||||
};
|
||||
let scene =
|
||||
Scene { meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(200, 30, 30) }], lines: vec![], background: None, radius: 20.0 };
|
||||
let scene = Scene {
|
||||
meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(200, 30, 30), face_colors: Vec::new() }],
|
||||
lines: vec![],
|
||||
background: None,
|
||||
radius: 20.0,
|
||||
};
|
||||
let mut cam = Camera { distance: 50.0, ..Default::default() };
|
||||
cam.set_view(StandardView::Front);
|
||||
let c = render(&scene, &cam, 64, 64);
|
||||
|
||||
@@ -21,36 +21,87 @@ use solvecraft_engine::geom::{Vec2, Vec3};
|
||||
use crate::SolveApp;
|
||||
use crate::control::ControlResponse;
|
||||
|
||||
/// How fast the pointer moves.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
/// How fast the pointer moves: 1× to 5×, or Instant (no animation: the command runs at once
|
||||
/// and the pointer jumps to where it acted, with a click ripple). Saved and sent as a number,
|
||||
/// or "instant"; the old "slow" and "normal" read as 1×.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Speed {
|
||||
/// No animation: the command runs at once.
|
||||
Times(f64),
|
||||
Instant,
|
||||
#[default]
|
||||
Normal,
|
||||
Slow,
|
||||
}
|
||||
|
||||
impl Default for Speed {
|
||||
fn default() -> Self {
|
||||
Speed::Times(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Speed {
|
||||
pub const ALL: [Speed; 3] = [Speed::Instant, Speed::Normal, Speed::Slow];
|
||||
pub fn label(self) -> &'static str {
|
||||
/// From a number (1–5) or a name ("instant"; "slow" and "normal" are 1×).
|
||||
pub fn from_value(v: &Value) -> Option<Speed> {
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64().filter(|x| x.is_finite() && (1.0..=5.0).contains(x)).map(Speed::Times),
|
||||
Value::String(s) => match s.to_ascii_lowercase().as_str() {
|
||||
"instant" => Some(Speed::Instant),
|
||||
"slow" | "normal" => Some(Speed::Times(1.0)),
|
||||
"fast" => Some(Speed::Times(2.0)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> String {
|
||||
match self {
|
||||
Speed::Instant => "Instant",
|
||||
Speed::Normal => "Normal",
|
||||
Speed::Slow => "Slow",
|
||||
Speed::Instant => "Instant".into(),
|
||||
Speed::Times(x) => {
|
||||
let s = format!("{x:.1}");
|
||||
format!("{}×", s.trim_end_matches(".0"))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Seconds to glide to a stop, and to click there.
|
||||
fn times(self) -> (f64, f64) {
|
||||
match self {
|
||||
Speed::Instant => (0.0, 0.0),
|
||||
Speed::Normal => (0.45, 0.25),
|
||||
Speed::Slow => (0.9, 0.45),
|
||||
Speed::Times(x) => {
|
||||
let x = x.clamp(1.0, 5.0);
|
||||
(0.45 / x, 0.25 / x)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Speed {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
Speed::Instant => s.serialize_str("instant"),
|
||||
Speed::Times(x) => s.serialize_f64(*x),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Speed {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
Ok(Speed::from_value(&v).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
/// The speed as a slider: 1× to 5× in half steps, and Instant at the far right.
|
||||
pub fn speed_slider(ui: &mut egui::Ui, speed: &mut Speed) -> egui::Response {
|
||||
let mut v = match *speed {
|
||||
Speed::Times(x) => x.clamp(1.0, 5.0),
|
||||
Speed::Instant => 5.5,
|
||||
};
|
||||
let r = ui.add(
|
||||
egui::Slider::new(&mut v, 1.0..=5.5).step_by(0.5).custom_formatter(|v, _| if v > 5.25 { "Instant".into() } else { Speed::Times(v).label() }),
|
||||
);
|
||||
if r.changed() {
|
||||
*speed = if v > 5.25 { Speed::Instant } else { Speed::Times(v) };
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The settings (kept with the other view settings).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -176,7 +227,7 @@ fn walk<'a>(v: &'a Value, key: Option<&'a str>, f: &mut dyn FnMut(Option<&'a str
|
||||
/// back when it should simply run.
|
||||
pub fn intercept(app: &mut SolveApp, method: &str, params: &Value, reply: &Sender<ControlResponse>) -> bool {
|
||||
let s = app.ui.agent_cursor;
|
||||
if !s.show || s.speed == Speed::Instant || !matches!(method, "engine.execute" | "command") {
|
||||
if !s.show || !matches!(method, "engine.execute" | "command") {
|
||||
return false;
|
||||
}
|
||||
if params.get("animate").and_then(Value::as_bool) == Some(false) {
|
||||
@@ -185,6 +236,14 @@ pub fn intercept(app: &mut SolveApp, method: &str, params: &Value, reply: &Sende
|
||||
let Some(cmd) = params.get("command").or(params.get("id")).and_then(Value::as_str) else { return false };
|
||||
let inner = params.get("params").cloned().unwrap_or(json!({}));
|
||||
let stops: VecDeque<Pos2> = stops(app, cmd, &inner).into();
|
||||
if s.speed == Speed::Instant {
|
||||
// No animation: the command runs now; the pointer jumps to where it acted.
|
||||
if let Some(last) = stops.back() {
|
||||
app.agent.pos = Some(*last);
|
||||
app.agent.ripples.push((*last, app.now));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if stops.is_empty() {
|
||||
return false;
|
||||
}
|
||||
@@ -336,4 +395,25 @@ mod tests {
|
||||
// Off-screen places are skipped rather than pointed at outside the view.
|
||||
assert!(stops(&a, "solid.hole", &json!({"position": [1e6, 0, 0]})).iter().all(|p| rect.contains(*p)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speeds_read_old_and_new_settings() {
|
||||
// Saved settings from before #79: slow and normal are 1×, instant stays.
|
||||
for (v, want) in [
|
||||
(json!("slow"), Speed::Times(1.0)),
|
||||
(json!("normal"), Speed::Times(1.0)),
|
||||
(json!("instant"), Speed::Instant),
|
||||
(json!(3), Speed::Times(3.0)),
|
||||
] {
|
||||
assert_eq!(Speed::from_value(&v), Some(want), "{v}");
|
||||
let s: Settings = serde_json::from_value(json!({"show": true, "speed": v})).unwrap();
|
||||
assert_eq!(s.speed, want);
|
||||
}
|
||||
assert_eq!(Speed::from_value(&json!(9)), None);
|
||||
assert_eq!(serde_json::to_value(Speed::Instant).unwrap(), json!("instant"));
|
||||
assert_eq!(serde_json::to_value(Speed::Times(2.5)).unwrap(), json!(2.5));
|
||||
assert_eq!((Speed::Times(1.0).label(), Speed::Times(2.5).label(), Speed::Instant.label()), ("1×".into(), "2.5×".into(), "Instant".into()));
|
||||
// Faster glides take less time.
|
||||
assert!(Speed::Times(5.0).times().0 < Speed::Times(1.0).times().0);
|
||||
}
|
||||
}
|
||||
|
||||
+129
-90
@@ -17,6 +17,17 @@ use crate::context_menu::{Item, Target};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{SolveApp, icons};
|
||||
|
||||
thread_local! {
|
||||
/// The rows drawn this frame: (label, row, fold arrow area, open). For automation
|
||||
/// (`ui.at {browser | chevron}`, `ui.browser`).
|
||||
static DRAWN: std::cell::RefCell<Vec<(String, Rect, Option<Rect>, Option<bool>)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// The rows on screen: (label, row rect, fold arrow area, open).
|
||||
pub fn drawn_rows() -> Vec<(String, Rect, Option<Rect>, Option<bool>)> {
|
||||
DRAWN.with(|d| d.borrow().clone())
|
||||
}
|
||||
|
||||
/// Browser state kept between frames.
|
||||
#[derive(Default)]
|
||||
pub struct TreeState {
|
||||
@@ -48,13 +59,18 @@ pub struct TreeState {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OccMove {
|
||||
pub occurrence: u64,
|
||||
/// Distances along X, Y, Z (mm) and turns about X, Y, Z (degrees) from where it was.
|
||||
pub translate: [f64; 3],
|
||||
pub angle_deg: f64,
|
||||
pub angles: [f64; 3],
|
||||
/// The occurrence's origin when the move began: what it turns about.
|
||||
pub pivot: Option<solvecraft_engine::geom::Vec3>,
|
||||
/// The values when the current triad drag began.
|
||||
drag0: Option<([f64; 3], [f64; 3])>,
|
||||
}
|
||||
|
||||
impl OccMove {
|
||||
pub fn new(occurrence: u64) -> Self {
|
||||
OccMove { occurrence, translate: [0.0; 3], angle_deg: 0.0 }
|
||||
OccMove { occurrence, translate: [0.0; 3], angles: [0.0; 3], pivot: None, drag0: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +262,8 @@ struct RowResp {
|
||||
hovered: bool,
|
||||
/// The row's response (drag and drop).
|
||||
resp: Option<egui::Response>,
|
||||
/// The press began on the fold arrow, eye or radio (not a drag of the row).
|
||||
on_control: bool,
|
||||
}
|
||||
|
||||
fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
@@ -256,7 +274,10 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
ui.allocate_space(r.size());
|
||||
return RowResp { rect: Some(r), ..Default::default() };
|
||||
}
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag());
|
||||
// A stable id per row (not the layout position), so a press and its release stay on the
|
||||
// same row while rows above open or close.
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::hover());
|
||||
let resp = ui.interact(r, id, Sense::click_and_drag());
|
||||
// While something is dragged (an appearance swatch, drawn under the pointer) `hovered` is
|
||||
// off; the row under the pointer still counts, so a drop lands on it.
|
||||
let dragged_over = egui::DragAndDrop::has_any_payload(ui.ctx()) && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p));
|
||||
@@ -269,6 +290,27 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
let mut x = r.left() + 2.0 + row.depth as f32 * INDENT;
|
||||
let cy = r.center().y;
|
||||
let ink = if row.dim { t.text_dim } else { t.icon };
|
||||
// The row is one widget: where a click lands decides what it does (the fold arrow with
|
||||
// padding, the eye, the radio, or the row itself), so a click on the arrow never also
|
||||
// selects the row or starts a drag.
|
||||
let fold_r = Rect::from_min_max(pos2(x - 4.0, r.top()), pos2(x + 15.0, r.bottom()));
|
||||
let eye_r = Rect::from_center_size(pos2(x + 14.0 + 8.0, cy), vec2(19.0, r.height()));
|
||||
let radio_r = Rect::from_center_size(pos2(x + 32.0 + 6.0, cy), vec2(15.0, r.height()));
|
||||
let at = |p: Pos2| -> u8 {
|
||||
if row.fold.is_some() && fold_r.contains(p) {
|
||||
1
|
||||
} else if row.eye.is_some() && eye_r.contains(p) {
|
||||
2
|
||||
} else if row.radio.is_some() && radio_r.contains(p) {
|
||||
3
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
DRAWN.with(|d| d.borrow_mut().push((row.label.to_string(), r, row.fold.map(|_| fold_r), row.fold)));
|
||||
let press_on = ui.input(|i| i.pointer.press_origin()).map_or(0, at);
|
||||
let click_on = resp.interact_pointer_pos().map_or(0, at);
|
||||
let hover_on = resp.hover_pos().map_or(0, at);
|
||||
// Fold arrow.
|
||||
if let Some(open) = row.fold {
|
||||
let ar = Rect::from_center_size(pos2(x + 6.0, cy), vec2(12.0, 12.0));
|
||||
@@ -277,9 +319,10 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
} else {
|
||||
vec![pos2(ar.left() + 4.0, ar.top() + 2.0), pos2(ar.right() - 3.0, ar.center().y), pos2(ar.left() + 4.0, ar.bottom() - 2.0)]
|
||||
};
|
||||
ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE));
|
||||
out.fold = ui.interact(ar.expand(3.0), id.with("fold"), Sense::click()).clicked();
|
||||
crate::scenario::publish_handle(&format!("fold:{}", row.label), ar.center());
|
||||
let ink = if hover_on == 1 { t.text } else { t.text_dim };
|
||||
ui.painter().add(egui::Shape::convex_polygon(pts, ink, Stroke::NONE));
|
||||
out.fold = resp.clicked() && click_on == 1;
|
||||
crate::scenario::publish_handle(&format!("fold:{}", row.label), fold_r.center());
|
||||
}
|
||||
x += 14.0;
|
||||
// Eye.
|
||||
@@ -289,9 +332,7 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
if !vis {
|
||||
ui.painter().line_segment([er.left_bottom() + vec2(2.0, -2.0), er.right_top() + vec2(-2.0, 2.0)], Stroke::new(1.4, t.text_dim));
|
||||
}
|
||||
let e = ui.interact(er.expand(2.0), id.with("eye"), Sense::click());
|
||||
out.eye = e.clicked();
|
||||
e.on_hover_text(if vis { "Hide" } else { "Show" });
|
||||
out.eye = resp.clicked() && click_on == 2;
|
||||
}
|
||||
x += 18.0;
|
||||
// Activation radio (components).
|
||||
@@ -301,9 +342,7 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
if active {
|
||||
ui.painter().circle_filled(rc, 2.8, t.accent);
|
||||
}
|
||||
let rr = ui.interact(Rect::from_center_size(rc, vec2(14.0, 14.0)), id.with("radio"), Sense::click());
|
||||
out.radio = rr.clicked();
|
||||
rr.on_hover_text(if active { "Active component" } else { "Activate" });
|
||||
out.radio = resp.clicked() && click_on == 3;
|
||||
x += 15.0;
|
||||
}
|
||||
if !row.icon.is_empty() {
|
||||
@@ -322,9 +361,20 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
bx += 15.0;
|
||||
}
|
||||
crate::scenario::publish_handle(&format!("row:{}", row.label), r.center());
|
||||
let tip = match hover_on {
|
||||
1 => Some(if row.fold == Some(true) { "Collapse" } else { "Expand" }),
|
||||
2 => Some(if row.eye == Some(true) { "Hide" } else { "Show" }),
|
||||
3 => Some(if row.radio == Some(true) { "Active component" } else { "Activate" }),
|
||||
_ => None,
|
||||
};
|
||||
let resp = match tip {
|
||||
Some(t) => resp.on_hover_text(t),
|
||||
None => resp,
|
||||
};
|
||||
out.on_control = press_on != 0;
|
||||
out.clicked = resp.clicked() && click_on == 0;
|
||||
out.double = resp.double_clicked() && click_on == 0;
|
||||
out.resp = Some(resp.clone());
|
||||
out.clicked = resp.clicked();
|
||||
out.double = resp.double_clicked();
|
||||
if resp.secondary_clicked() {
|
||||
out.secondary = resp.interact_pointer_pos().or(Some(r.left_bottom()));
|
||||
}
|
||||
@@ -424,6 +474,7 @@ struct Actions {
|
||||
pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
app.tree.order.clear();
|
||||
DRAWN.with(|d| d.borrow_mut().clear());
|
||||
let mut acts = Actions::default();
|
||||
egui::Panel::left("sc_browser")
|
||||
.exact_size(250.0)
|
||||
@@ -447,7 +498,7 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if let Some((at, target)) = acts.menu {
|
||||
crate::context_menu::open_for(app, at, target);
|
||||
}
|
||||
occurrence_panel(app, ui.ctx());
|
||||
occurrence_panel(app, ui);
|
||||
capture_prompt(app, ui.ctx());
|
||||
canvas_panel(app, ui.ctx());
|
||||
redefine_panel(app, ui.ctx());
|
||||
@@ -1227,7 +1278,7 @@ fn entry_row(
|
||||
}
|
||||
let Some(resp) = &r.resp else { return };
|
||||
// Drag: the selected items of this folder when this one is selected, else just this one.
|
||||
if resp.drag_started() {
|
||||
if resp.drag_started() && !r.on_control {
|
||||
let keys: Vec<String> = if e.selected { selected_keys(app, comp, folder) } else { vec![e.key.clone()] };
|
||||
resp.dnd_set_drag_payload(Drag { comp, folder, keys });
|
||||
}
|
||||
@@ -1311,20 +1362,24 @@ fn apply_drop(app: &mut SolveApp, d: DropAction) {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Move/Copy of an occurrence: offsets and a rotation about Z, pending until Capture Position.
|
||||
fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let Some(mut m) = app.tree.occurrence_move.clone() else { return };
|
||||
let ctx = ui.ctx().clone();
|
||||
if m.pivot.is_none() {
|
||||
m.pivot = occurrence_origin(app, m.occurrence);
|
||||
}
|
||||
let name = app.session.doc.occurrences.iter().find(|o| o.id == m.occurrence).map(|o| o.name.clone()).unwrap_or_default();
|
||||
let mut open = true;
|
||||
let mut action: Option<&str> = None;
|
||||
let before = (m.translate, m.angle_deg);
|
||||
crate::frame::window(ctx, format!("Move: {name}"), crate::frame::Width::Normal)
|
||||
let before = (m.translate, m.angles);
|
||||
crate::frame::window(&ctx, format!("Move: {name}"), crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("sc_occ_move"))
|
||||
.pivot(egui::Align2::LEFT_TOP)
|
||||
.default_pos(panel_pos(app))
|
||||
.open(&mut open)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.show(ctx, |ui| {
|
||||
.show(&ctx, |ui| {
|
||||
egui::Grid::new("sc_occ_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
|
||||
for (k, axis) in ["X distance", "Y distance", "Z distance"].iter().enumerate() {
|
||||
ui.label(*axis);
|
||||
@@ -1333,9 +1388,13 @@ fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label("Z angle");
|
||||
ui.add(egui::DragValue::new(&mut m.angle_deg).speed(1.0).suffix(" deg"));
|
||||
ui.end_row();
|
||||
for (k, axis) in ["X angle", "Y angle", "Z angle"].iter().enumerate() {
|
||||
ui.label(*axis);
|
||||
if let Some(v) = m.angles.get_mut(k) {
|
||||
ui.add(egui::DragValue::new(v).speed(1.0).suffix(" deg"));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
@@ -1347,14 +1406,9 @@ fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
});
|
||||
gizmo(app, ctx, &mut m);
|
||||
if (m.translate, m.angle_deg) != before {
|
||||
// The pending move is the typed offset from where the occurrence is now.
|
||||
let _ = app.session.execute("component.revert_position", &json!({}));
|
||||
let _ = app.run(
|
||||
"occurrence.move",
|
||||
json!({ "occurrence": m.occurrence, "translate": m.translate, "axis": [0, 0, 1], "angle": m.angle_deg.to_radians(), "capture": false }),
|
||||
);
|
||||
triad(app, ui, &mut m);
|
||||
if (m.translate, m.angles) != before {
|
||||
apply_occurrence_move(app, &m);
|
||||
}
|
||||
match action {
|
||||
Some("capture") => {
|
||||
@@ -1373,6 +1427,50 @@ fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pending move: turned about X, then Y, then Z through the starting origin, then moved.
|
||||
pub fn apply_occurrence_move(app: &mut SolveApp, m: &OccMove) {
|
||||
use crate::gizmo;
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
let a = m.angles.map(f64::to_radians);
|
||||
let r = gizmo::mul(&gizmo::mul(&gizmo::rotation(Vec3::Z, a[2]), &gizmo::rotation(Vec3::Y, a[1])), &gizmo::rotation(Vec3::X, a[0]));
|
||||
let (axis, angle) = gizmo::axis_angle(&r);
|
||||
let o = m.pivot.unwrap_or(Vec3::ZERO);
|
||||
let _ = app.session.execute("component.revert_position", &json!({}));
|
||||
let _ = app.run(
|
||||
"occurrence.move",
|
||||
json!({ "occurrence": m.occurrence, "translate": m.translate, "axis": [axis.x, axis.y, axis.z], "angle": angle, "origin": [o.x, o.y, o.z], "capture": false }),
|
||||
);
|
||||
}
|
||||
|
||||
/// The shared move triad at the occurrence's origin: arrows and squares move it, rings turn it.
|
||||
fn triad(app: &mut SolveApp, ui: &mut egui::Ui, m: &mut OccMove) {
|
||||
use crate::gizmo::{self, Change};
|
||||
let (Some(rect), Some(pivot)) = (app.viewport.rect, m.pivot) else { return };
|
||||
let proj = crate::viewport::projection(app, rect);
|
||||
let center = pivot + solvecraft_engine::geom::Vec3::new(m.translate[0], m.translate[1], m.translate[2]);
|
||||
let layer = egui::LayerId::new(egui::Order::Middle, egui::Id::new("sc_occ_triad_layer"));
|
||||
let mut gui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(rect));
|
||||
let painter = gui.painter().with_clip_rect(rect);
|
||||
let t = gizmo::Triad { center, translate: true, rotate: true };
|
||||
let step = crate::canvas::snap_step(app.cam.half_height());
|
||||
let Some(g) = gizmo::show(&mut gui, &painter, &proj, egui::Id::new("sc_occ_triad"), &t, step) else { return };
|
||||
if g.started || m.drag0.is_none() {
|
||||
m.drag0 = Some((m.translate, m.angles));
|
||||
}
|
||||
let (t0, a0) = m.drag0.unwrap_or((m.translate, m.angles));
|
||||
match g.change {
|
||||
Change::Translate(v) => m.translate = [t0[0] + v.x, t0[1] + v.y, t0[2] + v.z],
|
||||
Change::Rotate { axis, angle } => {
|
||||
if let Some(slot) = m.angles.get_mut(axis) {
|
||||
*slot = a0.get(axis).copied().unwrap_or(0.0) + angle.to_degrees();
|
||||
}
|
||||
}
|
||||
}
|
||||
if g.done {
|
||||
m.drag0 = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Dimensions of finished sketches whose dimensions are shown (Show Dimensions), drawn over the
|
||||
/// viewport like the sketch being edited draws its own (`crate::dim_view`), but not editable.
|
||||
pub fn dims_overlay(app: &SolveApp, ctx: &egui::Context) {
|
||||
@@ -1517,65 +1615,6 @@ fn occurrence_origin(app: &SolveApp, occurrence: u64) -> Option<solvecraft_engin
|
||||
Some(solvecraft_engine::doc::apply_point(&solvecraft_engine::doc::mat_mul(&parent, &own), solvecraft_engine::geom::Vec3::ZERO))
|
||||
}
|
||||
|
||||
/// Screen length of the gizmo's arrows.
|
||||
const GIZMO_PX: f32 = 80.0;
|
||||
|
||||
/// Arrows along X, Y and Z at the occurrence's origin: dragging one moves it along that axis.
|
||||
fn gizmo(app: &mut SolveApp, ctx: &egui::Context, m: &mut OccMove) {
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
let t = Tokens::get();
|
||||
let (Some(rect), Some(o)) = (app.viewport.rect, occurrence_origin(app, m.occurrence)) else { return };
|
||||
let proj = crate::viewport::projection(app, rect);
|
||||
let Some(p0) = proj.to_screen(o) else { return };
|
||||
if !rect.contains(p0) {
|
||||
return;
|
||||
}
|
||||
let axes = [(Vec3::X, t.axis_x), (Vec3::Y, t.axis_y), (Vec3::Z, t.axis_z)];
|
||||
// Per axis: the screen direction and pixels per millimetre.
|
||||
let mut arms = Vec::new();
|
||||
for (k, (a, col)) in axes.iter().enumerate() {
|
||||
let Some(p1) = proj.to_screen(o + *a) else { continue };
|
||||
let d = p1 - p0;
|
||||
let px_per_mm = d.length();
|
||||
if px_per_mm < 1e-6 || !px_per_mm.is_finite() {
|
||||
continue;
|
||||
}
|
||||
arms.push((k, d / px_per_mm, px_per_mm, *col));
|
||||
}
|
||||
let bbox = Rect::from_center_size(p0, vec2(2.0 * GIZMO_PX + 30.0, 2.0 * GIZMO_PX + 30.0)).intersect(rect);
|
||||
egui::Area::new(egui::Id::new("sc_occ_gizmo")).fixed_pos(bbox.min).order(egui::Order::Middle).show(ctx, |ui| {
|
||||
let (_, _) = ui.allocate_exact_size(bbox.size(), Sense::hover());
|
||||
let painter = ui.painter_at(bbox);
|
||||
for (k, dir, px_per_mm, col) in &arms {
|
||||
// An axis seen end-on has no arrow to drag.
|
||||
let len = GIZMO_PX * dir.length().min(1.0);
|
||||
let tip = p0 + *dir * len;
|
||||
let handle =
|
||||
Rect::from_center_size(p0 + *dir * (len * 0.6), vec2(len * 0.8, len * 0.8)).intersect(Rect::from_two_pos(p0, tip).expand(9.0));
|
||||
let resp = ui.interact(handle, ui.id().with(("arm", *k)), Sense::drag());
|
||||
let lit = resp.hovered() || resp.dragged();
|
||||
let w = if lit { 4.0 } else { 2.5 };
|
||||
painter.line_segment([p0 + *dir * 10.0, tip], Stroke::new(w, *col));
|
||||
let n = vec2(-dir.y, dir.x);
|
||||
painter.add(egui::Shape::convex_polygon(vec![tip + *dir * 10.0, tip + n * 5.5, tip - n * 5.5], *col, Stroke::NONE));
|
||||
if resp.dragged() {
|
||||
let mm = resp.drag_delta().dot(*dir) / px_per_mm;
|
||||
if let Some(v) = m.translate.get_mut(*k)
|
||||
&& mm.is_finite()
|
||||
{
|
||||
*v += f64::from(mm);
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
&& let Some(v) = m.translate.get_mut(*k)
|
||||
{
|
||||
*v = (*v * 100.0).round() / 100.0;
|
||||
}
|
||||
}
|
||||
painter.circle(p0, 6.0, t.panel, Stroke::new(1.5, t.accent));
|
||||
});
|
||||
}
|
||||
|
||||
/// Redefine Sketch Plane: pick an origin or construction plane, or the selected planar face.
|
||||
fn redefine_panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(id) = app.tree.redefine else { return };
|
||||
|
||||
@@ -13,7 +13,7 @@ use solvecraft_engine::geom::{Mesh, Vec3};
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{Dialog, Kind};
|
||||
use crate::theme::Tokens;
|
||||
use crate::viewport::Proj;
|
||||
use crate::viewport::{Proj, projected_polygon};
|
||||
|
||||
/// Where the dialog's geometry is: a base point and, for values that can be dragged, the
|
||||
/// direction a growing value points.
|
||||
@@ -145,6 +145,13 @@ fn format_len(v: f64) -> String {
|
||||
format!("{} mm", if s == "-0" { "0" } else { s })
|
||||
}
|
||||
|
||||
/// The offset plane preview: a square of half side `h` around `c`, square to `n`.
|
||||
fn offset_plane_quad(proj: &Proj, c: Vec3, n: Vec3, h: f64) -> Option<Vec<Pos2>> {
|
||||
let u = n.cross(if n.z.abs() < 0.9 { Vec3::Z } else { Vec3::X }).normalized()?;
|
||||
let v = n.cross(u);
|
||||
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].iter().map(|(a, b)| proj.to_screen(c + u * (a * h) + v * (b * h))).collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog, base: Vec3, bs: Pos2, dir: Option<Vec3>) {
|
||||
let t = Tokens::get();
|
||||
@@ -168,16 +175,9 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
// An offset plane shows where it will be.
|
||||
if let (Kind::OffsetPlane { offset }, Some(n)) = (&d.kind, dir)
|
||||
&& let Ok(off) = app.session.doc.eval(offset, ValueKind::Length)
|
||||
&& let Some(u) = n.cross(if n.z.abs() < 0.9 { Vec3::Z } else { Vec3::X }).normalized()
|
||||
&& let Some(q) = offset_plane_quad(proj, base + n * off, n, half_height * 0.25)
|
||||
{
|
||||
let v = n.cross(u);
|
||||
let h = half_height * 0.25;
|
||||
let c = base + n * off;
|
||||
let q: Vec<Pos2> =
|
||||
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].iter().filter_map(|(a, b)| proj.to_screen(c + u * (a * h) + v * (b * h))).collect();
|
||||
if q.len() == 4 {
|
||||
painter.add(egui::Shape::convex_polygon(q, t.construction_plane, Stroke::new(1.2, t.origin_plane_edge)));
|
||||
}
|
||||
painter.extend(projected_polygon(&q, t.construction_plane, Stroke::new(1.2, t.origin_plane_edge)));
|
||||
}
|
||||
// Holes: the depth arrow points into the material (empty depth: through all); the
|
||||
// diameter is typed in the box.
|
||||
@@ -412,6 +412,30 @@ fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn offset_plane_preview_stays_within_its_square() {
|
||||
let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let mut worst = f32::NEG_INFINITY;
|
||||
for n in [Vec3::X, Vec3::Y, Vec3::Z] {
|
||||
for dir in crate::viewport::orbit_directions() {
|
||||
let cam = solvecraft_engine::render::Camera::default().looking_from(dir);
|
||||
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
|
||||
let h = cam.half_height() * 0.25;
|
||||
let (Some(q), Some(c), Some(side)) =
|
||||
(offset_plane_quad(&proj, Vec3::ZERO, n, h), proj.to_screen(Vec3::ZERO), proj.to_screen(cam.basis().0 * h))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// The square's half diagonal on screen (an orthographic view), plus stroke and feathering.
|
||||
let limit = c.distance(side) * 2f32.sqrt() + 3.0;
|
||||
for p in crate::viewport::drawn_points(projected_polygon(&q, Color32::GRAY, Stroke::new(1.2, Color32::BLACK))) {
|
||||
worst = worst.max(p.distance(c) - limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(worst <= 0.0, "a vertex {worst} px past the square");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapping_steps_and_formatting() {
|
||||
assert_eq!(snap_step(40.0), 1.0);
|
||||
|
||||
@@ -46,6 +46,12 @@ pub enum Target {
|
||||
Joint {
|
||||
id: u64,
|
||||
},
|
||||
/// A command (toolbar button, panel drop-down row, S box row).
|
||||
ToolbarCommand {
|
||||
id: String,
|
||||
},
|
||||
/// The workspace switcher.
|
||||
Workspace,
|
||||
/// The Named Views folder, and one view (saved or standard) in it.
|
||||
NamedViews,
|
||||
NamedView {
|
||||
@@ -228,6 +234,8 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec<String> {
|
||||
| Target::Origin
|
||||
| Target::Units
|
||||
| Target::Joint { .. }
|
||||
| Target::ToolbarCommand { .. }
|
||||
| Target::Workspace
|
||||
| Target::NamedViews
|
||||
| Target::NamedView { .. } => Vec::new(),
|
||||
}
|
||||
@@ -290,11 +298,19 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
|
||||
Target::Origin => crate::browser::origin_items(app),
|
||||
Target::NamedViews => vec![act("ui.newView", "New Named View", "perspective")],
|
||||
Target::Joint { id } => joint_items(app, *id),
|
||||
Target::ToolbarCommand { id } => crate::toolbar_custom::command_items(app, id),
|
||||
Target::Workspace => {
|
||||
let mut v = vec![act("ui.workspace", "Design", "box").key("current")];
|
||||
for w in ["Generative Design", "Render", "Animation", "Simulation", "Manufacture", "Drawing"] {
|
||||
v.push(act("ui.workspace", w, "").key("coming later").on(false));
|
||||
}
|
||||
v
|
||||
}
|
||||
Target::Units => crate::prefs::UNITS
|
||||
.iter()
|
||||
.map(|u| {
|
||||
let label = if app.session.doc.units == *u { format!("{u} ✓") } else { (*u).to_string() };
|
||||
cmd(app, "document.units", &label).with(json!({ "units": u }))
|
||||
let item = cmd(app, "document.units", u).with(json!({ "units": u }));
|
||||
if app.session.doc.units == *u { item.key("current") } else { item }
|
||||
})
|
||||
.collect(),
|
||||
Target::NamedView { name } => crate::browser::view_items(app, name),
|
||||
@@ -773,6 +789,8 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
}
|
||||
}
|
||||
"ui.canvasVisible" => drop(app.run("canvas.edit", p)),
|
||||
"ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.shortcutPin" => crate::toolbar_custom::run(app, &item.id, &p),
|
||||
"ui.workspace" => {}
|
||||
"ui.editJoint" | "ui.driveJoint" => {
|
||||
if let Some(id) = id_of(&p, "joint") {
|
||||
let d = if item.id == "ui.editJoint" {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
//! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_cursor, msaa,
|
||||
//! length_decimals, angle_decimals); returns them all
|
||||
//! - `ui.capture {action?: capture|revert|cancel}`: the Capture Position question
|
||||
//! - `ui.agent_cursor {show?, speed?: instant|normal|slow, follow_camera?}`: the agent cursor
|
||||
//! - `ui.agent_cursor {show?, speed?: 1–5 (times) | "instant", follow_camera?}`: the agent cursor
|
||||
//! (#35); while it is shown, `engine.execute` first glides to the command's button and picks
|
||||
//! and replies once the command has run (`"animate": false` skips that one call)
|
||||
//! - `ui.resize {width, height}`, `app.quit`
|
||||
@@ -107,9 +107,9 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
s.follow_camera = v;
|
||||
}
|
||||
if let Some(v) = p.get("speed") {
|
||||
match serde_json::from_value(v.clone()) {
|
||||
Ok(sp) => s.speed = sp,
|
||||
Err(_) => return err("speed must be instant, normal or slow"),
|
||||
match crate::agent_cursor::Speed::from_value(v) {
|
||||
Some(sp) => s.speed = sp,
|
||||
None => return err("speed must be a number from 1 to 5, or \"instant\""),
|
||||
}
|
||||
}
|
||||
ok(json!(app.ui.agent_cursor))
|
||||
@@ -287,6 +287,20 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
None => err(format!("handle {h} is not shown")),
|
||||
};
|
||||
}
|
||||
// A browser row by its label: the row itself, or its fold arrow.
|
||||
for (k, chevron) in [("browser", false), ("chevron", true)] {
|
||||
if let Some(label) = p.get(k).and_then(Value::as_str) {
|
||||
let rows = crate::browser::drawn_rows();
|
||||
let found = rows
|
||||
.iter()
|
||||
.find(|r| r.0 == label)
|
||||
.and_then(|r| if chevron { r.2.map(|f| f.center()) } else { Some(r.1.left_center() + egui::vec2(120.0, 0.0)) });
|
||||
return match found {
|
||||
Some(q) => ok(json!([q.x, q.y])),
|
||||
None => err(format!("no browser row `{label}`{} on screen", if chevron { " with a fold arrow" } else { "" })),
|
||||
};
|
||||
}
|
||||
}
|
||||
if let Some(d) = p.get("dimension").and_then(Value::as_str) {
|
||||
return match crate::dim_view::text_at(d) {
|
||||
Some(q) => ok(json!([q.x, q.y])),
|
||||
@@ -426,6 +440,11 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
None => ok(json!({"command": id, "key": crate::keymap::effective(app, id)})),
|
||||
}
|
||||
}
|
||||
"ui.browser" => ok(json!({
|
||||
"rows": crate::browser::drawn_rows().iter().map(|(label, r, _, open)| json!({"label": label, "y": r.center().y, "open": open})).collect::<Vec<_>>(),
|
||||
"selection": app.session.selection,
|
||||
"picked_components": app.tree.picked_components,
|
||||
})),
|
||||
"ui.capture" => match s("action") {
|
||||
Some(a) => match crate::browser::answer_capture(app, a) {
|
||||
Ok(()) => ok(json!({"pending_moves": app.session.pending_moves.len()})),
|
||||
@@ -433,7 +452,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
},
|
||||
None => ok(json!({"question": app.tree.capture_prompt, "pending_moves": app.session.pending_moves.len()})),
|
||||
},
|
||||
"ui.prefs" => wrap(crate::prefs::set(app, p)),
|
||||
"ui.prefs" => wrap(crate::prefs::control(app, p)),
|
||||
"ui.help" => match s("item") {
|
||||
Some(i @ ("about" | "shortcuts" | "report")) => {
|
||||
crate::help::run(app, ctx, i);
|
||||
|
||||
@@ -151,8 +151,6 @@ pub enum Kind {
|
||||
Stock {
|
||||
margin: String,
|
||||
},
|
||||
/// Application preferences (applied as they change; kept between runs).
|
||||
Preferences,
|
||||
/// Physical material (and with it the appearance) of bodies.
|
||||
Material {
|
||||
index: usize,
|
||||
@@ -502,10 +500,6 @@ impl Dialog {
|
||||
Some(d)
|
||||
}
|
||||
|
||||
pub fn preferences() -> Dialog {
|
||||
Dialog::new(Kind::Preferences, vec![])
|
||||
}
|
||||
|
||||
pub fn rename(feature: u64, name: &str) -> Dialog {
|
||||
Dialog::new(Kind::Rename { feature, name: name.to_string() }, vec![])
|
||||
}
|
||||
@@ -775,7 +769,6 @@ fn title(k: &Kind) -> &'static str {
|
||||
Kind::Mirror => "MIRROR",
|
||||
Kind::Measure { .. } => "MEASURE",
|
||||
Kind::Material { .. } => "PHYSICAL MATERIAL",
|
||||
Kind::Preferences => "PREFERENCES",
|
||||
Kind::OffsetPlane { .. } => "OFFSET PLANE",
|
||||
Kind::Rib { web: false, .. } => "RIB",
|
||||
Kind::Rib { web: true, .. } => "WEB",
|
||||
@@ -1136,30 +1129,6 @@ fn update_measure(app: &SolveApp, d: &mut Dialog) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The preferences, applied as they change.
|
||||
fn preferences_rows(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.label("Theme");
|
||||
let mut theme = usize::from(!app.ui.dark);
|
||||
combo(ui, "pref_theme", &["Dark", "Light"], &mut theme);
|
||||
app.ui.dark = theme == 0;
|
||||
ui.end_row();
|
||||
ui.label("Grid");
|
||||
ui.checkbox(&mut app.ui.show_grid, "");
|
||||
ui.end_row();
|
||||
ui.label("Perspective view");
|
||||
ui.checkbox(&mut app.ui.perspective, "");
|
||||
ui.end_row();
|
||||
ui.label("Project face edges into new sketches");
|
||||
let mut auto = app.session.auto_project;
|
||||
if ui.checkbox(&mut auto, "").changed() {
|
||||
let _ = app.run("sketch.auto_project", json!({ "value": auto }));
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("Click selects whole bodies");
|
||||
ui.checkbox(&mut app.ui.pick_bodies, "");
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
fn fmt_mm(v: &Value) -> String {
|
||||
v.as_f64().map(|x| format!("{x:.3} mm")).unwrap_or_default()
|
||||
}
|
||||
@@ -1251,7 +1220,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
.corner_radius(egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 })
|
||||
.shadow(egui::Shadow { offset: [-2, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(40) });
|
||||
// OK is offered once the inputs make a feature.
|
||||
let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences);
|
||||
let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. });
|
||||
let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none());
|
||||
let width = if wide { crate::frame::Width::Wide } else { crate::frame::Width::Normal };
|
||||
let wkey = egui::Id::new(("sc_dialog_w", wide));
|
||||
@@ -1453,6 +1422,37 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
row_label(ui, "Swap Ends");
|
||||
extra_check(ui, &mut d.extra, "swap_ends");
|
||||
ui.end_row();
|
||||
// Radii between the ends: position along the edge (from the start end)
|
||||
// and radius.
|
||||
let mut mids = mid_points(&d.extra);
|
||||
let mut remove = None;
|
||||
for (k, (t, r)) in mids.iter_mut().enumerate() {
|
||||
row_label(ui, "Mid Radius");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::DragValue::new(t).speed(0.01).range(0.01..=0.99).fixed_decimals(2))
|
||||
.on_hover_text("Position along the edge, 0 at the start end, 1 at the other");
|
||||
enter |= field(ui, r);
|
||||
if ui.small_button("×").on_hover_text("Remove this point").clicked() {
|
||||
remove = Some(k);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
if let Some(k) = remove {
|
||||
mids.remove(k);
|
||||
}
|
||||
row_label(ui, "");
|
||||
if ui.button("Add Mid Radius").clicked() && mids.len() < 20 {
|
||||
let t = if mids.is_empty() { 0.5 } else { (mids.iter().map(|m| m.0).fold(0.0, f64::max) + 1.0) * 0.5 };
|
||||
mids.push((t.min(0.99), radius.clone()));
|
||||
}
|
||||
ui.end_row();
|
||||
d.extra.insert("mid".into(), json!(mids.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>()));
|
||||
if !mids.is_empty() {
|
||||
row_label(ui, "Smooth");
|
||||
extra_check(ui, &mut d.extra, "smooth");
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
row_label(ui, "Tangent Chain");
|
||||
ui.checkbox(chain, "");
|
||||
@@ -1490,7 +1490,6 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Measure { result, .. } => measure_rows(ui, result.as_ref()),
|
||||
Kind::Preferences => preferences_rows(app, ui),
|
||||
Kind::OffsetPlane { offset } => {
|
||||
row_label(ui, "Distance");
|
||||
enter |= field(ui, offset);
|
||||
@@ -1811,7 +1810,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences) { "Close" } else { "Cancel" };
|
||||
let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. }) { "Close" } else { "Cancel" };
|
||||
if ui.add(egui::Button::new(close).min_size(vec2(64.0, 24.0))).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
@@ -1824,7 +1823,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let nothing_focused = ctx.memory(|m| m.focused().is_none());
|
||||
let canvas_enter = enter_free && ctx.memory(|m| m.had_focus_last_frame(egui::Id::new("sc_canvas_value")));
|
||||
// Tables (configurations, a study's keys) take Enter for their cells.
|
||||
let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. } | Kind::Preferences)
|
||||
let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. })
|
||||
&& !matches!(&d.kind, Kind::Motion(k) if k.wide());
|
||||
if enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) {
|
||||
ok = true;
|
||||
@@ -1945,6 +1944,25 @@ fn extra_field(ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, ke
|
||||
enter
|
||||
}
|
||||
|
||||
/// A variable fillet's middle radii as (position, radius expression).
|
||||
fn mid_points(extra: &serde_json::Map<String, Value>) -> Vec<(f64, String)> {
|
||||
extra
|
||||
.get("mid")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|m| {
|
||||
let t = m.get(0)?.as_f64()?;
|
||||
let r = match m.get(1)? {
|
||||
Value::String(x) => x.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
Some((t, r))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
const CHAMFER_TYPES: [&str; 3] = ["Equal Distance", "Two Distances", "Distance and Angle"];
|
||||
|
||||
/// A new Fillet or Chamfer dialog's values.
|
||||
@@ -2202,6 +2220,13 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
if let Some((a, b)) = ends {
|
||||
p["start"] = pt(if swap { b } else { a });
|
||||
}
|
||||
let mids = mid_points(&d.extra);
|
||||
if !mids.is_empty() {
|
||||
p["mid"] = json!(mids.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>());
|
||||
if d.extra.get("smooth").and_then(Value::as_bool).unwrap_or(false) {
|
||||
p["smooth"] = json!(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
("solid.fillet", p)
|
||||
}
|
||||
@@ -2533,7 +2558,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
Kind::EditParam { name, expr } => ("parameters.change", json!({"name": name, "expression": expr})),
|
||||
Kind::Rename { feature, name } => ("timeline.rename", json!({"feature": feature, "name": name})),
|
||||
Kind::ConfirmDelete { feature, .. } => ("timeline.delete", json!({ "features": [feature.to_string()] })),
|
||||
Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences => return Ok(Vec::new()),
|
||||
Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } => return Ok(Vec::new()),
|
||||
Kind::Assembly(k) => return crate::dialogs_assembly::commands(app, k, &d.inputs),
|
||||
Kind::Sheet(k) => return crate::dialogs_sheet::commands(app, k, &d.inputs, &d.extra),
|
||||
Kind::Surface(k) => return crate::dialogs_surface::commands(app, k, &d.inputs, &d.extra),
|
||||
@@ -2765,10 +2790,8 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
if let Some(inp) = d.inputs.first_mut() {
|
||||
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
|
||||
}
|
||||
for (k, v) in [("targets", (!targets.is_empty()).then(|| json!(targets)))] {
|
||||
if let Some(v) = v {
|
||||
d.extra.insert(k.into(), v);
|
||||
}
|
||||
if !targets.is_empty() {
|
||||
d.extra.insert("targets".into(), json!(targets));
|
||||
}
|
||||
d
|
||||
}
|
||||
@@ -2824,9 +2847,11 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
FilletStyle::Chord => {
|
||||
d.extra.insert("type".into(), json!("chord"));
|
||||
}
|
||||
FilletStyle::Variable { radius2, start } => {
|
||||
FilletStyle::Variable { radius2, start, mid, smooth } => {
|
||||
d.extra.insert("type".into(), json!("variable"));
|
||||
d.extra.insert("radius2".into(), json!(radius2));
|
||||
d.extra.insert("mid".into(), json!(mid.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>()));
|
||||
d.extra.insert("smooth".into(), json!(smooth));
|
||||
// The start was the first edge's last point: ends swapped.
|
||||
let first_end = edges.first().and_then(|p| edge_sel(s, *p)).and_then(|x| match x {
|
||||
Sel::Edge { body, index, .. } => {
|
||||
|
||||
@@ -250,7 +250,8 @@ fn drive_value_takes_the_keyboard() {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
|
||||
let frame = |app: &mut SolveApp, events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() };
|
||||
ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx()))
|
||||
// Nothing renders here: drop the frame's texture changes deliberately.
|
||||
ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx())).textures_delta.clear();
|
||||
};
|
||||
frame(&mut app, vec![]);
|
||||
frame(&mut app, vec![]);
|
||||
|
||||
@@ -10,7 +10,7 @@ use egui::{Color32, Pos2, Rect, Sense, Shape, Stroke, vec2};
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::viewport::Proj;
|
||||
use crate::viewport::{Proj, projected_polygon};
|
||||
|
||||
/// A part of the triad.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -264,7 +264,7 @@ pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::I
|
||||
}
|
||||
for (i, q) in &sh.planes {
|
||||
let c = col(Handle::Plane(*i), *i);
|
||||
painter.add(Shape::convex_polygon(q.clone(), c.gamma_multiply(if hot(Handle::Plane(*i)) { 0.7 } else { 0.35 }), Stroke::new(1.0, c)));
|
||||
painter.extend(projected_polygon(q, c.gamma_multiply(if hot(Handle::Plane(*i)) { 0.7 } else { 0.35 }), Stroke::new(1.0, c)));
|
||||
}
|
||||
for (i, a, b) in &sh.arrows {
|
||||
arrow(painter, *a, *b, col(Handle::Axis(*i), *i), hot(Handle::Axis(*i)));
|
||||
@@ -413,6 +413,27 @@ pub fn axis_angle(m: &[[f64; 3]; 3]) -> (Vec3, f64) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plane_squares_stay_within_the_triad() {
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let t = Triad { center: Vec3::ZERO, translate: true, rotate: false };
|
||||
// The squares reach 0.45 √2 of the arrow length from the centre, plus stroke and feathering.
|
||||
let limit = (0.45 * 2f64.sqrt() * LEN_PX) as f32 + 3.0;
|
||||
let mut worst = 0.0f32;
|
||||
for dir in crate::viewport::orbit_directions() {
|
||||
let cam = solvecraft_engine::render::Camera::default().looking_from(dir);
|
||||
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
|
||||
let l = mm_per_px(&proj, t.center).unwrap() * LEN_PX;
|
||||
let sh = shapes(&proj, &t, l).unwrap();
|
||||
for (_, q) in &sh.planes {
|
||||
for p in crate::viewport::drawn_points(projected_polygon(q, Color32::GRAY, Stroke::new(1.0, Color32::BLACK))) {
|
||||
worst = worst.max(p.distance(sh.ball));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(worst <= limit, "a vertex {worst} px from the centre (limit {limit})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotations_compose_to_one_axis_angle() {
|
||||
let r = mul(&rotation(Vec3::Z, 0.7), &rotation(Vec3::X, 0.4));
|
||||
|
||||
+76
-62
@@ -173,6 +173,9 @@ struct Batches {
|
||||
tris: Option<Batch>,
|
||||
trans: Option<Batch>,
|
||||
glass: Option<Batch>,
|
||||
/// The glass triangles as built, and the view direction they were last sorted for.
|
||||
glass_cpu: Vec<u8>,
|
||||
glass_sorted_for: Option<[f32; 3]>,
|
||||
lines: Option<Batch>,
|
||||
hidden: Option<Batch>,
|
||||
overlays: Option<Batch>,
|
||||
@@ -189,7 +192,9 @@ impl Batches {
|
||||
if let Some(sc) = slot.lock().ok().and_then(|mut s| s.take()) {
|
||||
self.tris = upload(device, "sc_tris", &sc.tris, TRI_SIZE);
|
||||
self.trans = upload(device, "sc_trans", &sc.trans, TRI_SIZE);
|
||||
self.glass = upload(device, "sc_glass", &sc.glass, TRI_SIZE);
|
||||
self.glass = upload_with(device, "sc_glass", &sc.glass, TRI_SIZE, wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST);
|
||||
self.glass_cpu = sc.glass;
|
||||
self.glass_sorted_for = None;
|
||||
self.lines = upload(device, "sc_lines", &sc.lines, LINE_SIZE);
|
||||
self.hidden = upload(device, "sc_hidden", &sc.hidden, LINE_SIZE);
|
||||
self.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE);
|
||||
@@ -214,8 +219,6 @@ struct Resources {
|
||||
line: wgpu::RenderPipeline,
|
||||
/// Lines behind the model (depth test >).
|
||||
line_hidden: wgpu::RenderPipeline,
|
||||
/// Silhouettes: back faces pushed out a little on screen, drawn dark behind the model.
|
||||
outline: wgpu::RenderPipeline,
|
||||
overlay: wgpu::RenderPipeline,
|
||||
uniform: wgpu::Buffer,
|
||||
bind: wgpu::BindGroup,
|
||||
@@ -271,7 +274,7 @@ const SHADER: &str = r#"
|
||||
struct U {
|
||||
vp: mat4x4<f32>,
|
||||
back: vec4<f32>, // xyz = toward the eye, w = 1 for linear output
|
||||
screen: vec4<f32>, // viewport width, height (px), depth bias
|
||||
screen: vec4<f32>, // viewport width, height (px), depth bias, silhouettes on (1)
|
||||
clip: vec4<f32>, // section plane: normal, d (zero normal: no section)
|
||||
cap: vec4<f32>, // colour of the inside of cut bodies
|
||||
ana: vec4<f32>, // surface analysis: mode (1 zebra, 2 draft, 3 curvature), parameter
|
||||
@@ -321,14 +324,25 @@ fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f3
|
||||
// Derivatives first, in uniform control flow (browsers' WGSL compilers reject them after
|
||||
// a branch that returns; the whole shader module then fails and nothing is drawn).
|
||||
let k = curvature(i);
|
||||
let ndv = dot(normalize(i.n), normalize(u.back.xyz));
|
||||
let fw = fwidth(ndv);
|
||||
if (clipped(i.wp)) { discard; }
|
||||
if (!front && length(u.clip.xyz) > 0.5) {
|
||||
return out_color(u.cap);
|
||||
// The cut face: the cap colour, hatched at 45 degrees on screen.
|
||||
let h = fract((i.pos.x + i.pos.y) / 9.0);
|
||||
return out_color(vec4<f32>(select(u.cap.rgb, u.cap.rgb * 0.62, h < 0.22), u.cap.a));
|
||||
}
|
||||
if (u.ana.x > 0.5 && length(i.n) > 0.5) {
|
||||
return analysis(i, k);
|
||||
}
|
||||
return shade(i);
|
||||
let c = shade(i);
|
||||
// Silhouettes (styles with edges): where a curved surface turns away from the eye, a line
|
||||
// about 1.5 px wide, on the visible surface only (depth-correct).
|
||||
if (u.screen.w > 0.5 && length(i.n) > 0.5 && fw > 1e-6) {
|
||||
let a = 1.0 - smoothstep(fw * 0.8, fw * 2.0, abs(ndv));
|
||||
return vec4<f32>(mix(c.rgb, out_color(vec4<f32>(0.07, 0.08, 0.10, 1.0)).rgb, a), c.a);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
/// How fast the normal turns across the pixel (per mm).
|
||||
@@ -474,28 +488,6 @@ fn fs_line(i: LOut) -> @location(0) vec4<f32> {
|
||||
return out_color(i.c);
|
||||
}
|
||||
|
||||
/// Silhouettes: back faces pushed out along their screen-space normal by about 1.5 px.
|
||||
@vertex
|
||||
fn vs_outline(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec4<f32>) -> TOut {
|
||||
var o: TOut;
|
||||
o.pos = u.vp * vec4<f32>(p, 1.0);
|
||||
let nc = (u.vp * vec4<f32>(n, 0.0)).xy * u.screen.xy;
|
||||
if (length(nc) > 1e-6) {
|
||||
let d = normalize(nc) * 1.5 * 2.0 / u.screen.xy;
|
||||
o.pos = vec4<f32>(o.pos.xy + d * o.pos.w, o.pos.zw);
|
||||
}
|
||||
o.n = vec3<f32>(0.0);
|
||||
o.c = c;
|
||||
o.wp = p;
|
||||
return o;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_outline(i: TOut) -> @location(0) vec4<f32> {
|
||||
if (clipped(i.wp)) { discard; }
|
||||
return out_color(vec4<f32>(0.07, 0.08, 0.10, 1.0));
|
||||
}
|
||||
|
||||
struct IOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@@ -751,31 +743,6 @@ impl Resources {
|
||||
xray: pipeline("sc_xray", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Always, false), alpha),
|
||||
line: pipeline("sc_lines", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
|
||||
line_hidden: pipeline("sc_lines_hidden", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::Greater, false), alpha),
|
||||
outline: device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("sc_outline"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_outline"),
|
||||
buffers: &[Some(tri_layout())],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
cull_mode: Some(wgpu::Face::Front),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: depth(wgpu::CompareFunction::Less, false),
|
||||
multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false },
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_outline"),
|
||||
targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: alpha, write_mask: wgpu::ColorWrites::ALL })],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
}),
|
||||
overlay: pipeline("sc_overlay", "vs_line", "fs_line_all", line_layout(), depth(wgpu::CompareFunction::Always, false), alpha),
|
||||
uniform,
|
||||
bind,
|
||||
@@ -788,19 +755,44 @@ impl Resources {
|
||||
}
|
||||
|
||||
fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize) -> Option<Batch> {
|
||||
upload_with(device, label, bytes, stride, wgpu::BufferUsages::VERTEX)
|
||||
}
|
||||
|
||||
fn upload_with(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize, usage: wgpu::BufferUsages) -> Option<Batch> {
|
||||
if bytes.len() < stride {
|
||||
return None;
|
||||
}
|
||||
Some(Batch {
|
||||
buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents: bytes,
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
}),
|
||||
buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some(label), contents: bytes, usage }),
|
||||
count: u32::try_from(bytes.len() / stride).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Triangles (3 vertices of `TRI_SIZE` bytes each, position first) ordered far to near along
|
||||
/// the view direction `back` (toward the eye), so blended glass draws back to front.
|
||||
pub fn sort_back_to_front(bytes: &[u8], back: [f32; 3]) -> Vec<u8> {
|
||||
let tri = TRI_SIZE * 3;
|
||||
let depth = |t: &[u8]| -> f32 {
|
||||
let f = |o: usize| t.get(o..o + 4).and_then(|b| b.try_into().ok()).map_or(0.0, f32::from_le_bytes);
|
||||
(0..3).map(|v| (0..3).map(|k| f(v * TRI_SIZE + k * 4) * back[k]).sum::<f32>()).sum::<f32>()
|
||||
};
|
||||
let mut tris: Vec<(f32, &[u8])> = bytes.chunks_exact(tri).map(|t| (depth(t), t)).collect();
|
||||
tris.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
tris.into_iter().flat_map(|(_, t)| t.iter().copied()).collect()
|
||||
}
|
||||
|
||||
impl Batches {
|
||||
/// Re-sort the glass for a new view direction (when it turned more than about 2 degrees).
|
||||
fn sort_glass(&mut self, queue: &wgpu::Queue, back: [f32; 3]) {
|
||||
let (Some(g), false) = (&self.glass, self.glass_cpu.is_empty()) else { return };
|
||||
let turned = self.glass_sorted_for.is_none_or(|s| s[0] * back[0] + s[1] * back[1] + s[2] * back[2] < 0.9994);
|
||||
if turned {
|
||||
queue.write_buffer(&g.buffer, 0, &sort_back_to_front(&self.glass_cpu, back));
|
||||
self.glass_sorted_for = Some(back);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the pipelines. Call once with the app's render state and its depth/MSAA settings.
|
||||
pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32) -> GpuTarget {
|
||||
let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, 0), samples };
|
||||
@@ -822,6 +814,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
res.model.take(device, self.key, &self.slot);
|
||||
res.highlight.take(device, self.hl_key, &self.hl_slot);
|
||||
res.preview.take(device, self.pv_key, &self.pv_slot);
|
||||
res.model.sort_glass(queue, self.back);
|
||||
queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out));
|
||||
if let Some(a) = &self.access
|
||||
&& a.version != res.access_version
|
||||
@@ -909,11 +902,9 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
tris(pass, &res.tri, &m.tris);
|
||||
tris(pass, &res.tri, &pv.tris);
|
||||
tris(pass, &res.tri_hl, &h.tris);
|
||||
if self.outline {
|
||||
tris(pass, &res.outline, &m.tris);
|
||||
}
|
||||
lines(pass, &res.line, &m.lines);
|
||||
lines(pass, &res.line_hidden, &m.hidden);
|
||||
lines(pass, &res.line_hidden, &h.hidden);
|
||||
lines(pass, &res.line, &pv.lines);
|
||||
lines(pass, &res.line, &h.lines);
|
||||
for (id, q) in &res.quads {
|
||||
@@ -939,7 +930,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec<u8> {
|
||||
let mut v: Vec<f32> = cb.view_proj.iter().flatten().copied().collect();
|
||||
v.extend([cb.back[0], cb.back[1], cb.back[2], if linear { 1.0 } else { 0.0 }]);
|
||||
v.extend([w.max(1.0), h.max(1.0), 2e-4, 0.0]);
|
||||
v.extend([w.max(1.0), h.max(1.0), 2e-4, if cb.outline { 1.0 } else { 0.0 }]);
|
||||
v.extend(cb.clip);
|
||||
v.extend(cb.cap);
|
||||
v.extend(cb.analysis);
|
||||
@@ -1041,3 +1032,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod glass_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn glass_is_drawn_far_to_near() {
|
||||
// Two triangles at z = 5 and z = -5, given near first; looking down from +Z.
|
||||
let mut sc = GpuScene::default();
|
||||
for z in [5.0f32, -5.0] {
|
||||
for p in [[0.0, 0.0, z], [1.0, 0.0, z], [0.0, 1.0, z]] {
|
||||
sc.glass_tri(p, [0.0, 0.0, 1.0], [255, 0, 0, 100]);
|
||||
}
|
||||
}
|
||||
let sorted = sort_back_to_front(&sc.glass, [0.0, 0.0, 1.0]);
|
||||
let z0 = f32::from_le_bytes(sorted[8..12].try_into().unwrap());
|
||||
assert_eq!(z0, -5.0, "the far triangle first");
|
||||
assert_eq!(sorted.len(), sc.glass.len());
|
||||
// Seen from below, the order flips.
|
||||
let sorted = sort_back_to_front(&sc.glass, [0.0, 0.0, -1.0]);
|
||||
assert_eq!(f32::from_le_bytes(sorted[8..12].try_into().unwrap()), 5.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@ use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Keys the application handles before any command shortcut.
|
||||
pub const RESERVED: &[&str] = &["Escape", "S", "V", "F2", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O"];
|
||||
pub const RESERVED: &[&str] =
|
||||
&["Escape", "S", "V", "F2", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O", "Ctrl+Comma"];
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Keymap {
|
||||
/// Command id → key (`""`: no key). Only the changed ones.
|
||||
pub custom: BTreeMap<String, String>,
|
||||
/// The Keyboard Shortcuts window.
|
||||
/// Asks for the Keyboard Shortcuts page of Preferences (File and Help menus).
|
||||
pub open: bool,
|
||||
/// Opened from Help: a list to read, not to edit.
|
||||
pub read_only: bool,
|
||||
@@ -113,11 +114,10 @@ pub fn reset(app: &mut SolveApp, id: Option<&str>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Keyboard Shortcuts window.
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if !app.keymap.open {
|
||||
return;
|
||||
}
|
||||
/// The Keyboard Shortcuts page of Preferences: search, rebind (click, then press the key), clear,
|
||||
/// reset; a key another command uses is reported and taken over only on request.
|
||||
pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
// A key press while waiting for one binds it (Esc cancels).
|
||||
if let Some(id) = app.keymap.capturing.clone() {
|
||||
let press = ctx.input(|i| {
|
||||
@@ -142,18 +142,20 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let mut open = true;
|
||||
let mut action: Option<(String, &str)> = None;
|
||||
crate::frame::window(ctx, "Keyboard Shortcuts", crate::frame::Width::Wide)
|
||||
.id(egui::Id::new("sc_keymap"))
|
||||
.open(&mut open)
|
||||
.default_size([520.0, 480.0])
|
||||
.show(ctx, |ui| {
|
||||
{
|
||||
{
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut app.keymap.filter).hint_text("Filter commands or keys").desired_width(300.0));
|
||||
if !app.keymap.read_only && ui.button("Reset All").clicked() {
|
||||
action = Some((String::new(), "reset_all"));
|
||||
}
|
||||
if ui.button("Export…").on_hover_text("Keys, S box favourites and the toolbar, as a file").clicked() {
|
||||
action = Some((String::new(), "export"));
|
||||
}
|
||||
if !app.keymap.read_only && ui.button("Import…").clicked() {
|
||||
action = Some((String::new(), "import"));
|
||||
}
|
||||
});
|
||||
if let Some(m) = &app.keymap.message {
|
||||
ui.label(RichText::new(m).color(t.warning));
|
||||
@@ -170,7 +172,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
ui.separator();
|
||||
let q = app.keymap.filter.to_lowercase();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::ScrollArea::both().max_height(330.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
egui::Grid::new("sc_keymap_grid").num_columns(4).striped(true).spacing([10.0, 4.0]).show(ui, |ui| {
|
||||
for c in command_specs() {
|
||||
let key = effective(app, c.id).unwrap_or_default();
|
||||
@@ -193,8 +195,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
continue;
|
||||
}
|
||||
let b =
|
||||
egui::Button::new(RichText::new(text).color(if changed { t.accent } else { t.text })).min_size(egui::vec2(110.0, 0.0));
|
||||
let b = egui::Button::new(RichText::new(text).color(if changed { t.accent } else { t.text })).min_size(egui::vec2(80.0, 0.0));
|
||||
if ui.add(b).on_hover_text("Click, then press the new key").clicked() {
|
||||
action = Some((c.id.to_string(), "capture"));
|
||||
}
|
||||
@@ -210,7 +211,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
match action {
|
||||
Some((id, "capture")) => {
|
||||
app.keymap.capturing = Some(id);
|
||||
@@ -220,6 +222,21 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
Some((id, "clear")) => drop(bind(app, &id, "", false)),
|
||||
Some((id, "reset")) => reset(app, Some(&id)),
|
||||
Some((_, "reset_all")) => reset(app, None),
|
||||
Some((_, "export")) => {
|
||||
let text = crate::toolbar_custom::export(app).to_string();
|
||||
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f("SolveCraft shortcuts.json", &["json"])) {
|
||||
app.keymap.message = std::fs::write(&path, text).err().map(|e| format!("export: {e}"));
|
||||
}
|
||||
}
|
||||
Some((_, "import")) => {
|
||||
if let Some(path) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
let r = std::fs::read_to_string(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|t| serde_json::from_str::<Value>(&t).map_err(|e| e.to_string()))
|
||||
.and_then(|v| crate::toolbar_custom::import(app, &v));
|
||||
app.keymap.message = r.err().map(|e| format!("import: {e}"));
|
||||
}
|
||||
}
|
||||
Some((pair, "replace")) => {
|
||||
if let Some((id, key)) = pair.split_once('\n') {
|
||||
let _ = bind(app, id, key, true);
|
||||
@@ -233,15 +250,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !open {
|
||||
app.keymap.open = false;
|
||||
app.keymap.capturing = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the window waiting for a key (so the key must not also run a command)?
|
||||
/// Stop waiting for a key (the dialog closed).
|
||||
pub fn stop_capture(app: &mut SolveApp) {
|
||||
app.keymap.capturing = None;
|
||||
app.keymap.conflict = None;
|
||||
app.keymap.message = None;
|
||||
}
|
||||
|
||||
/// Is the page waiting for a key (so the key must not also run a command)?
|
||||
pub fn capturing(app: &SolveApp) -> bool {
|
||||
app.keymap.open && app.keymap.capturing.is_some()
|
||||
app.prefs_window.open && app.prefs_window.section == crate::prefs::PAGE_SHORTCUTS && app.keymap.capturing.is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -37,6 +37,23 @@ pub mod inference;
|
||||
pub mod keymap;
|
||||
#[cfg(test)]
|
||||
mod menu_tests;
|
||||
#[cfg(test)]
|
||||
mod status_tests {
|
||||
#[test]
|
||||
fn the_status_bar_names_commands_by_label() {
|
||||
assert_eq!(super::command_label("solid.fillet"), "Fillet");
|
||||
assert_eq!(
|
||||
super::friendly_error("solid.shell", "invalid parameters for `solid.shell`: offset: must be positive"),
|
||||
"Shell: offset: must be positive"
|
||||
);
|
||||
assert_eq!(super::friendly_error("edit.undo", "nothing to undo"), "Undo: nothing to undo");
|
||||
let mut app = super::SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
app.run("solid.box", serde_json::json!({"length": 5, "width": 5, "height": 5})).unwrap();
|
||||
assert_eq!(app.session.log.last().map(String::as_str), Some("Box done"));
|
||||
let _ = app.run("edit.redo", serde_json::json!({}));
|
||||
assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last());
|
||||
}
|
||||
}
|
||||
pub mod palette;
|
||||
pub mod params_dialog;
|
||||
pub mod prefs;
|
||||
@@ -54,10 +71,12 @@ pub mod sketch_dims;
|
||||
mod sketch_edit_tests;
|
||||
pub mod sketch_palette;
|
||||
pub mod sketch_tools;
|
||||
pub mod stand_up;
|
||||
pub mod theme;
|
||||
pub mod timeline;
|
||||
pub mod titlebar;
|
||||
pub mod toolbar;
|
||||
pub mod toolbar_custom;
|
||||
pub mod tools;
|
||||
pub mod viewport;
|
||||
pub mod workspace;
|
||||
@@ -97,6 +116,8 @@ pub struct UiState {
|
||||
pub visual_style: u8,
|
||||
/// A soft shadow on the ground under the model (seen from above).
|
||||
pub ground_shadow: bool,
|
||||
/// The world's up axis (Z or Y; #3): the camera, view cube, home view and ground follow it.
|
||||
pub up_axis: solvecraft_engine::render::UpAxis,
|
||||
/// Sketches hidden one by one, and finished sketches shown although a feature uses them.
|
||||
pub hidden_sketches: Vec<u64>,
|
||||
pub shown_sketches: Vec<u64>,
|
||||
@@ -128,6 +149,7 @@ impl Default for UiState {
|
||||
pick_bodies: false,
|
||||
visual_style: 0,
|
||||
ground_shadow: true,
|
||||
up_axis: Default::default(),
|
||||
hidden_sketches: Vec::new(),
|
||||
shown_sketches: Vec::new(),
|
||||
hidden_profiles: Vec::new(),
|
||||
@@ -177,6 +199,8 @@ pub struct SolveApp {
|
||||
pub keymap: keymap::Keymap,
|
||||
/// The Help menu and the About window.
|
||||
pub help: help::HelpState,
|
||||
/// Toolbar buttons the user pinned, removed or reordered.
|
||||
pub toolbar_custom: toolbar_custom::ToolbarCustom,
|
||||
/// Preferences kept between runs, and their window.
|
||||
pub preferences: prefs::Prefs,
|
||||
pub prefs_window: prefs::PrefsWindow,
|
||||
@@ -191,6 +215,8 @@ pub struct SolveApp {
|
||||
pub autosave: Option<solvecraft_engine::recovery::Autosaver>,
|
||||
/// Designs left by crashed apps, offered for recovery.
|
||||
pub recoverable: Vec<solvecraft_engine::recovery::Entry>,
|
||||
/// The bodies of a just-opened Y-up file, offered to be stood up (`stand_up`).
|
||||
pub stand_up: Option<Vec<String>>,
|
||||
/// The last command was a big operation: autosave after it.
|
||||
pub big_operation: bool,
|
||||
/// Autosave interval (minutes; 0 = only after big operations). A preference.
|
||||
@@ -238,6 +264,7 @@ impl SolveApp {
|
||||
sbox: shortcut_box::ShortcutBox::default(),
|
||||
keymap: keymap::Keymap::default(),
|
||||
help: help::HelpState::default(),
|
||||
toolbar_custom: toolbar_custom::ToolbarCustom::default(),
|
||||
preferences: prefs::Prefs::default(),
|
||||
prefs_window: prefs::PrefsWindow::default(),
|
||||
preview: preview::PreviewState::default(),
|
||||
@@ -247,6 +274,7 @@ impl SolveApp {
|
||||
quit_requested: false,
|
||||
autosave: None,
|
||||
recoverable: Vec::new(),
|
||||
stand_up: None,
|
||||
big_operation: false,
|
||||
autosave_minutes: 5.0,
|
||||
custom_titlebar: false,
|
||||
@@ -288,7 +316,10 @@ impl SolveApp {
|
||||
"recent": self.home.recent,
|
||||
"shortcut_box": self.sbox.prefs(),
|
||||
"shortcuts": self.keymap.prefs(),
|
||||
"toolbar": self.toolbar_custom.prefs(),
|
||||
"autosave_minutes": self.autosave_minutes,
|
||||
"ground_shadow": self.ui.ground_shadow,
|
||||
"agent_cursor": self.ui.agent_cursor,
|
||||
"preferences": self.preferences,
|
||||
})
|
||||
.to_string()
|
||||
@@ -339,9 +370,22 @@ impl SolveApp {
|
||||
if let Some(s) = v.get("shortcuts") {
|
||||
self.keymap.load(s);
|
||||
}
|
||||
if let Some(t) = v.get("toolbar") {
|
||||
self.toolbar_custom.load(t);
|
||||
}
|
||||
if let Some(p) = v.get("preferences").and_then(|p| serde_json::from_value::<prefs::Prefs>(p.clone()).ok()) {
|
||||
self.preferences = p;
|
||||
}
|
||||
// Saved before the theme was a preference: the Dark/Light flag says which.
|
||||
if v.get("preferences").and_then(|p| p.get("theme")).is_none() {
|
||||
self.preferences.theme = if self.ui.dark { "dark" } else { "light" }.into();
|
||||
}
|
||||
if let Some(g) = flag("ground_shadow") {
|
||||
self.ui.ground_shadow = g;
|
||||
}
|
||||
if let Some(a) = v.get("agent_cursor").and_then(|a| serde_json::from_value(a.clone()).ok()) {
|
||||
self.ui.agent_cursor = a;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||||
@@ -360,7 +404,7 @@ impl SolveApp {
|
||||
}
|
||||
match &r {
|
||||
Ok(_) => {
|
||||
self.session.echo(format!("{id}: done"));
|
||||
self.session.echo(format!("{} done", command_label(id)));
|
||||
if matches!(id, "file.new" | "doc.open") {
|
||||
self.cam = Camera::default();
|
||||
self.fit_view();
|
||||
@@ -372,8 +416,9 @@ impl SolveApp {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.session.echo(format!("{id}: {e}"));
|
||||
self.set_status(e.clone(), true);
|
||||
let msg = friendly_error(id, e);
|
||||
self.session.echo(msg.clone());
|
||||
self.set_status(msg, true);
|
||||
}
|
||||
}
|
||||
if self.session.active_sketch.is_some() && self.ui.tab != "SKETCH" {
|
||||
@@ -544,8 +589,12 @@ impl SolveApp {
|
||||
}
|
||||
|
||||
/// Is the origin widget shown (always while picking a sketch plane)?
|
||||
/// Origin planes and axes are drawn and pickable. Hidden ones only come back while Create
|
||||
/// Sketch has nothing else to offer (no body shown).
|
||||
pub fn origin_visible(&self) -> bool {
|
||||
self.ui.show_origin || self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Sketch))
|
||||
self.ui.show_origin
|
||||
|| (self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Sketch))
|
||||
&& !self.session.world_state().bodies.iter().any(|b| !self.ui.hidden_bodies.contains(&b.name)))
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, s: impl Into<String>, error: bool) {
|
||||
@@ -557,6 +606,7 @@ impl SolveApp {
|
||||
documents::prepare_open(self);
|
||||
if self.run("doc.open", json!({ "path": path })).is_ok() {
|
||||
self.fit_view();
|
||||
stand_up::offer(self, path, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,8 +614,10 @@ impl SolveApp {
|
||||
/// current design.
|
||||
pub fn insert_path(&mut self, path: &str) {
|
||||
let cmd = if solvecraft_engine::io::is_mesh_path(path) { "file.insert_mesh" } else { "file.insert_step" };
|
||||
let before: Vec<String> = self.session.world_state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
if let Ok(r) = self.run(cmd, json!({ "path": path })) {
|
||||
self.fit_view();
|
||||
stand_up::offer(self, path, &before);
|
||||
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
|
||||
self.set_status(format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or("")), false);
|
||||
}
|
||||
@@ -584,6 +636,16 @@ impl SolveApp {
|
||||
}
|
||||
|
||||
/// Frame the model at once (programmatic use; the toolbar animates).
|
||||
/// Make `up` the world's up axis: the camera turns to the same standard view in that world
|
||||
/// (home) and frames the model.
|
||||
pub fn set_up_axis(&mut self, up: solvecraft_engine::render::UpAxis) {
|
||||
self.ui.up_axis = up;
|
||||
self.cam_anim = None;
|
||||
self.cam.up = up;
|
||||
self.cam.set_view(StandardView::Iso);
|
||||
self.fit_view();
|
||||
}
|
||||
|
||||
pub fn fit_view(&mut self) {
|
||||
self.cam_anim = None;
|
||||
self.cam = self.fitted(self.cam);
|
||||
@@ -606,7 +668,18 @@ impl SolveApp {
|
||||
if !to.is_valid() {
|
||||
return;
|
||||
}
|
||||
self.cam_anim = Some(CameraAnim::new(self.cam, to, self.now));
|
||||
// Preferences › Navigation: animated (at a chosen speed) or immediate.
|
||||
if !self.preferences.look_at_anim {
|
||||
self.cam = to;
|
||||
self.cam_anim = None;
|
||||
return;
|
||||
}
|
||||
let mut anim = CameraAnim::new(self.cam, to, self.now);
|
||||
let speed = f64::from(self.preferences.look_at_speed);
|
||||
if speed.is_finite() && speed > 0.0 {
|
||||
anim.duration /= speed;
|
||||
}
|
||||
self.cam_anim = Some(anim);
|
||||
}
|
||||
|
||||
/// Animated view changes for the view cube and navigation bar.
|
||||
@@ -649,6 +722,7 @@ impl SolveApp {
|
||||
/// Per-frame logic before layout.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
self.esc_handled = false;
|
||||
prefs::apply(self, ctx);
|
||||
if !self.styled || theme::is_dark() != self.ui.dark {
|
||||
theme::set_dark(self.ui.dark);
|
||||
theme::apply(ctx);
|
||||
@@ -659,6 +733,9 @@ impl SolveApp {
|
||||
self.fitted = true;
|
||||
}
|
||||
self.now = ctx.input(|i| i.time);
|
||||
if self.cam.up != self.ui.up_axis {
|
||||
self.set_up_axis(self.ui.up_axis);
|
||||
}
|
||||
self.step_view_animation(ctx);
|
||||
self.drain_control(ctx);
|
||||
agent_cursor::step(self, ctx);
|
||||
@@ -733,13 +810,13 @@ impl SolveApp {
|
||||
sketch_palette::show(self, ui.ctx());
|
||||
params_dialog::show(self, ui.ctx());
|
||||
recovery_ui::show(self, ui.ctx());
|
||||
stand_up::show(self, ui.ctx());
|
||||
context_menu::show(self, ui.ctx());
|
||||
delete::show(self, ui.ctx());
|
||||
if self.custom_titlebar {
|
||||
titlebar::resize_zones(ui);
|
||||
}
|
||||
shortcut_box::show(self, ui.ctx());
|
||||
keymap::show(self, ui.ctx());
|
||||
prefs::show(self, ui.ctx());
|
||||
agent_cursor::paint(self, ui.ctx());
|
||||
self.frame_ms = now_ms() - t0;
|
||||
@@ -821,6 +898,18 @@ impl SolveApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A command's name as people see it (its label), for the status bar.
|
||||
pub fn command_label(id: &str) -> &str {
|
||||
solvecraft_engine::find_command(id).map_or(id, |c| c.label)
|
||||
}
|
||||
|
||||
/// An engine error with the command's label instead of its id: "Shell: offset must be …".
|
||||
pub fn friendly_error(id: &str, e: &str) -> String {
|
||||
let label = command_label(id);
|
||||
let msg = e.replace(&format!("invalid parameters for `{id}`: "), "").replace(&format!("{id}: "), "").replace(&format!("`{id}`"), label);
|
||||
if msg.starts_with(label) { msg } else { format!("{label}: {msg}") }
|
||||
}
|
||||
|
||||
pub fn now_ms() -> f64 {
|
||||
use web_time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
|
||||
|
||||
@@ -455,3 +455,76 @@ fn f2_renames_the_selected_item() {
|
||||
app.run("select.clear", json!({})).unwrap();
|
||||
assert!(!context_menu::rename_selection(&mut app, pos2(0.0, 0.0)), "nothing selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_moves_turn_about_their_origin_then_move() {
|
||||
let mut app = sample_app();
|
||||
let b = body(&app);
|
||||
app.run("component.from_bodies", json!({"bodies": [b]})).unwrap();
|
||||
let occ = app.session.doc.occurrences[0].id;
|
||||
let mut m = crate::browser::OccMove::new(occ);
|
||||
m.pivot = Some(solvecraft_engine::geom::Vec3::new(5.0, 0.0, 0.0));
|
||||
m.translate = [10.0, 0.0, 0.0];
|
||||
m.angles = [0.0, 0.0, 90.0];
|
||||
crate::browser::apply_occurrence_move(&mut app, &m);
|
||||
let t = *app.session.pending_moves.get(&occ).unwrap();
|
||||
// The pivot lands 10 mm along X; X turns into Y.
|
||||
let p = solvecraft_engine::doc::apply_point(&t, solvecraft_engine::geom::Vec3::new(5.0, 0.0, 0.0));
|
||||
let x = solvecraft_engine::doc::apply_vector(&t, solvecraft_engine::geom::Vec3::X);
|
||||
assert!((p.x - 15.0).abs() < 1e-9 && p.y.abs() < 1e-9, "{p:?}");
|
||||
assert!((x.y - 1.0).abs() < 1e-9, "{x:?}");
|
||||
// Changing the values again starts from where it was, not from the last move.
|
||||
m.translate = [0.0, 0.0, 0.0];
|
||||
m.angles = [0.0; 3];
|
||||
crate::browser::apply_occurrence_move(&mut app, &m);
|
||||
assert_eq!(*app.session.pending_moves.get(&occ).unwrap(), solvecraft_engine::doc::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolbar_pins_removes_reorders_and_keeps_its_layout() {
|
||||
use crate::toolbar_custom as tc;
|
||||
let mut app = sample_app();
|
||||
app.ui.tab = "SOLID".into();
|
||||
let ids = |app: &SolveApp, panel: &str| -> Vec<&'static str> {
|
||||
let lists = tc::panel_lists("SOLID");
|
||||
let (_, cmds, n) = lists.iter().find(|(p, ..)| *p == panel).unwrap();
|
||||
tc::buttons(app, "SOLID", panel, cmds, *n).iter().map(|c| c.id).collect()
|
||||
};
|
||||
let create = ids(&app, "CREATE");
|
||||
// Pin a command that isn't a button yet: it lands at the end of its panel.
|
||||
let extra =
|
||||
tc::panel_lists("SOLID").iter().find(|(p, ..)| *p == "CREATE").unwrap().1.iter().map(|c| c.id).find(|id| !create.contains(id)).unwrap();
|
||||
act(&mut app, &Target::ToolbarCommand { id: extra.into() }, "Pin to Toolbar");
|
||||
assert_eq!(ids(&app, "CREATE").last(), Some(&extra));
|
||||
// Remove a default button.
|
||||
act(&mut app, &Target::ToolbarCommand { id: create[0].into() }, "Remove from Toolbar");
|
||||
assert!(!ids(&app, "CREATE").contains(&create[0]));
|
||||
// Reorder: the pinned one before the second default; then into another panel.
|
||||
tc::reorder(&mut app, "SOLID", extra, "CREATE", Some(create[1]));
|
||||
assert_eq!(ids(&app, "CREATE")[0], extra);
|
||||
tc::reorder(&mut app, "SOLID", extra, "MODIFY", None);
|
||||
assert!(!ids(&app, "CREATE").contains(&extra) && ids(&app, "MODIFY").last() == Some(&extra));
|
||||
// Pin to Shortcuts goes to the S box's favourites.
|
||||
act(&mut app, &Target::ToolbarCommand { id: "Extrude".into() }, "Pin to Shortcuts");
|
||||
assert!(app.sbox.pinned.iter().any(|p| p == "Extrude"));
|
||||
// Kept between runs, and exported and imported with the shortcuts.
|
||||
let mut other = sample_app();
|
||||
other.load_prefs(&app.prefs());
|
||||
assert_eq!(other.toolbar_custom, app.toolbar_custom);
|
||||
let file = tc::export(&app);
|
||||
let mut third = sample_app();
|
||||
tc::import(&mut third, &file).unwrap();
|
||||
assert_eq!(third.toolbar_custom, app.toolbar_custom);
|
||||
assert!(tc::import(&mut third, &json!({"format": "nope"})).is_err());
|
||||
// Reset Toolbar puts the tab back.
|
||||
act(&mut app, &Target::ToolbarCommand { id: extra.into() }, "Reset Toolbar");
|
||||
assert_eq!(ids(&app, "CREATE"), create);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_workspace_menu_lists_design_and_greys_the_rest() {
|
||||
let app = sample_app();
|
||||
let items = context_menu::items(&app, &Target::Workspace);
|
||||
assert!(find(&items, "Design").enabled);
|
||||
assert!(!find(&items, "Render").enabled && find(&items, "Render").shortcut == "coming later");
|
||||
}
|
||||
|
||||
+537
-118
@@ -1,7 +1,12 @@
|
||||
//! Preferences (File › Preferences), in the spirit of Fusion's: General (theme, units of new
|
||||
//! designs, autosave interval), Navigation (mouse mapping preset, zoom direction, orbit centre), Display
|
||||
//! (grid, perspective, visual style with or without edges, display precision, anti-aliasing), Sketch and Selection. Everything
|
||||
//! applies at once and is kept between runs, except anti-aliasing, which needs a restart.
|
||||
//! Preferences, one dialog in the spirit of Fusion's, opened from the gear at the top right of the
|
||||
//! window, File › Preferences or Ctrl+, (Cmd+, on macOS): General (theme, UI scale, units of new
|
||||
//! designs, autosave, display precision, the agent cursor), Navigation (mouse presets, zoom
|
||||
//! direction, zoom to the cursor, orbit centre, Look At animation), Keyboard Shortcuts (search,
|
||||
//! rebind, conflicts; `keymap`), Design (default modelling orientation, Y up or Z up), Graphics
|
||||
//! (visual style, grid, shadow, perspective, anti-aliasing), Sketch and Selection. Changes apply
|
||||
//! at once; Cancel puts back what was there when the dialog opened, OK keeps them, Apply keeps
|
||||
//! them and stays open, and Restore Defaults resets the page shown. Everything is kept between
|
||||
//! runs, except anti-aliasing, which needs a restart.
|
||||
|
||||
use egui::{Modifiers, Pos2, RichText};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -14,7 +19,13 @@ use crate::viewport::NavMode;
|
||||
|
||||
pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"];
|
||||
/// Mouse mappings: (id, label).
|
||||
pub const NAV_PRESETS: [(&str, &str); 3] = [("fusion", "Fusion"), ("solidworks", "SolidWorks"), ("inventor", "Inventor")];
|
||||
pub const NAV_PRESETS: [(&str, &str); 5] =
|
||||
[("fusion", "Fusion"), ("solidworks", "SolidWorks"), ("inventor", "Inventor"), ("alias", "Alias"), ("tinkercad", "Tinkercad")];
|
||||
/// The dialog's pages, in order.
|
||||
pub const PAGES: [&str; 7] = ["General", "Navigation", "Keyboard Shortcuts", "Design", "Graphics", "Sketch", "Selection"];
|
||||
pub const PAGE_SHORTCUTS: usize = 2;
|
||||
pub const PAGE_DESIGN: usize = 3;
|
||||
pub const THEMES: [(&str, &str); 3] = [("dark", "Dark"), ("light", "Light"), ("system", "System")];
|
||||
pub const MSAA: [u32; 4] = [1, 2, 4, 8];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -33,6 +44,19 @@ pub struct Prefs {
|
||||
/// Decimals shown for lengths and angles.
|
||||
pub length_decimals: u8,
|
||||
pub angle_decimals: u8,
|
||||
/// `dark`, `light` or `system` (follow the operating system).
|
||||
pub theme: String,
|
||||
/// Interface scale (1 = 100 %).
|
||||
pub ui_scale: f32,
|
||||
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
|
||||
pub zoom_to_cursor: bool,
|
||||
/// View changes (view cube, Look At, Home) animate; `look_at_speed` scales how fast.
|
||||
pub look_at_anim: bool,
|
||||
pub look_at_speed: f32,
|
||||
/// Default modelling orientation: `z` (Z up) or `y` (Y up) (#3).
|
||||
pub up_axis: String,
|
||||
/// After a STEP or IGES file opens, offer to stand a Y-up part up (`stand_up`).
|
||||
pub offer_stand_up: bool,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -45,6 +69,13 @@ impl Default for Prefs {
|
||||
msaa: 4,
|
||||
length_decimals: 2,
|
||||
angle_decimals: 1,
|
||||
theme: "dark".into(),
|
||||
ui_scale: 1.0,
|
||||
zoom_to_cursor: true,
|
||||
look_at_anim: true,
|
||||
look_at_speed: 1.0,
|
||||
up_axis: "z".into(),
|
||||
offer_stand_up: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +84,127 @@ impl Default for Prefs {
|
||||
#[derive(Default)]
|
||||
pub struct PrefsWindow {
|
||||
pub open: bool,
|
||||
section: usize,
|
||||
/// The page shown (index into [`PAGES`]).
|
||||
pub section: usize,
|
||||
/// Everything as it was when the dialog opened (or at the last Apply), for Cancel.
|
||||
snapshot: Option<Snapshot>,
|
||||
}
|
||||
|
||||
/// Open the dialog at a page.
|
||||
pub fn open_at(app: &mut SolveApp, page: usize) {
|
||||
app.prefs_window.open = true;
|
||||
app.prefs_window.section = page.min(PAGES.len() - 1);
|
||||
}
|
||||
|
||||
/// Every setting the dialog changes, for Cancel and Restore Defaults.
|
||||
#[derive(Clone)]
|
||||
struct Snapshot {
|
||||
prefs: Prefs,
|
||||
dark: bool,
|
||||
grid: bool,
|
||||
shadow: bool,
|
||||
perspective: bool,
|
||||
style: u8,
|
||||
pick_bodies: bool,
|
||||
agent: crate::agent_cursor::Settings,
|
||||
autosave: f64,
|
||||
auto_project: bool,
|
||||
keys: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
fn take(app: &SolveApp) -> Snapshot {
|
||||
Snapshot {
|
||||
prefs: app.preferences.clone(),
|
||||
dark: app.ui.dark,
|
||||
grid: app.ui.show_grid,
|
||||
shadow: app.ui.ground_shadow,
|
||||
perspective: app.ui.perspective,
|
||||
style: app.ui.visual_style,
|
||||
pick_bodies: app.ui.pick_bodies,
|
||||
agent: app.ui.agent_cursor,
|
||||
autosave: app.autosave_minutes,
|
||||
auto_project: app.session.auto_project,
|
||||
keys: app.keymap.custom.clone(),
|
||||
}
|
||||
}
|
||||
fn restore(self, app: &mut SolveApp) {
|
||||
let msaa = app.preferences.msaa;
|
||||
app.preferences = self.prefs;
|
||||
app.ui.dark = self.dark;
|
||||
app.ui.show_grid = self.grid;
|
||||
app.ui.ground_shadow = self.shadow;
|
||||
app.ui.perspective = self.perspective;
|
||||
app.ui.visual_style = self.style;
|
||||
app.ui.pick_bodies = self.pick_bodies;
|
||||
app.ui.agent_cursor = self.agent;
|
||||
app.autosave_minutes = self.autosave;
|
||||
set_autosave(app);
|
||||
app.session.auto_project = self.auto_project;
|
||||
app.keymap.custom = self.keys;
|
||||
if app.preferences.msaa != msaa {
|
||||
save_graphics(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore Defaults for one page.
|
||||
fn defaults(app: &mut SolveApp, page: usize) {
|
||||
let d = Prefs::default();
|
||||
let p = &mut app.preferences;
|
||||
match page {
|
||||
0 => {
|
||||
p.theme = d.theme;
|
||||
p.ui_scale = d.ui_scale;
|
||||
p.default_units = d.default_units;
|
||||
p.length_decimals = d.length_decimals;
|
||||
p.angle_decimals = d.angle_decimals;
|
||||
app.ui.dark = true;
|
||||
app.ui.agent_cursor = Default::default();
|
||||
app.autosave_minutes = 5.0;
|
||||
set_autosave(app);
|
||||
}
|
||||
1 => {
|
||||
p.nav = d.nav;
|
||||
p.zoom_reverse = d.zoom_reverse;
|
||||
p.zoom_to_cursor = d.zoom_to_cursor;
|
||||
p.orbit_cursor = d.orbit_cursor;
|
||||
p.look_at_anim = d.look_at_anim;
|
||||
p.look_at_speed = d.look_at_speed;
|
||||
}
|
||||
PAGE_SHORTCUTS => crate::keymap::reset(app, None),
|
||||
PAGE_DESIGN => {
|
||||
p.up_axis = d.up_axis;
|
||||
p.offer_stand_up = d.offer_stand_up;
|
||||
}
|
||||
4 => {
|
||||
let msaa = p.msaa != d.msaa;
|
||||
p.msaa = d.msaa;
|
||||
app.ui.visual_style = 0;
|
||||
app.ui.show_grid = true;
|
||||
app.ui.ground_shadow = true;
|
||||
app.ui.perspective = false;
|
||||
if msaa {
|
||||
save_graphics(app);
|
||||
}
|
||||
}
|
||||
5 => app.session.auto_project = true,
|
||||
_ => app.ui.pick_bodies = false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings that follow the preferences every frame: the theme when it follows the system, and
|
||||
/// the interface scale.
|
||||
pub fn apply(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if app.preferences.theme == "system"
|
||||
&& let Some(t) = ctx.system_theme()
|
||||
{
|
||||
app.ui.dark = t == egui::Theme::Dark;
|
||||
}
|
||||
let z = app.preferences.ui_scale.clamp(0.5, 2.5);
|
||||
if z.is_finite() && (ctx.zoom_factor() - z).abs() > 1e-3 {
|
||||
ctx.set_zoom_factor(z);
|
||||
}
|
||||
}
|
||||
|
||||
/// A new, empty design takes the preferred units (not an undo step, not a change to save).
|
||||
@@ -80,7 +231,8 @@ pub fn set_autosave(app: &mut SolveApp) {
|
||||
/// Which navigation a mouse drag does under the preset.
|
||||
pub fn nav_mode(app: &SolveApp, middle: bool, secondary: bool, mods: Modifiers) -> Option<NavMode> {
|
||||
if secondary && !middle {
|
||||
return Some(NavMode::Orbit);
|
||||
// Tinkercad: Shift+right pans; everywhere else right drag orbits.
|
||||
return Some(if app.preferences.nav == "tinkercad" && mods.shift { NavMode::Pan } else { NavMode::Orbit });
|
||||
}
|
||||
if !middle {
|
||||
return None;
|
||||
@@ -103,6 +255,10 @@ pub fn nav_mode(app: &SolveApp, middle: bool, secondary: bool, mods: Modifiers)
|
||||
} else {
|
||||
NavMode::Pan
|
||||
}),
|
||||
// Alias: middle pans, Ctrl+middle zooms (right drag orbits).
|
||||
"alias" => Some(if ctrl { NavMode::Zoom } else { NavMode::Pan }),
|
||||
// Tinkercad: middle pans.
|
||||
"tinkercad" => Some(NavMode::Pan),
|
||||
// Fusion: middle pans, Shift+middle orbits.
|
||||
_ => Some(if mods.shift { NavMode::Orbit } else { NavMode::Pan }),
|
||||
}
|
||||
@@ -198,141 +354,311 @@ fn save_graphics(app: &SolveApp) {
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
// File › Keyboard Shortcuts and Help › Keyboard Shortcuts open this dialog at that page.
|
||||
if app.keymap.open {
|
||||
app.keymap.open = false;
|
||||
open_at(app, PAGE_SHORTCUTS);
|
||||
}
|
||||
if !app.prefs_window.open {
|
||||
app.prefs_window.snapshot = None;
|
||||
return;
|
||||
}
|
||||
if app.prefs_window.snapshot.is_none() {
|
||||
app.prefs_window.snapshot = Some(Snapshot::take(app));
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let mut open = true;
|
||||
let sections = ["General", "Navigation", "Display", "Sketch", "Selection"];
|
||||
let mut button: Option<&str> = None;
|
||||
let before = app.preferences.clone();
|
||||
crate::frame::window(ctx, "Preferences", crate::frame::Width::Wide)
|
||||
.id(egui::Id::new("sc_prefs"))
|
||||
.open(&mut open)
|
||||
.default_size([560.0, 360.0])
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
// A fixed page area: the vertical separator fills the height it is given, so an
|
||||
// auto-sized one would grow every frame.
|
||||
ui.allocate_ui_with_layout(egui::vec2(700.0, 400.0), egui::Layout::left_to_right(egui::Align::Min), |ui| {
|
||||
ui.set_min_size(egui::vec2(700.0, 400.0));
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(120.0);
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
ui.set_width(140.0);
|
||||
for (i, s) in PAGES.iter().enumerate() {
|
||||
if ui.selectable_label(app.prefs_window.section == i, *s).clicked() {
|
||||
app.prefs_window.section = i;
|
||||
app.keymap.read_only = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
egui::Grid::new("sc_prefs_grid").num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section {
|
||||
0 => {
|
||||
ui.label("Theme");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut app.ui.dark, true, "Dark");
|
||||
ui.radio_value(&mut app.ui.dark, false, "Light");
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Units of new designs");
|
||||
egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| {
|
||||
for u in UNITS {
|
||||
ui.selectable_value(&mut app.preferences.default_units, u.to_string(), u);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Autosave every");
|
||||
if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() {
|
||||
set_autosave(app);
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.label(RichText::new("0 turns it off").size(11.0).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
1 => {
|
||||
ui.label("Mouse");
|
||||
egui::ComboBox::from_id_salt("pref_nav")
|
||||
.selected_text(NAV_PRESETS.iter().find(|(id, _)| *id == app.preferences.nav).map_or("Fusion", |x| x.1))
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, label) in NAV_PRESETS {
|
||||
if ui.selectable_value(&mut app.preferences.nav, id.to_string(), label).clicked() {
|
||||
app.preferences.zoom_reverse = id == "solidworks";
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
let how = match app.preferences.nav.as_str() {
|
||||
"solidworks" => "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms",
|
||||
"inventor" => "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms",
|
||||
_ => "Middle drag pans, Shift+middle orbits",
|
||||
};
|
||||
ui.label(RichText::new(format!("{how}; right drag orbits; the wheel zooms at the cursor")).size(11.0).color(t.text_dim));
|
||||
ui.end_row();
|
||||
ui.label("Reverse zoom direction");
|
||||
ui.checkbox(&mut app.preferences.zoom_reverse, "");
|
||||
ui.end_row();
|
||||
ui.label("Orbit around");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre");
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, true, "Point under the cursor");
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
2 => {
|
||||
ui.label("Grid");
|
||||
ui.checkbox(&mut app.ui.show_grid, "");
|
||||
ui.end_row();
|
||||
ui.label("Perspective view");
|
||||
ui.checkbox(&mut app.ui.perspective, "");
|
||||
ui.end_row();
|
||||
ui.label("Visual style");
|
||||
let styles = ["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"];
|
||||
egui::ComboBox::from_id_salt("pref_style").selected_text(styles[usize::from(app.ui.visual_style.min(3))]).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
for (i, s) in styles.iter().enumerate() {
|
||||
ui.selectable_value(&mut app.ui.visual_style, i as u8, *s);
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.end_row();
|
||||
ui.label("Length decimals");
|
||||
ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6));
|
||||
ui.end_row();
|
||||
ui.label("Angle decimals");
|
||||
ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6));
|
||||
ui.end_row();
|
||||
ui.label("Anti-aliasing");
|
||||
egui::ComboBox::from_id_salt("pref_msaa").selected_text(format!("{}×", app.preferences.msaa)).show_ui(ui, |ui| {
|
||||
for m in MSAA {
|
||||
ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×"));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.label(RichText::new("Anti-aliasing applies the next time SolveCraft starts").size(11.0).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
3 => {
|
||||
ui.label("Project face edges into new sketches");
|
||||
let mut auto = app.session.auto_project;
|
||||
if ui.checkbox(&mut auto, "").changed() {
|
||||
let _ = app.run("sketch.auto_project", json!({ "value": auto }));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
_ => {
|
||||
ui.label("Click selects whole bodies");
|
||||
ui.checkbox(&mut app.ui.pick_bodies, "");
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
// A fixed width, so the window keeps its size from page to page.
|
||||
ui.set_width(540.0);
|
||||
ui.label(RichText::new(PAGES.get(app.prefs_window.section).copied().unwrap_or_default()).strong().size(14.0));
|
||||
ui.add_space(6.0);
|
||||
page(app, ui, &t);
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Restore Defaults").on_hover_text("Reset the settings on this page").clicked() {
|
||||
button = Some("defaults");
|
||||
}
|
||||
crate::frame::right_aligned(ui, 700.0, 170.0, |ui| {
|
||||
if ui.button("OK").clicked() {
|
||||
button = Some("ok");
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
button = Some("cancel");
|
||||
}
|
||||
if ui.button("Apply").clicked() {
|
||||
button = Some("apply");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if app.preferences.msaa != before.msaa {
|
||||
save_graphics(app);
|
||||
}
|
||||
if !open {
|
||||
app.prefs_window.open = false;
|
||||
if app.preferences.up_axis != before.up_axis {
|
||||
up_axis_changed(app);
|
||||
}
|
||||
let esc = ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !crate::keymap::capturing(app);
|
||||
match button {
|
||||
Some("defaults") => defaults(app, app.prefs_window.section),
|
||||
Some("apply") => app.prefs_window.snapshot = Some(Snapshot::take(app)),
|
||||
Some("ok") => close(app, false),
|
||||
Some("cancel") => close(app, true),
|
||||
_ if !open || esc => close(app, !open || esc),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the dialog; `revert`: put back what was there when it opened (Cancel, Esc, ×).
|
||||
fn close(app: &mut SolveApp, revert: bool) {
|
||||
if let Some(s) = app.prefs_window.snapshot.take()
|
||||
&& revert
|
||||
{
|
||||
let up = app.preferences.up_axis.clone();
|
||||
s.restore(app);
|
||||
if app.preferences.up_axis != up {
|
||||
up_axis_changed(app);
|
||||
}
|
||||
}
|
||||
app.prefs_window.open = false;
|
||||
app.keymap.read_only = false;
|
||||
crate::keymap::stop_capture(app);
|
||||
}
|
||||
|
||||
/// The default orientation changed: the view turns to match.
|
||||
fn up_axis_changed(app: &mut SolveApp) {
|
||||
app.animate_view("home");
|
||||
}
|
||||
|
||||
/// One page of the dialog.
|
||||
fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let note = |ui: &mut egui::Ui, s: &str| {
|
||||
ui.label("");
|
||||
ui.label(RichText::new(s).size(11.0).color(t.text_dim));
|
||||
ui.end_row();
|
||||
};
|
||||
if app.prefs_window.section == PAGE_SHORTCUTS {
|
||||
crate::keymap::page(app, ui);
|
||||
return;
|
||||
}
|
||||
egui::Grid::new(("sc_prefs_grid", app.prefs_window.section)).num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section {
|
||||
0 => {
|
||||
ui.label("Theme");
|
||||
ui.horizontal(|ui| {
|
||||
for (id, label) in THEMES {
|
||||
if ui.radio(app.preferences.theme == id, label).clicked() {
|
||||
app.preferences.theme = id.to_string();
|
||||
if id != "system" {
|
||||
app.ui.dark = id == "dark";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("UI scale");
|
||||
ui.add(egui::Slider::new(&mut app.preferences.ui_scale, 0.75..=2.0).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0)));
|
||||
ui.end_row();
|
||||
ui.label("Units of new designs");
|
||||
egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| {
|
||||
for u in UNITS {
|
||||
ui.selectable_value(&mut app.preferences.default_units, u.to_string(), u);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Autosave every");
|
||||
if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() {
|
||||
set_autosave(app);
|
||||
}
|
||||
ui.end_row();
|
||||
note(ui, "0 turns it off");
|
||||
ui.label("Length decimals");
|
||||
ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6));
|
||||
ui.end_row();
|
||||
ui.label("Angle decimals");
|
||||
ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6));
|
||||
ui.end_row();
|
||||
ui.label("Agent cursor");
|
||||
let s = &mut app.ui.agent_cursor;
|
||||
ui.checkbox(&mut s.show, "Show the pointer an AI agent moves (#35)");
|
||||
ui.end_row();
|
||||
ui.label("Agent cursor speed");
|
||||
ui.add_enabled_ui(s.show, |ui| {
|
||||
crate::agent_cursor::speed_slider(ui, &mut s.speed);
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.add_enabled(s.show, egui::Checkbox::new(&mut s.follow_camera, "Follow the camera (Look At sketches)"));
|
||||
ui.end_row();
|
||||
}
|
||||
1 => {
|
||||
ui.label("Pan, zoom, orbit");
|
||||
egui::ComboBox::from_id_salt("pref_nav")
|
||||
.selected_text(NAV_PRESETS.iter().find(|(id, _)| *id == app.preferences.nav).map_or("Fusion", |x| x.1))
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, label) in NAV_PRESETS {
|
||||
if ui.selectable_value(&mut app.preferences.nav, id.to_string(), label).clicked() {
|
||||
app.preferences.zoom_reverse = id == "solidworks";
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, &format!("{}; the wheel zooms", nav_help(&app.preferences.nav)));
|
||||
ui.label("Reverse zoom direction");
|
||||
ui.checkbox(&mut app.preferences.zoom_reverse, "");
|
||||
ui.end_row();
|
||||
ui.label("Zoom to the cursor");
|
||||
ui.checkbox(&mut app.preferences.zoom_to_cursor, "")
|
||||
.on_hover_text("The wheel zooms toward the point under the cursor (off: toward the view's centre)");
|
||||
ui.end_row();
|
||||
ui.label("Orbit around");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre");
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, true, "Point under the cursor");
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Animate view changes");
|
||||
ui.checkbox(&mut app.preferences.look_at_anim, "View cube, Look At, Home and Fit");
|
||||
ui.end_row();
|
||||
ui.label("Animation speed");
|
||||
ui.add_enabled(
|
||||
app.preferences.look_at_anim,
|
||||
egui::Slider::new(&mut app.preferences.look_at_speed, 0.5..=3.0).step_by(0.25).custom_formatter(|v, _| format!("{v:.2}×")),
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
PAGE_DESIGN => {
|
||||
ui.label("Default modelling orientation");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut app.preferences.up_axis, "z".to_string(), "Z up");
|
||||
ui.radio_value(&mut app.preferences.up_axis, "y".to_string(), "Y up");
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, "Which axis points up in the views (view cube, Home, Look At). Designs are unchanged.");
|
||||
ui.label("Offer to stand up Y-up files");
|
||||
ui.checkbox(&mut app.preferences.offer_stand_up, "")
|
||||
.on_hover_text("After a STEP or IGES file opens with Z up, offer a quarter turn about X");
|
||||
ui.end_row();
|
||||
}
|
||||
4 => {
|
||||
ui.label("Visual style");
|
||||
let styles = ["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"];
|
||||
egui::ComboBox::from_id_salt("pref_style").selected_text(styles[usize::from(app.ui.visual_style.min(3))]).show_ui(ui, |ui| {
|
||||
for (i, s) in styles.iter().enumerate() {
|
||||
ui.selectable_value(&mut app.ui.visual_style, i as u8, *s);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Grid");
|
||||
ui.checkbox(&mut app.ui.show_grid, "");
|
||||
ui.end_row();
|
||||
ui.label("Ground shadow");
|
||||
ui.checkbox(&mut app.ui.ground_shadow, "");
|
||||
ui.end_row();
|
||||
ui.label("Perspective view");
|
||||
ui.checkbox(&mut app.ui.perspective, "");
|
||||
ui.end_row();
|
||||
ui.label("Anti-aliasing");
|
||||
egui::ComboBox::from_id_salt("pref_msaa").selected_text(format!("{}×", app.preferences.msaa)).show_ui(ui, |ui| {
|
||||
for m in MSAA {
|
||||
ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×"));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, "Anti-aliasing applies the next time SolveCraft starts");
|
||||
ui.label("Renderer");
|
||||
ui.label(match (&app.viewport.gpu, &app.help.gpu) {
|
||||
(Some(_), Some(g)) => format!("GPU: {g}"),
|
||||
(Some(_), None) => "GPU".to_string(),
|
||||
_ => "CPU (software)".to_string(),
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
5 => {
|
||||
ui.label("Project face edges into new sketches");
|
||||
let mut auto = app.session.auto_project;
|
||||
if ui.checkbox(&mut auto, "").changed() {
|
||||
let _ = app.run("sketch.auto_project", json!({ "value": auto }));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
_ => {
|
||||
ui.label("Click selects whole bodies");
|
||||
ui.checkbox(&mut app.ui.pick_bodies, "");
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What the mouse does under a preset.
|
||||
pub fn nav_help(preset: &str) -> &'static str {
|
||||
match preset {
|
||||
"solidworks" => "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms, right drag orbits",
|
||||
"inventor" => "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms, right drag orbits",
|
||||
"alias" => "Alt+right or right drag orbits, middle drag pans, Ctrl+middle zooms",
|
||||
"tinkercad" => "Right drag orbits, middle or Shift+right drag pans",
|
||||
_ => "Middle drag pans, Shift+middle orbits, right drag orbits",
|
||||
}
|
||||
}
|
||||
|
||||
/// The control channel's `ui.prefs`: `open` (a page name) opens the dialog there, `button`
|
||||
/// presses OK, Cancel, Apply or Restore Defaults, `stand_up: true` answers the stand-up offer;
|
||||
/// any other keys set preferences. Returns them all, the open page and the offer.
|
||||
pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let mut rest = p.as_object().cloned().unwrap_or_default();
|
||||
if let Some(page) = rest.remove("open") {
|
||||
let name = page.as_str().unwrap_or("General");
|
||||
let i = PAGES.iter().position(|x| x.eq_ignore_ascii_case(name)).ok_or_else(|| format!("no page `{name}` ({})", PAGES.join(", ")))?;
|
||||
open_at(app, i);
|
||||
if app.prefs_window.snapshot.is_none() {
|
||||
app.prefs_window.snapshot = Some(Snapshot::take(app));
|
||||
}
|
||||
}
|
||||
if rest.remove("stand_up").and_then(|v| v.as_bool()) == Some(true) {
|
||||
crate::stand_up::stand_up(app)?;
|
||||
}
|
||||
let button = rest.remove("button");
|
||||
let mut out = set(app, &serde_json::Value::Object(rest))?;
|
||||
match button.as_ref().and_then(|b| b.as_str()) {
|
||||
Some("ok") => close(app, false),
|
||||
Some("cancel") => close(app, true),
|
||||
Some("apply") => app.prefs_window.snapshot = Some(Snapshot::take(app)),
|
||||
Some("defaults") => defaults(app, app.prefs_window.section),
|
||||
Some(b) => return Err(format!("unknown button `{b}` (ok, cancel, apply, defaults)")),
|
||||
None => {}
|
||||
}
|
||||
if button.is_some() {
|
||||
out = serde_json::to_value(&app.preferences).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert("dialog".into(), json!(app.prefs_window.open.then(|| PAGES.get(app.prefs_window.section).copied().unwrap_or_default())));
|
||||
o.insert("stand_up_offer".into(), json!(app.stand_up));
|
||||
o.insert("dark".into(), json!(app.ui.dark));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Set preferences from JSON (control channel); returns them all.
|
||||
@@ -351,13 +677,26 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
|
||||
return Err(format!("units must be one of {}", UNITS.join(", ")));
|
||||
}
|
||||
if !NAV_PRESETS.iter().any(|(id, _)| *id == new.nav) || !MSAA.contains(&new.msaa) {
|
||||
return Err("`nav` must be fusion, solidworks or inventor and `msaa` 1, 2, 4 or 8".into());
|
||||
return Err("`nav` must be fusion, solidworks, inventor, alias or tinkercad and `msaa` 1, 2, 4 or 8".into());
|
||||
}
|
||||
if !THEMES.iter().any(|(id, _)| *id == new.theme) || !["z", "y"].contains(&new.up_axis.as_str()) {
|
||||
return Err("`theme` must be dark, light or system and `up_axis` z or y".into());
|
||||
}
|
||||
if !(0.5..=2.5).contains(&new.ui_scale) || !(0.25..=4.0).contains(&new.look_at_speed) {
|
||||
return Err("`ui_scale` must be 0.5…2.5 and `look_at_speed` 0.25…4".into());
|
||||
}
|
||||
if new.theme != app.preferences.theme && new.theme != "system" {
|
||||
app.ui.dark = new.theme == "dark";
|
||||
}
|
||||
let up_changed = new.up_axis != app.preferences.up_axis;
|
||||
let msaa_changed = new.msaa != app.preferences.msaa;
|
||||
app.preferences = new;
|
||||
if msaa_changed {
|
||||
save_graphics(app);
|
||||
}
|
||||
if up_changed {
|
||||
up_axis_changed(app);
|
||||
}
|
||||
serde_json::to_value(&app.preferences).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -390,6 +729,86 @@ mod tests {
|
||||
assert_eq!(format_length(25.4, "in", 3), "1.000 in");
|
||||
}
|
||||
|
||||
/// The dialog: changes apply at once, Cancel puts back what was there when it opened, Apply
|
||||
/// keeps them, Restore Defaults resets one page, and the shortcuts menus open its page.
|
||||
#[test]
|
||||
fn preferences_dialog_cancel_apply_and_defaults() {
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
control(&mut app, &json!({"open": "general"})).unwrap();
|
||||
assert!(app.prefs_window.open && app.prefs_window.section == 0);
|
||||
control(&mut app, &json!({"theme": "light", "ui_scale": 1.25, "zoom_to_cursor": false})).unwrap();
|
||||
assert!(!app.ui.dark, "light applies at once");
|
||||
app.keymap.custom.insert("solid.extrude".into(), "Shift+E".into());
|
||||
let v = control(&mut app, &json!({"button": "cancel"})).unwrap();
|
||||
assert_eq!(v["dialog"], serde_json::Value::Null);
|
||||
assert!(app.ui.dark && app.preferences.theme == "dark" && app.preferences.ui_scale == 1.0 && app.preferences.zoom_to_cursor);
|
||||
assert!(app.keymap.custom.is_empty(), "a rebinding made while the dialog was open is undone too");
|
||||
|
||||
control(&mut app, &json!({"open": "Navigation", "nav": "alias", "look_at_anim": false})).unwrap();
|
||||
control(&mut app, &json!({"button": "apply"})).unwrap();
|
||||
control(&mut app, &json!({"nav": "tinkercad"})).unwrap();
|
||||
control(&mut app, &json!({"button": "cancel"})).unwrap();
|
||||
assert_eq!(app.preferences.nav, "alias", "Cancel goes back to the last Apply");
|
||||
let to = {
|
||||
let mut c = app.cam;
|
||||
c.set_view(solvecraft_engine::render::StandardView::Top);
|
||||
c
|
||||
};
|
||||
app.animate_to(to);
|
||||
assert!(app.cam_anim.is_none() && app.cam == to, "no animation: the view jumps");
|
||||
|
||||
control(&mut app, &json!({"open": "Navigation"})).unwrap();
|
||||
control(&mut app, &json!({"button": "defaults"})).unwrap();
|
||||
assert_eq!(app.preferences.nav, "fusion");
|
||||
assert!(app.preferences.look_at_anim);
|
||||
control(&mut app, &json!({"button": "ok"})).unwrap();
|
||||
assert!(!app.prefs_window.open);
|
||||
|
||||
// File and Help › Keyboard Shortcuts open the same dialog at its page.
|
||||
app.keymap.open = true;
|
||||
let ctx = egui::Context::default();
|
||||
let _ = ctx.run_ui(Default::default(), |ui| show(&mut app, ui.ctx()));
|
||||
assert!(app.prefs_window.open && app.prefs_window.section == PAGE_SHORTCUTS && !app.keymap.open);
|
||||
assert!(control(&mut app, &json!({"open": "Plugins"})).is_err());
|
||||
assert!(set(&mut app, &json!({"up_axis": "x"})).is_err());
|
||||
assert!(set(&mut app, &json!({"theme": "sepia"})).is_err());
|
||||
}
|
||||
|
||||
/// A STEP file opened Z-up is offered to be stood up: a quarter turn about X, undoable.
|
||||
#[test]
|
||||
fn a_y_up_step_file_can_be_stood_up() {
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-standup-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("tall_in_y.step").to_string_lossy().to_string();
|
||||
let mut src = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
src.run("solid.box", json!({"length": 10, "width": 40, "height": 5})).unwrap();
|
||||
src.run("file.export", json!({"path": path})).unwrap();
|
||||
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
app.open_path(&path);
|
||||
let offered = app.stand_up.clone().unwrap_or_default();
|
||||
assert_eq!(offered.len(), 1, "the file's body is offered");
|
||||
let size = |app: &SolveApp| solvecraft_engine::view::bounds(&app.session);
|
||||
assert!((size(&app).max.y - size(&app).min.y - 40.0).abs() < 1e-6);
|
||||
control(&mut app, &json!({"stand_up": true})).unwrap();
|
||||
let b = size(&app);
|
||||
assert!((b.max.z - b.min.z - 40.0).abs() < 1e-6, "Y became Z: {b:?}");
|
||||
assert!(app.stand_up.is_none());
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!((size(&app).max.y - size(&app).min.y - 40.0).abs() < 1e-6, "one undo step");
|
||||
|
||||
// Not offered when turned off, or with Y up as the default orientation.
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
app.preferences.offer_stand_up = false;
|
||||
app.open_path(&path);
|
||||
assert!(app.stand_up.is_none());
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
app.preferences.up_axis = "y".into();
|
||||
app.open_path(&path);
|
||||
assert!(app.stand_up.is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orbiting_around_the_cursor_keeps_that_point_still() {
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
|
||||
@@ -261,6 +261,30 @@ fn variable_fillet_round_trip() {
|
||||
assert_eq!(e.extra.get("radius2"), Some(&json!("4 mm")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_fillet_mid_radii_round_trip() {
|
||||
let mut app = boxed();
|
||||
let e0 = edge(&app, Vec3::new(40.0, 15.0, 20.0));
|
||||
let mut d = start(&mut app, vec![e0], "solid.fillet");
|
||||
d.inputs[0].items.truncate(1);
|
||||
d.extra.insert("type".into(), json!("variable"));
|
||||
d.extra.insert("radius2".into(), json!("2 mm"));
|
||||
d.extra.insert("mid".into(), json!([[0.5, "4 mm"]]));
|
||||
d.extra.insert("smooth".into(), json!(true));
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert_eq!((p["mid"].clone(), p["smooth"].clone()), (json!([[0.5, "4 mm"]]), json!(true)), "{p}");
|
||||
let id = apply(&mut app, &d);
|
||||
let e = crate::dialogs::for_feature(&app, id, None).unwrap();
|
||||
assert_eq!(e.extra.get("mid"), Some(&json!([[0.5, "4 mm"]])));
|
||||
assert_eq!(e.extra.get("smooth"), Some(&json!(true)));
|
||||
// All middle radii removed: a plain two-radius fillet again.
|
||||
let mut d = e;
|
||||
d.extra.insert("mid".into(), json!([]));
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
// (an edit: the feature's new parameters)
|
||||
assert_eq!(p["params"]["mid"], json!([]), "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_options_reach_their_commands() {
|
||||
let mut app = boxed();
|
||||
@@ -297,7 +321,7 @@ fn boundary_fill_finds_the_cells_and_fills_the_ticked_ones() {
|
||||
app.dialog = Some(d);
|
||||
// The dialog finds the cells on its next frame.
|
||||
let ctx = egui::Context::default();
|
||||
let _ = ctx.run_ui(Default::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
|
||||
ctx.run_ui(Default::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())).textures_delta.clear();
|
||||
let mut d = app.dialog.take().unwrap();
|
||||
let Kind::BoundaryFill { cells, .. } = &mut d.kind else { panic!() };
|
||||
// Only A, only B, and both.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`,
|
||||
//! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}`
|
||||
//! or `{"sketch": [x,y]}` (the sketch being edited)
|
||||
//! - `{"queued": "engine.execute", "params": {…}, "min_frames"?: n}`: a request sent the way an
|
||||
//! - `{"queued": "engine.execute", "params": {…}, "min_frames"?: n, "max_frames"?: n}`: a request sent the way an
|
||||
//! agent's arrive (through the app's request queue, answered between frames, so the agent
|
||||
//! cursor can act it out first); fails if the answer came in fewer than `min_frames` frames
|
||||
//! - `{"call": "ui.view", "params": {…}}`: any other control request (`fail: true` expects an
|
||||
@@ -24,9 +24,12 @@
|
||||
//! - `{"camera": {"yaw": rad, "pitch": rad}}` puts the camera at an orbit; `{"view_anim": "top",
|
||||
//! "max_deg": 10}` animates to a view and fails if any frame turns the view more than that
|
||||
//! - AT may also be `{"plane": "XY"}` (the middle of an origin plane's square), `{"axis": "Z"}`,
|
||||
//! `{"dimension": "d1"}` or `{"handle": "arrow"}` (a manipulator handle: see [`publish_handle`])
|
||||
//! `{"dimension": "d1"}`, `{"handle": "arrow"}` (a manipulator handle: see [`publish_handle`]),
|
||||
//! `{"browser": "Bodies"}` (a browser row by its label) or `{"chevron": "Bodies"}` (its fold arrow)
|
||||
//! - `{"autosave": true}`: autosave into the scenario's recovery folder; `{"restart": "crash" |
|
||||
//! "close"}`: the app dies (or closes) and a new one starts on the same folders
|
||||
//! - `{"script": "examples/…/x.json"}`: run a command script through the engine; `"$DIR"` in a
|
||||
//! `call` step's string parameters is the scenario's folder
|
||||
//! - `{"note": "…"}`: a comment; `{"pending": "why"}`: the scenario waits for a fix (its test
|
||||
//! is ignored)
|
||||
//! - `{"expect": {…}}`: checks, see [`check`]; `{"until": {…}}` waits (frames) until they pass
|
||||
@@ -95,6 +98,16 @@ pub fn clear_handles() {
|
||||
COUNTS.with(|h| h.borrow_mut().clear());
|
||||
}
|
||||
|
||||
/// `v` with "$DIR" in its strings replaced by `dir`.
|
||||
fn with_dir(v: Value, dir: &str) -> Value {
|
||||
match v {
|
||||
Value::String(t) => Value::String(t.replace("$DIR", dir)),
|
||||
Value::Array(a) => Value::Array(a.into_iter().map(|x| with_dir(x, dir)).collect()),
|
||||
Value::Object(o) => Value::Object(o.into_iter().map(|(k, x)| (k, with_dir(x, dir))).collect()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh folder for one scenario's files.
|
||||
fn scratch_dir() -> PathBuf {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@@ -362,9 +375,30 @@ impl Harness {
|
||||
if (frames as u64) < min {
|
||||
return Err(format!("{m} was answered after {frames} frames: the agent cursor did not act it out ({min})"));
|
||||
}
|
||||
if let Some(max) = s.get("max_frames").and_then(Value::as_u64)
|
||||
&& frames as u64 > max
|
||||
{
|
||||
return Err(format!("{m} was answered after {frames} frames, more than {max}"));
|
||||
}
|
||||
v
|
||||
} else if let Some(f) = s.get("script").and_then(Value::as_str) {
|
||||
// A command script (`{"commands": [{command, params}]}`, path from the repository
|
||||
// root) run through the engine, as `solvecraft-cli run` does.
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(f);
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{f}: {e}"))?;
|
||||
let v: Value = serde_json::from_str(&text).map_err(|e| format!("{f}: {e}"))?;
|
||||
for (i, c) in v["commands"].as_array().into_iter().flatten().enumerate() {
|
||||
let r = self.call("engine.execute", json!({"command": c["command"], "params": c.get("params").cloned().unwrap_or(json!({}))}));
|
||||
if r["ok"] != true {
|
||||
return Err(format!("{f}: step {} ({}): {}", i + 1, c["command"], r["error"]));
|
||||
}
|
||||
}
|
||||
self.frames(2);
|
||||
return Ok(());
|
||||
} else if let Some(m) = s.get("call").and_then(Value::as_str) {
|
||||
self.call(m, s.get("params").cloned().unwrap_or(json!({})))
|
||||
// "$DIR" in a string parameter is the scenario's folder.
|
||||
let params = with_dir(s.get("params").cloned().unwrap_or(json!({})), &self.dir.to_string_lossy());
|
||||
self.call(m, params)
|
||||
} else if s.get("note").is_some() || s.get("pending").is_some() {
|
||||
return Ok(());
|
||||
} else if let Some(name) = s.get("shot").and_then(Value::as_str) {
|
||||
@@ -490,7 +524,7 @@ fn approx(got: f64, want: &Value, what: &str) -> Result<(), String> {
|
||||
/// Checks: `bodies` (count), `volume` / `area` (total, number or [value, rel tol]), `faces`,
|
||||
/// `features` (count), `errors` (timeline features in error, default 0), `dialog` (null: none
|
||||
/// open, or a word its kind must contain), `tool` (null or command id), `sketching` (bool),
|
||||
/// `selection` (count), `param` ({name: value}), `sketch_status` (solved|…), `dof`, `query`
|
||||
/// `selection` (count), `glyphs` ({constraint id: count}, none overlapping), `param` ({name: value}), `sketch_status` (solved|…), `dof`, `query`
|
||||
/// ({command, params?, path, value}: a command's result).
|
||||
pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
let doc = h.call("document.inspect", json!({"measure": true}))["result"].clone();
|
||||
@@ -549,7 +583,8 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
"camera" => {
|
||||
// {"camera": {"yaw_deg": y, "pitch_deg": p}}: where the camera looks from (±0.5°),
|
||||
// {"camera": {"yaw_deg": y, "pitch_deg": p, "back"?: [x,y,z]}}: where the camera
|
||||
// looks from (yaw and pitch in its up-axis frame, ±0.5°),
|
||||
// once any view animation has finished.
|
||||
for _ in 0..240 {
|
||||
if h.app.cam_anim.is_none() {
|
||||
@@ -562,6 +597,14 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
if !near(yaw, &v["yaw_deg"]) || !near(pitch, &v["pitch_deg"]) {
|
||||
return Err(format!("camera yaw {yaw:.2}°, pitch {pitch:.2}°, want {v}"));
|
||||
}
|
||||
// "back": the world direction from the target to the eye.
|
||||
if let Some(want) = v["back"].as_array() {
|
||||
let b = h.app.cam.back();
|
||||
let w = |i: usize| want.get(i).and_then(Value::as_f64).unwrap_or(f64::NAN);
|
||||
if (b.x - w(0)).abs() > 1e-3 || (b.y - w(1)).abs() > 1e-3 || (b.z - w(2)).abs() > 1e-3 {
|
||||
return Err(format!("camera looks from {b:?}, want {want:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
"count" => {
|
||||
// {"count": {"name": n, "value": x}}: a published count.
|
||||
@@ -628,6 +671,21 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
return Err(format!("tool: got {}, want {v}", ui["tool"]));
|
||||
}
|
||||
}
|
||||
"glyphs" => {
|
||||
// {constraint id: glyphs drawn for it}; glyphs never overlap one another.
|
||||
let g = crate::sketch_tools::glyph_positions();
|
||||
for (id, n) in v.as_object().ok_or("glyphs takes {constraint id: count}")? {
|
||||
let got = g.iter().filter(|(x, _)| x == id).count();
|
||||
if n.as_u64() != Some(got as u64) {
|
||||
return Err(format!("glyphs of {id}: got {got}, want {n} (drawn: {g:?})"));
|
||||
}
|
||||
}
|
||||
for (i, (a, p)) in g.iter().enumerate() {
|
||||
if let Some((b, _)) = g.iter().skip(i + 1).find(|(_, q)| q.distance(*p) < 14.0) {
|
||||
return Err(format!("glyphs of {a} and {b} overlap at {p:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
"sketching" => {
|
||||
if doc["active_sketch"].is_null() == v.as_bool().unwrap_or(false) {
|
||||
return Err(format!("sketching: want {v}, active sketch {}", doc["active_sketch"]));
|
||||
@@ -788,6 +846,15 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
return Err(format!("start page open: got {}, want {v}", ui["home"]));
|
||||
}
|
||||
}
|
||||
"prefs" => {
|
||||
// {key: value…}: those entries of `ui.prefs` (preferences, `dialog`, `dark`…).
|
||||
let got = h.call("ui.prefs", json!({}))["result"].clone();
|
||||
for (pk, pv) in v.as_object().into_iter().flatten() {
|
||||
if got[pk.as_str()] != *pv {
|
||||
return Err(format!("preference {pk}: got {}, want {pv}", got[pk.as_str()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
"quit_requested" | "quitting" => {
|
||||
if ui[k] != *v {
|
||||
return Err(format!("{k}: got {}, want {v}", ui[k]));
|
||||
@@ -803,6 +870,27 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
return Err(format!("design name: got {}, want {v}", doc["name"]));
|
||||
}
|
||||
}
|
||||
"browser_has" | "browser_lacks" => {
|
||||
// Browser rows on screen, by label.
|
||||
let r = h.call("ui.browser", json!({}));
|
||||
let labels: Vec<String> =
|
||||
r["result"]["rows"].as_array().into_iter().flatten().filter_map(|x| x["label"].as_str().map(str::to_string)).collect();
|
||||
for want in v.as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
let there = labels.iter().any(|l| l == want);
|
||||
if there != (k.as_str() == "browser_has") {
|
||||
return Err(format!("browser row `{want}` {}: rows {labels:?}", if there { "still shown" } else { "missing" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
"browser_selected" => {
|
||||
// Nothing selected and no component picked (a fold click selects nothing).
|
||||
let r = h.call("ui.browser", json!({}));
|
||||
let none = r["result"]["selection"].as_array().is_none_or(|a| a.is_empty())
|
||||
&& r["result"]["picked_components"].as_array().is_none_or(|a| a.is_empty());
|
||||
if none == v.as_bool().unwrap_or(false) {
|
||||
return Err(format!("browser selection: {}", r["result"]));
|
||||
}
|
||||
}
|
||||
"query" => {
|
||||
// {command, params?, path: [keys or indices], value}: a command's result at path.
|
||||
let cmd = v["command"].as_str().ok_or("query needs `command`")?;
|
||||
|
||||
@@ -152,6 +152,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
let mut picked: Option<&'static str> = None;
|
||||
let mut pin: Option<&'static str> = None;
|
||||
let mut menu: Option<(Pos2, &'static str)> = None;
|
||||
let h = 380.0;
|
||||
let pos = pos2(
|
||||
at.x.clamp(screen.left() + 4.0, (screen.right() - W - 4.0).max(screen.left())),
|
||||
@@ -237,6 +238,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
} else if resp.clicked() && enabled {
|
||||
picked = Some(id);
|
||||
}
|
||||
if resp.secondary_clicked() {
|
||||
menu = Some((resp.interact_pointer_pos().unwrap_or(r.left_bottom()), id));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -244,6 +248,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if let Some(id) = pin {
|
||||
app.sbox.toggle_pin(id);
|
||||
}
|
||||
// A row's menu: pin to the toolbar or the shortcuts.
|
||||
if let Some((at, id)) = menu {
|
||||
crate::context_menu::open_for(app, at, crate::context_menu::Target::ToolbarCommand { id: id.to_string() });
|
||||
return;
|
||||
}
|
||||
if enter
|
||||
&& picked.is_none()
|
||||
&& let Some((id, ..)) = list.get(app.sbox.index)
|
||||
|
||||
@@ -291,7 +291,7 @@ pub fn refine_snap(
|
||||
|
||||
/// The constraint glyph under a screen position (its constraint id).
|
||||
pub fn glyph_at(pos: Pos2) -> Option<String> {
|
||||
GLYPHS.with(|g| g.borrow().iter().find(|(_, c)| c.distance(pos) <= 7.0).map(|(id, _)| id.clone()))
|
||||
GLYPHS.with(|g| g.borrow().iter().find(|(_, c)| c.distance(pos) <= GLYPH * 0.6).map(|(id, _)| id.clone()))
|
||||
}
|
||||
|
||||
/// Constraint glyphs drawn last frame: (constraint id, screen centre).
|
||||
@@ -304,7 +304,7 @@ pub fn click_glyph(app: &mut SolveApp, _proj: &Proj, pos: Pos2) -> bool {
|
||||
if app.session.active_sketch.is_none() {
|
||||
return false;
|
||||
}
|
||||
let hit = GLYPHS.with(|g| g.borrow().iter().find(|(_, c)| c.distance(pos) <= 7.0).map(|(id, _)| id.clone()));
|
||||
let hit = GLYPHS.with(|g| g.borrow().iter().find(|(_, c)| c.distance(pos) <= GLYPH * 0.6).map(|(id, _)| id.clone()));
|
||||
let used = hit.is_some();
|
||||
PICKED.with(|p| *p.borrow_mut() = hit.clone());
|
||||
if let Some(id) = hit {
|
||||
@@ -891,7 +891,10 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) {
|
||||
|
||||
/// Constraint glyphs of the active sketch: a small badge per constraint at its anchor
|
||||
/// (conflicting ones red).
|
||||
fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
/// Glyph size and spacing (screen pixels).
|
||||
const GLYPH: f32 = 15.0;
|
||||
|
||||
fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option<Pos2>) {
|
||||
let Some(sid) = active_sketch(app) else { return };
|
||||
let st = app.session.world_state();
|
||||
let Some(ss) = st.sketch(sid) else { return };
|
||||
@@ -900,87 +903,172 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
return;
|
||||
}
|
||||
let tk = crate::theme::Tokens::get();
|
||||
let mut placed: Vec<Pos2> = Vec::new();
|
||||
let picked = PICKED.with(|p| p.borrow().clone());
|
||||
let mut drawn: Vec<(String, Pos2)> = Vec::new();
|
||||
// Where each glyph goes: beside its entity (to its left, away from the line), every entity
|
||||
// of a two-entity constraint getting one; glyphs that would overlap step along.
|
||||
let mut placed: Vec<(String, Pos2, Pos2)> = Vec::new();
|
||||
for c in &ss.sketch.constraints {
|
||||
if c.kind.is_dimension() {
|
||||
continue;
|
||||
}
|
||||
let Some(at) = glyph_anchor(&ss.sketch, &c.kind) else { continue };
|
||||
let Some(mut sp) = proj.to_screen(ss.plane.to_world(at)) else { continue };
|
||||
sp += egui::vec2(9.0, -9.0);
|
||||
// Keep glyphs at one spot from overlapping.
|
||||
while placed.iter().any(|q| q.distance(sp) < 12.0) {
|
||||
sp += egui::vec2(13.0, 0.0);
|
||||
for (at, dir) in glyph_anchors(&ss.sketch, &c.kind) {
|
||||
let Some(base) = proj.to_screen(ss.plane.to_world(at)) else { continue };
|
||||
// The entity's direction on screen, and its side.
|
||||
let side = match dir.and_then(|d| proj.to_screen(ss.plane.to_world(at + d))) {
|
||||
Some(q) => {
|
||||
let t = (q - base).normalized();
|
||||
if t.length() > 0.5 { egui::vec2(-t.y, t.x) } else { egui::vec2(0.7, -0.7) }
|
||||
}
|
||||
None => egui::vec2(0.7, -0.7),
|
||||
};
|
||||
let step = if dir.is_some() { egui::vec2(side.y, -side.x) } else { egui::vec2(1.0, 0.0) };
|
||||
let mut sp = base + side * (GLYPH * 0.9);
|
||||
for _ in 0..12 {
|
||||
if !placed.iter().any(|(_, q, _)| q.distance(sp) < GLYPH + 1.0) {
|
||||
break;
|
||||
}
|
||||
sp += step * (GLYPH + 2.0);
|
||||
}
|
||||
placed.push((c.id.clone(), sp, base));
|
||||
}
|
||||
placed.push(sp);
|
||||
let failing = ss.report.failing.contains(&c.id);
|
||||
let selected = picked.as_deref() == Some(c.id.as_str())
|
||||
|| app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::SketchConstraint { id } if *id == c.id));
|
||||
}
|
||||
let hovered = hover.and_then(|h| placed.iter().find(|(_, q, _)| q.distance(h) <= GLYPH * 0.6).map(|(id, _, _)| id.clone()));
|
||||
// The hovered (or selected) constraint's entities lit up.
|
||||
if let Some(id) = hovered.as_ref().or(picked.as_ref())
|
||||
&& let Some(c) = ss.sketch.constraints.iter().find(|c| &c.id == id)
|
||||
{
|
||||
let (cs, ps) = constraint_entities(&c.kind);
|
||||
for ci in cs {
|
||||
let pts: Vec<Pos2> = ss.sketch.polyline(ci).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect();
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(3.0, tk.accent.gamma_multiply(0.7))));
|
||||
}
|
||||
for pi in ps {
|
||||
if let Some(q) = ss.sketch.point(pi).and_then(|q| proj.to_screen(ss.plane.to_world(q))) {
|
||||
painter.circle_filled(q, 4.5, tk.accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut drawn: Vec<(String, Pos2)> = Vec::new();
|
||||
for (id, sp, base) in &placed {
|
||||
let Some(c) = ss.sketch.constraints.iter().find(|c| &c.id == id) else { continue };
|
||||
let failing = ss.report.failing.contains(id);
|
||||
let selected = picked.as_deref() == Some(id.as_str())
|
||||
|| app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::SketchConstraint { id: x } if x == id));
|
||||
let lit = selected || hovered.as_deref() == Some(id.as_str());
|
||||
let col = if failing {
|
||||
tk.error
|
||||
} else if selected {
|
||||
} else if lit {
|
||||
tk.accent
|
||||
} else {
|
||||
tk.text_dim
|
||||
tk.glyph_text
|
||||
};
|
||||
drawn.push((c.id.clone(), sp));
|
||||
let r = egui::Rect::from_center_size(sp, egui::vec2(12.0, 12.0));
|
||||
painter.rect_filled(r, 2.0, tk.panel.gamma_multiply(0.85));
|
||||
painter.rect_stroke(r, 2.0, Stroke::new(0.8, col), egui::StrokeKind::Inside);
|
||||
painter.text(sp, egui::Align2::CENTER_CENTER, glyph_letter(c.kind.name()), egui::FontId::proportional(9.0), col);
|
||||
// A faint leader back to the entity when the glyph was pushed away from it.
|
||||
if sp.distance(*base) > GLYPH * 1.6 {
|
||||
painter.line_segment([*base, *sp], Stroke::new(0.8, tk.text_dim.gamma_multiply(0.6)));
|
||||
}
|
||||
let r = egui::Rect::from_center_size(*sp, egui::vec2(GLYPH, GLYPH));
|
||||
painter.rect_filled(r, 3.0, if lit { tk.accent_soft } else { tk.glyph_bg });
|
||||
painter.rect_stroke(r, 3.0, Stroke::new(1.0, if failing || lit { col } else { tk.glyph_edge }), egui::StrokeKind::Inside);
|
||||
match glyph_icon(c.kind.name()) {
|
||||
Some(icon) => crate::icons::paint(painter, r.shrink(2.0), icon, col, tk.glyph_bg, col),
|
||||
None => {
|
||||
painter.text(*sp, egui::Align2::CENTER_CENTER, glyph_letter(c.kind.name()), egui::FontId::proportional(10.5), col);
|
||||
}
|
||||
}
|
||||
drawn.push((id.clone(), *sp));
|
||||
}
|
||||
if let (Some(id), Some(h)) = (hovered, hover)
|
||||
&& let Some(c) = ss.sketch.constraints.iter().find(|c| c.id == id)
|
||||
{
|
||||
let label = format!("{} ({id})", c.kind.name());
|
||||
let at = h + egui::vec2(12.0, 12.0);
|
||||
let g = painter.layout_no_wrap(label, egui::FontId::proportional(11.0), tk.text);
|
||||
painter.rect_filled(egui::Rect::from_min_size(at, g.size()).expand(3.0), 3.0, tk.panel);
|
||||
painter.galley(at, g, tk.text);
|
||||
}
|
||||
GLYPHS.with(|g| *g.borrow_mut() = drawn);
|
||||
}
|
||||
|
||||
/// Toolbar icons used as glyphs (H and V stay letters, which tell them apart).
|
||||
fn glyph_icon(name: &str) -> Option<&'static str> {
|
||||
Some(match name {
|
||||
"Coincident" => "c_coincident",
|
||||
"Parallel" => "c_parallel",
|
||||
"Perpendicular" => "c_perpendicular",
|
||||
"Collinear" => "c_collinear",
|
||||
"Tangent" => "c_tangent",
|
||||
"Smooth" => "c_smooth",
|
||||
"Equal" => "c_equal",
|
||||
"Concentric" => "c_concentric",
|
||||
"MidPoint" => "c_midpoint",
|
||||
"Symmetry" => "c_symmetry",
|
||||
"Fix" => "c_fix",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The curves and points a constraint ties together.
|
||||
fn constraint_entities(k: &solvecraft_engine::sketch::ConstraintKind) -> (Vec<usize>, Vec<usize>) {
|
||||
use solvecraft_engine::sketch::ConstraintKind::*;
|
||||
match *k {
|
||||
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![], vec![p, q]),
|
||||
Fix { p } => (vec![], vec![p]),
|
||||
Midpoint { p, l } => (vec![l], vec![p]),
|
||||
PointOnCurve { p, c } => (vec![c], vec![p]),
|
||||
Symmetric { p, q, l } => (vec![l], vec![p, q]),
|
||||
Horizontal { l } | Vertical { l } => (vec![l], vec![]),
|
||||
Parallel { a, b }
|
||||
| Perpendicular { a, b }
|
||||
| Collinear { a, b }
|
||||
| Equal { a, b }
|
||||
| Concentric { a, b }
|
||||
| Tangent { a, b }
|
||||
| Smooth { a, b } => (vec![a, b], vec![]),
|
||||
_ => (vec![], vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
fn glyph_letter(name: &str) -> &'static str {
|
||||
match name {
|
||||
"Coincident" => "\u{25cf}",
|
||||
"Horizontal" => "H",
|
||||
"Vertical" => "V",
|
||||
"Parallel" => "//",
|
||||
"Perpendicular" => "\u{22a5}",
|
||||
"Collinear" => "\u{2261}",
|
||||
"Tangent" => "T",
|
||||
"Smooth" => "G2",
|
||||
"Equal" => "=",
|
||||
"Concentric" => "\u{25ce}",
|
||||
"MidPoint" => "M",
|
||||
"Symmetry" => "[]",
|
||||
"Fix" => "\u{1f512}",
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
fn glyph_anchor(sk: &solvecraft_engine::sketch::Sketch, k: &solvecraft_engine::sketch::ConstraintKind) -> Option<Vec2> {
|
||||
/// Where a constraint's glyphs go: one per entity (the middle of a curve, with its direction
|
||||
/// there; a point, without).
|
||||
fn glyph_anchors(sk: &solvecraft_engine::sketch::Sketch, k: &solvecraft_engine::sketch::ConstraintKind) -> Vec<(Vec2, Option<Vec2>)> {
|
||||
use solvecraft_engine::sketch::ConstraintKind::*;
|
||||
// Half way along the curve.
|
||||
let mid = |c: usize| -> Option<Vec2> {
|
||||
// Half way along the curve, and the direction there.
|
||||
let mid = |c: usize| -> Option<(Vec2, Option<Vec2>)> {
|
||||
let poly = sk.polyline(c);
|
||||
let total: f64 = poly.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let mut acc = 0.0;
|
||||
for w in poly.windows(2) {
|
||||
let l = w[0].dist(w[1]);
|
||||
if acc + l >= total * 0.5 && l > 0.0 {
|
||||
return Some(w[0].lerp(w[1], (total * 0.5 - acc) / l));
|
||||
return Some((w[0].lerp(w[1], (total * 0.5 - acc) / l), (w[1] - w[0]).normalized()));
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
poly.first().copied()
|
||||
poly.first().map(|p| (*p, None))
|
||||
};
|
||||
let pt = |p: usize| sk.point(p).map(|q| (q, None));
|
||||
match *k {
|
||||
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } | PointOnCurve { p, .. } => sk.point(p),
|
||||
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => Some((sk.point(p)? + sk.point(q)?) * 0.5),
|
||||
Horizontal { l } | Vertical { l } => mid(l),
|
||||
Parallel { a, .. }
|
||||
| Perpendicular { a, .. }
|
||||
| Collinear { a, .. }
|
||||
| Equal { a, .. }
|
||||
| Concentric { a, .. }
|
||||
| Tangent { a, .. }
|
||||
| Smooth { a, .. } => mid(a),
|
||||
_ => None,
|
||||
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } | PointOnCurve { p, .. } => pt(p).into_iter().collect(),
|
||||
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => {
|
||||
sk.point(p).zip(sk.point(q)).map(|(a, b)| ((a + b) * 0.5, None)).into_iter().collect()
|
||||
}
|
||||
Horizontal { l } | Vertical { l } => mid(l).into_iter().collect(),
|
||||
Parallel { a, b }
|
||||
| Perpendicular { a, b }
|
||||
| Collinear { a, b }
|
||||
| Equal { a, b }
|
||||
| Concentric { a, b }
|
||||
| Tangent { a, b }
|
||||
| Smooth { a, b } => [mid(a), mid(b)].into_iter().flatten().collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,7 +1090,7 @@ fn snap_hint(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
/// Per frame, after the viewport is drawn: constraint glyphs and the text-entry box.
|
||||
pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) {
|
||||
SHIFT.with(|c| c.set(ui.input(|i| i.modifiers.shift)));
|
||||
glyphs(app, painter, proj);
|
||||
glyphs(app, painter, proj, ui.input(|i| i.pointer.hover_pos()));
|
||||
snap_hint(app, painter, proj);
|
||||
combs(app, painter, proj);
|
||||
if DEFERRED.with(|d| d.replace(false)) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Standing up a Y-up file (#3). Many CAD programs save parts with Y up, so a STEP or IGES file
|
||||
//! from them opens lying on its back in a Z-up design. After such a file opens, SolveCraft
|
||||
//! offers to stand it up: a quarter turn about X (Y → Z) of the bodies it brought, as one
|
||||
//! Move feature that can be undone or edited. Preferences › Design › "Offer to stand up Y-up
|
||||
//! files" turns the offer off; with Y up as the default orientation it isn't needed.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SolveApp;
|
||||
|
||||
/// The bodies a just-opened file brought (`before`: body names before it opened, none for a
|
||||
/// new design): offer to stand them up.
|
||||
pub fn offer(app: &mut SolveApp, path: &str, before: &[String]) {
|
||||
let io = &solvecraft_engine::io::is_step_path;
|
||||
if !(io(path) || solvecraft_engine::io::is_iges_path(path)) || !app.preferences.offer_stand_up || app.preferences.up_axis != "z" {
|
||||
return;
|
||||
}
|
||||
let bodies: Vec<String> = app.session.world_state().bodies.iter().map(|b| b.name.clone()).filter(|n| !before.contains(n)).collect();
|
||||
if !bodies.is_empty() {
|
||||
app.stand_up = Some(bodies);
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn the offered bodies a quarter turn about X (Y up becomes Z up).
|
||||
pub fn stand_up(app: &mut SolveApp) -> Result<(), String> {
|
||||
let Some(bodies) = app.stand_up.take() else { return Err("no file is waiting to be stood up".into()) };
|
||||
app.run("solid.move", json!({ "bodies": bodies, "axis": [1, 0, 0], "angle": "90 deg" })).map_err(|e| e.to_string())?;
|
||||
app.animate_view("home");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The offer, under the toolbar.
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if app.stand_up.is_none() {
|
||||
return;
|
||||
}
|
||||
let mut act: Option<&str> = None;
|
||||
crate::frame::window(ctx, "Stand this part up?", crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("sc_stand_up"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 140.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.label("Files from many CAD programs have Y up, so they open lying on their back here (Z up).");
|
||||
ui.label("Stand it up: a quarter turn about X, as one Move feature you can undo.");
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Stand it up").clicked() {
|
||||
act = Some("stand");
|
||||
}
|
||||
if ui.button("Leave it").clicked() {
|
||||
act = Some("leave");
|
||||
}
|
||||
if ui.button("Don't ask again").on_hover_text("Preferences › Design turns the offer back on").clicked() {
|
||||
act = Some("never");
|
||||
}
|
||||
});
|
||||
});
|
||||
match act {
|
||||
Some("stand") => {
|
||||
if let Err(e) = stand_up(app) {
|
||||
app.set_status(e, true);
|
||||
}
|
||||
}
|
||||
Some("leave") => app.stand_up = None,
|
||||
Some("never") => {
|
||||
app.stand_up = None;
|
||||
app.preferences.offer_stand_up = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::command_specs;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{SolveApp, icons};
|
||||
@@ -10,7 +9,7 @@ use crate::{SolveApp, icons};
|
||||
pub const TABS: &[&str] = &["SOLID", "SURFACE", "MESH", "SHEET METAL", "PLASTIC", "UTILITIES"];
|
||||
|
||||
/// Panels shown on each tab (in order). Commands come from the registry by (tab, panel).
|
||||
fn panels(tab: &str) -> &'static [&'static str] {
|
||||
pub fn panels(tab: &str) -> &'static [&'static str] {
|
||||
if let Some(p) = crate::workspace::panels(tab) {
|
||||
return p;
|
||||
}
|
||||
@@ -26,7 +25,7 @@ fn panels(tab: &str) -> &'static [&'static str] {
|
||||
}
|
||||
|
||||
/// How many commands of a panel get a big button.
|
||||
fn promoted(tab: &str, panel: &str) -> usize {
|
||||
pub fn promoted(tab: &str, panel: &str) -> usize {
|
||||
match (tab, panel) {
|
||||
("SOLID", "CREATE") => 4,
|
||||
("SOLID", "MODIFY") => 4,
|
||||
@@ -85,6 +84,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let theme_tip = if app.ui.dark { "Light theme" } else { "Dark theme" };
|
||||
if click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip) {
|
||||
app.ui.dark = !app.ui.dark;
|
||||
app.preferences.theme = if app.ui.dark { "dark" } else { "light" }.into();
|
||||
}
|
||||
if click(ui, "help", "Help") {
|
||||
app.help.menu = !app.help.menu;
|
||||
@@ -108,7 +108,18 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
// Document tabs.
|
||||
let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
|
||||
crate::documents::tabs(app, ui, r, x + 16.0, r.right() - captions - 60.0);
|
||||
crate::documents::tabs(app, ui, r, x + 16.0, r.right() - captions - 90.0);
|
||||
// Preferences, at the top right like Fusion's profile menu (also Ctrl+,).
|
||||
let gear = Rect::from_center_size(pos2(r.right() - captions - 70.0, r.center().y), vec2(24.0, 24.0));
|
||||
let gresp = ui.interact(gear, ui.id().with("sc_prefs_button"), Sense::click());
|
||||
if gresp.hovered() {
|
||||
ui.painter().rect_filled(gear, 4.0, Color32::from_white_alpha(30));
|
||||
}
|
||||
icons::paint(ui.painter(), gear.shrink(4.0), "settings", t.app_bar_text, t.app_bar_text, t.app_bar);
|
||||
crate::scenario::publish_handle("titlebar:preferences", gear.center());
|
||||
if gresp.on_hover_text(if cfg!(target_os = "macos") { "Preferences (Cmd+,)" } else { "Preferences (Ctrl+,)" }).clicked() {
|
||||
crate::prefs::open_at(app, 0);
|
||||
}
|
||||
ui.painter().text(
|
||||
pos2(r.right() - 12.0 - captions, r.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
@@ -241,13 +252,25 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let painter = ui.painter().clone();
|
||||
// Workspace selector and tabs.
|
||||
let tabs_y = r.top() + 2.0;
|
||||
let ws = Rect::from_min_size(pos2(r.left() + 8.0, tabs_y + 26.0), vec2(96.0, 56.0));
|
||||
painter.rect(ws, 4.0, t.field, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
// The workspace switcher spans the toolbar's height (the tab row and the panel row).
|
||||
let ws = Rect::from_min_max(pos2(r.left() + 6.0, r.top() + 4.0), pos2(r.left() + 104.0, r.bottom() - 5.0));
|
||||
let wresp = ui.interact(ws, ui.id().with("workspace_switcher"), Sense::click());
|
||||
painter.rect(ws, 4.0, if wresp.hovered() { t.hover } else { t.field }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
icons::paint(&painter, Rect::from_center_size(pos2(ws.center().x, ws.top() + 24.0), vec2(26.0, 26.0)), "box", t.icon, t.icon_fill, t.accent);
|
||||
painter.text(
|
||||
pos2(ws.center().x - 5.0, ws.top() + 52.0),
|
||||
Align2::CENTER_CENTER,
|
||||
crate::toolbar_custom::WORKSPACE,
|
||||
FontId::proportional(12.0),
|
||||
t.text,
|
||||
);
|
||||
caret(&painter, pos2(ws.center().x + 27.0, ws.top() + 52.0), t.text);
|
||||
painter.text(pos2(ws.center().x, ws.top() + 69.0), Align2::CENTER_CENTER, "workspace", FontId::proportional(10.0), t.text_dim);
|
||||
if wresp.on_hover_text("Change workspace").clicked() {
|
||||
crate::context_menu::open_for(app, ws.left_bottom(), crate::context_menu::Target::Workspace);
|
||||
}
|
||||
crate::scenario::publish_rect("toolbar", r);
|
||||
crate::scenario::publish_rect("workspace_switcher", ws);
|
||||
painter.text(pos2(ws.center().x - 5.0, ws.top() + 14.0), Align2::CENTER_CENTER, "DESIGN", FontId::proportional(12.0), t.text);
|
||||
caret(&painter, pos2(ws.center().x + 26.0, ws.top() + 14.0), t.text);
|
||||
painter.text(pos2(ws.center().x, ws.top() + 34.0), Align2::CENTER_CENTER, "workspace", FontId::proportional(10.0), t.text_dim);
|
||||
let mut x = r.left() + 112.0;
|
||||
let mut tabs: Vec<&str> = Vec::new();
|
||||
if sketching {
|
||||
@@ -281,23 +304,50 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
// Panels.
|
||||
let tab = app.ui.tab.clone();
|
||||
let specs = command_specs();
|
||||
let mut px = r.left() + 112.0;
|
||||
let top = r.top() + 26.0;
|
||||
for panel in panels(&tab) {
|
||||
let (cmds, promote) = match crate::workspace::layout(&tab, panel, &specs) {
|
||||
Some(l) => l,
|
||||
None => (specs.iter().filter(|c| c.tab == tab && c.panel == *panel).copied().collect(), promoted(&tab, panel)),
|
||||
};
|
||||
let cmds = crate::sketch_tools::also_in(&tab, panel, cmds, &specs);
|
||||
let n = promote.min(cmds.len()).max(if cmds.is_empty() { 1 } else { 0 });
|
||||
let mut drop: Option<(&'static str, &'static str, Option<&'static str>)> = None;
|
||||
let mut menu: Option<(egui::Pos2, &'static str)> = None;
|
||||
for (panel, cmds, promote) in crate::toolbar_custom::panel_lists(&tab) {
|
||||
let panel: &&str = &panel;
|
||||
let shown = crate::toolbar_custom::buttons(app, &tab, panel, &cmds, promote);
|
||||
let n = shown.len().max(1);
|
||||
let width = (n as f32 * 40.0).max(64.0) + 8.0;
|
||||
let enabled_panel = !cmds.is_empty() || *panel == "SELECT";
|
||||
for (i, c) in cmds.iter().take(n).enumerate() {
|
||||
// Dropped on the panel's free space: the button goes to its end.
|
||||
let free = Rect::from_min_size(
|
||||
pos2(px + 4.0 + shown.len() as f32 * 40.0, top + 2.0),
|
||||
vec2((width - 8.0 - shown.len() as f32 * 40.0).max(8.0), 38.0),
|
||||
);
|
||||
let fresp = ui.interact(free, ui.id().with(("panel_free", panel)), Sense::hover());
|
||||
if fresp.dnd_hover_payload::<ToolbarDrag>().is_some() {
|
||||
painter.line_segment([pos2(free.left(), free.top() + 2.0), pos2(free.left(), free.bottom() - 2.0)], Stroke::new(2.0, t.accent));
|
||||
}
|
||||
if let Some(d) = fresp.dnd_release_payload::<ToolbarDrag>() {
|
||||
drop = Some((d.0, *panel, None));
|
||||
}
|
||||
for (i, c) in shown.iter().enumerate() {
|
||||
let br = Rect::from_min_size(pos2(px + 4.0 + i as f32 * 40.0, top + 2.0), vec2(38.0, 38.0));
|
||||
let info = c.info(&app.session);
|
||||
let resp = ui.interact(br, ui.id().with(("cmd", c.id)), Sense::click());
|
||||
let resp = ui.interact(br, ui.id().with(("cmd", c.id)), Sense::click_and_drag());
|
||||
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), br.center());
|
||||
if resp.drag_started() {
|
||||
resp.dnd_set_drag_payload(ToolbarDrag(c.id));
|
||||
}
|
||||
if let Some(d) = resp.dnd_hover_payload::<ToolbarDrag>()
|
||||
&& d.0 != c.id
|
||||
{
|
||||
painter
|
||||
.line_segment([pos2(br.left() - 1.0, br.top() + 2.0), pos2(br.left() - 1.0, br.bottom() - 2.0)], Stroke::new(2.0, t.accent));
|
||||
}
|
||||
if let Some(d) = resp.dnd_release_payload::<ToolbarDrag>()
|
||||
&& d.0 != c.id
|
||||
{
|
||||
drop = Some((d.0, *panel, Some(c.id)));
|
||||
}
|
||||
if resp.secondary_clicked() {
|
||||
menu = Some((resp.interact_pointer_pos().unwrap_or(br.left_bottom()), c.id));
|
||||
}
|
||||
if resp.hovered() && info.enabled {
|
||||
painter.rect_filled(br, 4.0, t.hover);
|
||||
}
|
||||
@@ -317,7 +367,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
app.start(id);
|
||||
}
|
||||
}
|
||||
if cmds.is_empty() {
|
||||
if shown.is_empty() {
|
||||
let br = Rect::from_min_size(pos2(px + 4.0, top + 2.0), vec2(width - 8.0, 38.0));
|
||||
painter.text(br.center(), Align2::CENTER_CENTER, "—", FontId::proportional(14.0), t.border);
|
||||
}
|
||||
@@ -363,6 +413,9 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
icons::paint(ui.painter(), ir, c.icon, t.icon, t.icon_fill, t.accent);
|
||||
let b = ui.add_enabled(info.enabled, egui::Button::new(c.label).frame(false).shortcut_text(c.shortcut.unwrap_or("")));
|
||||
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), b.rect.center());
|
||||
if b.secondary_clicked() {
|
||||
menu = Some((b.interact_pointer_pos().unwrap_or(b.rect.left_bottom()), c.id));
|
||||
}
|
||||
if b.clicked() {
|
||||
let id = c.id;
|
||||
app.start(id);
|
||||
@@ -381,9 +434,20 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
px += 2.0;
|
||||
}
|
||||
painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border));
|
||||
if let Some((id, panel, before)) = drop {
|
||||
crate::toolbar_custom::reorder(app, &tab, id, panel, before);
|
||||
}
|
||||
if let Some((at, id)) = menu {
|
||||
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
|
||||
crate::context_menu::open_for(app, at, crate::context_menu::Target::ToolbarCommand { id: id.to_string() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A toolbar button being dragged (its command id).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ToolbarDrag(&'static str);
|
||||
|
||||
/// The SELECT panel: what a click in the view picks.
|
||||
fn selection_filter(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
@@ -436,6 +500,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
Key::Y => drop(app.run("edit.redo", json!({}))),
|
||||
Key::S => save(app),
|
||||
Key::N => crate::documents::new_design(app),
|
||||
Key::Comma => crate::prefs::open_at(app, 0),
|
||||
Key::O => {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
app.open_path(&p);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Toolbar customisation: which commands each panel shows as buttons, per workspace and tab.
|
||||
//! A command's menu (right-click on a toolbar button, a panel drop-down row or an S box row)
|
||||
//! pins it to the toolbar, removes it, or pins it to the S box's favourites; buttons are dragged
|
||||
//! to reorder them within and between panels; Reset Toolbar restores the tab. Kept with the
|
||||
//! preferences and exported or imported with the keyboard shortcuts.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::{CommandSpec, command_specs};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::context_menu::Item;
|
||||
|
||||
/// The only workspace so far.
|
||||
pub const WORKSPACE: &str = "DESIGN";
|
||||
|
||||
#[derive(Default, Clone, Debug, PartialEq)]
|
||||
pub struct ToolbarCustom {
|
||||
/// `"<workspace>/<tab>/<panel>"` → the panel's buttons, in order (only panels the user
|
||||
/// changed).
|
||||
pub panels: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl ToolbarCustom {
|
||||
pub fn prefs(&self) -> Value {
|
||||
json!(self.panels)
|
||||
}
|
||||
pub fn load(&mut self, v: &Value) {
|
||||
self.panels = v
|
||||
.as_object()
|
||||
.map(|o| {
|
||||
o.iter()
|
||||
.take(500)
|
||||
.filter_map(|(k, v)| {
|
||||
let ids: Vec<String> = v.as_array()?.iter().take(100).filter_map(|x| x.as_str().map(str::to_string)).collect();
|
||||
Some((k.clone(), ids))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
fn key(tab: &str, panel: &str) -> String {
|
||||
format!("{WORKSPACE}/{tab}/{panel}")
|
||||
}
|
||||
|
||||
/// The buttons a panel shows: the user's list, or the first `promoted` commands of the panel.
|
||||
pub fn buttons(app: &SolveApp, tab: &str, panel: &str, cmds: &[&'static CommandSpec], promoted: usize) -> Vec<&'static CommandSpec> {
|
||||
match app.toolbar_custom.panels.get(&key(tab, panel)) {
|
||||
Some(ids) => ids.iter().filter_map(|id| solvecraft_engine::find_command(id)).collect(),
|
||||
None => cmds.iter().take(promoted).copied().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The panels of a tab with their commands and default button counts (what the toolbar draws).
|
||||
pub fn panel_lists(tab: &str) -> Vec<(&'static str, Vec<&'static CommandSpec>, usize)> {
|
||||
let specs = command_specs();
|
||||
crate::toolbar::panels(tab)
|
||||
.iter()
|
||||
.map(|panel| {
|
||||
let (cmds, promote) = match crate::workspace::layout(tab, panel, &specs) {
|
||||
Some(l) => l,
|
||||
None => (specs.iter().filter(|c| c.tab == tab && c.panel == *panel).copied().collect(), crate::toolbar::promoted(tab, panel)),
|
||||
};
|
||||
let cmds = crate::sketch_tools::also_in(tab, panel, cmds, &specs);
|
||||
(*panel, cmds, promote)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The buttons of a panel as ids, made the user's own list (so it can change).
|
||||
fn own(app: &mut SolveApp, tab: &str, panel: &str) -> Vec<String> {
|
||||
let k = key(tab, panel);
|
||||
if let Some(v) = app.toolbar_custom.panels.get(&k) {
|
||||
return v.clone();
|
||||
}
|
||||
let lists = panel_lists(tab);
|
||||
let ids: Vec<String> =
|
||||
lists.iter().find(|(p, ..)| *p == panel).map(|(_, cmds, n)| cmds.iter().take(*n).map(|c| c.id.to_string()).collect()).unwrap_or_default();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Where a command is a button on a tab now: its panel.
|
||||
pub fn pinned_on(app: &SolveApp, tab: &str, id: &str) -> Option<&'static str> {
|
||||
panel_lists(tab).into_iter().find(|(panel, cmds, n)| buttons(app, tab, panel, cmds, *n).iter().any(|c| c.id == id)).map(|(p, ..)| p)
|
||||
}
|
||||
|
||||
/// The panel a command belongs to on a tab (its drop-down), or the tab's first panel.
|
||||
fn home_panel(tab: &str, id: &str) -> Option<&'static str> {
|
||||
let lists = panel_lists(tab);
|
||||
lists.iter().find(|(_, cmds, _)| cmds.iter().any(|c| c.id == id)).or(lists.first()).map(|(p, ..)| *p)
|
||||
}
|
||||
|
||||
/// Pin a command to the toolbar of a tab (as a button of its panel, at the end).
|
||||
pub fn pin(app: &mut SolveApp, tab: &str, id: &str) -> Result<(), String> {
|
||||
if solvecraft_engine::find_command(id).is_none() {
|
||||
return Err(format!("no command `{id}`"));
|
||||
}
|
||||
if pinned_on(app, tab, id).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let panel = home_panel(tab, id).ok_or_else(|| format!("the {tab} tab has no panels"))?;
|
||||
let mut ids = own(app, tab, panel);
|
||||
ids.push(id.to_string());
|
||||
app.toolbar_custom.panels.insert(key(tab, panel), ids);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a command's button from a tab (it stays in its panel's drop-down).
|
||||
pub fn remove(app: &mut SolveApp, tab: &str, id: &str) {
|
||||
let Some(panel) = pinned_on(app, tab, id) else { return };
|
||||
let mut ids = own(app, tab, panel);
|
||||
ids.retain(|x| x != id);
|
||||
app.toolbar_custom.panels.insert(key(tab, panel), ids);
|
||||
}
|
||||
|
||||
/// Move a button before another one (`before`: None puts it at the end of `panel`).
|
||||
pub fn reorder(app: &mut SolveApp, tab: &str, id: &str, panel: &str, before: Option<&str>) {
|
||||
if let Some(from) = pinned_on(app, tab, id) {
|
||||
let mut ids = own(app, tab, from);
|
||||
ids.retain(|x| x != id);
|
||||
app.toolbar_custom.panels.insert(key(tab, from), ids);
|
||||
}
|
||||
let mut ids = own(app, tab, panel);
|
||||
ids.retain(|x| x != id);
|
||||
let at = before.and_then(|b| ids.iter().position(|x| x == b)).unwrap_or(ids.len());
|
||||
ids.insert(at, id.to_string());
|
||||
app.toolbar_custom.panels.insert(key(tab, panel), ids);
|
||||
}
|
||||
|
||||
/// Reset Toolbar: a tab's panels back to their defaults.
|
||||
pub fn reset(app: &mut SolveApp, tab: &str) {
|
||||
let prefix = format!("{WORKSPACE}/{tab}/");
|
||||
app.toolbar_custom.panels.retain(|k, _| !k.starts_with(&prefix));
|
||||
}
|
||||
|
||||
/// The menu of a command (toolbar button, drop-down row, S box row).
|
||||
pub fn command_items(app: &SolveApp, id: &str) -> Vec<Item> {
|
||||
let tab = app.ui.tab.clone();
|
||||
let on = pinned_on(app, &tab, id).is_some();
|
||||
let fav = app.sbox.pinned.iter().any(|p| p == id);
|
||||
let mut v = Vec::new();
|
||||
v.push(if on {
|
||||
Item::action("ui.toolbarRemove", "Remove from Toolbar", "").with(json!({ "command": id }))
|
||||
} else {
|
||||
Item::action("ui.toolbarPin", "Pin to Toolbar", "").with(json!({ "command": id }))
|
||||
});
|
||||
v.push(Item::action("ui.shortcutPin", if fav { "Unpin from Shortcuts" } else { "Pin to Shortcuts" }, "").with(json!({ "command": id })));
|
||||
v.push(Item::sep());
|
||||
v.push(Item::action("ui.toolbarReset", "Reset Toolbar", "").with(json!({ "tab": tab })));
|
||||
v
|
||||
}
|
||||
|
||||
/// Run a toolbar item from a menu.
|
||||
pub fn run(app: &mut SolveApp, action: &str, p: &Value) {
|
||||
let tab = p.get("tab").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| app.ui.tab.clone());
|
||||
let id = p.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
match action {
|
||||
"ui.toolbarPin" => {
|
||||
if let Err(e) = pin(app, &tab, id) {
|
||||
app.set_status(e, true);
|
||||
}
|
||||
}
|
||||
"ui.toolbarRemove" => remove(app, &tab, id),
|
||||
"ui.toolbarReset" => reset(app, &tab),
|
||||
"ui.shortcutPin" => app.sbox.toggle_pin(id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shortcut keys, S box favourites and the toolbar as one file (Keyboard Shortcuts › Export).
|
||||
pub fn export(app: &SolveApp) -> Value {
|
||||
json!({ "format": "solvecraft-shortcuts/1", "shortcuts": app.keymap.prefs(), "favourites": app.sbox.pinned, "toolbar": app.toolbar_custom.prefs() })
|
||||
}
|
||||
|
||||
pub fn import(app: &mut SolveApp, v: &Value) -> Result<(), String> {
|
||||
if v.get("format").and_then(Value::as_str) != Some("solvecraft-shortcuts/1") {
|
||||
return Err("not a SolveCraft shortcuts file".into());
|
||||
}
|
||||
if let Some(s) = v.get("shortcuts") {
|
||||
app.keymap.load(s);
|
||||
}
|
||||
if let Some(f) = v.get("favourites").and_then(Value::as_array) {
|
||||
app.sbox.pinned = f.iter().take(64).filter_map(|x| x.as_str().map(str::to_string)).collect();
|
||||
}
|
||||
if let Some(t) = v.get("toolbar") {
|
||||
app.toolbar_custom.load(t);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -242,38 +242,9 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec<String> {
|
||||
.ok()
|
||||
.map(|v| v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect())
|
||||
.unwrap_or_default();
|
||||
tie_snaps(app, t, &curves);
|
||||
curves
|
||||
}
|
||||
|
||||
/// A click that snapped to an existing point (the origin, a corner) but went in as plain
|
||||
/// coordinates: tie the new shape's point there to it, as Fusion does.
|
||||
fn tie_snaps(app: &mut SolveApp, t: &Tool, curves: &[String]) {
|
||||
let mut ties = Vec::new();
|
||||
{
|
||||
let st = app.session.world_state();
|
||||
let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return };
|
||||
let sk = &ss.sketch;
|
||||
let new_pts: Vec<usize> =
|
||||
curves.iter().filter_map(|c| sk.curve_index(c)).flat_map(|i| sk.curves.get(i).map(|c| c.kind.point_ids()).unwrap_or_default()).collect();
|
||||
for (at, r) in &t.pts {
|
||||
let Some(r) = r else { continue };
|
||||
let Some(target) = sk.resolve_point(r) else { continue };
|
||||
if new_pts.contains(&target) {
|
||||
continue;
|
||||
}
|
||||
if let Some(q) = new_pts.iter().copied().find(|q| sk.point(*q).is_some_and(|p| p.dist(*at) < 1e-6))
|
||||
&& let Some(qid) = sk.points.get(q).map(|p| p.id.clone())
|
||||
{
|
||||
ties.push((r.clone(), qid));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (a, b) in ties {
|
||||
let _ = app.run("sketch.constraint.coincident", json!({"a": a, "b": b}));
|
||||
}
|
||||
}
|
||||
|
||||
fn run_pick(app: &mut SolveApp, t: &Tool) {
|
||||
let k = &t.picks;
|
||||
let params = match t.cmd {
|
||||
|
||||
+270
-61
@@ -6,7 +6,7 @@ use serde_json::json;
|
||||
use solvecraft_engine::Sel;
|
||||
use solvecraft_engine::geom::{Vec2, Vec3};
|
||||
use solvecraft_engine::render::{Camera, Mat4, Rgb};
|
||||
use solvecraft_engine::sketch::{ConstraintKind, CurveKind};
|
||||
use solvecraft_engine::sketch::CurveKind;
|
||||
use solvecraft_engine::view::{colors, grid_step, sketch_consumed, sketch_lines};
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
@@ -125,6 +125,44 @@ impl Proj {
|
||||
}
|
||||
}
|
||||
|
||||
/// A projected 3D face (convex): an unfeathered triangle fan, outlined one edge at a time (no
|
||||
/// joins between the edges). Unlike `Shape::convex_polygon`, it stays inside its outline when
|
||||
/// the face turns edge-on.
|
||||
pub(crate) fn projected_polygon(pts: &[Pos2], fill: Color32, stroke: Stroke) -> Vec<Shape> {
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for (i, p) in (0u32..).zip(pts) {
|
||||
mesh.colored_vertex(*p, fill);
|
||||
if i >= 2 {
|
||||
mesh.add_triangle(0, i - 1, i);
|
||||
}
|
||||
}
|
||||
let mut out = vec![Shape::mesh(mesh)];
|
||||
out.extend(pts.iter().zip(pts.iter().skip(1).chain(pts.first())).map(|(a, b)| Shape::line_segment([*a, *b], stroke)));
|
||||
out
|
||||
}
|
||||
|
||||
/// View directions all round, a few degrees apart, none exactly along an axis.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn orbit_directions() -> impl Iterator<Item = Vec3> {
|
||||
(0..90).flat_map(|i| {
|
||||
(0..45).map(move |j| {
|
||||
let (yaw, pitch) = (f64::from(i) * 4.0f64.to_radians() + 0.003, f64::from(j) * 4.0f64.to_radians() - 1.5);
|
||||
Vec3::new(pitch.cos() * yaw.cos(), pitch.cos() * yaw.sin(), pitch.sin())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Every vertex egui draws for `shapes`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn drawn_points(shapes: Vec<Shape>) -> Vec<Pos2> {
|
||||
let mut tess = egui::epaint::Tessellator::new(1.0, egui::epaint::TessellationOptions::default(), [1, 1], vec![]);
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for shape in shapes {
|
||||
tess.tessellate_shape(shape, &mut mesh);
|
||||
}
|
||||
mesh.vertices.iter().map(|v| v.pos).collect()
|
||||
}
|
||||
|
||||
fn rgba(c: Rgb) -> [u8; 4] {
|
||||
[c.0, c.1, c.2, 255]
|
||||
}
|
||||
@@ -165,6 +203,9 @@ fn scene_key(app: &SolveApp) -> u64 {
|
||||
app.ui.show_grid.hash(&mut h);
|
||||
app.ui.visual_style.hash(&mut h);
|
||||
app.ui.ground_shadow.hash(&mut h);
|
||||
// The section plane (its cut outlines are part of the model scene).
|
||||
section_plane(app).map(|(o, n)| [o.x, o.y, o.z, n.x, n.y, n.z].map(f64::to_bits)).hash(&mut h);
|
||||
app.cam.up.hash(&mut h);
|
||||
(app.cam.pitch > 0.0).hash(&mut h);
|
||||
app.ui.show_sketches.hash(&mut h);
|
||||
app.ui.hidden_bodies.hash(&mut h);
|
||||
@@ -204,15 +245,18 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
if app.ui.show_grid && !active_view(app).hide_grid {
|
||||
let (minor, major) = grid_step(app.cam.half_height());
|
||||
let ext = (app.cam.half_height() * 3.0 / major).ceil() * major;
|
||||
let c = Vec3::new((app.cam.target.x / major).round() * major, (app.cam.target.y / major).round() * major, 0.0);
|
||||
// The ground plane: XY with Z up, XZ with Y up (laid out in the camera's Z-up frame).
|
||||
let tf = app.cam.to_frame(app.cam.target);
|
||||
let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0);
|
||||
let w3 = |p: Vec3| app.cam.from_frame(p).to_f32();
|
||||
let n = ((ext / minor) as i64).clamp(1, 300);
|
||||
for i in -n..=n {
|
||||
let t = i as f64 * minor;
|
||||
let maj = (t / major).round() * major == t;
|
||||
let col = if maj { c4(tk.grid_major) } else { c4(tk.grid) };
|
||||
let w = if maj { 1.0 } else { 0.7 };
|
||||
sc.under_line((c + Vec3::new(t, -ext, 0.0)).to_f32(), (c + Vec3::new(t, ext, 0.0)).to_f32(), col, w);
|
||||
sc.under_line((c + Vec3::new(-ext, t, 0.0)).to_f32(), (c + Vec3::new(ext, t, 0.0)).to_f32(), col, w);
|
||||
sc.under_line(w3(c + Vec3::new(t, -ext, 0.0)), w3(c + Vec3::new(t, ext, 0.0)), col, w);
|
||||
sc.under_line(w3(c + Vec3::new(-ext, t, 0.0)), w3(c + Vec3::new(ext, t, 0.0)), col, w);
|
||||
}
|
||||
}
|
||||
for b in &st.bodies {
|
||||
@@ -277,6 +321,15 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
if app.ui.ground_shadow && app.ui.visual_style != 2 && app.cam.pitch > 0.0 {
|
||||
ground_shadow(&mut sc, app, &st);
|
||||
}
|
||||
// Section: the outline of each body where the plane cuts it.
|
||||
if let Some((o, n)) = section_plane(app) {
|
||||
let col = c4(tk.text.gamma_multiply(0.9));
|
||||
for b in st.bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(&b.name)) {
|
||||
for (a, c) in section_segments(&b.mesh(), o, n) {
|
||||
sc.line(a.to_f32(), c.to_f32(), col, 1.8, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
for sid in visible_sketches(app) {
|
||||
let Some(ss) = st.sketch(sid) else { continue };
|
||||
let active = s.active_sketch == Some(sid);
|
||||
@@ -310,9 +363,21 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
|
||||
/// A soft dark ellipse on the ground under the shown bodies (darkest in the middle).
|
||||
fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc::ModelState) {
|
||||
// The bodies' box in the camera's Z-up frame (the ground is its XY plane).
|
||||
let mut b = solvecraft_engine::geom::Aabb3::EMPTY;
|
||||
for x in st.bodies.iter().filter(|x| !app.ui.hidden_bodies.contains(&x.name)) {
|
||||
b = b.union(&x.mesh().bounds());
|
||||
let bb = x.mesh().bounds();
|
||||
if bb.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for i in 0..8 {
|
||||
let p = Vec3::new(
|
||||
if i & 1 == 0 { bb.min.x } else { bb.max.x },
|
||||
if i & 2 == 0 { bb.min.y } else { bb.max.y },
|
||||
if i & 4 == 0 { bb.min.z } else { bb.max.z },
|
||||
);
|
||||
b.add(app.cam.to_frame(p));
|
||||
}
|
||||
}
|
||||
if b.is_empty() {
|
||||
return;
|
||||
@@ -330,7 +395,7 @@ fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc:
|
||||
let n = 48;
|
||||
let at = |r: f64, k: usize| {
|
||||
let a = k as f64 / n as f64 * std::f64::consts::TAU;
|
||||
Vec3::new(c.x + rx * r * a.cos(), c.y + ry * r * a.sin(), z).to_f32()
|
||||
app.cam.from_frame(Vec3::new(c.x + rx * r * a.cos(), c.y + ry * r * a.sin(), z)).to_f32()
|
||||
};
|
||||
for w in rings.windows(2) {
|
||||
let ((r0, a0), (r1, a1)) = (w[0], w[1]);
|
||||
@@ -349,6 +414,28 @@ fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc:
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the plane through `o` with normal `n` cuts a mesh: one segment per crossed triangle,
|
||||
/// nudged a hair to the kept side (so the clip leaves them drawn).
|
||||
pub(crate) fn section_segments(m: &solvecraft_engine::geom::Mesh, o: Vec3, n: Vec3) -> Vec<(Vec3, Vec3)> {
|
||||
let eps = 1e-4 * (1.0 + m.bounds().diagonal());
|
||||
let mut out = Vec::new();
|
||||
for t in &m.triangles {
|
||||
let Some(p) = m.tri(t) else { continue };
|
||||
let d = p.map(|q| n.dot(q - o));
|
||||
let mut pts = Vec::with_capacity(2);
|
||||
for (i, j) in [(0, 1), (1, 2), (2, 0)] {
|
||||
if (d[i] < 0.0) != (d[j] < 0.0) {
|
||||
let s = d[i] / (d[i] - d[j]);
|
||||
pts.push(p[i] + (p[j] - p[i]) * s - n * eps);
|
||||
}
|
||||
}
|
||||
if let [a, b] = pts[..] {
|
||||
out.push((a, b));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The drawn pieces of a polyline in a dash pattern (on, off, on, off… lengths).
|
||||
pub(crate) fn dashes(pts: &[Vec3], pattern: &[f64]) -> Vec<(Vec3, Vec3)> {
|
||||
let mut out = Vec::new();
|
||||
@@ -390,7 +477,9 @@ pub fn origin_planes(app: &SolveApp) -> Vec<(&'static str, Vec3, [Vec3; 4])> {
|
||||
if !app.origin_visible() {
|
||||
return Vec::new();
|
||||
}
|
||||
let creating = app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::Sketch));
|
||||
// While the Origin is hidden, Create Sketch may bring the planes back (empty design): then
|
||||
// every plane; otherwise only those not hidden one by one.
|
||||
let creating = !app.ui.show_origin && app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::Sketch));
|
||||
origin_quads(origin_size(app.cam.half_height()))
|
||||
.into_iter()
|
||||
.filter(|(n, _, _)| creating || !app.ui.hidden_origin.iter().any(|h| h == n))
|
||||
@@ -724,14 +813,15 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||||
if let Some(f) = bestf {
|
||||
surf.push(f);
|
||||
}
|
||||
// Planes give way to a face or profile at the same depth (a floor on the XZ plane).
|
||||
for (name, _, q) in origin_planes(app) {
|
||||
if let Some(t) = ray_quad(o, d, &q) {
|
||||
surf.push((t, Hit::Plane { name: name.into(), point: o + d * t }));
|
||||
surf.push((t + slack, Hit::Plane { name: name.into(), point: o + d * t }));
|
||||
}
|
||||
}
|
||||
for (name, _, q) in construction_quads(app) {
|
||||
if let Some(t) = ray_quad(o, d, &q) {
|
||||
surf.push((t, Hit::Plane { name, point: o + d * t }));
|
||||
surf.push((t + slack, Hit::Plane { name, point: o + d * t }));
|
||||
}
|
||||
}
|
||||
surf.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
@@ -910,12 +1000,29 @@ fn face_tris(sc: &mut GpuScene, m: &solvecraft_engine::geom::Mesh, face: usize,
|
||||
}
|
||||
}
|
||||
|
||||
/// A highlighted edge: a halo and a core, and where the model hides it, the core dimmed (so a
|
||||
/// pick on the far side still shows).
|
||||
fn edge_lines(sc: &mut GpuScene, e: &[Vec3], core: Color32, halo: Color32, w: f32) {
|
||||
for (col, width) in [(halo, w + 3.0), (core, w)] {
|
||||
for p in e.windows(2) {
|
||||
sc.line(p[0].to_f32(), p[1].to_f32(), c4(col), width, false);
|
||||
}
|
||||
}
|
||||
for p in e.windows(2) {
|
||||
sc.hidden_line(p[0].to_f32(), p[1].to_f32(), c4(core.gamma_multiply(0.45)), (w * 0.7).max(1.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline of a highlighted face where the model hides it (dimmed).
|
||||
fn hidden_face_outline(sc: &mut GpuScene, m: &solvecraft_engine::geom::Mesh, face: usize, col: Color32) {
|
||||
for (ei, e) in m.edges.iter().enumerate() {
|
||||
let on = m.edge_faces.get(ei).is_some_and(|f| f.iter().any(|x| *x as usize == face));
|
||||
if on && !m.seams.get(ei).copied().unwrap_or(false) {
|
||||
for p in e.windows(2) {
|
||||
sc.hidden_line(p[0].to_f32(), p[1].to_f32(), c4(col.gamma_multiply(0.45)), 1.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) {
|
||||
@@ -1045,6 +1152,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
// A live preview stands in for the body: its old faces would fight with it.
|
||||
if let Some(b) = st.body(body).filter(|_| !app.preview.replaced.contains(body)) {
|
||||
face_tris(&mut sc, &b.mesh(), *index, c4(t.sel_face), false);
|
||||
hidden_face_outline(&mut sc, &b.mesh(), *index, t.sel_edge);
|
||||
}
|
||||
}
|
||||
Sel::Body { name } => {
|
||||
@@ -1255,7 +1363,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let h = rect.height().max(1.0) as f64;
|
||||
if inside && scroll != 0.0 {
|
||||
let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp();
|
||||
let anchor = hover.map(|p| {
|
||||
// Preferences › Navigation › Zoom to the cursor (off: toward the view's centre).
|
||||
let anchor = hover.filter(|_| app.preferences.zoom_to_cursor).map(|p| {
|
||||
let proj = projection(app, rect);
|
||||
// Toward the model's surface under the cursor, else the target's depth.
|
||||
if let Some(q) = pick_cached(app, &proj, p).iter().find_map(|h| if let Hit::Face { point, .. } = h { Some(*point) } else { None }) {
|
||||
@@ -1775,7 +1884,7 @@ fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sketch points, dimension labels and constraint glyphs of the active sketch.
|
||||
/// Sketch points and the status of the active sketch (constraint glyphs: `sketch_tools`).
|
||||
fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
let t = Tokens::get();
|
||||
let st = app.session.world_state();
|
||||
@@ -1803,9 +1912,6 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c);
|
||||
}
|
||||
}
|
||||
for c in sk.constraints.iter().filter(|c| !c.kind.is_dimension() && !sk.view.hide_constraints) {
|
||||
glyph(painter, proj, ss, &c.kind);
|
||||
}
|
||||
// Sketch status.
|
||||
if let Some(r) = app.viewport.rect {
|
||||
let msg = if !ss.report.ok() {
|
||||
@@ -1820,35 +1926,6 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
}
|
||||
}
|
||||
|
||||
fn line_mid(sk: &solvecraft_engine::sketch::Sketch, l: usize) -> Option<(Vec2, Vec2)> {
|
||||
match sk.curves.get(l)?.kind {
|
||||
CurveKind::Line { a, b } => Some((sk.point(a)?, sk.point(b)?)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Small constraint markers next to the constrained geometry.
|
||||
fn glyph(painter: &egui::Painter, proj: &Proj, ss: &solvecraft_engine::doc::SolvedSketch, k: &ConstraintKind) {
|
||||
use ConstraintKind::*;
|
||||
let sk = &ss.sketch;
|
||||
let (anchor, label) = match *k {
|
||||
Horizontal { l } => (line_mid(sk, l).map(|(a, b)| (a + b) * 0.5), "H"),
|
||||
Vertical { l } => (line_mid(sk, l).map(|(a, b)| (a + b) * 0.5), "V"),
|
||||
Parallel { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5), "//"),
|
||||
Perpendicular { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5), "L"),
|
||||
Tangent { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5).or_else(|| sk.center(a)), "T"),
|
||||
Equal { a, .. } => (line_mid(sk, a).map(|(p, q)| (p + q) * 0.5).or_else(|| sk.center(a)), "="),
|
||||
Fix { p } => (sk.point(p), "F"),
|
||||
_ => (None, ""),
|
||||
};
|
||||
let Some(a) = anchor.and_then(|a| proj.to_screen(ss.plane.to_world(a))) else { return };
|
||||
let r = Rect::from_center_size(a + vec2(10.0, -10.0), vec2(13.0, 13.0));
|
||||
let t = Tokens::get();
|
||||
painter.rect_filled(r, 2.0, t.glyph_bg);
|
||||
painter.rect_stroke(r, 2.0, Stroke::new(1.0, t.glyph_edge), egui::StrokeKind::Inside);
|
||||
painter.text(r.center(), Align2::CENTER_CENTER, label, FontId::proportional(10.0), t.glyph_text);
|
||||
}
|
||||
|
||||
fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, rect: Rect, proj: &Proj) {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
scene_key(app).hash(&mut h);
|
||||
@@ -1873,7 +1950,7 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
|
||||
|
||||
/// Where a point in view-cube units (faces at ±1) is drawn.
|
||||
pub(crate) fn cube_point(app: &SolveApp, rect: Rect, p: Vec3) -> Pos2 {
|
||||
let (r, u, _) = app.cam.basis();
|
||||
let (r, u, _) = cube_basis(&app.cam);
|
||||
pos2(rect.right() - 80.0 + (p.dot(r) * CUBE_SCALE) as f32, rect.top() + 80.0 - (p.dot(u) * CUBE_SCALE) as f32)
|
||||
}
|
||||
|
||||
@@ -1884,29 +1961,46 @@ const CUBE_SCALE: f64 = 32.0;
|
||||
/// (orthographic views) and the 8 corner facets (iso views); the edges between them are not
|
||||
/// targets. Hover lights the face or facet under the pointer, a click turns the view to it, the
|
||||
/// house goes home.
|
||||
/// The camera's basis in its up-axis frame (Z up): the cube's TOP is the world's up, X, Y or Z.
|
||||
fn cube_basis(cam: &Camera) -> (Vec3, Vec3, Vec3) {
|
||||
let (r, u, b) = cam.basis();
|
||||
(cam.to_frame(r), cam.to_frame(u), cam.to_frame(b))
|
||||
}
|
||||
|
||||
fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = Tokens::get();
|
||||
let c = pos2(rect.right() - 80.0, rect.top() + 80.0);
|
||||
let (r, u, b) = app.cam.basis();
|
||||
let (r, u, b) = cube_basis(&app.cam);
|
||||
let (wr, wu, _) = app.cam.basis();
|
||||
let painter = ui.painter_at(rect);
|
||||
let hover = ui.input(|i| i.pointer.hover_pos());
|
||||
let targets = cube_targets();
|
||||
let shapes = cube_projected(&targets, (r, u, b), c);
|
||||
let hovered = hover.and_then(|h| cube_hit(&shapes, h));
|
||||
// Axis triad from the cube's back corner, drawn first: the cube hides what is behind it.
|
||||
// The corner is in the cube's frame, the axes are the world's.
|
||||
let o = Vec3::new(-1.0, -1.0, -1.0);
|
||||
let at = pos2(c.x + (o.dot(r) * CUBE_SCALE) as f32, c.y - (o.dot(u) * CUBE_SCALE) as f32);
|
||||
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
|
||||
let tip = at + vec2((d.dot(wr) * CUBE_SCALE * 2.6) as f32, -(d.dot(wu) * CUBE_SCALE * 2.6) as f32);
|
||||
painter.line_segment([at, tip], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2)));
|
||||
}
|
||||
// Back to front: the faces and facets turned toward the camera.
|
||||
for (i, pts) in shapes.iter().rev() {
|
||||
let tg = &targets[*i];
|
||||
let n = tg.normal();
|
||||
let shade = (0.75 + 0.25 * n.dot(b)) as f32;
|
||||
let fill = if tg.corner { t.cube_face.gamma_multiply(shade * 0.92) } else { t.cube_face.gamma_multiply(shade) };
|
||||
painter.add(Shape::convex_polygon(pts.clone(), fill.to_opaque(), Stroke::new(1.0, t.cube_edge)));
|
||||
painter.extend(projected_polygon(pts, fill.to_opaque(), Stroke::new(1.0, t.cube_edge)));
|
||||
if hovered == Some(*i) {
|
||||
painter.add(Shape::convex_polygon(pts.clone(), t.accent_soft, Stroke::new(1.0, t.accent)));
|
||||
painter.extend(projected_polygon(pts, t.accent_soft, Stroke::new(1.0, t.accent)));
|
||||
}
|
||||
let centre = cube_centroid(pts);
|
||||
crate::scenario::publish_handle(&format!("cube:{}", tg.name), centre);
|
||||
if !tg.corner && n.dot(b) > 0.35 {
|
||||
painter.text(centre, Align2::CENTER_CENTER, tg.name.to_uppercase(), FontId::proportional(9.5), Color32::from_rgb(40, 44, 52));
|
||||
// Fades out as the face turns edge-on.
|
||||
let ink = (n.dot(b) / 0.3).clamp(0.0, 1.0) as f32;
|
||||
if !tg.corner && ink > 0.05 {
|
||||
painter.add(cube_label(ui.ctx(), &tg.name.to_uppercase(), n, (r, u), c, Color32::from_rgb(40, 44, 52).gamma_multiply(ink)));
|
||||
}
|
||||
}
|
||||
crate::scenario::publish_count("cube_highlights", f64::from(u8::from(hovered.is_some())));
|
||||
@@ -1915,12 +2009,6 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
// Clicked: the view from the face's or facet's direction (target → eye).
|
||||
let clicked = hovered.filter(|_| ui.input(|i| i.pointer.primary_clicked())).map(|i| targets[i].dir);
|
||||
// Axis triad at the cube's corner.
|
||||
let to2 = |p: Vec3| pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32);
|
||||
let o = Vec3::new(-1.0, -1.0, -1.0);
|
||||
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
|
||||
painter.line_segment([to2(o), to2(o + d * 2.6)], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2)));
|
||||
}
|
||||
let home = Rect::from_center_size(pos2(c.x - 52.0, c.y - 48.0), vec2(18.0, 18.0));
|
||||
let hr = ui.interact(home, ui.id().with("vc_home"), Sense::click());
|
||||
icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { t.overlay }, t.accent);
|
||||
@@ -1928,7 +2016,11 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.animate_view("home");
|
||||
}
|
||||
if let Some(dir) = clicked {
|
||||
let to = app.cam.looking_from(dir);
|
||||
let mut to = app.cam.looking_from(app.cam.from_frame(dir));
|
||||
// TOP and BOTTOM turn to the standard plan views (front edge at the bottom).
|
||||
if dir.x == 0.0 && dir.y == 0.0 {
|
||||
to.yaw = 0.0;
|
||||
}
|
||||
app.animate_to(to);
|
||||
}
|
||||
}
|
||||
@@ -1987,16 +2079,44 @@ pub(crate) fn cube_targets() -> Vec<CubeTarget> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a point in cube units is drawn, for the camera's right and up `ru` and the cube's
|
||||
/// centre `c`.
|
||||
fn cube_to_screen((r, u): (Vec3, Vec3), c: Pos2, p: Vec3) -> Pos2 {
|
||||
pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32)
|
||||
}
|
||||
|
||||
/// A face's text directions (right, up): read from outside the cube, and upright in the view
|
||||
/// clicking the face turns to (TOP and BOTTOM: the plan views, front edge at the bottom).
|
||||
fn cube_label_axes(n: Vec3) -> (Vec3, Vec3) {
|
||||
if n.z.abs() > 0.5 { (Vec3::X, Vec3::Y * n.z.signum()) } else { (Vec3::Z.cross(n), Vec3::Z) }
|
||||
}
|
||||
|
||||
/// A face's label printed on the face: laid out flat, then mapped through the cube's projection,
|
||||
/// so it turns and foreshortens with the face.
|
||||
fn cube_label(ctx: &egui::Context, text: &str, n: Vec3, ru: (Vec3, Vec3), c: Pos2, color: Color32) -> Shape {
|
||||
let galley = ctx.fonts_mut(|f| f.layout(text.to_string(), FontId::proportional(9.5), color, f32::INFINITY));
|
||||
let options = egui::epaint::TessellationOptions { round_text_to_pixels: false, ..Default::default() };
|
||||
let mut tess = egui::epaint::Tessellator::new(ctx.pixels_per_point(), options, ctx.fonts(|f| f.font_image_size()), vec![]);
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let at = (-galley.size() / 2.0).to_pos2();
|
||||
tess.tessellate_text(&egui::epaint::TextShape::new(at, galley, color), &mut mesh);
|
||||
let (right, up) = cube_label_axes(n);
|
||||
for v in &mut mesh.vertices {
|
||||
let p = n + right * (f64::from(v.pos.x) / CUBE_SCALE) - up * (f64::from(v.pos.y) / CUBE_SCALE);
|
||||
v.pos = cube_to_screen(ru, c, p);
|
||||
}
|
||||
Shape::mesh(mesh)
|
||||
}
|
||||
|
||||
/// The targets turned toward the camera (basis `rub`), front first, as screen polygons around
|
||||
/// the cube's centre `c`.
|
||||
pub(crate) fn cube_projected(targets: &[CubeTarget], rub: (Vec3, Vec3, Vec3), c: Pos2) -> Vec<(usize, Vec<Pos2>)> {
|
||||
let (r, u, b) = rub;
|
||||
let to2 = |p: Vec3| pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32);
|
||||
let mut v: Vec<(usize, f64, Vec<Pos2>)> = targets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, tg)| tg.normal().dot(b) > 1e-3)
|
||||
.map(|(i, tg)| (i, tg.normal().dot(b), tg.outline.iter().map(|p| to2(*p)).collect()))
|
||||
.map(|(i, tg)| (i, tg.normal().dot(b), tg.outline.iter().map(|p| cube_to_screen((r, u), c, *p)).collect()))
|
||||
.collect();
|
||||
v.sort_by(|a, b2| b2.1.total_cmp(&a.1));
|
||||
v.into_iter().map(|(i, _, p)| (i, p)).collect()
|
||||
@@ -2155,9 +2275,7 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
|
||||
ui.add_enabled_ui(s.show, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Speed");
|
||||
for sp in crate::agent_cursor::Speed::ALL {
|
||||
ui.selectable_value(&mut s.speed, sp, sp.label());
|
||||
}
|
||||
crate::agent_cursor::speed_slider(ui, &mut s.speed);
|
||||
});
|
||||
ui.checkbox(&mut s.follow_camera, "Follow camera (Look At sketches)");
|
||||
});
|
||||
@@ -2172,6 +2290,37 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn section_outlines_follow_the_cut() {
|
||||
let mut s = solvecraft_engine::Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let st = s.model.state();
|
||||
let m = st.bodies[0].mesh();
|
||||
let (o, n) = (Vec3::new(0.0, 15.0, 0.0), Vec3::new(0.0, -1.0, 0.0));
|
||||
let segs = section_segments(&m, o, n);
|
||||
assert!(segs.len() >= 4, "{segs:?}");
|
||||
// On the plane (a hair to the kept side) and around the 40 x 20 cut.
|
||||
let mut len = 0.0;
|
||||
for (a, b) in &segs {
|
||||
assert!((a.y - 15.0).abs() < 0.01 && (b.y - 15.0).abs() < 0.01);
|
||||
len += a.dist(*b);
|
||||
}
|
||||
assert!((len - 120.0).abs() < 0.5, "outline length {len}");
|
||||
// A plane that misses the body cuts nothing.
|
||||
assert!(section_segments(&m, Vec3::new(0.0, 99.0, 0.0), n).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_shows_where_the_model_hides_it() {
|
||||
let mut s = solvecraft_engine::Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let mut app = SolveApp::new(s, crate::Services::default());
|
||||
assert!(build_highlight(&app).hidden.is_empty());
|
||||
// The back face: its outline is drawn (dimmed) where the box hides it.
|
||||
app.session.selection = vec![Sel::Face { body: "Body1".into(), index: 3, point: Vec3::new(20.0, 30.0, 10.0) }];
|
||||
assert!(!build_highlight(&app).hidden.is_empty());
|
||||
}
|
||||
|
||||
/// A camera basis looking from `dir` (target → eye), as the view cube draws with.
|
||||
fn basis_from(dir: Vec3) -> (Vec3, Vec3, Vec3) {
|
||||
let cam = solvecraft_engine::render::Camera::default().looking_from(dir);
|
||||
@@ -2196,6 +2345,66 @@ mod tests {
|
||||
assert_eq!(cube_hit(&shapes, pos2(400.0, 400.0)), None);
|
||||
}
|
||||
|
||||
/// From any direction, including faces turned nearly edge-on, everything drawn stays within
|
||||
/// the cube.
|
||||
#[test]
|
||||
fn view_cube_faces_never_spike_past_the_cube() {
|
||||
let targets = cube_targets();
|
||||
let c = pos2(100.0, 100.0);
|
||||
// The cube's circumradius (corner at √3) plus the stroke and feathering.
|
||||
let limit = (3f64.sqrt() * CUBE_SCALE) as f32 + 3.0;
|
||||
let mut worst = 0.0f32;
|
||||
for dir in orbit_directions() {
|
||||
for (_, pts) in cube_projected(&targets, basis_from(dir), c) {
|
||||
for p in drawn_points(projected_polygon(&pts, Color32::GRAY, Stroke::new(1.0, Color32::BLACK))) {
|
||||
worst = worst.max(p.distance(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(worst <= limit, "a vertex {worst} px from the centre (limit {limit})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_cube_labels_read_upright_in_their_own_view() {
|
||||
for tg in cube_targets().iter().filter(|t| !t.corner) {
|
||||
// The view clicking the face turns to (TOP and BOTTOM with yaw 0).
|
||||
let mut cam = solvecraft_engine::render::Camera::default().looking_from(tg.dir);
|
||||
if tg.dir.z.abs() > 0.5 {
|
||||
cam.yaw = 0.0;
|
||||
}
|
||||
let (r, u, _) = cam.basis();
|
||||
let (right, up) = cube_label_axes(tg.dir);
|
||||
assert!(right.dot(r) > 0.99 && up.dot(u) > 0.99, "{}: right·r {} up·u {}", tg.name, right.dot(r), up.dot(u));
|
||||
}
|
||||
}
|
||||
|
||||
/// From any direction, each label is printed within its face.
|
||||
#[test]
|
||||
fn view_cube_labels_stay_on_their_faces() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut worst, mut drawn) = (f32::NEG_INFINITY, 0);
|
||||
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let targets = cube_targets();
|
||||
let c = pos2(100.0, 100.0);
|
||||
for dir in orbit_directions().step_by(7) {
|
||||
let (r, u, b) = basis_from(dir);
|
||||
for (i, pts) in cube_projected(&targets, (r, u, b), c) {
|
||||
let tg = &targets[i];
|
||||
if tg.corner {
|
||||
continue;
|
||||
}
|
||||
for p in drawn_points(vec![cube_label(ui.ctx(), &tg.name.to_uppercase(), tg.normal(), (r, u), c, Color32::BLACK)]) {
|
||||
let outside = if point_in_poly(p, &pts) { 0.0 } else { poly_distance(p, &pts) };
|
||||
worst = worst.max(outside);
|
||||
drawn += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
assert!(drawn > 10_000, "only {drawn} label vertices drawn");
|
||||
assert!(worst <= 0.5, "a label vertex {worst} px outside its face");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_cube_edges_are_not_targets() {
|
||||
let targets = cube_targets();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"pending": "#40: offsets of projected geometry get new ids on recompute, the lid's lip fails"
|
||||
},
|
||||
{
|
||||
"note": "Acceptance (#27): the board grows 60 \u2192 70 mm; the lid's lip (offsets of the case's projected rim) follows."
|
||||
},
|
||||
{
|
||||
"script": "examples/acceptance/sensor_enclosure.json"
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "parameters.change",
|
||||
"params": {
|
||||
"name": "board_w",
|
||||
"expression": "70 mm"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"errors": 0,
|
||||
"bodies": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"note": "Acceptance (#27): a lip on the case's rounded rim with the Lip command."
|
||||
},
|
||||
{
|
||||
"script": "examples/acceptance/sensor_enclosure.json"
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "plastic.lip",
|
||||
"params": {
|
||||
"face": [
|
||||
0,
|
||||
29,
|
||||
25
|
||||
],
|
||||
"width": 1,
|
||||
"height": 1.5,
|
||||
"body": "Case"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"errors": 0,
|
||||
"bodies": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"note": "Acceptance (#27): suppressing the PCB standoff takes the patterned copies with it."
|
||||
},
|
||||
{
|
||||
"script": "examples/acceptance/sensor_enclosure.json"
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "timeline.suppress",
|
||||
"params": {
|
||||
"feature": "Standoff",
|
||||
"suppressed": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"body_volume": {
|
||||
"body": "Case",
|
||||
"volume": 24934.4
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,145 @@
|
||||
[
|
||||
{
|
||||
"note": "Acceptance (#27): the sensor enclosure built from examples/acceptance/sensor_enclosure.json, then edited: the USB cut-out moved, the height changed, features suppressed and back, saved and reopened, exported to STEP and read back."
|
||||
},
|
||||
{
|
||||
"script": "examples/acceptance/sensor_enclosure.json"
|
||||
},
|
||||
{
|
||||
"call": "ui.view",
|
||||
"params": {
|
||||
"view": "home"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"bodies": 2,
|
||||
"errors": 0,
|
||||
"body_volume": {
|
||||
"body": "Case",
|
||||
"volume": 25401.7
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"body_volume": {
|
||||
"body": "Lid",
|
||||
"volume": 19629.7
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "parameters.change",
|
||||
"params": {
|
||||
"name": "usb_offset",
|
||||
"expression": "15 mm"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"errors": 0,
|
||||
"bodies": 2,
|
||||
"body_volume": {
|
||||
"body": "Case",
|
||||
"volume": 25401.7
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "timeline.suppress",
|
||||
"params": {
|
||||
"feature": "SnapFit1",
|
||||
"suppressed": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"errors": 0,
|
||||
"body_volume": {
|
||||
"body": "Case",
|
||||
"volume": 25340.5
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "timeline.suppress",
|
||||
"params": {
|
||||
"feature": "SnapFit1",
|
||||
"suppressed": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"errors": 0,
|
||||
"body_volume": {
|
||||
"body": "Case",
|
||||
"volume": 25401.7
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "file.save_as",
|
||||
"params": {
|
||||
"path": "$DIR/enclosure.solvecraft"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "doc.open",
|
||||
"params": {
|
||||
"path": "$DIR/enclosure.solvecraft"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"errors": 0,
|
||||
"bodies": 2,
|
||||
"body_volume": {
|
||||
"body": "Case",
|
||||
"volume": 25401.7
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "file.export",
|
||||
"params": {
|
||||
"path": "$DIR/enclosure.step"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "doc.open",
|
||||
"params": {
|
||||
"path": "$DIR/enclosure.step"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"bodies": 2,
|
||||
"body_volume": {
|
||||
"body": "Case",
|
||||
"volume": 25401.7
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
[
|
||||
{
|
||||
"pending": "#41: a snap fit stays at its old point when its wall moves (a floating body)"
|
||||
},
|
||||
{
|
||||
"note": "Acceptance (#27): the board grows 40 \u2192 50 mm deep; the snap fit stays on the moved front wall."
|
||||
},
|
||||
{
|
||||
"script": "examples/acceptance/sensor_enclosure.json"
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "parameters.change",
|
||||
"params": {
|
||||
"name": "board_d",
|
||||
"expression": "50 mm"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"errors": 0,
|
||||
"bodies": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"bbox": {
|
||||
"body": "Case",
|
||||
"min": [
|
||||
-40,
|
||||
-35,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
40,
|
||||
35,
|
||||
25
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,17 +1,32 @@
|
||||
[
|
||||
{"note": "#35: with the agent cursor on, an agent's commands are acted out (the reply waits for the pointer) and give the same result as with it off"},
|
||||
{"call": "ui.agent_cursor", "params": {"show": true, "speed": "normal", "follow_camera": true}},
|
||||
{"note": "#35, #79: with the agent cursor on, an agent's commands are acted out (the reply waits for the pointer) at 1x and 3x; at Instant they run at once. Each speed builds the same body as the cursor being off."},
|
||||
{"call": "ui.agent_cursor", "params": {"show": true, "speed": 1, "follow_camera": true}},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}, "min_frames": 30},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}, "min_frames": 45},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}, "min_frames": 30},
|
||||
{"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}, "min_frames": 30},
|
||||
{"expect": {"errors": 0, "bodies": 1, "volume": [10000, 1e-6]}},
|
||||
{"note": "3x: still acted out, about three times as fast"},
|
||||
{"call": "ui.agent_cursor", "params": {"speed": 3}},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}, "min_frames": 8, "max_frames": 45},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}, "min_frames": 8},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}, "min_frames": 8},
|
||||
{"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}, "min_frames": 8},
|
||||
{"expect": {"errors": 0, "bodies": 2, "volume": [20000, 1e-6]}},
|
||||
{"note": "Instant: answered on the next frame, no animation"},
|
||||
{"call": "ui.agent_cursor", "params": {"speed": "instant"}},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}, "max_frames": 2},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}, "max_frames": 2},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}, "max_frames": 2},
|
||||
{"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}, "max_frames": 2},
|
||||
{"expect": {"errors": 0, "bodies": 3, "volume": [30000, 1e-6]}},
|
||||
{"note": "A call with animate: false runs at once"},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}, "animate": false}},
|
||||
{"call": "ui.agent_cursor", "params": {"speed": 1}},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}, "animate": false}, "max_frames": 2},
|
||||
{"call": "ui.agent_cursor", "params": {"show": false}},
|
||||
{"note": "The same steps with the cursor off: a second, identical body"},
|
||||
{"note": "The same steps with the cursor off: a fourth, identical body"},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}},
|
||||
{"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}},
|
||||
{"expect": {"errors": 0, "bodies": 2, "volume": [20000, 1e-6]}}
|
||||
{"expect": {"errors": 0, "bodies": 4, "volume": [40000, 1e-6]}}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
[
|
||||
{
|
||||
"note": "A design with every kind of browser folder: bodies in a group, a component, a sketch, a construction plane, a canvas and named views."
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "solid.box",
|
||||
"params": {
|
||||
"length": 10,
|
||||
"width": 10,
|
||||
"height": 10
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "solid.box",
|
||||
"params": {
|
||||
"length": 10,
|
||||
"width": 10,
|
||||
"height": 10,
|
||||
"corner": [
|
||||
30,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "solid.box",
|
||||
"params": {
|
||||
"length": 10,
|
||||
"width": 10,
|
||||
"height": 10,
|
||||
"corner": [
|
||||
60,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "component.from_bodies",
|
||||
"params": {
|
||||
"bodies": [
|
||||
"Body3"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "browser.group",
|
||||
"params": {
|
||||
"folder": "bodies",
|
||||
"items": [
|
||||
"Body1"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "construct.plane.offset",
|
||||
"params": {
|
||||
"base": "XY",
|
||||
"offset": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": "XZ"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "sketch.rectangle.two_point",
|
||||
"params": {
|
||||
"p0": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"p1": [
|
||||
5,
|
||||
5
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"make_image": "pic.png"
|
||||
},
|
||||
{
|
||||
"choose_file": "pic.png"
|
||||
},
|
||||
{
|
||||
"start": "canvas.insert"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"plane": "XY"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Escape"
|
||||
},
|
||||
{
|
||||
"call": "ui.set",
|
||||
"params": {
|
||||
"dark": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "Folders that start closed open with their arrow, and close again; the click selects nothing."
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_lacks": [
|
||||
"Units: mm"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Document Settings"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Units: mm"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Document Settings"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_lacks": [
|
||||
"Units: mm"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_lacks": [
|
||||
"Top"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Named Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Top"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Named Views"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_lacks": [
|
||||
"Top"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_lacks": [
|
||||
"XY"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Origin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"XY"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Origin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_lacks": [
|
||||
"XY"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "Folders that start open close with their arrow, and open again."
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Body1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Group1 (1)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_lacks": [
|
||||
"Body1"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Group1 (1)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Body1"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Sketch1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Sketches"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_lacks": [
|
||||
"Sketch1"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Sketches"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Sketch1"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Plane1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Construction"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_lacks": [
|
||||
"Plane1"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Construction"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Plane1"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Canvas1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Canvases"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_lacks": [
|
||||
"Canvas1"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Canvases"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Canvas1"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Body3"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Body3:1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_lacks": [
|
||||
"Body3"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Body3:1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Body3"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Body2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Bodies"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_lacks": [
|
||||
"Body2"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Bodies"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Body2"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "The design's own row closes the whole tree."
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Bodies"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Untitled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_lacks": [
|
||||
"Bodies"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"chevron": "Untitled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Bodies"
|
||||
],
|
||||
"browser_selected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "Clicking the row of a folder (not its arrow) toggles it too; clicking a component's arrow doesn't pick it."
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"browser": "Sketches"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_lacks": [
|
||||
"Sketch1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"browser": "Sketches"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"browser_has": [
|
||||
"Sketch1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"shot": "browser_folds"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 20], "connect": false}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.circle.center", "params": {"center": [60, 10], "radius": 5}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[45, -10], [75, -10]]}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[45, 30], [75, 30]]}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.constraint.parallel", "params": {"a": "l5", "b": "l6"}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.constraint.tangent", "params": {"a": "l5", "b": "c1"}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.constraint.tangent", "params": {"a": "l6", "b": "c1"}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.constraint.equal", "params": {"a": "l5", "b": "l6"}}},
|
||||
{"call": "ui.view", "params": {"view": "top"}},
|
||||
{"call": "ui.view", "params": {"view": "fit"}},
|
||||
{"expect": {"glyphs": {"k1": 1, "k5": 2, "k6": 2, "k7": 2, "k8": 2}}}
|
||||
]
|
||||
@@ -0,0 +1,81 @@
|
||||
[
|
||||
{
|
||||
"note": "#31: a face wins a tie with an origin plane through it (a shelled box's floor where the XZ plane crosses it)."
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "solid.box",
|
||||
"params": {
|
||||
"corner": [
|
||||
-10,
|
||||
-10,
|
||||
0
|
||||
],
|
||||
"length": 20,
|
||||
"width": 20,
|
||||
"height": 6
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "solid.shell",
|
||||
"params": {
|
||||
"faces": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
6
|
||||
]
|
||||
],
|
||||
"thickness": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"volume": [
|
||||
1376,
|
||||
0.001
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "ui.view",
|
||||
"params": {
|
||||
"view": "iso"
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "sketch.create"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"world": [
|
||||
0,
|
||||
0,
|
||||
2
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"sketching": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"query": {
|
||||
"command": "sketch.inspect",
|
||||
"path": [
|
||||
"plane",
|
||||
"origin",
|
||||
2
|
||||
],
|
||||
"value": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,91 @@
|
||||
[
|
||||
{
|
||||
"note": "#31: with the Origin hidden, its planes are not pickable in Create Sketch (a body is shown, so they don't come back)."
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "solid.box",
|
||||
"params": {
|
||||
"corner": [
|
||||
6,
|
||||
-15,
|
||||
0
|
||||
],
|
||||
"length": 4,
|
||||
"width": 4,
|
||||
"height": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "solid.box",
|
||||
"params": {
|
||||
"corner": [
|
||||
-15,
|
||||
10,
|
||||
-5
|
||||
],
|
||||
"length": 3,
|
||||
"width": 3,
|
||||
"height": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "ui.view",
|
||||
"params": {
|
||||
"view": "fit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "ui.view",
|
||||
"params": {
|
||||
"view": "iso"
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "sketch.create"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"plane": "YZ"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"sketching": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "sketch.finish"
|
||||
},
|
||||
{
|
||||
"call": "ui.set",
|
||||
"params": {
|
||||
"showOrigin": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "sketch.create"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"world": [
|
||||
0,
|
||||
3,
|
||||
3
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"sketching": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Escape"
|
||||
}
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user