Compare commits

...
24 Commits
Author SHA1 Message Date
bherbruck 74fed469dc fix: deploy docs web and Flatpak through one Pages workflow 2026-10-10 21:59:18 -04:00
bherbruck efa6706226 Release SolveCraft 0.1.6 2026-10-10 21:43:21 -04:00
bherbruck c1b6478ab5 fix: join additive extrusions and merge coplanar faces 2026-10-10 21:31:19 -04:00
bherbruck 17e8e3eff2 Merge pull request #109 from CypherNaught-0x/fix/timeline-modifier-highlighting
Highlight fillets and other modifier faces from the timeline
2026-10-10 20:56:59 -04:00
bherbruck 4b7e1d317d fix: load designs off-thread and cache recent thumbnails as PNGs 2026-10-10 20:55:14 -04:00
bherbruck b375c2a92a Merge pull request #108 from CypherNaught-0x/fix/basin-rim-blends
Fix fillets and chamfers around sloped pocket rims
2026-10-10 20:05:45 -04:00
CypherNaugh_0x 5275911b44 Highlight modifier faces from timeline hover and selection 2026-10-11 01:59:02 +02:00
95ae096265 Restore minimal view-cube turns and drag-to-orbit
Recover N3MI-DG's TOP/BOTTOM heading and drag-to-orbit contributions
from PR #101, adapted to the existing 14-target cube. Add Y-up/Z-up
regressions, navigation ownership and dialog blocking coverage.

Original heading commit: c28e780f52
Original orbit commit: f30b30be6e
Source: https://github.com/bherbruck/solvecraft/pull/101

