Compare 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 | ||
|
|
ade30e9c4e | ||
|
|
9c09e82cff |
No files matched your search
@@ -12,150 +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
|
||||
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 }}
|
||||
@@ -172,6 +193,7 @@ jobs:
|
||||
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
|
||||
@@ -212,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 }}
|
||||
@@ -235,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
|
||||
@@ -277,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 }}
|
||||
@@ -302,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
|
||||
@@ -323,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 |
|
||||
|
||||
Generated
+13
-13
@@ -3057,7 +3057,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3071,7 +3071,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3080,7 +3080,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3094,7 +3094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3110,7 +3110,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3118,7 +3118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
@@ -3130,7 +3130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3145,7 +3145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3153,7 +3153,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3162,7 +3162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3173,7 +3173,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3187,7 +3187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4486,7 +4486,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
+6
-2
@@ -51,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 |
|
||||
@@ -66,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(())
|
||||
}
|
||||
@@ -191,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, .. } => {
|
||||
|
||||
+67
-10
@@ -226,7 +226,8 @@ pub enum FeatureKind {
|
||||
body: Option<String>,
|
||||
},
|
||||
Box {
|
||||
corner: Vec3,
|
||||
#[serde(with = "point_expr")]
|
||||
corner: PointExpr,
|
||||
length: String,
|
||||
width: String,
|
||||
height: String,
|
||||
@@ -234,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,
|
||||
@@ -243,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)]
|
||||
@@ -482,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,
|
||||
@@ -939,6 +944,45 @@ 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.
|
||||
@@ -1228,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, .. } => {
|
||||
@@ -1308,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);
|
||||
|
||||
+34
-12
@@ -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> {
|
||||
@@ -684,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) {
|
||||
@@ -1537,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)?,
|
||||
@@ -1624,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)?;
|
||||
@@ -2689,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 } => {
|
||||
@@ -2936,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) {
|
||||
@@ -2970,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();
|
||||
|
||||
@@ -197,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 {
|
||||
@@ -213,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));
|
||||
|
||||
@@ -25,7 +25,7 @@ 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,
|
||||
|
||||
@@ -173,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 {
|
||||
|
||||
+24
-3
@@ -165,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();
|
||||
@@ -202,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(),
|
||||
@@ -345,3 +357,12 @@ fn input_sketches_lists_every_variant_with_a_sketch_field() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
@@ -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?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
|
||||
.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)? })
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
@@ -348,15 +348,8 @@ 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, as the active component sees the model
|
||||
@@ -388,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 },
|
||||
@@ -1462,6 +1455,8 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
place_text(sk, &id, text_at);
|
||||
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.0, "unit": current.1, "sketch": info}));
|
||||
}
|
||||
@@ -1477,6 +1472,13 @@ 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
|
||||
/// dimension so it follows the geometry.
|
||||
fn place_text(sk: &mut Sketch, id: &str, at: Option<Vec2>) {
|
||||
|
||||
@@ -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();
|
||||
@@ -243,7 +263,7 @@ fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degree
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ents, "type": "angle", "value": "30 deg"}));
|
||||
assert_eq!(d["sketch"]["dof"], 0, "{d}");
|
||||
assert_eq!(d["unit"], "deg", "{d}");
|
||||
assert!((d["measured"].as_f64().unwrap() - 30.3).abs() < 0.1, "{d}");
|
||||
assert!((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.
|
||||
@@ -253,7 +273,7 @@ fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degree
|
||||
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
|
||||
let mut s = spout();
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2", "l3"], "value": "60 deg"}));
|
||||
assert!((d["measured"].as_f64().unwrap() - 59.74).abs() < 0.01, "{d}");
|
||||
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"]}));
|
||||
|
||||
@@ -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;
|
||||
@@ -203,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);
|
||||
|
||||
@@ -439,6 +439,42 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Live preview: run `commands` (id, parameters) on a scratch copy and return the model
|
||||
/// before and after. Nothing in this session changes (document, undo history, selection).
|
||||
/// A `timeline.redefine` is shown even while the timeline is rolled back to the feature
|
||||
|
||||
+130
-3
@@ -36,6 +36,8 @@ fn join_extrude_from_a_chamfered_top_face() {
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -590,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();
|
||||
@@ -603,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]}));
|
||||
@@ -611,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() {
|
||||
@@ -1648,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() {
|
||||
@@ -2001,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:?}");
|
||||
}
|
||||
+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) {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -248,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);
|
||||
|
||||
+136
-2
@@ -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;
|
||||
@@ -418,7 +420,6 @@ fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
|
||||
/// 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>> {
|
||||
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
|
||||
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);
|
||||
@@ -434,6 +435,12 @@ fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option
|
||||
};
|
||||
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()) {
|
||||
@@ -507,7 +514,7 @@ fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option
|
||||
Some(crate::sew::sew(&faces))
|
||||
}
|
||||
|
||||
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
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).
|
||||
@@ -573,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,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -302,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();
|
||||
@@ -315,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();
|
||||
@@ -435,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() {
|
||||
@@ -1505,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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, Sk
|
||||
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};
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -127,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) {
|
||||
|
||||
@@ -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).
|
||||
@@ -2801,6 +2801,11 @@ 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> {
|
||||
@@ -3008,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 } => {
|
||||
@@ -3018,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 } => {
|
||||
@@ -3035,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 } => {
|
||||
|
||||
@@ -91,7 +91,7 @@ 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
|
||||
}
|
||||
@@ -141,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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+164
-5
@@ -31,11 +31,17 @@ pub struct HomeState {
|
||||
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 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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -185,7 +320,7 @@ pub fn open_recent(app: &mut SolveApp, path: &str) {
|
||||
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).
|
||||
@@ -250,7 +385,7 @@ 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);
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -416,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();
|
||||
|
||||
@@ -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;
|
||||
@@ -230,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,
|
||||
}
|
||||
@@ -281,6 +283,7 @@ impl SolveApp {
|
||||
styled: false,
|
||||
fitted: false,
|
||||
home: home::HomeState::default(),
|
||||
file_load: file_load::FileLoad::default(),
|
||||
docs: documents::Documents::default(),
|
||||
}
|
||||
}
|
||||
@@ -388,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;
|
||||
@@ -406,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) => {
|
||||
@@ -441,7 +450,7 @@ impl SolveApp {
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -622,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) {
|
||||
@@ -641,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,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 {
|
||||
@@ -798,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);
|
||||
|
||||
@@ -865,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]));
|
||||
}
|
||||
|
||||
+209
-182
@@ -1,6 +1,6 @@
|
||||
//! The timeline (bottom): features in order, the rollback marker, errors and warnings.
|
||||
|
||||
use egui::{Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use egui::{Color32, Rect, Sense, Stroke, UiBuilder, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::doc::FeatureKind;
|
||||
|
||||
@@ -106,195 +106,222 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
x += 24.0;
|
||||
}
|
||||
x += 14.0;
|
||||
let feats: Vec<_> = app
|
||||
.session
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), f.kind.clone()))
|
||||
.collect();
|
||||
// The feature whose body is under the cursor in the viewport.
|
||||
let st = app.session.world_state();
|
||||
let from_view = match &app.viewport.hover {
|
||||
Some(crate::viewport::Hit::Face { body, .. } | crate::viewport::Hit::Edge { body, .. } | crate::viewport::Hit::Vertex { body, .. }) => {
|
||||
st.body(body).map(|b| b.feature)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let drag_id = ui.id().with("tl_drag");
|
||||
let mut drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id));
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let mut xs: Vec<f32> = Vec::new();
|
||||
let mut hovered: Option<u64> = None;
|
||||
let mut edit: Option<u64> = None;
|
||||
let mut marker_rect: Option<Rect> = None;
|
||||
for (i, (id, name, icon, suppressed, ty, kind)) in feats.iter().enumerate() {
|
||||
if i == marker {
|
||||
marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0)));
|
||||
x += 10.0;
|
||||
}
|
||||
xs.push(x);
|
||||
let br = Rect::from_min_size(pos2(x, r.top() + 6.0), vec2(28.0, 28.0));
|
||||
crate::scenario::publish_handle(&format!("timeline:{name}"), br.center());
|
||||
let resp = ui.interact(br, ui.id().with(("feat", *id)), Sense::click_and_drag());
|
||||
let res = app.session.model.result(*id);
|
||||
let err = res.and_then(|r| r.error.clone());
|
||||
let warn = res.and_then(|r| r.warning.clone());
|
||||
let rolled = i >= marker;
|
||||
let is_it = |s: &solvecraft_engine::Sel| matches!(s, solvecraft_engine::Sel::Feature { id: f } if f == id);
|
||||
let sel = app.session.selection.iter().any(is_it) || app.dialog.as_ref().is_some_and(|d| d.items().iter().any(is_it));
|
||||
if resp.hovered() {
|
||||
hovered = Some(*id);
|
||||
}
|
||||
let bg = if err.is_some() {
|
||||
t.timeline_item_error
|
||||
} else if sel {
|
||||
t.accent_soft
|
||||
} else if resp.hovered() || from_view == Some(*id) {
|
||||
t.hover
|
||||
} else {
|
||||
t.timeline_item
|
||||
};
|
||||
let edge = if err.is_some() {
|
||||
t.error
|
||||
} else if from_view == Some(*id) {
|
||||
t.accent
|
||||
} else {
|
||||
t.border
|
||||
};
|
||||
p.rect(br, 3.0, bg, Stroke::new(1.0, edge), egui::StrokeKind::Inside);
|
||||
let ink = if rolled || *suppressed { t.border } else { t.icon };
|
||||
icons::paint(&p, br.shrink(4.0), icon, ink, if rolled { t.panel_header } else { t.icon_fill }, if rolled { t.border } else { t.accent });
|
||||
if *suppressed {
|
||||
p.line_segment([br.left_bottom() + vec2(4.0, -4.0), br.right_top() + vec2(-4.0, 4.0)], Stroke::new(1.5, t.text_dim));
|
||||
}
|
||||
if warn.is_some() && err.is_none() {
|
||||
icons::paint(&p, Rect::from_min_size(br.right_top() - vec2(10.0, 0.0), vec2(10.0, 10.0)), "warning", t.icon, t.warning, t.warning);
|
||||
}
|
||||
let mut tip = format!("{name} ({ty})");
|
||||
if let Some(e) = &err {
|
||||
tip += &format!("\nError: {e}");
|
||||
}
|
||||
if let Some(w) = &warn {
|
||||
tip += &format!("\nWarning: {w}");
|
||||
}
|
||||
if let Some(r) = res {
|
||||
tip += &format!("\n{:.1} ms", r.ms);
|
||||
}
|
||||
tip += "\nDouble-click to edit · drag to reorder · right-click for more";
|
||||
if resp.drag_started() {
|
||||
drag = Some(Drag::Feature(*id));
|
||||
}
|
||||
let resp = resp.on_hover_text(tip);
|
||||
if resp.clicked() {
|
||||
// A dialog input that takes features (pattern objects) gets the click.
|
||||
let takes = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::FEATURES != 0);
|
||||
if takes {
|
||||
if let Some(mut d) = app.dialog.take() {
|
||||
d.pick(&app.session, solvecraft_engine::Sel::Feature { id: *id });
|
||||
app.dialog = Some(d);
|
||||
// The features scroll horizontally in the space right of the playback controls.
|
||||
let strip = Rect::from_min_max(pos2(x, r.top()), r.right_bottom());
|
||||
ui.scope_builder(UiBuilder::new().max_rect(strip), |ui| {
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| {
|
||||
let p = ui.painter().clone();
|
||||
let mut x = ui.max_rect().left();
|
||||
let origin = x;
|
||||
let feats: Vec<_> = app
|
||||
.session
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), f.kind.clone()))
|
||||
.collect();
|
||||
// The feature whose body is under the cursor in the viewport.
|
||||
let st = app.session.world_state();
|
||||
let from_view = match &app.viewport.hover {
|
||||
Some(
|
||||
crate::viewport::Hit::Face { body, .. } | crate::viewport::Hit::Edge { body, .. } | crate::viewport::Hit::Vertex { body, .. },
|
||||
) => st.body(body).map(|b| b.feature),
|
||||
_ => None,
|
||||
};
|
||||
let drag_id = ui.id().with("tl_drag");
|
||||
let mut drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id));
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let mut xs: Vec<f32> = Vec::new();
|
||||
let mut hovered: Option<u64> = None;
|
||||
let mut edit: Option<u64> = None;
|
||||
let mut marker_rect: Option<Rect> = None;
|
||||
for (i, (id, name, icon, suppressed, ty, kind)) in feats.iter().enumerate() {
|
||||
if i == marker {
|
||||
marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0)));
|
||||
x += 10.0;
|
||||
}
|
||||
} else {
|
||||
let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]}));
|
||||
xs.push(x);
|
||||
let br = Rect::from_min_size(pos2(x, r.top() + 6.0), vec2(28.0, 28.0));
|
||||
crate::scenario::publish_handle(&format!("timeline:{name}"), br.center());
|
||||
let resp = ui.interact(br, ui.id().with(("feat", *id)), Sense::click_and_drag());
|
||||
let res = app.session.model.result(*id);
|
||||
let err = res.and_then(|r| r.error.clone());
|
||||
let warn = res.and_then(|r| r.warning.clone());
|
||||
let rolled = i >= marker;
|
||||
let is_it = |s: &solvecraft_engine::Sel| matches!(s, solvecraft_engine::Sel::Feature { id: f } if f == id);
|
||||
let sel = app.session.selection.iter().any(is_it) || app.dialog.as_ref().is_some_and(|d| d.items().iter().any(is_it));
|
||||
if resp.hovered() {
|
||||
hovered = Some(*id);
|
||||
}
|
||||
let bg = if err.is_some() {
|
||||
t.timeline_item_error
|
||||
} else if sel {
|
||||
t.accent_soft
|
||||
} else if resp.hovered() || from_view == Some(*id) {
|
||||
t.hover
|
||||
} else {
|
||||
t.timeline_item
|
||||
};
|
||||
let edge = if err.is_some() {
|
||||
t.error
|
||||
} else if from_view == Some(*id) {
|
||||
t.accent
|
||||
} else {
|
||||
t.border
|
||||
};
|
||||
p.rect(br, 3.0, bg, Stroke::new(1.0, edge), egui::StrokeKind::Inside);
|
||||
let ink = if rolled || *suppressed { t.border } else { t.icon };
|
||||
icons::paint(
|
||||
&p,
|
||||
br.shrink(4.0),
|
||||
icon,
|
||||
ink,
|
||||
if rolled { t.panel_header } else { t.icon_fill },
|
||||
if rolled { t.border } else { t.accent },
|
||||
);
|
||||
if *suppressed {
|
||||
p.line_segment([br.left_bottom() + vec2(4.0, -4.0), br.right_top() + vec2(-4.0, 4.0)], Stroke::new(1.5, t.text_dim));
|
||||
}
|
||||
if warn.is_some() && err.is_none() {
|
||||
icons::paint(
|
||||
&p,
|
||||
Rect::from_min_size(br.right_top() - vec2(10.0, 0.0), vec2(10.0, 10.0)),
|
||||
"warning",
|
||||
t.icon,
|
||||
t.warning,
|
||||
t.warning,
|
||||
);
|
||||
}
|
||||
let mut tip = format!("{name} ({ty})");
|
||||
if let Some(e) = &err {
|
||||
tip += &format!("\nError: {e}");
|
||||
}
|
||||
if let Some(w) = &warn {
|
||||
tip += &format!("\nWarning: {w}");
|
||||
}
|
||||
if let Some(r) = res {
|
||||
tip += &format!("\n{:.1} ms", r.ms);
|
||||
}
|
||||
tip += "\nDouble-click to edit · drag to reorder · right-click for more";
|
||||
if resp.drag_started() {
|
||||
drag = Some(Drag::Feature(*id));
|
||||
}
|
||||
let resp = resp.on_hover_text(tip);
|
||||
if resp.clicked() {
|
||||
// A dialog input that takes features (pattern objects) gets the click.
|
||||
let takes = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::FEATURES != 0);
|
||||
if takes {
|
||||
if let Some(mut d) = app.dialog.take() {
|
||||
d.pick(&app.session, solvecraft_engine::Sel::Feature { id: *id });
|
||||
app.dialog = Some(d);
|
||||
}
|
||||
} else {
|
||||
let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]}));
|
||||
}
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
edit = Some(*id);
|
||||
}
|
||||
let sketch_of = match kind {
|
||||
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Sweep { sketch, .. } => {
|
||||
Some(*sketch)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button("Edit Feature").clicked() {
|
||||
edit = Some(*id);
|
||||
ui.close();
|
||||
}
|
||||
if let Some(sk) = sketch_of
|
||||
&& ui.button("Edit Profile Sketch").clicked()
|
||||
{
|
||||
app.edit_sketch(sk);
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Rename").clicked() {
|
||||
app.dialog = Some(crate::dialogs::Dialog::rename(*id, name));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(if *suppressed { "Unsuppress Features" } else { "Suppress Features" }).clicked() {
|
||||
let _ = app.run("timeline.suppress", json!({"feature": id}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Roll History Marker Here").clicked() {
|
||||
let _ = app.run("timeline.roll_to", json!({ "feature": id }));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Find in Browser").clicked() {
|
||||
find_in_browser(app, *id);
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Delete").clicked() {
|
||||
delete_feature(app, *id);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
x += 32.0;
|
||||
}
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
edit = Some(*id);
|
||||
}
|
||||
let sketch_of = match kind {
|
||||
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Sweep { sketch, .. } => Some(*sketch),
|
||||
_ => None,
|
||||
};
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button("Edit Feature").clicked() {
|
||||
edit = Some(*id);
|
||||
ui.close();
|
||||
let end = x;
|
||||
if marker >= feats.len() {
|
||||
marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0)));
|
||||
}
|
||||
if let Some(sk) = sketch_of
|
||||
&& ui.button("Edit Profile Sketch").clicked()
|
||||
// The history marker: drag it to roll the model back or forward.
|
||||
if let Some(mr) = marker_rect {
|
||||
let resp = ui.interact(mr.expand2(vec2(3.0, 0.0)), ui.id().with("tl_marker"), Sense::drag());
|
||||
if resp.drag_started() {
|
||||
drag = Some(Drag::Marker);
|
||||
}
|
||||
let col = if resp.hovered() || drag == Some(Drag::Marker) { t.accent } else { t.timeline_marker };
|
||||
p.rect_filled(mr, 1.0, col);
|
||||
resp.on_hover_text("History marker: drag to roll the model back; new features go here");
|
||||
}
|
||||
let content_end = crate::dialogs_assembly::timeline_joints(app, ui, &p, end + 10.0, r);
|
||||
// The scroll range follows the drawn content (the buttons don't take layout space themselves).
|
||||
ui.allocate_exact_size(vec2((content_end - origin).max(0.0), r.height()), Sense::hover());
|
||||
// Drag feedback, and the drop.
|
||||
if let (Some(dg), Some(pp)) = (drag, pointer) {
|
||||
let slot = slot_at(&xs, end, pp.x);
|
||||
let sx = xs.get(slot).copied().unwrap_or(end) - 4.0;
|
||||
p.line_segment(
|
||||
[pos2(sx, r.top() + 3.0), pos2(sx, r.bottom() - 3.0)],
|
||||
Stroke::new(2.0, if dg == Drag::Marker { t.accent } else { t.sketch_accent }),
|
||||
);
|
||||
}
|
||||
let released = ui.input(|i| i.pointer.any_released());
|
||||
// (Taking the drag ends it even when the pointer has left the window.)
|
||||
if released
|
||||
&& let Some(dg) = drag.take()
|
||||
&& let Some(pp) = pointer
|
||||
{
|
||||
app.edit_sketch(sk);
|
||||
ui.close();
|
||||
let slot = slot_at(&xs, end, pp.x);
|
||||
match dg {
|
||||
Drag::Marker => {
|
||||
let _ = app.run("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) });
|
||||
}
|
||||
Drag::Feature(id) => {
|
||||
let from = app.session.doc.feature_index(id).unwrap_or(0);
|
||||
// Dropping after itself means the slot shifts by one.
|
||||
let to = if slot > from { slot - 1 } else { slot };
|
||||
if to != from {
|
||||
let _ = app.run("timeline.reorder", json!({ "feature": id, "position": to }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.button("Rename").clicked() {
|
||||
app.dialog = Some(crate::dialogs::Dialog::rename(*id, name));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(if *suppressed { "Unsuppress Features" } else { "Suppress Features" }).clicked() {
|
||||
let _ = app.run("timeline.suppress", json!({"feature": id}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Roll History Marker Here").clicked() {
|
||||
let _ = app.run("timeline.roll_to", json!({ "feature": id }));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Find in Browser").clicked() {
|
||||
find_in_browser(app, *id);
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Delete").clicked() {
|
||||
delete_feature(app, *id);
|
||||
ui.close();
|
||||
ui.data_mut(|d| match drag {
|
||||
Some(dg) => {
|
||||
d.insert_temp(drag_id, dg);
|
||||
}
|
||||
None => d.remove::<Drag>(drag_id),
|
||||
});
|
||||
app.viewport.hover_feature = hovered;
|
||||
if let Some(id) = edit {
|
||||
app.edit_feature(id);
|
||||
}
|
||||
});
|
||||
x += 32.0;
|
||||
}
|
||||
let end = x;
|
||||
if marker >= feats.len() {
|
||||
marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0)));
|
||||
}
|
||||
// The history marker: drag it to roll the model back or forward.
|
||||
if let Some(mr) = marker_rect {
|
||||
let resp = ui.interact(mr.expand2(vec2(3.0, 0.0)), ui.id().with("tl_marker"), Sense::drag());
|
||||
if resp.drag_started() {
|
||||
drag = Some(Drag::Marker);
|
||||
}
|
||||
let col = if resp.hovered() || drag == Some(Drag::Marker) { t.accent } else { t.timeline_marker };
|
||||
p.rect_filled(mr, 1.0, col);
|
||||
resp.on_hover_text("History marker: drag to roll the model back; new features go here");
|
||||
}
|
||||
crate::dialogs_assembly::timeline_joints(app, ui, &p, end + 10.0, r);
|
||||
// Drag feedback, and the drop.
|
||||
if let (Some(dg), Some(pp)) = (drag, pointer) {
|
||||
let slot = slot_at(&xs, end, pp.x);
|
||||
let sx = xs.get(slot).copied().unwrap_or(end) - 4.0;
|
||||
p.line_segment(
|
||||
[pos2(sx, r.top() + 3.0), pos2(sx, r.bottom() - 3.0)],
|
||||
Stroke::new(2.0, if dg == Drag::Marker { t.accent } else { t.sketch_accent }),
|
||||
);
|
||||
}
|
||||
let released = ui.input(|i| i.pointer.any_released());
|
||||
// (Taking the drag ends it even when the pointer has left the window.)
|
||||
if released
|
||||
&& let Some(dg) = drag.take()
|
||||
&& let Some(pp) = pointer
|
||||
{
|
||||
let slot = slot_at(&xs, end, pp.x);
|
||||
match dg {
|
||||
Drag::Marker => {
|
||||
let _ = app.run("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) });
|
||||
}
|
||||
Drag::Feature(id) => {
|
||||
let from = app.session.doc.feature_index(id).unwrap_or(0);
|
||||
// Dropping after itself means the slot shifts by one.
|
||||
let to = if slot > from { slot - 1 } else { slot };
|
||||
if to != from {
|
||||
let _ = app.run("timeline.reorder", json!({ "feature": id, "position": to }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.data_mut(|d| match drag {
|
||||
Some(dg) => {
|
||||
d.insert_temp(drag_id, dg);
|
||||
}
|
||||
None => d.remove::<Drag>(drag_id),
|
||||
});
|
||||
app.viewport.hover_feature = hovered;
|
||||
if let Some(id) = edit {
|
||||
app.edit_feature(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
if item(ui, "Open…", "Ctrl+O") {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
app.open_path(&p);
|
||||
app.open_path_async(&p);
|
||||
}
|
||||
close = true;
|
||||
}
|
||||
@@ -531,6 +531,9 @@ fn caret(p: &egui::Painter, c: egui::Pos2, col: Color32) {
|
||||
|
||||
/// Global keyboard shortcuts (when no text field has focus).
|
||||
pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if app.file_load.busy() {
|
||||
return;
|
||||
}
|
||||
// The Keyboard Shortcuts window is waiting for a key: it is not a shortcut.
|
||||
if crate::keymap::capturing(app) {
|
||||
return;
|
||||
@@ -554,7 +557,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
Key::Comma => crate::prefs::open_at(app, 0),
|
||||
Key::O => {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
app.open_path(&p);
|
||||
app.open_path_async(&p);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
|
||||
+307
-36
@@ -43,7 +43,7 @@ pub struct ViewportState {
|
||||
pub mouse: Option<Pos2>,
|
||||
/// Box selection being dragged.
|
||||
pub boxsel: Option<BoxSel>,
|
||||
/// Timeline item under the cursor: its bodies are highlighted.
|
||||
/// Timeline item under the cursor: its bodies or resulting faces are highlighted.
|
||||
pub hover_feature: Option<u64>,
|
||||
/// Bodies whose browser row is under the cursor: highlighted the same way.
|
||||
pub hover_bodies: Vec<String>,
|
||||
@@ -51,6 +51,8 @@ pub struct ViewportState {
|
||||
pub orbit_pivot: Option<Vec3>,
|
||||
/// Continuous orbit: the view turns about the vertical until the next click or key.
|
||||
pub spin: bool,
|
||||
/// A primary press on the cube: the pointer position used by its last orbit step.
|
||||
pub cube_drag: Option<Pos2>,
|
||||
pub build_ms: f64,
|
||||
/// The right-click menu, open at this screen position.
|
||||
pub context_menu: Option<Pos2>,
|
||||
@@ -860,13 +862,55 @@ pub fn section_plane(app: &SolveApp) -> Option<(Vec3, Vec3)> {
|
||||
app.session.section
|
||||
}
|
||||
|
||||
/// Visible geometry belonging to a timeline feature. Body creators retain their whole-body
|
||||
/// feedback; modifiers use persistent face names, which survive later edits and placement.
|
||||
/// Do not expand these faces into surface patches: neighbouring faces can belong to another
|
||||
/// feature even when they share a surface.
|
||||
fn feature_geometry(app: &SolveApp, id: u64) -> Vec<Sel> {
|
||||
if !app.session.model.result(id).is_some_and(|r| !r.skipped && r.error.is_none()) {
|
||||
return Vec::new();
|
||||
}
|
||||
let st = app.session.world_state();
|
||||
let prefix = format!("F{id}:");
|
||||
let mut out = Vec::new();
|
||||
for b in &st.bodies {
|
||||
if app.session.visibility.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) {
|
||||
continue;
|
||||
}
|
||||
if b.feature == id {
|
||||
out.push(Sel::Body { name: b.name.clone() });
|
||||
continue;
|
||||
}
|
||||
let names = solvecraft_engine::doc::naming::face_names(b);
|
||||
let m = b.mesh();
|
||||
for (index, _) in names.iter().enumerate().filter(|(_, n)| n.starts_with(&prefix)) {
|
||||
if let Some(point) = m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| {
|
||||
if *f as usize != index {
|
||||
return None;
|
||||
}
|
||||
m.tri(t).map(|[a, b, c]| (a + b + c) / 3.0)
|
||||
}) {
|
||||
out.push(Sel::Face { body: b.name.clone(), index, point });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn displayed_selection(app: &SolveApp, item: &Sel) -> Vec<Sel> {
|
||||
match item {
|
||||
Sel::Feature { id } => feature_geometry(app, *id),
|
||||
_ => grouped_selection(&app.session, item),
|
||||
}
|
||||
}
|
||||
|
||||
/// The box around what is selected (bodies, faces, edges, vertices, sketch curves and the
|
||||
/// bodies of selected features), where it is shown.
|
||||
pub fn selection_bounds(app: &SolveApp) -> Option<solvecraft_engine::geom::Aabb3> {
|
||||
let st = app.session.world_state();
|
||||
let mut b = solvecraft_engine::geom::Aabb3::EMPTY;
|
||||
for s in &app.session.selection {
|
||||
match s {
|
||||
for s in app.session.selection.iter().flat_map(|item| displayed_selection(app, item)) {
|
||||
match &s {
|
||||
Sel::Body { name } => {
|
||||
if let Some(x) = st.body(name) {
|
||||
b = b.union(&x.mesh().bounds());
|
||||
@@ -892,11 +936,6 @@ pub fn selection_bounds(app: &SolveApp) -> Option<solvecraft_engine::geom::Aabb3
|
||||
}
|
||||
}
|
||||
Sel::Vertex { point, .. } => b.add(*point),
|
||||
Sel::Feature { id } => {
|
||||
for x in st.bodies.iter().filter(|x| x.feature == *id) {
|
||||
b = b.union(&x.mesh().bounds());
|
||||
}
|
||||
}
|
||||
Sel::SketchCurve { id } => {
|
||||
for ss in &st.sketches {
|
||||
if let Some(i) = ss.sketch.curve_index(id) {
|
||||
@@ -1093,7 +1132,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
let st = app.session.world_state();
|
||||
let mut sel = Vec::new();
|
||||
for item in app.highlighted() {
|
||||
for piece in grouped_selection(&app.session, &item) {
|
||||
for piece in displayed_selection(app, &item) {
|
||||
if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) {
|
||||
sel.push(piece);
|
||||
}
|
||||
@@ -1211,21 +1250,40 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Bodies made by the timeline item under the cursor, or whose browser row is.
|
||||
if app.viewport.hover_feature.is_some() || !app.viewport.hover_bodies.is_empty() {
|
||||
// Timeline modifiers light only their resulting faces; browser rows light whole bodies.
|
||||
let mut hovered = app.viewport.hover_feature.map(|id| feature_geometry(app, id)).unwrap_or_default();
|
||||
hovered.extend(app.viewport.hover_bodies.iter().map(|name| Sel::Body { name: name.clone() }));
|
||||
for b in st.bodies.iter().filter(|b| !app.session.visibility.hidden_bodies.contains(&b.name) && !app.preview.replaced.contains(&b.name)) {
|
||||
let whole = hovered.iter().any(|x| matches!(x, Sel::Body { name } if name == &b.name));
|
||||
let faces: Vec<_> = if whole {
|
||||
(0..b.body.face_count()).collect()
|
||||
} else {
|
||||
hovered
|
||||
.iter()
|
||||
.filter_map(|x| match x {
|
||||
Sel::Face { body, index, .. } if body == &b.name => Some(*index),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if faces.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let k = t.hover_face_lift;
|
||||
let lit =
|
||||
|b: &solvecraft_engine::doc::ModelBody| Some(b.feature) == app.viewport.hover_feature || app.viewport.hover_bodies.contains(&b.name);
|
||||
for b in st.bodies.iter().filter(|b| lit(b) && !app.session.visibility.hidden_bodies.contains(&b.name)) {
|
||||
let c = body_rgb(app, &b.name);
|
||||
let m = b.mesh();
|
||||
for f in 0..b.body.face_count() {
|
||||
let c = body_rgb(app, &b.name);
|
||||
let m = b.mesh();
|
||||
for &f in &faces {
|
||||
if !sel.iter().any(|x| {
|
||||
matches!(x, Sel::Face { body, index, .. } if body == &b.name && *index == f) || matches!(x, Sel::Body { name } if name == &b.name)
|
||||
}) {
|
||||
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
|
||||
}
|
||||
for (ei, e) in m.edges.iter().enumerate() {
|
||||
if !m.seams.get(ei).copied().unwrap_or(false) {
|
||||
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5);
|
||||
}
|
||||
}
|
||||
for (ei, e) in m.edges.iter().enumerate() {
|
||||
if !m.seams.get(ei).copied().unwrap_or(false)
|
||||
&& (whole || m.edge_faces.get(ei).is_some_and(|fs| fs.iter().any(|f| faces.contains(&(*f as usize)))))
|
||||
{
|
||||
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1401,7 +1459,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let (dx, dy) = (delta.x as f64, delta.y as f64);
|
||||
let mode = if middle || secondary_down {
|
||||
crate::prefs::nav_mode(app, middle, secondary_down, mods)
|
||||
} else if primary_down {
|
||||
} else if primary_down && app.viewport.cube_drag.is_none() {
|
||||
app.viewport.nav
|
||||
} else {
|
||||
None
|
||||
@@ -1448,8 +1506,13 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
// Suppress hover for the whole navigation gesture, including frames between pointer
|
||||
// events. Testing delta made the highlight reappear whenever a held drag paused.
|
||||
// Resolve it before rendering so the GPU scene and screen overlays use the same hit.
|
||||
let navigating =
|
||||
scroll != 0.0 || middle || secondary_down || (app.viewport.nav.is_some() && primary_down) || app.viewport.spin || app.cam_anim.is_some();
|
||||
let navigating = scroll != 0.0
|
||||
|| middle
|
||||
|| secondary_down
|
||||
|| (app.viewport.nav.is_some() && primary_down)
|
||||
|| app.viewport.cube_drag.is_some()
|
||||
|| app.viewport.spin
|
||||
|| app.cam_anim.is_some();
|
||||
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default();
|
||||
let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) };
|
||||
app.viewport.hover = cand.as_ref().map(|c| c.0.clone());
|
||||
@@ -2084,16 +2147,45 @@ fn cube_basis(cam: &Camera) -> (Vec3, Vec3, Vec3) {
|
||||
(cam.to_frame(r), cam.to_frame(u), cam.to_frame(b))
|
||||
}
|
||||
|
||||
/// Dragging a primary press on the cube orbits about the view centre. A stationary press
|
||||
/// remains a click; a floating window over the cube owns its input.
|
||||
fn view_cube_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, targets: &[CubeTarget], c: Pos2) -> bool {
|
||||
let (pressed, down, decided, origin, pos) = ui.input(|i| {
|
||||
let p = &i.pointer;
|
||||
(p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos())
|
||||
});
|
||||
if pressed && ui.rect_contains_pointer(rect) {
|
||||
let shapes = cube_projected(targets, cube_basis(&app.cam), c);
|
||||
app.viewport.cube_drag = origin.filter(|p| cube_hit(&shapes, *p).is_some());
|
||||
}
|
||||
if !down {
|
||||
app.viewport.cube_drag = None;
|
||||
}
|
||||
let Some(last) = app.viewport.cube_drag.filter(|_| decided) else { return false };
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
// Include the movement that crossed the drag threshold, so the orbit follows the pointer.
|
||||
if let Some(p) = pos
|
||||
&& p != last
|
||||
{
|
||||
app.cancel_view_animation();
|
||||
app.cam.orbit(f64::from(p.x - last.x), f64::from(p.y - last.y));
|
||||
app.viewport.cube_drag = Some(p);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = Tokens::get();
|
||||
let c = pos2(rect.right() - 80.0, rect.top() + 80.0);
|
||||
let targets = cube_targets();
|
||||
let orbiting = view_cube_orbit(app, ui, rect, &targets, c);
|
||||
let (r, u, b) = cube_basis(&app.cam);
|
||||
let (wr, wu, _) = app.cam.basis();
|
||||
let painter = ui.painter_at(rect);
|
||||
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect));
|
||||
let targets = cube_targets();
|
||||
let shapes = cube_projected(&targets, (r, u, b), c);
|
||||
let hovered = hover.and_then(|h| cube_hit(&shapes, h));
|
||||
let hovered = if orbiting { None } else { hover.and_then(|h| cube_hit(&shapes, h)) };
|
||||
// Axis triad from the cube's back corner, drawn first: the cube hides what is behind it.
|
||||
// The corner is in the cube's frame, the axes are the world's.
|
||||
let o = Vec3::new(-1.0, -1.0, -1.0);
|
||||
@@ -2102,6 +2194,8 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let tip = at + vec2((d.dot(wr) * CUBE_SCALE * 2.6) as f32, -(d.dot(wu) * CUBE_SCALE * 2.6) as f32);
|
||||
painter.line_segment([at, tip], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2)));
|
||||
}
|
||||
let labels: Vec<String> = targets.iter().filter(|tg| !tg.corner).map(|tg| tg.name.to_uppercase()).collect();
|
||||
let size = cube_label_size(ui.ctx(), &labels);
|
||||
// Back to front: the faces and facets turned toward the camera.
|
||||
for (i, pts) in shapes.iter().rev() {
|
||||
let tg = &targets[*i];
|
||||
@@ -2117,7 +2211,8 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Fades out as the face turns edge-on.
|
||||
let ink = (n.dot(b) / 0.3).clamp(0.0, 1.0) as f32;
|
||||
if !tg.corner && ink > 0.05 {
|
||||
painter.add(cube_label(ui.ctx(), &tg.name.to_uppercase(), n, (r, u), c, Color32::from_rgb(40, 44, 52).gamma_multiply(ink)));
|
||||
let color = Color32::from_rgb(40, 44, 52).gamma_multiply(ink);
|
||||
painter.add(cube_label(ui.ctx(), &tg.name.to_uppercase(), size, n, (r, u), c, color));
|
||||
}
|
||||
}
|
||||
crate::scenario::publish_count("cube_highlights", f64::from(u8::from(hovered.is_some())));
|
||||
@@ -2134,14 +2229,34 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
if let Some(dir) = clicked {
|
||||
let mut to = app.cam.looking_from(app.cam.from_frame(dir));
|
||||
// TOP and BOTTOM turn to the standard plan views (front edge at the bottom).
|
||||
// Keep the nearest square heading when looking straight down or up.
|
||||
if dir.x == 0.0 && dir.y == 0.0 {
|
||||
to.yaw = 0.0;
|
||||
to.yaw = nearest_quarter_turn(app.cam.yaw);
|
||||
}
|
||||
app.animate_to(to);
|
||||
}
|
||||
}
|
||||
|
||||
/// The nearest square heading. At a halfway tie, prefer the heading nearer the standard
|
||||
/// plan view, so clicking TOP or BOTTOM from home still puts the front edge at the bottom.
|
||||
fn nearest_quarter_turn(yaw: f64) -> f64 {
|
||||
if !yaw.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let q = std::f64::consts::FRAC_PI_2;
|
||||
let lo = (yaw / q).floor() * q;
|
||||
let hi = lo + q;
|
||||
let (dlo, dhi) = (yaw - lo, hi - yaw);
|
||||
if (dlo - dhi).abs() < 1e-6 {
|
||||
let off_front = |a: f64| ((a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI).abs();
|
||||
if off_front(lo) <= off_front(hi) { lo } else { hi }
|
||||
} else if dlo < dhi {
|
||||
lo
|
||||
} else {
|
||||
hi
|
||||
}
|
||||
}
|
||||
|
||||
/// How far along each edge a view cube corner is cut off (the cube's faces are at ±1).
|
||||
const CUBE_CHAMFER: f64 = 0.38;
|
||||
|
||||
@@ -2203,15 +2318,29 @@ fn cube_to_screen((r, u): (Vec3, Vec3), c: Pos2, p: Vec3) -> Pos2 {
|
||||
}
|
||||
|
||||
/// A face's text directions (right, up): read from outside the cube, and upright in the view
|
||||
/// clicking the face turns to (TOP and BOTTOM: the plan views, front edge at the bottom).
|
||||
/// of the standard face view (TOP and BOTTOM: yaw 0, front edge at the bottom).
|
||||
fn cube_label_axes(n: Vec3) -> (Vec3, Vec3) {
|
||||
if n.z.abs() > 0.5 { (Vec3::X, Vec3::Y * n.z.signum()) } else { (Vec3::Z.cross(n), Vec3::Z) }
|
||||
}
|
||||
|
||||
/// A face's label printed on the face: laid out flat, then mapped through the cube's projection,
|
||||
/// so it turns and foreshortens with the face.
|
||||
fn cube_label(ctx: &egui::Context, text: &str, n: Vec3, ru: (Vec3, Vec3), c: Pos2, color: Color32) -> Shape {
|
||||
let galley = ctx.fonts_mut(|f| f.layout(text.to_string(), FontId::proportional(9.5), color, f32::INFINITY));
|
||||
/// How much of a face the widest face label spans.
|
||||
const CUBE_LABEL_WIDTH: f32 = 0.9;
|
||||
|
||||
/// The face labels' font size: one size for all, the widest of `labels` spanning
|
||||
/// `CUBE_LABEL_WIDTH` of a face.
|
||||
fn cube_label_size(ctx: &egui::Context, labels: &[String]) -> f32 {
|
||||
let probe = 10.0;
|
||||
let widest = labels
|
||||
.iter()
|
||||
.map(|s| ctx.fonts_mut(|f| f.layout_no_wrap(s.clone(), FontId::proportional(probe), Color32::WHITE).size().x))
|
||||
.fold(0.0, f32::max);
|
||||
if widest > 0.0 { probe * CUBE_LABEL_WIDTH * 2.0 * CUBE_SCALE as f32 / widest } else { probe }
|
||||
}
|
||||
|
||||
/// A face's label printed on the face at `size`: laid out flat, then mapped through the cube's
|
||||
/// projection, so it turns and foreshortens with the face.
|
||||
fn cube_label(ctx: &egui::Context, text: &str, size: f32, n: Vec3, ru: (Vec3, Vec3), c: Pos2, color: Color32) -> Shape {
|
||||
let galley = ctx.fonts_mut(|f| f.layout(text.to_string(), FontId::proportional(size), color, f32::INFINITY));
|
||||
let options = egui::epaint::TessellationOptions { round_text_to_pixels: false, ..Default::default() };
|
||||
let mut tess = egui::epaint::Tessellator::new(ctx.pixels_per_point(), options, ctx.fonts(|f| f.font_image_size()), vec![]);
|
||||
let mut mesh = egui::Mesh::default();
|
||||
@@ -2407,6 +2536,80 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn timeline_modifiers_highlight_on_hover_and_click() {
|
||||
for command in ["solid.fillet", "solid.chamfer"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
let base = h.app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap()["feature"].as_u64().unwrap();
|
||||
let id = h.app.run(command, json!({"edges":[[0,0,10]],"radius":2,"distance":2,"name":"Blend"})).unwrap()["feature"].as_u64().unwrap();
|
||||
h.app.run("solid.box", json!({"length":5,"width":5,"height":5,"corner":[40,0,0]})).unwrap();
|
||||
h.frames(2);
|
||||
let geometry = feature_geometry(&h.app, id);
|
||||
assert_eq!(geometry.len(), 1, "{command}: {geometry:?}");
|
||||
let Sel::Face { body, index, .. } = &geometry[0] else { panic!("a modifier must highlight its face") };
|
||||
let st = h.app.session.world_state();
|
||||
let m = st.body(body).unwrap().mesh();
|
||||
let triangles = m.tri_face.iter().filter(|f| **f as usize == *index).count();
|
||||
assert!(triangles > 0 && triangles < m.triangles.len());
|
||||
let p = crate::scenario::handle_at("timeline:Blend").unwrap();
|
||||
h.step(&json!({"move":[p.x,p.y]})).unwrap();
|
||||
assert_eq!(h.app.viewport.hover_feature, Some(id));
|
||||
assert_eq!(build_highlight(&h.app).tris.len(), triangles * 3 * crate::gpu::TRI_SIZE);
|
||||
assert!(!build_highlight(&h.app).lines.is_empty());
|
||||
h.step(&json!({"click":[p.x,p.y]})).unwrap();
|
||||
assert_eq!(h.app.session.selection, vec![Sel::Feature { id }]);
|
||||
// Moving away removes hover but leaves the selected blend highlighted.
|
||||
h.step(&json!({"move":[5,5]})).unwrap();
|
||||
assert_eq!(h.app.viewport.hover_feature, None);
|
||||
assert_eq!(build_highlight(&h.app).tris.len(), triangles * 3 * crate::gpu::TRI_SIZE);
|
||||
let bounds = selection_bounds(&h.app).unwrap();
|
||||
assert!(bounds.max.x <= 2.01 && bounds.max.y <= 2.01 && bounds.max.z > 19.99, "{bounds:?}");
|
||||
// Creator features retain whole-body feedback, while hidden bodies stay hidden.
|
||||
assert_eq!(feature_geometry(&h.app, base), vec![Sel::Body { name: body.clone() }]);
|
||||
h.app.session.visibility.hidden_bodies.push(body.clone());
|
||||
assert!(feature_geometry(&h.app, id).is_empty());
|
||||
assert!(selection_bounds(&h.app).is_none());
|
||||
assert!(build_highlight(&h.app).tris.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_modifier_highlight_follows_edits_and_rollback() {
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
|
||||
app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap();
|
||||
let id = app.run("solid.fillet", json!({"edges":[[0,0,10]],"radius":2})).unwrap()["feature"].as_u64().unwrap();
|
||||
// A later chamfer changes the same body without consuming the fillet.
|
||||
app.run("solid.chamfer", json!({"edges":[[20,20,10]],"distance":1})).unwrap();
|
||||
app.run("solid.move", json!({"bodies":["Body1"],"translate":[30,0,0]})).unwrap();
|
||||
app.run("body.rename", json!({"body":"Body1","name":"Moved"})).unwrap();
|
||||
app.session.selection = vec![Sel::Feature { id }];
|
||||
let bounds = selection_bounds(&app).unwrap();
|
||||
assert!(bounds.min.x >= 29.99 && bounds.max.x <= 32.01, "{bounds:?}");
|
||||
assert!(feature_geometry(&app, id).iter().all(|x| matches!(x, Sel::Face { body, .. } if body == "Moved")));
|
||||
app.run("timeline.roll_to", json!({"position":1})).unwrap();
|
||||
assert!(feature_geometry(&app, id).is_empty());
|
||||
app.run("timeline.roll_to", json!({})).unwrap();
|
||||
assert!(!feature_geometry(&app, id).is_empty());
|
||||
app.run("timeline.suppress", json!({"feature":id})).unwrap();
|
||||
assert!(feature_geometry(&app, id).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_modifier_highlight_follows_component_instances() {
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
|
||||
app.run("component.create", json!({"name":"Part"})).unwrap();
|
||||
app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap();
|
||||
let id = app.run("solid.fillet", json!({"edges":[[0,0,10]],"radius":2})).unwrap()["feature"].as_u64().unwrap();
|
||||
app.run("occurrence.move", json!({"occurrence":"Part:1","translate":[30,0,0]})).unwrap();
|
||||
app.run("occurrence.copy", json!({"component":"Part","translate":[0,40,0]})).unwrap();
|
||||
let geometry = feature_geometry(&app, id);
|
||||
assert_eq!(geometry.len(), 2, "both occurrences retain the fillet's face: {geometry:?}");
|
||||
app.session.selection = vec![Sel::Feature { id }];
|
||||
let bounds = selection_bounds(&app).unwrap();
|
||||
assert!(bounds.min.x >= 29.99 && bounds.max.x <= 32.01 && bounds.max.y >= 41.99, "{bounds:?}");
|
||||
}
|
||||
|
||||
fn hover_frame(app: &mut SolveApp, ctx: &egui::Context, events: Vec<egui::Event>) {
|
||||
let selected = app.highlighted();
|
||||
let _ = ctx.run_ui(
|
||||
@@ -2731,6 +2934,9 @@ mod tests {
|
||||
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(0.0));
|
||||
h.call("ui.click", json!({"x": p.x, "y": p.y}));
|
||||
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the view cube");
|
||||
h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 20.0, "y1": p.y, "steps": 8}));
|
||||
assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog drags must not orbit the cube");
|
||||
assert!(h.app.viewport.cube_drag.is_none());
|
||||
h.call("ui.prefs", json!({"button": "cancel"}));
|
||||
h.call("ui.move", json!({"x": p.x, "y": p.y}));
|
||||
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0));
|
||||
@@ -2738,6 +2944,24 @@ mod tests {
|
||||
assert_ne!(serde_json::to_value(h.app.cam).unwrap(), camera);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_cube_drag_owns_navigation_and_keeps_the_view_centre() {
|
||||
for nav in [None, Some(NavMode::Orbit), Some(NavMode::Pan)] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.call("ui.view", json!({"view": "front"}));
|
||||
h.app.viewport.nav = nav;
|
||||
let before = h.app.cam;
|
||||
let p = crate::scenario::handle_at("cube:front").unwrap();
|
||||
h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 48.0, "y1": p.y, "steps": 8}));
|
||||
assert!((h.app.cam.yaw - 48.0 * 0.008).abs() < 1e-6, "{nav:?}: cube drag must orbit exactly once");
|
||||
assert_eq!(h.app.cam.target, before.target, "{nav:?}: cube drag must not pan");
|
||||
assert_eq!(h.app.cam.distance, before.distance);
|
||||
assert_eq!(h.app.cam.pitch, before.pitch);
|
||||
assert!(h.app.viewport.cube_drag.is_none(), "release clears the cube gesture");
|
||||
assert!(h.app.viewport.boxsel.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_outlines_follow_the_cut() {
|
||||
let mut s = solvecraft_engine::Session::default();
|
||||
@@ -2815,7 +3039,7 @@ mod tests {
|
||||
#[test]
|
||||
fn view_cube_labels_read_upright_in_their_own_view() {
|
||||
for tg in cube_targets().iter().filter(|t| !t.corner) {
|
||||
// The view clicking the face turns to (TOP and BOTTOM with yaw 0).
|
||||
// The standard face view (TOP and BOTTOM with yaw 0).
|
||||
let mut cam = solvecraft_engine::render::Camera::default().looking_from(tg.dir);
|
||||
if tg.dir.z.abs() > 0.5 {
|
||||
cam.yaw = 0.0;
|
||||
@@ -2826,6 +3050,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn face_labels(targets: &[CubeTarget]) -> Vec<String> {
|
||||
targets.iter().filter(|t| !t.corner).map(|t| t.name.to_uppercase()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_cube_top_bottom_choose_nearest_square_heading() {
|
||||
for (from, to) in [
|
||||
(0.0, 0.0),
|
||||
(30.0, 0.0),
|
||||
(-80.0, -90.0),
|
||||
(100.0, 90.0),
|
||||
(170.0, 180.0),
|
||||
(400.0, 360.0),
|
||||
(-400.0, -360.0),
|
||||
(-45.0, 0.0),
|
||||
(45.0, 0.0),
|
||||
(135.0, 90.0),
|
||||
(-135.0, -90.0),
|
||||
(44.99, 0.0),
|
||||
(45.01, 90.0),
|
||||
(-44.99, 0.0),
|
||||
(-45.01, -90.0),
|
||||
] {
|
||||
let got = nearest_quarter_turn(f64::to_radians(from)).to_degrees();
|
||||
assert!((got - to).abs() < 1e-9, "{from}° → {got}°, expected {to}°");
|
||||
}
|
||||
assert_eq!(nearest_quarter_turn(f64::NAN), 0.0);
|
||||
assert_eq!(nearest_quarter_turn(f64::INFINITY), 0.0);
|
||||
}
|
||||
|
||||
/// The widest label spans 90% of its face, looking straight at it.
|
||||
#[test]
|
||||
fn view_cube_widest_label_spans_most_of_its_face() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut span = 0.0f32;
|
||||
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let size = cube_label_size(ui.ctx(), &face_labels(&cube_targets()));
|
||||
let front = Vec3::new(0.0, -1.0, 0.0);
|
||||
let (r, u, _) = basis_from(front);
|
||||
let pts = drawn_points(vec![cube_label(ui.ctx(), "BOTTOM", size, front, (r, u), pos2(100.0, 100.0), Color32::BLACK)]);
|
||||
let (lo, hi) = pts.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| (lo.min(p.x), hi.max(p.x)));
|
||||
span = (hi - lo) / (2.0 * CUBE_SCALE as f32);
|
||||
});
|
||||
assert!((span - CUBE_LABEL_WIDTH).abs() < 0.05, "BOTTOM spans {span} of the face");
|
||||
}
|
||||
|
||||
/// From any direction, each label is printed within its face.
|
||||
#[test]
|
||||
fn view_cube_labels_stay_on_their_faces() {
|
||||
@@ -2833,6 +3103,7 @@ mod tests {
|
||||
let (mut worst, mut drawn) = (f32::NEG_INFINITY, 0);
|
||||
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let targets = cube_targets();
|
||||
let size = cube_label_size(ui.ctx(), &face_labels(&targets));
|
||||
let c = pos2(100.0, 100.0);
|
||||
for dir in orbit_directions().step_by(7) {
|
||||
let (r, u, b) = basis_from(dir);
|
||||
@@ -2841,7 +3112,7 @@ mod tests {
|
||||
if tg.corner {
|
||||
continue;
|
||||
}
|
||||
for p in drawn_points(vec![cube_label(ui.ctx(), &tg.name.to_uppercase(), tg.normal(), (r, u), c, Color32::BLACK)]) {
|
||||
for p in drawn_points(vec![cube_label(ui.ctx(), &tg.name.to_uppercase(), size, tg.normal(), (r, u), c, Color32::BLACK)]) {
|
||||
let outside = if point_in_poly(p, &pts) { 0.0 } else { poly_distance(p, &pts) };
|
||||
worst = worst.max(outside);
|
||||
drawn += 1;
|
||||
|
||||
@@ -80,6 +80,7 @@ fn home_tab_and_file_recent_open_designs() {
|
||||
{"expect":{"home":false,"bodies":1}},
|
||||
{"click":{"handle":"tab:home"}},
|
||||
{"click":{"handle":"home:recent:0"}},
|
||||
{"until":{"file_busy":false}},
|
||||
{"expect":{"home":false,"bodies":1,"documents":2}},
|
||||
{"click":{"handle":"appbar:folder"}},
|
||||
{"widget":"Open Recent"}
|
||||
@@ -87,6 +88,7 @@ fn home_tab_and_file_recent_open_designs() {
|
||||
.unwrap();
|
||||
h.run(&json!([
|
||||
{"widget":path},
|
||||
{"until":{"file_busy":false}},
|
||||
{"expect":{"home":false,"bodies":1,"documents":3}}
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{"note": "An elbow addition meets the existing arm along a profile side, not behind its centre. The automatic dialog must keep Join selected."},
|
||||
{"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 30}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": {"origin": [10, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [10, 20], "p1": [30, 30]}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.finish", "params": {}}},
|
||||
{"start": "solid.extrude"},
|
||||
{"text": "-10"},
|
||||
{"key": "Enter"},
|
||||
{"expect": {"bodies": 1, "errors": 0, "dialog": null, "volume": 5000}},
|
||||
{"shot": "elbow_joined"}
|
||||
]
|
||||
@@ -83,6 +83,7 @@
|
||||
{
|
||||
"widget": "Insert STEP\u2026"
|
||||
},
|
||||
{"until": {"file_busy": false}},
|
||||
{
|
||||
"debug": 1
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
{"expect": {"bodies": 0}},
|
||||
{"choose_file": "Sample Plate.solvecraft"},
|
||||
{"key": "O", "cmd": true},
|
||||
{"until": {"file_busy": false}},
|
||||
{"debug": 1},
|
||||
{"expect": {"bodies": 1, "errors": 0, "param": {"width": 100}}},
|
||||
{"shot": "s15_reopened"}
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
"start": "solid.extrude"
|
||||
},
|
||||
{
|
||||
"widget": "New Body"
|
||||
"widget": "Join"
|
||||
},
|
||||
{
|
||||
"widget": "Cut"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{"note": "A cube drag rotates by the whole pointer movement and releasing it does not click the pressed face."},
|
||||
{"call": "ui.view", "params": {"view": "front"}},
|
||||
{"drag": [{"cube": [0, -1, 0]}, {"cube": [1.5, -1, 0]}]},
|
||||
{"expect": {"camera": {"yaw_deg": 22, "pitch_deg": 0}}},
|
||||
{"note": "A stationary click still selects a face."},
|
||||
{"call": "ui.view", "params": {"view": "home"}},
|
||||
{"click": {"handle": "cube:front"}},
|
||||
{"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0}}}
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
[
|
||||
{"note": "TOP and BOTTOM keep the nearest square heading, with home ties toward yaw zero. Clicking side faces still selects their standard view."},
|
||||
{"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}}},
|
||||
{"call": "ui.view", "params": {"view": "home"}},
|
||||
{"click": {"handle": "cube:top"}},
|
||||
{"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 90}}},
|
||||
{"call": "ui.view", "params": {"view": "home"}},
|
||||
{"click": {"handle": "cube:top-back-right"}},
|
||||
{"expect": {"camera": {"yaw_deg": -135, "pitch_deg": 35.26}}},
|
||||
{"click": {"handle": "cube:top"}},
|
||||
{"expect": {"camera": {"yaw_deg": -90, "pitch_deg": 90, "back": [0, 0, 1]}}},
|
||||
{"shot": "minimal-turn-top-z"},
|
||||
{"camera": {"yaw": 2.9670597283903604, "pitch": -0.6}},
|
||||
{"click": {"handle": "cube:bottom"}},
|
||||
{"expect": {"camera": {"yaw_deg": 180, "pitch_deg": -90, "back": [0, 0, -1]}}},
|
||||
{"camera": {"yaw": 1.3962634015954636, "pitch": 0.6}},
|
||||
{"click": {"handle": "cube:top"}},
|
||||
{"expect": {"camera": {"yaw_deg": 90, "pitch_deg": 90}}},
|
||||
{"call": "ui.view", "params": {"view": "home"}},
|
||||
{"click": {"handle": "cube:front"}},
|
||||
{"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0}}},
|
||||
{"note": "The same heading choice is measured in the Y-up camera frame."},
|
||||
{"call": "ui.prefs", "params": {"up_axis": "y"}},
|
||||
{"camera": {"yaw": -1.3962634015954636, "pitch": 0.6}},
|
||||
{"click": {"handle": "cube:top"}},
|
||||
{"expect": {"camera": {"yaw_deg": -90, "pitch_deg": 90, "back": [0, 1, 0]}}},
|
||||
{"shot": "minimal-turn-top-y"},
|
||||
{"camera": {"yaw": 1.3962634015954636, "pitch": -0.6}},
|
||||
{"click": {"handle": "cube:bottom"}},
|
||||
{"expect": {"camera": {"yaw_deg": 90, "pitch_deg": -90, "back": [0, -1, 0]}}}
|
||||
]
|
||||
@@ -12,6 +12,12 @@ it.
|
||||
| Construct | offset plane, plane at angle |
|
||||
| Inspect | measure, section analysis, interference, curvature comb and map, zebra, draft, minimum radius, isocurves, centre of mass |
|
||||
|
||||
Extrude automatically selects **Join** for additions when the active component already has a
|
||||
solid, including sideways elbow connections and screw bosses that meet only part of a face.
|
||||
Extruding into existing material selects **Cut**. In an empty component it selects **New Body**;
|
||||
choose **New Body** explicitly to keep an addition separate. Changing the default does not alter
|
||||
operations already saved in a design.
|
||||
|
||||
Fillets and chamfers handle straight edges between planar faces, whole smooth loops, runs of
|
||||
neighbouring edges with mitred or spherical corners, and closed chains of curved edges (a rolling
|
||||
ball). See the roadmap's known limitations for the rest.
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. The
|
||||
wheel zooms at the cursor, the middle button pans and Shift+middle orbits (the mouse preset in
|
||||
Preferences switches to SolidWorks or Inventor style). The view cube and the navigation bar
|
||||
change the view: a cube face turns to its standard view, a chamfered corner to that corner's
|
||||
iso view, and the house goes home. Right-click for the marking menu: eight commands around the cursor, plus a
|
||||
change the view: a cube face turns to look straight at it (TOP and BOTTOM keep the nearest
|
||||
quarter-turn heading), a chamfered corner to that corner's
|
||||
iso view, and the house goes home. Drag the cube to orbit around the view centre.
|
||||
Right-click for the marking menu: eight commands around the cursor, plus a
|
||||
menu for whatever is under it.
|
||||
- **Dialogs** dock on the right with a live preview. Most features also show drag handles and a
|
||||
value box in the viewport.
|
||||
|
||||
@@ -46,6 +46,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints
|
||||
| `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed) |
|
||||
| `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists |
|
||||
| `ui.documents` | `action?`: `new`, `switch`, `close`, `quit`; `index?`; `force?` (close without asking); `answer?`: `save`, `dont_save`, `cancel` (answers "save changes?") | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt, whether a quit is asking |
|
||||
| `ui.file` | `path`; `insert?`: bool | starts an interactive background Open or Insert; poll `ui.inspect.file_busy` for completion; the window stays responsive |
|
||||
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
|
||||
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
|
||||
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text |
|
||||
|
||||
@@ -1,3 +1,35 @@
|
||||
# Design loading
|
||||
|
||||
`solvecraft-cli bench-open design.solvecraft` reports JSON lines for file reading,
|
||||
JSON decoding and migrations (including input-name repair), timeline evaluation, individual
|
||||
features, and world placement/display meshing. `--parse-only` stops before geometry evaluation.
|
||||
The parse timer includes document validation and repair, not just the JSON parser.
|
||||
|
||||
Windows x64, optimized `ci` profile, measured during other builds:
|
||||
|
||||
| Design | Parse/repair | Evaluate | Display meshes |
|
||||
|---|---:|---:|---:|
|
||||
| 19-feature enclosure, 34 KB | 1.2 ms | 14.9 s | 6 ms |
|
||||
| 25-feature assembly, 15.2 MB, 2,990 bodies | 21 ms | 12.5 s | 2.1 s |
|
||||
| Generated 1,000 independent boxes, 185 KB | 5 ms | 0.82 s | 0.15 s |
|
||||
| Generated 5,000 boxes in five grids, 10 features | 0.45 ms | 17.6 s | 0.81 s |
|
||||
|
||||
The enclosures spend most of evaluation in joins and cuts, not parsing. Designs save feature
|
||||
definitions; opening them rebuilds their geometry. Those times remain after the repair fix.
|
||||
|
||||
A separate generated timeline with missing input names reproduced minutes in the reader:
|
||||
|
||||
| Timeline (geometry rolled back) | Parse/repair before | After |
|
||||
|---|---:|---:|
|
||||
| 2,000 boxes, 6,000 unnamed inputs | 78.1 s | 10–20 ms |
|
||||
| 5,000 boxes, 15,000 unnamed inputs | interrupted after 131 s, still repairing | 26–46 ms |
|
||||
|
||||
Previously, each missing input restarted the `d1`, `d2`, … search, scanning the document for
|
||||
every candidate. This approaches cubic work as unnamed inputs accumulate. Loading now reserves
|
||||
names once and assigns missing names with an advancing cursor. Existing names, names on later
|
||||
features, parameter collisions and reuse of surplus names retain the previous behavior.
|
||||
Regression tests compare the old and bulk repairs and load a 5,000-feature unnamed timeline.
|
||||
|
||||
# Viewport performance
|
||||
|
||||
Measured with the control channel (`ui.inspect` `frame_ms`: CPU time of one UI frame, which
|
||||
@@ -81,3 +113,43 @@ errors) through edits, suppression, roll-back, undo and redo. Setting a sketch d
|
||||
can still recompute (oracle 61, 62): solving the sketch moves its points by rounding noise, so
|
||||
its inputs are not exactly the old ones. Oracle 59's first parameter edit fails (a recipe
|
||||
expression the bench's `* 1.02` can't scale) and is left out.
|
||||
|
||||
|
||||
## Desktop loading and recent thumbnails (2026-10-10)
|
||||
|
||||
The Arduino Uno enclosure (19 features, 34 KB) and Raspberry Pi 5 case (24 features,
|
||||
61 KB) each decode in about 1 ms. A release build spends roughly 12.1 s and 8.2 s,
|
||||
respectively, evaluating their geometry on this Windows machine. Changing the document
|
||||
encoding does not eliminate these boolean operations.
|
||||
|
||||
Interactive Open, recent-file clicks, drag/drop and STEP/mesh insertion now execute on a
|
||||
worker, including preparation of the world-space bodies and display meshes. The existing
|
||||
design stays available until the result is ready; a failed or cancelled job cannot replace
|
||||
it. Insertion preserves the file, saved state, earlier undo history and view settings.
|
||||
Timeline copies share their lazy mesh cell, and adopting a prepared session keeps its warm
|
||||
world-space cache instead of transforming and tessellating its components again on the UI.
|
||||
A STEP body's missing-face check reuses its orientation tessellation when the tolerances
|
||||
match and its orientation was not reversed.
|
||||
|
||||
Live desktop tests (`ci` profile, with other builds/tests running) opened the Arduino and
|
||||
Pi case in 15.2 s and 10.5 s. During those jobs, median `ui.inspect` round trips were 20.5 ms
|
||||
and 8.3 ms; the slowest were 33.7 ms and 28.6 ms. This fixes the window freeze, not the
|
||||
remaining cost of cold geometry evaluation.
|
||||
|
||||
Recent thumbnails are 240 × 150 PNG files in `SolveCraft/thumbnails` under the platform
|
||||
cache directory (`LOCALAPPDATA` on Windows). Their keys include the source path, file size
|
||||
and modification time. Open and Save render from the model already in memory. Cached
|
||||
recents decode the PNG without reading or rebuilding a document, and run before cold
|
||||
recents and sample thumbnails. Missing or invalid pictures fall back to a background rebuild.
|
||||
No thumbnail or base64 image is added to the design JSON. A regression test verifies the
|
||||
source bytes remain unchanged, a cached picture works even without a parseable source
|
||||
file, and changing the source invalidates the cache.
|
||||
|
||||
`solvecraft-cli bench-open` also accepts STEP/IGES. For exchange files its `parse` phase
|
||||
includes constructing and checking the imported B-rep, not merely decoding STEP syntax.
|
||||
|
||||
The live test loaded both cached recent cards in 21 ms and inserted the 2 MB lightweight
|
||||
Pi STEP (27 bodies) into the case in 279 ms; undo restored the original two bodies. The
|
||||
full 77 MB board STEP (2689 solid definitions, 45,689 faces) still had not completed after
|
||||
300 s. Its window continued handling inspection and Cancel, and cancelling preserved the
|
||||
case. Complex STEP construction remains an outstanding kernel performance problem.
|
||||
+26
-1
@@ -6,6 +6,27 @@ GitHub Release. Publishing that verified draft is a separate operation. Pushing
|
||||
pushing a tag does not start the release workflow. There is no automatic commit-message-based
|
||||
version bump and no patch release for every merge.
|
||||
|
||||
## Nightly prereleases
|
||||
|
||||
`release.yml` also runs at midnight in `America/New_York` (Eastern time, following daylight
|
||||
saving time). GitHub may start scheduled runs later when its runners are busy.
|
||||
Before building, it compares the exact scheduled `main` commit with the tag of the most
|
||||
recently published release, including prereleases. If the commits match, all packaging and
|
||||
publication jobs are skipped: no new version, tag, or release is created. Drafts do not count
|
||||
as published releases, so a failed build or publication can be retried the following night.
|
||||
|
||||
Changed commits use the existing package build and validation jobs. Only after all jobs
|
||||
succeed, the workflow stages the assets in a draft and publishes a prerelease named
|
||||
`v<workspace-version>-nightly.<YYYYMMDD>.<run-number>.<run-attempt>` with checksums and generated
|
||||
notes. Any existing workspace prerelease suffix is replaced by the nightly suffix. Dates
|
||||
use Eastern time; package versions are overridden without changing Cargo.toml or Cargo.lock,
|
||||
so the binaries' embedded Cargo version remains the workspace version. Nightlies do not
|
||||
replace the latest stable release or advance the `release` branch. The normal deliberate
|
||||
release process below continues to create drafts for separate verification and publication.
|
||||
|
||||
Nightly publication uses `GITHUB_TOKEN`, so it does not trigger the downstream Flatpak Pages
|
||||
deployment. The stable Flatpak repository is not automatically updated with nightly builds.
|
||||
|
||||
## Choose and prepare the version
|
||||
|
||||
1. Fetch `origin` and its tags. Inspect the previous published release and the changes since
|
||||
@@ -96,7 +117,11 @@ Keep that deployment separate from building the application. A publication made
|
||||
Actions `GITHUB_TOKEN` does not trigger downstream `release: published` workflows; in that
|
||||
case explicitly dispatch the Flatpak workflow with the published tag, or use an appropriately
|
||||
authorized maintainer/App token for publication. Do not assume the repository updated: check
|
||||
the deployment run. Coordinate its GitHub Pages output with the existing docs website.
|
||||
the deployment run. The Flatpak workflow requests the shared Pages workflow with that tag;
|
||||
Pages assembles the documentation, browser app, and Flatpak repository together. Ordinary
|
||||
docs updates include the latest stable Flatpak release too. Verify the resulting Pages run,
|
||||
not only the dispatch job, so a release cannot erase the docs or app and docs updates cannot
|
||||
erase the package repository.
|
||||
|
||||
## Repository setup
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
# All Pages deployments include the book, web app, and published Flatpak repository.
|
||||
# Shared by docs updates and release publication; no other workflow replaces the site.
|
||||
set -euo pipefail
|
||||
|
||||
site="${1:?site directory required}"
|
||||
tag="${RELEASE_TAG:-}"
|
||||
app_id=ai.storyteller.solvecraft
|
||||
if [ -z "$tag" ]; then
|
||||
tag="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
|
||||
--jq '[.[] | select(.draft == false and .prerelease == false)][0].tag_name // empty')"
|
||||
fi
|
||||
# Before the first publication the docs and browser app can still deploy.
|
||||
if [ -z "$tag" ]; then exit 0; fi
|
||||
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 "invalid release tag: $tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
published="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" \
|
||||
--jq 'select(.draft == false and .published_at != null) | .tag_name')"
|
||||
test "$published" = "$tag"
|
||||
test -s "$site/index.html"
|
||||
test -s "$site/app/index.html"
|
||||
|
||||
version="${tag#v}"
|
||||
bundles="$(mktemp -d)"
|
||||
gh release download "$tag" --repo "$GITHUB_REPOSITORY" --pattern SHA256SUMS.txt --dir "$bundles"
|
||||
repo_dir="$site/flatpak/repo"
|
||||
mkdir -p "$repo_dir"
|
||||
ostree --repo="$repo_dir" init --mode=archive-z2
|
||||
base_url="${PAGES_BASE_URL%/}"
|
||||
previous_url="$base_url/flatpak/repo"
|
||||
if curl --fail --silent --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
|
||||
for arch in x86_64 aarch64; do
|
||||
asset="solvecraft-$version-linux-$arch.flatpak"
|
||||
gh release download "$tag" --repo "$GITHUB_REPOSITORY" --pattern "$asset" --dir "$bundles"
|
||||
(
|
||||
cd "$bundles"
|
||||
awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \
|
||||
SHA256SUMS.txt | sha256sum --check -
|
||||
)
|
||||
validation_repo="$(mktemp -d)"
|
||||
ostree --repo="$validation_repo" init --mode=archive-z2
|
||||
flatpak build-import-bundle "$validation_repo" "$bundles/$asset"
|
||||
ostree --repo="$validation_repo" refs | grep -Fxq "app/$app_id/$arch/stable"
|
||||
flatpak build-import-bundle "$repo_dir" "$bundles/$asset"
|
||||
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"
|
||||
test -s "$repo_dir/summary"
|
||||
cat >"$site/flatpak/solvecraft.flatpakrepo" <<META
|
||||
[Flatpak Repo]
|
||||
Title=SolveCraft
|
||||
Comment=Official SolveCraft releases
|
||||
Description=Official Flatpak repository for SolveCraft
|
||||
Url=$base_url/flatpak/repo/
|
||||
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>$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
|
||||
touch "$site/.nojekyll"
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# Decide before building or assigning a nightly version. Checkout must include all tags.
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$GITHUB_EVENT_NAME" != schedule ]; then
|
||||
echo 'build=true' >>"$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# /releases/latest excludes prereleases. Include every published release instead,
|
||||
# including nightlies, and sort by publication time across all API pages.
|
||||
latest_tag="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
|
||||
--jq '[.[][] | select(.draft == false and .published_at != null)] | sort_by(.published_at) | last | .tag_name // empty')"
|
||||
if [ -n "$latest_tag" ]; then
|
||||
# Resolve the tag itself, not target_commitish (which can be a moving branch).
|
||||
released_sha="$(git rev-parse --verify "refs/tags/$latest_tag^{commit}")"
|
||||
if [ "$released_sha" = "$GITHUB_SHA" ]; then
|
||||
echo 'build=false' >>"$GITHUB_OUTPUT"
|
||||
echo "### Nightly skipped: no new commits since $latest_tag" >>"$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo 'build=true' >>"$GITHUB_OUTPUT"
|
||||
Reference in new issue
Block a user