mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 19:45:54 +00:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74fed469dc | |||
| efa6706226 | |||
| c1b6478ab5 | |||
| 17e8e3eff2 | |||
| 4b7e1d317d | |||
| b375c2a92a | |||
| 5275911b44 | |||
| 95ae096265 | |||
| 5835bb10d4 | |||
| db4f885bac | |||
| 683d45c28d | |||
| 13dd4566ce | |||
| e575bded0e | |||
| 1a1838c114 | |||
| ff1453f868 | |||
| e3ee1b359e | |||
| 15bb64c2ab | |||
| feaed2e3f6 | |||
| f33efa2b6e | |||
| c61b77613a | |||
| 9c6f9410d2 | |||
| 31dadcee63 | |||
| 4ac803ab52 | |||
| 63fd838f55 | |||
| ade30e9c4e | |||
| 9c09e82cff | |||
| 71a4f3061b | |||
| 1227fab860 | |||
| 9a2a3cef8b | |||
| 3086c32a9a | |||
| 5249ae84ef | |||
| f3a689f284 | |||
| f17ff913d6 | |||
| 6f16b31771 | |||
| ab23fc9906 | |||
| 54a86dc6f5 | |||
| acf4669f73 | |||
| e042a7b555 | |||
| 96ee326424 | |||
| d6cdec40da | |||
| 49bd89de13 | |||
| 90ce5261b3 | |||
| 81be4e74b8 | |||
| 0321778d38 | |||
| b5fd55cce6 | |||
| b1ec9fc1ce | |||
| 29796762de | |||
| aa268f0f0e | |||
| 4a41de72ec | |||
| 524ef6b8c2 | |||
| 74f70614cc |
@@ -98,16 +98,29 @@ jobs:
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-tests
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
- name: Compile tests
|
||||
run: cargo test --workspace --profile ci --locked --no-run
|
||||
- name: Run tests
|
||||
run: cargo test --workspace --profile ci --locked
|
||||
# Every test binary at once (cargo test runs them one after another).
|
||||
run: cargo xtask test --locked
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
|
||||
@@ -12,151 +12,26 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: flatpak-pages-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Flatpak repository
|
||||
publish:
|
||||
name: Request the combined docs, web, and Flatpak deployment
|
||||
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
|
||||
- name: Deploy through the shared Pages workflow
|
||||
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
|
||||
published="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" \
|
||||
--jq 'select(.draft == false and .published_at != null) | .tag_name')"
|
||||
test "$published" = "$RELEASE_TAG"
|
||||
gh workflow run pages.yml --repo "$GITHUB_REPOSITORY" --ref main \
|
||||
-f release_tag="$RELEASE_TAG"
|
||||
echo "Requested the combined Pages deployment for $RELEASE_TAG" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -7,6 +7,12 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Published release for Flatpak (empty: latest stable release)"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -50,6 +56,17 @@ jobs:
|
||||
cp -r target/book/. site/
|
||||
cp -r dist/web site/app
|
||||
- uses: actions/configure-pages@v5
|
||||
id: pages
|
||||
- name: Install Flatpak repository tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends flatpak ostree
|
||||
- name: Include the published Flatpak repository
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.release_tag || '' }}
|
||||
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
|
||||
run: bash packaging/flatpak-site.sh site
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: site
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# 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.
|
||||
# At midnight Eastern, changed main commits build and publish a nightly pre-release.
|
||||
# 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
|
||||
@@ -15,6 +16,9 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
timezone: America/New_York
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
@@ -39,17 +43,30 @@ jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build: ${{ steps.changed.outputs.build }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: changed
|
||||
name: Skip unchanged nightly builds
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: bash packaging/nightly.sh
|
||||
- id: v
|
||||
if: steps.changed.outputs.build == 'true'
|
||||
name: Resolve version
|
||||
env:
|
||||
REQUEST_DRAFT: ${{ inputs.draft || false }}
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
if [ "$GITHUB_EVENT_NAME" = schedule ]; then
|
||||
# Unique SemVer without committing daily version bumps to main.
|
||||
VERSION="${VERSION%%-*}-nightly.$(TZ=America/New_York date +%Y%m%d).$GITHUB_RUN_NUMBER.$GITHUB_RUN_ATTEMPT"
|
||||
fi
|
||||
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
|
||||
@@ -65,6 +82,8 @@ jobs:
|
||||
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
|
||||
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
|
||||
icons:
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -85,6 +104,7 @@ jobs:
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
@@ -135,6 +155,7 @@ jobs:
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
@@ -169,63 +190,10 @@ jobs:
|
||||
|
||||
|
||||
|
||||
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
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
@@ -266,6 +234,7 @@ jobs:
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: macos-14
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
@@ -289,6 +258,7 @@ jobs:
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: [version, icons]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -331,6 +301,7 @@ jobs:
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
@@ -356,8 +327,9 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release or dry-run artifacts
|
||||
name: GitHub Release or dry-run artifacts
|
||||
needs: [version, linux, flatpak, freebsd, macos, windows, web]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -377,6 +349,18 @@ jobs:
|
||||
run: |
|
||||
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
|
||||
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
|
||||
- name: Publish nightly pre-release
|
||||
if: github.event_name == 'schedule'
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
sha256sum --check SHA256SUMS.txt
|
||||
# Stage all packages before publishing. Published tags are never replaced.
|
||||
gh release create "$tag" --draft --prerelease --latest=false \
|
||||
--title "SolveCraft v$VERSION (nightly)" --target "$GITHUB_SHA" \
|
||||
--notes-file ../notes.md --generate-notes -- *
|
||||
gh release edit "$tag" --draft=false --prerelease --latest=false
|
||||
echo "### Published nightly: $tag (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
- name: Create or update the draft release
|
||||
if: github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft)
|
||||
working-directory: artifacts
|
||||
|
||||
@@ -42,6 +42,12 @@ Generated-in-code assets are original and have no file to list:
|
||||
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
|
||||
| .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 |
|
||||
|
||||
## Recovered contributions
|
||||
|
||||
| SolveCraft file | Original contribution | Licence |
|
||||
|---|---|---|
|
||||
| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `view_cube_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solvecraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solvecraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solvecraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the 14-target cube | MIT OR Apache-2.0 |
|
||||
|
||||
## Vendored third-party code
|
||||
|
||||
| Directory | Source | Licence | Changes |
|
||||
@@ -49,3 +55,4 @@ Generated-in-code assets are original and have no file to list:
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
+8
-5
@@ -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 --profile ci # unit, engine and UI-scenario tests
|
||||
cargo xtask test # 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 --profile ci` |
|
||||
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo xtask test` (or `cargo test --workspace --profile ci`) |
|
||||
| UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | part of `cargo test` |
|
||||
| Fusion oracle | `cargo xtask oracle` | needs the local oracle data (see below) |
|
||||
|
||||
@@ -105,7 +105,10 @@ put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the
|
||||
**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.
|
||||
in the PR. `cargo xtask test` builds the tests with it and then runs every test binary at once
|
||||
(`cargo test` runs them one after another); arguments after `--` go to each binary, e.g.
|
||||
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
|
||||
depend on wall-clock timing.
|
||||
|
||||
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
|
||||
SolveCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's
|
||||
@@ -120,8 +123,8 @@ install. If you work on geometry and want to check against it, ask in an issue.
|
||||
comment on the issue so it gets the `in progress` label and two people don't build the same
|
||||
thing. For anything bigger than a bug fix that has no issue yet, open one first.
|
||||
2. Branch from `main` and keep the PR to one topic.
|
||||
3. Run `cargo xtask ci` before pushing. It runs fmt, clippy (`-D warnings`), the tests in
|
||||
release mode, the asset and layer checks, and the wasm build check. A PR has to pass it.
|
||||
3. Run `cargo xtask ci` before pushing. It runs fmt, the asset, licence and layer checks,
|
||||
clippy (`-D warnings`), then the tests with the wasm build check alongside. A PR has to pass it.
|
||||
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
|
||||
the expected ones).
|
||||
|
||||
|
||||
Generated
+13
-15
@@ -1753,8 +1753,6 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
[[package]]
|
||||
name = "matext4cgmath"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6541e181de37f70f0aceb25441823a3d0efa9cc1d23f475a0d3926678949178"
|
||||
dependencies = [
|
||||
"cgmath",
|
||||
"katexit",
|
||||
@@ -3059,7 +3057,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.1.3"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3073,7 +3071,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.1.3"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3082,7 +3080,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.1.3"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3096,7 +3094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.1.3"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3112,7 +3110,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.1.3"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3120,7 +3118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.1.3"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
@@ -3132,7 +3130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.1.3"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3147,7 +3145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.1.3"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3155,7 +3153,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.1.3"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3164,7 +3162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.1.3"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3175,7 +3173,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.1.3"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3189,7 +3187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.1.3"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4488,7 +4486,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.3"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.3"
|
||||
version = "0.1.6"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -98,3 +98,4 @@ debug = 0
|
||||
truck-shapeops = { path = "vendor/truck-shapeops" }
|
||||
truck-stepio = { path = "vendor/truck-stepio" }
|
||||
truck-meshalgo = { path = "vendor/truck-meshalgo" }
|
||||
matext4cgmath = { path = "vendor/matext4cgmath" }
|
||||
|
||||
+15
-4
@@ -1,6 +1,6 @@
|
||||
# SolveCraft roadmap
|
||||
|
||||
Status as of **2026-10-09**. SolveCraft targets Fusion-style parametric modelling (sketch →
|
||||
Status as of **2026-10-10**. SolveCraft targets Fusion-style parametric modelling (sketch →
|
||||
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
|
||||
|
||||
## At a glance
|
||||
@@ -10,12 +10,19 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
|
||||
| Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) |
|
||||
| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) |
|
||||
| Tests | 596 (41 of them UI scenarios): solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command |
|
||||
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
||||
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
|
||||
|
||||
## Scope (owner decision, 2026-10-07)
|
||||
|
||||
The issue batch adds preservation of blind pockets in through cuts, associative projected
|
||||
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
|
||||
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
|
||||
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
|
||||
acceptance scenarios now include board resizes; STEP reopening is automated. Inspection in
|
||||
Fusion remains an external acceptance step. See [WIP reconciliation](docs/wip-reconciliation.md).
|
||||
|
||||
In scope, in priority order:
|
||||
1. SOLID and SKETCH (now).
|
||||
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
|
||||
@@ -44,7 +51,7 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 |
|
||||
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 |
|
||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace | deferred | — |
|
||||
| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
@@ -59,7 +66,11 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
|
||||
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
|
||||
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
|
||||
planar end faces; whole smooth loops of a planar face, picked whole or by one edge (tangent
|
||||
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
|
||||
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
|
||||
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
|
||||
connected equal-distance chamfers also trim each other across unequal sloped rim walls;
|
||||
whole smooth loops of a planar face, picked whole or by one edge (tangent
|
||||
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
|
||||
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
|
||||
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! ```text
|
||||
//! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
|
||||
//! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
|
||||
//! solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only] loading timings as JSON lines
|
||||
//! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! solvecraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solvecraft-cli commands the command registry as JSON
|
||||
@@ -31,6 +32,7 @@ const USAGE: &str = "usage:
|
||||
solvecraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
|
||||
solvecraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
|
||||
solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
|
||||
solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]
|
||||
solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solvecraft-cli exec <command> [json-params]
|
||||
solvecraft-cli commands
|
||||
@@ -74,6 +76,7 @@ fn main() -> ExitCode {
|
||||
Some("save-stress") => cmd_save_stress(&args[1..]),
|
||||
// Hidden: time recomputes after parameter edits (docs/perf.md).
|
||||
Some("bench-edit") => cmd_bench_edit(&args[1..]),
|
||||
Some("bench-open") => cmd_bench_open(&args[1..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
@@ -351,3 +354,43 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
|
||||
println!("{}", pretty(&json!(out)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
|
||||
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
let path = args.first().ok_or("usage: solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]")?;
|
||||
let t = std::time::Instant::now();
|
||||
let len = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
|
||||
let step = solvecraft_engine::io::is_step_path(path);
|
||||
let limit = if step { solvecraft_engine::io::MAX_STEP_BYTES } else { solvecraft_engine::io::MAX_DESIGN_BYTES };
|
||||
if len > limit as u64 {
|
||||
return Err(format!("{path}: file too large"));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()}));
|
||||
let t = std::time::Instant::now();
|
||||
let doc = if step {
|
||||
let imported = solvecraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
|
||||
let mut doc = solvecraft_engine::doc::Document::new(&imported.name);
|
||||
doc.add_feature(imported.kind, Some(&imported.name)).map_err(|e| e.to_string())?;
|
||||
doc
|
||||
} else {
|
||||
solvecraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?
|
||||
};
|
||||
let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len()}));
|
||||
if args.iter().any(|a| a == "--parse-only") {
|
||||
return Ok(());
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let s = Session::new(doc);
|
||||
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0}));
|
||||
for r in &s.model.results {
|
||||
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let st = s.world_state();
|
||||
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
|
||||
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ fn plane_param(pl: &Value) -> Value {
|
||||
let origin_zero = pl.get("origin").and_then(Value::as_array).is_none_or(|o| o.iter().all(|x| x.as_f64().unwrap_or(0.0).abs() < 1e-12));
|
||||
let std = match r.to_ascii_uppercase().as_str() {
|
||||
"XY" => Some(([1.0, 0.0, 0.0], [0.0, 1.0, 0.0])),
|
||||
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
|
||||
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, -1.0])),
|
||||
"YZ" => Some(([0.0, 1.0, 0.0], [0.0, 0.0, 1.0])),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -153,7 +153,8 @@ fn try_bytes(bytes: &[u8]) {
|
||||
#[test]
|
||||
fn damaged_and_hostile_design_files_never_panic() {
|
||||
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
|
||||
let mut failures = Vec::new();
|
||||
// The cases come from one seeded sequence; trying them is independent, so threads share it.
|
||||
let mut all_cases: Vec<(String, String, Vec<u8>)> = Vec::new();
|
||||
for p in corpus() {
|
||||
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let bytes = std::fs::read(&p).unwrap();
|
||||
@@ -198,14 +199,28 @@ fn damaged_and_hostile_design_files_never_panic() {
|
||||
cases.push(("features repeated".into(), v.to_string().into_bytes()));
|
||||
cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes()));
|
||||
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
|
||||
for (what, b) in cases {
|
||||
if std::env::var_os("CORPUS_TRACE").is_some() {
|
||||
eprintln!("{name}: {what}");
|
||||
}
|
||||
if std::panic::catch_unwind(|| try_bytes(&b)).is_err() {
|
||||
failures.push(format!("{name}: {what}"));
|
||||
}
|
||||
}
|
||||
all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b)));
|
||||
}
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
|
||||
let failures: Vec<String> = std::thread::scope(|scope| {
|
||||
let workers: Vec<_> = (0..threads)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
let mut failures = Vec::new();
|
||||
while let Some((name, what, b)) = all_cases.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
if std::env::var_os("CORPUS_TRACE").is_some() {
|
||||
eprintln!("{name}: {what}");
|
||||
}
|
||||
if std::panic::catch_unwind(|| try_bytes(b)).is_err() {
|
||||
failures.push(format!("{name}: {what}"));
|
||||
}
|
||||
}
|
||||
failures
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
|
||||
});
|
||||
assert!(failures.is_empty(), "panics:\n{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
@@ -22,10 +22,20 @@ fn killed_mid_save_the_old_file_survives() {
|
||||
assert_eq!(round_of(&path).as_deref(), Some("0"));
|
||||
let mut kills = 0;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let modified = || std::fs::metadata(&path).and_then(|m| m.modified()).ok();
|
||||
for i in 0..25u64 {
|
||||
let before = modified();
|
||||
let mut child = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "100000"]).spawn().unwrap();
|
||||
// Let it get into its save loop (building the design takes a moment), then kill it.
|
||||
std::thread::sleep(Duration::from_millis(150 + (i * 37) % 200));
|
||||
// Wait until it is in its save loop (its first save replaced the file) and has reached a
|
||||
// round that varies from kill to kill, however busy the machine is; then kill it a
|
||||
// varying while later.
|
||||
let target = i % 10;
|
||||
let reached = || modified() != before && round_of(&path).and_then(|r| r.parse::<u64>().ok()).is_some_and(|r| r >= target);
|
||||
let t0 = std::time::Instant::now();
|
||||
while !reached() && t0.elapsed() < Duration::from_secs(20) && child.try_wait().unwrap().is_none() {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis((i * 7) % 30));
|
||||
if child.try_wait().unwrap().is_none() {
|
||||
child.kill().unwrap();
|
||||
kills += 1;
|
||||
|
||||
@@ -376,7 +376,8 @@ mod web {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs, DEPTH_BITS, 1);
|
||||
// eframe's web painter has no stencil buffer.
|
||||
app.set_wgpu(rs, DEPTH_BITS, 0, 1);
|
||||
}
|
||||
if query().contains("sample") {
|
||||
if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) {
|
||||
|
||||
@@ -61,6 +61,7 @@ fn services() -> Services {
|
||||
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
|
||||
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
|
||||
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
|
||||
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
|
||||
.add_filter("All files", &["*"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
@@ -69,6 +70,23 @@ fn services() -> Services {
|
||||
rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
||||
show_in_folder: Some(Box::new(|path| {
|
||||
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
|
||||
"explorer"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
} else {
|
||||
"xdg-open"
|
||||
});
|
||||
command.arg(folder);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x08000000);
|
||||
}
|
||||
let _ = command.spawn();
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +112,8 @@ fn msaa() -> u16 {
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
/// The section cap marks where it shows in the stencil.
|
||||
const STENCIL_BITS: u8 = 8;
|
||||
const MSAA: u16 = 4;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
@@ -129,6 +149,7 @@ fn main() -> eframe::Result {
|
||||
.with_title_shown(false)
|
||||
.with_app_id("ai.storyteller.solvecraft"),
|
||||
depth_buffer: DEPTH_BITS,
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
multisampling: samples,
|
||||
..Default::default()
|
||||
};
|
||||
@@ -155,7 +176,7 @@ fn main() -> eframe::Result {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs, DEPTH_BITS, u32::from(samples));
|
||||
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, u32::from(samples));
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
@@ -170,7 +191,7 @@ fn main() -> eframe::Result {
|
||||
app.session.undo.clear();
|
||||
}
|
||||
for f in &files {
|
||||
app.open_path(f);
|
||||
app.open_path_async(f);
|
||||
}
|
||||
// No design given: the start page (not when driven over the control channel, whose
|
||||
// clients expect the design view; `ui.home` shows it there).
|
||||
|
||||
@@ -164,6 +164,39 @@ fn map_geo_ref(m: &Mat, g: &mut crate::construct::GeoRef) {
|
||||
g.map_points(&mut |p: &mut Vec3| *p = apply_point(m, *p), &mut |pl: &mut PlaneRef| map_plane_ref(m, pl));
|
||||
}
|
||||
|
||||
/// Map a point given as expressions by `m`: numbers stay numbers; a coordinate that is an
|
||||
/// expression stays one (`m · (x, y, z) + t`, written out), so it still follows its parameters.
|
||||
pub fn map_point_expr(m: &Mat, p: &mut crate::PointExpr) {
|
||||
let num = |e: &String| e.trim().parse::<f64>().ok().filter(|v| v.is_finite());
|
||||
if let [Some(x), Some(y), Some(z)] = [num(&p[0]), num(&p[1]), num(&p[2])] {
|
||||
*p = crate::point_expr(apply_point(m, Vec3::new(x, y, z)));
|
||||
return;
|
||||
}
|
||||
let old = p.clone();
|
||||
for (row, out) in p.iter_mut().enumerate() {
|
||||
let mut constant = m[3][row];
|
||||
let mut terms: Vec<String> = Vec::new();
|
||||
for (col, e) in old.iter().enumerate() {
|
||||
let c = m[col][row];
|
||||
if c.abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
match num(e) {
|
||||
Some(v) => constant += c * v,
|
||||
None if (c - 1.0).abs() < 1e-12 => terms.push(format!("({e})")),
|
||||
None => terms.push(format!("{c} * ({e})")),
|
||||
}
|
||||
}
|
||||
let mut e = terms.join(" + ");
|
||||
if terms.is_empty() {
|
||||
e = format!("{constant} mm");
|
||||
} else if constant.abs() >= 1e-12 {
|
||||
e = format!("{e} {} {} mm", if constant < 0.0 { '-' } else { '+' }, constant.abs());
|
||||
}
|
||||
*out = e;
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
/// Map the feature's world geometry (points, directions, explicit planes) by `m`.
|
||||
pub fn transform_geometry(&mut self, m: &Mat) {
|
||||
@@ -207,12 +240,12 @@ impl FeatureKind {
|
||||
map_plane_ref(m, neutral);
|
||||
dir(pull);
|
||||
}
|
||||
FeatureKind::Box { corner, .. } => pt(corner),
|
||||
FeatureKind::Box { corner, .. } => map_point_expr(m, corner),
|
||||
FeatureKind::Cylinder { base, axis, .. } => {
|
||||
pt(base);
|
||||
map_point_expr(m, base);
|
||||
dir(axis);
|
||||
}
|
||||
FeatureKind::Sphere { center, .. } | FeatureKind::Torus { center, .. } => pt(center),
|
||||
FeatureKind::Sphere { center, .. } | FeatureKind::Torus { center, .. } => map_point_expr(m, center),
|
||||
FeatureKind::Pattern { pattern: PatternKind::Rectangular { dir1, dir2, .. }, .. } => {
|
||||
dir(dir1);
|
||||
if let Some(d) = dir2 {
|
||||
@@ -259,7 +292,7 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetUnfold { .. } => {}
|
||||
FeatureKind::Coil { base, axis, .. } => {
|
||||
pt(base);
|
||||
map_point_expr(m, base);
|
||||
dir(axis);
|
||||
}
|
||||
FeatureKind::ReplaceFace { faces, target, .. } => {
|
||||
|
||||
@@ -34,6 +34,14 @@ pub struct Canvas {
|
||||
pub flip: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub visible: bool,
|
||||
/// The component it belongs to (its plane is in that component's frame, so it moves with
|
||||
/// the component's occurrence); 0 = the root.
|
||||
#[serde(default, skip_serializing_if = "is_root")]
|
||||
pub component: u64,
|
||||
}
|
||||
|
||||
fn is_root(c: &u64) -> bool {
|
||||
*c == 0
|
||||
}
|
||||
|
||||
fn half() -> f64 {
|
||||
@@ -57,7 +65,7 @@ impl Canvas {
|
||||
let f = if self.flip { -1.0 } else { 1.0 };
|
||||
[rot(-hw * f, -hh), rot(hw * f, -hh), rot(hw * f, hh), rot(-hw * f, hh)]
|
||||
}
|
||||
/// Corners in world coordinates.
|
||||
/// Corners on `plane` (in whatever frame the plane is given).
|
||||
pub fn world_corners(&self, plane: &Plane) -> [Vec3; 4] {
|
||||
self.corners().map(|p| plane.to_world(p))
|
||||
}
|
||||
@@ -66,6 +74,19 @@ impl Canvas {
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Document {
|
||||
/// A canvas's plane in the world: resolved in its component's frame, then placed where the
|
||||
/// component's occurrence puts it.
|
||||
pub fn canvas_plane(&self, vals: &std::collections::BTreeMap<String, crate::expr::Value>, c: &Canvas) -> crate::Result<Plane> {
|
||||
let p = self.resolve_plane(vals, &c.plane, 0)?;
|
||||
let m = self.component_transform(c.component);
|
||||
if crate::is_identity(&m) {
|
||||
return Ok(p);
|
||||
}
|
||||
Ok(Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)).unwrap_or(p))
|
||||
}
|
||||
}
|
||||
|
||||
/// Image kind and pixel size from the file header (PNG or JPEG).
|
||||
pub fn image_info(b: &[u8]) -> Option<(&'static str, [u32; 2])> {
|
||||
if b.len() > 24 && b.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
|
||||
+131
-23
@@ -125,7 +125,8 @@ pub struct Extent {
|
||||
/// Optional second side distance (two-sided extrude), along the negative normal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub distance2: Option<String>,
|
||||
/// Start offset from the sketch plane (expression).
|
||||
/// Start offset from the sketch plane (expression), along the sketch normal whatever the
|
||||
/// direction.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub start_offset: Option<String>,
|
||||
/// Through all bodies in the direction(s) instead of `distance`.
|
||||
@@ -225,7 +226,8 @@ pub enum FeatureKind {
|
||||
body: Option<String>,
|
||||
},
|
||||
Box {
|
||||
corner: Vec3,
|
||||
#[serde(with = "point_expr")]
|
||||
corner: PointExpr,
|
||||
length: String,
|
||||
width: String,
|
||||
height: String,
|
||||
@@ -233,7 +235,8 @@ pub enum FeatureKind {
|
||||
operation: Operation,
|
||||
},
|
||||
Cylinder {
|
||||
base: Vec3,
|
||||
#[serde(with = "point_expr")]
|
||||
base: PointExpr,
|
||||
#[serde(default = "z_axis")]
|
||||
axis: Vec3,
|
||||
radius: String,
|
||||
@@ -242,13 +245,15 @@ pub enum FeatureKind {
|
||||
operation: Operation,
|
||||
},
|
||||
Sphere {
|
||||
center: Vec3,
|
||||
#[serde(with = "point_expr")]
|
||||
center: PointExpr,
|
||||
radius: String,
|
||||
#[serde(default)]
|
||||
operation: Operation,
|
||||
},
|
||||
Torus {
|
||||
center: Vec3,
|
||||
#[serde(with = "point_expr")]
|
||||
center: PointExpr,
|
||||
major: String,
|
||||
minor: String,
|
||||
#[serde(default)]
|
||||
@@ -481,7 +486,8 @@ pub enum FeatureKind {
|
||||
/// A helical coil: a circular or square section swept `turns` times about `axis`, rising
|
||||
/// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside).
|
||||
Coil {
|
||||
base: Vec3,
|
||||
#[serde(with = "point_expr")]
|
||||
base: PointExpr,
|
||||
#[serde(default = "z_axis")]
|
||||
axis: Vec3,
|
||||
diameter: String,
|
||||
@@ -938,7 +944,106 @@ fn z_axis() -> Vec3 {
|
||||
Vec3::Z
|
||||
}
|
||||
|
||||
/// A point whose coordinates are length expressions (`["basin_width / 2", "10 mm", "0"]`), so
|
||||
/// it follows parameter changes. Primitives are placed by one.
|
||||
pub type PointExpr = [String; 3];
|
||||
|
||||
/// A point as expressions (plain numbers, mm).
|
||||
pub fn point_expr(p: Vec3) -> PointExpr {
|
||||
[format!("{}", p.x), format!("{}", p.y), format!("{}", p.z)]
|
||||
}
|
||||
|
||||
/// Stored as `[x, y, z]`: plain numbers as numbers (as points were before they took
|
||||
/// expressions, so such designs read and write the same), anything else as a string.
|
||||
mod point_expr {
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Coord {
|
||||
Num(f64),
|
||||
Expr(String),
|
||||
}
|
||||
|
||||
pub fn serialize<S: Serializer>(p: &super::PointExpr, s: S) -> Result<S::Ok, S::Error> {
|
||||
let c = |e: &String| match e.parse::<f64>() {
|
||||
Ok(v) if v.is_finite() && format!("{v}") == *e => Coord::Num(v),
|
||||
_ => Coord::Expr(e.clone()),
|
||||
};
|
||||
[c(&p[0]), c(&p[1]), c(&p[2])].serialize(s)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<super::PointExpr, D::Error> {
|
||||
let c = |x: Coord| match x {
|
||||
Coord::Num(v) => format!("{v}"),
|
||||
Coord::Expr(e) => e,
|
||||
};
|
||||
let [x, y, z] = <[Coord; 3]>::deserialize(d)?;
|
||||
Ok([c(x), c(y), c(z)])
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
/// Sketches the feature is built from (profiles, paths, points): deleting one of them
|
||||
/// deletes the feature. Every kind is listed, so a new one has to say what it uses.
|
||||
pub fn input_sketches(&self) -> Vec<u64> {
|
||||
use FeatureKind as K;
|
||||
match self {
|
||||
K::Extrude { sketch, .. }
|
||||
| K::Revolve { sketch, .. }
|
||||
| K::Emboss { sketch, .. }
|
||||
| K::Rib { sketch, .. }
|
||||
| K::SheetBase { sketch, .. }
|
||||
| K::SheetContour { sketch, .. } => vec![*sketch],
|
||||
K::Sweep { sketch, path_sketch, .. } => vec![*sketch, *path_sketch],
|
||||
K::Pipe { path_sketch, .. } | K::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } => vec![*path_sketch],
|
||||
K::Loft { sections, .. } => sections.iter().map(|s| s.sketch).collect(),
|
||||
K::SheetFold { sketch, .. } | K::SplitFace { sketch, .. } | K::Patch { sketch, .. } => sketch.iter().copied().collect(),
|
||||
K::Hole { points, .. } => points.iter().map(|p| p.sketch).collect(),
|
||||
K::Sketch { .. }
|
||||
| K::Pattern { .. }
|
||||
| K::Fillet { .. }
|
||||
| K::Chamfer { .. }
|
||||
| K::Box { .. }
|
||||
| K::Cylinder { .. }
|
||||
| K::Sphere { .. }
|
||||
| K::Torus { .. }
|
||||
| K::Combine { .. }
|
||||
| K::Mirror { .. }
|
||||
| K::Thread { .. }
|
||||
| K::Shell { .. }
|
||||
| K::Draft { .. }
|
||||
| K::ConstructionPlane { .. }
|
||||
| K::Split { .. }
|
||||
| K::ConstructionAxis { .. }
|
||||
| K::ConstructionPoint { .. }
|
||||
| K::Scale { .. }
|
||||
| K::OffsetFace { .. }
|
||||
| K::DeleteFace { .. }
|
||||
| K::BoundingSolid { .. }
|
||||
| K::ReplaceFace { .. }
|
||||
| K::Align { .. }
|
||||
| K::Coil { .. }
|
||||
| K::SheetFlange { .. }
|
||||
| K::SheetHem { .. }
|
||||
| K::SheetUnfold { .. }
|
||||
| K::SheetConvert { .. }
|
||||
| K::Boss { .. }
|
||||
| K::Lip { .. }
|
||||
| K::SnapFit { .. }
|
||||
| K::Rest { .. }
|
||||
| K::BoundaryFill { .. }
|
||||
| K::Remove { .. }
|
||||
| K::Stitch { .. }
|
||||
| K::Thicken { .. }
|
||||
| K::SurfaceExtend { .. }
|
||||
| K::SurfaceTrim { .. }
|
||||
| K::Move { .. }
|
||||
| K::Import { .. }
|
||||
| K::MeshImport { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A sheet metal feature (its result depends on the sheet metal rules).
|
||||
pub fn is_plastic(&self) -> bool {
|
||||
matches!(self, FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. })
|
||||
@@ -1115,6 +1220,7 @@ impl FeatureKind {
|
||||
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
|
||||
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
|
||||
FeatureKind::Thread { face, .. } => vec![face],
|
||||
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1166,10 +1272,22 @@ impl FeatureKind {
|
||||
v.extend(distance2.iter().map(String::as_str));
|
||||
v.extend(angle.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::Box { length, width, height, .. } => v.extend([length.as_str(), width, height]),
|
||||
FeatureKind::Cylinder { radius, height, .. } => v.extend([radius.as_str(), height]),
|
||||
FeatureKind::Sphere { radius, .. } => v.push(radius),
|
||||
FeatureKind::Torus { major, minor, .. } => v.extend([major.as_str(), minor]),
|
||||
FeatureKind::Box { length, width, height, corner, .. } => {
|
||||
v.extend([length.as_str(), width, height]);
|
||||
v.extend(corner.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::Cylinder { radius, height, base, .. } => {
|
||||
v.extend([radius.as_str(), height]);
|
||||
v.extend(base.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::Sphere { radius, center, .. } => {
|
||||
v.push(radius);
|
||||
v.extend(center.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::Torus { major, minor, center, .. } => {
|
||||
v.extend([major.as_str(), minor]);
|
||||
v.extend(center.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::Combine { .. } => {}
|
||||
FeatureKind::Pattern { pattern, .. } => match pattern {
|
||||
PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => {
|
||||
@@ -1246,9 +1364,10 @@ impl FeatureKind {
|
||||
v.extend(gap.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::SheetBase { .. } | FeatureKind::SheetUnfold { .. } | FeatureKind::SheetConvert { .. } => {}
|
||||
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, .. } => {
|
||||
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, base, .. } => {
|
||||
v.extend([diameter.as_str(), pitch, turns, section_size]);
|
||||
v.extend(start_angle.iter().map(String::as_str));
|
||||
v.extend(base.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::Rib { thickness, depth, .. } => {
|
||||
v.push(thickness);
|
||||
@@ -1619,18 +1738,7 @@ impl Document {
|
||||
pub fn delete_feature(&mut self, id: u64) -> Result<Vec<u64>> {
|
||||
let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?;
|
||||
let mut gone = vec![id];
|
||||
for f in &self.features {
|
||||
match &f.kind {
|
||||
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } if *sketch == id => gone.push(f.id),
|
||||
FeatureKind::Sweep { sketch, path_sketch, .. } if *sketch == id || *path_sketch == id => gone.push(f.id),
|
||||
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.sketch == id) => gone.push(f.id),
|
||||
FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id),
|
||||
FeatureKind::Emboss { sketch, .. } | FeatureKind::Rib { sketch, .. } if *sketch == id => gone.push(f.id),
|
||||
FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } if *sketch == id => gone.push(f.id),
|
||||
FeatureKind::SheetFold { sketch: Some(s), .. } | FeatureKind::SplitFace { sketch: Some(s), .. } if *s == id => gone.push(f.id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id));
|
||||
self.features.retain(|f| !gone.contains(&f.id));
|
||||
// Configuration columns of deleted features go with them.
|
||||
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect();
|
||||
|
||||
+131
-18
@@ -19,7 +19,9 @@ pub struct ModelBody {
|
||||
pub body: Body,
|
||||
/// Feature that created it.
|
||||
pub feature: u64,
|
||||
mesh: OnceLock<Arc<Mesh>>,
|
||||
// Timeline snapshots are cloned before meshes are requested. Share the cell itself so
|
||||
// meshing any snapshot also warms every unchanged copy of this body.
|
||||
mesh: Arc<OnceLock<Arc<Mesh>>>,
|
||||
/// Identity of this body's content for the evaluation cache: the key of the evaluation
|
||||
/// that produced it (kept while later features pass it through unchanged).
|
||||
key: u64,
|
||||
@@ -33,7 +35,7 @@ pub struct ModelBody {
|
||||
|
||||
impl ModelBody {
|
||||
pub fn new(name: String, body: Body, feature: u64) -> Self {
|
||||
ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0, names: Default::default(), component: 0, imported: None }
|
||||
ModelBody { name, body, feature, mesh: Arc::new(OnceLock::new()), key: 0, names: Default::default(), component: 0, imported: None }
|
||||
}
|
||||
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
|
||||
pub fn mesh(&self) -> Arc<Mesh> {
|
||||
@@ -377,6 +379,10 @@ pub struct Model {
|
||||
empty: Arc<ModelState>,
|
||||
/// Features recomputed by the last `evaluate`.
|
||||
pub last_recomputed: usize,
|
||||
/// Features whose result the last `evaluate` took from the cache instead of recomputing: the
|
||||
/// same feature on the same inputs was evaluated before (an edit set back, undo, redo, a
|
||||
/// configuration row). Their geometry did change since the previous evaluation.
|
||||
pub last_restored: usize,
|
||||
/// Results by inputs, shared with this model's copies.
|
||||
pub cache: Arc<std::sync::Mutex<EvalCache>>,
|
||||
/// Evaluate everything from scratch (no reuse; tests compare the two).
|
||||
@@ -467,6 +473,22 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Where components are placed, for a sketch that sees other components' geometry (on a face,
|
||||
/// or with projections): it is resolved in its own component's view of them.
|
||||
fn placement_digest(doc: &Document, f: &Feature) -> u64 {
|
||||
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 };
|
||||
if doc.occurrences.is_empty() || (!matches!(plane, PlaneRef::Face { .. }) && sketch.links.is_empty()) {
|
||||
return 0;
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
for o in &doc.occurrences {
|
||||
o.id.hash(&mut h);
|
||||
o.transform.iter().flatten().for_each(|x| x.to_bits().hash(&mut h));
|
||||
}
|
||||
serde_json::to_string(&doc.body_offsets).unwrap_or_default().hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn now() -> web_time::Instant {
|
||||
web_time::Instant::now()
|
||||
}
|
||||
@@ -505,11 +527,12 @@ impl Model {
|
||||
let mut out: Vec<FeatureResult> = Vec::with_capacity(doc.features.len());
|
||||
let mut reuse = !self.no_cache;
|
||||
let mut recomputed = 0;
|
||||
let mut restored = 0;
|
||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||
let ctx = context_digest(doc, &vals);
|
||||
for (i, f) in doc.features.iter().enumerate() {
|
||||
let rolled_back = i >= marker;
|
||||
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back);
|
||||
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back) ^ placement_digest(doc, f);
|
||||
// Sheet metal features also depend on the rules (and the parameters they use).
|
||||
if f.kind.is_sheet() {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
@@ -565,10 +588,11 @@ impl Model {
|
||||
continue;
|
||||
}
|
||||
// The same feature on the same inputs: its earlier result.
|
||||
let key = cache_key(f, fp, fingerprint(0, f, &vals, false), ctx, &state);
|
||||
let key = cache_key(f, fp, fingerprint(0, f, &vals, false) ^ placement_digest(doc, f), ctx, &state);
|
||||
if !self.no_cache
|
||||
&& let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key))
|
||||
{
|
||||
restored += 1;
|
||||
if c.error.is_none() {
|
||||
state = c.state.clone();
|
||||
}
|
||||
@@ -652,6 +676,7 @@ impl Model {
|
||||
}
|
||||
self.results = out;
|
||||
self.last_recomputed = recomputed;
|
||||
self.last_restored = restored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,6 +686,13 @@ pub(crate) const DESIGN_LEN: &str = "\u{1}design_len";
|
||||
|
||||
/// An expression's value. A length that comes out unit-less without being a bare number (a
|
||||
/// unit-less parameter used as a length) is in the design's units.
|
||||
/// A point given as length expressions.
|
||||
fn point(vals: &BTreeMap<String, Value>, p: &crate::PointExpr) -> Result<Vec3> {
|
||||
let [x, y, z] = p;
|
||||
let v = Vec3::new(val(vals, x, Kind::Length)?, val(vals, y, Kind::Length)?, val(vals, z, Kind::Length)?);
|
||||
if [v.x, v.y, v.z].iter().all(|c| c.is_finite() && c.abs() < 1e9) { Ok(v) } else { Err(DocError::Invalid("the position is out of range".into())) }
|
||||
}
|
||||
|
||||
fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
|
||||
let scale = vals.get(DESIGN_LEN).map(|x| x.v).unwrap_or(1.0);
|
||||
if k == Kind::Length && scale != 1.0 && !expr::is_literal(e) {
|
||||
@@ -1398,6 +1430,20 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
|
||||
}
|
||||
let mut tools = Vec::new();
|
||||
for r in ®ions {
|
||||
if *operation == Operation::Join {
|
||||
let exact = kernel::extrude(&ss.plane, std::slice::from_ref(r), lo + off, hi + off)?;
|
||||
if exact.iter().all(|tool| {
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| targets.is_empty() || targets.contains(&b.name))
|
||||
.any(|b| kernel::can_join_touching_faces(&b.body, tool))
|
||||
}) {
|
||||
// Preserve a whole touching face: extending into a chamfer replaces
|
||||
// the sewable seam with a coincident curved intersection.
|
||||
tools.extend(exact);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets);
|
||||
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
|
||||
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
|
||||
@@ -1500,6 +1546,7 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
|
||||
Ok(vec![kernel::loft_to_point(&ss.plane, &r.outer, ps.plane.to_world(q.pos))?])
|
||||
}
|
||||
FeatureKind::Coil { base, axis, diameter, pitch, turns, section_size, section, position, start_angle, clockwise, .. } => {
|
||||
let base = &point(vals, base)?;
|
||||
let k = axis.normalized().ok_or_else(|| DocError::Invalid("coil axis".into()))?;
|
||||
let (d, p, n, s) = (
|
||||
val(vals, diameter, Kind::Length)?,
|
||||
@@ -1587,14 +1634,15 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
|
||||
}
|
||||
FeatureKind::Box { corner, length, width, height, .. } => {
|
||||
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
|
||||
Ok(vec![kernel::box_solid(*corner, *corner + s)?])
|
||||
let corner = point(vals, corner)?;
|
||||
Ok(vec![kernel::box_solid(corner, corner + s)?])
|
||||
}
|
||||
FeatureKind::Cylinder { base, axis, radius, height, .. } => {
|
||||
Ok(vec![kernel::cylinder(*base, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?])
|
||||
Ok(vec![kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?])
|
||||
}
|
||||
FeatureKind::Sphere { center, radius, .. } => Ok(vec![kernel::sphere(*center, val(vals, radius, Kind::Length)?)?]),
|
||||
FeatureKind::Sphere { center, radius, .. } => Ok(vec![kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?]),
|
||||
FeatureKind::Torus { center, major, minor, .. } => {
|
||||
Ok(vec![kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?])
|
||||
Ok(vec![kernel::torus(point(vals, center)?, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?])
|
||||
}
|
||||
FeatureKind::Hole { diameter, depth, hole, to, .. } => {
|
||||
let (spots, dir) = hole_spots(st, f)?;
|
||||
@@ -2281,17 +2329,25 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::Align { .. }
|
||||
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st),
|
||||
FeatureKind::Sketch { plane, sketch } => {
|
||||
let fst = state_in_frame(doc, st, f.component);
|
||||
let (plane, face_warning) = match plane {
|
||||
PlaneRef::Face { plane: picked, at, name } => face_plane(st, picked, *at, name.as_deref()),
|
||||
other => (doc.resolve_plane_in(vals, other, 0, Some(st))?, None),
|
||||
PlaneRef::Face { plane: picked, at, name } => face_plane(&fst, picked, *at, name.as_deref()),
|
||||
other => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
|
||||
};
|
||||
let mut sk = sketch.clone();
|
||||
let mut warns = crate::project::refresh_links(doc, vals, st, &plane, &mut sk);
|
||||
let mut warns = crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk);
|
||||
drop(fst);
|
||||
sk.refresh_offsets()?;
|
||||
if let Some(w) = face_warning {
|
||||
warns.insert(0, w);
|
||||
}
|
||||
doc.apply_dimension_values(vals, &mut sk)?;
|
||||
let report = solve(&mut sk);
|
||||
let mut report = solve(&mut sk);
|
||||
if !sk.offsets.is_empty() {
|
||||
// Source dimensions may have moved curves during the solve.
|
||||
sk.refresh_offsets()?;
|
||||
report = solve(&mut sk);
|
||||
}
|
||||
if !report.ok() {
|
||||
warns.insert(0, format!("the sketch constraints conflict ({})", report.failing.join(", ")));
|
||||
}
|
||||
@@ -2644,19 +2700,20 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
FeatureKind::Box { corner, length, width, height, operation } => {
|
||||
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
|
||||
let b = kernel::box_solid(*corner, *corner + s)?;
|
||||
let corner = point(vals, corner)?;
|
||||
let b = kernel::box_solid(corner, corner + s)?;
|
||||
apply_op(st, f, vec![b], *operation, &[])
|
||||
}
|
||||
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
|
||||
let b = kernel::cylinder(*base, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
|
||||
let b = kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
|
||||
apply_op(st, f, vec![b], *operation, &[])
|
||||
}
|
||||
FeatureKind::Sphere { center, radius, operation } => {
|
||||
let b = kernel::sphere(*center, val(vals, radius, Kind::Length)?)?;
|
||||
let b = kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?;
|
||||
apply_op(st, f, vec![b], *operation, &[])
|
||||
}
|
||||
FeatureKind::Torus { center, major, minor, operation } => {
|
||||
let b = kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?;
|
||||
let b = kernel::torus(point(vals, center)?, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?;
|
||||
apply_op(st, f, vec![b], *operation, &[])
|
||||
}
|
||||
FeatureKind::Combine { target, tools, operation, keep_tools } => {
|
||||
@@ -2811,6 +2868,52 @@ fn unique_body_name(st: &ModelState, base: &str) -> String {
|
||||
(2..).map(|i| format!("{base} ({i})")).find(|n| st.body(n).is_none()).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
/// The model as a component sees it: bodies and sketches of components placed differently from
|
||||
/// it are moved into its frame (through both placements), so a sketch on another component's
|
||||
/// face finds the face where it is shown and projects its edges there. The model itself when
|
||||
/// every placement agrees (the usual case).
|
||||
pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
|
||||
use std::borrow::Cow;
|
||||
if doc.occurrences.is_empty() && doc.body_offsets.is_empty() {
|
||||
return Cow::Borrowed(st);
|
||||
}
|
||||
let Some(inv) = crate::mat_inverse(&doc.component_transform(comp)) else { return Cow::Borrowed(st) };
|
||||
let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let rel = |c: u64, body: Option<&str>| {
|
||||
let m = crate::mat_mul(&inv, &doc.component_transform(c));
|
||||
match body.and_then(|b| doc.body_offsets.get(b)) {
|
||||
Some(o) => crate::mat_mul(&m, o),
|
||||
None => m,
|
||||
}
|
||||
};
|
||||
let body_m: Vec<Mat> = st.bodies.iter().map(|b| rel(doc.body_component(&b.name, b.feature), Some(&b.name))).collect();
|
||||
let sketch_m: Vec<Mat> = st.sketches.iter().map(|s| rel(comp_of_feature(s.feature), None)).collect();
|
||||
if body_m.iter().chain(&sketch_m).all(crate::is_identity) {
|
||||
return Cow::Borrowed(st);
|
||||
}
|
||||
let mut out = st.clone();
|
||||
for (b, m) in out.bodies.iter_mut().zip(&body_m) {
|
||||
if !crate::is_identity(m)
|
||||
&& let Ok(t) = kernel::transform_matrix(&b.body, *m)
|
||||
{
|
||||
// Moving a body keeps its faces (and their order): it keeps their names.
|
||||
let names = crate::naming::face_names(b);
|
||||
let mut nb = ModelBody::new(b.name.clone(), t, b.feature);
|
||||
nb.component = b.component;
|
||||
nb.names = crate::naming::NameCell::known(names);
|
||||
*b = nb;
|
||||
}
|
||||
}
|
||||
for (s, m) in out.sketches.iter_mut().zip(&sketch_m) {
|
||||
if !crate::is_identity(m)
|
||||
&& let Some(np) = Plane::new(crate::apply_point(m, s.plane.origin), crate::apply_vector(m, s.plane.x), crate::apply_vector(m, s.plane.y))
|
||||
{
|
||||
s.plane = np;
|
||||
}
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// The model placed in the world: each component's bodies, sketches and threads through every
|
||||
/// occurrence of it (bodies of the first placement keep their names; further instances get the
|
||||
/// occurrence path in brackets). Without components this is the model itself.
|
||||
@@ -2845,7 +2948,11 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
|
||||
let bm = doc.body_offsets.get(&b.name).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
|
||||
let body =
|
||||
if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) };
|
||||
out.bodies.push(ModelBody::new(name, body, b.feature));
|
||||
let mut placed = ModelBody::new(name, body, b.feature);
|
||||
// Placement preserves topology: face ownership remains that of the
|
||||
// source model, including modifiers on component instances.
|
||||
placed.names = crate::naming::NameCell::known(crate::naming::face_names(b));
|
||||
out.bodies.push(placed);
|
||||
}
|
||||
if primary {
|
||||
for s in st.sketches.iter().filter(|s| comp_of_feature(s.feature) == comp) {
|
||||
@@ -2879,7 +2986,13 @@ mod chain_tests {
|
||||
fn tangent_chain_follows_smooth_edges() {
|
||||
let mut doc = Document::new("T");
|
||||
doc.add_feature(
|
||||
FeatureKind::Box { corner: Vec3::ZERO, length: "40".into(), width: "30".into(), height: "20".into(), operation: Operation::NewBody },
|
||||
FeatureKind::Box {
|
||||
corner: crate::point_expr(Vec3::ZERO),
|
||||
length: "40".into(),
|
||||
width: "30".into(),
|
||||
height: "20".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -613,7 +613,12 @@ fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, le
|
||||
/// The body a placed plastic feature joins: by name, else the one under the point.
|
||||
fn body_under(st: &ModelState, body: &Option<String>, p: Vec3) -> Result<String> {
|
||||
let i = body_at(st, body, &[p])?;
|
||||
st.bodies.get(i).map(|b| b.name.clone()).ok_or_else(|| DocError::Invalid("there is no body".into()))
|
||||
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("there is no body".into()))?;
|
||||
let tolerance = (b.body.size() * 1e-5).max(1e-4);
|
||||
if crate::project::nearest_face(&b.mesh(), p).is_none_or(|(_, _, distance)| distance > tolerance) {
|
||||
return Err(DocError::Invalid("the plastic feature's placement is no longer on its body face".into()));
|
||||
}
|
||||
Ok(b.name.clone())
|
||||
}
|
||||
|
||||
/// Axes with z along `z` (and x along `x` when given).
|
||||
|
||||
+118
-6
@@ -18,13 +18,15 @@
|
||||
//! ["*"]` (every body), which is what they did, so those designs keep their shape.
|
||||
//! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means
|
||||
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
|
||||
//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become
|
||||
//! explicit planes retaining their previous axes, including offset-plane expressions.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 5;
|
||||
pub const FORMAT: u32 = 6;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solvecraft/{n}")
|
||||
@@ -37,7 +39,30 @@ pub fn version_of(s: &str) -> Option<u32> {
|
||||
|
||||
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
||||
type JsonStep = fn(&mut Value) -> Result<()>;
|
||||
const JSON_STEPS: [JsonStep; 4] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5];
|
||||
const JSON_STEPS: [JsonStep; 5] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6];
|
||||
|
||||
/// Old XZ references retain their exact frame, including nested offset and angled planes.
|
||||
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
|
||||
/// dimensions, profiles, extrude directions or parameter expressions in saved designs.
|
||||
fn v5_to_v6(v: &mut Value) -> Result<()> {
|
||||
fn preserve(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
let old_xz = o.get("type").and_then(Value::as_str) == Some("origin")
|
||||
&& o.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case("XZ") || n.eq_ignore_ascii_case("FRONT"));
|
||||
if old_xz {
|
||||
*v = serde_json::json!({"type": "custom", "plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}});
|
||||
} else {
|
||||
o.values_mut().for_each(preserve);
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(preserve),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
preserve(v);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Features in designs with components keep reaching every body.
|
||||
fn v4_to_v5(v: &mut Value) -> Result<()> {
|
||||
@@ -172,10 +197,7 @@ fn rigid(m: &crate::Mat) -> bool {
|
||||
|
||||
/// Feature input names and component occurrences, which format 1 files may predate.
|
||||
fn normalise(d: &mut Document) -> Result<()> {
|
||||
let ids: Vec<u64> = d.features.iter().map(|f| f.id).collect();
|
||||
for id in ids {
|
||||
d.name_feature_inputs(id);
|
||||
}
|
||||
d.name_all_feature_inputs();
|
||||
let missing: Vec<(u64, u64)> =
|
||||
d.components.iter().filter(|c| !d.occurrences.iter().any(|o| o.component == c.id)).map(|c| (c.id, c.parent)).collect();
|
||||
for (c, p) in missing {
|
||||
@@ -188,6 +210,70 @@ fn normalise(d: &mut Document) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bulk_input_repair_preserves_existing_names_and_reuses_surplus_names() {
|
||||
let mut d = Document::new("input repair");
|
||||
d.set_param("d2", "1", None, None).unwrap();
|
||||
for i in 0..5 {
|
||||
d.add_feature(
|
||||
crate::FeatureKind::Box {
|
||||
corner: ["0".into(), "0".into(), "0".into()],
|
||||
length: "10".into(),
|
||||
width: "10".into(),
|
||||
height: "10".into(),
|
||||
operation: crate::Operation::NewBody,
|
||||
},
|
||||
Some(&format!("Box{i}")),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
d.features[0].param_names = vec!["".into(), "custom".into(), "".into(), "d1".into(), "d2".into()];
|
||||
d.features[1].param_names.clear();
|
||||
d.features[2].param_names = vec!["d7".into(), "".into(), "d9".into()];
|
||||
d.features[3].param_names = vec!["d7".into(), "d11".into(), "d12".into(), "d1".into()];
|
||||
d.features[4].param_names.clear();
|
||||
let mut expected = d.clone();
|
||||
for id in expected.features.iter().map(|f| f.id).collect::<Vec<_>>() {
|
||||
expected.name_feature_inputs(id);
|
||||
}
|
||||
d.name_all_feature_inputs();
|
||||
assert_eq!(d, expected);
|
||||
assert_eq!(read(&d.to_json()).unwrap(), d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_unnamed_timeline_loads_with_unique_inputs() {
|
||||
let mut d = Document::new("large input repair");
|
||||
let id = d
|
||||
.add_feature(
|
||||
crate::FeatureKind::Box {
|
||||
corner: ["0".into(), "0".into(), "0".into()],
|
||||
length: "10".into(),
|
||||
width: "10".into(),
|
||||
height: "10".into(),
|
||||
operation: crate::Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut f = d.feature(id).unwrap().clone();
|
||||
f.param_names.clear();
|
||||
d.features = (1..=5000)
|
||||
.map(|id| {
|
||||
let mut f = f.clone();
|
||||
f.id = id;
|
||||
f
|
||||
})
|
||||
.collect();
|
||||
d.next_id = 5001;
|
||||
let back = read(&d.to_json()).unwrap();
|
||||
let names: Vec<_> = back.features.iter().flat_map(|f| &f.param_names).collect();
|
||||
assert_eq!(names.len(), 15000);
|
||||
assert_eq!(names.first().unwrap().as_str(), "d1");
|
||||
assert_eq!(names.last().unwrap().as_str(), "d15000");
|
||||
assert_eq!(names.iter().collect::<std::collections::BTreeSet<_>>().len(), names.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions() {
|
||||
assert_eq!(version_of("solvecraft/1"), Some(1));
|
||||
@@ -198,6 +284,32 @@ mod tests {
|
||||
assert_eq!(back, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_xz_and_nested_offsets_keep_their_frame() {
|
||||
let mut doc = Document::new("old XZ");
|
||||
let id = doc
|
||||
.add_feature(
|
||||
crate::FeatureKind::ConstructionPlane {
|
||||
plane: crate::PlaneRef::Offset { base: Box::new(crate::PlaneRef::Origin { name: "XZ".into() }), distance: "7 mm".into() },
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
json["format"] = Value::from("solvecraft/5");
|
||||
let migrated = read(&json.to_string()).unwrap();
|
||||
let crate::FeatureKind::ConstructionPlane { plane } = &migrated.feature(id).unwrap().kind else { panic!("plane") };
|
||||
let (values, errors) = migrated.param_values();
|
||||
assert!(errors.is_empty());
|
||||
let p = migrated.resolve_plane(&values, plane, 0).unwrap();
|
||||
assert_eq!(p.y, solvecraft_geom::Vec3::Z);
|
||||
assert_eq!(p.normal(), -solvecraft_geom::Vec3::Y);
|
||||
assert_eq!(p.origin, solvecraft_geom::Vec3::new(0.0, -7.0, 0.0));
|
||||
assert_eq!(read(&migrated.to_json()).unwrap(), migrated);
|
||||
let fresh = doc.resolve_plane(&values, &crate::PlaneRef::Origin { name: "XZ".into() }, 0).unwrap();
|
||||
assert_eq!(fresh.normal(), solvecraft_geom::Vec3::Y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_upgraded_newer_refused_others_rejected() {
|
||||
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
|
||||
|
||||
@@ -25,11 +25,11 @@ mod project3d;
|
||||
pub use project3d::iso_grid;
|
||||
pub mod sheet;
|
||||
|
||||
pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, mat_inverse, mat_mul, rigid};
|
||||
pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse, mat_mul, rigid};
|
||||
pub use document::*;
|
||||
pub use eval::{
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices,
|
||||
world_state,
|
||||
state_in_frame, world_state,
|
||||
};
|
||||
pub use params::{ParamRow, ParamUser, cycles};
|
||||
pub use solvecraft_kernel as kernel;
|
||||
|
||||
@@ -34,6 +34,12 @@ impl NameCell {
|
||||
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
|
||||
NameCell { origin, names: Arc::new(OnceLock::new()) }
|
||||
}
|
||||
/// Names already known (a moved copy of a body keeps its faces' names).
|
||||
pub fn known(names: Arc<Vec<String>>) -> Self {
|
||||
let cell = OnceLock::new();
|
||||
let _ = cell.set(names);
|
||||
NameCell { origin: None, names: Arc::new(cell) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Deeper chains than this are named by position only (bounded work and stack).
|
||||
@@ -167,6 +173,13 @@ pub fn face_names(b: &ModelBody) -> Arc<Vec<String>> {
|
||||
|
||||
/// Work out a body's face names from the feature that made it and the model before it.
|
||||
fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
// A rigid Move changes coordinates, not topology or face ownership. Geometric
|
||||
// overlap with the unmoved input cannot recover the names at the new position.
|
||||
if matches!(o.feature.kind, FeatureKind::Move { .. })
|
||||
&& let Some(input) = o.before.body(&b.name)
|
||||
{
|
||||
return face_names(input).as_ref().clone();
|
||||
}
|
||||
let m = b.mesh();
|
||||
let size = b.body.size().max(1e-6);
|
||||
let tol = (size * 1e-4).max(1e-6);
|
||||
|
||||
@@ -276,6 +276,32 @@ impl FeatureKind {
|
||||
v
|
||||
}
|
||||
|
||||
/// A primitive's placement point (length expressions). Its coordinates are not inputs:
|
||||
/// they get no parameter names and keep bare numbers in mm whatever the design's units, as
|
||||
/// every other position does; renames and users still find them.
|
||||
pub fn point_expr_mut(&mut self) -> Option<&mut crate::PointExpr> {
|
||||
match self {
|
||||
FeatureKind::Box { corner: p, .. }
|
||||
| FeatureKind::Cylinder { base: p, .. }
|
||||
| FeatureKind::Sphere { center: p, .. }
|
||||
| FeatureKind::Torus { center: p, .. }
|
||||
| FeatureKind::Coil { base: p, .. } => Some(p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only view of [`FeatureKind::point_expr_mut`].
|
||||
pub fn point_expr(&self) -> Option<&crate::PointExpr> {
|
||||
match self {
|
||||
FeatureKind::Box { corner: p, .. }
|
||||
| FeatureKind::Cylinder { base: p, .. }
|
||||
| FeatureKind::Sphere { center: p, .. }
|
||||
| FeatureKind::Torus { center: p, .. }
|
||||
| FeatureKind::Coil { base: p, .. } => Some(p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only view of [`FeatureKind::inputs_mut`]: (label, expression, kind).
|
||||
pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> {
|
||||
// Imports have no inputs (and their STEP text is large: don't clone it).
|
||||
@@ -372,6 +398,55 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair a loaded timeline in one pass. Reserve even later features' names, just as
|
||||
/// `name_feature_inputs` does, without restarting a document scan for every `dN` candidate.
|
||||
pub(crate) fn name_all_feature_inputs(&mut self) {
|
||||
let mut taken: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for name in self.params.iter().map(|p| &p.name).chain(self.features.iter().flat_map(|f| &f.param_names)) {
|
||||
*taken.entry(name.clone()).or_default() += 1;
|
||||
}
|
||||
let mut next = 1usize;
|
||||
for f in &mut self.features {
|
||||
let n = f.kind.inputs().len();
|
||||
for k in 0..n {
|
||||
if f.param_names.get(k).is_some_and(|name| !name.is_empty()) {
|
||||
continue;
|
||||
}
|
||||
let name = loop {
|
||||
let name = format!("d{next}");
|
||||
next += 1;
|
||||
if !taken.contains_key(&name) {
|
||||
break name;
|
||||
}
|
||||
};
|
||||
if f.param_names.len() <= k {
|
||||
f.param_names.resize(k + 1, String::new());
|
||||
}
|
||||
// Empty slots cannot collide with a generated name.
|
||||
if let Some(slot) = f.param_names.get_mut(k) {
|
||||
*slot = name.clone();
|
||||
}
|
||||
taken.insert(name, 1);
|
||||
}
|
||||
// Match the single-feature repair: surplus names become available only after
|
||||
// this feature has been repaired. Counts preserve names shared with parameters.
|
||||
for name in f.param_names.drain(n..) {
|
||||
if let Some(count) = taken.get_mut(&name) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
taken.remove(&name);
|
||||
if let Some(i) = name.strip_prefix('d').and_then(|s| s.parse::<usize>().ok())
|
||||
&& name == format!("d{i}")
|
||||
&& i > 0
|
||||
{
|
||||
next = next.min(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Change a parameter: a stored one, or a feature input by its name.
|
||||
/// A typed length that is a bare number means the design's units: it gets that unit written
|
||||
/// in (feature inputs are stored in mm otherwise). Anything else is returned unchanged.
|
||||
@@ -655,6 +730,9 @@ impl Document {
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.kind.point_expr().is_some_and(|p| p.iter().any(|e| expr::references(e).iter().any(|r| r == name))) {
|
||||
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: "Position".into() });
|
||||
}
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
for c in &sketch.constraints {
|
||||
if c.param.as_deref() == Some(name) {
|
||||
@@ -718,6 +796,13 @@ impl Document {
|
||||
*x = new.to_string();
|
||||
}
|
||||
}
|
||||
for e in f.kind.point_expr_mut().into_iter().flatten() {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
*e = r;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for (_, e, _) in f.kind.inputs_mut() {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
|
||||
@@ -273,7 +273,20 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelS
|
||||
let mut warn = Vec::new();
|
||||
let links = sk.links.clone();
|
||||
for l in &links {
|
||||
match resolve(doc, vals, st, plane, l.kind, &l.source) {
|
||||
let mut source = l.source.clone();
|
||||
if let (Some(name), LinkSource::Face { at, .. }) = (&l.face_name, &mut source) {
|
||||
if let Some((p, split)) = crate::naming::point_on_face(st, name, *at) {
|
||||
*at = p;
|
||||
if split {
|
||||
warn.push(format!("projected face `{name}` was split; using the nearest piece"));
|
||||
}
|
||||
} else {
|
||||
sk.set_link_lost(&l.id, true);
|
||||
warn.push(format!("projected face `{name}` no longer exists"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match resolve(doc, vals, st, plane, l.kind, &source) {
|
||||
Ok(r) => {
|
||||
sk.set_link_source(&l.id, r.source);
|
||||
let _ = sk.set_link_wires(&l.id, &r.wires);
|
||||
@@ -472,7 +485,7 @@ fn tilted_circle(plane: &Plane, pts: &[Vec3]) -> Option<Vec<LinkGeom>> {
|
||||
let (c, n, r, closed) = circle3(pts)?;
|
||||
let sn = plane.normal();
|
||||
let cosang = n.dot(sn).abs();
|
||||
if cosang > 1.0 - 1e-12 || cosang < 1e-9 {
|
||||
if !(1e-9..=1.0 - 1e-12).contains(&cosang) {
|
||||
return None;
|
||||
}
|
||||
if closed {
|
||||
|
||||
@@ -899,7 +899,7 @@ impl SheetBody {
|
||||
return Err(DocError::Invalid("the hem is shorter than its bend".into()));
|
||||
}
|
||||
// A hem folds back over the face it starts from (toward the bottom unless flipped).
|
||||
let up = !top == flip;
|
||||
let up = top != flip;
|
||||
let up = !up;
|
||||
self.flanges.push(SheetFlange {
|
||||
parent: panel,
|
||||
|
||||
+94
-3
@@ -81,6 +81,26 @@ fn parametric_plate_reevaluates() {
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
|
||||
}
|
||||
|
||||
/// Setting a parameter back takes the earlier results from the cache: nothing is recomputed, but
|
||||
/// the evaluation reports the features it restored (their geometry did change back).
|
||||
#[test]
|
||||
fn set_back_reports_restored_features() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
|
||||
doc.set_param("width", "50 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
|
||||
doc.set_param("width", "40 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (0, 2));
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 24000.0) < 1e-9);
|
||||
// No change: nothing recomputed and nothing restored.
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_fillet_cut_timeline() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
@@ -145,7 +165,13 @@ fn errors_are_reported_not_fatal() {
|
||||
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None, style: Default::default() }, None)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Box { corner: Vec3::ZERO, length: "nope".into(), width: "1".into(), height: "1".into(), operation: Operation::NewBody },
|
||||
FeatureKind::Box {
|
||||
corner: point_expr(Vec3::ZERO),
|
||||
length: "nope".into(),
|
||||
width: "1".into(),
|
||||
height: "1".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -182,13 +208,19 @@ fn params_and_serialization() {
|
||||
fn primitives_and_combine() {
|
||||
let mut doc = Document::new("p");
|
||||
doc.add_feature(
|
||||
FeatureKind::Box { corner: Vec3::ZERO, length: "10".into(), width: "10".into(), height: "10".into(), operation: Operation::NewBody },
|
||||
FeatureKind::Box {
|
||||
corner: point_expr(Vec3::ZERO),
|
||||
length: "10".into(),
|
||||
width: "10".into(),
|
||||
height: "10".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Cylinder {
|
||||
base: Vec3::new(5.0, 5.0, 0.0),
|
||||
base: point_expr(Vec3::new(5.0, 5.0, 0.0)),
|
||||
axis: Vec3::Z,
|
||||
radius: "2".into(),
|
||||
height: "20".into(),
|
||||
@@ -275,3 +307,62 @@ fn parameter_cycles_are_found() {
|
||||
assert!(errs["a"].contains("circular reference: a → b → c → a"), "{}", errs["a"]);
|
||||
assert!(vals.contains_key("d") && errs.contains_key("a") && errs.contains_key("e"));
|
||||
}
|
||||
|
||||
/// Features that use a sketch's points or regions go with it (not only profile and path users).
|
||||
#[test]
|
||||
fn input_sketches_cover_points_and_patches() {
|
||||
let hole: FeatureKind = serde_json::from_value(serde_json::json!({
|
||||
"type": "hole", "position": [0, 0, 0], "direction": [0, 0, -1], "diameter": "3",
|
||||
"points": {"sketch": 7, "ids": ["p1"]},
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(hole.input_sketches(), vec![7]);
|
||||
let patch: FeatureKind = serde_json::from_value(serde_json::json!({"type": "patch", "sketch": 9})).unwrap();
|
||||
assert_eq!(patch.input_sketches(), vec![9]);
|
||||
let pipe: FeatureKind = serde_json::from_value(serde_json::json!({"type": "pipe", "path_sketch": 72, "path": ["l1"], "diameter": "4"})).unwrap();
|
||||
assert_eq!(pipe.input_sketches(), vec![72]);
|
||||
}
|
||||
|
||||
/// Guard for `FeatureKind::input_sketches`: a variant with a field that refers to a sketch
|
||||
/// (a field named `*sketch*` holding an id, sketch points or loft sections) must not sit in the
|
||||
/// arm that returns no sketches. New variants are caught by the compiler (the match has no
|
||||
/// wildcard); this catches a sketch field added to an existing variant.
|
||||
#[test]
|
||||
fn input_sketches_lists_every_variant_with_a_sketch_field() {
|
||||
let src = include_str!("document.rs");
|
||||
let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap();
|
||||
let mut with_sketch = Vec::new();
|
||||
let mut variant = "";
|
||||
for line in body.lines() {
|
||||
let t = line.trim();
|
||||
if line.starts_with(" ") && !line.starts_with(" ") && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
|
||||
variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
let Some((name, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue };
|
||||
let refers = (name.contains("sketch") && (ty.contains("u64") || ty.contains("SketchPoints")))
|
||||
|| ty.contains("SketchPoints")
|
||||
|| ty.contains("LoftSection");
|
||||
if refers && !line.trim_start().starts_with("//") && !with_sketch.contains(&variant) {
|
||||
with_sketch.push(variant);
|
||||
}
|
||||
}
|
||||
assert!(with_sketch.len() >= 10, "the FeatureKind parse found too few sketch users: {with_sketch:?}");
|
||||
let f = src.split("pub fn input_sketches(&self)").nth(1).unwrap();
|
||||
let empty_arm = f.split("=> Vec::new()").next().and_then(|s| s.rsplit("=> ").next()).unwrap();
|
||||
for v in with_sketch {
|
||||
assert!(
|
||||
!empty_arm.contains(&format!("K::{v} {{")),
|
||||
"FeatureKind::{v} has a sketch field but input_sketches says it uses no sketch: deleting the sketch would leave it broken"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_clones_share_meshes_even_when_cloned_before_tessellation() {
|
||||
let body = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap();
|
||||
let a = crate::eval::ModelBody::new("box".into(), body, 1);
|
||||
let b = a.clone();
|
||||
assert!(std::sync::Arc::ptr_eq(&a.mesh(), &b.mesh()));
|
||||
assert!(!a.mesh().triangles.is_empty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Booleans that failed, panicked or hung, replayed from the scripts in
|
||||
//! `examples/regressions/` (each script's description says what went wrong).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn replay(script: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(script).unwrap();
|
||||
s.run_script(&script).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn volume(s: &Session, body: &str) -> f64 {
|
||||
s.model.state().body(body).map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Slab less a 557.68 × 100 × 120 pocket and the loft below its floor (556.68 × 99 down 4 mm to
|
||||
/// 80 × 80; prismatoid: h/6 · (A_top + A_bottom + 4 A_mid)).
|
||||
fn slab_pocket_loft() -> (f64, f64) {
|
||||
let slab = 900.0 * 480.0 * 160.0;
|
||||
let loft = 4.0 / 6.0 * (556.68 * 99.0 + 80.0 * 80.0 + 4.0 * (556.68 + 80.0) / 2.0 * (99.0 + 80.0) / 2.0);
|
||||
(slab - 557.68 * 100.0 * 120.0, loft)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loft_cut_beside_a_round_hole() {
|
||||
let s = replay(include_str!("../../../examples/regressions/loft_cut_beside_round_hole.json"));
|
||||
let (pocketed, loft) = slab_pocket_loft();
|
||||
let hole = std::f64::consts::PI * 17.5 * 17.5 * 25.0;
|
||||
let want = pocketed - loft - hole;
|
||||
let got = volume(&s, "Body1");
|
||||
assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shallow_loft_cut_from_the_top_face() {
|
||||
let s = replay(include_str!("../../../examples/regressions/shallow_loft_cut_from_top_face.json"));
|
||||
let (_, loft) = slab_pocket_loft();
|
||||
let want = 900.0 * 480.0 * 160.0 - loft;
|
||||
let got = volume(&s, "Body1");
|
||||
assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oblique_cut_through_a_small_arc_matches_a_split() {
|
||||
let scripts = [
|
||||
include_str!("../../../examples/regressions/oblique_cut_of_cylinder.json"),
|
||||
include_str!("../../../examples/regressions/oblique_cut_of_cylinder_hang.json"),
|
||||
];
|
||||
// The same bar split by the prism's tilted top: the piece above it is the cut's result.
|
||||
let mut split = Session::default();
|
||||
let script: Value = serde_json::from_str(scripts[0]).unwrap();
|
||||
let steps = script.get("commands").and_then(Value::as_array).unwrap();
|
||||
split.run_script(&Value::Array(steps[..5].to_vec())).unwrap();
|
||||
let whole = volume(&split, "Tool");
|
||||
split.execute("solid.split_body", &json!({"body": "Tool", "plane": {"origin": [0, -472, 853.91], "normal": [0, 15.44, 40]}})).unwrap();
|
||||
let above = volume(&split, "Tool");
|
||||
assert!(above < whole - 10.0, "{above} of {whole}");
|
||||
for script in scripts {
|
||||
let s = replay(script);
|
||||
let got = volume(&s, "Tool");
|
||||
assert!((got - above).abs() < 1e-6 * above, "{got} vs {above}");
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use serde_json::{Value, json};
|
||||
use solvecraft_doc::FeatureKind;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::params::{bad, bool_, str_};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
@@ -34,6 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).noundo(),
|
||||
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
|
||||
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
|
||||
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
|
||||
or folder?: bodies|sketches|construction|origin (all of it). View state: not saved, not an undo step",
|
||||
),
|
||||
];
|
||||
|
||||
const FOLDERS: [&str; 3] = ["bodies", "sketches", "construction"];
|
||||
@@ -155,6 +159,83 @@ fn order(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
const ORIGIN: [&str; 7] = ["O", "X", "Y", "Z", "XY", "XZ", "YZ"];
|
||||
|
||||
/// A browser item whose visibility can change.
|
||||
enum Shown {
|
||||
Sketch(u64),
|
||||
Body(String),
|
||||
/// Origin items and construction geometry, by name.
|
||||
Origin(String),
|
||||
}
|
||||
|
||||
fn is_construction(k: &FeatureKind) -> bool {
|
||||
matches!(k, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. })
|
||||
}
|
||||
|
||||
fn shown_item(s: &Session, key: &str) -> Option<Shown> {
|
||||
if let Some(f) = s.doc.find_feature(key) {
|
||||
if matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
return Some(Shown::Sketch(f.id));
|
||||
}
|
||||
if is_construction(&f.kind) {
|
||||
return Some(Shown::Origin(f.name.clone()));
|
||||
}
|
||||
}
|
||||
if s.world_state().bodies.iter().any(|b| b.name == key) {
|
||||
return Some(Shown::Body(key.to_string()));
|
||||
}
|
||||
ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
|
||||
}
|
||||
|
||||
fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "browser.visibility";
|
||||
let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?;
|
||||
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
|
||||
(Some(_), Some(_)) => return Err(bad(cmd, "give `items` or `folder`, not both")),
|
||||
(Some("bodies"), None) => s.world_state().bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(),
|
||||
(Some("sketches"), None) => s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)).collect(),
|
||||
(Some("construction"), None) => s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())).collect(),
|
||||
(Some("origin"), None) => ORIGIN.iter().map(|o| Shown::Origin(o.to_string())).collect(),
|
||||
(Some(f), None) => return Err(bad(cmd, format!("unknown folder `{f}` (bodies, sketches, construction, origin)"))),
|
||||
(None, Some(_)) => {
|
||||
let keys = keys(p, "items", cmd)?;
|
||||
if keys.is_empty() {
|
||||
return Err(bad(cmd, "`items` must list sketches, bodies or construction geometry"));
|
||||
}
|
||||
// Every item is found before anything changes.
|
||||
keys.iter()
|
||||
.map(|k| shown_item(s, k).ok_or_else(|| bad(cmd, format!("no sketch, body or construction item `{k}`"))))
|
||||
.collect::<Result<_>>()?
|
||||
}
|
||||
(None, None) => return Err(bad(cmd, "`items` or `folder` is required")),
|
||||
};
|
||||
let v = &mut s.visibility;
|
||||
for it in &items {
|
||||
match it {
|
||||
Shown::Sketch(id) => {
|
||||
v.hidden_sketches.retain(|x| x != id);
|
||||
v.shown_sketches.retain(|x| x != id);
|
||||
if visible { v.shown_sketches.push(*id) } else { v.hidden_sketches.push(*id) }
|
||||
}
|
||||
Shown::Body(n) => {
|
||||
v.hidden_bodies.retain(|x| x != n);
|
||||
if !visible {
|
||||
v.hidden_bodies.push(n.clone());
|
||||
}
|
||||
}
|
||||
Shown::Origin(n) => {
|
||||
v.hidden_origin.retain(|x| x != n);
|
||||
if !visible {
|
||||
v.hidden_origin.push(n.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.revision += 1;
|
||||
Ok(json!({ "visible": visible, "changed": items.len(), "visibility": s.visibility }))
|
||||
}
|
||||
|
||||
fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "document.units";
|
||||
let u = str_(p, "units").ok_or_else(|| bad(cmd, "`units` is required"))?;
|
||||
@@ -339,6 +420,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.body_components.insert(name.to_string(), c);
|
||||
}
|
||||
rename_key(d, "bodies", &old, name);
|
||||
for b in s.visibility.hidden_bodies.iter_mut().filter(|b| **b == old) {
|
||||
*b = name.to_string();
|
||||
}
|
||||
Ok(json!({"body": old, "name": name}))
|
||||
}
|
||||
|
||||
@@ -411,7 +495,9 @@ mod tests {
|
||||
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
|
||||
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
|
||||
let z = zmax(&mut s);
|
||||
assert!(z > 9.0, "the profile stands up on XZ: {z}");
|
||||
assert!(z.abs() < 1e-6, "positive sketch Y on XZ extends toward -Z: {z}");
|
||||
let ymin = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap();
|
||||
assert!((ymin + 20.0).abs() < 1e-6);
|
||||
assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err());
|
||||
assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err());
|
||||
}
|
||||
@@ -478,6 +564,60 @@ mod tests {
|
||||
assert_eq!(s.doc.named_views.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_sketches_hide_and_show() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let floor = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [50, 0]], "construction": true}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let drain = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.point", json!({"point": [25, 5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Floor"}));
|
||||
let body = s.model.state().bodies[0].name.clone();
|
||||
let drain_name = s.doc.feature(drain).unwrap().name.clone();
|
||||
let cam = solvecraft_render::Camera::default();
|
||||
let lines = |s: &Session| crate::view::scene(s, &cam).lines.len();
|
||||
let shown = |s: &mut Session| {
|
||||
let d = run(s, "document.inspect", json!({}));
|
||||
d["sketches"].as_array().unwrap().iter().filter(|x| x["visible"] == true).count()
|
||||
};
|
||||
let (n0, undo0, doc0) = (lines(&s), s.undo.len(), s.doc.clone());
|
||||
assert_eq!(shown(&mut s), 2);
|
||||
// By id and by name; the view loses their lines, the design and its history don't change.
|
||||
let r = run(&mut s, "browser.visibility", json!({"items": [floor, drain_name], "visible": false}));
|
||||
assert_eq!(r["changed"], 2);
|
||||
assert_eq!(shown(&mut s), 0);
|
||||
assert!(lines(&s) < n0);
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc0) && s.undo.len() == undo0, "view state, not a design change");
|
||||
run(&mut s, "browser.visibility", json!({"folder": "sketches", "visible": true}));
|
||||
assert_eq!(lines(&s), n0);
|
||||
// Bodies (following a rename) and construction planes.
|
||||
run(&mut s, "browser.visibility", json!({"items": [body.clone(), "Floor", "xy"], "visible": false}));
|
||||
assert!(crate::view::scene(&s, &cam).meshes.is_empty());
|
||||
assert_eq!(s.visibility.hidden_origin, vec!["Floor".to_string(), "XY".to_string()]);
|
||||
run(&mut s, "body.rename", json!({"body": body, "name": "Tray"}));
|
||||
assert_eq!(s.visibility.hidden_bodies, vec!["Tray".to_string()]);
|
||||
let d = run(&mut s, "document.inspect", json!({}));
|
||||
assert_eq!(d["bodies"][0]["visible"], false);
|
||||
// Hostile input is refused and changes nothing.
|
||||
let before = s.visibility.clone();
|
||||
for p in [
|
||||
json!({"items": [floor]}),
|
||||
json!({"items": [floor], "visible": "no"}),
|
||||
json!({"items": [floor, "Nope"], "visible": false}),
|
||||
json!({"items": [], "visible": false}),
|
||||
json!({"items": "Floor", "visible": false}),
|
||||
json!({"folder": "sketches", "items": [floor], "visible": false}),
|
||||
json!({"folder": "nope", "visible": false}),
|
||||
json!({"visible": false}),
|
||||
] {
|
||||
assert!(s.execute("browser.visibility", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.visibility, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_hold_items_and_follow_renames() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -10,6 +10,12 @@ use crate::{EngineError, Result, Sel, Session, Snapshot};
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
|
||||
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
|
||||
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
|
||||
.noundo()
|
||||
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
|
||||
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint)
|
||||
.noundo()
|
||||
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"),
|
||||
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
|
||||
CommandSpec::new("timeline.delete", "Delete", delete)
|
||||
.at("SOLID", "MODIFY")
|
||||
@@ -28,8 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature)
|
||||
.params("feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}}"),
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
|
||||
"feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \
|
||||
restored: features whose earlier result for the same inputs was reused",
|
||||
),
|
||||
CommandSpec::new("select.set", "Select", select_set)
|
||||
.noundo()
|
||||
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"),
|
||||
@@ -58,6 +66,21 @@ fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"redone": label}))
|
||||
}
|
||||
|
||||
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"checkpoint": s.checkpoint()}))
|
||||
}
|
||||
|
||||
fn restore_checkpoint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "edit.restore_checkpoint";
|
||||
let id = p.get("checkpoint").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`checkpoint` must be an id from edit.checkpoint"))?;
|
||||
if bool_(p, "forget") == Some(true) {
|
||||
s.drop_checkpoint(id);
|
||||
return Ok(json!({"checkpoint": id, "restored": false}));
|
||||
}
|
||||
s.restore_checkpoint(id)?;
|
||||
Ok(json!({"checkpoint": id, "restored": true}))
|
||||
}
|
||||
|
||||
fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.model = solvecraft_doc::Model::new();
|
||||
s.refresh();
|
||||
@@ -204,7 +227,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
|
||||
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
|
||||
}
|
||||
|
||||
fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -307,7 +330,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
|
||||
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
|
||||
}
|
||||
|
||||
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, ProfileSel, expr::Kind};
|
||||
use solvecraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, expr::Kind, point_expr};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("extrude")
|
||||
.key("E")
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
@@ -23,10 +23,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("sweep")
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?"),
|
||||
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"),
|
||||
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z]; operation?"),
|
||||
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z]; axis?: [x,y,z]; operation?"),
|
||||
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z]; operation?"),
|
||||
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?; operation?"),
|
||||
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"),
|
||||
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?"),
|
||||
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"),
|
||||
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"),
|
||||
CommandSpec::new("solid.fillet", "Fillet", fillet)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("fillet")
|
||||
@@ -46,7 +46,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pattern_rect")
|
||||
.params("features: [names] | bodies: [names]; path_sketch: sketch; path: [curve ids in order]; count; spacing (between instances) | distance (first to last); orientation?: identical|path; flip?: bool"),
|
||||
CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ | {origin, x_dir, y_dir}"),
|
||||
CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ|construction plane name or id|{origin, normal}|{origin, x_dir, y_dir} (a construction plane keeps the mirror following it)"),
|
||||
CommandSpec::new("solid.hole", "Hole", hole)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("hole")
|
||||
@@ -141,7 +141,8 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
|
||||
}
|
||||
|
||||
/// What `operation: "auto"` means for an extrude with these parameters: `cut` when it goes
|
||||
/// into a body, `join` when it grows out of one, `new` otherwise.
|
||||
/// into a body, otherwise `join` when the active component has a solid. Keeping an
|
||||
/// addition separate requires an explicit `new` operation.
|
||||
pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
|
||||
let d = s.doc.eval(&expr(p, "distance")?, Kind::Length).ok()?;
|
||||
let dir = str_(p, "direction").map(str::to_ascii_lowercase);
|
||||
@@ -154,23 +155,25 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
|
||||
}
|
||||
// Only the active component's bodies take part (or the ones named as participants).
|
||||
let parts = string_list(p, "participants");
|
||||
let inside = |q: Vec3| {
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) })
|
||||
.any(|b| b.mesh().contains(q))
|
||||
};
|
||||
let eps = (d.abs() * 1e-3).max(1e-3);
|
||||
let sign = if d < 0.0 { -1.0 } else { 1.0 };
|
||||
let bodies: Vec<_> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) })
|
||||
.collect();
|
||||
if bodies.is_empty() {
|
||||
return Some("new");
|
||||
}
|
||||
let inside = |q: Vec3| bodies.iter().any(|b| b.mesh().contains(q));
|
||||
if symmetric {
|
||||
return Some(if inside(base + n * (d.abs() * 0.5)) || inside(base - n * (d.abs() * 0.5)) { "cut" } else { "new" });
|
||||
return Some(if inside(base + n * (d.abs() * 0.5)) || inside(base - n * (d.abs() * 0.5)) { "cut" } else { "join" });
|
||||
}
|
||||
if inside(base + n * (d * 0.5)) {
|
||||
Some("cut")
|
||||
} else if inside(base - n * (eps * sign)) {
|
||||
Some("join")
|
||||
} else {
|
||||
Some("new")
|
||||
// A profile can meet material along its side, or only around part of a
|
||||
// screw boss. A single point behind its centre cannot establish contact.
|
||||
// Default additions to Join and let the boolean merge the actual geometry.
|
||||
Some("join")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,19 +487,59 @@ fn radius_expr(p: &Value, cmd: &str) -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A placement point: `[x, y, z]` (or `{x, y, z}`) of numbers (mm) or length expressions;
|
||||
/// the origin when it is not given. Anything else is refused (never read as the origin).
|
||||
pub(super) fn point_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<PointExpr>> {
|
||||
let Some(v) = p.get(key) else { return Ok(None) };
|
||||
let want = || bad(cmd, format!("`{key}` must be a point [x, y, z] of numbers or length expressions"));
|
||||
let coords: Vec<&Value> = match v {
|
||||
Value::Array(a) if a.len() == 3 => a.iter().collect(),
|
||||
Value::Object(o) => ["x", "y", "z"].iter().map(|k| o.get(*k)).collect::<Option<Vec<_>>>().ok_or_else(want)?,
|
||||
_ => return Err(want()),
|
||||
};
|
||||
let mut out = PointExpr::default();
|
||||
for (slot, (c, axis)) in out.iter_mut().zip(coords.into_iter().zip(["x", "y", "z"])) {
|
||||
let e = expr(&json!({ "v": c }), "v").ok_or_else(want)?;
|
||||
let x = s.doc.eval(&e, Kind::Length).map_err(|err| bad(cmd, format!("{key} {axis}: {err}")))?;
|
||||
if !(x.is_finite() && x.abs() < 1e9) {
|
||||
return Err(bad(cmd, format!("{key} {axis} is out of range")));
|
||||
}
|
||||
*slot = e;
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// An axis direction: X|Y|Z or a non-zero `[x, y, z]`; `Z` when it is not given.
|
||||
pub(super) fn axis_dir_param(p: &Value, cmd: &str) -> Result<Vec3> {
|
||||
match p.get("axis") {
|
||||
None => Ok(Vec3::Z),
|
||||
Some(Value::String(a)) => match a.trim().to_ascii_uppercase().as_str() {
|
||||
"X" => Ok(Vec3::X),
|
||||
"Y" => Ok(Vec3::Y),
|
||||
"Z" => Ok(Vec3::Z),
|
||||
_ => Err(bad(cmd, "`axis` must be X, Y, Z or [x, y, z]")),
|
||||
},
|
||||
Some(v) => vec3(v).and_then(|a| a.normalized()).ok_or_else(|| bad(cmd, "`axis` must be X, Y, Z or a non-zero [x, y, z]")),
|
||||
}
|
||||
}
|
||||
|
||||
fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.box";
|
||||
let (l, w, h) = (req_expr(cmd, p, "length")?, req_expr(cmd, p, "width")?, req_expr(cmd, p, "height")?);
|
||||
for (e, n) in [(&l, "length"), (&w, "width"), (&h, "height")] {
|
||||
check_expr(s, e, Kind::Length, cmd, n)?;
|
||||
}
|
||||
let corner = match (p.get("corner").and_then(vec3), p.get("center").and_then(vec3)) {
|
||||
let corner = match (point_param(s, p, "corner", cmd)?, point_param(s, p, "center", cmd)?) {
|
||||
(Some(c), _) => c,
|
||||
(None, Some(c)) => {
|
||||
let v = |e: &str| s.doc.eval(e, Kind::Length).unwrap_or(0.0);
|
||||
c - Vec3::new(v(&l), v(&w), v(&h)) * 0.5
|
||||
// The corner half a size back from the centre (numbers stay numbers).
|
||||
(None, Some([cx, cy, cz])) => {
|
||||
let back = |c: String, size: &String| match (c.parse::<f64>(), size.parse::<f64>()) {
|
||||
(Ok(a), Ok(b)) => format!("{}", a - b / 2.0),
|
||||
_ => format!("({c}) - ({size}) / 2"),
|
||||
};
|
||||
[back(cx, &l), back(cy, &w), back(cz, &h)]
|
||||
}
|
||||
_ => Vec3::ZERO,
|
||||
_ => point_expr(Vec3::ZERO),
|
||||
};
|
||||
add_feature(s, p, FeatureKind::Box { corner, length: l, width: w, height: h, operation: operation(p, cmd)? })
|
||||
}
|
||||
@@ -507,8 +550,8 @@ fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let height = req_expr(cmd, p, "height")?;
|
||||
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
|
||||
check_expr(s, &height, Kind::Length, cmd, "height")?;
|
||||
let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let axis = p.get("axis").and_then(vec3).unwrap_or(Vec3::Z);
|
||||
let base = point_param(s, p, "base", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
|
||||
let axis = axis_dir_param(p, cmd)?;
|
||||
add_feature(s, p, FeatureKind::Cylinder { base, axis, radius, height, operation: operation(p, cmd)? })
|
||||
}
|
||||
|
||||
@@ -516,7 +559,7 @@ fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.sphere";
|
||||
let radius = radius_expr(p, cmd)?;
|
||||
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
|
||||
let center = p.get("center").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let center = point_param(s, p, "center", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
|
||||
add_feature(s, p, FeatureKind::Sphere { center, radius, operation: operation(p, cmd)? })
|
||||
}
|
||||
|
||||
@@ -525,7 +568,7 @@ fn prim_torus(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (major, minor) = (req_expr(cmd, p, "major")?, req_expr(cmd, p, "minor")?);
|
||||
check_expr(s, &major, Kind::Length, cmd, "major")?;
|
||||
check_expr(s, &minor, Kind::Length, cmd, "minor")?;
|
||||
let center = p.get("center").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let center = point_param(s, p, "center", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
|
||||
add_feature(s, p, FeatureKind::Torus { center, major, minor, operation: operation(p, cmd)? })
|
||||
}
|
||||
|
||||
@@ -581,7 +624,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new = format!("({old}) + ({distance})");
|
||||
let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?;
|
||||
let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?;
|
||||
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed")}));
|
||||
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed"), "restored": out.get("restored")}));
|
||||
}
|
||||
// Another curved face moves along its normal.
|
||||
let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?;
|
||||
@@ -829,35 +872,35 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.mirror";
|
||||
let bodies = string_list(p, "bodies");
|
||||
let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() };
|
||||
let plane = match p.get("plane") {
|
||||
Some(Value::String(n)) if solvecraft_geom::Plane::named(n).is_some() => solvecraft_doc::PlaneRef::Origin { name: n.to_ascii_uppercase() },
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let pl = match (o.get("x_dir").and_then(vec3), o.get("y_dir").and_then(vec3), o.get("normal").and_then(vec3)) {
|
||||
(Some(x), Some(y), _) => solvecraft_geom::Plane::new(origin, x, y),
|
||||
(_, _, Some(n)) => solvecraft_geom::Plane::from_normal(origin, n),
|
||||
_ => None,
|
||||
};
|
||||
solvecraft_doc::PlaneRef::Custom { plane: pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))? }
|
||||
}
|
||||
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or {origin, normal}")),
|
||||
};
|
||||
let plane = plane_param(s, p.get("plane"), cmd)?;
|
||||
let combine = bool_(p, "combine").unwrap_or(false);
|
||||
add_feature(s, p, FeatureKind::Mirror { features, plane, bodies, combine })
|
||||
}
|
||||
|
||||
/// A plane reference from a parameter: an origin plane, a construction plane name, or an
|
||||
/// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`.
|
||||
/// A plane reference from a parameter: an origin plane, a construction plane (by name or
|
||||
/// feature id; kept by name, so the feature follows the plane when it changes), or an
|
||||
/// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`. Every command that takes a
|
||||
/// plane reads it here.
|
||||
pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solvecraft_doc::PlaneRef> {
|
||||
use solvecraft_doc::PlaneRef;
|
||||
const WANT: &str = "XY, XZ, YZ, a construction plane name or id, or {origin, normal}";
|
||||
let construction = |r: &str| match s.doc.find_feature(r) {
|
||||
Some(f) if matches!(f.kind, FeatureKind::ConstructionPlane { .. }) => Ok(PlaneRef::Construction { name: f.name.clone() }),
|
||||
Some(f) => Err(bad(cmd, format!("`{}` is not a construction plane (a plane is {WANT})", f.name))),
|
||||
None => Err(bad(cmd, format!("no plane `{r}` (a plane is {WANT})"))),
|
||||
};
|
||||
match v {
|
||||
Some(Value::String(n)) if solvecraft_geom::Plane::named(n).is_some() => Ok(PlaneRef::Origin { name: n.to_ascii_uppercase() }),
|
||||
Some(Value::String(n)) => match s.doc.find_feature(n).map(|f| &f.kind) {
|
||||
Some(FeatureKind::ConstructionPlane { .. }) => Ok(PlaneRef::Construction { name: n.clone() }),
|
||||
_ => Err(bad(cmd, format!("no plane `{n}`"))),
|
||||
Some(Value::String(n)) if solvecraft_geom::Plane::named(n.trim()).is_some() => Ok(PlaneRef::Origin { name: n.trim().to_ascii_uppercase() }),
|
||||
Some(Value::String(n)) => construction(n.trim()),
|
||||
Some(Value::Number(id)) => match id.as_u64() {
|
||||
Some(id) => construction(&id.to_string()),
|
||||
None => Err(bad(cmd, format!("`plane` must be {WANT}"))),
|
||||
},
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let origin = match o.get("origin") {
|
||||
None => Vec3::ZERO,
|
||||
Some(x) => vec3(x).ok_or_else(|| bad(cmd, "the plane's `origin` must be a point [x, y, z]"))?,
|
||||
};
|
||||
let pl = match (o.get("x_dir").and_then(vec3), o.get("y_dir").and_then(vec3), o.get("normal").and_then(vec3)) {
|
||||
(Some(x), Some(y), _) => solvecraft_geom::Plane::new(origin, x, y),
|
||||
(_, _, Some(n)) => solvecraft_geom::Plane::from_normal(origin, n),
|
||||
@@ -865,7 +908,7 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
|
||||
};
|
||||
Ok(PlaneRef::Custom { plane: pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))? })
|
||||
}
|
||||
_ => Err(bad(cmd, "`plane` must be XY, XZ, YZ, a construction plane name or {origin, normal}")),
|
||||
_ => Err(bad(cmd, format!("`plane` must be {WANT}"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use solvecraft_doc::expr::Kind;
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, check_expr, feature_sketch, plane_param, profiles};
|
||||
use super::features::{add_feature, axis_dir_param, check_expr, feature_sketch, plane_param, point_param, profiles};
|
||||
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -16,7 +16,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
CommandSpec::new("solid.coil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params(
|
||||
"diameter; two of revolutions (or turns), height, pitch; section_size; section?: circular|square; section_position?: inside|center|outside; \
|
||||
base?: [x,y,z]; axis?: X|Y|Z|[x,y,z] (default Z); start_angle?; clockwise?: bool; operation?, targets?, name?, body_name?",
|
||||
base?: [x,y,z] (numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); start_angle?; clockwise?: bool; operation?, targets?, name?, body_name?",
|
||||
),
|
||||
CommandSpec::new("solid.rib", "Rib", rib)
|
||||
.at("SOLID", "CREATE")
|
||||
@@ -43,17 +43,8 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
|
||||
fn coil(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.coil";
|
||||
let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let axis = match p.get("axis") {
|
||||
None => Vec3::Z,
|
||||
Some(Value::String(a)) => match a.to_ascii_uppercase().as_str() {
|
||||
"X" => Vec3::X,
|
||||
"Y" => Vec3::Y,
|
||||
"Z" => Vec3::Z,
|
||||
_ => return Err(bad(cmd, "`axis` must be X, Y, Z or [x, y, z]")),
|
||||
},
|
||||
Some(v) => vec3(v).and_then(|a| a.normalized()).ok_or_else(|| bad(cmd, "`axis` must be X, Y, Z or a non-zero [x, y, z]"))?,
|
||||
};
|
||||
let base = point_param(s, p, "base", cmd)?.unwrap_or_else(|| solvecraft_doc::point_expr(Vec3::ZERO));
|
||||
let axis = axis_dir_param(p, cmd)?;
|
||||
let diameter = req_expr(cmd, p, "diameter")?;
|
||||
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
|
||||
let turns = expr(p, "revolutions").or_else(|| expr(p, "turns"));
|
||||
|
||||
@@ -170,6 +170,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
|
||||
"profiles": profiles,
|
||||
"links": sk.links,
|
||||
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link})).collect::<Vec<_>>(),
|
||||
"visible": crate::view::sketch_shown(s, id),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -207,18 +208,27 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect();
|
||||
let with_measure = bool_(p, "measure").unwrap_or(false);
|
||||
let bodies: Vec<Value> =
|
||||
st.bodies.iter().map(|b| if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) }).collect();
|
||||
let bodies: Vec<Value> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let mut v = if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) };
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("visible".into(), json!(!s.visibility.hidden_bodies.contains(&b.name)));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let sketches: Vec<Value> = st
|
||||
.sketches
|
||||
.iter()
|
||||
.map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len()}))
|
||||
.map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len(), "visible": crate::view::sketch_shown(s, ss.feature)}))
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"name": s.doc.name, "units": s.doc.units, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
|
||||
"active_sketch": s.active_sketch, "marker": marker,
|
||||
"params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches,
|
||||
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(),
|
||||
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(), "hidden_origin": s.visibility.hidden_origin,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -55,13 +55,13 @@ fn densify(pts: &[Vec3]) -> Vec<Vec3> {
|
||||
|
||||
/// The mesh edge a selection means: by index if it still passes through the point, otherwise
|
||||
/// the nearest.
|
||||
fn edge_points(m: &Mesh, index: usize, point: Vec3) -> Option<Vec<Vec3>> {
|
||||
fn edge_index(m: &Mesh, index: usize, point: Vec3) -> Option<usize> {
|
||||
let near = |e: &Vec<Vec3>| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||||
let tol = m.bounds().diagonal().max(1.0) * 1e-6;
|
||||
if let Some(e) = m.edges.get(index).filter(|e| near(e) < tol) {
|
||||
return Some(e.clone());
|
||||
if m.edges.get(index).is_some_and(|e| near(e) < tol) {
|
||||
return Some(index);
|
||||
}
|
||||
m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).cloned()
|
||||
m.edges.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b))).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
fn face_tris(m: &Mesh, f: usize) -> Vec<[Vec3; 3]> {
|
||||
@@ -75,15 +75,23 @@ fn describe(s: &Session, sel: &Sel) -> Option<(Geo, Option<Vec3>, Value)> {
|
||||
Sel::Vertex { point, .. } => (Geo::Points(vec![*point]), None, json!({"type": "vertex", "position": point})),
|
||||
Sel::Edge { body, index, point } => {
|
||||
let m = st.body(body)?.mesh();
|
||||
let pts = edge_points(&m, *index, *point)?;
|
||||
let index = edge_index(&m, *index, *point)?;
|
||||
let pts = m.edges.get(index)?;
|
||||
let rim = m.circular_rim(index);
|
||||
if rim.len() > 1 {
|
||||
let pieces: Vec<&Vec<Vec3>> = rim.iter().filter_map(|&i| m.edges.get(i)).collect();
|
||||
let len: f64 = pieces.iter().map(|p| polyline_len(p)).sum();
|
||||
let points = pieces.iter().flat_map(|p| densify(p)).collect();
|
||||
return Some((Geo::Points(points), None, json!({"type": "edge", "length_mm": len})));
|
||||
}
|
||||
let (a, b) = (*pts.first()?, *pts.last()?);
|
||||
let straight = (b - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(b))));
|
||||
let len = polyline_len(&pts);
|
||||
(Geo::Points(densify(&pts)), straight, json!({"type": "edge", "length_mm": len}))
|
||||
let len = polyline_len(pts);
|
||||
(Geo::Points(densify(pts)), straight, json!({"type": "edge", "length_mm": len}))
|
||||
}
|
||||
Sel::Face { body, index, .. } => {
|
||||
let m = st.body(body)?.mesh();
|
||||
let tris = face_tris(&m, *index);
|
||||
let tris = m.surface_patch(*index).into_iter().flat_map(|f| face_tris(&m, f)).collect::<Vec<_>>();
|
||||
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
|
||||
let normals: Vec<Vec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect();
|
||||
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
|
||||
@@ -150,10 +158,41 @@ fn closest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> {
|
||||
pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.measure";
|
||||
let list = items.as_array().ok_or_else(|| bad(cmd, "`items` must be a list of selections"))?;
|
||||
if list.is_empty() || list.len() > 2 {
|
||||
if list.is_empty() || list.len() > 1000 {
|
||||
return Err(bad(cmd, "measure one or two items"));
|
||||
}
|
||||
let sels: Vec<Sel> = list.iter().map(|v| serde_json::from_value::<Sel>(v.clone()).map_err(|e| bad(cmd, e.to_string()))).collect::<Result<_>>()?;
|
||||
let st = s.world_state();
|
||||
let mut sels = Vec::new();
|
||||
for value in list {
|
||||
let mut sel = serde_json::from_value::<Sel>(value.clone()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
// Two kernel halves of one visible item are one measurement target.
|
||||
match &mut sel {
|
||||
Sel::Edge { body, index, point } => {
|
||||
if let Some(b) = st.body(body) {
|
||||
let m = b.mesh();
|
||||
let resolved = edge_index(&m, *index, *point).unwrap_or(*index);
|
||||
*index = m.circular_rim(resolved).first().copied().unwrap_or(resolved);
|
||||
}
|
||||
}
|
||||
Sel::Face { body, index, .. } => {
|
||||
if let Some(b) = st.body(body) {
|
||||
*index = b.mesh().surface_patch(*index).first().copied().unwrap_or(*index);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let duplicate = sels.iter().any(|x| match (x, &sel) {
|
||||
(Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. })
|
||||
| (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j,
|
||||
_ => false,
|
||||
});
|
||||
if !duplicate {
|
||||
sels.push(sel);
|
||||
}
|
||||
if sels.len() > 2 {
|
||||
return Err(bad(cmd, "measure one or two items"));
|
||||
}
|
||||
}
|
||||
let mut described = Vec::new();
|
||||
for x in &sels {
|
||||
described.push(describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces or bodies)"))?);
|
||||
|
||||
@@ -12,7 +12,8 @@ use crate::{EngineError, Result, Session};
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
|
||||
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\" (new parameters default to their value's unit), comment?, \
|
||||
favorite?: bool, new_name?: rename (references follow); or delete: name",
|
||||
favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \
|
||||
restored: features whose earlier result for the same inputs was reused (e.g. a value set back)",
|
||||
),
|
||||
CommandSpec::new("parameters.list", "List Parameters", list).noundo().params(
|
||||
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
|
||||
@@ -107,6 +108,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
out["value"] = json!(vals.get(&name).map(|v| v.v));
|
||||
out["display_value"] = json!(s.doc.param_display_value(&vals, &name));
|
||||
out["recomputed"] = json!(s.model.last_recomputed);
|
||||
out["restored"] = json!(s.model.last_restored);
|
||||
out["errors"] = json!(failing(s));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,25 @@ fn width_follows_an_expression() {
|
||||
assert!(e.contains("expected a length, got an angle"), "{e}");
|
||||
}
|
||||
|
||||
/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the
|
||||
/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken
|
||||
/// for "nothing changed". timeline.edit reports the same.
|
||||
#[test]
|
||||
fn setting_a_parameter_back_reports_restored_features() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}");
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}");
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
|
||||
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}");
|
||||
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "10"}}}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(1)), "{r}");
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_inputs_are_named_and_editable() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -231,7 +231,7 @@ fn cyl(s: &mut Session, d: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
|
||||
/// A solid of revolution about z from a closed (r, z) outline, as one body or joined.
|
||||
fn revolve(s: &mut Session, pts: &[(f64, f64)], op: &str) -> Result<()> {
|
||||
run(s, "sketch.create", json!({"plane": "XZ"}))?;
|
||||
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, *z]).collect();
|
||||
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, -*z]).collect();
|
||||
run(s, "sketch.line", json!({"points": points, "closed": true}))?;
|
||||
run(s, "sketch.finish", json!({}))?;
|
||||
run(s, "solid.revolve", json!({"axis": "y", "operation": op}))?;
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] =
|
||||
&[CommandSpec::new("inspect.section", "Section Analysis", section).at("SOLID", "INSPECT").icon("section").noundo().params(
|
||||
"plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: -Y, YZ: +X); \
|
||||
"plane: XY|XZ|YZ | construction plane name or id | {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",
|
||||
)];
|
||||
@@ -24,18 +24,31 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// 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)")))?;
|
||||
Some(Value::String(n)) if Plane::named(n.trim()).is_some() => {
|
||||
let pl = Plane::named(n.trim()).ok_or_else(|| bad(cmd, format!("unknown plane `{n}`")))?;
|
||||
let normal = pl.normal();
|
||||
let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5);
|
||||
(pl.origin, normal, axis)
|
||||
}
|
||||
// A construction plane (name or id): where it is now.
|
||||
Some(v @ (Value::String(_) | Value::Number(_))) => {
|
||||
let r = super::features::plane_param(s, Some(v), cmd)?;
|
||||
let solvecraft_doc::PlaneRef::Construction { name } = &r else { return Err(bad(cmd, "`plane` must be a plane")) };
|
||||
let st = s.model.state();
|
||||
let pl = st
|
||||
.construct
|
||||
.iter()
|
||||
.find(|c| &c.name == name)
|
||||
.and_then(|c| if let solvecraft_doc::construct::ConstructGeom::Plane(pl) = &c.geom { Some(*pl) } else { None })
|
||||
.ok_or_else(|| bad(cmd, format!("construction plane `{name}` is not built (rolled back, suppressed or failed)")))?;
|
||||
(pl.origin, pl.normal(), None)
|
||||
}
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let n = o.get("normal").and_then(vec3).and_then(|n| n.normalized()).ok_or_else(|| bad(cmd, "the plane needs a non-zero `normal`"))?;
|
||||
(origin, n, None)
|
||||
}
|
||||
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or {origin, normal}")),
|
||||
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ, a construction plane name or id, or {origin, normal}")),
|
||||
};
|
||||
let length = |key: &str| -> Result<Option<f64>> {
|
||||
match expr(p, key) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Sketch commands: create/edit/finish sketches, draw curves, constraints and dimensions.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{FeatureKind, PlaneRef};
|
||||
use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind};
|
||||
use solvecraft_geom::{Plane, Vec2, Vec3};
|
||||
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
|
||||
|
||||
@@ -20,7 +20,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sketch")
|
||||
.params("plane: XY|XZ|YZ | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"),
|
||||
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"),
|
||||
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"),
|
||||
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
|
||||
CommandSpec::new("sketch.line", "Line", draw_line)
|
||||
@@ -94,7 +94,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.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)",
|
||||
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
|
||||
),
|
||||
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
@@ -348,19 +348,14 @@ pub(super) fn result(sk_out: (Vec<String>, Vec<String>), info: Value) -> Value {
|
||||
pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
|
||||
let base = match p.get("plane") {
|
||||
None => PlaneRef::Origin { name: "XY".into() },
|
||||
Some(Value::String(n)) => {
|
||||
if Plane::named(n).is_some() {
|
||||
PlaneRef::Origin { name: n.to_ascii_uppercase() }
|
||||
} else if matches!(s.doc.find_feature(n).map(|f| &f.kind), Some(FeatureKind::ConstructionPlane { .. })) {
|
||||
PlaneRef::Construction { name: n.clone() }
|
||||
} else {
|
||||
return Err(bad(cmd, format!("unknown plane `{n}` (XY, XZ, YZ or a construction plane)")));
|
||||
}
|
||||
}
|
||||
// An origin plane, or a construction plane by name or id.
|
||||
Some(v @ (Value::String(_) | Value::Number(_))) => super::features::plane_param(s, Some(v), cmd)?,
|
||||
Some(v @ Value::Object(o)) => {
|
||||
if let Some(fp) = o.get("face").and_then(vec3) {
|
||||
// A planar face of a body at this point.
|
||||
let st = s.model.state();
|
||||
// A planar face of a body at this point, as the active component sees the model
|
||||
// (another component's face where it is placed).
|
||||
let model = s.model.state();
|
||||
let st = solvecraft_doc::state_in_frame(&s.doc, &model, s.active_component);
|
||||
let mut found = None;
|
||||
let mut face_name = None;
|
||||
for b in &st.bodies {
|
||||
@@ -386,7 +381,7 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
|
||||
PlaneRef::Custom { plane: Plane::new(origin, x, y).ok_or_else(|| bad(cmd, "degenerate plane"))? }
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or an object")),
|
||||
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ, a construction plane name or id, or an object")),
|
||||
};
|
||||
Ok(match expr(p, "offset") {
|
||||
Some(d) => PlaneRef::Offset { base: Box::new(base), distance: d },
|
||||
@@ -406,7 +401,10 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo
|
||||
let s1 = (b - a).cross(p - a).dot(n);
|
||||
let s2 = (c - b).cross(p - b).dot(n);
|
||||
let s3 = (a - c).cross(p - c).dot(n);
|
||||
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0) || (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
|
||||
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0)
|
||||
|| (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
|
||||
// Exact curved boundaries can sit just outside their tessellated chords.
|
||||
|| p.dist_to_segment(a, b).min(p.dist_to_segment(b, c)).min(p.dist_to_segment(c, a)) <= tol * 0.1
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1332,7 +1330,9 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let is_line = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. }));
|
||||
let is_circle = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. }));
|
||||
let (k, cur): (ConstraintKind, f64) = match (e0, e1) {
|
||||
// The other reading of an angle between two lines (see below).
|
||||
let mut alt: Option<(ConstraintKind, f64)> = None;
|
||||
let (mut k, mut cur): (ConstraintKind, f64) = match (e0, e1) {
|
||||
((None, Some(c)), None) if is_line(c) => {
|
||||
let (a, b) = line_pts(sk, c).unwrap_or_default();
|
||||
match ty.as_str() {
|
||||
@@ -1409,7 +1409,14 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ang = -ang;
|
||||
std::mem::swap(&mut ka, &mut kb);
|
||||
}
|
||||
(ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang)
|
||||
// Two lines make two angles, θ and 180° − θ, whichever way they were
|
||||
// drawn: the acute one unless the value asks for the other.
|
||||
let direct = (ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang);
|
||||
let supplement =
|
||||
(ConstraintKind::Angle { a: kb, b: ka, value: std::f64::consts::PI - ang, flip: true }, std::f64::consts::PI - ang);
|
||||
let (acute, obtuse) = if ang <= std::f64::consts::FRAC_PI_2 { (direct, supplement) } else { (supplement, direct) };
|
||||
alt = Some(obtuse);
|
||||
acute
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1426,7 +1433,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
c.driven = true;
|
||||
}
|
||||
before = None;
|
||||
return Ok((String::new(), name, cur));
|
||||
return Ok((String::new(), name, measured(is_angle, cur)));
|
||||
}
|
||||
let (unit, default_expr) =
|
||||
if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) };
|
||||
@@ -1437,20 +1444,39 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(err) = errs.get(&pname) {
|
||||
return Err(bad(cmd, format!("value: {err}")));
|
||||
}
|
||||
let _ = vals;
|
||||
// An angle between two lines dimensions whichever of its two readings is nearer the value.
|
||||
if let (Some((ka, ca)), Some(want)) = (alt, vals.get(&pname).and_then(|v| v.to_kind(Kind::Angle).ok()))
|
||||
&& (want - ca).abs() < (want - cur).abs()
|
||||
{
|
||||
(k, cur) = (ka, ca);
|
||||
}
|
||||
let name = k.name();
|
||||
let id = sk.add_constraint(k, Some(pname.clone()))?;
|
||||
place_text(sk, &id, text_at);
|
||||
Ok((pname, name, cur))
|
||||
Ok((pname, name, measured(is_angle, cur)))
|
||||
})?;
|
||||
// Report what the dimension measures once the sketch is solved, not the geometry before.
|
||||
let current = solved_measure(s, p, cmd).unwrap_or(current);
|
||||
if driven {
|
||||
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current, "sketch": info}));
|
||||
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}));
|
||||
}
|
||||
if reject_redundant(before, &info, cmd).is_err() {
|
||||
return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true"));
|
||||
}
|
||||
let v = s.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default();
|
||||
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current, "sketch": info}))
|
||||
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info}))
|
||||
}
|
||||
|
||||
/// A dimension's measured value as reported, in the unit its expression is written in.
|
||||
fn measured(is_angle: bool, v: f64) -> (f64, &'static str) {
|
||||
if is_angle { (round6(v.to_degrees()), "deg") } else { (round6(v), "mm") }
|
||||
}
|
||||
|
||||
/// What the dimension just added (the sketch's last constraint) measures on the solved sketch.
|
||||
fn solved_measure(s: &Session, p: &Value, cmd: &str) -> Option<(f64, &'static str)> {
|
||||
let sk = s.doc.sketch(target_sketch(s, p, cmd).ok()?).ok()?;
|
||||
let k = &sk.constraints.last()?.kind;
|
||||
Some(measured(k.is_angle(), solvecraft_sketch::measure_dimension(sk, k)?))
|
||||
}
|
||||
|
||||
/// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the
|
||||
|
||||
@@ -57,6 +57,8 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
|
||||
return Err(bad(cmd, "`width` must be positive"));
|
||||
}
|
||||
let opacity = num(p, "opacity").unwrap_or(if decal { 1.0 } else { 0.5 }).clamp(0.0, 1.0);
|
||||
// It belongs to the active component (its plane is in that frame) and moves with it.
|
||||
let component = s.active_component;
|
||||
let doc = s.doc_mut();
|
||||
let id = doc.canvases.iter().map(|c| c.id).max().unwrap_or(0) + 1;
|
||||
let base = if decal { "Decal" } else { "Canvas" };
|
||||
@@ -74,6 +76,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
|
||||
opacity,
|
||||
flip: bool_(p, "flip").unwrap_or(false),
|
||||
visible: true,
|
||||
component,
|
||||
});
|
||||
Ok(json!({"canvas": id, "name": name, "pixels": pixels}))
|
||||
}
|
||||
|
||||
@@ -48,6 +48,26 @@ fn redundant_constraints_and_dimensions_are_refused_driven_ones_measure() {
|
||||
assert_eq!(g["fully_constrained"], true);
|
||||
}
|
||||
|
||||
/// `measured` is the dimension on the solved sketch: a trough drawn 99.84 long and dimensioned
|
||||
/// 100 reports 100, not the length it had before solving.
|
||||
#[test]
|
||||
fn a_dimension_reports_the_solved_value() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [99.84, 40.2]}));
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 100}));
|
||||
assert!((d["measured"].as_f64().unwrap() - 100.0).abs() < 1e-6, "{d}");
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": "40 mm"}));
|
||||
assert!((d["measured"].as_f64().unwrap() - 40.0).abs() < 1e-6, "{d}");
|
||||
// A driven dimension measures the solved geometry too.
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["p1", "p3"], "driven": true}));
|
||||
assert!((d["measured"].as_f64().unwrap() - 100.0_f64.hypot(40.0)).abs() < 1e-6, "{d}");
|
||||
let c = run(&mut s, "sketch.circle.center", json!({"center": [50, 20], "radius": 7.3}));
|
||||
let c = ids(&c["curves"])[0].clone();
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": [c], "value": 15}));
|
||||
assert!((d["measured"].as_f64().unwrap() - 15.0).abs() < 1e-6, "{d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curvature_polygon_arc_length_linear_diameter() {
|
||||
let mut s = new_sketch();
|
||||
@@ -201,3 +221,65 @@ fn drawing_onto_a_curve_keeps_the_point_on_it() {
|
||||
let cc = sk.center(sk.curve_index(&c).unwrap()).unwrap();
|
||||
assert!((st.dist(cc) - 8.0).abs() < 1e-7, "{st:?} {cc:?}");
|
||||
}
|
||||
|
||||
/// A faucet spout path: a vertical riser, a horizontal top and an outlet sloping down, joined
|
||||
/// by fillets. The riser points up and the outlet down-left, so their directions are 150° apart
|
||||
/// while the lines meet at 30°.
|
||||
fn spout() -> Session {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[795, 955], [795, 1068], [689, 1068], [661, 1020]], "ids": ["l1", "l2", "l3"]}));
|
||||
run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 50}));
|
||||
run(&mut s, "sketch.fillet", json!({"point": "l2.end", "radius": 35}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "vertical"}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "horizontal"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 795}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 955}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l2.start"], "type": "vertical", "value": 1068}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l3.end", "l1.start"], "type": "horizontal", "value": 134}));
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l3.end"], "type": "vertical", "value": 1020}));
|
||||
assert_eq!(d["sketch"]["dof"], 1, "{d}");
|
||||
s
|
||||
}
|
||||
|
||||
/// The angle (degrees, 0..=90) between two lines of the active sketch.
|
||||
fn line_angle(s: &Session, a: &str, b: &str) -> f64 {
|
||||
let sk = sketch(s);
|
||||
let dir = |l: &str| {
|
||||
let c = sk.curve_index(l).unwrap();
|
||||
let (p, q) = crate::cmd::sketch::line_pts(&sk, c).unwrap();
|
||||
q - p
|
||||
};
|
||||
let (u, v) = (dir(a), dir(b));
|
||||
let t = u.cross(v).abs().atan2(u.dot(v)).to_degrees();
|
||||
t.min(180.0 - t)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degrees() {
|
||||
// The riser and the outlet are not adjacent and meet at 30°; their directions are 150°
|
||||
// apart. Asking for 30° used to swing the outlet round and was refused as over-constraining.
|
||||
for ents in [["l1", "l3"], ["l3", "l1"]] {
|
||||
let mut s = spout();
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ents, "type": "angle", "value": "30 deg"}));
|
||||
assert_eq!(d["sketch"]["dof"], 0, "{d}");
|
||||
assert_eq!(d["unit"], "deg", "{d}");
|
||||
assert!((d["measured"].as_f64().unwrap() - 30.0).abs() < 1e-6, "{d}");
|
||||
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
|
||||
}
|
||||
// The other reading still works, and the adjacent top line measures 60° the same way.
|
||||
let mut s = spout();
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"], "value": "150 deg"}));
|
||||
assert_eq!(d["sketch"]["dof"], 0, "{d}");
|
||||
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
|
||||
let mut s = spout();
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2", "l3"], "value": "60 deg"}));
|
||||
assert!((d["measured"].as_f64().unwrap() - 60.0).abs() < 1e-6, "{d}");
|
||||
// Without a value the acute angle is kept as it is.
|
||||
let mut s = spout();
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"]}));
|
||||
let e = d["expression"].as_str().unwrap();
|
||||
assert!(e.ends_with(" deg") && (e.trim_end_matches(" deg").parse::<f64>().unwrap() - 30.3).abs() < 0.1, "{d}");
|
||||
// Lengths report millimetres.
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "driven": true}));
|
||||
assert_eq!(d["unit"], "mm", "{d}");
|
||||
}
|
||||
|
||||
@@ -520,7 +520,7 @@ fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize
|
||||
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 {
|
||||
if !(1e-6..=std::f64::consts::FRAC_PI_2 - 1e-9).contains(&half) {
|
||||
return Err(bad(cmd, "the lines are parallel"));
|
||||
}
|
||||
let d = r / half.tan();
|
||||
@@ -813,6 +813,12 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let ci = sk.add_circle(cc, nr, Some(c), None)?;
|
||||
made.push(ci);
|
||||
sk.offsets.push(solvecraft_sketch::OffsetChain {
|
||||
sources: vec![(ids[0].clone(), true)],
|
||||
outputs: ids_of(sk, &made),
|
||||
distance: r - nr,
|
||||
closed: true,
|
||||
});
|
||||
return Ok((ids_of(sk, &made), cons));
|
||||
}
|
||||
let pool: Vec<usize> = if use_chain {
|
||||
@@ -916,7 +922,13 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((ids_of(sk, &made), cons))
|
||||
let outputs = ids_of(sk, &made);
|
||||
if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) {
|
||||
let sources = order.iter().filter_map(|(i, forward)| sk.curves.get(*i).map(|c| (c.id.clone(), *forward))).collect();
|
||||
sk.offsets.push(solvecraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed });
|
||||
sk.refresh_offsets()?;
|
||||
}
|
||||
Ok((outputs, cons))
|
||||
})?;
|
||||
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
|
||||
}
|
||||
|
||||
@@ -140,8 +140,20 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki
|
||||
let plane = sketch_plane(doc, id)?;
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
// The model as the sketch's component sees it.
|
||||
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let st = solvecraft_doc::state_in_frame(doc, &st, comp);
|
||||
let r = resolve(doc, &vals, &st, &plane, kind, &src)?;
|
||||
Ok(sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?)
|
||||
let name = if let LinkSource::Face { at, .. } = &r.source {
|
||||
solvecraft_doc::naming::face_names_at(&st, &[*at]).into_iter().next().filter(|n| !n.is_empty())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let link = sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?;
|
||||
if let Some(l) = sk.links.iter_mut().find(|l| l.id == link) {
|
||||
l.face_name = name;
|
||||
}
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
fn link_json(sk: &Sketch, link: &str) -> Value {
|
||||
|
||||
+54
-11
@@ -10,7 +10,7 @@
|
||||
//! without moved components).
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use solvecraft_doc::{Mat, apply_point, apply_vector, is_identity, mat_inverse};
|
||||
use solvecraft_doc::{Mat, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::Session;
|
||||
@@ -30,6 +30,12 @@ enum K {
|
||||
Plane,
|
||||
/// A body name, or a list of them.
|
||||
Body,
|
||||
/// A point (or a list of picks) for the sketch being made or edited: into that sketch's
|
||||
/// component frame, whichever component the geometry under it belongs to (a sketch in the
|
||||
/// root on a moved component's face finds the face where it is shown).
|
||||
Here,
|
||||
/// A plane object for a new sketch or canvas: into the active component's frame.
|
||||
HerePlane,
|
||||
}
|
||||
|
||||
use K::*;
|
||||
@@ -56,11 +62,12 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
"plastic.lip" | "sheet.convert" => &[("face", Pick), ("body", Body)],
|
||||
"sheet.flange" | "sheet.hem" => &[("edges", Picks), ("body", Body)],
|
||||
"sheet.fold" => &[("points", Picks), ("fixed", Pick), ("body", Body)],
|
||||
"sketch.create" | "sketch.redefine" => &[("plane", Plane)],
|
||||
"canvas.decal" => &[("face", Pick)],
|
||||
"sketch.intersect" => &[("refs", Picks)],
|
||||
"sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Pick), ("body", Body)],
|
||||
"sketch.intersection_curve" => &[("a", Picks), ("b", Picks)],
|
||||
"sketch.create" | "sketch.redefine" => &[("plane", HerePlane)],
|
||||
"canvas.decal" => &[("face", Here)],
|
||||
"canvas.insert" => &[("plane", HerePlane), ("at", Here)],
|
||||
"sketch.project" | "sketch.intersect" | "sketch.include_3d" => &[("refs", Here), ("ref", Here)],
|
||||
"sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Here)],
|
||||
"sketch.intersection_curve" => &[("a", Here), ("b", Here)],
|
||||
"inspect.curvature_comb" | "inspect.minimum_radius" | "inspect.isocurve" => &[("edges", Picks), ("faces", Picks)],
|
||||
"parts.insert" | "parts.fastener" => &[("at", Pick), ("point", Make), ("direction", Dir)],
|
||||
"appearance.assign" => &[("faces", Picks), ("bodies", Body), ("body", Body)],
|
||||
@@ -75,6 +82,8 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
struct Frames {
|
||||
bodies: Vec<(String, String, Mat)>,
|
||||
active: Mat,
|
||||
/// The frame of the sketch being edited (its component), else the active component's.
|
||||
sketch: Mat,
|
||||
}
|
||||
|
||||
fn frames(s: &Session) -> Frames {
|
||||
@@ -104,7 +113,8 @@ fn frames(s: &Session) -> Frames {
|
||||
(world, own, m)
|
||||
})
|
||||
.collect();
|
||||
Frames { bodies, active: doc.component_transform(s.active_component) }
|
||||
let sketch_comp = s.active_sketch.and_then(|id| doc.feature(id)).map(|f| f.component).unwrap_or(s.active_component);
|
||||
Frames { bodies, active: doc.component_transform(s.active_component), sketch: doc.component_transform(sketch_comp) }
|
||||
}
|
||||
|
||||
/// Does any placement move anything (so the world differs from the components' frames)?
|
||||
@@ -171,12 +181,14 @@ struct Mapper<'a> {
|
||||
/// The frame of the command's first pick (its directions turn with it).
|
||||
first: Option<Mat>,
|
||||
changed: bool,
|
||||
/// Picks go into the sketch's frame (`Here`), not the frame of the body under them.
|
||||
here: bool,
|
||||
}
|
||||
|
||||
impl Mapper<'_> {
|
||||
fn pick(&mut self, v: &Value, body: Option<&str>) -> Option<Value> {
|
||||
let p = vec3(v)?;
|
||||
let m = frame_at(self.s, &self.f, p, body).unwrap_or(self.f.active);
|
||||
let m = if self.here { self.f.sketch } else { frame_at(self.s, &self.f, p, body).unwrap_or(self.f.active) };
|
||||
self.first.get_or_insert(m);
|
||||
self.local_point(&m, p)
|
||||
}
|
||||
@@ -191,11 +203,29 @@ impl Mapper<'_> {
|
||||
}
|
||||
|
||||
fn make(&mut self, v: &Value) -> Option<Value> {
|
||||
let p = vec3(v)?;
|
||||
let m = self.f.active;
|
||||
let Some(p) = vec3(v) else { return self.make_expr(&m, v) };
|
||||
self.local_point(&m, p)
|
||||
}
|
||||
|
||||
/// A point with expression coordinates (`["w / 2", 0, 0]`): mapped as expressions.
|
||||
fn make_expr(&mut self, m: &Mat, v: &Value) -> Option<Value> {
|
||||
let a = v.as_array().filter(|a| a.len() == 3)?;
|
||||
let c = |i: usize| match a.get(i)? {
|
||||
Value::Number(n) => n.as_f64().filter(|x| x.is_finite()).map(|x| format!("{x}")),
|
||||
Value::String(e) => Some(e.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let mut p = [c(0)?, c(1)?, c(2)?];
|
||||
if is_identity(m) {
|
||||
return None;
|
||||
}
|
||||
let inv = mat_inverse(m)?;
|
||||
map_point_expr(&inv, &mut p);
|
||||
self.changed = true;
|
||||
Some(serde_json::json!(p))
|
||||
}
|
||||
|
||||
fn dir(&mut self, v: &Value) -> Option<Value> {
|
||||
let d = vec3(v)?;
|
||||
let m = self.first.unwrap_or(self.f.active);
|
||||
@@ -325,14 +355,14 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
|
||||
return None;
|
||||
}
|
||||
let obj = params.as_object()?;
|
||||
let mut m = Mapper { s, f: frames(s), first: None, changed: false };
|
||||
let mut m = Mapper { s, f: frames(s), first: None, changed: false, here: false };
|
||||
let body = obj.get("body").and_then(Value::as_str).map(str::to_string);
|
||||
let mut out: Map<String, Value> = obj.clone();
|
||||
// Picks first: their frame turns the directions.
|
||||
for pass in [true, false] {
|
||||
for (k, kind) in keys {
|
||||
let Some(v) = obj.get(*k) else { continue };
|
||||
let is_pick = matches!(kind, Pick | Picks | Plane);
|
||||
let is_pick = matches!(kind, Pick | Picks | Plane | Here | HerePlane);
|
||||
if is_pick != pass {
|
||||
continue;
|
||||
}
|
||||
@@ -343,6 +373,19 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
|
||||
Dir => m.dir(v),
|
||||
Plane => m.plane(v),
|
||||
Body => m.body(v),
|
||||
Here | HerePlane => {
|
||||
// A new sketch or canvas goes into the active component.
|
||||
let keep = m.f.sketch;
|
||||
if *kind == HerePlane {
|
||||
m.f.sketch = m.f.active;
|
||||
}
|
||||
m.here = true;
|
||||
// Body names stay: the sketch's view of the model keeps them.
|
||||
let r = if *kind == HerePlane { m.plane(v) } else { m.picks(v, None) };
|
||||
m.here = false;
|
||||
m.f.sketch = keep;
|
||||
r
|
||||
}
|
||||
};
|
||||
if let Some(n) = new {
|
||||
out.insert((*k).to_string(), n);
|
||||
|
||||
@@ -144,3 +144,106 @@ fn sketch_on_a_face_follows_a_moved_body() {
|
||||
let c = sketch_point_world(&mut s, "OnTop", "p1");
|
||||
assert!((c.z - 25.0).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
/// A 1 x 1 PNG for canvases.
|
||||
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
|
||||
/// Canvases belong to the active component and move with its occurrence; one put on a moved
|
||||
/// component's face from the root sits on the face (and stays the root's).
|
||||
#[test]
|
||||
fn canvases_move_with_their_component() {
|
||||
let mut s = moved_part();
|
||||
let part = s.active_component;
|
||||
assert_ne!(part, 0);
|
||||
run(&mut s, "canvas.insert", json!({"data": PNG, "plane": "XY", "name": "Plan"}));
|
||||
// From the root, on the moved block's top face (world).
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "canvas.insert", json!({"data": PNG, "plane": {"face": [65, 10, 10]}, "name": "Lid", "width": 10}));
|
||||
run(&mut s, "canvas.insert", json!({"data": PNG, "plane": "XZ", "name": "Root"}));
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let world = |s: &Session, name: &str| {
|
||||
let c = s.doc.canvases.iter().find(|c| c.name == name).unwrap();
|
||||
(c.component, s.doc.canvas_plane(&vals, c).unwrap())
|
||||
};
|
||||
let (c, p) = world(&s, "Plan");
|
||||
assert_eq!(c, part);
|
||||
assert!(near(p.origin, Vec3::new(50.0, 0.0, 0.0)), "{:?}", p.origin);
|
||||
let (c, p) = world(&s, "Lid");
|
||||
assert_eq!(c, 0, "the active component's");
|
||||
let lid = s.doc.canvases.iter().find(|c| c.name == "Lid").unwrap();
|
||||
let centre = p.to_world(lid.center);
|
||||
assert!(near(centre, Vec3::new(65.0, 10.0, 10.0)), "{centre:?}");
|
||||
assert_eq!(world(&s, "Root").0, 0);
|
||||
// Moving the occurrence carries its canvases, not the root's.
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 40, 0]}));
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let plan = s.doc.canvases.iter().find(|c| c.name == "Plan").unwrap();
|
||||
assert!(near(s.doc.canvas_plane(&vals, plan).unwrap().origin, Vec3::new(50.0, 40.0, 0.0)));
|
||||
let lid = s.doc.canvases.iter().find(|c| c.name == "Lid").unwrap();
|
||||
let centre = s.doc.canvas_plane(&vals, lid).unwrap().to_world(lid.center);
|
||||
assert!(near(centre, Vec3::new(65.0, 10.0, 10.0)), "{centre:?}");
|
||||
let root = s.doc.canvases.iter().find(|c| c.name == "Root").unwrap();
|
||||
assert!(near(s.doc.canvas_plane(&vals, root).unwrap().origin, Vec3::ZERO));
|
||||
// The component survives a save and reopen.
|
||||
let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(back.canvases, s.doc.canvases);
|
||||
}
|
||||
|
||||
/// World points of a sketch's curves' points (sorted).
|
||||
fn sketch_world_points(s: &mut Session, sketch: &str) -> Vec<Vec3> {
|
||||
let si = run(s, "sketch.inspect", json!({ "sketch": sketch }));
|
||||
si["points"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let w = &p["world"];
|
||||
Vec3::new(w[0].as_f64().unwrap(), w[1].as_f64().unwrap(), w[2].as_f64().unwrap())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A block "C" (40 x 30 x 20) in its own component, the root active again.
|
||||
fn block_in_component() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "C"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The user's "big square": a sketch made in the root on a moved component's face lies on the
|
||||
/// face where it is shown, its projected loop on the face's edges (moved and turned), and it
|
||||
/// follows when the occurrence moves on.
|
||||
#[test]
|
||||
fn root_sketch_on_a_moved_components_face() {
|
||||
let mut s = block_in_component();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "translate": [100, 0, 0]}));
|
||||
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [120, 15, 20]}, "name": "Top"}));
|
||||
assert!(!r["projected"].is_null(), "the face's edges are projected: {r}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let pts = sketch_world_points(&mut s, "Top");
|
||||
assert!(pts.len() >= 4, "{pts:?}");
|
||||
// Every point but the sketch's origin (the model origin on the face plane).
|
||||
for p in pts.iter().filter(|p| !near(**p, Vec3::new(0.0, 0.0, 20.0))) {
|
||||
assert!((p.z - 20.0).abs() < 1e-6 && p.x > 100.0 - 1e-6 && p.x < 140.0 + 1e-6 && p.y > -1e-6 && p.y < 30.0 + 1e-6, "{p:?}");
|
||||
}
|
||||
for corner in [Vec3::new(100.0, 0.0, 20.0), Vec3::new(140.0, 30.0, 20.0)] {
|
||||
assert!(pts.iter().any(|p| near(*p, corner)), "{corner:?} in {pts:?}");
|
||||
}
|
||||
assert_eq!(s.doc.find_feature("Top").unwrap().component, 0, "the sketch is the root's");
|
||||
|
||||
// Turned 90 degrees about Z (around the world origin): the top face is now x -30..0, y 0..40.
|
||||
let mut s = block_in_component();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "axis": [0, 0, 1], "angle": "90 deg", "origin": [0, 0, 0]}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [-15, 20, 20]}, "name": "Top"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let pts = sketch_world_points(&mut s, "Top");
|
||||
for corner in [Vec3::new(0.0, 0.0, 20.0), Vec3::new(-30.0, 40.0, 20.0)] {
|
||||
assert!(pts.iter().any(|p| near(*p, corner)), "{corner:?} in {pts:?}");
|
||||
}
|
||||
// Projected geometry stays on the face when the occurrence moves on (the link re-resolves).
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "translate": [0, 0, 5]}));
|
||||
let pts = sketch_world_points(&mut s, "Top");
|
||||
assert!(pts.iter().any(|p| near(*p, Vec3::new(-30.0, 40.0, 25.0))), "{pts:?}");
|
||||
}
|
||||
|
||||
@@ -168,6 +168,36 @@ pub struct Session {
|
||||
pub section: Option<(Vec3, Vec3)>,
|
||||
/// Surface analysis shading the model (zebra, draft, curvature map).
|
||||
pub analysis: Option<SurfaceAnalysis>,
|
||||
/// What is hidden or shown in the view (`browser.visibility`).
|
||||
pub visibility: Visibility,
|
||||
/// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of
|
||||
/// commands are undone however long the undo history is.
|
||||
pub(crate) checkpoints: Vec<Checkpoint>,
|
||||
next_checkpoint: u64,
|
||||
}
|
||||
|
||||
/// The design, active sketch and undo history at an `edit.checkpoint`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Checkpoint {
|
||||
pub id: u64,
|
||||
pub doc: Arc<Document>,
|
||||
pub active_sketch: Option<u64>,
|
||||
pub undo: Vec<Snapshot>,
|
||||
pub redo: Vec<Snapshot>,
|
||||
}
|
||||
|
||||
/// Items hidden or shown one by one (view state, not part of the design and not an undo step).
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Visibility {
|
||||
/// Bodies by name.
|
||||
pub hidden_bodies: Vec<String>,
|
||||
/// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction geometry, by name.
|
||||
pub hidden_origin: Vec<String>,
|
||||
/// Sketches by feature id.
|
||||
pub hidden_sketches: Vec<u64>,
|
||||
/// Finished sketches shown although a feature uses them.
|
||||
pub shown_sketches: Vec<u64>,
|
||||
}
|
||||
|
||||
/// A way of shading the model's faces to judge their shape (view state, not part of the design).
|
||||
@@ -188,6 +218,8 @@ pub enum SurfaceAnalysis {
|
||||
}
|
||||
|
||||
const MAX_UNDO: usize = 200;
|
||||
/// Checkpoints kept (the oldest go first).
|
||||
const MAX_CHECKPOINTS: usize = 16;
|
||||
const MAX_LOG: usize = 2000;
|
||||
|
||||
impl Default for Session {
|
||||
@@ -219,6 +251,9 @@ impl Session {
|
||||
clipboard: Vec::new(),
|
||||
section: None,
|
||||
analysis: None,
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
visibility: Visibility::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +374,44 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember the design, active sketch and undo history; returns the checkpoint's id.
|
||||
pub fn checkpoint(&mut self) -> u64 {
|
||||
let id = self.next_checkpoint;
|
||||
self.next_checkpoint += 1;
|
||||
self.checkpoints.push(Checkpoint {
|
||||
id,
|
||||
doc: self.doc.clone(),
|
||||
active_sketch: self.active_sketch,
|
||||
undo: self.undo.clone(),
|
||||
redo: self.redo.clone(),
|
||||
});
|
||||
if self.checkpoints.len() > MAX_CHECKPOINTS {
|
||||
self.checkpoints.remove(0);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// Go back to checkpoint `id` as if nothing since had happened (the undo history too). It
|
||||
/// and later checkpoints are used up.
|
||||
pub fn restore_checkpoint(&mut self, id: u64) -> Result<()> {
|
||||
let i = self.checkpoints.iter().position(|c| c.id == id).ok_or_else(|| EngineError::Other(format!("no checkpoint {id}")))?;
|
||||
let Some(c) = self.checkpoints.drain(i..).next() else { return Err(EngineError::Other(format!("no checkpoint {id}"))) };
|
||||
self.doc = c.doc;
|
||||
self.active_sketch = c.active_sketch;
|
||||
self.undo = c.undo;
|
||||
self.redo = c.redo;
|
||||
self.selection.clear();
|
||||
self.refresh();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget checkpoint `id` (and later ones) without going back.
|
||||
pub fn drop_checkpoint(&mut self, id: u64) {
|
||||
if let Some(i) = self.checkpoints.iter().position(|c| c.id == id) {
|
||||
self.checkpoints.truncate(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// A scratch copy of the session (same design and model, no history or log) to try
|
||||
/// commands on without touching this one.
|
||||
pub fn scratch(&self) -> Session {
|
||||
@@ -360,6 +433,45 @@ impl Session {
|
||||
clipboard: self.clipboard.clone(),
|
||||
section: None,
|
||||
analysis: None,
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
visibility: Visibility::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh UI caches when adopting a session or switching tabs, without discarding its
|
||||
/// already prepared world-space geometry. This does not change the document.
|
||||
pub fn invalidate_view(&mut self, previous_revision: u64) {
|
||||
let old = self.revision;
|
||||
self.revision = old.max(previous_revision) + 1;
|
||||
if let Ok(mut cache) = self.world_cache.lock()
|
||||
&& let Some((revision, _)) = cache.as_mut()
|
||||
&& *revision == old
|
||||
{
|
||||
*revision = self.revision;
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit commands evaluated on a scratch copy without losing this session's file,
|
||||
/// saved state, view state, checkpoints or earlier undo steps.
|
||||
pub fn accept_computed(&mut self, mut computed: Session) {
|
||||
let previous_revision = self.revision;
|
||||
self.doc = computed.doc;
|
||||
self.model = computed.model;
|
||||
self.world_cache = computed.world_cache;
|
||||
self.undo.append(&mut computed.undo);
|
||||
if self.undo.len() > MAX_UNDO {
|
||||
self.undo.drain(..self.undo.len() - MAX_UNDO);
|
||||
}
|
||||
self.redo = computed.redo;
|
||||
self.active_sketch = computed.active_sketch;
|
||||
self.selection = computed.selection;
|
||||
self.revision = computed.revision;
|
||||
self.invalidate_view(previous_revision);
|
||||
self.active_component = computed.active_component;
|
||||
self.pending_moves = computed.pending_moves;
|
||||
for message in computed.log {
|
||||
self.echo(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +539,8 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod boolean_regression_tests;
|
||||
#[cfg(test)]
|
||||
mod construct_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -140,7 +140,11 @@ impl Autosaver {
|
||||
let _ = fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
let id = format!("{}-{}", unix_now(), std::process::id());
|
||||
// Unique within the process too: an app started again in the same second (tests that
|
||||
// "crash" and restart) must not take over the crashed app's entry.
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let id = format!("{}-{}-{seq}", unix_now(), std::process::id());
|
||||
let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, &id, "lock")).map_err(io)?;
|
||||
lock.lock().map_err(io)?;
|
||||
Ok(Autosaver { dir: dir.to_path_buf(), id, interval, lock: Some(lock), last: None, last_at: Instant::now(), has_entry: false })
|
||||
@@ -225,6 +229,21 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_app_restarted_in_the_same_second_still_offers_the_crashed_ones_work() {
|
||||
let d = dir("restart");
|
||||
let mut s = Session::default();
|
||||
let mut a = Autosaver::new(&d, Duration::from_secs(3600)).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
assert!(a.save(&s).unwrap());
|
||||
drop(a);
|
||||
let b = Autosaver::new(&d, Duration::from_secs(3600)).unwrap();
|
||||
let found = orphans(&d);
|
||||
assert_eq!(found.len(), 1, "the crashed app's entry is offered");
|
||||
assert_ne!(found[0].id, b.id);
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_drops_the_entry_and_a_clean_close_leaves_nothing() {
|
||||
let d = dir("clean");
|
||||
|
||||
+233
-33
@@ -20,7 +20,24 @@ fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_extrude_from_a_chamfered_top_face() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 20}));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": 2}));
|
||||
let base = volume(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20]}}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
assert!(rel(volume(&mut s), base + PI * 64.0 * 5.0) < 5e-4);
|
||||
}
|
||||
|
||||
mod component_frames;
|
||||
mod mirror_planes;
|
||||
mod primitive_points;
|
||||
mod robustness;
|
||||
|
||||
/// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole.
|
||||
@@ -279,8 +296,6 @@ fn hostile_params_never_panic() {
|
||||
"chain",
|
||||
"at",
|
||||
"curve",
|
||||
"major",
|
||||
"minor",
|
||||
"minor_radius",
|
||||
"apex",
|
||||
"rho",
|
||||
@@ -298,32 +313,47 @@ fn hostile_params_never_panic() {
|
||||
"kind",
|
||||
"prefix",
|
||||
];
|
||||
let mut internal = Vec::new();
|
||||
for spec in command_specs() {
|
||||
if matches!(spec.id, "doc.open" | "file.save" | "file.save_as" | "file.export" | "file.save_mesh" | "sketch.export_dxf") {
|
||||
continue; // file system side effects are covered by their own tests
|
||||
}
|
||||
for v in hostile_values() {
|
||||
for k in keys {
|
||||
if k == "path" && spec.id.starts_with("parameters.") {
|
||||
continue; // parameter files: covered by their own tests (no stray files)
|
||||
}
|
||||
let mut s = Session::default();
|
||||
// A model with a sketch, a body and an active sketch, so most commands get far.
|
||||
let _ = s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10}));
|
||||
let _ = s.execute("sketch.create", &json!({"plane": "XY"}));
|
||||
let _ = s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [5, 5]}));
|
||||
let p = json!({ k: v.clone() });
|
||||
if let Err(EngineError::Internal(id, msg)) = s.execute(spec.id, &p) {
|
||||
internal.push(format!("{id} {p}: {msg}"));
|
||||
}
|
||||
// Whole-value hostility too.
|
||||
if let Err(EngineError::Internal(id, msg)) = s.execute(spec.id, &v) {
|
||||
internal.push(format!("{id} {v}: {msg}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Commands are independent; worker threads take them one at a time (their costs vary a lot).
|
||||
let ids: Vec<&str> = command_specs()
|
||||
.iter()
|
||||
.map(|spec| spec.id)
|
||||
.filter(|id| !matches!(*id, "doc.open" | "file.save" | "file.save_as" | "file.export" | "file.save_mesh" | "sketch.export_dxf")) // file system side effects are covered by their own tests
|
||||
.collect();
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
|
||||
let internal: Vec<String> = std::thread::scope(|scope| {
|
||||
let workers: Vec<_> = (0..threads)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
let mut internal = Vec::new();
|
||||
while let Some(&id) = ids.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
for v in hostile_values() {
|
||||
for k in keys {
|
||||
if k == "path" && id.starts_with("parameters.") {
|
||||
continue; // parameter files: covered by their own tests (no stray files)
|
||||
}
|
||||
let mut s = Session::default();
|
||||
// A model with a sketch, a body and an active sketch, so most commands get far.
|
||||
let _ = s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10}));
|
||||
let _ = s.execute("sketch.create", &json!({"plane": "XY"}));
|
||||
let _ = s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [5, 5]}));
|
||||
let p = json!({ k: v.clone() });
|
||||
if let Err(EngineError::Internal(id, msg)) = s.execute(id, &p) {
|
||||
internal.push(format!("{id} {p}: {msg}"));
|
||||
}
|
||||
// Whole-value hostility too.
|
||||
if let Err(EngineError::Internal(id, msg)) = s.execute(id, &v) {
|
||||
internal.push(format!("{id} {v}: {msg}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
internal
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
|
||||
});
|
||||
assert!(internal.is_empty(), "commands panicked:\n{}", internal.join("\n"));
|
||||
}
|
||||
|
||||
@@ -562,7 +592,7 @@ fn preview_of_an_edit() {
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
}
|
||||
|
||||
/// `operation: "auto"`: into a body cuts, out of a body joins, in free space makes a new body.
|
||||
/// `operation: "auto"`: into a body cuts; additions default to Join when a solid exists.
|
||||
#[test]
|
||||
fn extrude_auto_operation() {
|
||||
let mut s = Session::default();
|
||||
@@ -575,7 +605,7 @@ fn extrude_auto_operation() {
|
||||
// Pulled out, it joins.
|
||||
assert_eq!(auto_operation(&s, &json!({"face": [20, 15, 15], "distance": 5})), Some("join"));
|
||||
// A sketch on XY under the box: up goes into it (cut), down grows out of it (join), and a
|
||||
// profile beside the box makes a new body.
|
||||
// profile beside the box still defaults to Join (New Body remains explicit).
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [60, 5], "p1": [70, 15]}));
|
||||
@@ -583,11 +613,70 @@ fn extrude_auto_operation() {
|
||||
let at = |x: f64, d: f64| json!({"profiles": [{"point": [x, 10]}], "distance": d});
|
||||
assert_eq!(auto_operation(&s, &at(10.0, 8.0)), Some("cut"));
|
||||
assert_eq!(auto_operation(&s, &at(10.0, -8.0)), Some("join"));
|
||||
assert_eq!(auto_operation(&s, &at(65.0, 8.0)), Some("new"));
|
||||
assert_eq!(auto_operation(&s, &at(65.0, 8.0)), Some("join"));
|
||||
run(&mut s, "solid.extrude", json!({"profiles": [{"point": [10, 10]}], "distance": 8, "operation": "auto"}));
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 15.0 - 100.0 * 8.0) < 1e-6);
|
||||
}
|
||||
|
||||
/// Additive extrusions default to joining even when only a profile side touches material.
|
||||
#[test]
|
||||
fn auto_extrude_joins_side_contact_elbow_and_keeps_new_body_explicit() {
|
||||
for operation in ["auto", "new"] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 30}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [10, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 20], "p1": [30, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// The profile centre is outside the vertical arm. Contact is along its
|
||||
// y=10 side, not behind the centre along the extrusion's X direction.
|
||||
let p = json!({"distance": 10, "to": [0, 15, 25], "operation": operation});
|
||||
assert_eq!(auto_operation(&s, &p), Some("join"));
|
||||
run(&mut s, "solid.extrude", p);
|
||||
assert!((volume(&mut s) - 5000.0).abs() < 1e-6);
|
||||
let st = s.model.state();
|
||||
assert_eq!(st.bodies.len(), if operation == "auto" { 1 } else { 2 });
|
||||
if operation == "auto" {
|
||||
let body = &st.bodies.first().unwrap().body;
|
||||
assert!(body.validity().is_empty());
|
||||
assert_eq!(body.lumps().unwrap().len(), 1);
|
||||
assert_eq!(body.face_count(), 8, "the two flat elbow sides must be single faces");
|
||||
assert!(body.contains(geom::Vec3::new(5.0, 10.0, 25.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_extrude_joins_corner_screw_boss() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 30}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 4}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 1}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let p = json!({"profiles": [{"point": [12, 10]}], "distance": 5, "operation": "auto"});
|
||||
assert_eq!(auto_operation(&s, &p), Some("join"));
|
||||
run(&mut s, "solid.extrude", p);
|
||||
assert!(rel(volume(&mut s), 3000.0 + PI * 15.0 * 5.0) < 1e-3);
|
||||
let st = s.model.state();
|
||||
assert_eq!(st.bodies.len(), 1);
|
||||
let body = &st.bodies.first().unwrap().body;
|
||||
assert_eq!(body.lumps().unwrap().len(), 1);
|
||||
assert!(body.validity().is_empty());
|
||||
assert!(!body.contains(geom::Vec3::new(10.0, 10.0, 32.0)), "the screw hole stays open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_extrude_starts_new_body_in_empty_component() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
run(&mut s, "component.create", json!({"name": "Other"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 2}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(auto_operation(&s, &json!({"distance": 5})), Some("new"));
|
||||
assert_eq!(auto_operation(&s, &json!({"distance": 5, "participants": ["missing"]})), Some("new"));
|
||||
}
|
||||
|
||||
/// Measuring picked geometry: distance between faces, angle, edge length.
|
||||
#[test]
|
||||
fn measure_items() {
|
||||
@@ -637,10 +726,10 @@ 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
|
||||
// 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}");
|
||||
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}");
|
||||
@@ -811,6 +900,51 @@ fn pipe_scale_offset_bounding_and_materials() {
|
||||
assert!(s.execute("material.assign", &json!({"bodies": ["Cube"], "material": "unobtainium"})).is_err());
|
||||
}
|
||||
|
||||
/// Regression: deleting a pipe's path sketch left the pipe in the timeline ("unknown sketch").
|
||||
#[test]
|
||||
fn deleting_a_path_sketch_deletes_its_pipe() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]], "ids": ["l1"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let pipe = run(&mut s, "solid.pipe", json!({"path_sketch": "Path", "path": ["l1"], "diameter": 4}));
|
||||
let deps = run(&mut s, "timeline.dependents", json!({"feature": "Path"}));
|
||||
assert_eq!(deps["deleted_with_it"].as_array().map(Vec::len), Some(1), "{deps} (pipe {pipe})");
|
||||
let r = run(&mut s, "timeline.delete", json!({"features": ["Path"]}));
|
||||
assert_eq!(r["deleted"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
assert!(s.doc.features.is_empty(), "{:?}", s.doc.features.iter().map(|f| &f.name).collect::<Vec<_>>());
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()));
|
||||
}
|
||||
|
||||
/// Regression: batches were rolled back by undoing as many steps as the undo history grew,
|
||||
/// which is none once the history is full, leaving their sketches behind.
|
||||
#[test]
|
||||
fn checkpoint_restores_with_a_full_undo_history() {
|
||||
let mut s = Session::default();
|
||||
for i in 0..MAX_UNDO + 5 {
|
||||
run(&mut s, "parameters.add", json!({"name": format!("p{i}"), "expression": "1 mm"}));
|
||||
}
|
||||
assert_eq!(s.undo.len(), MAX_UNDO);
|
||||
let labels: Vec<String> = s.undo.iter().map(|u| u.label.clone()).collect();
|
||||
let doc = s.doc.clone();
|
||||
let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "TapHole"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 2}));
|
||||
assert!(s.active_sketch.is_some());
|
||||
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id}));
|
||||
assert!(Arc::ptr_eq(&s.doc, &doc) && s.active_sketch.is_none());
|
||||
assert!(s.doc.find_feature("TapHole").is_none());
|
||||
assert_eq!(s.undo.iter().map(|u| u.label.clone()).collect::<Vec<_>>(), labels);
|
||||
// Used up: a second restore is an error, not a jump somewhere else.
|
||||
assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err());
|
||||
// Forgetting one leaves the design alone.
|
||||
let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Kept"}));
|
||||
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id, "forget": true}));
|
||||
assert!(s.doc.find_feature("Kept").is_some());
|
||||
assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_occurrences_and_world_placement() {
|
||||
let mut s = Session::default();
|
||||
@@ -1575,6 +1709,35 @@ fn unitless_parameters_used_as_lengths_take_the_design_units() {
|
||||
assert!((max(0) - 50.8).abs() < 1e-9 && (max(1) - 50.8).abs() < 1e-9 && (max(2) - 10.0).abs() < 1e-9, "{m}");
|
||||
}
|
||||
|
||||
/// A chamfer on a tapered pocket survives parameter edits and undo/redo.
|
||||
#[test]
|
||||
fn chamfer_sloped_basin_rim_recomputes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30}));
|
||||
for (name, z, p0, p1) in [("Rim", 30, [20, 4], [80, 50]), ("Floor", 10, [30, 20], [70, 40])] {
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": z, "name": name}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
}
|
||||
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "Rim"}, {"sketch": "Floor"}], "operation": "cut"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "rim_chamfer", "expression": "1 mm"}));
|
||||
let base = volume(&mut s);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[50, 4, 30]], "distance": "rim_chamfer"}));
|
||||
let expected = |size: f64| {
|
||||
let wall_slope: f64 = (20.0 - 4.0) / (30.0 - 10.0); // horizontal run / vertical drop
|
||||
let drop = size / (1.0 + wall_slope.powi(2)).sqrt();
|
||||
base - 0.5 * size * drop * (60.0 - drop / 3.0)
|
||||
};
|
||||
assert!(rel(volume(&mut s), expected(1.0)) < 1e-8);
|
||||
let edited = run(&mut s, "parameters.change", json!({"name": "rim_chamfer", "expression": "2 mm"}));
|
||||
assert_eq!(edited["errors"].as_array().unwrap().len(), 0, "{edited}");
|
||||
assert!(rel(volume(&mut s), expected(2.0)) < 1e-8);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(rel(volume(&mut s), expected(1.0)) < 1e-8);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(rel(volume(&mut s), expected(2.0)) < 1e-8);
|
||||
}
|
||||
|
||||
/// Chamfers with two distances or a distance and an angle.
|
||||
#[test]
|
||||
fn unequal_chamfers() {
|
||||
@@ -1928,3 +2091,40 @@ fn drawing_on_existing_points_connects_to_them() {
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_ne!(si["status"], "conflict", "{si}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computed_import_preserves_file_and_earlier_undo_history() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
s.path = Some("existing.solvecraft".into());
|
||||
s.mark_saved();
|
||||
let saved = s.doc.clone();
|
||||
let mut computed = s.scratch();
|
||||
run(&mut computed, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [30, 0, 0]}));
|
||||
s.accept_computed(computed);
|
||||
assert_eq!(s.path.as_deref(), Some("existing.solvecraft"));
|
||||
assert!(s.is_dirty());
|
||||
assert_eq!(s.model.state().bodies.len(), 2);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(std::sync::Arc::ptr_eq(&saved, &s.doc));
|
||||
assert!(!s.is_dirty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopting_a_computed_component_keeps_its_warm_world_geometry() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Part"}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
|
||||
let mut computed = s.scratch();
|
||||
run(&mut computed, "solid.box", json!({"length":5,"width":5,"height":5,"corner":[30,0,0]}));
|
||||
let prepared = computed.world_state();
|
||||
for b in &prepared.bodies {
|
||||
let _ = b.mesh();
|
||||
}
|
||||
s.accept_computed(computed);
|
||||
assert!(std::sync::Arc::ptr_eq(&prepared, &s.world_state()));
|
||||
s.invalidate_view(100);
|
||||
assert!(std::sync::Arc::ptr_eq(&prepared, &s.world_state()));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Commands that take a plane accept construction planes by name or id, like the origin planes
|
||||
//! (user report: `solid.mirror` refused a construction plane although its docs list plane
|
||||
//! names).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::run;
|
||||
use crate::*;
|
||||
|
||||
fn min_x(s: &Session, body: &str) -> f64 {
|
||||
let b = s.model.state().body(body).map(|b| b.body.clone()).unwrap();
|
||||
solvecraft_kernel::measure(&b).unwrap().bbox.min.x
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_across_a_construction_plane_follows_it() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "w", "expression": "100 mm"}));
|
||||
let pl = run(&mut s, "construct.plane.offset", json!({"base": "YZ", "offset": "w / 2", "name": "Mid"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Ledger"}));
|
||||
let before = s.model.state().bodies.len();
|
||||
// By name: the copy lands at x = 90…100.
|
||||
run(&mut s, "solid.mirror", json!({"bodies": ["Ledger"], "plane": "Mid"}));
|
||||
let st = s.model.state();
|
||||
assert_eq!(st.bodies.len(), before + 1);
|
||||
let copy = st.bodies.last().map(|b| b.name.clone()).unwrap();
|
||||
assert!((min_x(&s, ©) - 90.0).abs() < 1e-6, "{}", min_x(&s, ©));
|
||||
// Parametric: moving the plane moves the copy.
|
||||
run(&mut s, "parameters.change", json!({"name": "w", "expression": "60 mm"}));
|
||||
assert!((min_x(&s, ©) - 50.0).abs() < 1e-6, "{}", min_x(&s, ©));
|
||||
// By id, and case-insensitively by name.
|
||||
let id = pl["feature"].as_u64().unwrap();
|
||||
run(&mut s, "solid.mirror", json!({"bodies": ["Ledger"], "plane": id.to_string()}));
|
||||
run(&mut s, "solid.mirror", json!({"bodies": ["Ledger"], "plane": "mid"}));
|
||||
let st = s.model.state();
|
||||
assert_eq!(st.bodies.len(), before + 3);
|
||||
for b in st.bodies.iter().skip(before) {
|
||||
assert!((min_x(&s, &b.name) - 50.0).abs() < 1e-6, "{}", b.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_mirror_planes_are_refused_with_the_choices() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
|
||||
let e = s.execute("solid.mirror", &json!({"bodies": ["B"], "plane": "Nope"})).unwrap_err().to_string();
|
||||
assert!(e.contains("Nope"), "{e}");
|
||||
let e = s.execute("solid.mirror", &json!({"bodies": ["B"], "plane": 5})).unwrap_err().to_string();
|
||||
assert!(e.contains("construction plane"), "{e}");
|
||||
// A plane object with an unreadable origin is refused, not put at the world origin.
|
||||
assert!(s.execute("solid.mirror", &json!({"bodies": ["B"], "plane": {"origin": ["x", 0, 0], "normal": [1, 0, 0]}})).is_err());
|
||||
// A feature that is not a plane is no plane.
|
||||
assert!(s.execute("solid.mirror", &json!({"bodies": ["B"], "plane": "Box1"})).is_err());
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
}
|
||||
|
||||
/// Section Analysis cuts along a construction plane too.
|
||||
#[test]
|
||||
fn section_by_a_construction_plane() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 7, "name": "Cut"}));
|
||||
let r = run(&mut s, "inspect.section", json!({"plane": "Cut"}));
|
||||
assert_eq!(r["section"]["origin"][2].as_f64(), Some(7.0), "{r}");
|
||||
assert!(s.execute("inspect.section", &json!({"plane": "Nope"})).is_err());
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Primitive placement points (`base`, `corner`, `center`) take expressions like their sizes do,
|
||||
//! follow parameter changes, and are refused when they cannot be read (user report: a cylinder
|
||||
//! with `base: ["basin_width/2", …]` silently landed at the origin).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::run;
|
||||
use crate::*;
|
||||
|
||||
fn bbox(s: &Session, body: &str) -> ([f64; 3], [f64; 3]) {
|
||||
let b = s.model.state().body(body).map(|b| b.body.clone()).unwrap();
|
||||
let m = solvecraft_kernel::measure(&b).unwrap();
|
||||
([m.bbox.min.x, m.bbox.min.y, m.bbox.min.z], [m.bbox.max.x, m.bbox.max.y, m.bbox.max.z])
|
||||
}
|
||||
|
||||
fn near(a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_base_expressions_follow_parameters() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "basin_width", "expression": "100 mm"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "base": ["basin_width/2", "10 mm", "-2"], "body_name": "Pin"}));
|
||||
let (lo, hi) = bbox(&s, "Pin");
|
||||
assert!(near(lo, [45.0, 5.0, -2.0]) && near(hi, [55.0, 15.0, 8.0]), "{lo:?} {hi:?}");
|
||||
// A parameter change moves it on recompute.
|
||||
run(&mut s, "parameters.change", json!({"name": "basin_width", "expression": "60 mm"}));
|
||||
let (lo, _) = bbox(&s, "Pin");
|
||||
assert!(near(lo, [25.0, 5.0, -2.0]), "{lo:?}");
|
||||
// And it survives a save and reopen.
|
||||
let text = s.doc.to_json();
|
||||
assert!(text.contains("basin_width/2"), "{text}");
|
||||
let s2 = Session::new(solvecraft_doc::Document::from_json(&text).unwrap());
|
||||
let (lo, _) = bbox(&s2, "Pin");
|
||||
assert!(near(lo, [25.0, 5.0, -2.0]), "{lo:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primitive_points_take_expressions() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "w", "expression": "40 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": ["w", 0, 0], "body_name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"length": "w", "width": 10, "height": 10, "center": [0, "w", 0], "body_name": "B"}));
|
||||
run(&mut s, "solid.sphere", json!({"radius": 2, "center": ["w * 2", 0, 0], "body_name": "C"}));
|
||||
run(&mut s, "solid.torus", json!({"major": 5, "minor": 1, "center": [0, 0, "w"], "body_name": "D"}));
|
||||
run(&mut s, "solid.coil", json!({"diameter": 10, "revolutions": 2, "pitch": 5, "section_size": 1, "base": ["-w", 0, 0], "body_name": "E"}));
|
||||
assert!(near(bbox(&s, "A").0, [40.0, 0.0, 0.0]));
|
||||
assert!(near(bbox(&s, "B").0, [-20.0, 35.0, -5.0]), "{:?}", bbox(&s, "B"));
|
||||
assert!(near(bbox(&s, "C").0, [78.0, -2.0, -2.0]));
|
||||
assert!(near(bbox(&s, "D").0, [-6.0, -6.0, 39.0]));
|
||||
assert!((bbox(&s, "E").0[0] + 45.5).abs() < 0.2, "{:?}", bbox(&s, "E"));
|
||||
run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
|
||||
assert!(near(bbox(&s, "A").0, [20.0, 0.0, 0.0]));
|
||||
assert!(near(bbox(&s, "B").0, [-10.0, 15.0, -5.0]), "{:?}", bbox(&s, "B"));
|
||||
assert!(near(bbox(&s, "C").0, [38.0, -2.0, -2.0]));
|
||||
assert!(near(bbox(&s, "D").0, [-6.0, -6.0, 19.0]));
|
||||
assert!((bbox(&s, "E").0[0] + 25.5).abs() < 0.2, "{:?}", bbox(&s, "E"));
|
||||
}
|
||||
|
||||
/// Points and axes that cannot be read are refused, never silently put at the origin.
|
||||
#[test]
|
||||
fn unreadable_primitive_points_are_refused() {
|
||||
let mut s = Session::default();
|
||||
let cases: Vec<(&str, Value)> = vec![
|
||||
("solid.cylinder", json!({"radius": 5, "height": 10, "base": ["nope/2", 0, 0]})),
|
||||
("solid.cylinder", json!({"radius": 5, "height": 10, "base": [1, 2]})),
|
||||
("solid.cylinder", json!({"radius": 5, "height": 10, "base": "XY"})),
|
||||
("solid.cylinder", json!({"radius": 5, "height": 10, "base": [1, 2, "5 deg"]})),
|
||||
("solid.cylinder", json!({"radius": 5, "height": 10, "axis": ["x", 0, 1]})),
|
||||
("solid.cylinder", json!({"radius": 5, "height": 10, "axis": [0, 0, 0]})),
|
||||
("solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [0, null, 0]})),
|
||||
("solid.box", json!({"length": 1, "width": 1, "height": 1, "center": {"x": 1}})),
|
||||
("solid.sphere", json!({"radius": 1, "center": ["a +", 0, 0]})),
|
||||
("solid.torus", json!({"major": 5, "minor": 1, "center": [1e300, 0, 0]})),
|
||||
("solid.coil", json!({"diameter": 10, "revolutions": 2, "pitch": 5, "section_size": 1, "base": [true, 0, 0]})),
|
||||
];
|
||||
for (cmd, p) in cases {
|
||||
let before = s.doc.features.len();
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p} should be refused");
|
||||
assert_eq!(s.doc.features.len(), before, "{cmd} {p} added a feature");
|
||||
}
|
||||
}
|
||||
|
||||
/// Renaming a parameter follows into placement points; deleting it is refused while a point
|
||||
/// uses it. Plain-number points are stored as numbers, as before.
|
||||
#[test]
|
||||
fn point_parameters_rename_and_store() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "off", "expression": "30 mm"}));
|
||||
run(&mut s, "solid.sphere", json!({"radius": 2, "center": ["off", 0, 0], "body_name": "S"}));
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [1.5, -2, 0], "body_name": "B"}));
|
||||
assert!(s.execute("parameters.delete", &json!({"name": "off"})).is_err());
|
||||
run(&mut s, "parameters.rename", json!({"name": "off", "new_name": "shift"}));
|
||||
run(&mut s, "parameters.change", json!({"name": "shift", "expression": "10 mm"}));
|
||||
assert!(near(bbox(&s, "S").0, [8.0, -2.0, -2.0]), "{:?}", bbox(&s, "S"));
|
||||
let v: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
let feats = v["features"].as_array().unwrap();
|
||||
assert_eq!(feats[0]["center"], json!(["shift", 0.0, 0.0]), "{v}");
|
||||
assert_eq!(feats[1]["corner"], json!([1.5, -2.0, 0.0]), "{v}");
|
||||
}
|
||||
|
||||
/// A primitive placed by an expression in a moved component lands in that component's frame
|
||||
/// and keeps following its parameter.
|
||||
#[test]
|
||||
fn point_expressions_in_a_moved_component() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "x0", "expression": "10 mm"}));
|
||||
let c = run(&mut s, "component.create", json!({"name": "Arm"}));
|
||||
let (cid, occ) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap());
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": occ, "translate": [0, 50, 0]}));
|
||||
run(&mut s, "component.activate", json!({"component": cid}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 1, "height": 2, "base": ["x0", 60, 0], "body_name": "Pin"}));
|
||||
let wlo = |s: &Session| s.world_state().body("Pin").unwrap().mesh().bounds().min;
|
||||
let lo = wlo(&s);
|
||||
assert!((lo.x - 9.0).abs() < 1e-3 && (lo.y - 59.0).abs() < 1e-3, "{lo:?}");
|
||||
run(&mut s, "parameters.change", json!({"name": "x0", "expression": "20 mm"}));
|
||||
let lo = wlo(&s);
|
||||
assert!((lo.x - 19.0).abs() < 1e-3 && (lo.y - 59.0).abs() < 1e-3, "{lo:?}");
|
||||
}
|
||||
@@ -41,10 +41,11 @@ pub mod colors {
|
||||
pub fn bounds(s: &Session) -> Aabb3 {
|
||||
let st = s.world_state();
|
||||
let mut b = Aabb3::EMPTY;
|
||||
for body in &st.bodies {
|
||||
let v = &s.visibility;
|
||||
for body in st.bodies.iter().filter(|x| !v.hidden_bodies.contains(&x.name)) {
|
||||
b = b.union(&body.mesh().bounds());
|
||||
}
|
||||
for ss in &st.sketches {
|
||||
for ss in st.sketches.iter().filter(|x| !v.hidden_sketches.contains(&x.feature)) {
|
||||
if let Some((lo, hi)) = ss.sketch.bounds() {
|
||||
for p in [lo, hi] {
|
||||
b.add(ss.plane.to_world(p));
|
||||
@@ -121,7 +122,7 @@ pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool {
|
||||
/// The scene for a headless render.
|
||||
pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
let st = s.world_state();
|
||||
let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), ..Default::default() };
|
||||
let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), clip: s.section.map(|(o, n)| (n, n.dot(o))), ..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 the ground plane (XY with Z up, XZ with Y up).
|
||||
@@ -151,7 +152,7 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
|
||||
sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, width: 1.6, on_top: false });
|
||||
}
|
||||
for body in &st.bodies {
|
||||
for body in st.bodies.iter().filter(|b| !s.visibility.hidden_bodies.contains(&b.name)) {
|
||||
let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name));
|
||||
let mesh = body.mesh();
|
||||
// The body's appearance (or imported colour) and faces with looks of their own, as in
|
||||
@@ -174,8 +175,7 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
}
|
||||
for ss in &st.sketches {
|
||||
let active = s.active_sketch == Some(ss.feature);
|
||||
// Finished sketches are hidden once a feature uses them, like Fusion's default.
|
||||
if !active && sketch_consumed(s, ss.feature) {
|
||||
if !sketch_shown(s, ss.feature) {
|
||||
continue;
|
||||
}
|
||||
for (pts, col, cons) in sketch_lines(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) {
|
||||
@@ -185,6 +185,13 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
sc
|
||||
}
|
||||
|
||||
/// Is a sketch drawn? The active one is; others unless hidden. Finished sketches are hidden
|
||||
/// once a feature uses them, like Fusion's default, unless shown one by one.
|
||||
pub fn sketch_shown(s: &Session, id: u64) -> bool {
|
||||
let v = &s.visibility;
|
||||
s.active_sketch == Some(id) || (!v.hidden_sketches.contains(&id) && (v.shown_sketches.contains(&id) || !sketch_consumed(s, id)))
|
||||
}
|
||||
|
||||
/// Is a sketch used by a later feature?
|
||||
pub fn sketch_consumed(s: &Session, id: u64) -> bool {
|
||||
use solvecraft_doc::FeatureKind;
|
||||
@@ -293,4 +300,19 @@ mod tests {
|
||||
let dark = img.rgba.chunks(4).filter(|p| p[0] < 70 && p[1] < 70 && p[2] < 75).count();
|
||||
assert!(dark > 40, "silhouette pixels: {dark}");
|
||||
}
|
||||
|
||||
/// A section shows in snapshots: the cut part is gone and the cut face is drawn.
|
||||
#[test]
|
||||
fn snapshots_show_the_section() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let cam = home_camera(&s);
|
||||
let whole = solvecraft_render::render(&scene(&s, &cam), &cam, 200, 140).rgba;
|
||||
s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap();
|
||||
let sc = scene(&s, &cam);
|
||||
assert!(sc.clip.is_some());
|
||||
let cut = solvecraft_render::render(&sc, &cam, 200, 140).rgba;
|
||||
let changed = whole.chunks(4).zip(cut.chunks(4)).filter(|(a, b)| a != b).count();
|
||||
assert!(changed > 500, "{changed} pixels changed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,71 @@ impl Mesh {
|
||||
pub fn face_edges(&self, f: u32) -> Vec<usize> {
|
||||
self.edge_faces.iter().enumerate().filter(|(_, fs)| fs.contains(&f)).map(|(i, _)| i).collect()
|
||||
}
|
||||
/// Pieces of one surface joined across hidden seams, including `face` itself.
|
||||
pub fn surface_patch(&self, face: usize) -> Vec<usize> {
|
||||
let mut patch = vec![face];
|
||||
let mut todo = vec![face];
|
||||
while let Some(f) = todo.pop() {
|
||||
for (ei, fs) in self.edge_faces.iter().enumerate() {
|
||||
if !self.seams.get(ei).copied().unwrap_or(false) || !fs.iter().any(|x| *x as usize == f) {
|
||||
continue;
|
||||
}
|
||||
for &next in fs {
|
||||
let next = next as usize;
|
||||
if !patch.contains(&next) {
|
||||
patch.push(next);
|
||||
todo.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
patch.sort_unstable();
|
||||
patch
|
||||
}
|
||||
|
||||
/// A complete circular rim may be split into several kernel edges. Keep its pieces
|
||||
/// together for picking, without joining other tangent curves or changing edge indices.
|
||||
pub fn circular_rim(&self, edge: usize) -> Vec<usize> {
|
||||
let single = vec![edge];
|
||||
let Some(p) = self.edges.get(edge).filter(|p| p.len() >= 3) else { return single };
|
||||
let (Some(&a), Some(&b), Some(&c)) = (p.first(), p.get(p.len() / 2), p.last()) else { return single };
|
||||
let (u, v) = (b - a, c - a);
|
||||
let n = u.cross(v);
|
||||
if !(n.len2() > u.len2() * v.len2() * 1e-12) {
|
||||
return single;
|
||||
}
|
||||
let center = a + (v.cross(n) * u.len2() + n.cross(u) * v.len2()) / (2.0 * n.len2());
|
||||
let radius = center.dist(a);
|
||||
let normal = n.normalized().unwrap_or(Vec3::ZERO);
|
||||
let tol = (radius * 1e-6).max(1e-9);
|
||||
let mut rim = self.tangent_chain(edge, 2f64.to_radians());
|
||||
if rim.len() < 2 || !radius.is_finite() {
|
||||
return single;
|
||||
}
|
||||
// All samples must lie on this circle, and every end must join exactly one other
|
||||
// piece. Open arcs, ellipses, and branching tangent chains stay separate.
|
||||
for &i in &rim {
|
||||
let Some(points) = self.edges.get(i) else { return single };
|
||||
if points.iter().any(|q| !q.is_finite() || (q.dist(center) - radius).abs() > tol || (*q - center).dot(normal).abs() > tol) {
|
||||
return single;
|
||||
}
|
||||
for end in [points.first(), points.last()].into_iter().flatten() {
|
||||
let joins = rim
|
||||
.iter()
|
||||
.filter(|&&j| j != i)
|
||||
.filter_map(|&j| self.edges.get(j))
|
||||
.flat_map(|q| [q.first(), q.last()])
|
||||
.flatten()
|
||||
.filter(|q| q.dist(*end) < tol)
|
||||
.count();
|
||||
if joins != 1 {
|
||||
return single;
|
||||
}
|
||||
}
|
||||
}
|
||||
rim.sort_unstable();
|
||||
rim
|
||||
}
|
||||
/// The edges reached from `e` through vertices where the next edge continues smoothly
|
||||
/// (angle between the end tangents below `max_angle`), including `e` itself.
|
||||
pub fn tangent_chain(&self, e: usize, max_angle: f64) -> Vec<usize> {
|
||||
|
||||
@@ -17,8 +17,8 @@ pub struct Plane {
|
||||
impl Plane {
|
||||
/// Top: sketch x = +X, y = +Y, normal +Z.
|
||||
pub const XY: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y };
|
||||
/// Front: sketch x = +X, y = +Z, normal −Y.
|
||||
pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
|
||||
/// Front: sketch x = +X, y = −Z, normal +Y.
|
||||
pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::new(0.0, 0.0, -1.0) };
|
||||
/// Right: sketch x = +Y, y = +Z, normal +X.
|
||||
pub const YZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::Y, y: Vec3::Z };
|
||||
|
||||
|
||||
@@ -28,10 +28,10 @@ fn raycast_hits_front_face() {
|
||||
#[test]
|
||||
fn planes() {
|
||||
assert_eq!(Plane::XY.normal(), Vec3::Z);
|
||||
assert_eq!(Plane::XZ.normal(), Vec3::new(0.0, -1.0, 0.0));
|
||||
assert_eq!(Plane::XZ.normal(), Vec3::Y);
|
||||
assert_eq!(Plane::YZ.normal(), Vec3::X);
|
||||
let p = Plane::XZ.to_world(Vec2::new(3.0, 4.0));
|
||||
assert_eq!(p, Vec3::new(3.0, 0.0, 4.0));
|
||||
assert_eq!(p, Vec3::new(3.0, 0.0, -4.0));
|
||||
assert_eq!(Plane::XZ.to_local(p), Vec2::new(3.0, 4.0));
|
||||
let hit = Plane::XY.offset(5.0).intersect_ray(Vec3::new(1.0, 2.0, 10.0), Vec3::new(0.0, 0.0, -2.0)).unwrap();
|
||||
assert!(hit.dist(Vec3::new(1.0, 2.0, 5.0)) < 1e-12);
|
||||
@@ -84,6 +84,46 @@ fn tangent_chains_follow_smooth_edges() {
|
||||
assert_eq!(m.face_edges(1), vec![1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_rims_group_only_closed_cocircular_edges() {
|
||||
let arc = |start: f64, sweep: f64, yscale: f64| -> Vec<Vec3> {
|
||||
(0..=32)
|
||||
.map(|i| {
|
||||
let a = start + sweep * i as f64 / 32.0;
|
||||
Vec3::new(10.0 * a.cos(), yscale * 10.0 * a.sin(), 5.0)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let pi = std::f64::consts::PI;
|
||||
let mut m = Mesh {
|
||||
positions: vec![Vec3::new(-10.0, -10.0, 5.0), Vec3::new(10.0, 10.0, 5.0)],
|
||||
edges: vec![arc(0.0, pi, 1.0), arc(2.0 * pi, -pi, 1.0)],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(m.circular_rim(0), vec![0, 1]);
|
||||
assert_eq!(m.circular_rim(1), vec![0, 1]);
|
||||
// A hidden seam cannot form a selectable rim.
|
||||
m.seams = vec![false, true];
|
||||
assert_eq!(m.circular_rim(0), vec![0]);
|
||||
m.seams.clear();
|
||||
m.edges[1] = arc(pi, pi / 2.0, 1.0);
|
||||
assert_eq!(m.circular_rim(0), vec![0]);
|
||||
// A closed, smooth ellipse is not a circle.
|
||||
m.edges = vec![arc(0.0, pi, 0.7), arc(pi, pi, 0.7)];
|
||||
assert_eq!(m.circular_rim(0), vec![0]);
|
||||
assert_eq!(m.circular_rim(99), vec![99]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_patches_cross_only_hidden_seams() {
|
||||
let mut m = Mesh { edge_faces: vec![vec![0, 1], vec![1, 2], vec![2, 3]], seams: vec![true, true, false], ..Default::default() };
|
||||
assert_eq!(m.surface_patch(0), vec![0, 1, 2]);
|
||||
assert_eq!(m.surface_patch(2), vec![0, 1, 2]);
|
||||
assert_eq!(m.surface_patch(3), vec![3]);
|
||||
m.seams.clear();
|
||||
assert_eq!(m.surface_patch(0), vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubic_and_conic_segments_measure_exactly() {
|
||||
use crate::{Loop2, Seg2, Vec2};
|
||||
|
||||
+22
-5
@@ -425,13 +425,19 @@ pub const MAX_STL_TRIANGLES: usize = 20_000_000;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solvecraft_doc::{FeatureKind, Model, Operation};
|
||||
use solvecraft_doc::{FeatureKind, Model, Operation, point_expr};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
fn model() -> (Document, Model) {
|
||||
let mut doc = Document::new("t");
|
||||
doc.add_feature(
|
||||
FeatureKind::Box { corner: Vec3::ZERO, length: "10".into(), width: "20".into(), height: "30".into(), operation: Operation::NewBody },
|
||||
FeatureKind::Box {
|
||||
corner: point_expr(Vec3::ZERO),
|
||||
length: "10".into(),
|
||||
width: "20".into(),
|
||||
height: "30".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -558,7 +564,13 @@ mod tests {
|
||||
fn threemf_round_trip() {
|
||||
let mut doc = Document::new("t");
|
||||
doc.add_feature(
|
||||
FeatureKind::Box { corner: Vec3::ZERO, length: "40".into(), width: "30".into(), height: "10".into(), operation: Operation::NewBody },
|
||||
FeatureKind::Box {
|
||||
corner: point_expr(Vec3::ZERO),
|
||||
length: "40".into(),
|
||||
width: "30".into(),
|
||||
height: "10".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -569,7 +581,7 @@ mod tests {
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Cylinder {
|
||||
base: Vec3::new(80.0, 0.0, 0.0),
|
||||
base: point_expr(Vec3::new(80.0, 0.0, 0.0)),
|
||||
axis: Vec3::Z,
|
||||
radius: "6".into(),
|
||||
height: "15".into(),
|
||||
@@ -579,7 +591,12 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Torus { center: Vec3::new(0.0, 80.0, 0.0), major: "12".into(), minor: "3".into(), operation: Operation::NewBody },
|
||||
FeatureKind::Torus {
|
||||
center: point_expr(Vec3::new(0.0, 80.0, 0.0)),
|
||||
major: "12".into(),
|
||||
minor: "3".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
+152
-12
@@ -1,8 +1,8 @@
|
||||
//! Edge fillets and chamfers as a local B-rep operation.
|
||||
//!
|
||||
//! Supported: a straight, convex edge between two planar faces whose two end vertices each
|
||||
//! touch exactly one more planar face perpendicular to the edge (the common case for edges of
|
||||
//! extruded and box-like parts). The two side faces are trimmed back, the end faces get an arc
|
||||
//! Supported locally: a straight edge between two planar faces whose two end vertices each
|
||||
//! touch exactly one more planar face. Fillets require perpendicular end faces; chamfers
|
||||
//! intersect their offset rails with the end planes, including sloped walls. The end faces get an arc
|
||||
//! (fillet) or a line (chamfer) at the corner, and a cylindrical (or planar) face is inserted.
|
||||
//! The result is exact. Edges meeting at an already blended corner, curved edges and other
|
||||
//! configurations are reported as not supported yet.
|
||||
@@ -131,6 +131,60 @@ fn setback(r: f64, phi: f64, shape: Shape) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// An extended corner edge must stay in the trimmed side face, not just its infinite
|
||||
/// supporting plane. Reject crossings and contact with any boundary after the old corner,
|
||||
/// including holes. Curved boundaries are sampled more finely than the clearance tolerance.
|
||||
pub(crate) fn extension_fits(face: &mt::Face, p: Vec3, q: Vec3, tol: f64) -> bool {
|
||||
use mt::{BoundedCurve, ParameterDivision1D};
|
||||
// truck requires a curve sampling tolerance of at least 1e-6. Keep the
|
||||
// boundary clearance four times larger than the chord approximation error.
|
||||
let tol = tol.max(4e-6);
|
||||
let (Some(x), Some(n)) = ((q - p).normalized(), plane_normal(face)) else { return false };
|
||||
let y = n.cross(x);
|
||||
let len = p.dist(q);
|
||||
if !len.is_finite() || len <= tol {
|
||||
return false;
|
||||
}
|
||||
let mut inside = false;
|
||||
for edge in face.edge_iter() {
|
||||
let points = if matches!(edge.curve(), mt::Curve::Line(_)) {
|
||||
vec![edge.front().point(), edge.back().point()]
|
||||
} else {
|
||||
let curve = edge.curve();
|
||||
curve.parameter_division(curve.range_tuple(), tol * 0.25).1
|
||||
};
|
||||
if points.len() < 2 || points.len() > 100_000 {
|
||||
return false;
|
||||
}
|
||||
for pair in points.windows(2) {
|
||||
let [a, b] = pair else { return false };
|
||||
let a = from_p3(*a) - p;
|
||||
let b = from_p3(*b) - p;
|
||||
let (ax, ay, bx, by) = (a.dot(x), a.dot(y), b.dot(x), b.dot(y));
|
||||
if ![ax, ay, bx, by].iter().all(|v| v.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
// Contact/overlap with the extension, except its original endpoint.
|
||||
if (ay.abs() <= tol && by.abs() <= tol && ax.max(bx) > tol && ax.min(bx) <= len + tol)
|
||||
|| [(ax, ay), (bx, by)].iter().any(|(u, v)| *u > tol && *u <= len + tol && v.abs() <= tol)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (ay > 0.0) != (by > 0.0) {
|
||||
let hit = ax - ay * (bx - ax) / (by - ay);
|
||||
if hit > tol && hit <= len + tol {
|
||||
return false;
|
||||
}
|
||||
// Even-odd containment of q over all loops (outer boundary and holes).
|
||||
if hit > len {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn blend_geometry(
|
||||
solid: &Solid,
|
||||
@@ -153,6 +207,8 @@ fn blend_geometry(
|
||||
g: usize,
|
||||
e1: mt::Edge,
|
||||
e2: mt::Edge,
|
||||
a: Vec3,
|
||||
b: Vec3,
|
||||
}
|
||||
let mut ends: Vec<End> = Vec::new();
|
||||
for (v, p) in [(&v0, p0), (&v1, p1)] {
|
||||
@@ -161,9 +217,19 @@ fn blend_geometry(
|
||||
let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) };
|
||||
let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
|
||||
if ng.dot(d).abs() < 1.0 - 1e-9 {
|
||||
if shape == Shape::Round && ng.dot(d).abs() < 1.0 - 1e-9 {
|
||||
return Err(unsupported("the faces at the edge ends must be perpendicular to the edge"));
|
||||
}
|
||||
if ng.dot(d).abs() < 1e-9 {
|
||||
return Err(unsupported("an end face parallel to the edge"));
|
||||
}
|
||||
// Each rail is parallel to the original edge and set back by s in its side
|
||||
// face. Slide its endpoint along d until it lies on the actual end plane.
|
||||
let endpoint = |t: Vec3| {
|
||||
let q = p + t * s;
|
||||
if shape == Shape::Flat { q - d * (ng.dot(t) * s / ng.dot(d)) } else { q }
|
||||
};
|
||||
let (a, b) = (endpoint(t1), endpoint(t2));
|
||||
// Edges at v shared by g with f1 and with f2.
|
||||
let shared = |fi: usize| -> Result<mt::Edge> {
|
||||
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
@@ -172,26 +238,46 @@ fn blend_geometry(
|
||||
.ok_or_else(|| unsupported("unexpected corner topology"))
|
||||
};
|
||||
let (e1, e2) = (shared(i1)?, shared(i2)?);
|
||||
for (e, t) in [(&e1, t1), (&e2, t2)] {
|
||||
for (e, q, t, fi) in [(&e1, a, t1, i1), (&e2, b, t2, i2)] {
|
||||
if !matches!(e.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("the edges at the corners must be straight"));
|
||||
}
|
||||
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 <= s + tol {
|
||||
let delta = other - p;
|
||||
if shape == Shape::Round {
|
||||
// Fillets retain the square-end check: the original corner edge
|
||||
// must run in the setback direction and have enough length left.
|
||||
let along = delta.dot(t);
|
||||
if (delta - t * along).len() > tol * 100.0 + 1e-7 || along <= s + tol {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let len = delta.len();
|
||||
let dir = delta.normalized().ok_or_else(|| unsupported("zero-length corner edge"))?;
|
||||
let along = (q - p).dot(dir);
|
||||
// An inner rim extends the neighbouring rim edge away from its other
|
||||
// vertex. This is valid only within the trimmed side face.
|
||||
if (q - p - dir * along).len() > tol * 100.0 + 1e-7
|
||||
|| along >= len - tol
|
||||
|| (along < 0.0 && !faces.get(fi).is_some_and(|f| extension_fits(f, p, q, tol)))
|
||||
{
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
}
|
||||
ends.push(End { v: v.clone(), g, e1, e2 });
|
||||
ends.push(End { v: v.clone(), g, e1, e2, a, b });
|
||||
}
|
||||
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
|
||||
if end0.g == end1.g {
|
||||
return Err(unsupported("both ends touch the same face"));
|
||||
}
|
||||
let a0 = builder::vertex(p3(p0 + t1 * s));
|
||||
let b0 = builder::vertex(p3(p0 + t2 * s));
|
||||
let a1 = builder::vertex(p3(p1 + t1 * s));
|
||||
let b1 = builder::vertex(p3(p1 + t2 * s));
|
||||
if (end1.a - end0.a).dot(d) <= tol || (end1.b - end0.b).dot(d) <= tol {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
let a0 = builder::vertex(p3(end0.a));
|
||||
let b0 = builder::vertex(p3(end0.b));
|
||||
let a1 = builder::vertex(p3(end1.a));
|
||||
let b1 = builder::vertex(p3(end1.b));
|
||||
// Corner curves on the end faces (A → B) and the blend surface. The arc centre is r from
|
||||
// both faces (inside the material for a convex edge, outside for a concave one); the arc's
|
||||
// middle is the point nearest the old edge.
|
||||
@@ -699,6 +785,14 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
|
||||
return Ok(r);
|
||||
}
|
||||
}
|
||||
// At a pocket rim the cutters cannot extend beyond the selected edges. Rebuild
|
||||
// the corners locally, allowing adjacent chamfers to reach different depths
|
||||
// on their common wall seam.
|
||||
if edges.len() > 1
|
||||
&& let Ok(result) = crate::blend_corner::chamfer(body, edges, distance)
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
// Coplanar neighbours as one face (as after a join), so corners look as they should.
|
||||
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)? {
|
||||
@@ -861,3 +955,49 @@ fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, b
|
||||
let region = solvecraft_geom::Region2 { outer: lp, holes: vec![] };
|
||||
crate::build::extrude(&plane, &[region], 0.0, len + 2.0 * ext)?.pop().ok_or_else(|| KernelError::Failed("chamfer tool".into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solvecraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
|
||||
#[test]
|
||||
fn chamfer_extension_cannot_cross_a_hole_and_reenter_the_face() {
|
||||
let rectangle = |a, b, c, d| Loop2::polygon(&[Vec2::new(a, b), Vec2::new(c, b), Vec2::new(c, d), Vec2::new(a, d)]);
|
||||
let region =
|
||||
Region2 { outer: rectangle(0.0, 0.0, 10.0, 10.0), holes: vec![rectangle(4.0, 4.0, 6.0, 6.0), Loop2::circle(Vec2::new(4.0, 1.5), 0.25)] };
|
||||
for plane in [Plane::XY, Plane::XZ] {
|
||||
let face = crate::build::face(&plane, ®ion).unwrap();
|
||||
let p = plane.to_world(Vec2::new(4.0, 4.0));
|
||||
let fits = |y| extension_fits(&face, p, plane.to_world(Vec2::new(4.0, y)), 1e-6);
|
||||
assert!(fits(2.0));
|
||||
assert!(!fits(1.5)); // endpoint in the circular hole
|
||||
assert!(!fits(0.5)); // endpoint in material, but the extension crossed the hole
|
||||
assert!(!fits(-1e8)); // an arbitrarily long slide cannot escape the face
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_chamfer_intersects_sloped_end_planes() {
|
||||
for slope in [-0.5, 0.5] {
|
||||
let region = Region2 {
|
||||
outer: Loop2::polygon(&[Vec2::ZERO, Vec2::new(40.0, 0.0), Vec2::new(40.0 - slope * 20.0, 20.0), Vec2::new(slope * 20.0, 20.0)]),
|
||||
holes: vec![],
|
||||
};
|
||||
let body = crate::extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().pop().unwrap();
|
||||
let pick = [Vec3::new(20.0, 0.0, 20.0)];
|
||||
let result = blend(&body, &pick, 2.0, Shape::Flat, "chamfer").unwrap();
|
||||
let removed = 2.0 * (40.0 - 2.0 * slope * 2.0 / 3.0);
|
||||
let actual = crate::measure(&body).unwrap().volume - crate::measure(&result).unwrap().volume;
|
||||
assert!((actual - removed).abs() < 1e-6, "{actual} vs {removed}");
|
||||
assert!(result.validity().is_empty());
|
||||
// The new corner vertices lie on both original end planes.
|
||||
for point in [Vec3::new(slope * 2.0, 2.0, 20.0), Vec3::new(40.0 - slope * 2.0, 2.0, 20.0)] {
|
||||
assert!(result.solid.vertex_iter().any(|v| vtx(&v).dist(point) < 1e-8));
|
||||
}
|
||||
assert!(blend(&body, &pick, 25.0, Shape::Flat, "chamfer").is_err());
|
||||
// Oblique fillet caps need elliptical trims, which this local path does not build.
|
||||
assert!(blend(&body, &pick, 2.0, Shape::Round, "fillet").is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Fillets with a radius per edge, and the corners where filleted edges meet.
|
||||
//!
|
||||
//! The edges are straight and convex, and every face at their end vertices is planar with three
|
||||
//! edges meeting at each such vertex (box-like corners). The faces there are rebuilt around the
|
||||
//! edges meeting at each such vertex, including reflex corners of pocket rims. The faces there are rebuilt around the
|
||||
//! blends, faces away from them are kept. At a vertex:
|
||||
//! - one filleted edge ends on the third face: the blend ends in that face's section of the
|
||||
//! cylinder (an exact arc or ellipse);
|
||||
@@ -38,7 +38,7 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result<Body> {
|
||||
let mut first: Option<KernelError> = None;
|
||||
// Earlier rounds at the edges' ends come off and are blended again with the new edges.
|
||||
for _ in 0..4 {
|
||||
let err = match build(&cur, &sel) {
|
||||
let err = match build(&cur, &sel, true) {
|
||||
Ok(b) => return Ok(b),
|
||||
Err(e) => e,
|
||||
};
|
||||
@@ -54,6 +54,14 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result<Body> {
|
||||
Err(first.unwrap_or_else(|| unsupported("these fillets")))
|
||||
}
|
||||
|
||||
/// Local equal-distance chamfers, including pocket rims where adjacent walls have
|
||||
/// different slopes. The planar strips trim each other at each selected corner.
|
||||
pub(crate) fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
|
||||
let cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
|
||||
let selected: Vec<_> = edges.iter().map(|p| (*p, distance)).collect();
|
||||
build(&cur, &selected, false)
|
||||
}
|
||||
|
||||
/// A face of the shell being rebuilt, or a new face.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Owner {
|
||||
@@ -112,7 +120,7 @@ fn edge(a: &mt::Vertex, b: &mt::Vertex, c: mt::Curve) -> Result<mt::Edge> {
|
||||
/// along `u`.
|
||||
fn hit(v: Vec3, u: Vec3, t: Vec3, s: f64) -> Result<Vec3> {
|
||||
let k = u.dot(t);
|
||||
if k < 1e-6 {
|
||||
if k.abs() < 1e-6 {
|
||||
return Err(unsupported("a fillet that runs along its neighbouring edge"));
|
||||
}
|
||||
Ok(v + u * (s / k))
|
||||
@@ -122,8 +130,8 @@ fn hit(v: Vec3, u: Vec3, t: Vec3, s: f64) -> Result<Vec3> {
|
||||
/// directions `t1`, `t2`, setbacks `s1`, `s2`) cross.
|
||||
fn meet(v: Vec3, (u1, t1, s1): (Vec3, Vec3, f64), (u2, t2, s2): (Vec3, Vec3, f64)) -> Result<Vec3> {
|
||||
let (k1, k2) = (u1.dot(t2), u2.dot(t1));
|
||||
if k1 < 1e-6 || k2 < 1e-6 {
|
||||
return Err(unsupported("fillets of edges that meet at a reflex corner"));
|
||||
if k1.abs() < 1e-6 || k2.abs() < 1e-6 {
|
||||
return Err(unsupported("fillets of nearly parallel edges at a corner"));
|
||||
}
|
||||
Ok(v + u1 * (s2 / k1) + u2 * (s1 / k2))
|
||||
}
|
||||
@@ -174,7 +182,7 @@ fn connect(from: &mt::Vertex, to: &mt::Vertex, pool: &[(mt::Edge, Vec<Owner>)],
|
||||
Err(KernelError::Failed("fillet corner: a boundary does not close".into()))
|
||||
}
|
||||
|
||||
fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result<Body> {
|
||||
fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
|
||||
let size = cur.size();
|
||||
let tol = (size * 2e-3).max(1e-3);
|
||||
let solid = cur.deep_copy();
|
||||
@@ -252,16 +260,20 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result<Body> {
|
||||
return Err(unsupported("the faces at the edge are tangent"));
|
||||
}
|
||||
let r = radius.get(id).copied().unwrap_or(1.0);
|
||||
let s = r / (alpha / 2.0).tan();
|
||||
let s = if round { r / (alpha / 2.0).tan() } else { r };
|
||||
let w = (alpha / 2.0).sin();
|
||||
// The cylinder, well past both ends (sections on oblique end faces reach beyond them).
|
||||
let m = 4.0 * (s + r);
|
||||
let (q0, q1) = (p0 - d * m, p1 + d * m);
|
||||
let ctrl = vec![vec![h(q0 + ta * s, 1.0), h(q1 + ta * s, 1.0)], vec![h(q0, w), h(q1, w)], vec![h(q0 + tb * s, 1.0), h(q1 + tb * s, 1.0)]];
|
||||
let surf = mt::Surface::NurbsSurface(mt::NurbsSurface::new(mt::BSplineSurface::new(
|
||||
(mt::KnotVec::bezier_knot(2), mt::KnotVec::bezier_knot(1)),
|
||||
ctrl,
|
||||
)));
|
||||
let surf = if round {
|
||||
mt::Surface::NurbsSurface(mt::NurbsSurface::new(mt::BSplineSurface::new(
|
||||
(mt::KnotVec::bezier_knot(2), mt::KnotVec::bezier_knot(1)),
|
||||
ctrl,
|
||||
)))
|
||||
} else {
|
||||
mt::Surface::Plane(mt::Plane::new(p3(q0 + ta * s), p3(q1 + ta * s), p3(q0 + tb * s)))
|
||||
};
|
||||
let mid = (p0 + p1) * 0.5;
|
||||
let axis = mid + ta * s - na * r;
|
||||
let out = ((ta + tb) * -1.0).normalized().unwrap_or(na);
|
||||
@@ -311,20 +323,33 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result<Body> {
|
||||
let rest: Vec<(mt::EdgeID, Vec3)> = ar.edges.iter().filter(|(id, _)| !sels.contains_key(id)).copied().collect();
|
||||
chosen.sort_by_key(|(id, _)| order.iter().position(|x| x == id));
|
||||
let sel = |id: mt::EdgeID| sels.get(&id).ok_or_else(|| KernelError::Failed("fillet".into()));
|
||||
// At an inner rim a contact line can extend the next edge backwards.
|
||||
// Its new endpoint must remain in the trimmed face, including its holes.
|
||||
let check_extension = |q: Vec3, fi: usize| -> Result<Vec3> {
|
||||
let face = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
if !crate::blend::extension_fits(face, v, q, size * 1e-7) {
|
||||
return Err(unsupported("a fillet larger than the faces next to it"));
|
||||
}
|
||||
Ok(q)
|
||||
};
|
||||
let hit = |u: Vec3, t: Vec3, s: f64, fi: usize| -> Result<Vec3> {
|
||||
let q = hit(v, u, t, s)?;
|
||||
if u.dot(t) < 0.0 { check_extension(q, fi) } else { Ok(q) }
|
||||
};
|
||||
let list = pool.entry(*vid).or_default();
|
||||
match (&chosen[..], &rest[..]) {
|
||||
([(e1, _)], [(e2, u2), (e3, u3)]) => {
|
||||
let s1 = sel(*e1)?;
|
||||
let (f12, f31) = (face_with(*e1, *e2)?, face_with(*e1, *e3)?);
|
||||
let f23 = face_with(*e2, *e3)?;
|
||||
let p2 = hit(v, *u2, s1.t(f12), s1.s)?;
|
||||
let p3_ = hit(v, *u3, s1.t(f31), s1.s)?;
|
||||
let p2 = hit(*u2, s1.t(f12), s1.s, f12)?;
|
||||
let p3_ = hit(*u3, s1.t(f31), s1.s, f31)?;
|
||||
let (a, b) = (builder::vertex(p3(p2)), builder::vertex(p3(p3_)));
|
||||
endpt.insert((*e2, *vid, None), a.clone());
|
||||
endpt.insert((*e1, *vid, Some(f12)), a.clone());
|
||||
endpt.insert((*e3, *vid, None), b.clone());
|
||||
endpt.insert((*e1, *vid, Some(f31)), b.clone());
|
||||
let c = edge(&a, &b, mt::Curve::NurbsCurve(conic(p2, v, p3_, s1.w)))?;
|
||||
let c = if round { edge(&a, &b, mt::Curve::NurbsCurve(conic(p2, v, p3_, s1.w)))? } else { builder::line(&a, &b) };
|
||||
list.push((c, vec![Owner::Face(f23), Owner::Cyl(*e1)]));
|
||||
}
|
||||
([(ea, ua), (eb, ub)], [(e3, u3)]) => {
|
||||
@@ -332,14 +357,19 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result<Body> {
|
||||
let fab = face_with(*ea, *eb)?;
|
||||
let (fb3, fa3) = (face_with(*eb, *e3)?, face_with(*ea, *e3)?);
|
||||
let q = meet(v, (*ua, sa.t(fab), sa.s), (*ub, sb.t(fab), sb.s))?;
|
||||
if ua.dot(sb.t(fab)) < 0.0 || ub.dot(sa.t(fab)) < 0.0 {
|
||||
check_extension(q, fab)?;
|
||||
}
|
||||
// Where each cylinder touches the third edge.
|
||||
let xb = hit(v, *u3, sb.t(fb3), sb.s)?;
|
||||
let xa = hit(v, *u3, sa.t(fa3), sa.s)?;
|
||||
let xb = hit(*u3, sb.t(fb3), sb.s, fb3)?;
|
||||
let xa = hit(*u3, sa.t(fa3), sa.s, fa3)?;
|
||||
let qv = builder::vertex(p3(q));
|
||||
endpt.insert((*ea, *vid, Some(fab)), qv.clone());
|
||||
endpt.insert((*eb, *vid, Some(fab)), qv.clone());
|
||||
let (la, lb) = ((xa - v).len(), (xb - v).len());
|
||||
let crease_to = |end: Vec3| -> Result<mt::Curve> { crease(sa, sb, q, end, size) };
|
||||
let crease_to = |end: Vec3| -> Result<mt::Curve> {
|
||||
if round { crease(sa, sb, q, end, size) } else { Ok(mt::Curve::Line(mt::Line(p3(q), p3(end)))) }
|
||||
};
|
||||
if (la - lb).abs() <= size * 1e-9 {
|
||||
let xv = builder::vertex(p3(xb));
|
||||
endpt.insert((*e3, *vid, None), xv.clone());
|
||||
@@ -354,16 +384,22 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result<Body> {
|
||||
if lb > la { (sb, sa, *ua, fb3, fa3, *eb, *ea, xb) } else { (sa, sb, *ub, fa3, fb3, *ea, *eb, xa) };
|
||||
// The big cylinder's section by the small one's end face: from the third
|
||||
// edge to the small edge's line, where it crosses the small contact line.
|
||||
let z = hit(v, us, big.t(fab), big.s)?;
|
||||
let z = hit(us, big.t(fab), big.s, fab)?;
|
||||
let mut sec = conic(x, v, z, big.w);
|
||||
let tt = small.t(fsmall3);
|
||||
let c0 = (x - v).dot(tt) - small.s;
|
||||
let c1 = big.w * (0.0 - small.s);
|
||||
let c2 = (z - v).dot(tt) - small.s;
|
||||
let t = bernstein_root(c0, c1, c2).ok_or_else(|| unsupported("fillets this different in size at one corner"))?;
|
||||
let y = {
|
||||
// A chamfer section is a line, not the cylinder's conic.
|
||||
// Where it meets the other strip's contact line is also
|
||||
// where the crease ends; the remainder trims that wall.
|
||||
let t = if round { bernstein_root(c0, c1, c2) } else { Some(c0 / (c0 - c2)).filter(|t| *t > 0.0 && *t < 1.0) }
|
||||
.ok_or_else(|| unsupported("blends this different in size at one corner"))?;
|
||||
let y = if round {
|
||||
use mt::ParametricCurve;
|
||||
crate::body::from_p3(sec.subs(t))
|
||||
} else {
|
||||
x + (z - x) * t
|
||||
};
|
||||
{
|
||||
use mt::Cut;
|
||||
@@ -374,13 +410,16 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)]) -> Result<Body> {
|
||||
endpt.insert((*e3, *vid, None), xv.clone());
|
||||
endpt.insert((eb_, *vid, Some(fbig3)), xv.clone());
|
||||
endpt.insert((es, *vid, Some(fsmall3)), yv.clone());
|
||||
let piece = edge(&xv, &yv, mt::Curve::NurbsCurve(sec))?;
|
||||
let piece = if round { edge(&xv, &yv, mt::Curve::NurbsCurve(sec))? } else { builder::line(&xv, &yv) };
|
||||
list.push((piece, vec![Owner::Face(fsmall3), Owner::Cyl(eb_)]));
|
||||
let c = edge(&qv, &yv, crease_to(y)?)?;
|
||||
list.push((c, vec![Owner::Cyl(*ea), Owner::Cyl(*eb)]));
|
||||
}
|
||||
}
|
||||
([(e1, u1), (e2, u2), (e3, u3)], []) => {
|
||||
if !round {
|
||||
return Err(unsupported("three chamfers at one corner in the local blend"));
|
||||
}
|
||||
let (s1, s2, s3) = (sel(*e1)?, sel(*e2)?, sel(*e3)?);
|
||||
let (f12, f23, f31) = (face_with(*e1, *e2)?, face_with(*e2, *e3)?, face_with(*e3, *e1)?);
|
||||
let q12 = meet(v, (*u1, s1.t(f12), s1.s), (*u2, s2.t(f12), s2.s))?;
|
||||
@@ -1086,4 +1125,107 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(fillet_radii(&l, &[(Vec3::new(10.0, 10.0, 10.0), 1.0), (c, 2.0)]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sloped_basin_rim_fillets() {
|
||||
use solvecraft_geom::{Loop2, Plane, Vec2};
|
||||
let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
|
||||
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
|
||||
let pocket =
|
||||
crate::loft(&[(Plane::XY.offset(30.0), rectangle(20.0, 4.0, 80.0, 50.0)), (Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0))])
|
||||
.unwrap();
|
||||
let basin = crate::boolean(&slab, &pocket, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
let picks = [Vec3::new(50.0, 4.0, 30.0), Vec3::new(80.0, 27.0, 30.0), Vec3::new(50.0, 50.0, 30.0), Vec3::new(20.0, 27.0, 30.0)];
|
||||
for count in 1..=4 {
|
||||
let result = fillet(&basin, &picks[..count], 2.0).unwrap();
|
||||
assert!(result.validity().is_empty());
|
||||
assert_eq!(result.unmeshed_faces(), 0);
|
||||
assert!(vol(&result) < vol(&basin));
|
||||
let mesh = result.tessellate(0.01).unwrap();
|
||||
assert!(mesh.contains(Vec3::new(50.0, 2.0, 27.0)));
|
||||
assert!(!mesh.contains(Vec3::new(50.0, 3.9, 29.99)));
|
||||
// The small blend removes only a strip at the rim, and never escapes
|
||||
// the slab. Topology checks alone do not catch crossed faces.
|
||||
assert!(vol(&basin) - vol(&result) < 2.0_f64.powi(2) * (60.0 + 46.0) * 2.0);
|
||||
for v in result.solid.vertex_iter() {
|
||||
let p = vtx(&v);
|
||||
assert!(p.x >= 0.0 && p.x <= 100.0 && p.y >= 0.0 && p.y <= 60.0 && p.z >= 0.0 && p.z <= 30.0 + 1e-8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sloped_rim_fillets_cannot_extend_outside_the_material() {
|
||||
use solvecraft_geom::{Loop2, Plane, Vec2};
|
||||
for margin in [0.5, 1.0] {
|
||||
let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
|
||||
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
|
||||
let pocket = crate::loft(&[
|
||||
(Plane::XY.offset(30.0), rectangle(20.0, margin, 80.0, 50.0)),
|
||||
(Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0)),
|
||||
])
|
||||
.unwrap();
|
||||
let basin = crate::boolean(&slab, &pocket, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
let picks = [Vec3::new(50.0, margin, 30.0), Vec3::new(80.0, 27.0, 30.0), Vec3::new(50.0, 50.0, 30.0), Vec3::new(20.0, 27.0, 30.0)];
|
||||
for count in [1, 2, 4] {
|
||||
assert!(fillet(&basin, &picks[..count], 4.0).is_err());
|
||||
assert!(fillet(&basin, &picks[..count], margin * 0.5).is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfers_meet_on_unequal_sloped_rim_walls() {
|
||||
use solvecraft_geom::{Loop2, Plane, Vec2};
|
||||
for margin in [1.0, 4.0] {
|
||||
let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
|
||||
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
|
||||
let pocket = crate::loft(&[
|
||||
(Plane::XY.offset(30.0), rectangle(20.0, margin, 80.0, 50.0)),
|
||||
(Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0)),
|
||||
])
|
||||
.unwrap();
|
||||
let basin = crate::boolean(&slab, &pocket, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
let picks = [Vec3::new(50.0, margin, 30.0), Vec3::new(80.0, 27.0, 30.0), Vec3::new(50.0, 50.0, 30.0), Vec3::new(20.0, 27.0, 30.0)];
|
||||
for count in [2, 3, 4] {
|
||||
if margin == 1.0 {
|
||||
assert!(crate::chamfer(&basin, &picks[..count], 2.0).is_err());
|
||||
assert!(crate::chamfer(&basin, &picks[..count], 1.0).is_err());
|
||||
}
|
||||
let distance = if margin == 1.0 { 0.5 } else { 2.0 };
|
||||
let result = crate::chamfer(&basin, &picks[..count], distance).unwrap();
|
||||
assert!(result.validity().is_empty());
|
||||
assert_eq!(result.unmeshed_faces(), 0);
|
||||
assert!(vol(&result) < vol(&basin));
|
||||
assert!(vol(&basin) - vol(&result) < distance.powi(2) * (60.0 + 50.0 - margin));
|
||||
let reverse: Vec<_> = picks[..count].iter().copied().rev().collect();
|
||||
let reversed = crate::chamfer(&basin, &reverse, distance).unwrap();
|
||||
assert!((vol(&result) - vol(&reversed)).abs() < 1e-6);
|
||||
// These are exact planes, with every boundary on its owning face.
|
||||
for f in result.solid.face_iter() {
|
||||
let Some(n) = plane_normal(f) else { continue };
|
||||
let origin = vtx(&f.vertex_iter().next().unwrap());
|
||||
for v in f.vertex_iter() {
|
||||
assert!((vtx(&v) - origin).dot(n).abs() < 1e-7);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_slope_rim_loops_cannot_escape_the_face() {
|
||||
use solvecraft_geom::{Loop2, Plane, Vec2};
|
||||
let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
|
||||
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
|
||||
let pocket =
|
||||
crate::loft(&[(Plane::XY.offset(30.0), rectangle(20.0, 1.0, 80.0, 50.0)), (Plane::XY.offset(10.0), rectangle(30.0, 11.0, 70.0, 40.0))])
|
||||
.unwrap();
|
||||
let basin = crate::boolean(&slab, &pocket, crate::BoolOp::Cut).unwrap().unwrap();
|
||||
let picks = [Vec3::new(50.0, 1.0, 30.0), Vec3::new(80.0, 27.0, 30.0), Vec3::new(50.0, 50.0, 30.0), Vec3::new(20.0, 27.0, 30.0)];
|
||||
assert!(crate::chamfer(&basin, &picks, 2.0).is_err());
|
||||
assert!(fillet(&basin, &picks, 4.0).is_err());
|
||||
assert!(crate::chamfer(&basin, &picks, 0.5).is_ok());
|
||||
assert!(fillet(&basin, &picks, 0.5).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ pub(crate) type Solid = mt::Solid;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Body {
|
||||
pub(crate) solid: std::sync::Arc<Solid>,
|
||||
/// Missing-face check from the orientation mesh, reusable at exactly its tolerance.
|
||||
pub(crate) coarse_missing: Option<(f64, usize)>,
|
||||
/// Set for mesh bodies (`solid` is then empty).
|
||||
pub(crate) mesh: Option<std::sync::Arc<crate::meshbody::TriMesh>>,
|
||||
/// Display colour (linear RGB 0..1) from an imported file; operations drop it.
|
||||
@@ -302,11 +304,24 @@ impl Body {
|
||||
if lo.x.is_finite() { (hi - lo).len() } else { 1.0 }
|
||||
};
|
||||
let tol = (span * 0.01).clamp(1e-3, 1.0);
|
||||
let v = guard("orient", || Ok(mesh_shells(&solid, tol).iter().map(|s| s.to_polygon().volume()).sum::<f64>()))?;
|
||||
let (v, missing) = guard("orient", || {
|
||||
let shells = mesh_shells(&solid, tol);
|
||||
let volume = shells.iter().map(|s| s.to_polygon().volume()).sum::<f64>();
|
||||
let missing =
|
||||
shells.iter().flat_map(|s| s.face_iter()).filter(|f| f.surface().is_none_or(|m| m.faces().triangle_iter().next().is_none())).count();
|
||||
Ok((volume, missing))
|
||||
})?;
|
||||
if v < 0.0 {
|
||||
solid.not();
|
||||
}
|
||||
Ok(Body { solid: std::sync::Arc::new(solid), mesh: None, color: None, paint: None, splits: None })
|
||||
Ok(Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
coarse_missing: (v >= 0.0).then_some((tol, missing)),
|
||||
mesh: None,
|
||||
color: None,
|
||||
paint: None,
|
||||
splits: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A deep copy of the solid that can be mutated without affecting other bodies.
|
||||
@@ -506,7 +521,13 @@ impl Body {
|
||||
if self.is_mesh() {
|
||||
return 0;
|
||||
}
|
||||
let Ok(m) = self.tessellate((self.size() * 1e-2).max(1e-3)) else { return self.face_count() };
|
||||
let tol = (self.size() * 1e-2).max(1e-3);
|
||||
if let Some((cached_tol, missing)) = self.coarse_missing
|
||||
&& cached_tol == tol
|
||||
{
|
||||
return missing;
|
||||
}
|
||||
let Ok(m) = self.tessellate(tol) else { return self.face_count() };
|
||||
let mut has = vec![false; self.face_count()];
|
||||
for f in &m.tri_face {
|
||||
if let Some(h) = has.get_mut(*f as usize) {
|
||||
|
||||
@@ -795,13 +795,68 @@ pub enum PathSeg {
|
||||
},
|
||||
}
|
||||
|
||||
impl PathSeg {
|
||||
/// Unit directions of the path where the segment starts and ends.
|
||||
fn tangents(&self) -> Option<(Vec3, Vec3)> {
|
||||
match *self {
|
||||
PathSeg::Line { a, b } => {
|
||||
let t = (b - a).normalized()?;
|
||||
Some((t, t))
|
||||
}
|
||||
PathSeg::Arc { a, center, axis, angle } => {
|
||||
let k = axis.normalized()?;
|
||||
let end = rotate_about(a, center, k, angle);
|
||||
Some((k.cross(a - center).normalized()?, k.cross(end - center).normalized()?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Points along the segment (an arc within `tol` of its chords), without its start.
|
||||
fn points_after_start(&self, tol: f64) -> Vec<Vec3> {
|
||||
match *self {
|
||||
PathSeg::Line { b, .. } => vec![b],
|
||||
PathSeg::Arc { a, center, axis, angle } => {
|
||||
let Some(k) = axis.normalized() else { return vec![a] };
|
||||
let r = (a - center).len();
|
||||
let step = if r > tol { 2.0 * (1.0 - tol / r).acos() } else { angle.abs() };
|
||||
let n = ((angle.abs() / step.max(1e-6)).ceil() as usize).clamp(1, 512);
|
||||
(1..=n).map(|i| rotate_about(a, center, k, angle * i as f64 / n as f64)).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `p` turned by `angle` about the unit axis `k` through `center` (right hand).
|
||||
fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 {
|
||||
let v = p - center;
|
||||
let (s, c) = angle.sin_cos();
|
||||
center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c))
|
||||
}
|
||||
|
||||
/// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile
|
||||
/// perpendicular to the path (lines translate it, arcs rotate it).
|
||||
/// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners
|
||||
/// is swept as a polyline, mitred at the corners.
|
||||
pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body> {
|
||||
region_ok(region)?;
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
||||
}
|
||||
// Segment by segment the profile only follows a path whose direction runs on across each
|
||||
// joint: at a corner the next line would slide the profile within its own plane.
|
||||
let ends: Vec<(Vec3, Vec3)> =
|
||||
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
|
||||
let corner = ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos());
|
||||
if corner {
|
||||
let first = match path.first() {
|
||||
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
|
||||
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
|
||||
};
|
||||
let mut pts = vec![first];
|
||||
for seg in path {
|
||||
pts.extend(seg.points_after_start(1e-2));
|
||||
}
|
||||
return crate::sweep_path::sweep_path(plane, region, &pts);
|
||||
}
|
||||
// The outer loop and each hole, swept along the path; the ends are capped with holes.
|
||||
let mut starts = vec![wire(plane, ®ion.outer.ccw())?];
|
||||
for h in ®ion.holes {
|
||||
|
||||
@@ -404,6 +404,12 @@ pub fn heal(solid: Solid, size: f64) -> Solid {
|
||||
|
||||
/// [`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 {
|
||||
// Edges a boolean made are intersection curves, which panic where they no longer
|
||||
// evaluate; the solid then stays as it is.
|
||||
crate::guard("heal", || Ok(heal_shells(&solid, size, keep))).ok().flatten().unwrap_or(solid)
|
||||
}
|
||||
|
||||
fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>]) -> Option<Solid> {
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
let mut shells = Vec::new();
|
||||
let mut changed = false;
|
||||
@@ -428,9 +434,9 @@ pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> Solid {
|
||||
shells.push(cur);
|
||||
}
|
||||
if !changed {
|
||||
return solid;
|
||||
return None;
|
||||
}
|
||||
Solid::try_new(shells).unwrap_or(solid)
|
||||
Solid::try_new(shells).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -150,7 +150,8 @@ fn a_drill_point_round_trips_through_iges() {
|
||||
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 plane = Plane { y: Vec3::Z, ..Plane::XZ }; // Positive profile heights are world +Z.
|
||||
let hole = crate::revolve(&plane, &[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);
|
||||
|
||||
@@ -61,7 +61,7 @@ pub use lip::raise_band;
|
||||
pub use measure::{BodyMeasure, measure};
|
||||
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||
pub use offset::with_same_surface;
|
||||
pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
|
||||
pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix};
|
||||
pub use polybool::{faceted_boolean, planar_boolean};
|
||||
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
|
||||
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
|
||||
|
||||
@@ -270,7 +270,7 @@ fn build(
|
||||
if !(phi > 1e-3 && phi < std::f64::consts::PI - 1e-3) {
|
||||
return None;
|
||||
}
|
||||
let sb = r / (phi / 2.0).tan();
|
||||
let sb = if round { r / (phi / 2.0).tan() } else { r };
|
||||
Some((m, t, sb, r / (phi / 2.0).sin()))
|
||||
};
|
||||
// The straight seam between the walls at a sharp joint (direction away from the face).
|
||||
@@ -328,6 +328,19 @@ fn build(
|
||||
}
|
||||
pb.push(p + e * (sb / h1));
|
||||
}
|
||||
// Sharp joints between straight rim edges can extend beyond the old corner.
|
||||
// Equal wall slopes reach this path instead of the corner builder, and must
|
||||
// also reject an extension through the outer wall or another hole.
|
||||
for (i, q) in pa.iter().enumerate() {
|
||||
// Smooth arc transitions and open-run ends retain their existing checks.
|
||||
if !mitre.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let p = joint_point(i).ok_or_else(|| unsupported("degenerate loop joint"))?;
|
||||
if !crate::blend::extension_fits(f, p, *q, size * 1e-7) {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
}
|
||||
// The offset arcs must not turn inside out; an arc of exactly the blend's radius shrinks
|
||||
// to a point (a rounded corner rounded again: the blend there is a sphere).
|
||||
let mut collapsed = vec![false; k.max(nj)];
|
||||
|
||||
@@ -101,7 +101,14 @@ 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, splits: None })
|
||||
Ok(Body {
|
||||
solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())),
|
||||
coarse_missing: None,
|
||||
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
|
||||
@@ -185,6 +192,13 @@ impl Body {
|
||||
t.swap(1, 2);
|
||||
}
|
||||
}
|
||||
Some(Body { solid: self.solid.clone(), mesh: Some(Arc::new(out)), color: self.color, paint: self.paint.clone(), splits: self.splits.clone() })
|
||||
Some(Body {
|
||||
solid: self.solid.clone(),
|
||||
coarse_missing: None,
|
||||
mesh: Some(Arc::new(out)),
|
||||
color: self.color,
|
||||
paint: self.paint.clone(),
|
||||
splits: self.splits.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+50
-11
@@ -15,6 +15,12 @@ pub enum BoolOp {
|
||||
Intersect,
|
||||
}
|
||||
|
||||
/// Whether a union can sew two matching planar faces without intersecting them.
|
||||
/// Callers can retain the exact touching tool instead of extending it into material.
|
||||
pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool {
|
||||
a.solid.boundaries().len() == 1 && b.solid.boundaries().len() == 1 && crate::coplanar::glue(a, b).is_some_and(|r| r.is_ok())
|
||||
}
|
||||
|
||||
/// Shifts applied to both operands on retries. The boolean classifies some faces by casting a
|
||||
/// ray whose direction is derived from point coordinates; moving both solids (and the result
|
||||
/// back) leaves the geometry unchanged but changes those rays.
|
||||
@@ -46,7 +52,10 @@ fn mismatch(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, result: &solve
|
||||
let hi = ba.max.min(bb.max);
|
||||
let all = ba.union(&bb);
|
||||
let mut boxes = vec![(all, 300usize)];
|
||||
if lo.x < hi.x && lo.y < hi.y && lo.z < hi.z {
|
||||
// A touching seam can overlap by round-off only. Samples there sit on the
|
||||
// operands' boundaries, where ray parity cannot classify membership reliably.
|
||||
let gap = all.diagonal().max(1.0) * 1e-9;
|
||||
if hi.x - lo.x > gap && hi.y - lo.y > gap && hi.z - lo.z > gap {
|
||||
boxes.push((solvecraft_geom::Aabb3 { min: lo, max: hi }, 900));
|
||||
}
|
||||
let (ia_idx, ib_idx, ir_idx) = (a.inside_index(), b.inside_index(), result.inside_index());
|
||||
@@ -180,11 +189,28 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
r
|
||||
}
|
||||
|
||||
/// Smallest side of the box where the bodies' vertex boxes overlap (infinite when they don't).
|
||||
fn meeting_size(a: &Body, b: &Body) -> f64 {
|
||||
let bbox = |x: &Body| {
|
||||
let mut bb = solvecraft_geom::Aabb3::EMPTY;
|
||||
for v in x.solid.vertex_iter() {
|
||||
bb.add(from_p3(v.point()));
|
||||
}
|
||||
bb
|
||||
};
|
||||
let (ba, bb) = (bbox(a), bbox(b));
|
||||
let s = solvecraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }.size();
|
||||
if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
|
||||
}
|
||||
|
||||
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());
|
||||
let tol_m = size * 5e-4;
|
||||
// The checks mesh both bodies; where they meet in a region much thinner than they are (a
|
||||
// thin part trimmed by a long one), a step from their size would facet the small faces
|
||||
// there coarsely enough to fail the membership test on a correct result.
|
||||
let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5);
|
||||
let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok());
|
||||
let (va, vb) = match &meshes {
|
||||
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
|
||||
@@ -222,6 +248,15 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
// Prisms along one direction (plates with holes): exact, in 2D (so only the volume
|
||||
// bounds are checked).
|
||||
if let Some(Ok(r)) = crate::prism::prism_boolean(a, b, op) {
|
||||
// Slab-by-slab prism joins sew a closed solid, but leave cap and wall
|
||||
// pieces along slab boundaries. Merge those coplanar faces just as the
|
||||
// general B-rep boolean does, retaining intentional Split Face lines.
|
||||
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
|
||||
let r = if op == BoolOp::Union {
|
||||
r.map(|body| guard("heal prism boolean", || Body::new(crate::heal::heal_keep((*body.solid).clone(), size, &keep))).unwrap_or(body))
|
||||
} else {
|
||||
r
|
||||
};
|
||||
match &r {
|
||||
Some(body) => {
|
||||
let v = volume(body);
|
||||
@@ -287,7 +322,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
empty_votes += 1;
|
||||
last = "empty result".into();
|
||||
}
|
||||
Ok(s) => match Body::new(crate::heal::heal_keep(s, size, &keep)) {
|
||||
Ok(s) => match guard("boolean result", || Body::new(crate::heal::heal_keep(s, size, &keep))) {
|
||||
Ok(body) => {
|
||||
let (ok, v) = check(&body);
|
||||
if ok {
|
||||
@@ -489,6 +524,15 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle:
|
||||
/// Split a body with a plane: the parts on the positive and the negative side of the plane
|
||||
/// (only the non-empty ones).
|
||||
pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec<Body>> {
|
||||
let mut out = Vec::new();
|
||||
for positive in [true, false] {
|
||||
out.extend(half_space(body, plane, positive)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The part of a body on one side of a plane (`None` when nothing is there).
|
||||
pub(crate) fn half_space(body: &Body, plane: &solvecraft_geom::Plane, positive: bool) -> Result<Option<Body>> {
|
||||
body.require_brep("split")?;
|
||||
let size = body.size();
|
||||
let mut bb = solvecraft_geom::Aabb3::EMPTY;
|
||||
@@ -506,12 +550,7 @@ pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec
|
||||
]),
|
||||
holes: vec![],
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (lo, hi) in [(0.0, reach), (-reach, 0.0)] {
|
||||
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
|
||||
if let Some(part) = boolean(body, &half, BoolOp::Intersect)? {
|
||||
out.push(part);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
let (lo, hi) = if positive { (0.0, reach) } else { (-reach, 0.0) };
|
||||
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
|
||||
boolean(body, &half, BoolOp::Intersect)
|
||||
}
|
||||
|
||||
@@ -306,12 +306,118 @@ pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
a.require_brep("a boolean")?;
|
||||
b.require_brep("a boolean")?;
|
||||
let (Some(pa), Some(pb)) = (planar_polys(a), planar_polys(b)) else {
|
||||
if let Some(r) = sliced_planar_boolean(a, b, op) {
|
||||
return r;
|
||||
}
|
||||
return Err(KernelError::Failed("not supported yet: coincident faces on curved bodies".into()));
|
||||
};
|
||||
let size = a.size().max(b.size());
|
||||
polys_boolean(pa, pb, size, op, size * 1e-9)
|
||||
}
|
||||
|
||||
/// [`planar_boolean`] of a body with curved faces and an all-planar one, when a slab between
|
||||
/// two parallel planes holds `b` and none of `a`'s curved faces (a pocket cut beside a round
|
||||
/// hole): `a` is split across the slab, the middle piece takes the exact boolean, and the
|
||||
/// pieces outside are joined back on. `None` when no such slab exists.
|
||||
fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
let pb = planar_polys(b)?;
|
||||
let size = a.size().max(b.size());
|
||||
let tol = (a.size() * 1e-3).max(1e-3);
|
||||
let infos = a.faces(tol).ok()?;
|
||||
let mesh = a.tessellate(tol).ok()?;
|
||||
let mut curved: HashMap<u32, solvecraft_geom::Aabb3> = HashMap::new();
|
||||
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if infos.get(*fi as usize)?.plane_normal.is_some() {
|
||||
continue;
|
||||
}
|
||||
let bx = curved.entry(*fi).or_insert(solvecraft_geom::Aabb3::EMPTY);
|
||||
for p in mesh.tri(t)? {
|
||||
bx.add(p);
|
||||
}
|
||||
}
|
||||
let mut tool = solvecraft_geom::Aabb3::EMPTY;
|
||||
for p in pb.iter().flat_map(|p| &p.v) {
|
||||
tool.add(*p);
|
||||
}
|
||||
let at = |v: Vec3, k: usize| [v.x, v.y, v.z].get(k).copied().unwrap_or(0.0);
|
||||
let axes = [Vec3::X, Vec3::Y, Vec3::Z];
|
||||
// Planar faces of `a` square to an axis: a cut must not land in one of their planes.
|
||||
let flat: Vec<(Vec3, Vec3)> = infos.iter().filter_map(|f| Some((f.plane_normal?, f.centroid))).collect();
|
||||
let gap = size * 1e-4;
|
||||
let place = |k: usize, near: f64, far: f64| -> Option<f64> {
|
||||
let axis = *axes.get(k)?;
|
||||
[0.5, 0.37, 0.63, 0.25, 0.75]
|
||||
.iter()
|
||||
.map(|t| near + (far - near) * t)
|
||||
.find(|x| !flat.iter().any(|(n, c)| n.dot(axis).abs() > 1.0 - 1e-9 && (at(*c, k) - x).abs() < gap))
|
||||
};
|
||||
// An axis along which every curved face lies clear of the tool: the cuts either side.
|
||||
let (k, lo, hi) = (0..3).find_map(|k| {
|
||||
let (t0, t1) = (at(tool.min, k), at(tool.max, k));
|
||||
let (mut below, mut above) = (None::<f64>, None::<f64>);
|
||||
for bx in curved.values() {
|
||||
let (c0, c1) = (at(bx.min, k), at(bx.max, k));
|
||||
if c1 < t0 - gap {
|
||||
below = Some(below.map_or(c1, |x| x.max(c1)));
|
||||
} else if c0 > t1 + gap {
|
||||
above = Some(above.map_or(c0, |x| x.min(c0)));
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let lo = match below {
|
||||
Some(c) => Some(place(k, t0, c)?),
|
||||
None => None,
|
||||
};
|
||||
let hi = match above {
|
||||
Some(c) => Some(place(k, t1, c)?),
|
||||
None => None,
|
||||
};
|
||||
Some((k, lo, hi))
|
||||
})?;
|
||||
let (u, w) = match k {
|
||||
0 => (Vec3::Y, Vec3::Z),
|
||||
1 => (Vec3::Z, Vec3::X),
|
||||
_ => (Vec3::X, Vec3::Y),
|
||||
};
|
||||
let axis = *axes.get(k)?;
|
||||
let run = || -> Result<Option<Body>> {
|
||||
let fail = |m: &str| KernelError::Failed(format!("planar boolean beside curved faces: {m}"));
|
||||
let mut middle = a.clone();
|
||||
let mut outside = Vec::new();
|
||||
for (cut, keep_positive) in [(lo, true), (hi, false)] {
|
||||
let Some(x) = cut else { continue };
|
||||
let plane = solvecraft_geom::Plane::new(axis * x, u, w).ok_or_else(|| fail("plane"))?;
|
||||
outside.extend(crate::ops::half_space(&middle, &plane, !keep_positive)?);
|
||||
middle = crate::ops::half_space(&middle, &plane, keep_positive)?.ok_or_else(|| fail("nothing beside the tool"))?;
|
||||
}
|
||||
let pm = planar_polys(&middle).ok_or_else(|| fail("the middle piece is not planar"))?;
|
||||
let r = polys_boolean(pm, pb, size, op, size * 1e-9)?;
|
||||
if op == BoolOp::Intersect {
|
||||
return Ok(r);
|
||||
}
|
||||
// The rebuild leaves the cut faces' edges split where other polygons were cut; healed,
|
||||
// they match the pieces outside edge for edge.
|
||||
let mut pieces: Vec<Body> = match r {
|
||||
Some(m) => vec![Body::new(crate::heal::heal_keep(m.deep_copy(), size, a.split_keep()))?],
|
||||
None => Vec::new(),
|
||||
};
|
||||
// Each piece outside meets the middle along its cut face (glued one shell to another).
|
||||
if pieces.iter().chain(&outside).any(|x| x.solid.boundaries().len() != 1) {
|
||||
return Err(fail("a piece in several parts"));
|
||||
}
|
||||
for o in outside {
|
||||
let joined = pieces.iter().enumerate().find_map(|(i, m)| Some((i, crate::coplanar::glue(m, &o)?.ok()?)));
|
||||
let (i, j) = joined.ok_or_else(|| fail("a piece does not join back"))?;
|
||||
if let Some(slot) = pieces.get_mut(i) {
|
||||
*slot = j;
|
||||
}
|
||||
}
|
||||
crate::ops::join_pieces(pieces)
|
||||
};
|
||||
Some(run())
|
||||
}
|
||||
|
||||
fn polys_boolean(pa: Vec<Poly>, pb: Vec<Poly>, size: f64, op: BoolOp, eps: f64) -> Result<Option<Body>> {
|
||||
let polys = csg(pa, pb, op, eps)?;
|
||||
if polys.is_empty() {
|
||||
|
||||
+263
-4
@@ -3,6 +3,8 @@
|
||||
//! spanning the target's height, the result is the prism over the 2D boolean of their
|
||||
//! sections. Plates with holes, slots and patterns of them: exact, and immune to the mesh
|
||||
//! intersection's trouble with walls meeting along lines.
|
||||
//! Stepped extrusions (case floors and walls) use sections between every cap level, so a
|
||||
//! circular boss can meet the rim without shifting either operand off its exact plane.
|
||||
|
||||
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
@@ -77,6 +79,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
|
||||
let mut hi = f64::NEG_INFINITY;
|
||||
let mut tops: Vec<&mt::Face> = Vec::new();
|
||||
let mut top_h = Vec::new();
|
||||
let mut bottom_h = Vec::new();
|
||||
for f in b.solid.face_iter() {
|
||||
let surf = f.oriented_surface();
|
||||
if let mt::Surface::Plane(p) = &surf {
|
||||
@@ -90,6 +93,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
|
||||
continue;
|
||||
}
|
||||
if n.dot(d) < -1.0 + 1e-9 {
|
||||
bottom_h.push(h);
|
||||
lo = lo.min(h);
|
||||
continue;
|
||||
}
|
||||
@@ -110,7 +114,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
|
||||
}
|
||||
}
|
||||
// One height for every top (and bottom) cap.
|
||||
if top_h.iter().any(|h| (h - hi).abs() > tol) || !(lo.is_finite() && hi > lo + tol) {
|
||||
if top_h.iter().any(|h| (h - hi).abs() > tol) || bottom_h.iter().any(|h| (h - lo).abs() > tol) || !(lo.is_finite() && hi > lo + tol) {
|
||||
return None;
|
||||
}
|
||||
let mut regions = Vec::new();
|
||||
@@ -382,7 +386,135 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
|
||||
}
|
||||
|
||||
/// The boolean done on prism sections, or `None` when it doesn't apply.
|
||||
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
/// The pieces of a segment between the given points (those lying on it, inside it).
|
||||
fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
|
||||
if !matches!(seg, Seg2::Line { .. } | Seg2::Arc { .. }) {
|
||||
return None;
|
||||
}
|
||||
let mut ts: Vec<f64> = pts
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let t = seg.closest_param(*p);
|
||||
(t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t)
|
||||
})
|
||||
.collect();
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup_by(|x, y| (*x - *y).abs() < 1e-9);
|
||||
let mut out = Vec::new();
|
||||
let mut t0 = 0.0;
|
||||
for t1 in ts.into_iter().chain(std::iter::once(1.0)) {
|
||||
out.push(match *seg {
|
||||
Seg2::Line { .. } => Seg2::Line { a: seg.point_at(t0), b: seg.point_at(t1) },
|
||||
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius, start: start + sweep * t0, sweep: sweep * (t1 - t0) },
|
||||
_ => return None,
|
||||
});
|
||||
t0 = t1;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The union of two prisms along `d` stacked end to end (or overlapping) with different
|
||||
/// sections, sewn from its faces: each slab between the prisms' end levels has walls over its
|
||||
/// section's outline; each level has a face up over the section below less the one above, and
|
||||
/// a face down over the one above less the one below. Every outline is split where any other
|
||||
/// meets it, so the faces share their edges. `None` when a slab is empty or a curve isn't a
|
||||
/// line or an arc.
|
||||
fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option<Result<Body>> {
|
||||
let mut levels = vec![pa.lo, pa.hi, pb.lo, pb.hi];
|
||||
levels.sort_by(f64::total_cmp);
|
||||
levels.dedup_by(|x, y| (*x - *y).abs() < tol);
|
||||
let covers = |p: &Prism, z0: f64, z1: f64| p.lo <= z0 + tol && p.hi >= z1 - tol;
|
||||
let mut slabs: Vec<Vec<Region2>> = Vec::new();
|
||||
for w in levels.windows(2) {
|
||||
let (z0, z1) = (*w.first()?, *w.get(1)?);
|
||||
let s = match (covers(pa, z0, z1), covers(pb, z0, z1)) {
|
||||
(true, true) => boolean2d(&pa.regions, &pb.regions, BoolOp::Union, tol)?,
|
||||
(true, false) => pa.regions.clone(),
|
||||
(false, true) => pb.regions.clone(),
|
||||
(false, false) => return None,
|
||||
};
|
||||
slabs.push(oriented(&s));
|
||||
}
|
||||
sew_sections(plane, d, &levels, &slabs, tol)
|
||||
}
|
||||
|
||||
/// Sew the exact sections between successive levels, including empty gaps.
|
||||
fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<Region2>], tol: f64) -> Option<Result<Body>> {
|
||||
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
|
||||
// Faces at each level: up where the slab below sticks out, down where the one above does.
|
||||
let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> {
|
||||
match (x.is_empty(), y.is_empty()) {
|
||||
(true, _) => Some(Vec::new()),
|
||||
(false, true) => Some(x.to_vec()),
|
||||
(false, false) => boolean2d(x, y, BoolOp::Cut, tol).map(|r| oriented(&r)),
|
||||
}
|
||||
};
|
||||
let mut caps: Vec<(f64, bool, Vec<Region2>)> = Vec::new();
|
||||
for (k, z) in levels.iter().enumerate() {
|
||||
let below: &[Region2] = if k == 0 { &[] } else { slabs.get(k - 1)? };
|
||||
let above: &[Region2] = slabs.get(k).map(Vec::as_slice).unwrap_or(&[]);
|
||||
caps.push((*z, true, diff(below, above)?));
|
||||
caps.push((*z, false, diff(above, below)?));
|
||||
}
|
||||
// Every segment end, everywhere: outlines split there.
|
||||
let loops_of =
|
||||
|rs: &[Region2]| -> Vec<Loop2> { rs.iter().flat_map(|r| std::iter::once(r.outer.clone()).chain(r.holes.iter().cloned())).collect() };
|
||||
let mut pts: Vec<Vec2> = Vec::new();
|
||||
for lp in slabs.iter().flat_map(|s| loops_of(s)).chain(caps.iter().flat_map(|c| loops_of(&c.2))) {
|
||||
for sg in &lp.segs {
|
||||
pts.push(sg.start());
|
||||
pts.push(sg.end());
|
||||
}
|
||||
}
|
||||
let at = |u: Vec2, z: f64| plane.to_world(u) + d * z;
|
||||
let edge = |sg: &Seg2, z: f64| -> EdgeSpec {
|
||||
match sg {
|
||||
Seg2::Arc { .. } => EdgeSpec::Arc { a: at(sg.start(), z), b: at(sg.end(), z), mid: at(sg.point_at(0.5), z) },
|
||||
_ => EdgeSpec::Line { a: at(sg.start(), z), b: at(sg.end(), z) },
|
||||
}
|
||||
};
|
||||
let mut faces: Vec<FaceSpec> = Vec::new();
|
||||
for (i, s) in slabs.iter().enumerate() {
|
||||
let (z0, z1) = (*levels.get(i)?, *levels.get(i + 1)?);
|
||||
for lp in loops_of(s) {
|
||||
for sg0 in &lp.segs {
|
||||
for sg in split_seg(sg0, &pts, tol)? {
|
||||
let (a, b) = (sg.start(), sg.end());
|
||||
let lp3 = vec![
|
||||
edge(&sg, z0),
|
||||
EdgeSpec::Line { a: at(b, z0), b: at(b, z1) },
|
||||
edge(&sg.reversed(), z1),
|
||||
EdgeSpec::Line { a: at(a, z1), b: at(a, z0) },
|
||||
];
|
||||
let tangent = plane.dir_to_world(sg.derivative(0.5));
|
||||
let outward = tangent.cross(d).normalized()?;
|
||||
let surface = match sg {
|
||||
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
|
||||
_ => SurfSpec::Plane,
|
||||
};
|
||||
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (z, up, rs) in &caps {
|
||||
for r in rs {
|
||||
let mut loops = Vec::new();
|
||||
for lp in std::iter::once(&r.outer).chain(&r.holes) {
|
||||
let mut segs: Vec<Seg2> = Vec::new();
|
||||
for sg in &lp.segs {
|
||||
segs.extend(split_seg(sg, &pts, tol)?);
|
||||
}
|
||||
let segs: Vec<Seg2> = if *up { segs } else { segs.iter().rev().map(Seg2::reversed).collect() };
|
||||
loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::<Vec<_>>());
|
||||
}
|
||||
faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None });
|
||||
}
|
||||
}
|
||||
Some(crate::sew::sew(&faces))
|
||||
}
|
||||
|
||||
fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
let size = a.size().max(b.size());
|
||||
let tol = (size * 1e-7).max(1e-9) * 10.0;
|
||||
// Candidate directions: a's planar face normals (each line once).
|
||||
@@ -423,10 +555,10 @@ pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
|
||||
return Some(Ok(None));
|
||||
}
|
||||
let (lo, hi) = if stacked {
|
||||
// Only when the sections are the same (the union adds nothing to either).
|
||||
// Different sections (a boss hanging over a plate's edge): sewn slab by slab.
|
||||
let (u, x, y) = (area(®ions), area(&pa.regions), area(&pb.regions));
|
||||
if (u - x).abs() > tol * (1.0 + u) || (u - y).abs() > tol * (1.0 + u) {
|
||||
return None;
|
||||
return sew_stack(&plane, d, &pa, &pb, tol).map(|r| r.map(Some));
|
||||
}
|
||||
(pa.lo.min(pb.lo), pa.hi.max(pb.hi))
|
||||
} else {
|
||||
@@ -448,3 +580,130 @@ pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
|
||||
}
|
||||
Some(Ok(Some(first)))
|
||||
}
|
||||
|
||||
/// A stepped extrusion (case floor plus walls), recovered from its cap events.
|
||||
/// Downward caps add a section above their level; upward caps remove one.
|
||||
fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
|
||||
use mt::{ParametricSurface3D, SearchNearestParameter};
|
||||
if let Some(p) = prism_of(b, d, plane, tol) {
|
||||
return Some(vec![p]);
|
||||
}
|
||||
if b.face_count() > 2048 {
|
||||
return None;
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
for f in b.solid.face_iter() {
|
||||
let surf = f.oriented_surface();
|
||||
if let mt::Surface::Plane(p) = &surf {
|
||||
let n = p.normal();
|
||||
let n = Vec3::new(n.x, n.y, n.z).normalized()?;
|
||||
if n.dot(d).abs() > 1.0 - 1e-9 {
|
||||
let mut loops = Vec::new();
|
||||
for w in f.boundaries() {
|
||||
let mut segs = Vec::new();
|
||||
for e in w.edge_iter() {
|
||||
segs.extend(seg2(e, plane, tol)?);
|
||||
}
|
||||
loops.push(Loop2 { segs });
|
||||
}
|
||||
let (outer, holes) = split_outer(loops)?;
|
||||
let region = Region2 { outer, holes };
|
||||
events.push((plane.height(from_p3(p.origin())), n.dot(d) > 0.0, oriented(&[region])));
|
||||
continue;
|
||||
}
|
||||
if n.dot(d).abs() > 1e-9 {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
// Curved walls must be extruded along d, not slanted or rounded caps.
|
||||
for v in f.vertex_iter() {
|
||||
let (u, w) = surf.search_nearest_parameter(v.point(), None, 50)?;
|
||||
let n = surf.normal(u, w);
|
||||
if Vec3::new(n.x, n.y, n.z).dot(d).abs() > 1e-7 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut levels: Vec<f64> = events.iter().map(|(z, _, _)| *z).collect();
|
||||
levels.sort_by(f64::total_cmp);
|
||||
levels.dedup_by(|x, y| (*x - *y).abs() < tol);
|
||||
if levels.len() < 2 || levels.len() > 64 || levels.iter().any(|z| !z.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut current = Vec::new();
|
||||
let mut stack = Vec::new();
|
||||
for (i, z) in levels.iter().enumerate() {
|
||||
// Remove terminating material before adding any material starting here.
|
||||
for up in [true, false] {
|
||||
for (_, _, section) in events.iter().filter(|(h, u, _)| (*h - *z).abs() < tol && *u == up) {
|
||||
current = match (current.is_empty(), up) {
|
||||
(true, false) => section.clone(),
|
||||
(true, true) => return None,
|
||||
(false, _) => boolean2d(¤t, section, if up { BoolOp::Cut } else { BoolOp::Union }, tol)?,
|
||||
};
|
||||
}
|
||||
}
|
||||
if let Some(next) = levels.get(i + 1)
|
||||
&& !current.is_empty()
|
||||
{
|
||||
stack.push(Prism { lo: *z, hi: *next, regions: oriented(¤t) });
|
||||
}
|
||||
}
|
||||
(current.is_empty() && !stack.is_empty()).then_some(stack)
|
||||
}
|
||||
|
||||
fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
// This path repairs joins with a common outer cap (a boss flush with a rim).
|
||||
// Other extents and subtractive operations retain their existing B-rep path.
|
||||
if op != BoolOp::Union {
|
||||
return None;
|
||||
}
|
||||
fn section(ps: &[Prism], lo: f64, hi: f64, tol: f64) -> &[Region2] {
|
||||
ps.iter().find(|p| p.lo <= lo + tol && p.hi >= hi - tol).map(|p| p.regions.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
let tol = (a.size().max(b.size()) * 1e-7).max(1e-9) * 10.0;
|
||||
let mut dirs: Vec<Vec3> = Vec::new();
|
||||
for f in a.solid.face_iter() {
|
||||
if let mt::Surface::Plane(p) = f.oriented_surface() {
|
||||
let n = p.normal();
|
||||
if let Some(n) = Vec3::new(n.x, n.y, n.z).normalized()
|
||||
&& !dirs.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9)
|
||||
{
|
||||
dirs.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (d, plane, pa, pb) = dirs.into_iter().take(8).find_map(|d| {
|
||||
let plane = Plane::from_normal(Vec3::ZERO, d)?;
|
||||
Some((d, plane, stack_of(a, d, &plane, tol)?, stack_of(b, d, &plane, tol)?))
|
||||
})?;
|
||||
let ends = |ps: &[Prism]| ps.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.lo), hi.max(p.hi)));
|
||||
let (ea, eb) = (ends(&pa), ends(&pb));
|
||||
if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol {
|
||||
return None;
|
||||
}
|
||||
let mut levels: Vec<f64> = pa.iter().chain(&pb).flat_map(|p| [p.lo, p.hi]).collect();
|
||||
levels.sort_by(f64::total_cmp);
|
||||
levels.dedup_by(|x, y| (*x - *y).abs() < tol);
|
||||
let mut slabs = Vec::new();
|
||||
for w in levels.windows(2) {
|
||||
let (lo, hi) = (*w.first()?, *w.get(1)?);
|
||||
let (x, y) = (section(&pa, lo, hi, tol), section(&pb, lo, hi, tol));
|
||||
let s = match (x.is_empty(), y.is_empty(), op) {
|
||||
(true, _, BoolOp::Cut | BoolOp::Intersect) | (_, true, BoolOp::Intersect) => Vec::new(),
|
||||
(true, _, BoolOp::Union) => y.to_vec(),
|
||||
(_, true, _) => x.to_vec(),
|
||||
_ => boolean2d(x, y, op, tol)?,
|
||||
};
|
||||
slabs.push(oriented(&s));
|
||||
}
|
||||
if slabs.iter().all(Vec::is_empty) {
|
||||
return Some(Ok(None));
|
||||
}
|
||||
sew_sections(&plane, d, &levels, &slabs, tol).map(|r| r.map(Some))
|
||||
}
|
||||
|
||||
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
crate::guard("prism boolean", || Ok(simple_prism_boolean(a, b, op).or_else(|| stack_boolean(a, b, op)))).ok().flatten()
|
||||
}
|
||||
|
||||
@@ -877,7 +877,10 @@ 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, splits: body.splits.clone() }, imp))
|
||||
Ok((
|
||||
Body { solid: std::sync::Arc::new(solid), coarse_missing: None, mesh: None, color: body.color, paint: None, splits: body.splits.clone() },
|
||||
imp,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1208,7 +1211,7 @@ mod tests {
|
||||
let m0 = measure(&c).unwrap();
|
||||
let side = c.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_none()).unwrap();
|
||||
// A horizontal line seen from the front (XZ sketch) at z = 5, swept along y.
|
||||
let xz = Plane::XZ;
|
||||
let xz = Plane { y: Vec3::Z, ..Plane::XZ };
|
||||
let tool = SplitTool::Curves { plane: xz, curves: vec![vec![Seg2::Line { a: Vec2::new(-20.0, 5.0), b: Vec2::new(20.0, 5.0) }]] };
|
||||
let s = split_faces(&c, &[side], &tool).unwrap();
|
||||
let m = measure(&s).unwrap();
|
||||
|
||||
@@ -22,7 +22,14 @@ 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, splits: None }
|
||||
Body {
|
||||
solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
color: None,
|
||||
paint: None,
|
||||
splits: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A body from faces: a solid when they close, else a surface.
|
||||
@@ -31,7 +38,14 @@ 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, splits: None })
|
||||
Ok(Body {
|
||||
solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
color: None,
|
||||
paint: None,
|
||||
splits: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+164
-5
@@ -40,8 +40,8 @@ fn extrude_on_other_planes_and_offsets() {
|
||||
let b = extrude(&Plane::XZ, &[rect(10.0, 5.0)], -2.0, 3.0).unwrap().pop().unwrap();
|
||||
let m = measure(&b).unwrap();
|
||||
assert!(rel(m.volume, 250.0) < 1e-9);
|
||||
// XZ normal is -Y: the body spans y in [-3, 2].
|
||||
assert!((m.bbox.min.y + 3.0).abs() < 1e-6 && (m.bbox.max.y - 2.0).abs() < 1e-6, "{:?}", m.bbox);
|
||||
// XZ normal is +Y: the body spans y in [-2, 3].
|
||||
assert!((m.bbox.min.y + 2.0).abs() < 1e-6 && (m.bbox.max.y - 3.0).abs() < 1e-6, "{:?}", m.bbox);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -52,6 +52,39 @@ fn region_with_hole() {
|
||||
assert!(rel(m.volume, (1200.0 - PI * 25.0) * 10.0) < 5e-4, "{}", m.volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn through_cut_preserves_a_larger_blind_pocket() {
|
||||
let plate = box_solid(Vec3::new(-30.0, -28.0, 0.0), Vec3::new(60.0, 22.0, 8.0)).unwrap();
|
||||
let pocket = cylinder(Vec3::ZERO, Vec3::Z, 11.0, 6.0).unwrap();
|
||||
let through = cylinder(Vec3::new(0.0, 0.0, -1.0), Vec3::Z, 9.5, 10.0).unwrap();
|
||||
let expected = 90.0 * 50.0 * 8.0 - PI * 11.0_f64.powi(2) * 6.0 - PI * 9.5_f64.powi(2) * 2.0;
|
||||
for reverse in [false, true] {
|
||||
let (first, second) = if reverse { (&through, &pocket) } else { (&pocket, &through) };
|
||||
let cut = boolean(&plate, first, BoolOp::Cut).unwrap().unwrap();
|
||||
let result = boolean(&cut, second, BoolOp::Cut).unwrap().unwrap();
|
||||
let m = measure(&result).unwrap();
|
||||
assert!(rel(m.volume, expected) < 5e-4, "reverse={reverse}: {} vs {expected}", m.volume);
|
||||
assert_eq!(m.merged.faces, 9);
|
||||
assert!(!result.tessellate(0.01).unwrap().contains(Vec3::new(10.0, 0.0, 3.0)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_a_cylinder_on_its_chamfered_top() {
|
||||
for seam in [0.0, crate::build::SEAM_ANGLE] {
|
||||
let region = Region2 { outer: Loop2::circle_from(Vec2::ZERO, 10.0, seam), holes: vec![] };
|
||||
let base = extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().pop().unwrap();
|
||||
let base = chamfer(&base, &[Vec3::new(10.0, 0.0, 20.0)], 2.0).unwrap();
|
||||
let region = Region2 { outer: Loop2::circle_from(Vec2::new(0.0, -3.774758283725534e-15), 8.0, seam), holes: vec![] };
|
||||
let plane = Plane { origin: Vec3::new(0.0, 0.0, 20.000000000000007), ..Plane::XY };
|
||||
let tool = extrude(&plane, &[region], 0.0, 5.0).unwrap().pop().unwrap();
|
||||
let expected = measure(&base).unwrap().volume + PI * 64.0 * 5.0;
|
||||
let joined = boolean(&base, &tool, BoolOp::Union).unwrap().unwrap();
|
||||
assert!(rel(measure(&joined).unwrap().volume, expected) < 5e-4);
|
||||
assert_eq!(joined.lumps().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_fillet_cut_volume() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||
@@ -225,6 +258,38 @@ fn loft_and_sweep() {
|
||||
assert!(rel(measure(&t).unwrap().volume, expect) < 1e-3, "{} vs {expect}", measure(&t).unwrap().volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_round_a_sharp_corner_is_mitred() {
|
||||
// A 2 × 2 square centred on an L of two 10 mm lines: lines alone would slide the profile
|
||||
// along the second leg within its own plane (an empty, inside-out body). Mitred, the
|
||||
// volume is the area times the path length.
|
||||
let sq =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(-1.0, -1.0), Vec2::new(1.0, -1.0), Vec2::new(1.0, 1.0), Vec2::new(-1.0, 1.0)]), holes: vec![] };
|
||||
let path = [
|
||||
PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 10.0) },
|
||||
PathSeg::Line { a: Vec3::new(0.0, 0.0, 10.0), b: Vec3::new(10.0, 0.0, 10.0) },
|
||||
];
|
||||
let b = sweep(&Plane::XY, &sq, &path).unwrap();
|
||||
let m = measure(&b).unwrap();
|
||||
assert!(rel(m.volume, 80.0) < 1e-9, "{}", m.volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_body_with_an_intersection_curve_edge_ends() {
|
||||
// A cylinder cut by a tilted box keeps an elliptical intersection curve as an edge. Moving
|
||||
// such a body scales the curve's tolerance by the matrix norm, whose root refinement cycled
|
||||
// forever for about one translation in eight (this one included): the app hung.
|
||||
let cyl = cylinder(Vec3::ZERO, Vec3::Z, 5.0, 40.0).unwrap();
|
||||
let block = box_solid(Vec3::new(-20.0, -20.0, 20.0), Vec3::new(20.0, 20.0, 60.0)).unwrap();
|
||||
let block = crate::ops::transform(&block, Vec3::ZERO, Vec3::new(0.0, 0.0, 20.0), Vec3::X, 0.4).unwrap();
|
||||
let cut = boolean(&cyl, &block, BoolOp::Cut).unwrap().unwrap();
|
||||
assert!(cut.solid.edge_iter().any(|e| matches!(e.curve(), truck_modeling::Curve::IntersectionCurve(_))));
|
||||
let t = Vec3::new(0.6911, -0.3337, -0.6127) * 2.334;
|
||||
let moved = crate::ops::transform(&cut, t, Vec3::ZERO, Vec3::Z, 0.0).unwrap();
|
||||
let (v0, v1) = (measure(&cut).unwrap().volume, measure(&moved).unwrap().volume);
|
||||
assert!(rel(v0, v1) < 1e-6, "{v0} vs {v1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planar_booleans_with_coincident_faces() {
|
||||
// L-bracket: a wall flush with three sides of its base.
|
||||
@@ -237,6 +302,8 @@ fn planar_booleans_with_coincident_faces() {
|
||||
// Through the public boolean (B-rep first, polygon fallback).
|
||||
let u2 = boolean(&base, &wall, BoolOp::Union).unwrap().unwrap();
|
||||
assert!(rel(measure(&u2).unwrap().volume, m.volume) < 1e-9);
|
||||
assert_eq!(u2.face_count(), 8, "coplanar wall and base faces must merge in the B-rep");
|
||||
assert!(u2.validity().is_empty());
|
||||
// Two boxes sharing a face, and a cut flush with a side.
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
|
||||
let b = box_solid(Vec3::new(10.0, 0.0, 0.0), Vec3::new(20.0, 10.0, 10.0)).unwrap();
|
||||
@@ -250,6 +317,32 @@ fn planar_booleans_with_coincident_faces() {
|
||||
assert!(rel(measure(&i).unwrap().volume, 250.0) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_boss_on_stepped_case_has_one_exact_top_face() {
|
||||
let outer = box_solid(Vec3::new(-50.0, -25.0, 0.0), Vec3::new(50.0, 25.0, 25.0)).unwrap();
|
||||
let cavity = box_solid(Vec3::new(-48.0, -23.0, 2.0), Vec3::new(48.0, 23.0, 26.0)).unwrap();
|
||||
let case = boolean(&outer, &cavity, BoolOp::Cut).unwrap().unwrap();
|
||||
let boss = cylinder(Vec3::new(-45.0, 20.0, 2.0), Vec3::Z, 5.0, 23.0).unwrap();
|
||||
let joined = boolean(&case, &boss, BoolOp::Union).unwrap().unwrap();
|
||||
let common = boolean(&case, &boss, BoolOp::Intersect).unwrap().unwrap();
|
||||
let cut = boolean(&case, &boss, BoolOp::Cut).unwrap().unwrap();
|
||||
let (a, b, u, i, c) =
|
||||
(measure(&case).unwrap(), measure(&boss).unwrap(), measure(&joined).unwrap(), measure(&common).unwrap(), measure(&cut).unwrap());
|
||||
assert!(rel(u.volume + i.volume, a.volume + b.volume) < 1e-4);
|
||||
assert!(rel(c.volume + i.volume, a.volume) < 1e-4);
|
||||
for body in [&joined, &common, &cut] {
|
||||
assert!(body.validity().is_empty());
|
||||
}
|
||||
assert!((u.bbox.max.z - 25.0).abs() < 1e-9, "the boss must not be nudged above the rim");
|
||||
let top_faces = |body: &Body| {
|
||||
body.faces(0.01).unwrap().iter().filter(|f| f.plane_normal.is_some_and(|n| n.z > 0.999999) && (f.centroid.z - 25.0).abs() < 1e-9).count()
|
||||
};
|
||||
assert_eq!(top_faces(&joined), 1, "the boss and both walls share one top face");
|
||||
let rounded = fillet(&joined, &[Vec3::new(-50.0, 25.0, 12.5)], 5.0).unwrap();
|
||||
assert!(rounded.validity().is_empty());
|
||||
assert_eq!(top_faces(&rounded), 1, "rounding the outside corner must retain the merged top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sphere_booleans() {
|
||||
let s = sphere(Vec3::ZERO, 15.0).unwrap();
|
||||
@@ -370,6 +463,50 @@ fn chamfer_top_loop_mitres() {
|
||||
assert_eq!(m.merged.faces, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfer_sloped_basin_rim() {
|
||||
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
|
||||
let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
|
||||
let pocket =
|
||||
loft(&[(Plane::XY.offset(30.0), rectangle(20.0, 4.0, 80.0, 50.0)), (Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0))]).unwrap();
|
||||
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
|
||||
let result = chamfer(&basin, &[Vec3::new(50.0, 4.0, 30.0)], 2.0).unwrap();
|
||||
// The removed triangular section has area s² sin(phi)/2. Its length varies
|
||||
// linearly between the sloped end walls, so its centroid gives the mean length.
|
||||
let wall_slope: f64 = (20.0 - 4.0) / (30.0 - 10.0); // horizontal run / vertical drop
|
||||
let drop = 2.0 / (1.0 + wall_slope.powi(2)).sqrt();
|
||||
let removed = drop * (60.0 - drop / 3.0);
|
||||
let before = measure(&basin).unwrap();
|
||||
let after = measure(&result).unwrap();
|
||||
assert!(rel(after.volume, before.volume - removed) < 1e-8, "{} vs {}", after.volume, before.volume - removed);
|
||||
assert_eq!(after.merged.faces, before.merged.faces + 1);
|
||||
assert_eq!(result.lumps().unwrap().len(), 1);
|
||||
assert!(result.validity().is_empty());
|
||||
let mesh = result.tessellate(0.01).unwrap();
|
||||
assert!(!mesh.contains(Vec3::new(50.0, 3.5, 29.9)));
|
||||
assert!(mesh.contains(Vec3::new(50.0, 3.5, 28.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfer_wider_than_sloped_basin_rim_is_refused() {
|
||||
for (margin, distance) in [(1.0, 2.0), (1.0, 3.0), (0.5, 4.0), (1.0, 1.0)] {
|
||||
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
|
||||
let rectangle = |x0, y0, x1, y1| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
|
||||
let pocket =
|
||||
loft(&[(Plane::XY.offset(30.0), rectangle(20.0, margin, 80.0, 50.0)), (Plane::XY.offset(10.0), rectangle(30.0, 20.0, 70.0, 40.0))])
|
||||
.unwrap();
|
||||
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
|
||||
let before = measure(&basin).unwrap();
|
||||
let error = chamfer(&basin, &[Vec3::new(50.0, margin, 30.0)], distance).unwrap_err();
|
||||
assert!(error.to_string().contains("larger than the neighbouring faces"), "{margin}, {distance}: {error}");
|
||||
assert_eq!(measure(&basin).unwrap().volume, before.volume);
|
||||
// A smaller chamfer on the same thin rim is still supported.
|
||||
let smaller = chamfer(&basin, &[Vec3::new(50.0, margin, 30.0)], margin * 0.5).unwrap();
|
||||
assert!(smaller.validity().is_empty());
|
||||
assert!(measure(&smaller).unwrap().volume < before.volume);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_chamfer_cut() {
|
||||
@@ -1344,7 +1481,8 @@ fn boolean_identity_survey() {
|
||||
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();
|
||||
let plane = Plane { y: Vec3::Z, ..Plane::XZ }; // This fixture's rectangle is above z = 0.
|
||||
let pie = revolve(&plane, &[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}");
|
||||
@@ -1361,8 +1499,12 @@ fn partial_revolve_on_the_axis() {
|
||||
/// 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);
|
||||
// Independent seeds, one thread each.
|
||||
let cases: Vec<_> = std::thread::scope(|scope| {
|
||||
let workers = [1, 32, 169].map(|seed| scope.spawn(move || (seed, crate::fuzz::boolean_case(seed))));
|
||||
workers.into_iter().map(|w| w.join().unwrap()).collect()
|
||||
});
|
||||
for (seed, c) in cases {
|
||||
assert_eq!(c.outcome, "ok", "seed {seed}: {} {:?} {:?}", c.detail, c.a, c.b);
|
||||
}
|
||||
}
|
||||
@@ -1435,3 +1577,20 @@ fn raise_band_on_a_rounded_rim() {
|
||||
assert!((v - (v0 + band * 1.5)).abs() < 1e-5 * v, "outside {outside}: {v} vs {}", v0 + band * 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_mesh_reuses_missing_face_check_at_matching_tolerance() {
|
||||
for scale in [0.001, 1.0, 1000.0] {
|
||||
let body = box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.) * scale).unwrap();
|
||||
let tol = (body.size() * 0.01).max(1e-3);
|
||||
let mesh = body.tessellate(tol).unwrap();
|
||||
let mut present = vec![false; body.face_count()];
|
||||
for face in mesh.tri_face {
|
||||
present[face as usize] = true;
|
||||
}
|
||||
assert_eq!(body.unmeshed_faces(), present.iter().filter(|p| !**p).count());
|
||||
if scale == 1.0 {
|
||||
assert_eq!(body.coarse_missing, Some((tol, 0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +213,52 @@ fn batch_stops_at_first_error_and_rolls_back() {
|
||||
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 2);
|
||||
}
|
||||
|
||||
/// Regression: with a full undo history a failed batch reported `undone: 0` and left its
|
||||
/// sketches behind (an empty "Sketch1", two sketches named "TapHole").
|
||||
#[test]
|
||||
fn batch_rolls_back_with_a_full_undo_history() {
|
||||
let mut s = server();
|
||||
let fill: Vec<Value> =
|
||||
(0..230).map(|i| json!({"command": "parameters.add", "params": {"name": format!("p{i}"), "expression": "1 mm"}})).collect();
|
||||
tool(&mut s, "batch", json!({"commands": fill}));
|
||||
let undo = tool(&mut s, "inspect_design", json!({"measure": false}))["undo"].clone();
|
||||
for _ in 0..2 {
|
||||
let r = raw_tool(
|
||||
&mut s,
|
||||
"batch",
|
||||
json!({"commands": [
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "TapHole"}},
|
||||
{"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 2}},
|
||||
{"command": "sketch.dimension", "params": {"entities": ["nope"], "value": 3}},
|
||||
]}),
|
||||
);
|
||||
let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(body["failed"]["index"], 2, "{body}");
|
||||
assert_eq!(body["undone"], 2, "{body}");
|
||||
}
|
||||
let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
|
||||
assert_eq!(d["sketches"].as_array().map(Vec::len), Some(0), "{d}");
|
||||
assert!(d["active_sketch"].is_null(), "{d}");
|
||||
assert_eq!(d["undo"], undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_sketches_can_be_hidden() {
|
||||
let mut s = server();
|
||||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 40, "height": 2}}));
|
||||
let sk = tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}))["sketch"].clone();
|
||||
tool(&mut s, "execute", json!({"command": "sketch.line", "params": {"points": [[0, 0], [40, 0]], "construction": true}}));
|
||||
tool(&mut s, "execute", json!({"command": "sketch.finish"}));
|
||||
let visible = |s: &mut Server| tool(s, "inspect_design", json!({"measure": false}))["sketches"][0]["visible"].clone();
|
||||
assert_eq!(visible(&mut s), true);
|
||||
tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": [sk], "visible": false}}));
|
||||
assert_eq!(visible(&mut s), false);
|
||||
let r = raw_tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": ["Nope"], "visible": false}}));
|
||||
assert_eq!(r["result"]["isError"], true);
|
||||
tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"folder": "sketches", "visible": true}}));
|
||||
assert_eq!(visible(&mut s), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tools_and_bad_arguments_are_clean_errors() {
|
||||
let mut s = server();
|
||||
|
||||
+55
-12
@@ -129,7 +129,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"set_parameter",
|
||||
"Set a parameter",
|
||||
"Create or change a user parameter and re-evaluate the timeline. `value` is a number (mm) or an expression \
|
||||
(\"40 mm\", \"width * 2\", \"30 deg\"). Returns the new value, the features recomputed and any features that now fail.",
|
||||
(\"40 mm\", \"width * 2\", \"30 deg\"). Returns the new value, the features recomputed (`recomputed`), the features whose earlier \
|
||||
result for the same inputs was reused instead (`restored`, e.g. after setting a value back) and any features that now fail.",
|
||||
obj(
|
||||
json!({
|
||||
"name": string("Parameter name"),
|
||||
@@ -360,7 +361,56 @@ fn undo_depth(b: &mut dyn Backend) -> Option<u64> {
|
||||
b.call("document.inspect", json!({})).ok().and_then(|d| d["undo"].as_u64())
|
||||
}
|
||||
|
||||
/// `batch`: run steps in order, stop at the first failure and (by default) undo what ran.
|
||||
/// How a failed batch goes back to where it started.
|
||||
enum Undo {
|
||||
/// An engine checkpoint: the design, active sketch and undo history exactly as they were.
|
||||
Checkpoint(u64),
|
||||
/// An app without checkpoints: undo as many steps as the batch added (wrong once the undo
|
||||
/// history is full, as its depth no longer grows).
|
||||
Depth(u64),
|
||||
None,
|
||||
}
|
||||
|
||||
impl Undo {
|
||||
fn begin(b: &mut dyn Backend) -> Undo {
|
||||
match exec(b, "edit.checkpoint", json!({})).ok().and_then(|v| v["checkpoint"].as_u64()) {
|
||||
Some(id) => Undo::Checkpoint(id),
|
||||
None => undo_depth(b).map_or(Undo::None, Undo::Depth),
|
||||
}
|
||||
}
|
||||
|
||||
/// Go back; the number of steps undone.
|
||||
fn rollback(self, b: &mut dyn Backend, steps: usize) -> u64 {
|
||||
match self {
|
||||
Undo::Checkpoint(id) => match exec(b, "edit.restore_checkpoint", json!({"checkpoint": id})) {
|
||||
Ok(_) => steps as u64,
|
||||
Err(_) => 0,
|
||||
},
|
||||
Undo::Depth(before) => {
|
||||
let after = undo_depth(b).unwrap_or(before);
|
||||
let mut undone = 0u64;
|
||||
for _ in before..after {
|
||||
if exec(b, "edit.undo", json!({})).is_err() {
|
||||
break;
|
||||
}
|
||||
undone += 1;
|
||||
}
|
||||
undone
|
||||
}
|
||||
Undo::None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The batch went through: forget the checkpoint.
|
||||
fn finish(self, b: &mut dyn Backend) {
|
||||
if let Undo::Checkpoint(id) = self {
|
||||
let _ = exec(b, "edit.restore_checkpoint", json!({"checkpoint": id, "forget": true}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `batch`: run steps in order, stop at the first failure and (by default) go back to the
|
||||
/// design as it was before the batch.
|
||||
fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
||||
let Some(steps) = a.get("commands").and_then(Value::as_array) else {
|
||||
return text_result(&json!("`commands` must be an array of {command, params}"), true);
|
||||
@@ -369,7 +419,7 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
||||
return text_result(&json!(format!("batch too long ({} steps, at most {MAX_BATCH})", steps.len())), true);
|
||||
}
|
||||
let rollback = a.get("rollback").and_then(Value::as_bool).unwrap_or(true);
|
||||
let before = if rollback { undo_depth(b) } else { None };
|
||||
let start = if rollback { Undo::begin(b) } else { Undo::None };
|
||||
let mut results = Vec::with_capacity(steps.len());
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str);
|
||||
@@ -380,15 +430,7 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
||||
match r {
|
||||
Ok(v) => results.push(v),
|
||||
Err(e) => {
|
||||
let mut undone = 0u64;
|
||||
if let (Some(before), Some(after)) = (before, rollback.then(|| undo_depth(b)).flatten()) {
|
||||
for _ in before..after {
|
||||
if exec(b, "edit.undo", json!({})).is_err() {
|
||||
break;
|
||||
}
|
||||
undone += 1;
|
||||
}
|
||||
}
|
||||
let undone = start.rollback(b, i);
|
||||
return text_result(
|
||||
&json!({
|
||||
"ok": false,
|
||||
@@ -403,6 +445,7 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
|
||||
}
|
||||
}
|
||||
}
|
||||
start.finish(b);
|
||||
text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false)
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ pub struct Scene {
|
||||
pub background: Option<(Rgb, Rgb)>,
|
||||
/// Scene radius for clipping planes.
|
||||
pub radius: f64,
|
||||
/// A section cut: (normal, d); what lies where normal·p > d is cut away, and the inside
|
||||
/// shows as a hatched cap.
|
||||
pub clip: Option<(Vec3, f64)>,
|
||||
}
|
||||
|
||||
/// RGBA pixels plus depth.
|
||||
@@ -109,6 +112,9 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
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 wp = t.map(|k| m.positions.get(k as usize).copied().unwrap_or_default());
|
||||
// Seen from inside (only where a section cuts the body open).
|
||||
let inside = (wp[1] - wp[0]).cross(wp[2] - wp[0]).dot(view_dir) < 0.0;
|
||||
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;
|
||||
};
|
||||
@@ -134,6 +140,11 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 {
|
||||
continue;
|
||||
}
|
||||
if let Some((cn, cd)) = scene.clip
|
||||
&& cn.dot(wp[0] * w0 + wp[1] * w1 + wp[2] * w2) > cd
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let z = (w0 * a[2] + w1 * b[2] + w2 * cc[2]) as f32;
|
||||
let i = y * sw + x;
|
||||
if c.depth.get(i).is_some_and(|d| z < *d) {
|
||||
@@ -141,6 +152,12 @@ 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);
|
||||
if scene.clip.is_some() && inside {
|
||||
// The cut: the body's colour, hatched at 45 degrees.
|
||||
let k = if ((x + y) / SS) % 9 < 2 { 0.5 } else { 0.8 };
|
||||
c.put(x, y, base.map(|v| v * k), 1.0);
|
||||
continue;
|
||||
}
|
||||
let mut col = shade(n, view_dir, base);
|
||||
let ndv = n.dot(view_dir).abs();
|
||||
if curved && ndv < 0.14 {
|
||||
@@ -165,6 +182,11 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
let steps = (len.ceil() as usize).clamp(1, 20_000);
|
||||
for k in 0..=steps {
|
||||
let t = k as f64 / steps as f64;
|
||||
if let Some((cn, cd)) = scene.clip
|
||||
&& cn.dot(*p + (*q - *p) * t) > cd + 1e-6
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (x, y, z) = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t);
|
||||
let r = half.ceil() as i64;
|
||||
for dy in -r..=r {
|
||||
@@ -268,6 +290,7 @@ mod tests {
|
||||
lines: vec![],
|
||||
background: None,
|
||||
radius: 20.0,
|
||||
clip: None,
|
||||
};
|
||||
let mut cam = Camera { distance: 50.0, ..Default::default() };
|
||||
cam.set_view(StandardView::Front);
|
||||
|
||||
@@ -13,6 +13,7 @@ mod dims;
|
||||
mod linalg;
|
||||
mod link;
|
||||
mod model;
|
||||
mod offset;
|
||||
mod profiles;
|
||||
mod shape;
|
||||
mod solver;
|
||||
@@ -25,9 +26,10 @@ pub use curves::{
|
||||
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
|
||||
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
|
||||
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError, SketchView};
|
||||
pub use offset::OffsetChain;
|
||||
pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions};
|
||||
pub use shape::{Shape, intersections};
|
||||
pub use solver::{Hold, SolveReport, SolveStatus, solve, solve_holding};
|
||||
pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding};
|
||||
pub use text::{MAX_TEXT_CHARS, text_geometry};
|
||||
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, fit_polyline};
|
||||
|
||||
|
||||
@@ -104,6 +104,9 @@ pub struct Link {
|
||||
#[serde(default)]
|
||||
pub kind: LinkKind,
|
||||
pub source: LinkSource,
|
||||
/// Persistent model face identity, resolved by the document before geometry refresh.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub face_name: Option<String>,
|
||||
/// The reference could not be found on the last recompute; the geometry is the last good one.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub lost: bool,
|
||||
@@ -169,7 +172,7 @@ struct Layout {
|
||||
curves: Vec<LCurve>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum LCurve {
|
||||
Line(usize, usize),
|
||||
Circle(usize, f64),
|
||||
@@ -368,7 +371,7 @@ impl Sketch {
|
||||
return Err(SketchError::TooLarge);
|
||||
}
|
||||
let id = self.fresh("j");
|
||||
self.links.push(Link { id: id.clone(), kind, source, lost: false });
|
||||
self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false });
|
||||
self.build_link(&id, &lay)?;
|
||||
Ok(id)
|
||||
}
|
||||
@@ -579,6 +582,77 @@ impl Sketch {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
// A resized face can enumerate the same boundary in another order. Match the
|
||||
// shared-point graph in normalized sketch space before discarding its identity.
|
||||
if let Some(cur) = &cur
|
||||
&& points.len() == lay.pts.len()
|
||||
&& cur.len() == lay.curves.len()
|
||||
&& points.len() <= 512
|
||||
{
|
||||
let old: Vec<Vec2> = points.iter().filter_map(|i| self.point(*i)).collect();
|
||||
let normalized = |ps: &[Vec2]| -> Vec<Vec2> {
|
||||
let lo = ps.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y)));
|
||||
let hi = ps.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y)));
|
||||
ps.iter().map(|p| Vec2::new((p.x - lo.x) / (hi.x - lo.x).max(1e-9), (p.y - lo.y) / (hi.y - lo.y).max(1e-9))).collect()
|
||||
};
|
||||
let (a, b) = (normalized(&old), normalized(&lay.pts));
|
||||
let mut mapping = Vec::new();
|
||||
for p in &a {
|
||||
let mut candidates: Vec<(usize, f64)> = b.iter().enumerate().map(|(i, q)| (i, p.dist(*q))).collect();
|
||||
candidates.sort_by(|x, y| x.1.total_cmp(&y.1));
|
||||
if let Some((i, d)) = candidates.first()
|
||||
&& *d < 0.1
|
||||
&& candidates.get(1).is_none_or(|(_, second)| *d < *second * 0.5)
|
||||
&& !mapping.contains(i)
|
||||
{
|
||||
mapping.push(*i);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut used = Vec::new();
|
||||
let mut curve_map = Vec::new();
|
||||
if mapping.len() == points.len() {
|
||||
for c in cur {
|
||||
let mapped: Option<Vec<usize>> = curve_points(c).iter().map(|i| mapping.get(*i).copied()).collect();
|
||||
let found = lay
|
||||
.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(i, fresh)| {
|
||||
if used.contains(i) || !same_shape(c, fresh) {
|
||||
return false;
|
||||
}
|
||||
let fresh_pts = curve_points(fresh);
|
||||
mapped.as_ref().is_some_and(|p| {
|
||||
*p == fresh_pts || matches!(c, LCurve::Line(..)) && p.iter().rev().copied().collect::<Vec<_>>() == fresh_pts
|
||||
})
|
||||
})
|
||||
.map(|(i, _)| i);
|
||||
if let Some(i) = found {
|
||||
used.push(i);
|
||||
curve_map.push(i);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if curve_map.len() == cur.len() && mapping.len() == points.len() {
|
||||
for (pi, li) in points.iter().zip(mapping) {
|
||||
if let (Some(p), Some(q)) = (self.points.get_mut(*pi), lay.pts.get(li)) {
|
||||
p.pos = *q;
|
||||
}
|
||||
}
|
||||
for (ci, li) in curves.iter().zip(curve_map) {
|
||||
if let (Some(Curve { kind: CurveKind::Circle { r, .. }, .. }), Some(LCurve::Circle(_, value))) =
|
||||
(self.curves.get_mut(*ci), lay.curves.get(li))
|
||||
{
|
||||
*r = *value;
|
||||
}
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
// Rebuild: drop the old entities (constraints on them go too) and make new ones.
|
||||
self.drop_link_entities(id);
|
||||
self.build_link(id, &lay)?;
|
||||
|
||||
@@ -467,6 +467,8 @@ pub struct Sketch {
|
||||
/// Links to geometry outside the sketch (projections, intersections, includes).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub links: Vec<crate::link::Link>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub offsets: Vec<crate::offset::OffsetChain>,
|
||||
/// 3D curves (world coordinates) carried by the sketch.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub wires: Vec<crate::wire::Wire>,
|
||||
@@ -495,6 +497,7 @@ impl Sketch {
|
||||
curves: Vec::new(),
|
||||
constraints: Vec::new(),
|
||||
links: Vec::new(),
|
||||
offsets: Vec::new(),
|
||||
wires: Vec::new(),
|
||||
counters: Default::default(),
|
||||
view: SketchView::default(),
|
||||
@@ -695,10 +698,8 @@ impl Sketch {
|
||||
| Symmetric { l, .. }
|
||||
| PointLineDistance { l, .. }
|
||||
| LinearDiameter { l, .. } => need_line(l)?,
|
||||
ArcLength { c, .. } => {
|
||||
if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) {
|
||||
return Err(SketchError::WrongKind(name(c), "arc length needs an arc".into()));
|
||||
}
|
||||
ArcLength { c, .. } if !matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Arc { .. })) => {
|
||||
return Err(SketchError::WrongKind(name(c), "arc length needs an arc".into()));
|
||||
}
|
||||
Smooth { a, b } => {
|
||||
let ends = |i: usize| self.curves.get(i).and_then(|c| c.kind.ends());
|
||||
@@ -727,10 +728,8 @@ impl Sketch {
|
||||
return Err(SketchError::WrongKind(name(a), "tangent needs a circle or arc".into()));
|
||||
}
|
||||
}
|
||||
Equal { a, b } => {
|
||||
if is_line(a) != is_line(b) {
|
||||
return Err(SketchError::WrongKind(name(b), "equal needs two lines or two circles/arcs".into()));
|
||||
}
|
||||
Equal { a, b } if is_line(a) != is_line(b) => {
|
||||
return Err(SketchError::WrongKind(name(b), "equal needs two lines or two circles/arcs".into()));
|
||||
}
|
||||
Coincident { p, q } if p == q => return Err(SketchError::Invalid("a point is always coincident with itself".into())),
|
||||
_ => {}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Associative offsets keep their output entity ids while their source curves move.
|
||||
use crate::{CurveKind, Shape, Sketch, SketchError, intersections};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::Vec2;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OffsetChain {
|
||||
pub sources: Vec<(String, bool)>,
|
||||
pub outputs: Vec<String>,
|
||||
pub distance: f64,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
impl Sketch {
|
||||
pub fn refresh_offsets(&mut self) -> Result<(), SketchError> {
|
||||
for offset in self.offsets.clone() {
|
||||
if offset.sources.len() != offset.outputs.len() || offset.sources.len() > 10_000 || !offset.distance.is_finite() {
|
||||
return Err(SketchError::Invalid("damaged offset chain".into()));
|
||||
}
|
||||
let bad = || SketchError::Invalid("an offset source vanished or changed shape".into());
|
||||
let mut pieces = Vec::new();
|
||||
for (id, forward) in &offset.sources {
|
||||
let i = self.curve_index(id).ok_or_else(bad)?;
|
||||
let kind = &self.curves.get(i).ok_or_else(bad)?.kind;
|
||||
let piece = match *kind {
|
||||
CurveKind::Line { a, b } => {
|
||||
let (a, b) = (self.point(a).ok_or_else(bad)?, self.point(b).ok_or_else(bad)?);
|
||||
let (a, b) = if *forward { (a, b) } else { (b, a) };
|
||||
let n = (b - a).normalized().ok_or_else(bad)?.perp() * offset.distance;
|
||||
(Shape::Line { a: a + n, b: b + n }, a + n, b + n)
|
||||
}
|
||||
CurveKind::Arc { c, a, b } => {
|
||||
let (c, a, b) = (self.point(c).ok_or_else(bad)?, self.point(a).ok_or_else(bad)?, self.point(b).ok_or_else(bad)?);
|
||||
let r = c.dist(a) - if *forward { offset.distance } else { -offset.distance };
|
||||
if r <= 1e-9 {
|
||||
return Err(SketchError::Invalid("the offset collapses an arc".into()));
|
||||
}
|
||||
let a = c + (a - c).normalized().ok_or_else(bad)? * r;
|
||||
let b = c + (b - c).normalized().ok_or_else(bad)? * r;
|
||||
let (a, b) = if *forward { (a, b) } else { (b, a) };
|
||||
(Shape::Round { c, r, start: 0.0, sweep: std::f64::consts::TAU }, a, b)
|
||||
}
|
||||
CurveKind::Circle { c, r } => {
|
||||
let c = self.point(c).ok_or_else(bad)?;
|
||||
let r = r - offset.distance;
|
||||
if r <= 1e-9 {
|
||||
return Err(SketchError::Invalid("the offset collapses a circle".into()));
|
||||
}
|
||||
let out = offset.outputs.first().and_then(|id| self.curve_index(id)).ok_or_else(bad)?;
|
||||
if let Some(crate::Curve { kind: CurveKind::Circle { r: target, .. }, .. }) = self.curves.get_mut(out) {
|
||||
*target = r;
|
||||
} else {
|
||||
return Err(bad());
|
||||
}
|
||||
let _ = c;
|
||||
continue;
|
||||
}
|
||||
_ => return Err(bad()),
|
||||
};
|
||||
pieces.push(piece);
|
||||
}
|
||||
if pieces.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let joint = |i: usize, j: usize| -> Result<Vec2, SketchError> {
|
||||
let (x, y) = (pieces.get(i).ok_or_else(bad)?, pieces.get(j).ok_or_else(bad)?);
|
||||
if x.2.dist(y.1) < 1e-6 {
|
||||
return Ok(x.2);
|
||||
}
|
||||
let guess = (x.2 + y.1) * 0.5;
|
||||
intersections(&x.0, &y.0)
|
||||
.into_iter()
|
||||
.min_by(|a, b| a.dist(guess).total_cmp(&b.dist(guess)))
|
||||
.ok_or_else(|| SketchError::Invalid("the offset curves no longer meet".into()))
|
||||
};
|
||||
let n = pieces.len();
|
||||
let mut joints = vec![if offset.closed { joint(n - 1, 0)? } else { pieces.first().ok_or_else(bad)?.1 }];
|
||||
for i in 0..n.saturating_sub(1) {
|
||||
joints.push(joint(i, i + 1)?);
|
||||
}
|
||||
if !offset.closed {
|
||||
joints.push(pieces.last().ok_or_else(bad)?.2);
|
||||
}
|
||||
for (i, id) in offset.outputs.iter().enumerate() {
|
||||
let a = *joints.get(i).ok_or_else(bad)?;
|
||||
let b = *joints.get(if offset.closed { (i + 1) % n } else { i + 1 }).ok_or_else(bad)?;
|
||||
let shape = &pieces.get(i).ok_or_else(bad)?.0;
|
||||
if let Shape::Line { a: p, b: q } = shape
|
||||
&& (b - a).dot(*q - *p) <= 1e-9
|
||||
{
|
||||
return Err(SketchError::Invalid("the offset curves would cross".into()));
|
||||
}
|
||||
let ci = self.curve_index(id).ok_or_else(bad)?;
|
||||
let kind = self.curves.get(ci).ok_or_else(bad)?.kind.clone();
|
||||
let (pa, pb) = match kind {
|
||||
CurveKind::Line { a, b } => (a, b),
|
||||
CurveKind::Arc { a, b, .. } => {
|
||||
if offset.sources.get(i).is_some_and(|x| x.1) {
|
||||
(a, b)
|
||||
} else {
|
||||
(b, a)
|
||||
}
|
||||
}
|
||||
_ => return Err(bad()),
|
||||
};
|
||||
for (pi, pos) in [(pa, a), (pb, b)] {
|
||||
if let Some(p) = self.points.get_mut(pi) {
|
||||
p.pos = pos;
|
||||
p.fixed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -456,6 +456,13 @@ fn measure(s: &State, k: &ConstraintKind) -> Option<f64> {
|
||||
})
|
||||
}
|
||||
|
||||
/// What a dimension measures on the sketch as it stands (mm, radians for angles); `None` for a
|
||||
/// constraint that is not a dimension.
|
||||
pub fn measure_dimension(sk: &Sketch, k: &ConstraintKind) -> Option<f64> {
|
||||
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0 };
|
||||
measure(&State { sk, lay: &lay, x: &[] }, k).filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// One residual block: implicit arc radius equality, a user constraint, or a tangency at a known
|
||||
/// touch point (better conditioned than the distance form when an end point is on the curve).
|
||||
enum Block<'a> {
|
||||
|
||||
@@ -74,7 +74,7 @@ pub fn text_geometry(text: &str, at: Vec2, height: f64, angle: f64) -> Result<Ve
|
||||
if text.chars().count() > MAX_TEXT_CHARS {
|
||||
return Err(SketchError::TooLarge);
|
||||
}
|
||||
if !(height.is_finite() && height > 1e-6 && height < 1e6) || !angle.is_finite() || !at.is_finite() {
|
||||
if !(height.is_finite() && height > 1e-6 && height < 1e6 && angle.is_finite() && at.is_finite()) {
|
||||
return Err(SketchError::Invalid("text height must be positive".into()));
|
||||
}
|
||||
let face = ttf_parser::Face::parse(FONT, 0).map_err(|e| SketchError::Invalid(format!("font: {e}")))?;
|
||||
|
||||
@@ -98,7 +98,7 @@ impl Sketch {
|
||||
}
|
||||
let id = if geom.is_empty() {
|
||||
let id = self.fresh("j");
|
||||
self.links.push(Link { id: id.clone(), kind, source, lost: false });
|
||||
self.links.push(Link { id: id.clone(), kind, source, face_name: None, lost: false });
|
||||
id
|
||||
} else {
|
||||
self.add_link(kind, source, geom)?
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn map(app: &SolveApp) -> Option<AccessMap> {
|
||||
return Some(m);
|
||||
}
|
||||
let st = app.session.world_state();
|
||||
let meshes: Vec<_> = st.bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(&b.name)).map(|b| b.mesh()).collect();
|
||||
let meshes: Vec<_> = st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name)).map(|b| b.mesh()).collect();
|
||||
// Every new map gets a new version (the GPU uploads it once).
|
||||
let version = VERSION.with(|v| {
|
||||
v.set(v.get() + 1);
|
||||
|
||||
@@ -80,19 +80,19 @@ impl OccMove {
|
||||
|
||||
/// Is a sketch drawn in the viewport?
|
||||
pub fn sketch_visible(app: &SolveApp, id: u64) -> bool {
|
||||
if app.ui.hidden_sketches.contains(&id) {
|
||||
if app.session.visibility.hidden_sketches.contains(&id) {
|
||||
return false;
|
||||
}
|
||||
app.ui.shown_sketches.contains(&id) || (app.ui.show_sketches && !solvecraft_engine::view::sketch_consumed(&app.session, id))
|
||||
app.session.visibility.shown_sketches.contains(&id) || (app.ui.show_sketches && !solvecraft_engine::view::sketch_consumed(&app.session, id))
|
||||
}
|
||||
|
||||
pub fn set_sketch_visible(app: &mut SolveApp, id: u64, on: bool) {
|
||||
app.ui.hidden_sketches.retain(|x| *x != id);
|
||||
app.ui.shown_sketches.retain(|x| *x != id);
|
||||
app.session.visibility.hidden_sketches.retain(|x| *x != id);
|
||||
app.session.visibility.shown_sketches.retain(|x| *x != id);
|
||||
if on {
|
||||
app.ui.shown_sketches.push(id);
|
||||
app.session.visibility.shown_sketches.push(id);
|
||||
} else {
|
||||
app.ui.hidden_sketches.push(id);
|
||||
app.session.visibility.hidden_sketches.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ pub fn group_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
];
|
||||
if g.folder == "bodies" {
|
||||
let bodies = group_bodies(app, id);
|
||||
let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.ui.hidden_bodies.contains(b));
|
||||
let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.session.visibility.hidden_bodies.contains(b));
|
||||
v.push(Item::sep());
|
||||
v.push(Item::action(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": bodies })));
|
||||
v.push(Item::action("ui.isolate", "Isolate", "").with(json!({ "bodies": bodies })));
|
||||
@@ -158,8 +158,8 @@ pub fn folder_items(app: &SolveApp, component: u64, folder: &str) -> Vec<Item> {
|
||||
|
||||
/// The menu of the Origin folder.
|
||||
pub fn origin_items(app: &SolveApp) -> Vec<Item> {
|
||||
let planes = ["XY", "XZ", "YZ"].iter().all(|p| !app.ui.hidden_origin.iter().any(|h| h == p));
|
||||
let axes = ["X", "Y", "Z"].iter().all(|p| !app.ui.hidden_origin.iter().any(|h| h == p));
|
||||
let planes = ["XY", "XZ", "YZ"].iter().all(|p| !app.session.visibility.hidden_origin.iter().any(|h| h == p));
|
||||
let axes = ["X", "Y", "Z"].iter().all(|p| !app.session.visibility.hidden_origin.iter().any(|h| h == p));
|
||||
vec![
|
||||
Item::action("ui.origin", "Show/Hide", "eye").key("V"),
|
||||
Item::action("ui.originAll", "Show All", "eye"),
|
||||
@@ -232,41 +232,41 @@ const INDENT: f32 = 14.0;
|
||||
|
||||
/// One row's look.
|
||||
#[derive(Default)]
|
||||
struct Row<'a> {
|
||||
depth: usize,
|
||||
pub(crate) struct Row<'a> {
|
||||
pub(crate) depth: usize,
|
||||
/// Some(open) for rows that fold.
|
||||
fold: Option<bool>,
|
||||
pub(crate) fold: Option<bool>,
|
||||
/// Some(visible) for rows with an eye.
|
||||
eye: Option<bool>,
|
||||
icon: &'a str,
|
||||
label: &'a str,
|
||||
selected: bool,
|
||||
dim: bool,
|
||||
color: Option<Color32>,
|
||||
pub(crate) eye: Option<bool>,
|
||||
pub(crate) icon: &'a str,
|
||||
pub(crate) label: &'a str,
|
||||
pub(crate) selected: bool,
|
||||
pub(crate) dim: bool,
|
||||
pub(crate) color: Option<Color32>,
|
||||
/// Component rows: is it the active one?
|
||||
radio: Option<bool>,
|
||||
pub(crate) radio: Option<bool>,
|
||||
/// Trailing badges: "lock", "pin".
|
||||
badges: &'a [&'a str],
|
||||
pub(crate) badges: &'a [&'a str],
|
||||
}
|
||||
|
||||
/// What happened on a row.
|
||||
#[derive(Default)]
|
||||
struct RowResp {
|
||||
rect: Option<Rect>,
|
||||
clicked: bool,
|
||||
double: bool,
|
||||
secondary: Option<Pos2>,
|
||||
fold: bool,
|
||||
eye: bool,
|
||||
radio: bool,
|
||||
hovered: bool,
|
||||
pub(crate) struct RowResp {
|
||||
pub(crate) rect: Option<Rect>,
|
||||
pub(crate) clicked: bool,
|
||||
pub(crate) double: bool,
|
||||
pub(crate) secondary: Option<Pos2>,
|
||||
pub(crate) fold: bool,
|
||||
pub(crate) eye: bool,
|
||||
pub(crate) radio: bool,
|
||||
pub(crate) hovered: bool,
|
||||
/// The row's response (drag and drop).
|
||||
resp: Option<egui::Response>,
|
||||
pub(crate) resp: Option<egui::Response>,
|
||||
/// The press began on the fold arrow, eye or radio (not a drag of the row).
|
||||
on_control: bool,
|
||||
pub(crate) on_control: bool,
|
||||
}
|
||||
|
||||
fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
let t = Tokens::get();
|
||||
// Rows scrolled out of view only take their space: no widgets, no painting.
|
||||
let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_H));
|
||||
@@ -449,11 +449,11 @@ fn sel_of_key(k: &str) -> Option<Sel> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_open(app: &SolveApp, key: &str, default: bool) -> bool {
|
||||
pub(crate) fn is_open(app: &SolveApp, key: &str, default: bool) -> bool {
|
||||
if default { !app.tree.collapsed.contains(key) } else { app.tree.expanded.contains(key) }
|
||||
}
|
||||
|
||||
fn toggle(app: &mut SolveApp, key: &str, default: bool) {
|
||||
pub(crate) fn toggle(app: &mut SolveApp, key: &str, default: bool) {
|
||||
if default {
|
||||
if !app.tree.collapsed.remove(key) {
|
||||
app.tree.collapsed.insert(key.to_string());
|
||||
@@ -520,7 +520,7 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize,
|
||||
let open = is_open(app, &key, true);
|
||||
let active = app.session.active_component == id;
|
||||
let bodies = crate::context_menu::component_bodies(app, id);
|
||||
let visible = bodies.is_empty() || bodies.iter().any(|b| !app.ui.hidden_bodies.contains(b));
|
||||
let visible = bodies.is_empty() || bodies.iter().any(|b| !app.session.visibility.hidden_bodies.contains(b));
|
||||
let grounded = app.session.doc.occurrence_of(id).is_some_and(|o| o.grounded);
|
||||
let faded = app.session.active_component != 0 && !app.session.doc.component_within(id, app.session.active_component) && !active;
|
||||
let name = component_name(app, id);
|
||||
@@ -546,9 +546,12 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize,
|
||||
}
|
||||
if r.eye {
|
||||
if visible {
|
||||
app.ui.hidden_bodies.extend(bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(b)).cloned().collect::<Vec<_>>());
|
||||
app.session
|
||||
.visibility
|
||||
.hidden_bodies
|
||||
.extend(bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(b)).cloned().collect::<Vec<_>>());
|
||||
} else {
|
||||
app.ui.hidden_bodies.retain(|b| !bodies.contains(b));
|
||||
app.session.visibility.hidden_bodies.retain(|b| !bodies.contains(b));
|
||||
}
|
||||
}
|
||||
if r.clicked {
|
||||
@@ -661,7 +664,7 @@ fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
|
||||
("YZ", "plane", "YZ"),
|
||||
];
|
||||
for (k, icon, label) in items {
|
||||
let visible = !app.ui.hidden_origin.iter().any(|h| h == k);
|
||||
let visible = !app.session.visibility.hidden_origin.iter().any(|h| h == k);
|
||||
let sel = match icon {
|
||||
"plane" => Some(Sel::Plane { name: k.into() }),
|
||||
"axis" => Some(Sel::Axis { name: k.into() }),
|
||||
@@ -675,9 +678,9 @@ fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
|
||||
);
|
||||
if r.eye {
|
||||
if visible {
|
||||
app.ui.hidden_origin.push(k.into());
|
||||
app.session.visibility.hidden_origin.push(k.into());
|
||||
} else {
|
||||
app.ui.hidden_origin.retain(|h| h != k);
|
||||
app.session.visibility.hidden_origin.retain(|h| h != k);
|
||||
}
|
||||
}
|
||||
if r.clicked
|
||||
@@ -1001,7 +1004,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
|
||||
row_key: format!("b:{}", b.name),
|
||||
label: b.name.clone(),
|
||||
icon: "body",
|
||||
visible: !app.ui.hidden_bodies.contains(&b.name),
|
||||
visible: !app.session.visibility.hidden_bodies.contains(&b.name),
|
||||
selected: sel.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name)),
|
||||
color: None,
|
||||
locked: app.ui.locked_bodies.contains(&b.name),
|
||||
@@ -1040,7 +1043,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
|
||||
row_key: format!("p:{}", f.id),
|
||||
label: f.name.clone(),
|
||||
icon: "plane",
|
||||
visible: !app.ui.hidden_origin.contains(&f.name),
|
||||
visible: !app.session.visibility.hidden_origin.contains(&f.name),
|
||||
selected: sel.iter().any(|x| matches!(x, Sel::Plane { name } if *name == f.name)),
|
||||
color: None,
|
||||
locked: false,
|
||||
@@ -1079,9 +1082,9 @@ fn set_visible(app: &mut SolveApp, folder: &str, items: &[&Entry], on: bool) {
|
||||
for e in items {
|
||||
match folder {
|
||||
"bodies" => {
|
||||
app.ui.hidden_bodies.retain(|b| *b != e.key);
|
||||
app.session.visibility.hidden_bodies.retain(|b| *b != e.key);
|
||||
if !on {
|
||||
app.ui.hidden_bodies.push(e.key.clone());
|
||||
app.session.visibility.hidden_bodies.push(e.key.clone());
|
||||
}
|
||||
}
|
||||
"sketches" => {
|
||||
@@ -1090,9 +1093,9 @@ fn set_visible(app: &mut SolveApp, folder: &str, items: &[&Entry], on: bool) {
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
app.ui.hidden_origin.retain(|p| *p != e.label);
|
||||
app.session.visibility.hidden_origin.retain(|p| *p != e.label);
|
||||
if !on {
|
||||
app.ui.hidden_origin.push(e.label.clone());
|
||||
app.session.visibility.hidden_origin.push(e.label.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
|
||||
if has(|s| matches!(s, Sel::Face { .. })) {
|
||||
let face = sel.iter().find_map(|s| if let Sel::Face { body, .. } = s { Some(body.clone()) } else { None }).unwrap_or_default();
|
||||
let (feature, sketch) = body_feature_and_sketch(app, &face);
|
||||
let hidden = app.ui.hidden_bodies.contains(&face);
|
||||
let hidden = app.session.visibility.hidden_bodies.contains(&face);
|
||||
v.push(cmd(app, "sketch.create", "Create Sketch"));
|
||||
v.push(cmd(app, "solid.extrude", "Extrude"));
|
||||
v.push(cmd(app, "construct.plane.offset", "Offset Plane"));
|
||||
@@ -379,7 +379,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
|
||||
v.push(cmd(app, "inspect.measure", "Measure"));
|
||||
} else if has(|s| matches!(s, Sel::Edge { .. })) {
|
||||
let body = sel.iter().find_map(|s| if let Sel::Edge { body, .. } = s { Some(body.clone()) } else { None }).unwrap_or_default();
|
||||
let hidden = app.ui.hidden_bodies.contains(&body);
|
||||
let hidden = app.session.visibility.hidden_bodies.contains(&body);
|
||||
v.push(cmd(app, "solid.fillet", "Fillet"));
|
||||
v.push(cmd(app, "solid.chamfer", "Chamfer"));
|
||||
v.push(Item::sep());
|
||||
@@ -403,9 +403,9 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
|
||||
v.push(act("ui.fit", "Fit", "fit"));
|
||||
v.push(act("ui.home", "Home View", "home"));
|
||||
v.push(Item::sep());
|
||||
let anything_hidden = !app.ui.hidden_bodies.is_empty() || !app.ui.hidden_sketches.is_empty();
|
||||
let anything_hidden = !app.session.visibility.hidden_bodies.is_empty() || !app.session.visibility.hidden_sketches.is_empty();
|
||||
v.push(act("ui.showAll", "Show All", "eye").on(anything_hidden));
|
||||
v.push(act("ui.showAll", "Unisolate", "").on(!app.ui.hidden_bodies.is_empty()));
|
||||
v.push(act("ui.showAll", "Unisolate", "").on(!app.session.visibility.hidden_bodies.is_empty()));
|
||||
v.push(Item::sep());
|
||||
v.push(cmd(app, "solid.extrude", "Extrude"));
|
||||
v.push(cmd(app, "solid.fillet", "Fillet"));
|
||||
@@ -428,7 +428,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
|
||||
|
||||
fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec<Item> {
|
||||
let names = json!(bodies);
|
||||
let all_hidden = bodies.iter().all(|b| app.ui.hidden_bodies.contains(b));
|
||||
let all_hidden = bodies.iter().all(|b| app.session.visibility.hidden_bodies.contains(b));
|
||||
let one = bodies.len() == 1;
|
||||
let locked = bodies.iter().all(|b| app.ui.locked_bodies.contains(b));
|
||||
let items = json!(bodies.iter().map(|b| json!({"type": "body", "name": b})).collect::<Vec<_>>());
|
||||
@@ -493,7 +493,10 @@ fn joint_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
let rigid = j.kind == solvecraft_engine::doc::joints::JointKind::Rigid;
|
||||
vec![
|
||||
act("ui.editJoint", "Edit Joint", "joint").with(json!({ "joint": id })),
|
||||
act("ui.editJoint", "Edit Joint Limits", "").with(json!({ "joint": id })).on(!rigid),
|
||||
act("ui.driveJoint", "Drive Joint", "").with(json!({ "joint": id })).on(!rigid),
|
||||
act("ui.animateJoint", "Animate Joint", "").with(json!({ "joint": id })).on(!rigid && !j.suppressed),
|
||||
act("ui.findJoint", "Find in Window", "").with(json!({ "joint": id })),
|
||||
cmd(app, "joint.edit", if j.suppressed { "Unsuppress" } else { "Suppress" }).with(json!({ "joint": id, "suppressed": !j.suppressed })),
|
||||
Item::sep(),
|
||||
cmd(app, "joint.delete", "Delete").key("Del").with(json!({ "joint": id })),
|
||||
@@ -506,7 +509,7 @@ fn component_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
let occ = doc.occurrence_of(id);
|
||||
let active = app.session.active_component == id;
|
||||
let bodies = component_bodies(app, id);
|
||||
let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.ui.hidden_bodies.contains(b));
|
||||
let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.session.visibility.hidden_bodies.contains(b));
|
||||
let names = json!(bodies);
|
||||
let mut v = Vec::new();
|
||||
if id != 0 {
|
||||
@@ -666,18 +669,19 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
"ui.finishSketch" => app.finish_sketch(),
|
||||
"ui.hide" => {
|
||||
for b in bodies {
|
||||
if !app.ui.hidden_bodies.contains(&b) {
|
||||
app.ui.hidden_bodies.push(b);
|
||||
if !app.session.visibility.hidden_bodies.contains(&b) {
|
||||
app.session.visibility.hidden_bodies.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
"ui.show" => app.ui.hidden_bodies.retain(|b| !bodies.contains(b)),
|
||||
"ui.show" => app.session.visibility.hidden_bodies.retain(|b| !bodies.contains(b)),
|
||||
"ui.showAll" => {
|
||||
app.ui.hidden_bodies.clear();
|
||||
app.ui.hidden_sketches.clear();
|
||||
app.session.visibility.hidden_bodies.clear();
|
||||
app.session.visibility.hidden_sketches.clear();
|
||||
}
|
||||
"ui.isolate" => {
|
||||
app.ui.hidden_bodies = app.session.world_state().bodies.iter().map(|b| b.name.clone()).filter(|b| !bodies.contains(b)).collect();
|
||||
app.session.visibility.hidden_bodies =
|
||||
app.session.world_state().bodies.iter().map(|b| b.name.clone()).filter(|b| !bodies.contains(b)).collect();
|
||||
}
|
||||
"ui.lock" => {
|
||||
let locked = bodies.iter().all(|b| app.ui.locked_bodies.contains(b));
|
||||
@@ -791,12 +795,17 @@ 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" => {
|
||||
"ui.findJoint" => {
|
||||
if let Some(id) = id_of(&p, "joint") {
|
||||
let d = if item.id == "ui.editJoint" {
|
||||
crate::dialogs_assembly::edit_joint(app, id)
|
||||
} else {
|
||||
crate::dialogs_assembly::drive_joint(app, id)
|
||||
crate::dialogs_assembly::find_joint(app, id);
|
||||
}
|
||||
}
|
||||
"ui.editJoint" | "ui.driveJoint" | "ui.animateJoint" => {
|
||||
if let Some(id) = id_of(&p, "joint") {
|
||||
let d = match item.id.as_str() {
|
||||
"ui.editJoint" => crate::dialogs_assembly::edit_joint(app, id),
|
||||
"ui.animateJoint" => crate::dialogs_assembly::animate_joint(app, id),
|
||||
_ => crate::dialogs_assembly::drive_joint(app, id),
|
||||
};
|
||||
if let Some(d) = d {
|
||||
app.tool = None;
|
||||
@@ -822,14 +831,14 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
}
|
||||
"ui.originAll" => {
|
||||
app.ui.show_origin = true;
|
||||
app.ui.hidden_origin.retain(|h| !["O", "X", "Y", "Z", "XY", "XZ", "YZ"].contains(&h.as_str()));
|
||||
app.session.visibility.hidden_origin.retain(|h| !["O", "X", "Y", "Z", "XY", "XZ", "YZ"].contains(&h.as_str()));
|
||||
}
|
||||
"ui.originPart" => {
|
||||
let show = p.get("show").and_then(Value::as_bool).unwrap_or(true);
|
||||
for k in strs(&p, "keys") {
|
||||
app.ui.hidden_origin.retain(|h| *h != k);
|
||||
app.session.visibility.hidden_origin.retain(|h| *h != k);
|
||||
if !show {
|
||||
app.ui.hidden_origin.push(k);
|
||||
app.session.visibility.hidden_origin.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -925,16 +934,16 @@ pub fn toggle_visibility(app: &mut SolveApp) {
|
||||
})
|
||||
.collect();
|
||||
let canvases = app.tree.picked_canvases.clone();
|
||||
let any_visible = bodies.iter().any(|b| !app.ui.hidden_bodies.contains(b))
|
||||
let any_visible = bodies.iter().any(|b| !app.session.visibility.hidden_bodies.contains(b))
|
||||
|| sketches.iter().any(|s| crate::browser::sketch_visible(app, *s))
|
||||
|| origin.iter().any(|o| !app.ui.hidden_origin.contains(o))
|
||||
|| origin.iter().any(|o| !app.session.visibility.hidden_origin.contains(o))
|
||||
|| app.session.doc.canvases.iter().any(|c| canvases.contains(&c.id) && c.visible);
|
||||
let hide = any_visible;
|
||||
app.ui.hidden_bodies.retain(|b| !bodies.contains(b));
|
||||
app.ui.hidden_origin.retain(|o| !origin.contains(o));
|
||||
app.session.visibility.hidden_bodies.retain(|b| !bodies.contains(b));
|
||||
app.session.visibility.hidden_origin.retain(|o| !origin.contains(o));
|
||||
if hide {
|
||||
app.ui.hidden_bodies.extend(bodies);
|
||||
app.ui.hidden_origin.extend(origin);
|
||||
app.session.visibility.hidden_bodies.extend(bodies);
|
||||
app.session.visibility.hidden_origin.extend(origin);
|
||||
}
|
||||
for s in sketches {
|
||||
crate::browser::set_sketch_visible(app, s, !hide);
|
||||
@@ -1007,7 +1016,7 @@ fn commit_rename(app: &mut SolveApp, r: &Rename) {
|
||||
match &r.what {
|
||||
RenameWhat::Body(old) => {
|
||||
if app.run("body.rename", json!({ "body": old, "name": name })).is_ok() {
|
||||
for list in [&mut app.ui.hidden_bodies, &mut app.ui.locked_bodies] {
|
||||
for list in [&mut app.session.visibility.hidden_bodies, &mut app.ui.locked_bodies] {
|
||||
for b in list.iter_mut().filter(|b| *b == old) {
|
||||
*b = name.clone();
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
"document.inspect" => wrap(app.session.execute("document.inspect", p).map_err(|e| e.to_string())),
|
||||
"ui.inspect" => ok(json!({
|
||||
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
|
||||
"visibility": app.session.visibility,
|
||||
"viewport": app.viewport.rect.map(|r| json!([r.left(), r.top(), r.width(), r.height()])),
|
||||
"camera": app.cam,
|
||||
"tool": app.tool.as_ref().map(|t| t.cmd),
|
||||
@@ -126,13 +127,24 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
"hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")),
|
||||
"renderer": if app.viewport.gpu.is_some() { "gpu" } else { "cpu" },
|
||||
"frame_ms": app.frame_ms,
|
||||
"file_busy": app.file_load.busy(),
|
||||
"build_ms": app.viewport.build_ms,
|
||||
"preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced},
|
||||
"home": app.home.open,
|
||||
"recent_thumbnails_ready": app.home.ready_recents(),
|
||||
"documents": app.docs.count(),
|
||||
"quit_requested": app.quit_requested,
|
||||
"quitting": app.docs.quitting.is_some(),
|
||||
})),
|
||||
"ui.file" => {
|
||||
let Some(path) = s("path") else { return err("path is required") };
|
||||
if p.get("insert").and_then(Value::as_bool).unwrap_or(false) {
|
||||
app.insert_path_async(path);
|
||||
} else {
|
||||
app.open_path_async(path);
|
||||
}
|
||||
ok(json!({"busy": app.file_load.busy()}))
|
||||
}
|
||||
"ui.documents" => ok(crate::documents::control(app, p)),
|
||||
"ui.home" => {
|
||||
match p.get("open").and_then(Value::as_bool) {
|
||||
@@ -149,18 +161,30 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
ok(json!({"open": app.home.open, "recent": app.home.recent}))
|
||||
}
|
||||
"ui.set" => {
|
||||
// What is hidden lives in the session (`browser.visibility`); `ui.set` still sets it.
|
||||
const VIS: [(&str, &str); 4] = [
|
||||
("hiddenBodies", "hidden_bodies"),
|
||||
("hiddenOrigin", "hidden_origin"),
|
||||
("hiddenSketches", "hidden_sketches"),
|
||||
("shownSketches", "shown_sketches"),
|
||||
];
|
||||
let mut cur = serde_json::to_value(&app.ui).unwrap_or(json!({}));
|
||||
if let (Some(o), Some(src)) = (cur.as_object_mut(), p.as_object()) {
|
||||
let mut vis = serde_json::to_value(&app.session.visibility).unwrap_or(json!({}));
|
||||
if let (Some(o), Some(vo), Some(src)) = (cur.as_object_mut(), vis.as_object_mut(), p.as_object()) {
|
||||
for (k, v) in src {
|
||||
o.insert(k.clone(), v.clone());
|
||||
match VIS.iter().find(|(camel, _)| camel == k) {
|
||||
Some((_, snake)) => vo.insert(snake.to_string(), v.clone()),
|
||||
None => o.insert(k.clone(), v.clone()),
|
||||
};
|
||||
}
|
||||
}
|
||||
match serde_json::from_value::<crate::UiState>(cur) {
|
||||
Ok(u) => {
|
||||
match (serde_json::from_value::<crate::UiState>(cur), serde_json::from_value::<solvecraft_engine::Visibility>(vis)) {
|
||||
(Ok(u), Ok(v)) => {
|
||||
app.ui = u;
|
||||
app.session.visibility = v;
|
||||
ok(serde_json::to_value(&app.ui).unwrap_or_default())
|
||||
}
|
||||
Err(e) => err(e),
|
||||
(Err(e), _) | (_, Err(e)) => err(e),
|
||||
}
|
||||
}
|
||||
"ui.view" => {
|
||||
|
||||
@@ -33,7 +33,7 @@ pub enum Kind {
|
||||
distance: String,
|
||||
direction: usize,
|
||||
operation: usize,
|
||||
/// The operation follows the geometry (into a body: cut, out of one: join, else new)
|
||||
/// Into a body: cut; additions: join when a solid exists, otherwise new.
|
||||
/// until the user picks one.
|
||||
auto_op: bool,
|
||||
/// The other side's distance (two sides).
|
||||
@@ -480,10 +480,12 @@ impl Dialog {
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_else(|| vec![sel.clone()]),
|
||||
(Kind::Measure { .. }, _) => vec![crate::viewport::measurement_selection(s, sel)],
|
||||
_ if inp.multi => crate::viewport::grouped_selection(s, sel),
|
||||
_ => vec![sel.clone()],
|
||||
};
|
||||
for x in picked {
|
||||
if !inp.items.contains(&x) {
|
||||
if !inp.items.iter().any(|y| selection::same_item(y, &x)) {
|
||||
inp.items.push(x);
|
||||
}
|
||||
}
|
||||
@@ -631,8 +633,11 @@ impl Dialog {
|
||||
|
||||
/// A pick in the viewport (already accepted by the active input).
|
||||
pub fn pick(&mut self, s: &Session, sel: Sel) {
|
||||
let measure = matches!(self.kind, Kind::Measure { .. });
|
||||
let sel = if measure { crate::viewport::measurement_selection(s, &sel) } else { sel };
|
||||
let chain = matches!(self.kind, Kind::Fillet { chain: true, .. });
|
||||
let mut picked = vec![sel.clone()];
|
||||
let mut picked =
|
||||
if !measure && self.active_input().is_some_and(|i| i.multi) { crate::viewport::grouped_selection(s, &sel) } else { vec![sel.clone()] };
|
||||
// Tangent chain: an edge brings the edges that continue it smoothly.
|
||||
if chain
|
||||
&& let Sel::Edge { body, index, .. } = &sel
|
||||
@@ -667,13 +672,15 @@ impl Dialog {
|
||||
}
|
||||
|
||||
/// Box selection result: replace (or add to) the active input.
|
||||
pub fn take_box(&mut self, sels: Vec<Sel>, add: bool) {
|
||||
pub fn take_box(&mut self, s: &Session, sels: Vec<Sel>, add: bool) {
|
||||
let measure = matches!(self.kind, Kind::Measure { .. });
|
||||
let Some(inp) = self.inputs.get_mut(self.active) else { return };
|
||||
if !add {
|
||||
inp.items.clear();
|
||||
}
|
||||
for x in sels {
|
||||
if fits(inp.accept, &x) && !inp.items.contains(&x) {
|
||||
let x = if measure { crate::viewport::measurement_selection(s, &x) } else { x };
|
||||
if fits(inp.accept, &x) && !inp.items.iter().any(|y| selection::same_item(y, &x)) {
|
||||
inp.items.push(x);
|
||||
}
|
||||
}
|
||||
@@ -1848,7 +1855,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
Err(e) => d.error = Some(e),
|
||||
}
|
||||
}
|
||||
if !(keep && !cancel) {
|
||||
if !keep || cancel {
|
||||
crate::dialogs_motion::closed(app, &mut d, applied);
|
||||
}
|
||||
if keep && !cancel {
|
||||
@@ -2691,11 +2698,50 @@ pub(crate) fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &P
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// A dialog is being filled from a feature: its points are in component frames.
|
||||
static REFILL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// While filling a dialog from a feature (dropped at the end).
|
||||
struct Refill;
|
||||
|
||||
impl Refill {
|
||||
fn start() -> Refill {
|
||||
REFILL.with(|r| r.set(true));
|
||||
Refill
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Refill {
|
||||
fn drop(&mut self) {
|
||||
REFILL.with(|r| r.set(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// A point stored in a feature, as it shows on world body `body`: features keep their picks in
|
||||
/// the frame of the body they were on, so each body's placement takes the point to the world
|
||||
/// (a moved component, a pasted instance). Other points are the world already.
|
||||
fn stored_on(frames: &Option<Vec<(String, solvecraft_engine::doc::Mat)>>, body: &str, p: Vec3) -> Vec3 {
|
||||
frames.as_ref().and_then(|f| f.iter().find(|(n, _)| n == body)).map(|(_, m)| solvecraft_engine::doc::apply_point(m, p)).unwrap_or(p)
|
||||
}
|
||||
|
||||
/// Body placements while filling a dialog from a feature (when anything is placed).
|
||||
fn stored_frames(s: &Session) -> Option<Vec<(String, solvecraft_engine::doc::Mat)>> {
|
||||
if !REFILL.with(std::cell::Cell::get) || !solvecraft_engine::frames::placed(s) {
|
||||
return None;
|
||||
}
|
||||
let st = s.model.state();
|
||||
Some(s.doc.body_frames(&st).into_iter().map(|(w, _, _, m)| (w, m)).collect())
|
||||
}
|
||||
|
||||
/// The edge of a visible body through (or nearest) a point.
|
||||
pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option<Sel> {
|
||||
pub(crate) fn edge_sel(s: &Session, p0: Vec3) -> Option<Sel> {
|
||||
let st = s.world_state();
|
||||
let frames = stored_frames(s);
|
||||
let mut best: Option<(f64, Sel)> = None;
|
||||
for b in &st.bodies {
|
||||
let p = stored_on(&frames, &b.name, p0);
|
||||
let m = b.mesh();
|
||||
for (i, e) in m.edges.iter().enumerate() {
|
||||
let d = e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||||
@@ -2708,10 +2754,12 @@ pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option<Sel> {
|
||||
}
|
||||
|
||||
/// The body face containing a point (nearest triangle).
|
||||
pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
|
||||
pub(crate) fn face_sel(s: &Session, p0: Vec3) -> Option<Sel> {
|
||||
let st = s.world_state();
|
||||
let frames = stored_frames(s);
|
||||
let mut best: Option<(f64, Sel)> = None;
|
||||
for b in &st.bodies {
|
||||
let p = stored_on(&frames, &b.name, p0);
|
||||
let m = b.mesh();
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let Some([a, bb, c]) = m.tri(t) else { continue };
|
||||
@@ -2721,7 +2769,7 @@ pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
|
||||
}
|
||||
}
|
||||
}
|
||||
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p.len()).map(|x| x.1)
|
||||
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p0.len()).map(|x| x.1)
|
||||
}
|
||||
|
||||
pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
|
||||
@@ -2753,9 +2801,15 @@ fn pt3(v: Vec3) -> Value {
|
||||
json!([v.x, v.y, v.z])
|
||||
}
|
||||
|
||||
/// A point of expressions: numbers as numbers, expressions as strings.
|
||||
fn pt_expr(p: &solvecraft_engine::doc::PointExpr) -> Value {
|
||||
Value::Array(p.iter().map(|e| e.parse::<f64>().ok().filter(|x| x.is_finite()).map_or_else(|| json!(e), |x| json!(x))).collect())
|
||||
}
|
||||
|
||||
/// A dialog that edits an existing feature, filled from it. The timeline is rolled back to just
|
||||
/// before the feature, so its references show on the geometry they refer to.
|
||||
pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dialog> {
|
||||
let _refill = Refill::start();
|
||||
let s = &app.session;
|
||||
let f = s.doc.feature(id)?.clone();
|
||||
let st = s.world_state();
|
||||
@@ -2959,7 +3013,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
fields: vec![("length", length.clone()), ("width", width.clone()), ("height", height.clone())],
|
||||
operation: op_index(operation),
|
||||
};
|
||||
d.extra.insert("corner".into(), pt3(*corner));
|
||||
d.extra.insert("corner".into(), pt_expr(corner));
|
||||
d
|
||||
}
|
||||
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
|
||||
@@ -2969,14 +3023,14 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
fields: vec![("radius", radius.clone()), ("height", height.clone())],
|
||||
operation: op_index(operation),
|
||||
};
|
||||
d.extra.insert("base".into(), pt3(*base));
|
||||
d.extra.insert("base".into(), pt_expr(base));
|
||||
d.extra.insert("axis".into(), pt3(*axis));
|
||||
d
|
||||
}
|
||||
FeatureKind::Sphere { center, radius, operation } => {
|
||||
let mut d = start("solid.sphere")?;
|
||||
d.kind = Kind::Primitive { cmd: "solid.sphere", fields: vec![("radius", radius.clone())], operation: op_index(operation) };
|
||||
d.extra.insert("center".into(), pt3(*center));
|
||||
d.extra.insert("center".into(), pt_expr(center));
|
||||
d
|
||||
}
|
||||
FeatureKind::Torus { center, major, minor, operation } => {
|
||||
@@ -2986,7 +3040,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
fields: vec![("major", major.clone()), ("minor", minor.clone())],
|
||||
operation: op_index(operation),
|
||||
};
|
||||
d.extra.insert("center".into(), pt3(*center));
|
||||
d.extra.insert("center".into(), pt_expr(center));
|
||||
d
|
||||
}
|
||||
FeatureKind::BoundaryFill { tools, cells, operation, remove_tools } => {
|
||||
|
||||
@@ -209,6 +209,38 @@ pub fn edit_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
|
||||
}
|
||||
|
||||
/// The Drive Joints dialog on a joint (by id).
|
||||
/// Edit Joint with its motion playing (the browser's Animate Joint).
|
||||
pub fn animate_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
|
||||
let mut d = edit_joint(app, id)?;
|
||||
if let Kind::Assembly(Asm::Joint(f)) = &mut d.kind {
|
||||
f.animate = true;
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// Frame the two components a joint connects (the browser's Find in Window).
|
||||
pub fn find_joint(app: &mut SolveApp, id: u64) {
|
||||
let s = &app.session;
|
||||
let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == id) else { return };
|
||||
let occs = [j.a.occurrence, j.b.occurrence];
|
||||
let model = s.model.state();
|
||||
let world = s.world_state();
|
||||
let mut bb = Aabb3::default();
|
||||
for (name, _, path, _) in s.doc.body_frames(&model) {
|
||||
// The root (occurrence 0) holds the bodies outside every occurrence.
|
||||
let on = occs.iter().any(|o| if *o == 0 { path.is_empty() } else { path.contains(o) });
|
||||
if on && let Some(b) = world.body(&name) {
|
||||
bb = bb.union(&b.mesh().bounds());
|
||||
}
|
||||
}
|
||||
if bb.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut cam = app.cam;
|
||||
cam.fit(&bb);
|
||||
app.animate_to(cam);
|
||||
}
|
||||
|
||||
pub fn drive_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
|
||||
let joint = app.session.doc.assembly.joints.iter().position(|j| j.id == id)?;
|
||||
Some(Dialog::new(Kind::Assembly(Asm::Drive { joint, values: current_values(&app.session, joint), focus: true }), vec![]))
|
||||
@@ -494,12 +526,25 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
|
||||
let cur = s.doc.eval(v, unit_kind).ok().filter(|x| x.is_finite()).unwrap_or(0.0);
|
||||
let (mut shown, scale) = if angle { (cur.to_degrees(), 1f64.to_degrees()) } else { (cur, 1.0) };
|
||||
row_label(ui, "");
|
||||
ui.spacing_mut().slider_width = 124.0;
|
||||
ui.spacing_mut().slider_width = 160.0;
|
||||
let r = ui.add(egui::Slider::new(&mut shown, lo * scale..=hi * scale).show_value(false));
|
||||
if r.changed() {
|
||||
*v = if angle { format!("{shown:.1} deg") } else { format!("{shown:.2} mm") };
|
||||
}
|
||||
ui.end_row();
|
||||
// The slider's range: the joint's limits, or the default span when it has none.
|
||||
let limited = j.limits.get(i).copied().flatten().is_some();
|
||||
let fmt = |x: f64| {
|
||||
let (x, unit) = if angle { (x.to_degrees(), "°") } else { (x, " mm") };
|
||||
if x.fract().abs() < 1e-6 { format!("{x:.0}{unit}") } else { format!("{x:.1}{unit}") }
|
||||
};
|
||||
row_label(ui, "");
|
||||
ui.label(
|
||||
RichText::new(format!("{} … {}{}", fmt(lo), fmt(hi), if limited { " (limits)" } else { " (no limits)" }))
|
||||
.size(11.0)
|
||||
.color(if limited { t.text } else { t.text_dim }),
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
Asm::MotionLink { a, b, ia, ib, ratio, offset } => {
|
||||
@@ -826,6 +871,8 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A joint names its two picks; a joint origin has one.
|
||||
let two = matches!(k, Asm::Joint(_));
|
||||
let hover = match k {
|
||||
Asm::Joint(f) => Some(&mut f.hover),
|
||||
Asm::JointOrigin { hover, .. } => Some(hover),
|
||||
@@ -840,8 +887,9 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
|
||||
.filter(|(_, i)| i.accept == SNAPS)
|
||||
.filter_map(|(n, i)| i.items.first().and_then(|x| snap_of(&app.session, x)).map(|s| (s, if n == 0 { t.accent } else { t.warning })))
|
||||
.collect();
|
||||
for (s, c) in &picked {
|
||||
glyph(painter, proj, s, *c, app.cam.half_height());
|
||||
for (n, (s, c)) in picked.iter().enumerate() {
|
||||
let tag = format!("Component {}", n + 1);
|
||||
glyph(painter, proj, s, *c, app.cam.half_height(), two.then_some(tag.as_str()));
|
||||
}
|
||||
// The snap under the cursor (found again only when the hovered item changes).
|
||||
let accepting = d.inputs.get(d.active).is_some_and(|i| i.accept == SNAPS);
|
||||
@@ -853,7 +901,7 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
|
||||
*hover = Some((sel.clone(), snap_of(&app.session, &sel)));
|
||||
}
|
||||
if let Some((_, Some(s))) = hover.as_ref() {
|
||||
glyph(painter, proj, s, t.hover_profile_edge, app.cam.half_height());
|
||||
glyph(painter, proj, s, t.hover_profile_edge, app.cam.half_height(), Some(snap_name(s.kind)));
|
||||
}
|
||||
}
|
||||
None => *hover = None,
|
||||
@@ -861,36 +909,70 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
|
||||
}
|
||||
|
||||
/// A snap: its kind's marker at the point and the z axis as an arrow.
|
||||
fn glyph(painter: &egui::Painter, proj: &Proj, s: &SnapPt, col: Color32, half_height: f64) {
|
||||
fn glyph(painter: &egui::Painter, proj: &Proj, s: &SnapPt, col: Color32, half_height: f64, tag: Option<&str>) {
|
||||
let Some(c) = proj.to_screen(s.at) else { return };
|
||||
let len = half_height * 2.0 * 0.06;
|
||||
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
|
||||
let halo = Stroke::new(4.5, Color32::from_black_alpha(150));
|
||||
let white = Stroke::new(1.5, Color32::WHITE);
|
||||
if let Some(tip) = proj.to_screen(s.at + s.z * len) {
|
||||
painter.line_segment([c, tip], Stroke::new(2.0, col));
|
||||
let d = (tip - c).normalized();
|
||||
let n = vec2(-d.y, d.x);
|
||||
painter.add(egui::Shape::convex_polygon(vec![tip, tip - d * 8.0 + n * 4.0, tip - d * 8.0 - n * 4.0], col, Stroke::NONE));
|
||||
let head = vec![tip + d * 2.0, tip - d * 9.0 + n * 5.0, tip - d * 9.0 - n * 5.0];
|
||||
painter.line_segment([c, tip], halo);
|
||||
painter.add(egui::Shape::convex_polygon(head.clone(), Color32::TRANSPARENT, halo));
|
||||
painter.line_segment([c, tip], Stroke::new(2.0, col));
|
||||
painter.add(egui::Shape::convex_polygon(head, col, Stroke::NONE));
|
||||
}
|
||||
let white = Stroke::new(1.5, Color32::WHITE);
|
||||
let shape = |fill: Color32, stroke: Stroke, grow: f32| -> egui::Shape {
|
||||
match s.kind {
|
||||
SnapKind::FaceCenter => egui::Shape::rect_filled(egui::Rect::from_center_size(c, vec2(12.0 + grow, 12.0 + grow)), 1.5, fill),
|
||||
SnapKind::CircleCenter => egui::Shape::circle_filled(c, 7.0 + grow / 2.0, fill),
|
||||
SnapKind::EdgeMid => {
|
||||
let r = 8.0 + grow / 2.0;
|
||||
egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r * 0.93, r * 0.7), c + vec2(-r * 0.93, r * 0.7)], fill, stroke)
|
||||
}
|
||||
SnapKind::Vertex => {
|
||||
let r = 7.5 + grow / 2.0;
|
||||
egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r, 0.0), c + vec2(0.0, r), c + vec2(-r, 0.0)], fill, stroke)
|
||||
}
|
||||
}
|
||||
};
|
||||
painter.add(shape(Color32::from_black_alpha(150), Stroke::NONE, 4.0));
|
||||
painter.add(shape(col, Stroke::NONE, 0.0));
|
||||
match s.kind {
|
||||
SnapKind::FaceCenter => {
|
||||
painter.rect(egui::Rect::from_center_size(c, vec2(10.0, 10.0)), 1.0, col, white, egui::StrokeKind::Middle);
|
||||
painter.rect_stroke(egui::Rect::from_center_size(c, vec2(12.0, 12.0)), 1.5, white, egui::StrokeKind::Middle);
|
||||
}
|
||||
SnapKind::CircleCenter => {
|
||||
painter.circle(c, 6.0, col, white);
|
||||
painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], Stroke::new(1.2, Color32::WHITE));
|
||||
painter.line_segment([c - vec2(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.2, Color32::WHITE));
|
||||
painter.circle_stroke(c, 7.0, white);
|
||||
painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, Color32::WHITE));
|
||||
painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, Color32::WHITE));
|
||||
}
|
||||
SnapKind::EdgeMid => {
|
||||
painter.add(egui::Shape::convex_polygon(vec![c + vec2(0.0, -7.0), c + vec2(6.5, 5.0), c + vec2(-6.5, 5.0)], col, white));
|
||||
}
|
||||
SnapKind::Vertex => {
|
||||
painter.add(egui::Shape::convex_polygon(
|
||||
vec![c + vec2(0.0, -6.5), c + vec2(6.5, 0.0), c + vec2(0.0, 6.5), c + vec2(-6.5, 0.0)],
|
||||
col,
|
||||
white,
|
||||
));
|
||||
SnapKind::EdgeMid | SnapKind::Vertex => {
|
||||
painter.add(shape(Color32::TRANSPARENT, white, 0.0));
|
||||
}
|
||||
}
|
||||
// What it snaps to (hovered), or which component it is (picked): a small label.
|
||||
if let Some(tag) = tag {
|
||||
let t = Tokens::get();
|
||||
let font = egui::FontId::proportional(11.5);
|
||||
let g = painter.layout_no_wrap(tag.to_string(), font, t.text);
|
||||
let at = c + vec2(12.0, 10.0);
|
||||
let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0));
|
||||
painter.rect(r, 3.0, t.panel.gamma_multiply(0.92), Stroke::new(1.0, col), egui::StrokeKind::Inside);
|
||||
painter.galley(at, g, t.text);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a snap is called (the hover label).
|
||||
fn snap_name(k: SnapKind) -> &'static str {
|
||||
match k {
|
||||
SnapKind::FaceCenter => "Face center",
|
||||
SnapKind::CircleCenter => "Circle center",
|
||||
SnapKind::EdgeMid => "Edge midpoint",
|
||||
SnapKind::Vertex => "Vertex",
|
||||
}
|
||||
}
|
||||
|
||||
// ---- timeline and browser ----
|
||||
@@ -963,61 +1045,36 @@ pub fn timeline_joints(app: &mut SolveApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
/// The Joints folder at the end of the Browser: a row per joint (double-click edits, the menu
|
||||
/// edits, drives, suppresses or deletes).
|
||||
pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
|
||||
let t = Tokens::get();
|
||||
use crate::browser::{Row, draw_row, is_open, toggle};
|
||||
let joints: Vec<(u64, String, JointKind, bool)> =
|
||||
app.session.doc.assembly.joints.iter().map(|j| (j.id, j.name.clone(), j.kind, j.suppressed)).collect();
|
||||
if joints.is_empty() {
|
||||
return;
|
||||
}
|
||||
let fold_id = egui::Id::new("sc_browser_joints_open");
|
||||
let mut open = ui.data(|d| d.get_temp::<bool>(fold_id)).unwrap_or(true);
|
||||
let row = |ui: &mut egui::Ui, depth: f32, icon: &str, label: &str, dim: bool, fold: Option<bool>| -> egui::Response {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), egui::Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
let x = r.left() + 2.0 + depth * 14.0;
|
||||
let cy = r.center().y;
|
||||
if let Some(o) = fold {
|
||||
let c = egui::pos2(x + 6.0, cy);
|
||||
let pts = if o {
|
||||
vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)]
|
||||
} else {
|
||||
vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)]
|
||||
};
|
||||
ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE));
|
||||
}
|
||||
let ir = egui::Rect::from_center_size(egui::pos2(x + 26.0, cy), vec2(16.0, 16.0));
|
||||
crate::icons::paint(ui.painter(), ir, icon, if dim { t.border } else { t.icon }, t.icon_fill, t.accent);
|
||||
ui.painter().text(
|
||||
egui::pos2(x + 38.0, cy),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
label,
|
||||
egui::FontId::proportional(13.0),
|
||||
if dim { t.text_dim } else { t.text },
|
||||
);
|
||||
resp
|
||||
};
|
||||
let depth = depth as f32;
|
||||
if row(ui, depth, "folder", "Joints", false, Some(open)).clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(fold_id, open));
|
||||
// Drawn like every other browser node (its fold and rows publish `fold:Joints`, `row:<name>`).
|
||||
let key = "c0/joints";
|
||||
let open = is_open(app, key, true);
|
||||
let r = draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), icon: "folder", label: "Joints", ..Default::default() });
|
||||
if r.fold || r.clicked {
|
||||
toggle(app, key, true);
|
||||
}
|
||||
if !open {
|
||||
return;
|
||||
}
|
||||
let mut dialog: Option<Dialog> = None;
|
||||
for (id, name, kind, suppressed) in joints {
|
||||
let resp = row(ui, depth + 1.0, "joint", &name, suppressed, None).on_hover_text(format!("{kind:?} joint"));
|
||||
if resp.double_clicked() {
|
||||
let r =
|
||||
draw_row(ui, ui.id().with(("joint", id)), &Row { depth: depth + 1, icon: "joint", label: &name, dim: suppressed, ..Default::default() });
|
||||
if let Some(resp) = &r.resp {
|
||||
resp.clone().on_hover_text(format!("{kind:?} joint"));
|
||||
}
|
||||
if r.double {
|
||||
dialog = edit_joint(app, id);
|
||||
}
|
||||
// The same menus as the rest of the browser.
|
||||
if resp.secondary_clicked() {
|
||||
let at = resp.interact_pointer_pos().unwrap_or(resp.rect.left_bottom());
|
||||
if let Some(at) = r.secondary {
|
||||
crate::context_menu::open_for(app, at, crate::context_menu::Target::Joint { id });
|
||||
}
|
||||
let _ = (kind, suppressed);
|
||||
}
|
||||
if let Some(d) = dialog {
|
||||
app.tool = None;
|
||||
@@ -1028,3 +1085,51 @@ pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
|
||||
#[cfg(test)]
|
||||
#[path = "dialogs_assembly_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
thread_local! {
|
||||
/// Named joint origins in the world, for the design state they were found in.
|
||||
static ORIGINS: std::cell::RefCell<Option<(String, Vec<(String, solvecraft_engine::doc::Mat)>)>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Named joint origins in the world: on their occurrence's component, so they move with it
|
||||
/// (joint drives, free moves not captured yet, Capture Position).
|
||||
pub fn origins_world(s: &Session) -> Vec<(String, solvecraft_engine::doc::Mat)> {
|
||||
if s.doc.assembly.origins.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let key = format!("{}:{:?}", s.revision, s.pending_moves);
|
||||
if let Some(v) = ORIGINS.with(|o| o.borrow().as_ref().filter(|(k, _)| *k == key).map(|(_, v)| v.clone())) {
|
||||
return v;
|
||||
}
|
||||
let doc: std::borrow::Cow<solvecraft_engine::doc::Document> = if s.pending_moves.is_empty() {
|
||||
std::borrow::Cow::Borrowed(&*s.doc)
|
||||
} else {
|
||||
let mut d = (*s.doc).clone();
|
||||
for o in &mut d.occurrences {
|
||||
if let Some(m) = s.pending_moves.get(&o.id) {
|
||||
o.transform = *m;
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(d)
|
||||
};
|
||||
let st = s.model.state();
|
||||
let v: Vec<_> = doc.assembly.origins.iter().filter_map(|o| doc.origin_world(&st, &o.origin).ok().map(|m| (o.name.clone(), m))).collect();
|
||||
ORIGINS.with(|o| *o.borrow_mut() = Some((key, v.clone())));
|
||||
v
|
||||
}
|
||||
|
||||
/// Named joint origins in the viewport: a small triad where each sits.
|
||||
pub fn origins(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
let t = Tokens::get();
|
||||
let len = app.cam.half_height() * 2.0 * 0.04;
|
||||
for (_, m) in origins_world(&app.session) {
|
||||
let at = solvecraft_engine::doc::apply_point(&m, Vec3::ZERO);
|
||||
let Some(c) = proj.to_screen(at) else { continue };
|
||||
for (axis, col) in [(Vec3::X, t.axis_x), (Vec3::Y, t.axis_y), (Vec3::Z, t.axis_z)] {
|
||||
if let Some(tip) = proj.to_screen(at + solvecraft_engine::doc::apply_vector(&m, axis) * len) {
|
||||
painter.line_segment([c, tip], Stroke::new(2.0, col));
|
||||
}
|
||||
}
|
||||
painter.circle(c, 4.5, t.panel, Stroke::new(1.5, t.text));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,3 +263,41 @@ fn drive_value_takes_the_keyboard() {
|
||||
assert!((j.values[0] - 90f64.to_radians()).abs() < 1e-9, "{:?}", j.values);
|
||||
assert!(app.dialog.is_none());
|
||||
}
|
||||
|
||||
/// Named joint origins are drawn where their component is: a drive of the occurrence, or a free
|
||||
/// move not captured yet, carries them along.
|
||||
#[test]
|
||||
fn joint_origins_follow_their_component() {
|
||||
let mut app = two_boxes();
|
||||
app.session.execute("joint.origin", &json!({"name": "Top of B", "face": [40, 10, 10]})).unwrap();
|
||||
let at = |app: &SolveApp| {
|
||||
let v = origins_world(&app.session);
|
||||
let (_, m) = v.iter().find(|(n, _)| n == "Top of B").unwrap();
|
||||
solvecraft_engine::doc::apply_point(m, Vec3::ZERO)
|
||||
};
|
||||
assert!(at(&app).dist(Vec3::new(40.0, 10.0, 10.0)) < 1e-6, "{:?}", at(&app));
|
||||
let b = occ(&app, "B");
|
||||
app.session.execute("occurrence.move", &json!({"occurrence": b, "translate": [0, 0, 15]})).unwrap();
|
||||
assert!(at(&app).dist(Vec3::new(40.0, 10.0, 25.0)) < 1e-6, "{:?}", at(&app));
|
||||
// A drag not captured yet.
|
||||
let mut m = app.session.doc.occurrence_of(app.session.doc.find_component("B").unwrap()).unwrap().transform;
|
||||
m[3][0] += 5.0;
|
||||
app.session.pending_moves.insert(b, m);
|
||||
assert!(at(&app).dist(Vec3::new(45.0, 10.0, 25.0)) < 1e-6, "{:?}", at(&app));
|
||||
}
|
||||
|
||||
/// Edit Feature on a moved component: the dialog shows the feature's picks where the
|
||||
/// component is (its stored points are in the component's frame).
|
||||
#[test]
|
||||
fn edit_feature_on_a_moved_component_refills_in_the_world() {
|
||||
let mut app = two_boxes();
|
||||
let b = occ(&app, "B");
|
||||
app.session.execute("occurrence.move", &json!({"occurrence": b, "translate": [0, 0, 15]})).unwrap();
|
||||
let r = app.session.execute("solid.fillet", &json!({"edges": [[40, 0, 25]], "radius": 2})).unwrap();
|
||||
let id = r["feature"].as_u64().unwrap();
|
||||
let d = crate::dialogs::for_feature(&app, id, None).unwrap();
|
||||
let items: Vec<Sel> = d.inputs.iter().flat_map(|i| i.items.clone()).collect();
|
||||
let Some(Sel::Edge { body, point, .. }) = items.first() else { panic!("{items:?}") };
|
||||
assert_eq!(body, "Body2");
|
||||
assert!(point.dist(Vec3::new(40.0, 0.0, 25.0)) < 1e-6, "{point:?}");
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ pub enum Mo {
|
||||
tracks: Vec<Track>,
|
||||
step: f64,
|
||||
playing: bool,
|
||||
/// Playback starts over at the end (else it stops there).
|
||||
looping: bool,
|
||||
/// Playback speed (index into `SPEEDS`).
|
||||
speed: usize,
|
||||
/// The step the shown pose is for (and its revision).
|
||||
shown: Option<(u64, u64)>,
|
||||
saved: Option<BTreeMap<u64, Mat>>,
|
||||
@@ -101,6 +105,8 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
tracks: Vec::new(),
|
||||
step: 0.0,
|
||||
playing: false,
|
||||
looping: true,
|
||||
speed: 2,
|
||||
shown: None,
|
||||
saved: None,
|
||||
error: None,
|
||||
@@ -116,6 +122,73 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
|
||||
// ---- pure mappings (tested) ----
|
||||
|
||||
/// Playback speeds offered (times `STEPS_PER_S`).
|
||||
pub const SPEEDS: [f64; 5] = [0.25, 0.5, 1.0, 2.0, 4.0];
|
||||
/// Steps played per second at 1×.
|
||||
const STEPS_PER_S: f64 = 24.0;
|
||||
|
||||
/// The next playback step: `by` steps on; at the end it starts over (looping) or stops there.
|
||||
/// Returns the step and whether it still plays.
|
||||
pub fn advance(step: f64, by: f64, total: u32, looping: bool) -> (f64, bool) {
|
||||
let last = f64::from(total);
|
||||
let next = step + by.max(0.0);
|
||||
if next <= last {
|
||||
(next, true)
|
||||
} else if looping && last > 0.0 {
|
||||
(next % (last + 1.0), true)
|
||||
} else {
|
||||
(last, false)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Transport {
|
||||
Start,
|
||||
Back,
|
||||
Play,
|
||||
Stop,
|
||||
Forward,
|
||||
End,
|
||||
}
|
||||
|
||||
/// A media button drawn in code: bars and triangles.
|
||||
fn transport(ui: &mut egui::Ui, what: Transport, tip: &str) -> egui::Response {
|
||||
let t = Tokens::get();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 22.0), Sense::click());
|
||||
let fill = if resp.hovered() { t.hover } else { t.field };
|
||||
ui.painter().rect(r, 3.0, fill, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let c = r.center();
|
||||
let ink = if matches!(what, Transport::Play | Transport::Stop) { t.accent } else { t.icon };
|
||||
let p = ui.painter();
|
||||
let tri = |dir: f32, x: f32| {
|
||||
egui::Shape::convex_polygon(vec![pos2(x - 4.0 * dir, c.y - 5.0), pos2(x + 4.0 * dir, c.y), pos2(x - 4.0 * dir, c.y + 5.0)], ink, Stroke::NONE)
|
||||
};
|
||||
let bar = |x: f32| egui::Shape::rect_filled(egui::Rect::from_center_size(pos2(x, c.y), vec2(2.0, 10.0)), 0.0, ink);
|
||||
match what {
|
||||
Transport::Play => drop(p.add(tri(1.0, c.x + 1.0))),
|
||||
Transport::Stop => drop(p.rect_filled(egui::Rect::from_center_size(c, vec2(9.0, 9.0)), 1.0, ink)),
|
||||
Transport::Back => {
|
||||
p.add(bar(c.x + 4.0));
|
||||
p.add(tri(-1.0, c.x - 1.0));
|
||||
}
|
||||
Transport::Forward => {
|
||||
p.add(bar(c.x - 4.0));
|
||||
p.add(tri(1.0, c.x + 1.0));
|
||||
}
|
||||
Transport::Start => {
|
||||
p.add(bar(c.x - 6.0));
|
||||
p.add(tri(-1.0, c.x - 0.5));
|
||||
p.add(tri(-1.0, c.x + 5.0));
|
||||
}
|
||||
Transport::End => {
|
||||
p.add(tri(1.0, c.x - 5.0));
|
||||
p.add(tri(1.0, c.x + 0.5));
|
||||
p.add(bar(c.x + 6.0));
|
||||
}
|
||||
}
|
||||
resp.on_hover_text(tip)
|
||||
}
|
||||
|
||||
/// The study command's parameters.
|
||||
pub fn study_params(s: &Session, name: &str, study: Option<&str>, steps: &str, tracks: &[Track]) -> Result<Value, String> {
|
||||
let steps: u32 = steps.trim().parse().map_err(|_| "steps must be a whole number".to_string())?;
|
||||
@@ -321,7 +394,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
Mo::MotionStudy { name, study, steps, tracks, step, playing, shown, saved, error } => {
|
||||
Mo::MotionStudy { name, study, steps, tracks, step, playing, looping, speed, shown, saved, error } => {
|
||||
row_label(ui, "Name");
|
||||
ui.text_edit_singleline(name);
|
||||
ui.end_row();
|
||||
@@ -336,19 +409,54 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
||||
ui.end_row();
|
||||
return enter;
|
||||
}
|
||||
// Playback.
|
||||
row_label(ui, "Step");
|
||||
// Playback: to start, step back, play/stop, step on, to end; loop and speed.
|
||||
row_label(ui, "Playback");
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(if *playing { "■" } else { "▶" }).on_hover_text(if *playing { "Stop" } else { "Play" }).clicked() {
|
||||
let last = f64::from(total);
|
||||
if transport(ui, Transport::Start, "To the start").clicked() {
|
||||
*step = 0.0;
|
||||
*playing = false;
|
||||
}
|
||||
if transport(ui, Transport::Back, "Step back").clicked() {
|
||||
*step = (step.round() - 1.0).max(0.0);
|
||||
*playing = false;
|
||||
}
|
||||
let (what, tip) = if *playing { (Transport::Stop, "Stop") } else { (Transport::Play, "Play") };
|
||||
if transport(ui, what, tip).clicked() {
|
||||
// Play from the start again when at the end.
|
||||
if !*playing && step.round() >= last {
|
||||
*step = 0.0;
|
||||
}
|
||||
*playing = !*playing;
|
||||
}
|
||||
ui.spacing_mut().slider_width = 220.0;
|
||||
ui.add(egui::Slider::new(step, 0.0..=f64::from(total)).integer());
|
||||
if transport(ui, Transport::Forward, "Step on").clicked() {
|
||||
*step = (step.round() + 1.0).min(last);
|
||||
*playing = false;
|
||||
}
|
||||
if transport(ui, Transport::End, "To the end").clicked() {
|
||||
*step = last;
|
||||
*playing = false;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.checkbox(looping, "Loop");
|
||||
let names: Vec<String> = SPEEDS.iter().map(|x| format!("{x}×")).collect();
|
||||
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
|
||||
combo(ui, "ms_speed", &refs, speed);
|
||||
});
|
||||
ui.end_row();
|
||||
row_label(ui, "Step");
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().slider_width = 260.0;
|
||||
if ui.add(egui::Slider::new(step, 0.0..=f64::from(total)).integer().show_value(false)).dragged() {
|
||||
*playing = false;
|
||||
}
|
||||
ui.label(RichText::new(format!("{} / {total}", step.round() as u32)).color(t.text_dim));
|
||||
});
|
||||
ui.end_row();
|
||||
if *playing {
|
||||
let dt = ui.input(|i| i.stable_dt).min(0.1);
|
||||
*step = (*step + f64::from(dt) * 24.0) % (f64::from(total) + 1.0);
|
||||
let dt = f64::from(ui.input(|i| i.stable_dt).min(0.1));
|
||||
let fps = STEPS_PER_S * SPEEDS.get(*speed).copied().unwrap_or(1.0);
|
||||
(*step, *playing) = advance(*step, dt * fps, total, *looping);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
// Tracks: a row per keyed joint value, its keys on a strip under the steps.
|
||||
@@ -667,6 +775,15 @@ pub fn commands(app: &SolveApp, k: &Mo, inputs: &[SelInput]) -> Result<Vec<(Stri
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Playback runs on, starts over at the end when looping, else stops on the last step.
|
||||
#[test]
|
||||
fn playback_advances_loops_and_stops() {
|
||||
assert_eq!(advance(3.0, 2.0, 10, true), (5.0, true));
|
||||
assert_eq!(advance(9.5, 2.0, 10, true), (0.5, true));
|
||||
assert_eq!(advance(9.5, 2.0, 10, false), (10.0, false));
|
||||
assert_eq!(advance(4.0, -3.0, 10, false), (4.0, true));
|
||||
}
|
||||
|
||||
/// What a dialog shows goes away when it closes; an exploded view applied with OK stays.
|
||||
#[test]
|
||||
fn shown_poses_are_put_back() {
|
||||
@@ -688,6 +805,8 @@ mod tests {
|
||||
tracks: vec![],
|
||||
step: 0.0,
|
||||
playing: false,
|
||||
looping: true,
|
||||
speed: 2,
|
||||
shown: None,
|
||||
saved,
|
||||
error: None,
|
||||
|
||||
@@ -187,7 +187,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
||||
}
|
||||
let tk = Tokens::get();
|
||||
let is_sel = |id: &str| app.session.selection.iter().any(|s| matches!(s, Sel::SketchConstraint { id: x } if x == id));
|
||||
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| proj.rect.contains(*p)).and_then(hit);
|
||||
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect)).and_then(hit);
|
||||
let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p));
|
||||
let font = FontId::proportional(12.0);
|
||||
let centres = chain_centres(sk);
|
||||
|
||||
@@ -27,10 +27,6 @@ pub struct DocSlot {
|
||||
/// The per-design part of the UI state (what is hidden, shown or locked).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct DocView {
|
||||
hidden_bodies: Vec<String>,
|
||||
hidden_origin: Vec<String>,
|
||||
hidden_sketches: Vec<u64>,
|
||||
shown_sketches: Vec<u64>,
|
||||
hidden_profiles: Vec<u64>,
|
||||
shown_dims: Vec<u64>,
|
||||
locked_bodies: Vec<String>,
|
||||
@@ -63,10 +59,6 @@ impl Documents {
|
||||
fn take_view(app: &mut SolveApp) -> DocView {
|
||||
let u = &mut app.ui;
|
||||
DocView {
|
||||
hidden_bodies: std::mem::take(&mut u.hidden_bodies),
|
||||
hidden_origin: std::mem::take(&mut u.hidden_origin),
|
||||
hidden_sketches: std::mem::take(&mut u.hidden_sketches),
|
||||
shown_sketches: std::mem::take(&mut u.shown_sketches),
|
||||
hidden_profiles: std::mem::take(&mut u.hidden_profiles),
|
||||
shown_dims: std::mem::take(&mut u.shown_dims),
|
||||
locked_bodies: std::mem::take(&mut u.locked_bodies),
|
||||
@@ -75,10 +67,6 @@ fn take_view(app: &mut SolveApp) -> DocView {
|
||||
|
||||
fn put_view(app: &mut SolveApp, v: DocView) {
|
||||
let u = &mut app.ui;
|
||||
u.hidden_bodies = v.hidden_bodies;
|
||||
u.hidden_origin = v.hidden_origin;
|
||||
u.hidden_sketches = v.hidden_sketches;
|
||||
u.shown_sketches = v.shown_sketches;
|
||||
u.hidden_profiles = v.hidden_profiles;
|
||||
u.shown_dims = v.shown_dims;
|
||||
u.locked_bodies = v.locked_bodies;
|
||||
@@ -103,13 +91,14 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
|
||||
};
|
||||
put_view(app, slot.view);
|
||||
// Views cache by revision: the incoming design must not look unchanged.
|
||||
app.session.revision = app.session.revision.max(out_rev) + 1;
|
||||
app.session.invalidate_view(out_rev);
|
||||
app.ui.tab = if app.session.active_sketch.is_some() { "SKETCH".into() } else { "SOLID".into() };
|
||||
out
|
||||
}
|
||||
|
||||
/// Make tab `i` the active design.
|
||||
pub fn switch(app: &mut SolveApp, i: usize) {
|
||||
app.home.open = false;
|
||||
if i == app.docs.active || i >= app.docs.slots.len() {
|
||||
return;
|
||||
}
|
||||
@@ -152,7 +141,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
|
||||
crate::dialogs::cancel(app);
|
||||
app.tool = None;
|
||||
app.session = session;
|
||||
app.session.revision = app.session.revision.max(rev) + 1;
|
||||
app.session.invalidate_view(rev);
|
||||
app.cam = Camera::default();
|
||||
app.fit_view();
|
||||
} else if let Err(e) = open_tab(app, session) {
|
||||
@@ -305,6 +294,25 @@ pub fn control(app: &mut SolveApp, p: &Value) -> Value {
|
||||
/// + for a new design.
|
||||
pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
||||
let t = Tokens::get();
|
||||
let home = Rect::from_min_size(pos2(x0, r.top() + 5.0), vec2(66.0, r.height() - 5.0));
|
||||
let response = ui.interact(home, ui.id().with("home_tab"), Sense::click());
|
||||
ui.painter().rect_filled(
|
||||
home,
|
||||
egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 },
|
||||
if app.home.open {
|
||||
t.toolbar
|
||||
} else if response.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
t.app_bar
|
||||
},
|
||||
);
|
||||
ui.painter().text(home.center(), Align2::CENTER_CENTER, "Home", FontId::proportional(13.0), if app.home.open { t.text } else { t.app_bar_text });
|
||||
crate::scenario::publish_handle("tab:home", home.center());
|
||||
if response.clicked() {
|
||||
app.home.show(app.session.revision);
|
||||
}
|
||||
let x0 = x0 + 70.0;
|
||||
let info = tabs_info(app);
|
||||
let n = info.len();
|
||||
let (first, shown, w) = layout(n, app.docs.active, x1 - x0);
|
||||
@@ -313,8 +321,9 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
||||
let mut shut: Option<usize> = None;
|
||||
for (i, (name, dirty)) in info.iter().enumerate().skip(first).take(shown) {
|
||||
let tr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(w - 2.0, r.height() - 5.0));
|
||||
let active = i == app.docs.active || app.docs.slots.is_empty();
|
||||
let active = !app.home.open && (i == app.docs.active || app.docs.slots.is_empty());
|
||||
let resp = ui.interact(tr, ui.id().with(("doc_tab", i)), Sense::click_and_drag());
|
||||
crate::scenario::publish_handle(&format!("tab:design:{i}"), tr.center());
|
||||
// The tabs are part of the title bar: dragging one moves the window, a double click
|
||||
// maximizes it.
|
||||
if app.custom_titlebar || app.integrated_titlebar {
|
||||
@@ -329,7 +338,7 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
||||
} else if resp.hovered() {
|
||||
Color32::from_white_alpha(28)
|
||||
} else {
|
||||
Color32::from_white_alpha(10)
|
||||
t.app_bar
|
||||
};
|
||||
ui.painter().rect_filled(tr, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, fill);
|
||||
let ink = if active { t.text } else { t.app_bar_text };
|
||||
@@ -401,7 +410,6 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
||||
ui.painter().line_segment([c - vec2(0.0, 6.0), c + vec2(0.0, 6.0)], s);
|
||||
if presp.on_hover_text("New Design (Ctrl+N)").clicked() {
|
||||
new_design(app);
|
||||
app.home.show(app.session.revision);
|
||||
}
|
||||
if let Some(i) = shut {
|
||||
close(app, i, false);
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Interactive file operations run off the window thread. The old design stays intact until
|
||||
//! the command and display meshes are ready; failures and cancelled jobs never replace it.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicU8, Ordering},
|
||||
mpsc::{Receiver, TryRecvError},
|
||||
};
|
||||
|
||||
use serde_json::Value;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::{Session, doc::Document};
|
||||
|
||||
use crate::SolveApp;
|
||||
|
||||
struct Request {
|
||||
path: String,
|
||||
insert: bool,
|
||||
}
|
||||
struct Made {
|
||||
session: Session,
|
||||
result: Value,
|
||||
image: Option<egui::ColorImage>,
|
||||
}
|
||||
type Job = Result<Made, String>;
|
||||
struct Running {
|
||||
request: Request,
|
||||
base: Arc<Document>,
|
||||
before: Vec<String>,
|
||||
rx: Receiver<Job>,
|
||||
phase: Arc<AtomicU8>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FileLoad {
|
||||
queued: VecDeque<Request>,
|
||||
running: Option<Running>,
|
||||
}
|
||||
|
||||
impl FileLoad {
|
||||
pub fn busy(&self) -> bool {
|
||||
self.running.as_ref().is_some_and(|r| !r.cancelled.load(Ordering::Relaxed)) || !self.queued.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn begin(app: &mut SolveApp, path: &str, insert: bool) {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if insert {
|
||||
app.insert_path(path);
|
||||
} else {
|
||||
app.open_path(path);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if app.file_load.queued.len() >= crate::documents::MAX_OPEN {
|
||||
app.set_status("too many queued files", true);
|
||||
return;
|
||||
}
|
||||
app.file_load.queued.push_back(Request { path: path.into(), insert });
|
||||
start(app);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn start(app: &mut SolveApp) {
|
||||
if app.file_load.running.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(request) = app.file_load.queued.pop_front() else { return };
|
||||
if !request.insert && app.docs.count() >= crate::documents::MAX_OPEN && !crate::documents::active_is_blank(app) {
|
||||
app.set_status("too many open designs; close a tab before opening another", true);
|
||||
return;
|
||||
}
|
||||
let before = if request.insert { app.session.world_state().bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() };
|
||||
let mut scratch = if request.insert { app.session.scratch() } else { Session::default() };
|
||||
if request.insert {
|
||||
scratch.selection = app.session.selection.clone();
|
||||
}
|
||||
let base = app.session.doc.clone();
|
||||
let phase = Arc::new(AtomicU8::new(0));
|
||||
let worker_phase = phase.clone();
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let worker_cancelled = cancelled.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let path = request.path.clone();
|
||||
let insert = request.insert;
|
||||
let spawned = std::thread::Builder::new().name("file-load".into()).spawn(move || {
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(scratch, &path, insert, &worker_phase, &worker_cancelled)))
|
||||
.unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into()));
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => app.file_load.running = Some(Running { request, base, before, rx, phase, cancelled }),
|
||||
Err(e) => app.set_status(format!("could not start file operation: {e}"), true),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, cancelled: &AtomicBool) -> Job {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Err("file operation cancelled".into());
|
||||
}
|
||||
let command = if !insert {
|
||||
"doc.open"
|
||||
} else if solvecraft_engine::io::is_mesh_path(path) {
|
||||
"file.insert_mesh"
|
||||
} else {
|
||||
"file.insert_step"
|
||||
};
|
||||
let result = session.execute(command, &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
phase.store(1, Ordering::Relaxed);
|
||||
// Fit, picking and the first viewport frame must not trigger cold tessellation on the UI.
|
||||
for b in &session.world_state().bodies {
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
return Err("file operation cancelled".into());
|
||||
}
|
||||
let _ = b.mesh();
|
||||
}
|
||||
phase.store(2, Ordering::Relaxed);
|
||||
let image = if insert { None } else { crate::home::remember(&session) };
|
||||
Ok(Made { session, result, image })
|
||||
}
|
||||
|
||||
pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let result = app.file_load.running.as_ref().and_then(|r| match r.rx.try_recv() {
|
||||
Ok(job) => Some(job),
|
||||
Err(TryRecvError::Empty) => None,
|
||||
Err(TryRecvError::Disconnected) => Some(Err("the file operation stopped; the current design is unchanged".into())),
|
||||
});
|
||||
if let Some(job) = result
|
||||
&& let Some(running) = app.file_load.running.take()
|
||||
{
|
||||
match job {
|
||||
_ if running.cancelled.load(Ordering::Relaxed) => {}
|
||||
Ok(made) if !running.request.insert || Arc::ptr_eq(&app.session.doc, &running.base) => {
|
||||
if running.request.insert {
|
||||
app.session.accept_computed(made.session);
|
||||
app.big_operation = true;
|
||||
} else {
|
||||
crate::documents::adopt(app, made.session);
|
||||
app.home.add_recent(&running.request.path);
|
||||
crate::home::install(&mut app.home, ctx, &running.request.path, made.image);
|
||||
}
|
||||
app.home.open = false;
|
||||
app.fit_view();
|
||||
crate::stand_up::offer(app, &running.request.path, &running.before);
|
||||
let warnings = made.result.get("warnings").and_then(Value::as_array);
|
||||
app.set_status(
|
||||
if let Some(w) = warnings.filter(|w| !w.is_empty()) {
|
||||
format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or(""))
|
||||
} else {
|
||||
format!("{} done", if running.request.insert { "Insert" } else { "Open" })
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
Ok(_) => app.set_status("the active design changed while importing; the import was discarded", true),
|
||||
Err(e) => app.set_status(e, true),
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
start(app);
|
||||
if app.file_load.busy() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(r) = &app.file_load.running else { return };
|
||||
let cancelled = r.cancelled.load(Ordering::Relaxed);
|
||||
if cancelled && app.file_load.queued.is_empty() {
|
||||
return;
|
||||
}
|
||||
let name = std::path::Path::new(&r.request.path).file_name().map(|p| p.to_string_lossy()).unwrap_or_default();
|
||||
let phase = r.phase.load(Ordering::Relaxed);
|
||||
let mut cancel = false;
|
||||
egui::Modal::new(egui::Id::new("file_loading")).show(ctx, |ui| {
|
||||
ui.heading(if cancelled {
|
||||
"Waiting for file operation"
|
||||
} else if r.request.insert {
|
||||
"Inserting geometry"
|
||||
} else {
|
||||
"Opening design"
|
||||
});
|
||||
ui.label(name.as_ref());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label(if cancelled {
|
||||
"Waiting for the previous operation to stop..."
|
||||
} else {
|
||||
match phase {
|
||||
1 => "Preparing the view...",
|
||||
2 => "Preparing the thumbnail...",
|
||||
_ => "Reading and building geometry...",
|
||||
}
|
||||
});
|
||||
});
|
||||
cancel = ui.button("Cancel").clicked();
|
||||
});
|
||||
if cancel {
|
||||
// Keep the running job until it exits: cancelling must not allow another large
|
||||
// import to overlap it in memory. The user can continue editing the current design.
|
||||
if let Some(r) = &app.file_load.running {
|
||||
r.cancelled.store(true, Ordering::Relaxed);
|
||||
}
|
||||
app.file_load.queued.clear();
|
||||
app.set_status("file operation cancelled", false);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn failed_import_keeps_the_current_document() {
|
||||
let mut app = SolveApp::new(Session::default(), Default::default());
|
||||
let old = app.session.doc.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
tx.send(Err("bad STEP".into())).unwrap();
|
||||
app.file_load.running = Some(Running {
|
||||
request: Request { path: "bad.step".into(), insert: true },
|
||||
base: old.clone(),
|
||||
before: Vec::new(),
|
||||
rx,
|
||||
phase: Arc::new(AtomicU8::new(0)),
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
});
|
||||
tick(&mut app, &egui::Context::default());
|
||||
assert!(Arc::ptr_eq(&app.session.doc, &old));
|
||||
assert!(!app.file_load.busy());
|
||||
assert!(app.status.as_ref().unwrap().0.contains("bad STEP"));
|
||||
}
|
||||
#[test]
|
||||
fn cancelling_discards_a_finished_result_and_keeps_the_worker_slot_until_completion() {
|
||||
let mut app = SolveApp::new(Session::default(), Default::default());
|
||||
let old = app.session.doc.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let cancelled = Arc::new(AtomicBool::new(true));
|
||||
app.file_load.running = Some(Running {
|
||||
request: Request { path: "cancelled.step".into(), insert: true },
|
||||
base: old.clone(),
|
||||
before: Vec::new(),
|
||||
rx,
|
||||
phase: Arc::new(AtomicU8::new(0)),
|
||||
cancelled,
|
||||
});
|
||||
assert!(!app.file_load.busy());
|
||||
assert!(app.file_load.running.is_some());
|
||||
let mut computed = Session::default();
|
||||
computed.execute("solid.box", &serde_json::json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
tx.send(Ok(Made { session: computed, result: Value::Null, image: None })).unwrap();
|
||||
tick(&mut app, &egui::Context::default());
|
||||
assert!(app.file_load.running.is_none());
|
||||
assert!(Arc::ptr_eq(&old, &app.session.doc));
|
||||
}
|
||||
}
|
||||
@@ -255,7 +255,7 @@ pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::I
|
||||
}
|
||||
let state: Option<State> = ui.data(|d| d.get_temp(id));
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let hovered = if state.is_none() { pointer.filter(|p| proj.rect.contains(*p)).and_then(|p| hit(&sh, p)) } else { None };
|
||||
let hovered = if state.is_none() { pointer.filter(|_| ui.rect_contains_pointer(proj.rect)).and_then(|p| hit(&sh, p)) } else { None };
|
||||
let active = state.map(|s| s.handle).or(hovered);
|
||||
let hot = |h: Handle| active == Some(h);
|
||||
let col = |h: Handle, i: usize| if hot(h) { tk.accent } else { axis_color(i) };
|
||||
|
||||
+128
-34
@@ -11,7 +11,7 @@ use egui_wgpu::wgpu::util::DeviceExt;
|
||||
pub const TRI_SIZE: usize = 28;
|
||||
/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32).
|
||||
pub const LINE_SIZE: usize = 32;
|
||||
const UNIFORM_SIZE: u64 = 192;
|
||||
const UNIFORM_SIZE: u64 = 208;
|
||||
|
||||
/// CPU-side geometry waiting to be uploaded. The model scene changes with the design; the
|
||||
/// highlight scene (hover, selection, the origin widget) changes often and is small.
|
||||
@@ -146,6 +146,9 @@ pub struct ViewportCallback {
|
||||
pub access: Option<AccessMap>,
|
||||
/// Draw silhouette outlines around the model (visual styles with edges).
|
||||
pub outline: bool,
|
||||
/// Eye position and 1 for a perspective camera (0: orthographic), for the section cap's depth
|
||||
/// and the depth bias.
|
||||
pub eye: [f32; 4],
|
||||
}
|
||||
|
||||
/// Heights of the model seen from a direction (an orthographic depth map): a point is reachable
|
||||
@@ -208,6 +211,9 @@ impl Batches {
|
||||
|
||||
struct Resources {
|
||||
tri: wgpu::RenderPipeline,
|
||||
/// While a section is shown (when the depth buffer has a stencil): `tri` marking where the
|
||||
/// cap shows, then the cap again at the section plane's depth, so it hides the inside.
|
||||
tri_cut: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
|
||||
/// Highlight triangles over the model (depth test ≤, no depth write).
|
||||
tri_hl: wgpu::RenderPipeline,
|
||||
trans: wgpu::RenderPipeline,
|
||||
@@ -274,13 +280,14 @@ 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, silhouettes on (1)
|
||||
screen: vec4<f32>, // viewport width, height (px), depth bias (see `biased_z`), 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
|
||||
pull: vec4<f32>, // draft pull direction
|
||||
acc_u: vec4<f32>, // accessibility map axes (xyz, offset)
|
||||
acc_v: vec4<f32>,
|
||||
eye: vec4<f32>, // eye position, w = 1 for perspective (0: orthographic, looking along -back)
|
||||
};
|
||||
|
||||
@group(1) @binding(2) var acc_t: texture_2d<f32>;
|
||||
@@ -321,6 +328,41 @@ fn vs_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c:
|
||||
/// Opaque model surfaces: cut by the section plane, the inside shows as a flat cap colour.
|
||||
@fragment
|
||||
fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let c = solid(i, front);
|
||||
return c;
|
||||
}
|
||||
|
||||
struct CutOut {
|
||||
@location(0) c: vec4<f32>,
|
||||
@builtin(frag_depth) depth: f32,
|
||||
};
|
||||
|
||||
/// The section cap (the inside seen through the cut, drawn on the far walls' back faces where
|
||||
/// they are the visible surface) again, at the depth of the section plane rather than of the
|
||||
/// far wall, so the cut face hides what lies inside the body instead of showing it like a
|
||||
/// pane of glass.
|
||||
@fragment
|
||||
fn fs_solid_cut(i: TOut, @builtin(front_facing) front: bool) -> CutOut {
|
||||
var o: CutOut;
|
||||
o.c = solid(i, front);
|
||||
o.depth = cap_depth(i.wp, i.pos.z);
|
||||
return o;
|
||||
}
|
||||
|
||||
/// Depth of the section plane on the view ray through `wp` (a far wall seen through the cut),
|
||||
/// never behind the wall itself (`z`).
|
||||
fn cap_depth(wp: vec3<f32>, z: f32) -> f32 {
|
||||
var dir = -u.back.xyz;
|
||||
if (u.eye.w > 0.5) { dir = wp - u.eye.xyz; }
|
||||
let dn = dot(u.clip.xyz, dir);
|
||||
if (abs(dn) < 1e-9) { return z; }
|
||||
let p = wp + dir * ((u.clip.w - dot(u.clip.xyz, wp)) / dn);
|
||||
let c = u.vp * vec4<f32>(p, 1.0);
|
||||
if (c.w <= 0.0) { return z; }
|
||||
return clamp(c.z / c.w, 0.0, z);
|
||||
}
|
||||
|
||||
fn solid(i: TOut, front: bool) -> vec4<f32> {
|
||||
// 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);
|
||||
@@ -328,9 +370,11 @@ fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f3
|
||||
let fw = fwidth(ndv);
|
||||
if (clipped(i.wp)) { discard; }
|
||||
if (!front && length(u.clip.xyz) > 0.5) {
|
||||
// The cut face: the cap colour, hatched at 45 degrees on screen.
|
||||
// The cut face in its body's colour (as Fusion's "from component"), tinted toward the
|
||||
// cap colour so it reads as a cut, hatched at 45 degrees on screen.
|
||||
let base = mix(i.c.rgb, u.cap.rgb, 0.35);
|
||||
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));
|
||||
return out_color(vec4<f32>(select(base, base * 0.6, h < 0.22), 1.0));
|
||||
}
|
||||
if (u.ana.x > 0.5 && length(i.n) > 0.5) {
|
||||
return analysis(i, k);
|
||||
@@ -440,11 +484,23 @@ fn shade(i: TOut) -> vec4<f32> {
|
||||
return out_color(vec4<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), i.c.a));
|
||||
}
|
||||
|
||||
/// Clip-space z of `p` (clipped to `c`) pulled `s` depth biases toward the eye (pushed away
|
||||
/// when negative). Orthographic depth is linear in distance, so the bias is a fixed step of it.
|
||||
/// Perspective depth is not: near the far plane a fixed step spans tens of millimetres (edges
|
||||
/// showed through walls), so the point moves toward the eye by a fraction of its distance.
|
||||
fn biased_z(p: vec3<f32>, c: vec4<f32>, s: f32) -> f32 {
|
||||
if (u.eye.w > 0.5) {
|
||||
let q = u.vp * vec4<f32>(mix(p, u.eye.xyz, u.screen.z * 8.0 * s), 1.0);
|
||||
return q.z / max(q.w, 1e-6) * c.w;
|
||||
}
|
||||
return c.z - u.screen.z * s * c.w;
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_ghost(@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);
|
||||
o.pos.z = o.pos.z + u.screen.z * 4.0 * o.pos.w;
|
||||
o.pos.z = biased_z(p, o.pos, -4.0);
|
||||
o.n = n;
|
||||
o.c = c;
|
||||
o.wp = p;
|
||||
@@ -476,7 +532,7 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(
|
||||
let base = select(ca, cb, t > 0.5);
|
||||
let off = nrm * s * w * 0.5 + dir * (t * 2.0 - 1.0) * w * 0.5;
|
||||
var o: LOut;
|
||||
o.pos = vec4<f32>(base.xy + off / half * base.w, base.z - u.screen.z * base.w, base.w);
|
||||
o.pos = vec4<f32>(base.xy + off / half * base.w, biased_z(select(a, b, t > 0.5), base, 1.0), base.w);
|
||||
o.c = c;
|
||||
o.wp = select(a, b, t > 0.5);
|
||||
return o;
|
||||
@@ -503,7 +559,7 @@ fn vs_img(@location(0) p: vec3<f32>, @location(1) uv: vec2<f32>, @location(2) a:
|
||||
var o: IOut;
|
||||
o.pos = u.vp * vec4<f32>(p, 1.0);
|
||||
// Pulled toward the eye like lines, so a decal on a face is not lost in it.
|
||||
o.pos.z = o.pos.z - u.screen.z * o.pos.w;
|
||||
o.pos.z = biased_z(p, o.pos, 1.0);
|
||||
o.uv = uv;
|
||||
o.a = a;
|
||||
o.wp = p;
|
||||
@@ -697,27 +753,29 @@ impl Resources {
|
||||
bind_group_layouts: &[Some(&bgl), Some(&access_bgl)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let solid = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("sc_tris"),
|
||||
layout: Some(&solid_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_tri"),
|
||||
buffers: &[Some(tri_layout())],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
|
||||
depth_stencil: depth(wgpu::CompareFunction::Less, true),
|
||||
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_solid"),
|
||||
targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: None, write_mask: wgpu::ColorWrites::ALL })],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let solid = |label: &str, fs: &str, cull: Option<wgpu::Face>, ds: Option<wgpu::DepthStencilState>| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&solid_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_tri"),
|
||||
buffers: &[Some(tri_layout())],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, cull_mode: cull, ..Default::default() },
|
||||
depth_stencil: ds,
|
||||
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),
|
||||
targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: None, write_mask: wgpu::ColorWrites::ALL })],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let access_bind = access_texture(device, None, &access_bgl, 1, &[]);
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("sc_image"),
|
||||
@@ -731,7 +789,29 @@ impl Resources {
|
||||
sampler,
|
||||
textures: Default::default(),
|
||||
quads: Vec::new(),
|
||||
tri: solid,
|
||||
tri: solid("sc_tris", "fs_solid", None, depth(wgpu::CompareFunction::Less, true)),
|
||||
tri_cut: t.depth.filter(|f| f.has_stencil_aspect()).map(|format| {
|
||||
// The visible surface marks the stencil: 1 where it is a back face (the cap).
|
||||
use wgpu::StencilOperation::{Keep, Replace, Zero};
|
||||
let face = |pass_op| wgpu::StencilFaceState { compare: wgpu::CompareFunction::Always, fail_op: Keep, depth_fail_op: Keep, pass_op };
|
||||
let mark = wgpu::DepthStencilState {
|
||||
format,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Less),
|
||||
stencil: wgpu::StencilState { front: face(Zero), back: face(Replace), read_mask: 0xff, write_mask: 0xff },
|
||||
bias: Default::default(),
|
||||
};
|
||||
// Then the cap's back faces again, only there, at the section plane's depth.
|
||||
let only = wgpu::StencilFaceState { compare: wgpu::CompareFunction::Equal, ..face(Keep) };
|
||||
let cap = wgpu::DepthStencilState {
|
||||
format,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: wgpu::StencilState { front: only, back: only, read_mask: 0xff, write_mask: 0 },
|
||||
bias: Default::default(),
|
||||
};
|
||||
(solid("sc_tris_mark", "fs_solid", None, Some(mark)), solid("sc_tris_cap", "fs_solid_cut", Some(wgpu::Face::Front), Some(cap)))
|
||||
}),
|
||||
access_bgl,
|
||||
access_bind,
|
||||
access_version: 0,
|
||||
@@ -793,9 +873,10 @@ impl Batches {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 };
|
||||
/// Create the pipelines. Call once with the app's render state and its depth/stencil/MSAA
|
||||
/// settings. Without a stencil the section cap does not hide the inside of cut bodies.
|
||||
pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) -> GpuTarget {
|
||||
let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, stencil_bits), samples };
|
||||
let res = Resources::new(&rs.device, t);
|
||||
rs.renderer.write().callback_resources.insert(res);
|
||||
t
|
||||
@@ -899,8 +980,20 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
let (m, h, pv) = (&res.model, &res.highlight, &res.preview);
|
||||
lines(pass, &res.overlay, &m.under);
|
||||
pass.set_bind_group(1, &res.access_bind, &[]);
|
||||
tris(pass, &res.tri, &m.tris);
|
||||
tris(pass, &res.tri, &pv.tris);
|
||||
let cut = res.tri_cut.as_ref().filter(|_| self.clip[..3].iter().map(|c| c * c).sum::<f32>() > 0.25);
|
||||
match cut {
|
||||
Some((mark, cap)) => {
|
||||
pass.set_stencil_reference(1);
|
||||
tris(pass, mark, &m.tris);
|
||||
tris(pass, mark, &pv.tris);
|
||||
tris(pass, cap, &m.tris);
|
||||
tris(pass, cap, &pv.tris);
|
||||
}
|
||||
None => {
|
||||
tris(pass, &res.tri, &m.tris);
|
||||
tris(pass, &res.tri, &pv.tris);
|
||||
}
|
||||
}
|
||||
tris(pass, &res.tri_hl, &h.tris);
|
||||
lines(pass, &res.line, &m.lines);
|
||||
lines(pass, &res.line_hidden, &m.hidden);
|
||||
@@ -941,6 +1034,7 @@ pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec
|
||||
}
|
||||
None => v.extend([0.0; 8]),
|
||||
}
|
||||
v.extend(cb.eye);
|
||||
v.iter().flat_map(|x| x.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
|
||||
+217
-24
@@ -1,6 +1,6 @@
|
||||
//! The start page: New Design, Open, the recent designs and the built-in samples, each with a
|
||||
//! picture of the design rendered from it. Shown on launch without a file and from the house
|
||||
//! button; it closes when a design is opened or started, or the design changes.
|
||||
//! button or the first document tab; selecting a design returns to its view.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -24,22 +24,26 @@ pub struct HomeState {
|
||||
pub open: bool,
|
||||
/// Recent design paths, newest first (kept in the preferences).
|
||||
pub recent: Vec<String>,
|
||||
/// The session revision when the page opened (a change closes it).
|
||||
rev: u64,
|
||||
pub opened_at: std::collections::BTreeMap<String, u64>,
|
||||
thumbs: HashMap<String, Option<egui::TextureHandle>>,
|
||||
samples: HashMap<usize, Session>,
|
||||
/// Keys asked for and not made yet.
|
||||
queued: Vec<String>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
rx: Option<std::sync::mpsc::Receiver<Made>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
remembering: Option<std::sync::mpsc::Receiver<Made>>,
|
||||
/// A sample picked before its design was ready.
|
||||
wanted: Option<usize>,
|
||||
}
|
||||
|
||||
impl HomeState {
|
||||
pub fn show(&mut self, rev: u64) {
|
||||
pub fn ready_recents(&self) -> usize {
|
||||
self.recent.iter().filter(|p| self.thumbs.get(*p).is_some_and(Option::is_some)).count()
|
||||
}
|
||||
|
||||
pub fn show(&mut self, _rev: u64) {
|
||||
self.open = true;
|
||||
self.rev = rev;
|
||||
}
|
||||
|
||||
/// Put a design at the top of the recent list.
|
||||
@@ -47,6 +51,8 @@ impl HomeState {
|
||||
self.recent.retain(|p| p != path);
|
||||
self.recent.insert(0, path.to_string());
|
||||
self.recent.truncate(MAX_RECENT);
|
||||
self.opened_at.insert(path.to_string(), web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0));
|
||||
self.opened_at.retain(|p, _| self.recent.contains(p));
|
||||
self.thumbs.remove(path);
|
||||
}
|
||||
}
|
||||
@@ -67,8 +73,104 @@ pub fn thumbnail(s: &Session) -> Option<egui::ColorImage> {
|
||||
(c.rgba.len() == c.w * c.h * 4).then(|| egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba))
|
||||
}
|
||||
|
||||
/// File identity, not just its path: saving or replacing a design invalidates its picture.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn cache_path(key: &str) -> Option<std::path::PathBuf> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let meta = std::fs::metadata(key).ok()?;
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
key.hash(&mut h);
|
||||
meta.len().hash(&mut h);
|
||||
meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.hash(&mut h);
|
||||
let root = std::env::var_os("LOCALAPPDATA")
|
||||
.or_else(|| std::env::var_os("XDG_CACHE_HOME"))
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir);
|
||||
Some(root.join("SolveCraft").join("thumbnails").join(format!("{:016x}.png", h.finish())))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn cached(key: &str) -> Option<egui::ColorImage> {
|
||||
let path = cache_path(key)?;
|
||||
if std::fs::metadata(&path).ok()?.len() > 512 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Png);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(THUMB[0] as u32);
|
||||
limits.max_image_height = Some(THUMB[1] as u32);
|
||||
limits.max_alloc = Some((THUMB[0] * THUMB[1] * 8) as u64);
|
||||
reader.limits(limits);
|
||||
let image = reader.decode().ok()?.to_rgba8();
|
||||
(image.width() as usize == THUMB[0] && image.height() as usize == THUMB[1])
|
||||
.then(|| egui::ColorImage::from_rgba_unmultiplied(THUMB, image.as_raw()))
|
||||
}
|
||||
|
||||
/// Render from the model already built by Open/Save, rather than evaluating its file again.
|
||||
pub(crate) fn remember(s: &Session) -> Option<egui::ColorImage> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let identity = s.path.as_deref().and_then(cache_path);
|
||||
remember_at(
|
||||
s,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
identity,
|
||||
)
|
||||
}
|
||||
|
||||
fn remember_at(s: &Session, #[cfg(not(target_arch = "wasm32"))] identity: Option<std::path::PathBuf>) -> Option<egui::ColorImage> {
|
||||
let img = thumbnail(s)?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(path) = identity.filter(|p| s.path.as_deref().and_then(cache_path).as_ref() == Some(p)) {
|
||||
let rgba: Vec<u8> = img.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect();
|
||||
if let Some(bytes) = solvecraft_engine::render::png(THUMB[0], THUMB[1], rgba)
|
||||
&& let Some(dir) = path.parent()
|
||||
&& std::fs::create_dir_all(dir).is_ok()
|
||||
{
|
||||
let _ = solvecraft_engine::io::write_atomic(&path, &bytes, false);
|
||||
}
|
||||
}
|
||||
Some(img)
|
||||
}
|
||||
|
||||
pub(crate) fn remember_session(h: &mut HomeState, s: &Session) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(path) = &s.path {
|
||||
let identity = cache_path(path);
|
||||
let mut snapshot = s.scratch();
|
||||
snapshot.path = Some(path.clone());
|
||||
let key = path.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
if std::thread::Builder::new()
|
||||
.name("saved-thumbnail".into())
|
||||
.spawn(move || {
|
||||
let img = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| remember_at(&snapshot, identity))).ok().flatten();
|
||||
let _ = tx.send((key, img, None));
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
h.remembering = Some(rx);
|
||||
h.queued.retain(|k| k != path);
|
||||
h.thumbs.insert(path.clone(), None);
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (h, s);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn install(h: &mut HomeState, ctx: &egui::Context, key: &str, img: Option<egui::ColorImage>) {
|
||||
let tex = img.map(|i| ctx.load_texture(format!("thumb:{key}"), i, egui::TextureOptions::LINEAR));
|
||||
h.thumbs.insert(key.to_string(), tex);
|
||||
}
|
||||
|
||||
/// Build what a key stands for (runs off the UI thread where threads exist).
|
||||
fn make(key: &str) -> Made {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(img) = cached(key) {
|
||||
return (key.to_string(), Some(img), None);
|
||||
}
|
||||
let mut s = Session::default();
|
||||
let built = match key.strip_prefix("sample:").and_then(|i| i.parse::<usize>().ok()) {
|
||||
Some(i) => solvecraft_engine::sample::samples().get(i).is_some_and(|smp| {
|
||||
@@ -83,7 +185,7 @@ fn make(key: &str) -> Made {
|
||||
if !built {
|
||||
return (key.to_string(), None, None);
|
||||
}
|
||||
let img = thumbnail(&s);
|
||||
let img = remember(&s);
|
||||
(key.to_string(), img, key.starts_with("sample:").then_some(s))
|
||||
}
|
||||
|
||||
@@ -92,7 +194,18 @@ fn start_jobs(h: &mut HomeState) {
|
||||
if h.rx.is_some() || h.queued.is_empty() {
|
||||
return;
|
||||
}
|
||||
let keys = std::mem::take(&mut h.queued);
|
||||
let mut keys = std::mem::take(&mut h.queued);
|
||||
// Cached recents first, then cold recents, then samples. A sample must never hold up
|
||||
// pictures of the user's work; a cold file must not hold up already cached pictures.
|
||||
keys.sort_by_key(|k| {
|
||||
if k.starts_with("sample:") {
|
||||
2
|
||||
} else if cache_path(k).is_some_and(|p| p.exists()) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
});
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let spawned = std::thread::Builder::new().name("thumbnails".into()).spawn(move || {
|
||||
for k in keys {
|
||||
@@ -112,12 +225,31 @@ fn start_jobs(h: &mut HomeState) {
|
||||
fn poll(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let mut done: Vec<Made> = Vec::new();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let mut remembered = None;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Some(rx) = &app.home.remembering {
|
||||
match rx.try_recv() {
|
||||
Ok(m) => {
|
||||
remembered = Some(m);
|
||||
app.home.remembering = None;
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => app.home.remembering = None,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
}
|
||||
start_jobs(&mut app.home);
|
||||
if let Some(rx) = &app.home.rx {
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(m) => done.push(m),
|
||||
Ok(m) => {
|
||||
// A cold job started before a save/open cannot overwrite its fresh picture.
|
||||
if m.0.starts_with("sample:") || !app.home.thumbs.get(&m.0).is_some_and(Option::is_some) {
|
||||
done.push(m);
|
||||
}
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => break,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
app.home.rx = None;
|
||||
@@ -126,6 +258,9 @@ fn poll(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(m) = remembered {
|
||||
done.push(m);
|
||||
}
|
||||
if app.home.rx.is_some() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
@@ -179,22 +314,17 @@ pub fn open_sample(app: &mut SolveApp, i: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_recent(app: &mut SolveApp, path: &str) {
|
||||
pub fn open_recent(app: &mut SolveApp, path: &str) {
|
||||
if !solvecraft_engine::io::vfs::exists(path) {
|
||||
app.home.recent.retain(|p| p != path);
|
||||
app.set_status(format!("{path} is no longer there"), true);
|
||||
return;
|
||||
}
|
||||
app.home.open = false;
|
||||
app.open_path(path);
|
||||
app.open_path_async(path);
|
||||
}
|
||||
|
||||
/// The page itself (in place of the toolbar, browser and viewport).
|
||||
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if app.session.revision != app.home.rev {
|
||||
app.home.open = false;
|
||||
return;
|
||||
}
|
||||
poll(app, ui.ctx());
|
||||
let t = Tokens::get();
|
||||
let samples = solvecraft_engine::sample::samples();
|
||||
@@ -255,12 +385,25 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
action = Some(Box::new(|app: &mut SolveApp| {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
app.home.open = false;
|
||||
app.open_path(&p);
|
||||
app.open_path_async(&p);
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
ui.add_space(24.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.hyperlink_to("Documentation", crate::help::DOCS_URL);
|
||||
ui.hyperlink_to("Report an Issue", "https://github.com/bherbruck/solvecraft/issues/new");
|
||||
ui.hyperlink_to("What's new", "https://github.com/bherbruck/solvecraft/releases/latest");
|
||||
if ui.link("Keyboard Shortcuts").clicked() {
|
||||
app.keymap.open = true;
|
||||
app.keymap.read_only = true;
|
||||
}
|
||||
if ui.link("Preferences").clicked() {
|
||||
crate::prefs::open_at(app, 0);
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
section(ui, "Recent");
|
||||
if recent.is_empty() {
|
||||
ui.label(RichText::new("Designs you open or save appear here.").color(t.text_dim));
|
||||
@@ -269,20 +412,46 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let Some(p) = recent.get(i) else { return };
|
||||
let path = std::path::Path::new(p);
|
||||
let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| p.clone());
|
||||
let folder = path.parent().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let missing = !solvecraft_engine::io::vfs::exists(p);
|
||||
let sub = if missing {
|
||||
let mut sub = if missing {
|
||||
"missing".to_string()
|
||||
} else if p.starts_with("/opfs/") {
|
||||
// The web build keeps designs in the browser's own storage.
|
||||
"in this browser".to_string()
|
||||
} else {
|
||||
folder
|
||||
p.clone()
|
||||
};
|
||||
if card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing)
|
||||
.on_hover_text(p.as_str())
|
||||
.clicked()
|
||||
{
|
||||
if let Some(time) = app.home.opened_at.get(p) {
|
||||
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(*time);
|
||||
let seconds = now.saturating_sub(*time);
|
||||
let ago = if seconds < 60 {
|
||||
"just now".to_string()
|
||||
} else if seconds < 3600 {
|
||||
format!("{} min ago", seconds / 60)
|
||||
} else if seconds < 86400 {
|
||||
format!("{} h ago", seconds / 3600)
|
||||
} else {
|
||||
format!("{} days ago", seconds / 86400)
|
||||
};
|
||||
sub.push_str(&format!("\nOpened {ago}"));
|
||||
}
|
||||
let response = card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing)
|
||||
.on_hover_text(p.as_str());
|
||||
crate::scenario::publish_handle(&format!("home:recent:{i}"), response.rect.center());
|
||||
response.context_menu(|ui| {
|
||||
if ui.button("Remove from list").clicked() {
|
||||
app.home.recent.retain(|x| x != p);
|
||||
app.home.opened_at.remove(p);
|
||||
ui.close();
|
||||
}
|
||||
if !cfg!(target_arch = "wasm32") && ui.button("Show in folder").clicked() {
|
||||
if let Some(show) = &app.services.show_in_folder {
|
||||
show(p);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if response.clicked() {
|
||||
let p = p.clone();
|
||||
action = Some(Box::new(move |app: &mut SolveApp| open_recent(app, &p)));
|
||||
}
|
||||
@@ -340,7 +509,7 @@ enum Pic<'a> {
|
||||
/// A design card: its picture (a spinner while it renders), name and a line under it.
|
||||
fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui::Response {
|
||||
let t = Tokens::get();
|
||||
let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 62.0);
|
||||
let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 80.0);
|
||||
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
let hover = resp.hovered();
|
||||
ui.painter().rect(
|
||||
@@ -382,6 +551,30 @@ fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui::
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn cached_recent_uses_a_separate_png_without_reading_or_changing_design_json() {
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("solvecraft-thumb-{}-{nonce}.solvecraft", std::process::id()));
|
||||
// Deliberately invalid design: a cached recent must not parse/evaluate it.
|
||||
let bytes = b"{\"thumbnail_test\":true}";
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let key = path.to_string_lossy().to_string();
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &serde_json::json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
s.path = Some(key.clone());
|
||||
let image = remember(&s).unwrap();
|
||||
let png = cache_path(&key).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
let (_, cached_image, session) = make(&key);
|
||||
assert!(session.is_none());
|
||||
assert_eq!(cached_image.unwrap().pixels, image.pixels);
|
||||
std::fs::write(&path, b"changed file identity").unwrap();
|
||||
assert!(cached(&key).is_none());
|
||||
std::fs::remove_file(path).unwrap();
|
||||
std::fs::remove_file(png).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_list_is_newest_first_and_bounded() {
|
||||
let mut h = HomeState::default();
|
||||
|
||||
+49
-19
@@ -54,6 +54,7 @@ mod status_tests {
|
||||
assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last());
|
||||
}
|
||||
}
|
||||
mod file_load;
|
||||
pub mod palette;
|
||||
pub mod params_dialog;
|
||||
pub mod prefs;
|
||||
@@ -101,9 +102,6 @@ pub struct UiState {
|
||||
pub show_grid: bool,
|
||||
pub show_origin: bool,
|
||||
pub perspective: bool,
|
||||
pub hidden_bodies: Vec<String>,
|
||||
/// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction planes hidden one by one.
|
||||
pub hidden_origin: Vec<String>,
|
||||
pub show_sketches: bool,
|
||||
pub palette_open: bool,
|
||||
/// Dark theme (the default); a preference kept between runs.
|
||||
@@ -118,9 +116,6 @@ pub struct UiState {
|
||||
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>,
|
||||
/// Sketches whose closed profiles are not shaded.
|
||||
pub hidden_profiles: Vec<u64>,
|
||||
/// Finished sketches whose dimensions are shown.
|
||||
@@ -140,8 +135,6 @@ impl Default for UiState {
|
||||
show_grid: true,
|
||||
show_origin: true,
|
||||
perspective: false,
|
||||
hidden_bodies: Vec::new(),
|
||||
hidden_origin: Vec::new(),
|
||||
show_sketches: true,
|
||||
palette_open: false,
|
||||
dark: true,
|
||||
@@ -150,8 +143,6 @@ impl Default for UiState {
|
||||
visual_style: 0,
|
||||
ground_shadow: true,
|
||||
up_axis: Default::default(),
|
||||
hidden_sketches: Vec::new(),
|
||||
shown_sketches: Vec::new(),
|
||||
hidden_profiles: Vec::new(),
|
||||
shown_dims: Vec::new(),
|
||||
locked_bodies: Vec::new(),
|
||||
@@ -165,6 +156,7 @@ impl Default for UiState {
|
||||
pub struct Services {
|
||||
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
pub pick_save: Option<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
|
||||
pub show_in_folder: Option<Box<dyn Fn(&str)>>,
|
||||
/// Where the host reads the anti-aliasing preference at start (a small JSON file).
|
||||
pub graphics_path: Option<String>,
|
||||
}
|
||||
@@ -239,6 +231,7 @@ pub struct SolveApp {
|
||||
fitted: bool,
|
||||
/// The start page.
|
||||
pub home: home::HomeState,
|
||||
pub file_load: file_load::FileLoad,
|
||||
/// The other open designs (tabs).
|
||||
pub docs: documents::Documents,
|
||||
}
|
||||
@@ -290,14 +283,15 @@ impl SolveApp {
|
||||
styled: false,
|
||||
fitted: false,
|
||||
home: home::HomeState::default(),
|
||||
file_load: file_load::FileLoad::default(),
|
||||
docs: documents::Documents::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the viewport on the GPU (eframe's wgpu render state, with the app's depth buffer
|
||||
/// bits and MSAA sample count). Without it the viewport renders on the CPU.
|
||||
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32) {
|
||||
self.viewport.gpu = Some(gpu::install(rs, depth_bits, samples));
|
||||
/// Draw the viewport on the GPU (eframe's wgpu render state, with the app's depth and
|
||||
/// stencil buffer bits and MSAA sample count). Without it the viewport renders on the CPU.
|
||||
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) {
|
||||
self.viewport.gpu = Some(gpu::install(rs, depth_bits, stencil_bits, samples));
|
||||
let info = rs.adapter.get_info();
|
||||
self.help.gpu = Some(format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type));
|
||||
}
|
||||
@@ -314,6 +308,7 @@ impl SolveApp {
|
||||
"browser_collapsed": self.tree.collapsed,
|
||||
"browser_expanded": self.tree.expanded,
|
||||
"recent": self.home.recent,
|
||||
"recent_opened_at": self.home.opened_at,
|
||||
"shortcut_box": self.sbox.prefs(),
|
||||
"shortcuts": self.keymap.prefs(),
|
||||
"toolbar": self.toolbar_custom.prefs(),
|
||||
@@ -329,6 +324,7 @@ impl SolveApp {
|
||||
pub fn load_prefs(&mut self, prefs: &str) {
|
||||
let Ok(v) = serde_json::from_str::<Value>(prefs) else { return };
|
||||
let flag = |k: &str| v.get(k).and_then(Value::as_bool);
|
||||
self.home.opened_at = v.get("recent_opened_at").and_then(|x| serde_json::from_value(x.clone()).ok()).unwrap_or_default();
|
||||
if let Some(d) = flag("dark") {
|
||||
self.ui.dark = d;
|
||||
}
|
||||
@@ -395,6 +391,9 @@ impl SolveApp {
|
||||
|
||||
/// Run a command programmatically (JSON parameters, no dialogs).
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
if self.file_load.busy() {
|
||||
return Err("a file operation is in progress".into());
|
||||
}
|
||||
let t0 = now_ms();
|
||||
let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string());
|
||||
self.big_operation |= now_ms() - t0 >= recovery_ui::BIG_OPERATION_MS;
|
||||
@@ -413,6 +412,9 @@ impl SolveApp {
|
||||
&& let Some(p) = self.session.path.clone()
|
||||
{
|
||||
self.home.add_recent(&p);
|
||||
if matches!(id, "file.save" | "file.save_as") {
|
||||
home::remember_session(&mut self.home, &self.session);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -440,9 +442,15 @@ impl SolveApp {
|
||||
}
|
||||
self.tool = None;
|
||||
self.home.open = false;
|
||||
if matches!(id, "sketch.insert_dxf" | "sketch.insert_svg") {
|
||||
if let Some(path) = self.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
let _ = self.run(id, json!({"path": path}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if matches!(id, "file.insert_step" | "file.insert_mesh") {
|
||||
if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
self.insert_path(&p);
|
||||
self.insert_path_async(&p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -490,7 +498,20 @@ impl SolveApp {
|
||||
{
|
||||
self.last_command = Some((spec.id.to_string(), spec.label.to_string()));
|
||||
}
|
||||
if let Some(t) = tools::Tool::for_command(id) {
|
||||
if let Some(mut t) = tools::Tool::for_command(id) {
|
||||
if id == "sketch.dimension" {
|
||||
t.picks = self
|
||||
.session
|
||||
.selection
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
solvecraft_engine::Sel::SketchCurve { id } | solvecraft_engine::Sel::SketchPoint { id } => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.take(2)
|
||||
.collect();
|
||||
let _ = self.session.execute("select.clear", &json!({}));
|
||||
}
|
||||
self.tool = Some(t);
|
||||
self.set_status(tools::hint(id), false);
|
||||
return;
|
||||
@@ -594,7 +615,7 @@ impl SolveApp {
|
||||
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.session.world_state().bodies.iter().any(|b| !self.ui.hidden_bodies.contains(&b.name)))
|
||||
&& !self.session.world_state().bodies.iter().any(|b| !self.session.visibility.hidden_bodies.contains(&b.name)))
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, s: impl Into<String>, error: bool) {
|
||||
@@ -610,6 +631,13 @@ impl SolveApp {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_path_async(&mut self, path: &str) {
|
||||
file_load::begin(self, path, false);
|
||||
}
|
||||
pub fn insert_path_async(&mut self, path: &str) {
|
||||
file_load::begin(self, path, true);
|
||||
}
|
||||
|
||||
/// Insert a STEP file's bodies (an Import base feature) or a 3MF/STL file's meshes into the
|
||||
/// current design.
|
||||
pub fn insert_path(&mut self, path: &str) {
|
||||
@@ -629,9 +657,9 @@ impl SolveApp {
|
||||
pub fn drop_path(&mut self, path: &str) {
|
||||
let import = solvecraft_engine::io::is_step_path(path) || solvecraft_engine::io::is_mesh_path(path);
|
||||
if import && !self.session.doc.features.is_empty() {
|
||||
self.insert_path(path);
|
||||
self.insert_path_async(path);
|
||||
} else {
|
||||
self.open_path(path);
|
||||
self.open_path_async(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,6 +749,7 @@ impl SolveApp {
|
||||
|
||||
/// Per-frame logic before layout.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
file_load::tick(self, ctx);
|
||||
self.esc_handled = false;
|
||||
prefs::apply(self, ctx);
|
||||
if !self.styled || theme::is_dark() != self.ui.dark {
|
||||
@@ -786,6 +815,7 @@ impl SolveApp {
|
||||
let t0 = now_ms();
|
||||
scenario::clear_handles();
|
||||
toolbar::app_bar(self, ui);
|
||||
file_load::show(self, ui.ctx());
|
||||
documents::prompt(self, ui.ctx());
|
||||
if self.home.open {
|
||||
home::show(self, ui);
|
||||
|
||||
@@ -58,7 +58,7 @@ fn face_menu_hides_and_finds_the_body() {
|
||||
context_menu::run_item(&mut app, &find(&items, "Find in Timeline"), pos2(0.0, 0.0));
|
||||
assert!(matches!(app.session.selection.as_slice(), [solvecraft_engine::Sel::Feature { .. }]));
|
||||
context_menu::run_item(&mut app, &find(&items, "Show/Hide"), pos2(0.0, 0.0));
|
||||
assert_eq!(app.ui.hidden_bodies, vec![b]);
|
||||
assert_eq!(app.session.visibility.hidden_bodies, vec![b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -69,8 +69,8 @@ fn body_menu_isolates_renames_locks_and_deletes() {
|
||||
let t = Target::Body { name: b.clone() };
|
||||
let items = context_menu::items(&app, &t);
|
||||
context_menu::run_item(&mut app, &find(&items, "Isolate"), pos2(0.0, 0.0));
|
||||
assert_eq!(app.ui.hidden_bodies.len(), 1, "the other body is hidden");
|
||||
assert!(!app.ui.hidden_bodies.contains(&b));
|
||||
assert_eq!(app.session.visibility.hidden_bodies.len(), 1, "the other body is hidden");
|
||||
assert!(!app.session.visibility.hidden_bodies.contains(&b));
|
||||
// Rename through the rename box.
|
||||
context_menu::run_item(&mut app, &find(&items, "Rename"), pos2(10.0, 10.0));
|
||||
assert!(context_menu::finish_rename(&mut app, Some("Base"), true));
|
||||
@@ -184,7 +184,7 @@ fn folder_and_group_menus_make_rename_and_drop_groups() {
|
||||
assert!(context_menu::finish_rename(&mut app, Some("Parts"), true));
|
||||
assert_eq!(app.session.doc.browser_groups[0].name, "Parts");
|
||||
act(&mut app, &t, "Show/Hide");
|
||||
assert_eq!(app.ui.hidden_bodies.len(), 2);
|
||||
assert_eq!(app.session.visibility.hidden_bodies.len(), 2);
|
||||
act(&mut app, &t, "Ungroup");
|
||||
assert!(app.session.doc.browser_groups.is_empty());
|
||||
act(&mut app, &folder, "New Group");
|
||||
@@ -232,8 +232,10 @@ fn delete_key_handles_every_kind_and_asks_about_dependents() {
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc.features.len(), n);
|
||||
// A face its neighbours can't close (a side of the plate): refused, nothing changes.
|
||||
let b0 = app.session.model.state().bodies[0].name.clone();
|
||||
app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": 0, "point": [0, 0, 0]}]})).unwrap();
|
||||
let body = &app.session.model.state().bodies[0];
|
||||
let face = body.body.faces(0.01).unwrap().into_iter().find(|f| f.plane_normal.is_some_and(|n| n.x < -0.99)).unwrap();
|
||||
let b0 = body.name.clone();
|
||||
app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": face.index, "point": face.centroid}]})).unwrap();
|
||||
crate::delete::delete_selection(&mut app);
|
||||
assert!(app.status.as_ref().is_some_and(|s| s.0.contains("Delete Face")), "{:?}", app.status);
|
||||
assert_eq!(app.session.doc.features.len(), n);
|
||||
@@ -351,7 +353,7 @@ fn menus_follow_fusion_order() {
|
||||
let origin = context_menu::items(&app, &Target::Origin);
|
||||
order(&origin, &["Show/Hide", "Show All", "Hide Planes", "Hide Axes"]);
|
||||
context_menu::run_item(&mut app, &find(&origin, "Hide Planes"), pos2(0.0, 0.0));
|
||||
assert!(["XY", "XZ", "YZ"].iter().all(|p| app.ui.hidden_origin.iter().any(|h| h == p)));
|
||||
assert!(["XY", "XZ", "YZ"].iter().all(|p| app.session.visibility.hidden_origin.iter().any(|h| h == p)));
|
||||
app.run("select.clear", json!({})).unwrap();
|
||||
let empty = context_menu::items(&app, &Target::Viewport);
|
||||
order(&empty, &["Pan", "Zoom", "Orbit", "Show All", "Unisolate", "Extrude", "Fillet"]);
|
||||
@@ -367,17 +369,17 @@ fn v_toggles_what_is_selected() {
|
||||
app.run("select.set", json!({"items": [{"type": "body", "name": b}, {"type": "plane", "name": "XY"}, {"type": "feature", "id": sk}]})).unwrap();
|
||||
crate::browser::set_sketch_visible(&mut app, sk, true);
|
||||
context_menu::toggle_visibility(&mut app);
|
||||
assert!(app.ui.hidden_bodies.contains(&b) && app.ui.hidden_origin.iter().any(|h| h == "XY"));
|
||||
assert!(app.session.visibility.hidden_bodies.contains(&b) && app.session.visibility.hidden_origin.iter().any(|h| h == "XY"));
|
||||
assert!(!crate::browser::sketch_visible(&app, sk));
|
||||
context_menu::toggle_visibility(&mut app);
|
||||
assert!(app.ui.hidden_bodies.is_empty() && app.ui.hidden_origin.is_empty());
|
||||
assert!(app.session.visibility.hidden_bodies.is_empty() && app.session.visibility.hidden_origin.is_empty());
|
||||
assert!(crate::browser::sketch_visible(&app, sk));
|
||||
// Components picked in the browser hide their bodies.
|
||||
app.run("component.from_bodies", json!({"bodies": [b.clone()]})).unwrap();
|
||||
app.run("select.clear", json!({})).unwrap();
|
||||
app.tree.picked_components = vec![app.session.doc.components[0].id];
|
||||
context_menu::toggle_visibility(&mut app);
|
||||
assert_eq!(app.ui.hidden_bodies, vec![b]);
|
||||
assert_eq!(app.session.visibility.hidden_bodies, vec![b]);
|
||||
assert_eq!(find(&context_menu::items(&app, &Target::Body { name: body(&app) }), "Show/Hide").shortcut, "V");
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ pub struct PrefsWindow {
|
||||
pub section: usize,
|
||||
/// Everything as it was when the dialog opened (or at the last Apply), for Cancel.
|
||||
snapshot: Option<Snapshot>,
|
||||
/// The up axis preference the view follows now (applied when it changes, and at startup).
|
||||
applied_up: Option<String>,
|
||||
}
|
||||
|
||||
/// Open the dialog at a page.
|
||||
@@ -196,6 +198,10 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
/// 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) {
|
||||
// The view's up axis follows the preference (set in the dialog, by ui.prefs or loaded).
|
||||
if app.prefs_window.applied_up.as_deref() != Some(app.preferences.up_axis.as_str()) {
|
||||
up_axis_changed(app);
|
||||
}
|
||||
if app.preferences.theme == "system"
|
||||
&& let Some(t) = ctx.system_theme()
|
||||
{
|
||||
@@ -451,7 +457,12 @@ fn close(app: &mut SolveApp, revert: bool) {
|
||||
|
||||
/// The default orientation changed: the view turns to match.
|
||||
fn up_axis_changed(app: &mut SolveApp) {
|
||||
app.animate_view("home");
|
||||
use solvecraft_engine::render::UpAxis;
|
||||
app.prefs_window.applied_up = Some(app.preferences.up_axis.clone());
|
||||
let up = if app.preferences.up_axis.eq_ignore_ascii_case("y") { UpAxis::Y } else { UpAxis::Z };
|
||||
if app.ui.up_axis != up || app.cam.up != up {
|
||||
app.set_up_axis(up);
|
||||
}
|
||||
}
|
||||
|
||||
/// One page of the dialog.
|
||||
@@ -822,4 +833,22 @@ mod tests {
|
||||
let s = crate::viewport::projection(&app, rect).to_screen(pivot).unwrap();
|
||||
assert!(s.distance(at) < 2.0, "{s:?}");
|
||||
}
|
||||
|
||||
/// The Y up preference turns the view: the camera's up axis follows it, startup included.
|
||||
#[test]
|
||||
fn up_axis_preference_turns_the_view() {
|
||||
use solvecraft_engine::render::UpAxis;
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
apply(&mut app, &ctx);
|
||||
assert_eq!(app.cam.up, UpAxis::Z);
|
||||
app.preferences.up_axis = "y".into();
|
||||
apply(&mut app, &ctx);
|
||||
assert_eq!((app.ui.up_axis, app.cam.up), (UpAxis::Y, UpAxis::Y));
|
||||
// Home looks at the model with Y up: the eye is above the XZ ground.
|
||||
assert!(app.cam.back().y > 0.3, "{:?}", app.cam.back());
|
||||
app.preferences.up_axis = "z".into();
|
||||
apply(&mut app, &ctx);
|
||||
assert_eq!(app.cam.up, UpAxis::Z);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ pub fn gpu_images(app: &SolveApp) -> Vec<GpuImage> {
|
||||
.iter()
|
||||
.filter(|c| c.visible && c.opacity > 0.0)
|
||||
.filter_map(|c| {
|
||||
let plane = doc.resolve_plane(&vals, &c.plane, 0).ok()?;
|
||||
let plane = doc.canvas_plane(&vals, c).ok()?;
|
||||
let (_, version, size, rgba) = pixels(c)?;
|
||||
let corners = c.world_corners(&plane).map(|p| [p.x as f32, p.y as f32, p.z as f32]);
|
||||
Some(GpuImage { id: c.id, version, size, rgba, corners, opacity: c.opacity.clamp(0.0, 1.0) as f32 })
|
||||
@@ -105,7 +105,7 @@ pub fn show(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj)
|
||||
}
|
||||
let (vals, _) = doc.param_values();
|
||||
for c in doc.canvases.iter().filter(|c| c.visible) {
|
||||
let Ok(plane) = doc.resolve_plane(&vals, &c.plane, 0) else { continue };
|
||||
let Ok(plane) = doc.canvas_plane(&vals, c) else { continue };
|
||||
let Some(tex) = texture(ui.ctx(), c) else { continue };
|
||||
let [bl, br, _, tl] = c.corners();
|
||||
let (u, v) = (br - bl, tl - bl);
|
||||
|
||||
@@ -170,6 +170,8 @@ impl Harness {
|
||||
/// File pickers answer like a person would: save into the scenario's folder, open the file
|
||||
/// the scenario chose.
|
||||
fn install(&mut self) {
|
||||
// Frames are laid out, never painted.
|
||||
self.app.viewport.no_pixels = true;
|
||||
let dir = self.dir.clone();
|
||||
self.app.services.pick_save = Some(Box::new(move |name, _| Some(dir.join(name).to_string_lossy().into_owned())));
|
||||
let next = self.next_file.clone();
|
||||
@@ -243,6 +245,15 @@ impl Harness {
|
||||
}
|
||||
|
||||
pub fn call(&mut self, method: &str, params: Value) -> Value {
|
||||
if method == "ui.resize"
|
||||
&& let (Some(w), Some(h)) = (params["width"].as_f64(), params["height"].as_f64())
|
||||
&& w.is_finite()
|
||||
&& h.is_finite()
|
||||
&& w > 0.0
|
||||
&& h > 0.0
|
||||
{
|
||||
self.size = egui::vec2(w as f32, h as f32);
|
||||
}
|
||||
let (req, _rx) = ControlRequest::new(method, params);
|
||||
let out = match handle(&mut self.app, &self.ctx, &req) {
|
||||
Outcome::Done(v) => v,
|
||||
@@ -728,8 +739,7 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
}
|
||||
"hidden" => {
|
||||
// Bodies hidden in the view.
|
||||
let h = &ui["ui"]["hidden_bodies"];
|
||||
let h = if h.is_null() { &ui["ui"]["hiddenBodies"] } else { h };
|
||||
let h = &ui["visibility"]["hidden_bodies"];
|
||||
if h != v {
|
||||
return Err(format!("hidden bodies: got {h}, want {v}"));
|
||||
}
|
||||
@@ -855,7 +865,7 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
"quit_requested" | "quitting" => {
|
||||
"file_busy" | "quit_requested" | "quitting" => {
|
||||
if ui[k] != *v {
|
||||
return Err(format!("{k}: got {}, want {v}", ui[k]));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@ use solvecraft_engine::geom::Vec3;
|
||||
|
||||
use crate::viewport::Hit;
|
||||
|
||||
/// Model picks retain an on-surface point, but their identity is the body and topology index.
|
||||
pub(crate) fn same_item(a: &Sel, b: &Sel) -> bool {
|
||||
match (a, b) {
|
||||
(Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. })
|
||||
| (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j,
|
||||
_ => a == b,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a selection input accepts (bit set).
|
||||
pub type Accept = u16;
|
||||
pub const PROFILES: Accept = 1;
|
||||
@@ -73,11 +82,11 @@ impl SelInput {
|
||||
return;
|
||||
}
|
||||
// A pick of something already in (or a chain fully in) takes it out.
|
||||
if picked.iter().all(|p| self.items.contains(p)) {
|
||||
self.items.retain(|x| !picked.contains(x));
|
||||
if picked.iter().all(|p| self.items.iter().any(|x| same_item(x, p))) {
|
||||
self.items.retain(|x| !picked.iter().any(|p| same_item(x, p)));
|
||||
} else {
|
||||
for p in picked {
|
||||
if !self.items.contains(&p) {
|
||||
if !self.items.iter().any(|x| same_item(x, &p)) {
|
||||
self.items.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user