Co-authored-by: N3MI-DG <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 19:50:31 -04:00
CypherNaugh_0x 5835bb10d4 Join chamfers across unequal sloped rim walls 2026-10-11 01:37:06 +02:00
CypherNaugh_0x db4f885bac Support fillets around sloped pocket rims 2026-10-11 01:37:06 +02:00
bherbruck 683d45c28d Release SolveCraft 0.1.5 2026-10-10 19:34:26 -04:00
bherbruck 13dd4566ce Merge pull request #102 from CypherNaught-0x/fix/chamfer-sloped-rims
Fix chamfers on sloped end faces and enforce rim clearance
2026-10-10 19:28:48 -04:00
CypherNaugh_0x e575bded0e Fix construction-plane references in mirror, section, and sketches (#99)
Accept construction planes by name or feature ID, preserve parametric mirror references, and reject invalid plane inputs. Retain both mirror-plane and primitive-point regression test modules after resolving the conflict with main.

Validation: cargo xtask ci passed, including 282 engine tests, 138 UI tests, 62 UI scenarios, and WebAssembly.
2026-10-10 19:20:51 -04:00
bherbruck 1a1838c114 Merge pull request #106 from N3MI-DG/view_cube_label_size
View cube: larger face labels
2026-10-10 19:15:10 -04:00
N3MI-DGandClaude Opus 5.5 ff1453f868 View cube face labels are larger: one font size for all six, the widest (BOTTOM) spanning 90% of its face, instead of a fixed 9.5 px that left the names small on the cube. Tests: BOTTOM spans 90% of the face seen straight on, and every label stays within its face from any direction
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 10:06:05 +11:00
bherbruck e3ee1b359e Fix cubic input-name repair when loading large designs 2026-10-10 19:04:19 -04:00
CypherNaugh_0x 15bb64c2ab Reject chamfer extensions beyond trimmed rim faces 2026-10-11 00:50:20 +02:00
bherbruck feaed2e3f6 Merge pull request #97 from CypherNaught-0x/fix/dimension-measured-after-solve
sketch.dimension reports the measured value after solving
2026-10-10 18:49:25 -04:00
CypherNaugh_0xandClaude Haiku 5.5 f33efa2b6e The timeline scrolls sideways when its features don't fit the window: with more features than the window is wide, the features past the right edge were clipped and could not be reached
Cause: the feature buttons are placed by absolute x coordinates and take no layout space, so nothing in the panel could scroll. The playback controls and the feature strip shared one row with no scroll area.

Fix: the feature strip (after the playback controls) is a horizontal ScrollArea. The content width is allocated from the drawn features and joints so the scroll range matches them. Clicks, drags, the history marker, and context menus work inside the scrolled strip; the playback controls stay fixed.

Tests: cargo xtask ci passes. Checked by hand in the app with a long feature list. Not covered: auto-scroll while dragging a feature or the marker past the visible edge.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-10 18:37:48 -04:00
CypherNaugh_0x c61b77613a solid.cylinder, box, sphere, torus and coil place by expressions: base, corner and center take numbers or length expressions per coordinate ("basin_width/2", "10 mm") and follow parameter changes, renames and component frames; before, a point that was not three plain numbers (or an unreadable axis) was silently replaced by the origin (or Z). The features now store the point as expressions (plain numbers still saved as numbers, so existing designs read and write the same) and evaluate it on every recompute; an unreadable or out-of-range point or axis is refused with an error. Tests: primitive points take expressions, follow parameters, renames and a moved component, and bad points are refused 2026-10-10 18:37:22 -04:00
CypherNaugh_0x 9c6f9410d2 Fix equal-distance chamfers on sloped end faces 2026-10-11 00:30:28 +02:00
bherbruck 31dadcee63 Add nightly prereleases at midnight Eastern, skipping unchanged commits 2026-10-10 18:26:06 -04:00
CypherNaugh_0x ade30e9c4e Merge origin/main (with #86): sketch.dimension reports the solved value in the dimension's unit; the spout test expects the solved 30 and 60 degrees 2026-10-11 00:13:51 +02:00
CypherNaugh_0x 9c09e82cff sketch.dimension reports measured on the solved sketch: it reported the value the geometry had before the new dimension was solved, so dimensioning a 99.84 mm trough line to 100 answered measured: 99.84 and looked like the dimension had not taken. The command now measures the new dimension again once the sketch is solved (driven dimensions too), through a new solvecraft_sketch::measure_dimension that shares the solver's own measuring. Tests: lengths, a driven distance and a diameter report their solved values 2026-10-11 00:01:19 +02:00
64 changed files with 2880 additions and 561 deletions

No files matched your search

+10 -134
View File
@@ -12,150 +12,26 @@ on:
permissions: permissions:
contents: read contents: read
actions: write
concurrency: concurrency:
group: flatpak-pages-${{ github.repository }} group: flatpak-pages-${{ github.repository }}
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
build: publish:
name: Build Flatpak repository name: Request the combined docs, web, and Flatpak deployment
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: read
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
APP_ID: ai.storyteller.solvecraft
steps: steps:
- name: Configure Pages - name: Deploy through the shared Pages workflow
id: pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Install Flatpak repository tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak ostree
- name: Download and validate release bundles
shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
tag="$RELEASE_TAG" published="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$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 --jq 'select(.draft == false and .published_at != null) | .tag_name')"
echo "::error::Release tag '$tag' is not strict SemVer prefixed with v" test "$published" = "$RELEASE_TAG"
exit 1 gh workflow run pages.yml --repo "$GITHUB_REPOSITORY" --ref main \
fi -f release_tag="$RELEASE_TAG"
published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \ echo "Requested the combined Pages deployment for $RELEASE_TAG" >>"$GITHUB_STEP_SUMMARY"
'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
+17
View File
@@ -7,6 +7,12 @@ on:
push: push:
branches: [main] branches: [main]
workflow_dispatch: workflow_dispatch:
inputs:
release_tag:
description: "Published release for Flatpak (empty: latest stable release)"
type: string
required: false
default: ""
permissions: permissions:
contents: read contents: read
@@ -50,6 +56,17 @@ jobs:
cp -r target/book/. site/ cp -r target/book/. site/
cp -r dist/web site/app cp -r dist/web site/app
- uses: actions/configure-pages@v5 - 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 - uses: actions/upload-pages-artifact@v3
with: with:
path: site path: site
+39 -1
View File
@@ -3,6 +3,7 @@
# Push to `release` to build packages and create/update a DRAFT GitHub Release. The version # 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 # 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. # 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. # 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 # Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .tar.gz packaging/linux
@@ -15,6 +16,9 @@
name: Release name: Release
on: on:
schedule:
- cron: '0 0 * * *'
timezone: America/New_York
push: push:
branches: [release] branches: [release]
workflow_dispatch: workflow_dispatch:
@@ -39,17 +43,30 @@ jobs:
version: version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
build: ${{ steps.changed.outputs.build }}
version: ${{ steps.v.outputs.version }} version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }} date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }} prerelease: ${{ steps.v.outputs.prerelease }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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 - id: v
if: steps.changed.outputs.build == 'true'
name: Resolve version name: Resolve version
env: env:
REQUEST_DRAFT: ${{ inputs.draft || false }} REQUEST_DRAFT: ${{ inputs.draft || false }}
run: | run: |
. packaging/env.sh . 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 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" echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1"
exit 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 # 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. # Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
icons: icons:
needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -85,6 +104,7 @@ jobs:
linux: linux:
name: Linux ${{ matrix.arch }} name: Linux ${{ matrix.arch }}
needs: version needs: version
if: needs.version.outputs.build == 'true'
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35. # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
strategy: strategy:
@@ -135,6 +155,7 @@ jobs:
flatpak: flatpak:
name: Flatpak ${{ matrix.arch }} name: Flatpak ${{ matrix.arch }}
needs: [version, linux] 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 # 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. # build here. 24.04 for a newer flatpak-builder than 22.04 ships.
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
@@ -172,6 +193,7 @@ jobs:
freebsd: freebsd:
name: FreeBSD x86_64 name: FreeBSD x86_64
needs: version needs: version
if: needs.version.outputs.build == 'true'
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM. # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 180 timeout-minutes: 180
@@ -212,6 +234,7 @@ jobs:
macos: macos:
name: macOS universal name: macOS universal
needs: version needs: version
if: needs.version.outputs.build == 'true'
runs-on: macos-14 runs-on: macos-14
env: env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }} SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
@@ -235,6 +258,7 @@ jobs:
windows: windows:
name: Windows ${{ matrix.arch }} name: Windows ${{ matrix.arch }}
needs: [version, icons] needs: [version, icons]
if: needs.version.outputs.build == 'true'
runs-on: windows-latest runs-on: windows-latest
strategy: strategy:
fail-fast: false fail-fast: false
@@ -277,6 +301,7 @@ jobs:
web: web:
name: Web (wasm) name: Web (wasm)
needs: version needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }} SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
@@ -302,8 +327,9 @@ jobs:
if-no-files-found: error if-no-files-found: error
release: release:
name: Draft GitHub Release or dry-run artifacts name: GitHub Release or dry-run artifacts
needs: [version, linux, flatpak, freebsd, macos, windows, web] needs: [version, linux, flatpak, freebsd, macos, windows, web]
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
@@ -323,6 +349,18 @@ jobs:
run: | run: |
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt) (cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md 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 - name: Create or update the draft release
if: github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft) if: github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft)
working-directory: artifacts working-directory: artifacts
+6
View File
@@ -42,6 +42,12 @@ Generated-in-code assets are original and have no file to list:
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — | | 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 | | .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 ## Vendored third-party code
| Directory | Source | Licence | Changes | | Directory | Source | Licence | Changes |
Generated
+13 -13
View File
@@ -3057,7 +3057,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft" name = "solvecraft"
version = "0.1.4" version = "0.1.6"
dependencies = [ dependencies = [
"eframe", "eframe",
"egui", "egui",
@@ -3071,7 +3071,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-cli" name = "solvecraft-cli"
version = "0.1.4" version = "0.1.6"
dependencies = [ dependencies = [
"serde_json", "serde_json",
"solvecraft-engine", "solvecraft-engine",
@@ -3080,7 +3080,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-doc" name = "solvecraft-doc"
version = "0.1.4" version = "0.1.6"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -3094,7 +3094,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-engine" name = "solvecraft-engine"
version = "0.1.4" version = "0.1.6"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -3110,7 +3110,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-geom" name = "solvecraft-geom"
version = "0.1.4" version = "0.1.6"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -3118,7 +3118,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-io" name = "solvecraft-io"
version = "0.1.4" version = "0.1.6"
dependencies = [ dependencies = [
"quick-xml 0.38.4", "quick-xml 0.38.4",
"solvecraft-doc", "solvecraft-doc",
@@ -3130,7 +3130,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-kernel" name = "solvecraft-kernel"
version = "0.1.4" version = "0.1.6"
dependencies = [ dependencies = [
"log", "log",
"serde", "serde",
@@ -3145,7 +3145,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-mcp" name = "solvecraft-mcp"
version = "0.1.4" version = "0.1.6"
dependencies = [ dependencies = [
"serde_json", "serde_json",
"solvecraft-engine", "solvecraft-engine",
@@ -3153,7 +3153,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-render" name = "solvecraft-render"
version = "0.1.4" version = "0.1.6"
dependencies = [ dependencies = [
"image", "image",
"serde", "serde",
@@ -3162,7 +3162,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-sketch" name = "solvecraft-sketch"
version = "0.1.4" version = "0.1.6"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -3173,7 +3173,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-ui-egui" name = "solvecraft-ui-egui"
version = "0.1.4" version = "0.1.6"
dependencies = [ dependencies = [
"egui", "egui",
"egui-wgpu", "egui-wgpu",
@@ -3187,7 +3187,7 @@ dependencies = [
[[package]] [[package]]
name = "solvecraft-web" name = "solvecraft-web"
version = "0.1.4" version = "0.1.6"
dependencies = [ dependencies = [
"eframe", "eframe",
"egui", "egui",
@@ -4486,7 +4486,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "0.1.4" version = "0.1.6"
dependencies = [ dependencies = [
"serde_json", "serde_json",
] ]
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"] exclude = ["vendor"]
[workspace.package] [workspace.package]
version = "0.1.4" version = "0.1.6"
edition = "2024" edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
rust-version = "1.90" rust-version = "1.90"
+6 -2
View File
@@ -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 | | 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 | | M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 | | M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. 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 | | 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 | — | | 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 | | 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, - 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 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 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, 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 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; neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
+43
View File
@@ -3,6 +3,7 @@
//! ```text //! ```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 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 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 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 exec <command> [json-params] run one command on an empty design
//! solvecraft-cli commands the command registry as JSON //! 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 <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 run --in <design.solvecraft|part.step> [--out FILE]...
solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in 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 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 exec <command> [json-params]
solvecraft-cli commands solvecraft-cli commands
@@ -74,6 +76,7 @@ fn main() -> ExitCode {
Some("save-stress") => cmd_save_stress(&args[1..]), Some("save-stress") => cmd_save_stress(&args[1..]),
// Hidden: time recomputes after parameter edits (docs/perf.md). // Hidden: time recomputes after parameter edits (docs/perf.md).
Some("bench-edit") => cmd_bench_edit(&args[1..]), Some("bench-edit") => cmd_bench_edit(&args[1..]),
Some("bench-open") => cmd_bench_open(&args[1..]),
Some("--version" | "-V") => { Some("--version" | "-V") => {
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION")); println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(()) Ok(())
@@ -351,3 +354,43 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
println!("{}", pretty(&json!(out))); println!("{}", pretty(&json!(out)));
Ok(()) 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(())
}
+1 -1
View File
@@ -191,7 +191,7 @@ fn main() -> eframe::Result {
app.session.undo.clear(); app.session.undo.clear();
} }
for f in &files { 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 // No design given: the start page (not when driven over the control channel, whose
// clients expect the design view; `ui.home` shows it there). // clients expect the design view; `ui.home` shows it there).
+37 -4
View File
@@ -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)); 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 { impl FeatureKind {
/// Map the feature's world geometry (points, directions, explicit planes) by `m`. /// Map the feature's world geometry (points, directions, explicit planes) by `m`.
pub fn transform_geometry(&mut self, m: &Mat) { pub fn transform_geometry(&mut self, m: &Mat) {
@@ -207,12 +240,12 @@ impl FeatureKind {
map_plane_ref(m, neutral); map_plane_ref(m, neutral);
dir(pull); dir(pull);
} }
FeatureKind::Box { corner, .. } => pt(corner), FeatureKind::Box { corner, .. } => map_point_expr(m, corner),
FeatureKind::Cylinder { base, axis, .. } => { FeatureKind::Cylinder { base, axis, .. } => {
pt(base); map_point_expr(m, base);
dir(axis); 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, .. }, .. } => { FeatureKind::Pattern { pattern: PatternKind::Rectangular { dir1, dir2, .. }, .. } => {
dir(dir1); dir(dir1);
if let Some(d) = dir2 { if let Some(d) = dir2 {
@@ -259,7 +292,7 @@ impl FeatureKind {
} }
FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetUnfold { .. } => {} FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetUnfold { .. } => {}
FeatureKind::Coil { base, axis, .. } => { FeatureKind::Coil { base, axis, .. } => {
pt(base); map_point_expr(m, base);
dir(axis); dir(axis);
} }
FeatureKind::ReplaceFace { faces, target, .. } => { FeatureKind::ReplaceFace { faces, target, .. } => {
+67 -10
View File
@@ -226,7 +226,8 @@ pub enum FeatureKind {
body: Option<String>, body: Option<String>,
}, },
Box { Box {
corner: Vec3, #[serde(with = "point_expr")]
corner: PointExpr,
length: String, length: String,
width: String, width: String,
height: String, height: String,
@@ -234,7 +235,8 @@ pub enum FeatureKind {
operation: Operation, operation: Operation,
}, },
Cylinder { Cylinder {
base: Vec3, #[serde(with = "point_expr")]
base: PointExpr,
#[serde(default = "z_axis")] #[serde(default = "z_axis")]
axis: Vec3, axis: Vec3,
radius: String, radius: String,
@@ -243,13 +245,15 @@ pub enum FeatureKind {
operation: Operation, operation: Operation,
}, },
Sphere { Sphere {
center: Vec3, #[serde(with = "point_expr")]
center: PointExpr,
radius: String, radius: String,
#[serde(default)] #[serde(default)]
operation: Operation, operation: Operation,
}, },
Torus { Torus {
center: Vec3, #[serde(with = "point_expr")]
center: PointExpr,
major: String, major: String,
minor: String, minor: String,
#[serde(default)] #[serde(default)]
@@ -482,7 +486,8 @@ pub enum FeatureKind {
/// A helical coil: a circular or square section swept `turns` times about `axis`, rising /// 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). /// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside).
Coil { Coil {
base: Vec3, #[serde(with = "point_expr")]
base: PointExpr,
#[serde(default = "z_axis")] #[serde(default = "z_axis")]
axis: Vec3, axis: Vec3,
diameter: String, diameter: String,
@@ -939,6 +944,45 @@ fn z_axis() -> Vec3 {
Vec3::Z 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 { impl FeatureKind {
/// Sketches the feature is built from (profiles, paths, points): deleting one of them /// 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. /// 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(distance2.iter().map(String::as_str));
v.extend(angle.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::Box { length, width, height, corner, .. } => {
FeatureKind::Cylinder { radius, height, .. } => v.extend([radius.as_str(), height]), v.extend([length.as_str(), width, height]);
FeatureKind::Sphere { radius, .. } => v.push(radius), v.extend(corner.iter().map(String::as_str));
FeatureKind::Torus { major, minor, .. } => v.extend([major.as_str(), minor]), }
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::Combine { .. } => {}
FeatureKind::Pattern { pattern, .. } => match pattern { FeatureKind::Pattern { pattern, .. } => match pattern {
PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => { PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => {
@@ -1308,9 +1364,10 @@ impl FeatureKind {
v.extend(gap.iter().map(String::as_str)); v.extend(gap.iter().map(String::as_str));
} }
FeatureKind::SheetBase { .. } | FeatureKind::SheetUnfold { .. } | FeatureKind::SheetConvert { .. } => {} 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([diameter.as_str(), pitch, turns, section_size]);
v.extend(start_angle.iter().map(String::as_str)); v.extend(start_angle.iter().map(String::as_str));
v.extend(base.iter().map(String::as_str));
} }
FeatureKind::Rib { thickness, depth, .. } => { FeatureKind::Rib { thickness, depth, .. } => {
v.push(thickness); v.push(thickness);
+34 -12
View File
@@ -19,7 +19,9 @@ pub struct ModelBody {
pub body: Body, pub body: Body,
/// Feature that created it. /// Feature that created it.
pub feature: u64, 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 /// 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). /// that produced it (kept while later features pass it through unchanged).
key: u64, key: u64,
@@ -33,7 +35,7 @@ pub struct ModelBody {
impl ModelBody { impl ModelBody {
pub fn new(name: String, body: Body, feature: u64) -> Self { 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. /// Display mesh (chord tolerance 1/1000 of the body size), computed once.
pub fn mesh(&self) -> Arc<Mesh> { 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 /// 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. /// 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> { 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); 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) { 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))?]) 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, .. } => { 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 k = axis.normalized().ok_or_else(|| DocError::Invalid("coil axis".into()))?;
let (d, p, n, s) = ( let (d, p, n, s) = (
val(vals, diameter, Kind::Length)?, 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, .. } => { 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)?); 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, .. } => { 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, .. } => { 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, .. } => { FeatureKind::Hole { diameter, depth, hole, to, .. } => {
let (spots, dir) = hole_spots(st, f)?; 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 } => { 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 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, &[]) apply_op(st, f, vec![b], *operation, &[])
} }
FeatureKind::Cylinder { base, axis, radius, height, 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, &[]) apply_op(st, f, vec![b], *operation, &[])
} }
FeatureKind::Sphere { center, radius, 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, &[]) apply_op(st, f, vec![b], *operation, &[])
} }
FeatureKind::Torus { center, major, minor, 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, &[]) apply_op(st, f, vec![b], *operation, &[])
} }
FeatureKind::Combine { target, tools, operation, keep_tools } => { 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 bm = doc.body_offsets.get(&b.name).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
let body = let body =
if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) }; 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 { if primary {
for s in st.sketches.iter().filter(|s| comp_of_feature(s.feature) == comp) { 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() { fn tangent_chain_follows_smooth_edges() {
let mut doc = Document::new("T"); let mut doc = Document::new("T");
doc.add_feature( 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, None,
) )
.unwrap(); .unwrap();
+65 -4
View File
@@ -197,10 +197,7 @@ fn rigid(m: &crate::Mat) -> bool {
/// Feature input names and component occurrences, which format 1 files may predate. /// Feature input names and component occurrences, which format 1 files may predate.
fn normalise(d: &mut Document) -> Result<()> { fn normalise(d: &mut Document) -> Result<()> {
let ids: Vec<u64> = d.features.iter().map(|f| f.id).collect(); d.name_all_feature_inputs();
for id in ids {
d.name_feature_inputs(id);
}
let missing: Vec<(u64, u64)> = 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(); 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 { for (c, p) in missing {
@@ -213,6 +210,70 @@ fn normalise(d: &mut Document) -> Result<()> {
mod tests { mod tests {
use super::*; 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] #[test]
fn versions() { fn versions() {
assert_eq!(version_of("solvecraft/1"), Some(1)); assert_eq!(version_of("solvecraft/1"), Some(1));
+1 -1
View File
@@ -25,7 +25,7 @@ mod project3d;
pub use project3d::iso_grid; pub use project3d::iso_grid;
pub mod sheet; 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 document::*;
pub use eval::{ pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices, EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices,
+7
View File
@@ -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. /// 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> { 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 m = b.mesh();
let size = b.body.size().max(1e-6); let size = b.body.size().max(1e-6);
let tol = (size * 1e-4).max(1e-6); let tol = (size * 1e-4).max(1e-6);
+85
View File
@@ -276,6 +276,32 @@ impl FeatureKind {
v 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). /// Read-only view of [`FeatureKind::inputs_mut`]: (label, expression, kind).
pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> { pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> {
// Imports have no inputs (and their STEP text is large: don't clone it). // 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. /// 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 /// 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. /// 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 { if let FeatureKind::Sketch { sketch, .. } = &f.kind {
for c in &sketch.constraints { for c in &sketch.constraints {
if c.param.as_deref() == Some(name) { if c.param.as_deref() == Some(name) {
@@ -718,6 +796,13 @@ impl Document {
*x = new.to_string(); *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() { for (_, e, _) in f.kind.inputs_mut() {
let r = expr::rename_reference(e, old, new); let r = expr::rename_reference(e, old, new);
if r != *e { if r != *e {
+24 -3
View File
@@ -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) 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(); .unwrap();
doc.add_feature( 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, None,
) )
.unwrap(); .unwrap();
@@ -202,13 +208,19 @@ fn params_and_serialization() {
fn primitives_and_combine() { fn primitives_and_combine() {
let mut doc = Document::new("p"); let mut doc = Document::new("p");
doc.add_feature( 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, None,
) )
.unwrap(); .unwrap();
doc.add_feature( doc.add_feature(
FeatureKind::Cylinder { 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, axis: Vec3::Z,
radius: "2".into(), radius: "2".into(),
height: "20".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());
}
+93 -50
View File
@@ -1,7 +1,7 @@
//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move. //! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move.
use serde_json::{Value, json}; 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 solvecraft_geom::Vec3;
use super::CommandSpec; use super::CommandSpec;
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE") .at("SOLID", "CREATE")
.icon("extrude") .icon("extrude")
.key("E") .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) CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE") .at("SOLID", "CREATE")
.icon("revolve") .icon("revolve")
@@ -23,10 +23,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("sweep") .icon("sweep")
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?"), .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.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.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]; axis?: [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] (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]; 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?; 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) CommandSpec::new("solid.fillet", "Fillet", fillet)
.at("SOLID", "MODIFY") .at("SOLID", "MODIFY")
.icon("fillet") .icon("fillet")
@@ -46,7 +46,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE") .at("SOLID", "CREATE")
.icon("pattern_rect") .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"), .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) CommandSpec::new("solid.hole", "Hole", hole)
.at("SOLID", "CREATE") .at("SOLID", "CREATE")
.icon("hole") .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 /// 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> { pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
let d = s.doc.eval(&expr(p, "distance")?, Kind::Length).ok()?; let d = s.doc.eval(&expr(p, "distance")?, Kind::Length).ok()?;
let dir = str_(p, "direction").map(str::to_ascii_lowercase); 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). // Only the active component's bodies take part (or the ones named as participants).
let parts = string_list(p, "participants"); let parts = string_list(p, "participants");
let inside = |q: Vec3| { let bodies: Vec<_> = st
st.bodies .bodies
.iter() .iter()
.filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) }) .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)) .collect();
}; if bodies.is_empty() {
let eps = (d.abs() * 1e-3).max(1e-3); return Some("new");
let sign = if d < 0.0 { -1.0 } else { 1.0 }; }
let inside = |q: Vec3| bodies.iter().any(|b| b.mesh().contains(q));
if symmetric { 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)) { if inside(base + n * (d * 0.5)) {
Some("cut") Some("cut")
} else if inside(base - n * (eps * sign)) {
Some("join")
} else { } 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> { fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.box"; let cmd = "solid.box";
let (l, w, h) = (req_expr(cmd, p, "length")?, req_expr(cmd, p, "width")?, req_expr(cmd, p, "height")?); 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")] { for (e, n) in [(&l, "length"), (&w, "width"), (&h, "height")] {
check_expr(s, e, Kind::Length, cmd, n)?; 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, (Some(c), _) => c,
(None, Some(c)) => { // The corner half a size back from the centre (numbers stay numbers).
let v = |e: &str| s.doc.eval(e, Kind::Length).unwrap_or(0.0); (None, Some([cx, cy, cz])) => {
c - Vec3::new(v(&l), v(&w), v(&h)) * 0.5 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)? }) 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")?; let height = req_expr(cmd, p, "height")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?; check_expr(s, &radius, Kind::Length, cmd, "radius")?;
check_expr(s, &height, Kind::Length, cmd, "height")?; check_expr(s, &height, Kind::Length, cmd, "height")?;
let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO); let base = point_param(s, p, "base", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
let axis = p.get("axis").and_then(vec3).unwrap_or(Vec3::Z); let axis = axis_dir_param(p, cmd)?;
add_feature(s, p, FeatureKind::Cylinder { base, axis, radius, height, operation: operation(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 cmd = "solid.sphere";
let radius = radius_expr(p, cmd)?; let radius = radius_expr(p, cmd)?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?; 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)? }) 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")?); let (major, minor) = (req_expr(cmd, p, "major")?, req_expr(cmd, p, "minor")?);
check_expr(s, &major, Kind::Length, cmd, "major")?; check_expr(s, &major, Kind::Length, cmd, "major")?;
check_expr(s, &minor, Kind::Length, cmd, "minor")?; 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)? }) 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 cmd = "solid.mirror";
let bodies = string_list(p, "bodies"); let bodies = string_list(p, "bodies");
let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() }; let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() };
let plane = match p.get("plane") { let plane = plane_param(s, p.get("plane"), cmd)?;
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 combine = bool_(p, "combine").unwrap_or(false); let combine = bool_(p, "combine").unwrap_or(false);
add_feature(s, p, FeatureKind::Mirror { features, plane, bodies, combine }) 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 /// A plane reference from a parameter: an origin plane, a construction plane (by name or
/// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`. /// 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> { pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solvecraft_doc::PlaneRef> {
use 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 { 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)) if solvecraft_geom::Plane::named(n.trim()).is_some() => Ok(PlaneRef::Origin { name: n.trim().to_ascii_uppercase() }),
Some(Value::String(n)) => match s.doc.find_feature(n).map(|f| &f.kind) { Some(Value::String(n)) => construction(n.trim()),
Some(FeatureKind::ConstructionPlane { .. }) => Ok(PlaneRef::Construction { name: n.clone() }), Some(Value::Number(id)) => match id.as_u64() {
_ => Err(bad(cmd, format!("no plane `{n}`"))), Some(id) => construction(&id.to_string()),
None => Err(bad(cmd, format!("`plane` must be {WANT}"))),
}, },
Some(o @ Value::Object(_)) => { 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)) { 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(x), Some(y), _) => solvecraft_geom::Plane::new(origin, x, y),
(_, _, Some(n)) => solvecraft_geom::Plane::from_normal(origin, n), (_, _, 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"))? }) 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}"))),
} }
} }
+4 -13
View File
@@ -6,7 +6,7 @@ use solvecraft_doc::expr::Kind;
use solvecraft_geom::Vec3; use solvecraft_geom::Vec3;
use super::CommandSpec; 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::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{Result, Session}; use crate::{Result, Session};
@@ -16,7 +16,7 @@ pub static COMMANDS: &[CommandSpec] = &[
), ),
CommandSpec::new("solid.coil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params( 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; \ "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) CommandSpec::new("solid.rib", "Rib", rib)
.at("SOLID", "CREATE") .at("SOLID", "CREATE")
@@ -43,17 +43,8 @@ pub static COMMANDS: &[CommandSpec] = &[
fn coil(s: &mut Session, p: &Value) -> Result<Value> { fn coil(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.coil"; let cmd = "solid.coil";
let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO); let base = point_param(s, p, "base", cmd)?.unwrap_or_else(|| solvecraft_doc::point_expr(Vec3::ZERO));
let axis = match p.get("axis") { let axis = axis_dir_param(p, cmd)?;
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 diameter = req_expr(cmd, p, "diameter")?; let diameter = req_expr(cmd, p, "diameter")?;
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?; check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
let turns = expr(p, "revolutions").or_else(|| expr(p, "turns")); let turns = expr(p, "revolutions").or_else(|| expr(p, "turns"));
+17 -4
View File
@@ -10,7 +10,7 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = pub static COMMANDS: &[CommandSpec] =
&[CommandSpec::new("inspect.section", "Section Analysis", section).at("SOLID", "INSPECT").icon("section").noundo().params( &[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; \ 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", 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. // A named plane's world axis: which component of a point lies along its normal.
let (origin, normal, axis) = match p.get("plane") { let (origin, normal, axis) = match p.get("plane") {
Some(Value::String(n)) => { Some(Value::String(n)) if Plane::named(n.trim()).is_some() => {
let pl = Plane::named(n).ok_or_else(|| bad(cmd, format!("unknown plane `{n}` (XY, XZ, YZ)")))?; let pl = Plane::named(n.trim()).ok_or_else(|| bad(cmd, format!("unknown plane `{n}`")))?;
let normal = pl.normal(); let normal = pl.normal();
let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5); let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5);
(pl.origin, normal, axis) (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(_)) => { Some(o @ Value::Object(_)) => {
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); 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`"))?; let n = o.get("normal").and_then(vec3).and_then(|n| n.normalized()).ok_or_else(|| bad(cmd, "the plane needs a non-zero `normal`"))?;
(origin, n, None) (origin, n, 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>> { let length = |key: &str| -> Result<Option<f64>> {
match expr(p, key) { match expr(p, key) {
+13 -11
View File
@@ -20,7 +20,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.create", "Create Sketch", create_sketch) CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
.at("SOLID", "CREATE") .at("SOLID", "CREATE")
.icon("sketch") .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.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.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("sketch.line", "Line", draw_line) 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> { pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
let base = match p.get("plane") { let base = match p.get("plane") {
None => PlaneRef::Origin { name: "XY".into() }, None => PlaneRef::Origin { name: "XY".into() },
Some(Value::String(n)) => { // An origin plane, or a construction plane by name or id.
if Plane::named(n).is_some() { Some(v @ (Value::String(_) | Value::Number(_))) => super::features::plane_param(s, Some(v), cmd)?,
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)")));
}
}
Some(v @ Value::Object(o)) => { Some(v @ Value::Object(o)) => {
if let Some(fp) = o.get("face").and_then(vec3) { 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 // 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"))? } 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") { Ok(match expr(p, "offset") {
Some(d) => PlaneRef::Offset { base: Box::new(base), distance: d }, 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); place_text(sk, &id, text_at);
Ok((pname, name, measured(is_angle, cur))) Ok((pname, name, measured(is_angle, cur)))
})?; })?;
// Report what the dimension measures once the sketch is solved, not the geometry before.
let current = solved_measure(s, p, cmd).unwrap_or(current);
if driven { if driven {
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info})); return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.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") } 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 /// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the
/// dimension so it follows the geometry. /// dimension so it follows the geometry.
fn place_text(sk: &mut Sketch, id: &str, at: Option<Vec2>) { 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); 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] #[test]
fn curvature_polygon_arc_length_linear_diameter() { fn curvature_polygon_arc_length_linear_diameter() {
let mut s = new_sketch(); 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"})); 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["sketch"]["dof"], 0, "{d}");
assert_eq!(d["unit"], "deg", "{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); 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. // 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); assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
let mut s = spout(); let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2", "l3"], "value": "60 deg"})); 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. // Without a value the acute angle is kept as it is.
let mut s = spout(); let mut s = spout();
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"]})); let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"]}));
+20 -2
View File
@@ -10,7 +10,7 @@
//! without moved components). //! without moved components).
use serde_json::{Map, Value}; 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 solvecraft_geom::Vec3;
use crate::Session; use crate::Session;
@@ -203,11 +203,29 @@ impl Mapper<'_> {
} }
fn make(&mut self, v: &Value) -> Option<Value> { fn make(&mut self, v: &Value) -> Option<Value> {
let p = vec3(v)?;
let m = self.f.active; let m = self.f.active;
let Some(p) = vec3(v) else { return self.make_expr(&m, v) };
self.local_point(&m, p) 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> { fn dir(&mut self, v: &Value) -> Option<Value> {
let d = vec3(v)?; let d = vec3(v)?;
let m = self.first.unwrap_or(self.f.active); let m = self.first.unwrap_or(self.f.active);
+36
View File
@@ -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 /// 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). /// 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 /// A `timeline.redefine` is shown even while the timeline is rolled back to the feature
+130 -3
View File
@@ -36,6 +36,8 @@ fn join_extrude_from_a_chamfered_top_face() {
} }
mod component_frames; mod component_frames;
mod mirror_planes;
mod primitive_points;
mod robustness; mod robustness;
/// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole. /// 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)); 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] #[test]
fn extrude_auto_operation() { fn extrude_auto_operation() {
let mut s = Session::default(); let mut s = Session::default();
@@ -603,7 +605,7 @@ fn extrude_auto_operation() {
// Pulled out, it joins. // Pulled out, it joins.
assert_eq!(auto_operation(&s, &json!({"face": [20, 15, 15], "distance": 5})), Some("join")); 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 // 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.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": [5, 5], "p1": [15, 15]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [60, 5], "p1": [70, 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}); 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("cut"));
assert_eq!(auto_operation(&s, &at(10.0, -8.0)), Some("join")); 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"})); 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); 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. /// Measuring picked geometry: distance between faces, angle, edge length.
#[test] #[test]
fn measure_items() { 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}"); 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. /// Chamfers with two distances or a distance and an angle.
#[test] #[test]
fn unequal_chamfers() { fn unequal_chamfers() {
@@ -2001,3 +2091,40 @@ fn drawing_on_existing_points_connects_to_them() {
let si = run(&mut s, "sketch.inspect", json!({})); let si = run(&mut s, "sketch.inspect", json!({}));
assert_ne!(si["status"], "conflict", "{si}"); 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()));
}
+65
View File
@@ -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, &copy) - 90.0).abs() < 1e-6, "{}", min_x(&s, &copy));
// Parametric: moving the plane moves the copy.
run(&mut s, "parameters.change", json!({"name": "w", "expression": "60 mm"}));
assert!((min_x(&s, &copy) - 50.0).abs() < 1e-6, "{}", min_x(&s, &copy));
// 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());
}
+121
View File
@@ -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
View File
@@ -425,13 +425,19 @@ pub const MAX_STL_TRIANGLES: usize = 20_000_000;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use solvecraft_doc::{FeatureKind, Model, Operation}; use solvecraft_doc::{FeatureKind, Model, Operation, point_expr};
use solvecraft_geom::Vec3; use solvecraft_geom::Vec3;
fn model() -> (Document, Model) { fn model() -> (Document, Model) {
let mut doc = Document::new("t"); let mut doc = Document::new("t");
doc.add_feature( 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, None,
) )
.unwrap(); .unwrap();
@@ -558,7 +564,13 @@ mod tests {
fn threemf_round_trip() { fn threemf_round_trip() {
let mut doc = Document::new("t"); let mut doc = Document::new("t");
doc.add_feature( 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, None,
) )
.unwrap(); .unwrap();
@@ -569,7 +581,7 @@ mod tests {
.unwrap(); .unwrap();
doc.add_feature( doc.add_feature(
FeatureKind::Cylinder { 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, axis: Vec3::Z,
radius: "6".into(), radius: "6".into(),
height: "15".into(), height: "15".into(),
@@ -579,7 +591,12 @@ mod tests {
) )
.unwrap(); .unwrap();
doc.add_feature( 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, None,
) )
.unwrap(); .unwrap();
+152 -12
View File
@@ -1,8 +1,8 @@
//! Edge fillets and chamfers as a local B-rep operation. //! Edge fillets and chamfers as a local B-rep operation.
//! //!
//! Supported: a straight, convex edge between two planar faces whose two end vertices each //! Supported locally: a straight 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 //! touch exactly one more planar face. Fillets require perpendicular end faces; chamfers
//! extruded and box-like parts). The two side faces are trimmed back, the end faces get an arc //! 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. //! (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 //! The result is exact. Edges meeting at an already blended corner, curved edges and other
//! configurations are reported as not supported yet. //! 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)] #[allow(clippy::too_many_arguments)]
fn blend_geometry( fn blend_geometry(
solid: &Solid, solid: &Solid,
@@ -153,6 +207,8 @@ fn blend_geometry(
g: usize, g: usize,
e1: mt::Edge, e1: mt::Edge,
e2: mt::Edge, e2: mt::Edge,
a: Vec3,
b: Vec3,
} }
let mut ends: Vec<End> = Vec::new(); let mut ends: Vec<End> = Vec::new();
for (v, p) in [(&v0, p0), (&v1, p1)] { 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 [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 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"))?; 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")); 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. // Edges at v shared by g with f1 and with f2.
let shared = |fi: usize| -> Result<mt::Edge> { let shared = |fi: usize| -> Result<mt::Edge> {
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?; 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")) .ok_or_else(|| unsupported("unexpected corner topology"))
}; };
let (e1, e2) = (shared(i1)?, shared(i2)?); 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(_)) { if !matches!(e.curve(), mt::Curve::Line(_)) {
return Err(unsupported("the edges at the corners must be straight")); 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 other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
let along = (other - p).dot(t); let delta = other - p;
if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= s + tol { 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")); 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())) }; let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
if end0.g == end1.g { if end0.g == end1.g {
return Err(unsupported("both ends touch the same face")); return Err(unsupported("both ends touch the same face"));
} }
let a0 = builder::vertex(p3(p0 + t1 * s)); if (end1.a - end0.a).dot(d) <= tol || (end1.b - end0.b).dot(d) <= tol {
let b0 = builder::vertex(p3(p0 + t2 * s)); return Err(unsupported("the blend is larger than the neighbouring faces"));
let a1 = builder::vertex(p3(p1 + t1 * s)); }
let b1 = builder::vertex(p3(p1 + t2 * s)); 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 // 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 // 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. // 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); 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. // 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()))?; 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)? { 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![] }; 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())) 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, &region).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());
}
}
}
+163 -21
View File
@@ -1,7 +1,7 @@
//! Fillets with a radius per edge, and the corners where filleted edges meet. //! 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 //! 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: //! 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 //! - one filleted edge ends on the third face: the blend ends in that face's section of the
//! cylinder (an exact arc or ellipse); //! 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; let mut first: Option<KernelError> = None;
// Earlier rounds at the edges' ends come off and are blended again with the new edges. // Earlier rounds at the edges' ends come off and are blended again with the new edges.
for _ in 0..4 { for _ in 0..4 {
let err = match build(&cur, &sel) { let err = match build(&cur, &sel, true) {
Ok(b) => return Ok(b), Ok(b) => return Ok(b),
Err(e) => e, 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"))) 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. /// A face of the shell being rebuilt, or a new face.
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq)]
enum Owner { enum Owner {
@@ -112,7 +120,7 @@ fn edge(a: &mt::Vertex, b: &mt::Vertex, c: mt::Curve) -> Result<mt::Edge> {
/// along `u`. /// along `u`.
fn hit(v: Vec3, u: Vec3, t: Vec3, s: f64) -> Result<Vec3> { fn hit(v: Vec3, u: Vec3, t: Vec3, s: f64) -> Result<Vec3> {
let k = u.dot(t); 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")); return Err(unsupported("a fillet that runs along its neighbouring edge"));
} }
Ok(v + u * (s / k)) 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. /// 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> { 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)); let (k1, k2) = (u1.dot(t2), u2.dot(t1));
if k1 < 1e-6 || k2 < 1e-6 { if k1.abs() < 1e-6 || k2.abs() < 1e-6 {
return Err(unsupported("fillets of edges that meet at a reflex corner")); return Err(unsupported("fillets of nearly parallel edges at a corner"));
} }
Ok(v + u1 * (s2 / k1) + u2 * (s1 / k2)) 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())) 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 size = cur.size();
let tol = (size * 2e-3).max(1e-3); let tol = (size * 2e-3).max(1e-3);
let solid = cur.deep_copy(); 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")); return Err(unsupported("the faces at the edge are tangent"));
} }
let r = radius.get(id).copied().unwrap_or(1.0); 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(); let w = (alpha / 2.0).sin();
// The cylinder, well past both ends (sections on oblique end faces reach beyond them). // The cylinder, well past both ends (sections on oblique end faces reach beyond them).
let m = 4.0 * (s + r); let m = 4.0 * (s + r);
let (q0, q1) = (p0 - d * m, p1 + d * m); 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 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( let surf = if round {
(mt::KnotVec::bezier_knot(2), mt::KnotVec::bezier_knot(1)), mt::Surface::NurbsSurface(mt::NurbsSurface::new(mt::BSplineSurface::new(
ctrl, (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 mid = (p0 + p1) * 0.5;
let axis = mid + ta * s - na * r; let axis = mid + ta * s - na * r;
let out = ((ta + tb) * -1.0).normalized().unwrap_or(na); 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(); 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)); 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())); 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(); let list = pool.entry(*vid).or_default();
match (&chosen[..], &rest[..]) { match (&chosen[..], &rest[..]) {
([(e1, _)], [(e2, u2), (e3, u3)]) => { ([(e1, _)], [(e2, u2), (e3, u3)]) => {
let s1 = sel(*e1)?; let s1 = sel(*e1)?;
let (f12, f31) = (face_with(*e1, *e2)?, face_with(*e1, *e3)?); let (f12, f31) = (face_with(*e1, *e2)?, face_with(*e1, *e3)?);
let f23 = face_with(*e2, *e3)?; let f23 = face_with(*e2, *e3)?;
let p2 = hit(v, *u2, s1.t(f12), s1.s)?; let p2 = hit(*u2, s1.t(f12), s1.s, f12)?;
let p3_ = hit(v, *u3, s1.t(f31), s1.s)?; let p3_ = hit(*u3, s1.t(f31), s1.s, f31)?;
let (a, b) = (builder::vertex(p3(p2)), builder::vertex(p3(p3_))); let (a, b) = (builder::vertex(p3(p2)), builder::vertex(p3(p3_)));
endpt.insert((*e2, *vid, None), a.clone()); endpt.insert((*e2, *vid, None), a.clone());
endpt.insert((*e1, *vid, Some(f12)), a.clone()); endpt.insert((*e1, *vid, Some(f12)), a.clone());
endpt.insert((*e3, *vid, None), b.clone()); endpt.insert((*e3, *vid, None), b.clone());
endpt.insert((*e1, *vid, Some(f31)), 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)])); list.push((c, vec![Owner::Face(f23), Owner::Cyl(*e1)]));
} }
([(ea, ua), (eb, ub)], [(e3, u3)]) => { ([(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 fab = face_with(*ea, *eb)?;
let (fb3, fa3) = (face_with(*eb, *e3)?, face_with(*ea, *e3)?); 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))?; 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. // Where each cylinder touches the third edge.
let xb = hit(v, *u3, sb.t(fb3), sb.s)?; let xb = hit(*u3, sb.t(fb3), sb.s, fb3)?;
let xa = hit(v, *u3, sa.t(fa3), sa.s)?; let xa = hit(*u3, sa.t(fa3), sa.s, fa3)?;
let qv = builder::vertex(p3(q)); let qv = builder::vertex(p3(q));
endpt.insert((*ea, *vid, Some(fab)), qv.clone()); endpt.insert((*ea, *vid, Some(fab)), qv.clone());
endpt.insert((*eb, *vid, Some(fab)), qv.clone()); endpt.insert((*eb, *vid, Some(fab)), qv.clone());
let (la, lb) = ((xa - v).len(), (xb - v).len()); 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 { if (la - lb).abs() <= size * 1e-9 {
let xv = builder::vertex(p3(xb)); let xv = builder::vertex(p3(xb));
endpt.insert((*e3, *vid, None), xv.clone()); 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) }; 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 // 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. // 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 mut sec = conic(x, v, z, big.w);
let tt = small.t(fsmall3); let tt = small.t(fsmall3);
let c0 = (x - v).dot(tt) - small.s; let c0 = (x - v).dot(tt) - small.s;
let c1 = big.w * (0.0 - small.s); let c1 = big.w * (0.0 - small.s);
let c2 = (z - v).dot(tt) - 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"))?; // A chamfer section is a line, not the cylinder's conic.
let y = { // 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; use mt::ParametricCurve;
crate::body::from_p3(sec.subs(t)) crate::body::from_p3(sec.subs(t))
} else {
x + (z - x) * t
}; };
{ {
use mt::Cut; 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((*e3, *vid, None), xv.clone());
endpt.insert((eb_, *vid, Some(fbig3)), xv.clone()); endpt.insert((eb_, *vid, Some(fbig3)), xv.clone());
endpt.insert((es, *vid, Some(fsmall3)), yv.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_)])); list.push((piece, vec![Owner::Face(fsmall3), Owner::Cyl(eb_)]));
let c = edge(&qv, &yv, crease_to(y)?)?; let c = edge(&qv, &yv, crease_to(y)?)?;
list.push((c, vec![Owner::Cyl(*ea), Owner::Cyl(*eb)])); list.push((c, vec![Owner::Cyl(*ea), Owner::Cyl(*eb)]));
} }
} }
([(e1, u1), (e2, u2), (e3, u3)], []) => { ([(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 (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 (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))?; let q12 = meet(v, (*u1, s1.t(f12), s1.s), (*u2, s2.t(f12), s2.s))?;
@@ -1086,4 +1125,107 @@ mod tests {
.unwrap(); .unwrap();
assert!(fillet_radii(&l, &[(Vec3::new(10.0, 10.0, 10.0), 1.0), (c, 2.0)]).is_err()); 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());
}
} }
+24 -3
View File
@@ -13,6 +13,8 @@ pub(crate) type Solid = mt::Solid;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Body { pub struct Body {
pub(crate) solid: std::sync::Arc<Solid>, 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). /// Set for mesh bodies (`solid` is then empty).
pub(crate) mesh: Option<std::sync::Arc<crate::meshbody::TriMesh>>, pub(crate) mesh: Option<std::sync::Arc<crate::meshbody::TriMesh>>,
/// Display colour (linear RGB 0..1) from an imported file; operations drop it. /// 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 } if lo.x.is_finite() { (hi - lo).len() } else { 1.0 }
}; };
let tol = (span * 0.01).clamp(1e-3, 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 { if v < 0.0 {
solid.not(); 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. /// A deep copy of the solid that can be mutated without affecting other bodies.
@@ -506,7 +521,13 @@ impl Body {
if self.is_mesh() { if self.is_mesh() {
return 0; 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()]; let mut has = vec![false; self.face_count()];
for f in &m.tri_face { for f in &m.tri_face {
if let Some(h) = has.get_mut(*f as usize) { if let Some(h) = has.get_mut(*f as usize) {
+14 -1
View File
@@ -270,7 +270,7 @@ fn build(
if !(phi > 1e-3 && phi < std::f64::consts::PI - 1e-3) { if !(phi > 1e-3 && phi < std::f64::consts::PI - 1e-3) {
return None; 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())) Some((m, t, sb, r / (phi / 2.0).sin()))
}; };
// The straight seam between the walls at a sharp joint (direction away from the face). // 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)); 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 // 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). // to a point (a rounded corner rounded again: the blend there is a sphere).
let mut collapsed = vec![false; k.max(nj)]; let mut collapsed = vec![false; k.max(nj)];
+16 -2
View File
@@ -101,7 +101,14 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
t.swap(1, 2); 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 /// 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); 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(),
})
} }
} }
+9
View File
@@ -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 // Prisms along one direction (plates with holes): exact, in 2D (so only the volume
// bounds are checked). // bounds are checked).
if let Some(Ok(r)) = crate::prism::prism_boolean(a, b, op) { 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 { match &r {
Some(body) => { Some(body) => {
let v = volume(body); let v = volume(body);
+136 -2
View File
@@ -3,6 +3,8 @@
//! spanning the target's height, the result is the prism over the 2D boolean of their //! 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 //! sections. Plates with holes, slots and patterns of them: exact, and immune to the mesh
//! intersection's trouble with walls meeting along lines. //! 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 solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use truck_modeling as mt; 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 /// meets it, so the faces share their edges. `None` when a slab is empty or a curve isn't a
/// line or an arc. /// line or an arc.
fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option<Result<Body>> { 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]; let mut levels = vec![pa.lo, pa.hi, pb.lo, pb.hi];
levels.sort_by(f64::total_cmp); levels.sort_by(f64::total_cmp);
levels.dedup_by(|x, y| (*x - *y).abs() < tol); 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)); 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. // 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>> { let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> {
match (x.is_empty(), y.is_empty()) { 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)) 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 size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0; let tol = (size * 1e-7).max(1e-9) * 10.0;
// Candidate directions: a's planar face normals (each line once). // 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))) 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(&current, 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(&current) });
}
}
(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()
}
+4 -1
View File
@@ -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 shells = std::mem::take(&mut imp.shells);
let solid = mt::Solid::new_unchecked(shells.into_iter().map(mt::Shell::from).collect()); 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,
))
}) })
} }
+16 -2
View File
@@ -22,7 +22,14 @@ impl Body {
/// A body of faces as they are (no orientation fix: a surface has no inside). /// A body of faces as they are (no orientation fix: a surface has no inside).
fn surface_body(faces: Vec<mt::Face>) -> Body { fn surface_body(faces: Vec<mt::Face>) -> Body {
let shell = mt::Shell::from(faces); 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. /// 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 { if shell.shell_condition() == mt::ShellCondition::Closed {
Body::new(mt::Solid::new_unchecked(vec![shell])) Body::new(mt::Solid::new_unchecked(vec![shell]))
} else { } 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,
})
} }
} }
+89
View File
@@ -302,6 +302,8 @@ fn planar_booleans_with_coincident_faces() {
// Through the public boolean (B-rep first, polygon fallback). // Through the public boolean (B-rep first, polygon fallback).
let u2 = boolean(&base, &wall, BoolOp::Union).unwrap().unwrap(); let u2 = boolean(&base, &wall, BoolOp::Union).unwrap().unwrap();
assert!(rel(measure(&u2).unwrap().volume, m.volume) < 1e-9); 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. // 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 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(); 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); 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] #[test]
fn sphere_booleans() { fn sphere_booleans() {
let s = sphere(Vec3::ZERO, 15.0).unwrap(); let s = sphere(Vec3::ZERO, 15.0).unwrap();
@@ -435,6 +463,50 @@ fn chamfer_top_loop_mitres() {
assert_eq!(m.merged.faces, 10); 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] #[test]
#[ignore] #[ignore]
fn debug_chamfer_cut() { 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); 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)));
}
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, Sk
pub use offset::OffsetChain; pub use offset::OffsetChain;
pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions}; pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions};
pub use shape::{Shape, intersections}; 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 text::{MAX_TEXT_CHARS, text_geometry};
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, fit_polyline}; pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, fit_polyline};
+7
View File
@@ -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 /// 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). /// touch point (better conditioned than the distance form when an end point is on the curve).
enum Block<'a> { enum Block<'a> {
+11
View File
@@ -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:?}")), "hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")),
"renderer": if app.viewport.gpu.is_some() { "gpu" } else { "cpu" }, "renderer": if app.viewport.gpu.is_some() { "gpu" } else { "cpu" },
"frame_ms": app.frame_ms, "frame_ms": app.frame_ms,
"file_busy": app.file_load.busy(),
"build_ms": app.viewport.build_ms, "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}, "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, "home": app.home.open,
"recent_thumbnails_ready": app.home.ready_recents(),
"documents": app.docs.count(), "documents": app.docs.count(),
"quit_requested": app.quit_requested, "quit_requested": app.quit_requested,
"quitting": app.docs.quitting.is_some(), "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.documents" => ok(crate::documents::control(app, p)),
"ui.home" => { "ui.home" => {
match p.get("open").and_then(Value::as_bool) { match p.get("open").and_then(Value::as_bool) {
+10 -5
View File
@@ -33,7 +33,7 @@ pub enum Kind {
distance: String, distance: String,
direction: usize, direction: usize,
operation: 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. /// until the user picks one.
auto_op: bool, auto_op: bool,
/// The other side's distance (two sides). /// The other side's distance (two sides).
@@ -2801,6 +2801,11 @@ fn pt3(v: Vec3) -> Value {
json!([v.x, v.y, v.z]) 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 /// 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. /// 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> { 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())], fields: vec![("length", length.clone()), ("width", width.clone()), ("height", height.clone())],
operation: op_index(operation), operation: op_index(operation),
}; };
d.extra.insert("corner".into(), pt3(*corner)); d.extra.insert("corner".into(), pt_expr(corner));
d d
} }
FeatureKind::Cylinder { base, axis, radius, height, operation } => { 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())], fields: vec![("radius", radius.clone()), ("height", height.clone())],
operation: op_index(operation), 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.extra.insert("axis".into(), pt3(*axis));
d d
} }
FeatureKind::Sphere { center, radius, operation } => { FeatureKind::Sphere { center, radius, operation } => {
let mut d = start("solid.sphere")?; let mut d = start("solid.sphere")?;
d.kind = Kind::Primitive { cmd: "solid.sphere", fields: vec![("radius", radius.clone())], operation: op_index(operation) }; 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 d
} }
FeatureKind::Torus { center, major, minor, operation } => { 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())], fields: vec![("major", major.clone()), ("minor", minor.clone())],
operation: op_index(operation), operation: op_index(operation),
}; };
d.extra.insert("center".into(), pt3(*center)); d.extra.insert("center".into(), pt_expr(center));
d d
} }
FeatureKind::BoundaryFill { tools, cells, operation, remove_tools } => { FeatureKind::BoundaryFill { tools, cells, operation, remove_tools } => {
+2 -2
View File
@@ -91,7 +91,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
}; };
put_view(app, slot.view); put_view(app, slot.view);
// Views cache by revision: the incoming design must not look unchanged. // 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() }; app.ui.tab = if app.session.active_sketch.is_some() { "SKETCH".into() } else { "SOLID".into() };
out out
} }
@@ -141,7 +141,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
crate::dialogs::cancel(app); crate::dialogs::cancel(app);
app.tool = None; app.tool = None;
app.session = session; app.session = session;
app.session.revision = app.session.revision.max(rev) + 1; app.session.invalidate_view(rev);
app.cam = Camera::default(); app.cam = Camera::default();
app.fit_view(); app.fit_view();
} else if let Err(e) = open_tab(app, session) { } else if let Err(e) = open_tab(app, session) {
+261
View File
@@ -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
View File
@@ -31,11 +31,17 @@ pub struct HomeState {
queued: Vec<String>, queued: Vec<String>,
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
rx: Option<std::sync::mpsc::Receiver<Made>>, 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. /// A sample picked before its design was ready.
wanted: Option<usize>, wanted: Option<usize>,
} }
impl HomeState { 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) { pub fn show(&mut self, _rev: u64) {
self.open = true; 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)) (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). /// Build what a key stands for (runs off the UI thread where threads exist).
fn make(key: &str) -> Made { 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 mut s = Session::default();
let built = match key.strip_prefix("sample:").and_then(|i| i.parse::<usize>().ok()) { 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| { Some(i) => solvecraft_engine::sample::samples().get(i).is_some_and(|smp| {
@@ -83,7 +185,7 @@ fn make(key: &str) -> Made {
if !built { if !built {
return (key.to_string(), None, None); 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)) (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() { if h.rx.is_some() || h.queued.is_empty() {
return; 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 (tx, rx) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new().name("thumbnails".into()).spawn(move || { let spawned = std::thread::Builder::new().name("thumbnails".into()).spawn(move || {
for k in keys { for k in keys {
@@ -112,12 +225,31 @@ fn start_jobs(h: &mut HomeState) {
fn poll(app: &mut SolveApp, ctx: &egui::Context) { fn poll(app: &mut SolveApp, ctx: &egui::Context) {
let mut done: Vec<Made> = Vec::new(); let mut done: Vec<Made> = Vec::new();
#[cfg(not(target_arch = "wasm32"))] #[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); start_jobs(&mut app.home);
if let Some(rx) = &app.home.rx { if let Some(rx) = &app.home.rx {
loop { loop {
match rx.try_recv() { 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::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => { Err(std::sync::mpsc::TryRecvError::Disconnected) => {
app.home.rx = None; 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() { if app.home.rx.is_some() {
ctx.request_repaint_after(std::time::Duration::from_millis(100)); ctx.request_repaint_after(std::time::Duration::from_millis(100));
} }
@@ -185,7 +320,7 @@ pub fn open_recent(app: &mut SolveApp, path: &str) {
return; return;
} }
app.home.open = false; app.home.open = false;
app.open_path(path); app.open_path_async(path);
} }
/// The page itself (in place of the toolbar, browser and viewport). /// 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| { action = Some(Box::new(|app: &mut SolveApp| {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
app.home.open = false; 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 { mod tests {
use super::*; 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] #[test]
fn recent_list_is_newest_first_and_bounded() { fn recent_list_is_newest_first_and_bounded() {
let mut h = HomeState::default(); let mut h = HomeState::default();
+21 -3
View File
@@ -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()); 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 palette;
pub mod params_dialog; pub mod params_dialog;
pub mod prefs; pub mod prefs;
@@ -230,6 +231,7 @@ pub struct SolveApp {
fitted: bool, fitted: bool,
/// The start page. /// The start page.
pub home: home::HomeState, pub home: home::HomeState,
pub file_load: file_load::FileLoad,
/// The other open designs (tabs). /// The other open designs (tabs).
pub docs: documents::Documents, pub docs: documents::Documents,
} }
@@ -281,6 +283,7 @@ impl SolveApp {
styled: false, styled: false,
fitted: false, fitted: false,
home: home::HomeState::default(), home: home::HomeState::default(),
file_load: file_load::FileLoad::default(),
docs: documents::Documents::default(), docs: documents::Documents::default(),
} }
} }
@@ -388,6 +391,9 @@ impl SolveApp {
/// Run a command programmatically (JSON parameters, no dialogs). /// Run a command programmatically (JSON parameters, no dialogs).
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> { 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 t0 = now_ms();
let r = self.session.execute(id, &params).map_err(|e| e.to_string()); let r = self.session.execute(id, &params).map_err(|e| e.to_string());
self.big_operation |= now_ms() - t0 >= recovery_ui::BIG_OPERATION_MS; self.big_operation |= now_ms() - t0 >= recovery_ui::BIG_OPERATION_MS;
@@ -406,6 +412,9 @@ impl SolveApp {
&& let Some(p) = self.session.path.clone() && let Some(p) = self.session.path.clone()
{ {
self.home.add_recent(&p); self.home.add_recent(&p);
if matches!(id, "file.save" | "file.save_as") {
home::remember_session(&mut self.home, &self.session);
}
} }
} }
Err(e) => { Err(e) => {
@@ -441,7 +450,7 @@ impl SolveApp {
} }
if matches!(id, "file.insert_step" | "file.insert_mesh") { if matches!(id, "file.insert_step" | "file.insert_mesh") {
if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) { if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) {
self.insert_path(&p); self.insert_path_async(&p);
} }
return; 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 /// Insert a STEP file's bodies (an Import base feature) or a 3MF/STL file's meshes into the
/// current design. /// current design.
pub fn insert_path(&mut self, path: &str) { pub fn insert_path(&mut self, path: &str) {
@@ -641,9 +657,9 @@ impl SolveApp {
pub fn drop_path(&mut self, path: &str) { pub fn drop_path(&mut self, path: &str) {
let import = solvecraft_engine::io::is_step_path(path) || solvecraft_engine::io::is_mesh_path(path); let import = solvecraft_engine::io::is_step_path(path) || solvecraft_engine::io::is_mesh_path(path);
if import && !self.session.doc.features.is_empty() { if import && !self.session.doc.features.is_empty() {
self.insert_path(path); self.insert_path_async(path);
} else { } else {
self.open_path(path); self.open_path_async(path);
} }
} }
@@ -733,6 +749,7 @@ impl SolveApp {
/// Per-frame logic before layout. /// Per-frame logic before layout.
pub fn logic(&mut self, ctx: &egui::Context) { pub fn logic(&mut self, ctx: &egui::Context) {
file_load::tick(self, ctx);
self.esc_handled = false; self.esc_handled = false;
prefs::apply(self, ctx); prefs::apply(self, ctx);
if !self.styled || theme::is_dark() != self.ui.dark { if !self.styled || theme::is_dark() != self.ui.dark {
@@ -798,6 +815,7 @@ impl SolveApp {
let t0 = now_ms(); let t0 = now_ms();
scenario::clear_handles(); scenario::clear_handles();
toolbar::app_bar(self, ui); toolbar::app_bar(self, ui);
file_load::show(self, ui.ctx());
documents::prompt(self, ui.ctx()); documents::prompt(self, ui.ctx());
if self.home.open { if self.home.open {
home::show(self, ui); home::show(self, ui);
+1 -1
View File
@@ -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 { if ui[k] != *v {
return Err(format!("{k}: got {}, want {v}", ui[k])); return Err(format!("{k}: got {}, want {v}", ui[k]));
} }
+209 -182
View File
@@ -1,6 +1,6 @@
//! The timeline (bottom): features in order, the rollback marker, errors and warnings. //! 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 serde_json::json;
use solvecraft_engine::doc::FeatureKind; use solvecraft_engine::doc::FeatureKind;
@@ -106,195 +106,222 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
x += 24.0; x += 24.0;
} }
x += 14.0; x += 14.0;
let feats: Vec<_> = app // The features scroll horizontally in the space right of the playback controls.
.session let strip = Rect::from_min_max(pos2(x, r.top()), r.right_bottom());
.doc ui.scope_builder(UiBuilder::new().max_rect(strip), |ui| {
.features egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| {
.iter() let p = ui.painter().clone();
.map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), f.kind.clone())) let mut x = ui.max_rect().left();
.collect(); let origin = x;
// The feature whose body is under the cursor in the viewport. let feats: Vec<_> = app
let st = app.session.world_state(); .session
let from_view = match &app.viewport.hover { .doc
Some(crate::viewport::Hit::Face { body, .. } | crate::viewport::Hit::Edge { body, .. } | crate::viewport::Hit::Vertex { body, .. }) => { .features
st.body(body).map(|b| b.feature) .iter()
} .map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), f.kind.clone()))
_ => None, .collect();
}; // The feature whose body is under the cursor in the viewport.
let drag_id = ui.id().with("tl_drag"); let st = app.session.world_state();
let mut drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id)); let from_view = match &app.viewport.hover {
let pointer = ui.input(|i| i.pointer.hover_pos()); Some(
let mut xs: Vec<f32> = Vec::new(); crate::viewport::Hit::Face { body, .. } | crate::viewport::Hit::Edge { body, .. } | crate::viewport::Hit::Vertex { body, .. },
let mut hovered: Option<u64> = None; ) => st.body(body).map(|b| b.feature),
let mut edit: Option<u64> = None; _ => None,
let mut marker_rect: Option<Rect> = None; };
for (i, (id, name, icon, suppressed, ty, kind)) in feats.iter().enumerate() { let drag_id = ui.id().with("tl_drag");
if i == marker { let mut drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id));
marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0))); let pointer = ui.input(|i| i.pointer.hover_pos());
x += 10.0; let mut xs: Vec<f32> = Vec::new();
} let mut hovered: Option<u64> = None;
xs.push(x); let mut edit: Option<u64> = None;
let br = Rect::from_min_size(pos2(x, r.top() + 6.0), vec2(28.0, 28.0)); let mut marker_rect: Option<Rect> = None;
crate::scenario::publish_handle(&format!("timeline:{name}"), br.center()); for (i, (id, name, icon, suppressed, ty, kind)) in feats.iter().enumerate() {
let resp = ui.interact(br, ui.id().with(("feat", *id)), Sense::click_and_drag()); if i == marker {
let res = app.session.model.result(*id); marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0)));
let err = res.and_then(|r| r.error.clone()); x += 10.0;
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 { xs.push(x);
let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]})); 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;
} }
} let end = x;
if resp.double_clicked() { if marker >= feats.len() {
edit = Some(*id); marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0)));
}
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 // The history marker: drag it to roll the model back or forward.
&& ui.button("Edit Profile Sketch").clicked() 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); let slot = slot_at(&xs, end, pp.x);
ui.close(); 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() { ui.data_mut(|d| match drag {
app.dialog = Some(crate::dialogs::Dialog::rename(*id, name)); Some(dg) => {
ui.close(); d.insert_temp(drag_id, dg);
} }
if ui.button(if *suppressed { "Unsuppress Features" } else { "Suppress Features" }).clicked() { None => d.remove::<Drag>(drag_id),
let _ = app.run("timeline.suppress", json!({"feature": id})); });
ui.close(); app.viewport.hover_feature = hovered;
} if let Some(id) = edit {
if ui.button("Roll History Marker Here").clicked() { app.edit_feature(id);
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;
}
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);
}
}); });
} }
+5 -2
View File
@@ -161,7 +161,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
} }
if item(ui, "Open…", "Ctrl+O") { if item(ui, "Open…", "Ctrl+O") {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { 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; 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). /// Global keyboard shortcuts (when no text field has focus).
pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { 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. // The Keyboard Shortcuts window is waiting for a key: it is not a shortcut.
if crate::keymap::capturing(app) { if crate::keymap::capturing(app) {
return; return;
@@ -554,7 +557,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
Key::Comma => crate::prefs::open_at(app, 0), Key::Comma => crate::prefs::open_at(app, 0),
Key::O => { Key::O => {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { 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
View File
@@ -43,7 +43,7 @@ pub struct ViewportState {
pub mouse: Option<Pos2>, pub mouse: Option<Pos2>,
/// Box selection being dragged. /// Box selection being dragged.
pub boxsel: Option<BoxSel>, 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>, pub hover_feature: Option<u64>,
/// Bodies whose browser row is under the cursor: highlighted the same way. /// Bodies whose browser row is under the cursor: highlighted the same way.
pub hover_bodies: Vec<String>, pub hover_bodies: Vec<String>,
@@ -51,6 +51,8 @@ pub struct ViewportState {
pub orbit_pivot: Option<Vec3>, pub orbit_pivot: Option<Vec3>,
/// Continuous orbit: the view turns about the vertical until the next click or key. /// Continuous orbit: the view turns about the vertical until the next click or key.
pub spin: bool, 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, pub build_ms: f64,
/// The right-click menu, open at this screen position. /// The right-click menu, open at this screen position.
pub context_menu: Option<Pos2>, pub context_menu: Option<Pos2>,
@@ -860,13 +862,55 @@ pub fn section_plane(app: &SolveApp) -> Option<(Vec3, Vec3)> {
app.session.section 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 /// The box around what is selected (bodies, faces, edges, vertices, sketch curves and the
/// bodies of selected features), where it is shown. /// bodies of selected features), where it is shown.
pub fn selection_bounds(app: &SolveApp) -> Option<solvecraft_engine::geom::Aabb3> { pub fn selection_bounds(app: &SolveApp) -> Option<solvecraft_engine::geom::Aabb3> {
let st = app.session.world_state(); let st = app.session.world_state();
let mut b = solvecraft_engine::geom::Aabb3::EMPTY; let mut b = solvecraft_engine::geom::Aabb3::EMPTY;
for s in &app.session.selection { for s in app.session.selection.iter().flat_map(|item| displayed_selection(app, item)) {
match s { match &s {
Sel::Body { name } => { Sel::Body { name } => {
if let Some(x) = st.body(name) { if let Some(x) = st.body(name) {
b = b.union(&x.mesh().bounds()); 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::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 } => { Sel::SketchCurve { id } => {
for ss in &st.sketches { for ss in &st.sketches {
if let Some(i) = ss.sketch.curve_index(id) { 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 st = app.session.world_state();
let mut sel = Vec::new(); let mut sel = Vec::new();
for item in app.highlighted() { 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)) { if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) {
sel.push(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. // Timeline modifiers light only their resulting faces; browser rows light whole bodies.
if app.viewport.hover_feature.is_some() || !app.viewport.hover_bodies.is_empty() { 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 k = t.hover_face_lift;
let lit = let c = body_rgb(app, &b.name);
|b: &solvecraft_engine::doc::ModelBody| Some(b.feature) == app.viewport.hover_feature || app.viewport.hover_bodies.contains(&b.name); let m = b.mesh();
for b in st.bodies.iter().filter(|b| lit(b) && !app.session.visibility.hidden_bodies.contains(&b.name)) { for &f in &faces {
let c = body_rgb(app, &b.name); if !sel.iter().any(|x| {
let m = b.mesh(); matches!(x, Sel::Face { body, index, .. } if body == &b.name && *index == f) || matches!(x, Sel::Body { name } if name == &b.name)
for f in 0..b.body.face_count() { }) {
face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); 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) { for (ei, e) in m.edges.iter().enumerate() {
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5); 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 (dx, dy) = (delta.x as f64, delta.y as f64);
let mode = if middle || secondary_down { let mode = if middle || secondary_down {
crate::prefs::nav_mode(app, middle, secondary_down, mods) 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 app.viewport.nav
} else { } else {
None 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 // Suppress hover for the whole navigation gesture, including frames between pointer
// events. Testing delta made the highlight reappear whenever a held drag paused. // 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. // Resolve it before rendering so the GPU scene and screen overlays use the same hit.
let navigating = let navigating = scroll != 0.0
scroll != 0.0 || middle || secondary_down || (app.viewport.nav.is_some() && primary_down) || app.viewport.spin || app.cam_anim.is_some(); || 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 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))) }; 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()); 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)) (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) { fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
let t = Tokens::get(); let t = Tokens::get();
let c = pos2(rect.right() - 80.0, rect.top() + 80.0); 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 (r, u, b) = cube_basis(&app.cam);
let (wr, wu, _) = app.cam.basis(); let (wr, wu, _) = app.cam.basis();
let painter = ui.painter_at(rect); let painter = ui.painter_at(rect);
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(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 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. // 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. // The corner is in the cube's frame, the axes are the world's.
let o = Vec3::new(-1.0, -1.0, -1.0); let 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); 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))); 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. // Back to front: the faces and facets turned toward the camera.
for (i, pts) in shapes.iter().rev() { for (i, pts) in shapes.iter().rev() {
let tg = &targets[*i]; 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. // Fades out as the face turns edge-on.
let ink = (n.dot(b) / 0.3).clamp(0.0, 1.0) as f32; let ink = (n.dot(b) / 0.3).clamp(0.0, 1.0) as f32;
if !tg.corner && ink > 0.05 { 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()))); 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 { if let Some(dir) = clicked {
let mut to = app.cam.looking_from(app.cam.from_frame(dir)); let mut to = app.cam.looking_from(app.cam.from_frame(dir));
// TOP and BOTTOM turn to the standard plan views (front edge at the bottom). // Keep the nearest square heading when looking straight down or up.
if dir.x == 0.0 && dir.y == 0.0 { 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); 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). /// 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; 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 /// 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) { 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) } 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, /// How much of a face the widest face label spans.
/// so it turns and foreshortens with the face. const CUBE_LABEL_WIDTH: f32 = 0.9;
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)); /// 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 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 tess = egui::epaint::Tessellator::new(ctx.pixels_per_point(), options, ctx.fonts(|f| f.font_image_size()), vec![]);
let mut mesh = egui::Mesh::default(); let mut mesh = egui::Mesh::default();
@@ -2407,6 +2536,80 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
mod tests { mod tests {
use super::*; 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>) { fn hover_frame(app: &mut SolveApp, ctx: &egui::Context, events: Vec<egui::Event>) {
let selected = app.highlighted(); let selected = app.highlighted();
let _ = ctx.run_ui( let _ = ctx.run_ui(
@@ -2731,6 +2934,9 @@ mod tests {
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(0.0)); assert_eq!(crate::scenario::count_of("cube_highlights"), Some(0.0));
h.call("ui.click", json!({"x": p.x, "y": p.y})); 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"); 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.prefs", json!({"button": "cancel"}));
h.call("ui.move", json!({"x": p.x, "y": p.y})); h.call("ui.move", json!({"x": p.x, "y": p.y}));
assert_eq!(crate::scenario::count_of("cube_highlights"), Some(1.0)); 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); 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] #[test]
fn section_outlines_follow_the_cut() { fn section_outlines_follow_the_cut() {
let mut s = solvecraft_engine::Session::default(); let mut s = solvecraft_engine::Session::default();
@@ -2815,7 +3039,7 @@ mod tests {
#[test] #[test]
fn view_cube_labels_read_upright_in_their_own_view() { fn view_cube_labels_read_upright_in_their_own_view() {
for tg in cube_targets().iter().filter(|t| !t.corner) { 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); let mut cam = solvecraft_engine::render::Camera::default().looking_from(tg.dir);
if tg.dir.z.abs() > 0.5 { if tg.dir.z.abs() > 0.5 {
cam.yaw = 0.0; 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. /// From any direction, each label is printed within its face.
#[test] #[test]
fn view_cube_labels_stay_on_their_faces() { fn view_cube_labels_stay_on_their_faces() {
@@ -2833,6 +3103,7 @@ mod tests {
let (mut worst, mut drawn) = (f32::NEG_INFINITY, 0); let (mut worst, mut drawn) = (f32::NEG_INFINITY, 0);
let _ = ctx.run_ui(egui::RawInput::default(), |ui| { let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let targets = cube_targets(); let targets = cube_targets();
let size = cube_label_size(ui.ctx(), &face_labels(&targets));
let c = pos2(100.0, 100.0); let c = pos2(100.0, 100.0);
for dir in orbit_directions().step_by(7) { for dir in orbit_directions().step_by(7) {
let (r, u, b) = basis_from(dir); let (r, u, b) = basis_from(dir);
@@ -2841,7 +3112,7 @@ mod tests {
if tg.corner { if tg.corner {
continue; 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) }; let outside = if point_in_poly(p, &pts) { 0.0 } else { poly_distance(p, &pts) };
worst = worst.max(outside); worst = worst.max(outside);
drawn += 1; drawn += 1;
+2
View File
@@ -80,6 +80,7 @@ fn home_tab_and_file_recent_open_designs() {
{"expect":{"home":false,"bodies":1}}, {"expect":{"home":false,"bodies":1}},
{"click":{"handle":"tab:home"}}, {"click":{"handle":"tab:home"}},
{"click":{"handle":"home:recent:0"}}, {"click":{"handle":"home:recent:0"}},
{"until":{"file_busy":false}},
{"expect":{"home":false,"bodies":1,"documents":2}}, {"expect":{"home":false,"bodies":1,"documents":2}},
{"click":{"handle":"appbar:folder"}}, {"click":{"handle":"appbar:folder"}},
{"widget":"Open Recent"} {"widget":"Open Recent"}
@@ -87,6 +88,7 @@ fn home_tab_and_file_recent_open_designs() {
.unwrap(); .unwrap();
h.run(&json!([ h.run(&json!([
{"widget":path}, {"widget":path},
{"until":{"file_busy":false}},
{"expect":{"home":false,"bodies":1,"documents":3}} {"expect":{"home":false,"bodies":1,"documents":3}}
])) ]))
.unwrap(); .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" "widget": "Insert STEP\u2026"
}, },
{"until": {"file_busy": false}},
{ {
"debug": 1 "debug": 1
}, },
@@ -18,6 +18,7 @@
{"expect": {"bodies": 0}}, {"expect": {"bodies": 0}},
{"choose_file": "Sample Plate.solvecraft"}, {"choose_file": "Sample Plate.solvecraft"},
{"key": "O", "cmd": true}, {"key": "O", "cmd": true},
{"until": {"file_busy": false}},
{"debug": 1}, {"debug": 1},
{"expect": {"bodies": 1, "errors": 0, "param": {"width": 100}}}, {"expect": {"bodies": 1, "errors": 0, "param": {"width": 100}}},
{"shot": "s15_reopened"} {"shot": "s15_reopened"}
@@ -208,7 +208,7 @@
"start": "solid.extrude" "start": "solid.extrude"
}, },
{ {
"widget": "New Body" "widget": "Join"
}, },
{ {
"widget": "Cut" "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]}}}
]
+6
View File
@@ -12,6 +12,12 @@ it.
| Construct | offset plane, plane at angle | | Construct | offset plane, plane at angle |
| Inspect | measure, section analysis, interference, curvature comb and map, zebra, draft, minimum radius, isocurves, centre of mass | | 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 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 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. ball). See the roadmap's known limitations for the rest.
+4 -2
View File
@@ -15,8 +15,10 @@
- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. The - **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 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 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 change the view: a cube face turns to look straight at it (TOP and BOTTOM keep the nearest
iso view, and the house goes home. Right-click for the marking menu: eight commands around the cursor, plus a 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. menu for whatever is under it.
- **Dialogs** dock on the right with a live preview. Most features also show drag handles and a - **Dialogs** dock on the right with a live preview. Most features also show drag handles and a
value box in the viewport. value box in the viewport.
+1
View File
@@ -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.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.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.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.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.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 | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text |
+72
View File
@@ -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 # Viewport performance
Measured with the control channel (`ui.inspect` `frame_ms`: CPU time of one UI frame, which 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 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 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. 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
View File
@@ -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 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. 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 ## Choose and prepare the version
1. Fetch `origin` and its tags. Inspect the previous published release and the changes since 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 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 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 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 ## Repository setup
+68
View File
@@ -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"
+24
View File
@@ -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"