mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 10:35:58 +00:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c90070c497 | |||
| 5ec6d3562e | |||
| 9508ee749c | |||
| a0f456d4f4 | |||
| 9a22d9df92 | |||
| b8bd43f797 | |||
| 5d5ff44db2 | |||
| 448ea769eb | |||
| 49d6682fe7 | |||
| d57e90a32a | |||
| 52d2936f94 | |||
| 365879c722 | |||
| ba0f683e89 | |||
| e5ef451214 | |||
| b314e3f175 | |||
| cf1ab075ec | |||
| 03790f5509 | |||
| be095132b9 | |||
| 8b60d77dad | |||
| 5f45f0789f | |||
| 4812ec6c0b | |||
| b5e74030c0 | |||
| 9d3cbb4acc | |||
| fd4d38d31e | |||
| 535bd5dfff | |||
| 794a394b28 | |||
| 9dd901649c | |||
| d1cdc4d061 | |||
| 62746045af | |||
| be18bd6573 | |||
| a7fe0429f0 | |||
| 72bc6fb010 | |||
| d1428ef27c | |||
| 7318b98b8d | |||
| 132acc9863 | |||
| 0e2d8efd5c | |||
| a5729f1f97 | |||
| eac7306d59 | |||
| 183301bce9 | |||
| 721c8b96a4 | |||
| 1e5356e056 | |||
| 1c23620bd9 | |||
| 2dd540397d | |||
| 7044399da1 | |||
| 7a98800699 | |||
| 88b4c5b883 | |||
| a3a1d15d02 | |||
| 61f86d073d | |||
| 307ef11c0a | |||
| ab574952b1 | |||
| 67c79ec91c | |||
| 80f5a58806 | |||
| 0e9ddef4d8 | |||
| f967b8d3ff | |||
| 6ab39679e7 | |||
| d7f9f0a516 | |||
| 1bf377c14b | |||
| ccde7a4837 | |||
| 5d69452db3 | |||
| 322a357dee | |||
| abb9d7460f | |||
| 141f3acc85 | |||
| a00153fc99 | |||
| 3a416ccb7d | |||
| 4c8f080691 | |||
| 06be3500f8 | |||
| 0a1ab40ea0 | |||
| eef2430bc7 | |||
| a1a6e2b624 | |||
| 6b146942bd | |||
| ce61aef4a6 | |||
| 7e0618d64b | |||
| 90fde44c32 | |||
| cbdc4fe0d8 | |||
| 3b650cac5f | |||
| 50a74ac66d | |||
| 9c0632b07c | |||
| bf2b64f597 | |||
| 3a1f05bacf | |||
| 3966b8b3b9 | |||
| bd8e8c4809 | |||
| 4562c8e5ba | |||
| d2bc8e9847 | |||
| 8601611d0c |
+21
-58
@@ -1,62 +1,25 @@
|
||||
SolveCraft is an open-source parametric 3D CAD application in pure Rust: sketch on a plane,
|
||||
constrain and dimension the sketch, turn profiles into solids on a feature timeline, and change a
|
||||
parameter to watch the timeline rebuild. This is the first public build, a **pre-release**: it is
|
||||
early, and we'd like your bug reports.
|
||||
SolveCraft is open-source parametric 3D CAD in Rust: sketch, constrain, extrude, and change a
|
||||
parameter to watch the timeline rebuild. [Documentation](https://bherbruck.github.io/solvecraft/)
|
||||
· [try it in the browser](https://bherbruck.github.io/solvecraft/app/) ·
|
||||
[contributing](https://github.com/bherbruck/solvecraft/blob/main/CONTRIBUTING.md) ·
|
||||
[issues](https://github.com/bherbruck/solvecraft/issues). AI agents can model with it over MCP:
|
||||
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solvecraft/guide/mcp.html)).
|
||||
|
||||
- **Documentation:** https://bherbruck.github.io/solvecraft/
|
||||
- **Try it in the browser:** https://bherbruck.github.io/solvecraft/app/ (`?sample` opens a sample part)
|
||||
- **Contributing:** [CONTRIBUTING.md](https://github.com/bherbruck/solvecraft/blob/main/CONTRIBUTING.md)
|
||||
- **Bugs and ideas:** [issues](https://github.com/bherbruck/solvecraft/issues)
|
||||
**Which file?**
|
||||
- **Windows:** `SolveCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
|
||||
`-portable.zip` (x64, x86 and arm64).
|
||||
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag SolveCraft onto Applications.
|
||||
The CLI is in `solvecraft-cli-{version}-macos-universal.zip`.
|
||||
- **Linux** (x86_64 and aarch64, glibc 2.35+): the `.AppImage` (`chmod +x`, then run it), the
|
||||
`.deb` or `.rpm`, the `.flatpak` (`flatpak install --user <file>`), or the `.tar.gz`.
|
||||
- **FreeBSD:** `tar -xzf solvecraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
|
||||
- **Web:** `solvecraft-web-{version}.zip` is a static site to host yourself.
|
||||
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`.
|
||||
|
||||
## What works
|
||||
|
||||
- Sketches with SolveCraft's own constraint solver: lines, rectangles, circles, arcs, polygons,
|
||||
slots, splines, text; constraints and parameter-driven dimensions; automatic profiles.
|
||||
- Solid features on a parametric timeline that rebuilds incrementally: extrude, revolve, sweep,
|
||||
loft, fillet and chamfer, shell, draft, holes and threads, patterns, mirror, combine, split,
|
||||
primitives. Faces and edges are referenced by persistent names, so upstream edits don't
|
||||
scramble downstream features.
|
||||
- Parameters with unit-aware expressions, and configurations.
|
||||
- Components, joints, motion studies, sheet metal with flat patterns, and a plastic-enclosure
|
||||
subset (bosses, lips, snap fits).
|
||||
- Files: `.solvecraft` designs; STEP (AP203/214/242) and IGES import and export; 3MF, STL and OBJ;
|
||||
DXF. Atomic saves, autosave and crash recovery.
|
||||
- 76 of 76 reference parts built in Fusion rebuild in SolveCraft with the same volume, area and
|
||||
topology.
|
||||
|
||||
## Built for AI agents
|
||||
|
||||
Everything the UI does is a command with JSON parameters, so an agent can do it too.
|
||||
`solvecraft-cli mcp` is a Model Context Protocol server: headless, or bridged to the running app
|
||||
(`solvecraft --control 7878`, then `solvecraft-cli mcp --connect 127.0.0.1:7878`) so you can
|
||||
watch the agent model. In Claude Code: `claude mcp add solvecraft -- solvecraft-cli mcp`. The
|
||||
attached video shows an agent building an enclosure through the UI and MCP.
|
||||
|
||||
## Downloads
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `solvecraft-v0.1.0-windows-x86_64.zip` | Windows 10/11: `solvecraft.exe` (the app) and `solvecraft-cli.exe` |
|
||||
| `solvecraft-v0.1.0-linux-x86_64.tar.gz` | Linux x86_64 (glibc 2.35 or newer): `solvecraft` and `solvecraft-cli` |
|
||||
| `SolveCraft-v0.1.0-x86_64.AppImage` | Linux, the app as one file: `chmod +x` and run it |
|
||||
| `solvecraft-v0.1.0-macos-universal.zip` | macOS 11 or newer, Apple silicon and Intel: `SolveCraft.app` and `solvecraft-cli` |
|
||||
| `solvecraft-v0.1.0-web.zip` | the web app, to host yourself (any static server; HTTPS or localhost for saving files) |
|
||||
| `SHA256SUMS` | checksums: `sha256sum -c SHA256SUMS` |
|
||||
|
||||
### The binaries are not signed
|
||||
|
||||
- **macOS:** the first time, right-click (or Control-click) `SolveCraft.app` and choose **Open**,
|
||||
then **Open** again in the dialog. If macOS says the app is damaged, run
|
||||
`xattr -dr com.apple.quarantine SolveCraft.app`. Do the same for `solvecraft-cli`.
|
||||
- **Windows:** SmartScreen may say "Windows protected your PC". Click **More info**, then
|
||||
**Run anyway**.
|
||||
|
||||
## Known limitations
|
||||
|
||||
SolveCraft is early. Some fillet, chamfer and boolean configurations still fail (with an error,
|
||||
never a crash); surfaces, drawings, CAM, mesh tools and simulation are out of scope for now; the
|
||||
model is Z-up only, so STEP files from Y-up tools open lying on their backs. The full list is in
|
||||
The builds aren't signed yet. On macOS, right-click SolveCraft in Applications and choose
|
||||
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolveCraft.app`).
|
||||
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
|
||||
Licences are built in (Help ▸ About ▸ Licences, `solvecraft-cli licences`); the packages also
|
||||
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
|
||||
[ROADMAP.md](https://github.com/bherbruck/solvecraft/blob/main/ROADMAP.md#known-limitations).
|
||||
|
||||
SolveCraft is MIT OR Apache-2.0. It is a clean-room implementation and is not affiliated with
|
||||
Autodesk; Fusion is a trademark of Autodesk, Inc.
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Package the Linux release build: a tar.gz with both binaries, and an AppImage of the desktop app.
|
||||
# Usage: .github/release/package-linux.sh <tag> (from the repository root, after
|
||||
# `cargo build --release -p solvecraft -p solvecraft-cli`). Writes out/.
|
||||
set -euo pipefail
|
||||
tag=${1:?usage: package-linux.sh <tag>}
|
||||
bin=${CARGO_TARGET_DIR:-target}/release
|
||||
name="solvecraft-$tag-linux-x86_64"
|
||||
rm -rf stage out && mkdir -p "stage/$name" out
|
||||
cp "$bin/solvecraft" "$bin/solvecraft-cli" README.md LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md "stage/$name/"
|
||||
tar -C stage -czf "out/$name.tar.gz" "$name"
|
||||
|
||||
# AppImage. The icon is rendered by SolveCraft itself from the bracket example, so no image file
|
||||
# is committed.
|
||||
app=stage/SolveCraft.AppDir
|
||||
mkdir -p "$app/usr/bin"
|
||||
cp "$bin/solvecraft" "$bin/solvecraft-cli" "$app/usr/bin/"
|
||||
"$bin/solvecraft-cli" snapshot examples/bracket.json --out "$app/solvecraft.png" --width 256 --height 256 >/dev/null
|
||||
cat > "$app/solvecraft.desktop" <<'DESKTOP'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=SolveCraft
|
||||
Comment=Parametric 3D CAD
|
||||
Exec=solvecraft %f
|
||||
Icon=solvecraft
|
||||
Categories=Graphics;Engineering;
|
||||
MimeType=model/step;
|
||||
DESKTOP
|
||||
cat > "$app/AppRun" <<'APPRUN'
|
||||
#!/bin/sh
|
||||
here="$(dirname "$(readlink -f "$0")")"
|
||||
exec "$here/usr/bin/solvecraft" "$@"
|
||||
APPRUN
|
||||
chmod +x "$app/AppRun"
|
||||
tool=stage/appimagetool
|
||||
curl -sSfL -o "$tool" https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
chmod +x "$tool"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 "$tool" --no-appstream "$app" "out/SolveCraft-$tag-x86_64.AppImage"
|
||||
ls -l out
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Package the macOS release build: a universal (arm64 + x86_64) SolveCraft.app and CLI in one zip.
|
||||
# Usage: .github/release/package-macos.sh <tag> (from the repository root, after building both
|
||||
# aarch64-apple-darwin and x86_64-apple-darwin). Writes out/. The app is not signed.
|
||||
set -euo pipefail
|
||||
tag=${1:?usage: package-macos.sh <tag>}
|
||||
version=${tag#v}
|
||||
t=${CARGO_TARGET_DIR:-target}
|
||||
name="solvecraft-$tag-macos-universal"
|
||||
rm -rf stage out && mkdir -p "stage/$name" out
|
||||
app="stage/$name/SolveCraft.app"
|
||||
mkdir -p "$app/Contents/MacOS"
|
||||
lipo -create -output "$app/Contents/MacOS/solvecraft" "$t/aarch64-apple-darwin/release/solvecraft" "$t/x86_64-apple-darwin/release/solvecraft"
|
||||
lipo -create -output "stage/$name/solvecraft-cli" "$t/aarch64-apple-darwin/release/solvecraft-cli" "$t/x86_64-apple-darwin/release/solvecraft-cli"
|
||||
cat > "$app/Contents/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key><string>SolveCraft</string>
|
||||
<key>CFBundleDisplayName</key><string>SolveCraft</string>
|
||||
<key>CFBundleIdentifier</key><string>io.github.bherbruck.solvecraft</string>
|
||||
<key>CFBundleExecutable</key><string>solvecraft</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleVersion</key><string>$version</string>
|
||||
<key>CFBundleShortVersionString</key><string>$version</string>
|
||||
<key>LSMinimumSystemVersion</key><string>11.0</string>
|
||||
<key>NSHighResolutionCapable</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
cp README.md LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md "stage/$name/"
|
||||
lipo -info "$app/Contents/MacOS/solvecraft"
|
||||
(cd stage && ditto -c -k --keepParent "$name" "../out/$name.zip")
|
||||
ls -l out
|
||||
+174
-8
@@ -1,10 +1,18 @@
|
||||
# Every pull request must pass `cargo xtask ci`: fmt, clippy -D warnings, tests in release mode,
|
||||
# the asset and layer checks, and the wasm build check.
|
||||
# Fast checks always run; native builds run for code/CI changes.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cross_compile:
|
||||
description: "Also check WebAssembly and RISC-V (before a release)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
@@ -14,14 +22,172 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
changes:
|
||||
name: Select checks
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
native: ${{ steps.paths.outputs.native }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: paths
|
||||
name: Detect code and CI changes
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
native=false
|
||||
if [ "$EVENT_NAME" = workflow_dispatch ]; then
|
||||
native=true
|
||||
else
|
||||
# Compare both old and new paths of renames; unknown paths run native checks.
|
||||
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/ci-paths"
|
||||
while IFS= read -r -d '' path; do
|
||||
case "$path" in
|
||||
.github/workflows/ci.yml) native=true ;;
|
||||
*.rs|Cargo.toml|Cargo.lock|*/Cargo.toml|.cargo/*|rust-toolchain*) native=true ;;
|
||||
docs/*|*.md|.github/*|packaging/*|LICENSE*|NOTICE|THIRD-PARTY-LICENSES.txt) ;;
|
||||
*) native=true ;;
|
||||
esac
|
||||
done < "$RUNNER_TEMP/ci-paths"
|
||||
fi
|
||||
echo "native=$native" >> "$GITHUB_OUTPUT"
|
||||
|
||||
checks:
|
||||
name: Format and repository checks
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: repo-checks
|
||||
- name: Format
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Asset attribution
|
||||
run: cargo xtask assets
|
||||
- name: Licences
|
||||
run: cargo xtask licences --check
|
||||
- name: Dependency layers
|
||||
run: cargo xtask layers
|
||||
|
||||
clippy:
|
||||
name: Native Linux Clippy
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-clippy
|
||||
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
|
||||
|
||||
tests:
|
||||
name: Native Linux tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-tests
|
||||
- name: Compile tests
|
||||
run: cargo test --workspace --profile ci --locked --no-run
|
||||
- name: Run tests
|
||||
run: cargo test --workspace --profile ci --locked
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
needs: [changes, checks, clippy, tests]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
env:
|
||||
CHANGES: ${{ needs.changes.result }}
|
||||
CHECKS: ${{ needs.checks.result }}
|
||||
NATIVE: ${{ needs.changes.outputs.native }}
|
||||
CLIPPY: ${{ needs.clippy.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
run: |
|
||||
test "$CHANGES" = success
|
||||
test "$CHECKS" = success
|
||||
if [ "$NATIVE" = true ]; then
|
||||
test "$CLIPPY" = success
|
||||
test "$TESTS" = success
|
||||
fi
|
||||
|
||||
wasm:
|
||||
name: Compile WebAssembly (manual)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo xtask ci
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: wasm
|
||||
- run: cargo xtask wasm
|
||||
|
||||
riscv64-gates:
|
||||
name: Compile RISC-V (manual)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: riscv64
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
# Ubuntu's regular archive serves amd64; RISC-V packages live in ports.
|
||||
# The 24.04 runner uses deb822 sources, so scope every Ubuntu stanza.
|
||||
sudo sed -i '/^Architectures:/d; /^Types:/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
sudo tee /etc/apt/sources.list.d/riscv64.sources >/dev/null <<'EOF'
|
||||
Types: deb
|
||||
URIs: http://ports.ubuntu.com/ubuntu-ports
|
||||
Suites: noble noble-updates noble-backports noble-security
|
||||
Components: main restricted universe multiverse
|
||||
Architectures: riscv64
|
||||
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
|
||||
EOF
|
||||
sudo dpkg --add-architecture riscv64
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-riscv64-linux-gnu libasound2-dev:riscv64 libxkbcommon-dev:riscv64 \
|
||||
libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 libxi-dev:riscv64 libgl1-mesa-dev:riscv64
|
||||
- name: Cargo check on RISC-V
|
||||
env:
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: 1
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts"
|
||||
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
|
||||
@@ -0,0 +1,162 @@
|
||||
name: Publish Flatpak repository to Pages
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Published GitHub release tag to publish (for example, v1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: flatpak-pages-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Flatpak repository
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
|
||||
APP_ID: ai.storyteller.solvecraft
|
||||
steps:
|
||||
- name: Configure Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
|
||||
- name: Install Flatpak repository tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends flatpak ostree
|
||||
|
||||
- name: Download and validate release bundles
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="$RELEASE_TAG"
|
||||
if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::Release tag '$tag' is not strict SemVer prefixed with v"
|
||||
exit 1
|
||||
fi
|
||||
published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \
|
||||
'select(.draft == false and .published_at != null) | .tag_name')"
|
||||
if [[ "$published_tag" != "$tag" ]]; then
|
||||
echo "::error::$tag is not a published release in $GITHUB_REPOSITORY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${tag#v}"
|
||||
mkdir -p bundles
|
||||
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern SHA256SUMS.txt --dir bundles
|
||||
for arch in x86_64 aarch64; do
|
||||
asset="solvecraft-$version-linux-$arch.flatpak"
|
||||
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$asset" --dir bundles
|
||||
test -s "bundles/$asset"
|
||||
(
|
||||
cd bundles
|
||||
awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \
|
||||
SHA256SUMS.txt | sha256sum --check -
|
||||
)
|
||||
done
|
||||
test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2
|
||||
|
||||
for arch in x86_64 aarch64; do
|
||||
asset="bundles/solvecraft-$version-linux-$arch.flatpak"
|
||||
validation_repo="$(mktemp -d)"
|
||||
ostree --repo="$validation_repo" init --mode=archive-z2
|
||||
flatpak build-import-bundle "$validation_repo" "$asset"
|
||||
expected_ref="app/$APP_ID/$arch/stable"
|
||||
ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || {
|
||||
echo "::error::$(basename "$asset") does not contain $expected_ref"
|
||||
exit 1
|
||||
}
|
||||
rm -rf "$validation_repo"
|
||||
done
|
||||
|
||||
- name: Build repository and site
|
||||
shell: bash
|
||||
env:
|
||||
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
site="$RUNNER_TEMP/site"
|
||||
repo_dir="$site/flatpak/repo"
|
||||
mkdir -p "$repo_dir"
|
||||
ostree --repo="$repo_dir" init --mode=archive-z2
|
||||
|
||||
previous_url="${PAGES_BASE_URL%/}/flatpak/repo"
|
||||
if curl --fail --silent --show-error --location \
|
||||
--output /dev/null "$previous_url/summary"; then
|
||||
ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url"
|
||||
ostree --repo="$repo_dir" pull --mirror previous
|
||||
ostree --repo="$repo_dir" remote delete previous
|
||||
fi
|
||||
|
||||
version="${RELEASE_TAG#v}"
|
||||
for arch in x86_64 aarch64; do
|
||||
flatpak build-import-bundle "$repo_dir" \
|
||||
"bundles/solvecraft-$version-linux-$arch.flatpak"
|
||||
done
|
||||
flatpak build-update-repo \
|
||||
--title="SolveCraft" \
|
||||
--comment='Official SolveCraft releases' \
|
||||
--description='Official Flatpak repository for SolveCraft' \
|
||||
--homepage="https://github.com/$GITHUB_REPOSITORY" \
|
||||
--default-branch=stable \
|
||||
"$repo_dir"
|
||||
|
||||
for arch in x86_64 aarch64; do
|
||||
ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null
|
||||
done
|
||||
test -s "$repo_dir/summary"
|
||||
|
||||
base_url="${PAGES_BASE_URL%/}"
|
||||
repo_url="$base_url/flatpak/repo/"
|
||||
cat >"$site/flatpak/solvecraft.flatpakrepo" <<META
|
||||
[Flatpak Repo]
|
||||
Title=SolveCraft
|
||||
Comment=Official SolveCraft releases
|
||||
Description=Official Flatpak repository for SolveCraft
|
||||
Url=$repo_url
|
||||
Homepage=https://github.com/$GITHUB_REPOSITORY
|
||||
META
|
||||
cat >"$site/flatpak/index.html" <<HTML
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolveCraft Flatpak repository</title></head><body><main><h1>SolveCraft Flatpak repository</h1><p>Latest published release: <strong>$RELEASE_TAG</strong></p><p><a href="solvecraft.flatpakrepo">Add the SolveCraft repository</a></p><pre>flatpak remote-add --if-not-exists solvecraft $base_url/flatpak/solvecraft.flatpakrepo
|
||||
flatpak install solvecraft $APP_ID</pre></main></body></html>
|
||||
HTML
|
||||
cat >"$site/index.html" <<HTML
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolveCraft downloads</title></head><body><main><h1>SolveCraft downloads</h1><p><a href="flatpak/">Flatpak repository</a></p><p><a href="https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG">Release assets</a></p></main></body></html>
|
||||
HTML
|
||||
touch "$site/.nojekyll"
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: ${{ runner.temp }}/site
|
||||
include-hidden-files: true
|
||||
|
||||
deploy:
|
||||
name: Deploy Pages
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
||||
@@ -0,0 +1,53 @@
|
||||
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, the
|
||||
# packaging shell and PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
|
||||
# Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
name: Packaging lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: [".github/workflows/**", "packaging/**"]
|
||||
pull_request:
|
||||
paths: [".github/workflows/**", "packaging/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: packaging-lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: '"$RUNNER_TEMP/actionlint" -color'
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/solvecraft.wxs packaging/windows/solvecraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
desktop-file-validate packaging/linux/ai.storyteller.solvecraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solvecraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solvecraft'"
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
- name: Install mdbook and trunk
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
|
||||
+383
-95
@@ -1,136 +1,424 @@
|
||||
# Release builds: push a tag `v*` (or run it by hand with a tag name) and this builds the desktop
|
||||
# app and the CLI for Linux, Windows and macOS plus the web app, and attaches them to a GitHub
|
||||
# release (a pre-release while the version is 0.x) with SHA256SUMS. Notes:
|
||||
# .github/release-notes.md.
|
||||
# Release pipeline, after PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
#
|
||||
# Push to `release` to build packages and create/update a DRAFT GitHub Release. The version
|
||||
# comes from [workspace.package] in Cargo.toml. An agent verifies the artifacts and publishes
|
||||
# the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases.
|
||||
# A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
|
||||
#
|
||||
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .tar.gz packaging/linux
|
||||
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
|
||||
# macOS universal .dmg, and the CLI as a .zip packaging/macos
|
||||
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
|
||||
# Web a .zip of the static site packaging/web
|
||||
#
|
||||
# A dry run: dispatch with draft=false (the default). Only the release branch may create drafts.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to release (must exist), e.g. v0.1.0"
|
||||
required: true
|
||||
draft:
|
||||
description: "Create/update a draft (release branch only; off: build artifacts only)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
TRUNK_VERSION: v0.21.14
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-22.04 # an older glibc, so the binaries run on older distributions
|
||||
timeout-minutes: 60
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.TAG }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build --release --locked -p solvecraft -p solvecraft-cli
|
||||
- name: Package
|
||||
run: .github/release/package-linux.sh "$TAG"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux
|
||||
path: out/*
|
||||
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.TAG }}
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build --release --locked -p solvecraft -p solvecraft-cli
|
||||
- name: Package
|
||||
shell: pwsh
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve version
|
||||
env:
|
||||
REQUEST_DRAFT: ${{ inputs.draft || false }}
|
||||
run: |
|
||||
$name = "solvecraft-$env:TAG-windows-x86_64"
|
||||
New-Item -ItemType Directory -Force -Path "stage/$name", out | Out-Null
|
||||
Copy-Item target/release/solvecraft.exe, target/release/solvecraft-cli.exe, README.md, LICENSE-MIT, LICENSE-APACHE, NOTICE, ATTRIBUTION.md "stage/$name/"
|
||||
Compress-Archive -Path "stage/$name" -DestinationPath "out/$name.zip"
|
||||
- uses: actions/upload-artifact@v4
|
||||
. packaging/env.sh
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$'; then
|
||||
echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$REQUEST_DRAFT" = true ] && [ "$GITHUB_REF" != refs/heads/release ]; then
|
||||
echo "::error::Draft releases must be built from the release branch"
|
||||
exit 1
|
||||
fi
|
||||
case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
|
||||
echo "### SolveCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
|
||||
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
|
||||
icons:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
name: windows
|
||||
path: out/*
|
||||
key: release-icons
|
||||
- run: cargo build --release --locked -p solvecraft-cli
|
||||
- run: |
|
||||
. packaging/env.sh
|
||||
make_icons target/release/solvecraft-cli icons
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: icons
|
||||
path: icons
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends desktop-file-utils appstream zsync rpm
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft$|usr/bin/solvecraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
|
||||
rpm -qip dist/release/*.rpm
|
||||
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
|
||||
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
|
||||
ldd target/release/solvecraft
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/solvecraft-$SOLVECRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
# Ubuntu's regular archive serves amd64; RISC-V packages live in ports.
|
||||
# The 24.04 runner uses deb822 sources, so scope every Ubuntu stanza.
|
||||
sudo sed -i '/^Architectures:/d; /^Types:/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
sudo tee /etc/apt/sources.list.d/riscv64.sources >/dev/null <<'EOF'
|
||||
Types: deb
|
||||
URIs: http://ports.ubuntu.com/ubuntu-ports
|
||||
Suites: noble noble-updates noble-backports noble-security
|
||||
Components: main restricted universe multiverse
|
||||
Architectures: riscv64
|
||||
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
|
||||
EOF
|
||||
sudo dpkg --add-architecture riscv64
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-riscv64-linux-gnu libasound2-dev:riscv64 libxkbcommon-dev:riscv64 \
|
||||
libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 libxi-dev:riscv64 libgl1-mesa-dev:riscv64
|
||||
- name: Build and package
|
||||
env:
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: 1
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
export CROSS_ARCH=riscv64
|
||||
export CROSS_TARGET=riscv64gc-unknown-linux-gnu
|
||||
export DEB_ARCH=riscv64
|
||||
# We skip nfpm (deb/rpm) and AppImage for now on riscv64
|
||||
bash packaging/linux/package.sh --formats "tar"
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "SOLVECRAFT_VERSION SOLVECRAFT_BUILD_DATE"
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
|
||||
# runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/solvecraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/solvecraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/solvecraft$|/bin/solvecraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solvecraft/THIRD-PARTY-LICENSES\.txt$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
macos:
|
||||
# One universal build: arm64 natively, x86_64 cross-compiled on the same runner, joined by lipo.
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.TAG }}
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: |
|
||||
cargo build --release --locked -p solvecraft -p solvecraft-cli --target aarch64-apple-darwin
|
||||
cargo build --release --locked -p solvecraft -p solvecraft-cli --target x86_64-apple-darwin
|
||||
- name: Package
|
||||
run: .github/release/package-macos.sh "$TAG"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
name: macos
|
||||
path: out/*
|
||||
key: release-macos-universal
|
||||
- name: Build and package (dmg, CLI zip; the dmg is mounted and checked)
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: [version, icons]
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: icons
|
||||
path: icons
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build and package (msi, portable zip)
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.TAG }}
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -sSfL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Build (relative public URL, so it can be served from any path)
|
||||
working-directory: apps/solvecraft-web
|
||||
run: trunk build --release
|
||||
- name: Package
|
||||
run: |
|
||||
name="solvecraft-$TAG-web"
|
||||
mkdir -p out && cp -r dist/web "$name"
|
||||
cp LICENSE-MIT LICENSE-APACHE NOTICE "$name/"
|
||||
zip -qr "out/$name.zip" "$name"
|
||||
- uses: actions/upload-artifact@v4
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
- name: Build and zip
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: web
|
||||
path: out/*
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs: [linux, windows, macos, web]
|
||||
name: Draft GitHub Release or dry-run artifacts
|
||||
needs: [version, linux, flatpak, freebsd, macos, windows, web]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
ref: ${{ env.TAG }}
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: assets
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: assets
|
||||
run: sha256sum * > SHA256SUMS && cat SHA256SUMS
|
||||
- uses: softprops/action-gh-release@v2
|
||||
- name: Checksums and notes
|
||||
run: |
|
||||
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
|
||||
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
|
||||
- name: Create or update the draft release
|
||||
if: github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft)
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
# Refuse existing tags: a draft must point at this build, never an older tag.
|
||||
# matching-refs returns [] for an absent tag and fails on API/auth errors.
|
||||
existing_refs="$(gh api "repos/$GH_REPO/git/matching-refs/tags/$tag" \
|
||||
--jq '.[].ref')"
|
||||
if printf '%s\n' "$existing_refs" | grep -Fxq "refs/tags/$tag"; then
|
||||
echo "::error::Tag $tag already exists. Choose a new version; never move a release tag."
|
||||
exit 1
|
||||
fi
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != true ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version before releasing."
|
||||
exit 1
|
||||
fi
|
||||
# Replace the complete draft asset set, including assets removed in this build.
|
||||
assets="$(gh release view "$tag" --json assets --jq '.assets[].name')"
|
||||
while IFS= read -r asset; do
|
||||
if [ -n "$asset" ]; then gh release delete-asset "$tag" "$asset" --yes; fi
|
||||
done <<<"$assets"
|
||||
gh release edit "$tag" --draft --title "SolveCraft v$VERSION" \
|
||||
--target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
gh release create "$tag" --draft --title "SolveCraft v$VERSION" \
|
||||
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft **SolveCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
- name: Keep the files (dry run)
|
||||
if: github.event_name == 'workflow_dispatch' && !inputs.draft
|
||||
run: cp notes.md artifacts/release-notes.md
|
||||
- if: github.event_name == 'workflow_dispatch' && !inputs.draft
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
tag_name: ${{ env.TAG }}
|
||||
name: SolveCraft ${{ env.TAG }}
|
||||
body_path: .github/release-notes.md
|
||||
prerelease: ${{ startsWith(env.TAG, 'v0.') }}
|
||||
files: assets/*
|
||||
name: release-assets
|
||||
path: artifacts/*
|
||||
|
||||
@@ -71,6 +71,15 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
|
||||
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
|
||||
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
|
||||
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
|
||||
version bumps solely from commit prefixes, overwrite published versions, or push release tags
|
||||
before the draft is verified.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
|
||||
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
|
||||
|
||||
@@ -18,6 +18,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo.gif | SolveCraft contributors | screen recording of SolveCraft itself driven by examples/demo (3x speed) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sample-plate.png | SolveCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/bracket.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/enclosure.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
@@ -39,6 +40,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
| crates/io/src/threemf.rs (bounded zip part reading and writing) | GridCraft `crates/xlsx/src/package.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solvecraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
|
||||
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
|
||||
| .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 |
|
||||
|
||||
## Vendored third-party code
|
||||
|
||||
|
||||
@@ -71,6 +71,15 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
|
||||
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
|
||||
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
|
||||
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
|
||||
version bumps solely from commit prefixes, overwrite published versions, or push release tags
|
||||
before the draft is verified.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
|
||||
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
|
||||
|
||||
+8
-5
@@ -18,7 +18,7 @@ build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](htt
|
||||
```sh
|
||||
git clone https://github.com/bherbruck/solvecraft && cd solvecraft
|
||||
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
|
||||
cargo test --workspace --release # unit, engine and UI-scenario tests
|
||||
cargo test --workspace --profile ci # unit, engine and UI-scenario tests
|
||||
cargo xtask ci # everything a PR must pass (see below)
|
||||
```
|
||||
|
||||
@@ -59,7 +59,8 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
2. **No GPL/LGPL/AGPL code, not even reading it.** That rules out OpenCASCADE, FreeCAD
|
||||
(including planegcs), SolveSpace, CadQuery/OCP, LibreCAD and QCAD. SolveCraft is
|
||||
MIT OR Apache-2.0, and its solver and blends are written from the maths. Permissive
|
||||
dependencies are fine. Note which one you're adding and why in the PR.
|
||||
dependencies are fine. Note which one you're adding and why in the PR, and run
|
||||
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
|
||||
3. **Every asset is original or openly licensed and has a row in
|
||||
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
|
||||
`cargo xtask assets` enforces this.
|
||||
@@ -80,7 +81,7 @@ the most complete description of the house style.
|
||||
|
||||
| What | Where | Run |
|
||||
|---|---|---|
|
||||
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo test --workspace --release` |
|
||||
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo test --workspace --profile ci` |
|
||||
| UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | part of `cargo test` |
|
||||
| Fusion oracle | `cargo xtask oracle` | needs the local oracle data (see below) |
|
||||
|
||||
@@ -101,8 +102,10 @@ press keys, then assert on the result.
|
||||
A new `.json` file in that folder is picked up automatically. To record a bug before it's fixed,
|
||||
put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the bug.
|
||||
|
||||
**Release mode matters.** CI runs the tests with `--release`. truck has debug-only assertions
|
||||
that can fail on valid geometry, so if a test fails only in debug, say so in the PR.
|
||||
**Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables,
|
||||
no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It
|
||||
builds several times faster than `--release`. If a test fails only in a plain debug build, say so
|
||||
in the PR.
|
||||
|
||||
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
|
||||
SolveCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's
|
||||
|
||||
Generated
+14
-13
@@ -3059,7 +3059,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3068,11 +3068,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"solvecraft-ui-egui",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3081,7 +3082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3095,7 +3096,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3111,7 +3112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3119,7 +3120,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
@@ -3131,7 +3132,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3146,7 +3147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3154,7 +3155,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3163,7 +3164,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3174,7 +3175,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3188,7 +3189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4487,7 +4488,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+16
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -72,6 +72,21 @@ opt-level = 2
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
# Tests and lints (`cargo xtask ci`, GitHub CI): quick to build, quick enough to run. Light
|
||||
# optimisation, line tables only (file:line in backtraces), no LTO. Debug assertions are off because
|
||||
# truck's debug-only checks reject valid geometry (seam edges, closed edges); that was the only
|
||||
# reason the tests used to build with --release, which cost most of CI's time.
|
||||
[profile.ci]
|
||||
inherits = "dev"
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
debug-assertions = false
|
||||
overflow-checks = true
|
||||
incremental = false
|
||||
|
||||
[profile.ci.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
|
||||
@@ -5,7 +5,9 @@ Licensed under the MIT License or the Apache License, Version 2.0, at your optio
|
||||
|
||||
Third-party material
|
||||
|
||||
Software dependencies are listed in Cargo.lock. The geometry kernel builds on the truck crates
|
||||
Software dependencies are listed in Cargo.lock; their licences are in THIRD-PARTY-LICENSES.txt
|
||||
(`cargo xtask licences`), which the programs also carry (Help > About > Licences,
|
||||
`solvecraft-cli licences`). The geometry kernel builds on the truck crates
|
||||
(truck-modeling, truck-shapeops, truck-meshalgo, truck-stepio, truck-topology and their
|
||||
dependencies, https://github.com/ricosjp/truck), licensed under the Apache License 2.0 and used
|
||||
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolveCraft
|
||||
@@ -14,6 +16,8 @@ vendor/truck-shapeops/LICENSE. Copyright (c) RICOS Co. Ltd.
|
||||
|
||||
Some code is adapted from sibling projects in the storytold family (CADCraft, GridCraft), which are
|
||||
licensed MIT OR Apache-2.0; ATTRIBUTION.md lists the files.
|
||||
The release packaging (.github/workflows/release.yml, packaging/) is adapted from PhotoCraft
|
||||
(https://github.com/storytold/photocraft), licensed under the Apache License 2.0.
|
||||
|
||||
None of the bundled assets come from third parties: every icon is drawn in code. The per-file
|
||||
list is in ATTRIBUTION.md.
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
# SolveCraft
|
||||
|
||||
SolveCraft is an open-source parametric 3D CAD application written in Rust, in the style of
|
||||
Autodesk Fusion: sketch on a plane, constrain and dimension the sketch, turn profiles into solids
|
||||
with extrude and revolve, round edges, combine bodies, and change a parameter to watch the whole
|
||||
timeline rebuild. It is a clean-room implementation (see [CLAUDE.md](CLAUDE.md)) and part of the
|
||||
storytold "craft" family.
|
||||
**Parametric 3D CAD; an open-source, [clean-room](CLAUDE.md) reimplementation of Autodesk
|
||||
Fusion 360, built in pure Rust.**
|
||||
|
||||

|
||||
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
|
||||
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
|
||||
model through the built-in MCP server. Part of the storytold "craft" family.
|
||||
|
||||

|
||||
|
||||
*An agent builds an electronics enclosure over MCP: sketches on faces, shell, a port cut, one screw boss patterned to four, then width 80 → 100 mm in the Parameters dialog and the timeline rebuilds. 3× speed; [full video](https://github.com/bherbruck/solvecraft/releases/download/v0.1.0/enclosure-demo-v3.mp4), script in [examples/demo](examples/demo).*
|
||||
|
||||
**Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide,
|
||||
developer guide and command reference; sources in [docs/book](docs/book)), and the
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
//! `solvecraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
|
||||
//!
|
||||
//! Each seed runs in its own process (`fuzz-one`) with a time limit, so a hang or a hard crash
|
||||
//! is recorded like any other failure.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::fuzz;
|
||||
use solvecraft_engine::fuzz::boolean_case;
|
||||
|
||||
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// One seed: a design and a boolean case, as JSON on stdout (progress on stderr).
|
||||
pub fn one(args: &[String]) -> Result<(), String> {
|
||||
let seed: u64 = args.first().and_then(|s| s.parse().ok()).ok_or("fuzz-one <seed> [--steps N] [--minimise]")?;
|
||||
let steps = flag(args, "--steps", 8usize);
|
||||
let minimise = args.iter().any(|a| a == "--minimise");
|
||||
// Panics caught inside the kernel: harmless natively, fatal in the web build.
|
||||
static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
std::panic::set_hook(Box::new(|_| {
|
||||
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}));
|
||||
eprintln!("design {seed}");
|
||||
let run = fuzz::run_with(seed, steps, &mut |st| eprintln!("step {}", serde_json::to_string(st).unwrap_or_default()));
|
||||
let mut design = json!({
|
||||
"outcome": run.outcome,
|
||||
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
|
||||
"unsupported": run.unsupported,
|
||||
"rejected": run.rejected,
|
||||
"panics": PANICS.swap(0, std::sync::atomic::Ordering::SeqCst),
|
||||
});
|
||||
if run.outcome.is_bug() {
|
||||
design["script"] = run.script();
|
||||
if minimise {
|
||||
eprintln!("minimise");
|
||||
let m = fuzz::minimise(&run);
|
||||
design["minimal"] = json!({"outcome": m.outcome, "steps": m.steps.iter().map(|s| s.kind).collect::<Vec<_>>(), "script": m.script()});
|
||||
}
|
||||
}
|
||||
let (a, b) = solvecraft_engine::fuzz::boolean_shapes(seed);
|
||||
eprintln!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
|
||||
let b = boolean_case(seed);
|
||||
let bp = PANICS.swap(0, std::sync::atomic::Ordering::SeqCst);
|
||||
println!("{}", json!({"seed": seed, "design": design, "boolean": b, "boolean_panics": bp}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Job {
|
||||
seed: u64,
|
||||
child: std::process::Child,
|
||||
started: Instant,
|
||||
err: std::sync::mpsc::Receiver<String>,
|
||||
last: String,
|
||||
}
|
||||
|
||||
/// Run seeds `--from`…`--from + --count` in `--jobs` processes, `--timeout` seconds each;
|
||||
/// write failing scripts to `--out` and a summary to `--report`.
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let from = flag(args, "--from", 0u64);
|
||||
let count = flag(args, "--count", 100u64);
|
||||
let jobs = flag(args, "--jobs", 4usize).clamp(1, 64);
|
||||
let timeout = Duration::from_secs(flag(args, "--timeout", 120u64));
|
||||
let steps = flag(args, "--steps", 8usize);
|
||||
let out_dir = args.iter().position(|a| a == "--out").and_then(|i| args.get(i + 1)).cloned();
|
||||
let report = args.iter().position(|a| a == "--report").and_then(|i| args.get(i + 1)).cloned();
|
||||
let minimise = args.iter().any(|a| a == "--minimise");
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
if let Some(d) = &out_dir {
|
||||
std::fs::create_dir_all(d).map_err(|e| format!("{d}: {e}"))?;
|
||||
}
|
||||
let mut next = from;
|
||||
let mut running: Vec<Job> = Vec::new();
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
let t0 = Instant::now();
|
||||
while next < from + count || !running.is_empty() {
|
||||
while running.len() < jobs && next < from + count {
|
||||
let mut cmd = Command::new(&exe);
|
||||
cmd.args(["fuzz-one", &next.to_string(), "--steps", &steps.to_string()]);
|
||||
if minimise {
|
||||
cmd.arg("--minimise");
|
||||
}
|
||||
let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| e.to_string())?;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
if let Some(err) = child.stderr.take() {
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
running.push(Job { seed: next, child, started: Instant::now(), err: rx, last: String::new() });
|
||||
next += 1;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let mut i = 0;
|
||||
while i < running.len() {
|
||||
let Some(j) = running.get_mut(i) else { break };
|
||||
while let Ok(l) = j.err.try_recv() {
|
||||
if l.starts_with("step") || l.starts_with("boolean") || l.starts_with("minimise") {
|
||||
j.last = l;
|
||||
}
|
||||
}
|
||||
let done = match j.child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let mut s = String::new();
|
||||
if let Some(mut o) = j.child.stdout.take() {
|
||||
let _ = o.read_to_string(&mut s);
|
||||
}
|
||||
Some(match serde_json::from_str::<Value>(s.trim()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => json!({"seed": j.seed, "crash": format!("exit {status}"), "at": j.last}),
|
||||
})
|
||||
}
|
||||
Ok(None) if j.started.elapsed() > timeout => {
|
||||
let _ = j.child.kill();
|
||||
let _ = j.child.wait();
|
||||
Some(json!({"seed": j.seed, "timeout": timeout.as_secs(), "at": j.last}))
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(e) => Some(json!({"seed": j.seed, "crash": e.to_string(), "at": j.last})),
|
||||
};
|
||||
match done {
|
||||
Some(v) => {
|
||||
record(&v, out_dir.as_deref());
|
||||
results.push(v);
|
||||
running.swap_remove(i);
|
||||
}
|
||||
None => i += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
let summary = summarise(&results, t0.elapsed());
|
||||
println!("{summary}");
|
||||
if let Some(r) = report {
|
||||
std::fs::write(&r, &summary).map_err(|e| format!("{r}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print a failure as it comes in and keep its script.
|
||||
fn record(v: &Value, out_dir: Option<&str>) {
|
||||
let seed = v["seed"].as_u64().unwrap_or(0);
|
||||
let d = &v["design"];
|
||||
let bug = matches!(d["outcome"]["kind"].as_str(), Some("failed" | "panic" | "invalid" | "volume"));
|
||||
if bug || v.get("timeout").is_some() || v.get("crash").is_some() {
|
||||
eprintln!("seed {seed}: {}", if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") });
|
||||
}
|
||||
let bk = v["boolean"]["outcome"].as_str().unwrap_or("ok");
|
||||
if bk != "ok" {
|
||||
eprintln!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]);
|
||||
}
|
||||
if let Some(dir) = out_dir {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(format!("{dir}/results.jsonl")) {
|
||||
let _ = writeln!(f, "{v}");
|
||||
}
|
||||
if bug {
|
||||
let script = if d.get("minimal").is_some() { &d["minimal"]["script"] } else { &d["script"] };
|
||||
let _ = std::fs::write(format!("{dir}/design-{seed}.json"), serde_json::to_string_pretty(script).unwrap_or_default());
|
||||
}
|
||||
if bk != "ok" {
|
||||
let _ = std::fs::write(format!("{dir}/boolean-{seed}.json"), serde_json::to_string_pretty(&v["boolean"]).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn summarise(results: &[Value], took: Duration) -> String {
|
||||
let n = results.len().max(1);
|
||||
let mut design: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut boolean: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut at_step: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut unsupported: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut steps = 0;
|
||||
let (mut dp, mut bp) = (0, 0);
|
||||
for v in results {
|
||||
dp += usize::from(v["design"]["panics"].as_u64().unwrap_or(0) > 0);
|
||||
bp += usize::from(v["boolean_panics"].as_u64().unwrap_or(0) > 0);
|
||||
let k = if v.get("timeout").is_some() {
|
||||
"timeout".to_string()
|
||||
} else if v.get("crash").is_some() {
|
||||
"crash".to_string()
|
||||
} else {
|
||||
v["design"]["outcome"]["kind"].as_str().unwrap_or("?").to_string()
|
||||
};
|
||||
if k != "ok" {
|
||||
let last = v["design"]["steps"].as_array().and_then(|a| a.last()).and_then(Value::as_str).unwrap_or("?");
|
||||
*at_step.entry(format!("{k} at {last}")).or_default() += 1;
|
||||
}
|
||||
*design.entry(k).or_default() += 1;
|
||||
*boolean.entry(v["boolean"]["outcome"].as_str().unwrap_or("not run").to_string()).or_default() += 1;
|
||||
steps += v["design"]["steps"].as_array().map(Vec::len).unwrap_or(0);
|
||||
for u in v["design"]["unsupported"].as_array().into_iter().flatten() {
|
||||
*unsupported.entry(u[0].as_str().unwrap_or("?").to_string()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let pct = |c: usize| format!("{c} ({:.1} %)", 100.0 * c as f64 / n as f64);
|
||||
let mut s = format!("{} seeds, {steps} design steps, {:.0} s\n\nDesigns:\n", results.len(), took.as_secs_f64());
|
||||
for (k, c) in &design {
|
||||
s += &format!("- {k}: {}\n", pct(*c));
|
||||
}
|
||||
s += "\nFailures by step:\n";
|
||||
for (k, c) in &at_step {
|
||||
s += &format!("- {k}: {c}\n");
|
||||
}
|
||||
s += "\nSteps refused as not supported (undone, the design went on):\n";
|
||||
for (k, c) in &unsupported {
|
||||
s += &format!("- {k}: {c}\n");
|
||||
}
|
||||
s += &format!("\nCaught panics (fatal in the web build): designs {}, boolean checks {}\n", pct(dp), pct(bp));
|
||||
s += "\nBoolean identity (union, intersection, cut of two primitives):\n";
|
||||
for (k, c) in &boolean {
|
||||
s += &format!("- {k}: {}\n", pct(*c));
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -10,10 +10,13 @@
|
||||
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with measure.json
|
||||
//! solvecraft-cli step-corpus <case-dir>... import part.step and compare with measure.json
|
||||
//! solvecraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
|
||||
//! solvecraft-cli licences SolveCraft's licence and the third-party licences
|
||||
//! solvecraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod fuzz;
|
||||
mod oracle;
|
||||
mod recipe;
|
||||
mod seams;
|
||||
@@ -33,8 +36,11 @@ const USAGE: &str = "usage:
|
||||
solvecraft-cli commands
|
||||
solvecraft-cli recipe <recipe.json>
|
||||
solvecraft-cli oracle <case-dir>... [--json]
|
||||
solvecraft-cli step-corpus <case-dir>... [--json]
|
||||
solvecraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
|
||||
solvecraft-cli step-corpus <case-dir>... [--json] [--iges]
|
||||
solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
|
||||
solvecraft-cli licences
|
||||
solvecraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -54,7 +60,16 @@ fn main() -> ExitCode {
|
||||
}),
|
||||
Some("oracle") => oracle::run(&args[1..]),
|
||||
Some("step-corpus") => oracle::step_corpus(&args[1..]),
|
||||
Some("fuzz") => fuzz::run(&args[1..]),
|
||||
Some("fuzz-one") => fuzz::one(&args[1..]),
|
||||
Some("mcp") => cmd_mcp(&args[1..]),
|
||||
Some("icon") => cmd_icon(&args[1..]),
|
||||
Some("licences" | "licenses") => {
|
||||
// Written, not print!ed: a closed pipe (`| head`) is not a crash.
|
||||
use std::io::Write as _;
|
||||
let _ = std::io::stdout().lock().write_all(solvecraft_engine::licences::all().as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
// Hidden, for the kill-mid-save test: save a large design over and over.
|
||||
Some("save-stress") => cmd_save_stress(&args[1..]),
|
||||
// Hidden: time recomputes after parameter edits (docs/perf.md).
|
||||
@@ -196,6 +211,69 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The app icon: a blue block with a bore, drawn by the CPU renderer on a transparent background
|
||||
/// (so packages need no committed image). `.ico` holds the Windows sizes as PNGs.
|
||||
fn cmd_icon(args: &[String]) -> Result<(), String> {
|
||||
let out = flag(args, "--out").ok_or("icon needs --out FILE.png or FILE.ico")?;
|
||||
let size: usize = flag(args, "--size").and_then(|x| x.parse().ok()).unwrap_or(256).clamp(8, 2048);
|
||||
let bytes = if out.to_ascii_lowercase().ends_with(".ico") {
|
||||
let pngs: Vec<(usize, Vec<u8>)> =
|
||||
[16, 20, 24, 32, 40, 48, 64, 128, 256].into_iter().map(|s| icon_png(s).map(|p| (s, p))).collect::<Result<_, _>>()?;
|
||||
ico(&pngs)?
|
||||
} else {
|
||||
icon_png(size)?
|
||||
};
|
||||
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
println!("{}", pretty(&json!({"path": out, "bytes": bytes.len()})));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
|
||||
use solvecraft_engine::render::{Rgb, Scene, SceneLine, SceneMesh, png, render_transparent};
|
||||
let mut s = Session::default();
|
||||
for (cmd, p) in [
|
||||
("solid.box", json!({"length": 20, "width": 20, "height": 20, "center": [0, 0, 0]})),
|
||||
("solid.cylinder", json!({"diameter": 9, "height": 30, "base": [0, 0, -15], "operation": "cut"})),
|
||||
] {
|
||||
s.execute(cmd, &p).map_err(|e| format!("icon: {e}"))?;
|
||||
}
|
||||
let mut cam = solvecraft_engine::view::home_camera(&s);
|
||||
cam.distance *= 0.95;
|
||||
let mut scene = Scene { radius: 60.0, ..Default::default() };
|
||||
let px = size as f64 / 256.0;
|
||||
for body in &s.world_state().bodies {
|
||||
let mesh = body.mesh();
|
||||
scene.meshes.push(SceneMesh { mesh: std::sync::Arc::clone(&mesh), color: Rgb(90, 160, 240), face_colors: Vec::new() });
|
||||
for (i, e) in mesh.edges.iter().enumerate() {
|
||||
if !mesh.seams.get(i).copied().unwrap_or(false) {
|
||||
scene.lines.push(SceneLine { points: e.clone(), color: Rgb(24, 52, 96), width: (3.0 * px).max(0.8), on_top: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
let c = render_transparent(&mut scene, &cam, size, size);
|
||||
png(c.w, c.h, c.rgba).ok_or_else(|| "icon: PNG encoding failed".to_string())
|
||||
}
|
||||
|
||||
/// A Windows icon file holding PNG images (Vista and later read these).
|
||||
fn ico(images: &[(usize, Vec<u8>)]) -> Result<Vec<u8>, String> {
|
||||
let n = u16::try_from(images.len()).map_err(|_| "too many icon sizes")?;
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&[0, 0, 1, 0]);
|
||||
out.extend_from_slice(&n.to_le_bytes());
|
||||
let mut offset = 6 + 16 * images.len();
|
||||
for (size, data) in images {
|
||||
let dim = if *size >= 256 { 0 } else { *size as u8 };
|
||||
out.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]);
|
||||
out.extend_from_slice(&u32::try_from(data.len()).map_err(|_| "icon too large")?.to_le_bytes());
|
||||
out.extend_from_slice(&u32::try_from(offset).map_err(|_| "icon too large")?.to_le_bytes());
|
||||
offset += data.len();
|
||||
}
|
||||
for (_, data) in images {
|
||||
out.extend_from_slice(data);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
let id = args.first().ok_or_else(|| USAGE.to_string())?;
|
||||
let p: Value = match args.get(1) {
|
||||
|
||||
@@ -170,9 +170,10 @@ const FUSION_AREA_OFF: &[&str] = &["50-sweep-tight-bend"];
|
||||
|
||||
/// Import a case's `part.step` (as File → Open does) and compare with Fusion's measurements:
|
||||
/// body count, volume and area (relative 1e-3) and the faces read from the file.
|
||||
fn check_step_case(dir: &str) -> Value {
|
||||
/// Open the case's `part.step` (or `part.igs` with `iges`) and compare with measure.json.
|
||||
fn check_step_case(dir: &str, iges: bool) -> Value {
|
||||
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let sp = format!("{dir}/part.step");
|
||||
let sp = format!("{dir}/{}", if iges { "part.igs" } else { "part.step" });
|
||||
let want = match read_json(&format!("{dir}/measure.json")) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e}),
|
||||
@@ -191,7 +192,14 @@ fn check_step_case(dir: &str) -> Value {
|
||||
// Faces read from the file (the kernel may split a face that wraps around a closed surface).
|
||||
let file_faces = std::fs::read(&sp)
|
||||
.ok()
|
||||
.and_then(|b| solvecraft_engine::kernel::step_import_shared(&String::from_utf8_lossy(&b)).ok())
|
||||
.and_then(|b| {
|
||||
let text = String::from_utf8_lossy(&b);
|
||||
if iges {
|
||||
solvecraft_engine::kernel::iges_import(&text).ok().map(std::sync::Arc::new)
|
||||
} else {
|
||||
solvecraft_engine::kernel::step_import_shared(&text).ok()
|
||||
}
|
||||
})
|
||||
.map(|imp| imp.bodies.iter().map(|b| b.file_faces).sum::<usize>() as f64);
|
||||
let mut checks = Vec::new();
|
||||
let mut pass = true;
|
||||
@@ -221,7 +229,8 @@ pub fn step_corpus(args: &[String]) -> Result<(), String> {
|
||||
if dirs.is_empty() {
|
||||
return Err("step-corpus: give one or more case directories (each with part.step and measure.json)".into());
|
||||
}
|
||||
let results: Vec<Value> = dirs.iter().map(|d| check_step_case(d)).collect();
|
||||
let iges = args.iter().any(|a| a == "--iges");
|
||||
let results: Vec<Value> = dirs.iter().map(|d| check_step_case(d, iges)).collect();
|
||||
report(args, &results);
|
||||
let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false)).count();
|
||||
if failed > 0 { Err(format!("{failed} STEP case(s) failed")) } else { Ok(()) }
|
||||
|
||||
@@ -29,4 +29,5 @@ web-sys = { version = "0.3", features = [
|
||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget",
|
||||
] }
|
||||
|
||||
@@ -27,6 +27,25 @@ mod web {
|
||||
/// Seconds between autosaves of unsaved work.
|
||||
const AUTOSAVE_S: f64 = 30.0;
|
||||
|
||||
thread_local! {
|
||||
/// An open design has unsaved changes: leaving the page asks first.
|
||||
static UNSAVED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
|
||||
/// unsaved changes (the page can't show its own dialog when it is being closed).
|
||||
fn ask_before_unload(window: &web_sys::Window) {
|
||||
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
|
||||
if UNSAVED.with(std::cell::Cell::get) {
|
||||
e.prevent_default();
|
||||
e.set_return_value("unsaved");
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
on_unload.forget();
|
||||
}
|
||||
|
||||
struct Shell {
|
||||
app: SolveApp,
|
||||
files: Rc<Files>,
|
||||
@@ -291,6 +310,7 @@ mod web {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.app.logic(ctx);
|
||||
self.files_tick(ctx);
|
||||
UNSAVED.with(|u| u.set(solvecraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
files::take_drops(&self.files, raw);
|
||||
@@ -317,7 +337,9 @@ mod web {
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
ask_before_unload(&window);
|
||||
let Some(document) = window.document() else { return };
|
||||
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
|
||||
@@ -18,3 +18,6 @@ egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
@@ -21,6 +21,12 @@ struct App(SolveApp);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
|
||||
// designs first; the window closes once they are answered (`quit_requested`).
|
||||
if ctx.input(|i| i.viewport().close_requested()) && !self.0.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
solvecraft_ui_egui::documents::request_quit(&mut self.0);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
if self.0.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
@@ -126,6 +132,18 @@ fn main() -> eframe::Result {
|
||||
multisampling: samples,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut options = options;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; SOLVECRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("SOLVECRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
"SolveCraft",
|
||||
options,
|
||||
|
||||
@@ -148,7 +148,11 @@ fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
|
||||
*at = apply_point(m, *at);
|
||||
map_geo_ref(m, face);
|
||||
}
|
||||
PlaneRef::Midplane { .. } | PlaneRef::TwoEdges { .. } | PlaneRef::ThreePoints { .. } | PlaneRef::AlongPath { .. } => {
|
||||
PlaneRef::Midplane { .. }
|
||||
| PlaneRef::TwoEdges { .. }
|
||||
| PlaneRef::ThreePoints { .. }
|
||||
| PlaneRef::AlongPath { .. }
|
||||
| PlaneRef::Perpendicular { .. } => {
|
||||
for r in p.refs_mut() {
|
||||
map_geo_ref(m, r);
|
||||
}
|
||||
|
||||
@@ -513,6 +513,15 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
|
||||
let (on, n) = surface_normal(s, *at).ok_or_else(|| bad("the point is on the face's axis"))?;
|
||||
Plane::from_normal(on, n).ok_or_else(degenerate)
|
||||
}
|
||||
PlaneRef::Perpendicular { plane: base, line: l } => {
|
||||
let pl = plane(ctx, base)?;
|
||||
let (o, d) = line(ctx, l)?;
|
||||
let n = d
|
||||
.cross(pl.normal())
|
||||
.normalized()
|
||||
.ok_or_else(|| bad("the line is square to the plane: every plane through it is perpendicular; pick a line in or along the plane"))?;
|
||||
Plane::new(o, d, n.cross(d)).ok_or_else(degenerate)
|
||||
}
|
||||
PlaneRef::AlongPath { path, t } => {
|
||||
let t = Document::eval_in(ctx.vals, t, Kind::Unitless)?;
|
||||
let (p, tan) = along(&path_points(ctx, path)?, t).ok_or_else(|| bad("the path has no length"))?;
|
||||
@@ -621,6 +630,7 @@ impl PlaneRef {
|
||||
pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> {
|
||||
match self {
|
||||
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => vec![a.as_mut(), b.as_mut()],
|
||||
PlaneRef::Perpendicular { plane, line } => vec![plane.as_mut(), line.as_mut()],
|
||||
PlaneRef::ThreePoints { a, b, c } => vec![a.as_mut(), b.as_mut(), c.as_mut()],
|
||||
PlaneRef::Tangent { face, .. } => vec![face.as_mut()],
|
||||
PlaneRef::AlongPath { path, .. } => vec![path.as_mut()],
|
||||
|
||||
@@ -54,6 +54,8 @@ pub enum PlaneRef {
|
||||
Tangent { face: Box<GeoRef>, at: Vec3 },
|
||||
/// Square to an edge or sketch curve, a fraction `t` (expression, 0…1) of the way along it.
|
||||
AlongPath { path: Box<GeoRef>, t: String },
|
||||
/// Through a straight edge (or line, axis) and square to a plane (or planar face).
|
||||
Perpendicular { plane: Box<GeoRef>, line: Box<GeoRef> },
|
||||
/// A planar body face: found again on each evaluation (the face with this plane's normal
|
||||
/// nearest `at`), so sketches on it follow when earlier features change; `plane` is the
|
||||
/// plane as picked (its frame, and the fallback when the face is gone). `name` is the face's
|
||||
@@ -771,8 +773,16 @@ pub enum FilletStyle {
|
||||
Constant,
|
||||
/// `radius` is the chord: the width across the blend.
|
||||
Chord,
|
||||
/// `radius` at the end of each edge nearest `start`, `radius2` at the other, linear between.
|
||||
Variable { radius2: String, start: Vec3 },
|
||||
/// `radius` at the end of each edge nearest `start`, `radius2` at the other; `mid` radii at
|
||||
/// positions between (0 to 1 from the start end); straight between them, or `smooth`.
|
||||
Variable {
|
||||
radius2: String,
|
||||
start: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
mid: Vec<(f64, String)>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
smooth: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl FilletStyle {
|
||||
@@ -1078,6 +1088,20 @@ impl FeatureKind {
|
||||
/// Every expression the feature uses.
|
||||
/// Points on faces the feature refers to (shell, draft, offset and replace faces, a hole's
|
||||
/// placement), for persistent face names.
|
||||
/// The feature's profile selections, each with its sketch.
|
||||
pub fn profile_sels_mut(&mut self) -> Vec<(u64, &mut ProfileSel)> {
|
||||
match self {
|
||||
FeatureKind::Extrude { sketch, profiles, .. }
|
||||
| FeatureKind::Revolve { sketch, profiles, .. }
|
||||
| FeatureKind::Sweep { sketch, profiles, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles, .. }
|
||||
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sk), profiles, .. } => vec![(*sk, profiles)],
|
||||
FeatureKind::Loft { sections, .. } => sections.iter_mut().filter(|s| s.point.is_none()).map(|s| (s.sketch, &mut s.profiles)).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> {
|
||||
match self {
|
||||
FeatureKind::Shell { faces, .. }
|
||||
@@ -1132,8 +1156,9 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::Fillet { radius, style, .. } => {
|
||||
v.push(radius);
|
||||
if let FilletStyle::Variable { radius2, .. } = style {
|
||||
if let FilletStyle::Variable { radius2, mid, .. } = style {
|
||||
v.push(radius2);
|
||||
v.extend(mid.iter().map(|(_, r)| r.as_str()));
|
||||
}
|
||||
}
|
||||
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
|
||||
@@ -1794,7 +1819,8 @@ impl Document {
|
||||
| PlaneRef::TwoEdges { .. }
|
||||
| PlaneRef::ThreePoints { .. }
|
||||
| PlaneRef::Tangent { .. }
|
||||
| PlaneRef::AlongPath { .. } => {
|
||||
| PlaneRef::AlongPath { .. }
|
||||
| PlaneRef::Perpendicular { .. } => {
|
||||
let st = st.ok_or_else(|| DocError::Invalid("this plane is built from the model: evaluate first".into()))?;
|
||||
crate::construct::resolve_geo_plane(&crate::construct::Ctx { doc: self, vals, st }, p)
|
||||
}
|
||||
|
||||
+34
-6
@@ -283,6 +283,12 @@ impl ModelState {
|
||||
(key, &b.name).hash(&mut h);
|
||||
b.key = h.finish();
|
||||
b.names = crate::naming::NameCell::new(Some(origin.clone()));
|
||||
// Faces split on purpose stay split through later features (for export).
|
||||
if b.body.split_lines().is_none()
|
||||
&& let Some(lines) = before.bodies.iter().find(|x| x.name == b.name).and_then(|x| x.body.split_lines())
|
||||
{
|
||||
b.body = b.body.clone().with_split_lines(Some(lines.clone()));
|
||||
}
|
||||
// An imported look outlives the operations on the body.
|
||||
if b.body.paint().is_none()
|
||||
&& b.body.color().is_none()
|
||||
@@ -1942,11 +1948,29 @@ fn path_at(segs: &[kernel::PathSeg], d: f64) -> (Vec3, Vec3) {
|
||||
}
|
||||
|
||||
/// Apply transformed copies of the tools of `features` (patterns, mirrors).
|
||||
fn replay(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, features: &[String], mats: &[Mat]) -> Result<()> {
|
||||
fn replay(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
f: &Feature,
|
||||
st: &mut ModelState,
|
||||
features: &[String],
|
||||
mats: &[Mat],
|
||||
warning: &mut Option<String>,
|
||||
) -> Result<()> {
|
||||
if features.is_empty() {
|
||||
return Err(DocError::Invalid("no features selected".into()));
|
||||
}
|
||||
for name in features {
|
||||
// A suppressed feature has nothing to copy: its copies go with it.
|
||||
let suppressed: Vec<&String> = features.iter().filter(|n| doc.find_feature(n).is_some_and(|x| x.suppressed)).collect();
|
||||
if !suppressed.is_empty() {
|
||||
let list = suppressed.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(", ");
|
||||
*warning = Some(if suppressed.len() == features.len() {
|
||||
format!("nothing to copy: {list} suppressed")
|
||||
} else {
|
||||
format!("{list} suppressed and not copied")
|
||||
});
|
||||
}
|
||||
for name in features.iter().filter(|n| !suppressed.contains(n)) {
|
||||
// A sketch and its extrude often share a name; the feature that makes geometry wins.
|
||||
let src = doc
|
||||
.features
|
||||
@@ -2509,7 +2533,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
FeatureKind::Pattern { features, pattern, bodies } => {
|
||||
let mats = pattern_transforms(vals, st, pattern)?;
|
||||
if bodies.is_empty() {
|
||||
return replay(doc, vals, f, st, features, &mats);
|
||||
return replay(doc, vals, f, st, features, &mats, warning);
|
||||
}
|
||||
// Bodies: each copy is a new body named after its source.
|
||||
for n in bodies {
|
||||
@@ -2525,7 +2549,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
FeatureKind::Mirror { features, plane, bodies, combine } => {
|
||||
let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?;
|
||||
if bodies.is_empty() {
|
||||
return replay(doc, vals, f, st, features, &[mirror_matrix(&pl)]);
|
||||
return replay(doc, vals, f, st, features, &[mirror_matrix(&pl)], warning);
|
||||
}
|
||||
let m = mirror_matrix(&pl);
|
||||
for n in bodies {
|
||||
@@ -2596,11 +2620,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
};
|
||||
let nb = match &f.kind {
|
||||
FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => kernel::fillet_chord(&mb.body, &edges, r)?,
|
||||
FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start }, .. } => {
|
||||
FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start, mid, smooth }, .. } => {
|
||||
let r2 = val(vals, radius2, Kind::Length)?;
|
||||
let mut pts = vec![(0.0, r), (1.0, r2)];
|
||||
for (t, e) in mid {
|
||||
pts.push((*t, val(vals, e, Kind::Length)?));
|
||||
}
|
||||
let mut b = mb.body.clone();
|
||||
for p in &edges {
|
||||
b = kernel::fillet_variable(&b, *p, *start, r, r2)?;
|
||||
b = kernel::fillet_variable_points(&b, *p, *start, &pts, *smooth)?;
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
@@ -783,11 +783,28 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
return Err(DocError::Invalid("pick the rim face of a shelled body (a face with an inner and an outer edge)".into()));
|
||||
};
|
||||
let bw = if *groove { w + g } else { w };
|
||||
// The band along the chosen edge, inside the rim face.
|
||||
// A lip: the band raised from the rim's own faces (its walls continue the walls
|
||||
// below, which a join can't do: they would coincide).
|
||||
if !*groove
|
||||
&& let Ok(b) = kernel::raise_band(&mb.body, *face, w, h, *outside)
|
||||
&& let Some(slot) = st.bodies.iter_mut().find(|x| x.name == target)
|
||||
{
|
||||
*slot = ModelBody::new(target.clone(), b, slot.feature);
|
||||
return Ok(());
|
||||
}
|
||||
// The band along the chosen edge, inside the rim face. A groove's cutter reaches past
|
||||
// the edge into the free side (the cavity, or outside), so no wall coincides.
|
||||
let free = if *groove { bw } else { 0.0 };
|
||||
let band = if *outside {
|
||||
Region2 { outer: Loop2::polygon(&outer).ccw(), holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()] }
|
||||
Region2 {
|
||||
outer: Loop2::polygon(&offset_left(&outer, -free)).ccw(),
|
||||
holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()],
|
||||
}
|
||||
} else {
|
||||
Region2 { outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(), holes: vec![Loop2::polygon(&inner).ccw().reversed()] }
|
||||
Region2 {
|
||||
outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(),
|
||||
holes: vec![Loop2::polygon(&offset_left(&inner, free)).ccw().reversed()],
|
||||
}
|
||||
};
|
||||
let delta = (h * 0.05).clamp(1e-3, 0.5);
|
||||
if *groove {
|
||||
|
||||
@@ -75,9 +75,10 @@ impl FeatureKind {
|
||||
FeatureKind::Fillet { radius, style, .. } => match style {
|
||||
crate::FilletStyle::Constant => v.push(("Radius", radius, L)),
|
||||
crate::FilletStyle::Chord => v.push(("Chord length", radius, L)),
|
||||
crate::FilletStyle::Variable { radius2, .. } => {
|
||||
crate::FilletStyle::Variable { radius2, mid, .. } => {
|
||||
v.push(("Start radius", radius, L));
|
||||
v.push(("End radius", radius2, L));
|
||||
v.extend(mid.iter_mut().map(|(_, r)| ("Mid radius", r, L)));
|
||||
}
|
||||
},
|
||||
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
|
||||
|
||||
@@ -441,6 +441,23 @@ mod tests {
|
||||
assert_eq!(s.doc.units, "mm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_width_keeps_its_inches_when_the_design_goes_metric() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.units", json!({"units": "in"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "w", "expression": "2", "unit": ""}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": "w", "height": 5}));
|
||||
let width = |s: &mut Session| {
|
||||
let b = &run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"];
|
||||
b["max"][1].as_f64().unwrap() - b["min"][1].as_f64().unwrap()
|
||||
};
|
||||
assert!((width(&mut s) - 50.8).abs() < 1e-6, "2 in an inch design");
|
||||
let r = run(&mut s, "document.units", json!({"units": "mm"}));
|
||||
assert!((width(&mut s) - 50.8).abs() < 1e-6, "still 50.8 mm: {r}");
|
||||
assert_eq!(r["kept"], json!(["w"]));
|
||||
assert_eq!(s.doc.param("w").map(|p| p.expr.as_str()), Some("2 in"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_views_are_saved_renamed_and_deleted() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -83,6 +83,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane")
|
||||
.params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name? The plane is square to the path there"),
|
||||
CommandSpec::new("construct.plane.perpendicular", "Perpendicular Plane", plane_perpendicular).at("SOLID", "CONSTRUCT").icon("plane").params(
|
||||
"plane: plane or planar face ref; line: straight edge, sketch line or axis ref; name? The plane holds the line and is square to the plane",
|
||||
),
|
||||
];
|
||||
|
||||
/// A reference from its JSON form.
|
||||
@@ -249,3 +252,9 @@ fn plane_along(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = t_param(s, p, cmd)?;
|
||||
plane(s, p, PlaneRef::AlongPath { path: Box::new(path), t })
|
||||
}
|
||||
|
||||
fn plane_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "construct.plane.perpendicular";
|
||||
let (base, line) = (geo(s, p.get("plane"), cmd, "plane")?, geo(s, p.get("line"), cmd, "line")?);
|
||||
plane(s, p, PlaneRef::Perpendicular { plane: Box::new(base), line: Box::new(line) })
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("fillet")
|
||||
.key("F")
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]"),
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii)"),
|
||||
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
|
||||
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
|
||||
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
||||
@@ -209,6 +209,37 @@ pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
.ok_or_else(|| bad(cmd, "there is no sketch to use (create one first)"))
|
||||
}
|
||||
|
||||
/// Profile indices become the curve ids of each profile's loops as the sketch is now solved.
|
||||
fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> {
|
||||
let st = s.model.state();
|
||||
'sels: for (sketch, sel) in kind.profile_sels_mut() {
|
||||
let ProfileSel::Indices { indices } = sel else { continue };
|
||||
let Some(ss) = st.sketch(sketch) else { continue };
|
||||
let mut loops = Vec::with_capacity(indices.len());
|
||||
for i in indices.iter() {
|
||||
let pr = ss
|
||||
.profiles
|
||||
.get(*i)
|
||||
.ok_or_else(|| EngineError::Other(format!("no profile {i} in sketch `{}` (it has {})", ss.name, ss.profiles.len())))?;
|
||||
// Two profiles with the same outer curves (one inside the other's holes cannot be):
|
||||
// keep the index then.
|
||||
if pr.outer_curves.is_empty() || ss.profiles.iter().filter(|q| same_loop(&q.outer_curves, &pr.outer_curves)).count() != 1 {
|
||||
continue 'sels;
|
||||
}
|
||||
loops.push(pr.outer_curves.clone());
|
||||
}
|
||||
*sel = ProfileSel::Curves { loops };
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn same_loop(a: &[String], b: &[String]) -> bool {
|
||||
let (mut a, mut b) = (a.to_vec(), b.to_vec());
|
||||
a.sort();
|
||||
b.sort();
|
||||
a == b
|
||||
}
|
||||
|
||||
pub(super) fn profiles(p: &Value, cmd: &str) -> Result<ProfileSel> {
|
||||
let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) };
|
||||
match v {
|
||||
@@ -270,6 +301,9 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
|
||||
*e = doc.with_design_unit(e, k);
|
||||
}
|
||||
}
|
||||
// Profiles picked by index are kept by the curves around them: a recompute can renumber a
|
||||
// sketch's profiles (a face sketch's projected outline rebuilt), never the curves.
|
||||
stable_profiles(s, &mut kind)?;
|
||||
// Blends remember where their edges sat, to find them again after upstream edits.
|
||||
let (refs, names) = match &kind {
|
||||
FeatureKind::Fillet { edges, body, .. } | FeatureKind::Chamfer { edges, body, .. } => {
|
||||
@@ -570,7 +604,24 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_expr(s, &radius2, Kind::Length, cmd, "radius2")?;
|
||||
let start =
|
||||
p.get("start").and_then(vec3).ok_or_else(|| bad(cmd, "a variable fillet needs `start`: [x,y,z] near the end that takes `radius`"))?;
|
||||
solvecraft_doc::FilletStyle::Variable { radius2, start }
|
||||
let mut mid = Vec::new();
|
||||
for m in p.get("mid").and_then(Value::as_array).into_iter().flatten().take(100) {
|
||||
let (Some(t), Some(r)) = (m.get(0).and_then(Value::as_f64), m.get(1)) else {
|
||||
return Err(bad(cmd, "`mid` entries are [position, radius]"));
|
||||
};
|
||||
if !(t.is_finite() && t > 0.0 && t < 1.0) {
|
||||
return Err(bad(cmd, "`mid` positions lie between 0 and 1 (from the start end)"));
|
||||
}
|
||||
let r = match r {
|
||||
Value::String(x) => x.clone(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
_ => return Err(bad(cmd, "a `mid` radius is a number or an expression")),
|
||||
};
|
||||
check_expr(s, &r, Kind::Length, cmd, "mid radius")?;
|
||||
mid.push((t, r));
|
||||
}
|
||||
let smooth = p.get("smooth").and_then(Value::as_bool).unwrap_or(false);
|
||||
solvecraft_doc::FilletStyle::Variable { radius2, start, mid, smooth }
|
||||
}
|
||||
Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord or variable)"))),
|
||||
};
|
||||
|
||||
@@ -179,6 +179,8 @@ fn deepest(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, tol: f64) -> f6
|
||||
|
||||
/// Ray parity along a skew direction (no axis-aligned edge hits).
|
||||
fn inside(p: Vec3, tris: &[[Vec3; 3]]) -> bool {
|
||||
// Fixed ray coordinates, not an approximation used to compute Euler's constant.
|
||||
#[allow(clippy::approx_constant)]
|
||||
let dir = Vec3::new(0.577_215_664_9, 0.327_118_441_9, 0.751_891_438_7);
|
||||
let mut n = 0;
|
||||
for [a, b, c] in tris {
|
||||
|
||||
@@ -8,11 +8,12 @@ use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, expr, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.section", "Section Analysis", section)
|
||||
.at("SOLID", "INSPECT")
|
||||
.icon("section")
|
||||
.noundo()
|
||||
.params("plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the normal; flip?: bool; or clear: true")];
|
||||
pub static COMMANDS: &[CommandSpec] =
|
||||
&[CommandSpec::new("inspect.section", "Section Analysis", section).at("SOLID", "INSPECT").icon("section").noundo().params(
|
||||
"plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the plane's normal (XY: +Z, XZ: -Y, YZ: +X); \
|
||||
at?: expr, the cut's world coordinate on the named plane's axis (XY: Z, XZ: Y, YZ: X), instead of offset; \
|
||||
flip?: bool; or clear: true. Returns the cut's origin and normal, and for a named plane its axis and at",
|
||||
)];
|
||||
|
||||
fn section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.section";
|
||||
@@ -21,21 +22,34 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.revision += 1;
|
||||
return Ok(json!({"section": null}));
|
||||
}
|
||||
let (origin, normal) = match p.get("plane") {
|
||||
// A named plane's world axis: which component of a point lies along its normal.
|
||||
let (origin, normal, axis) = match p.get("plane") {
|
||||
Some(Value::String(n)) => {
|
||||
let pl = Plane::named(n).ok_or_else(|| bad(cmd, format!("unknown plane `{n}` (XY, XZ, YZ)")))?;
|
||||
(pl.origin, pl.normal())
|
||||
let normal = pl.normal();
|
||||
let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5);
|
||||
(pl.origin, normal, axis)
|
||||
}
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let n = o.get("normal").and_then(vec3).and_then(|n| n.normalized()).ok_or_else(|| bad(cmd, "the plane needs a non-zero `normal`"))?;
|
||||
(origin, n)
|
||||
(origin, n, None)
|
||||
}
|
||||
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or {origin, normal}")),
|
||||
};
|
||||
let offset = match expr(p, "offset") {
|
||||
Some(e) => s.doc.eval(&e, solvecraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("offset: {e}")))?,
|
||||
None => 0.0,
|
||||
let length = |key: &str| -> Result<Option<f64>> {
|
||||
match expr(p, key) {
|
||||
Some(e) => s.doc.eval(&e, solvecraft_doc::expr::Kind::Length).map(Some).map_err(|e| bad(cmd, format!("{key}: {e}"))),
|
||||
None => Ok(None),
|
||||
}
|
||||
};
|
||||
let (offset, at) = (length("offset")?, length("at")?);
|
||||
let offset = match (offset, at, axis) {
|
||||
(Some(_), Some(_), _) => return Err(bad(cmd, "give either `offset` (along the normal) or `at` (a world coordinate), not both")),
|
||||
(_, Some(_), None) => return Err(bad(cmd, "`at` needs a named plane (XY, XZ, YZ); move an {origin, normal} plane by its origin")),
|
||||
// The named planes pass through the world origin, so the offset is `at` signed by the normal.
|
||||
(_, Some(at), Some(i)) => at * axis_component(normal, i).signum(),
|
||||
(offset, None, _) => offset.unwrap_or(0.0),
|
||||
};
|
||||
if !origin.is_finite() || !offset.is_finite() {
|
||||
return Err(bad(cmd, "the plane must be finite"));
|
||||
@@ -44,5 +58,18 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let origin = origin + normal * offset * if bool_(p, "flip").unwrap_or(false) { -1.0 } else { 1.0 };
|
||||
s.section = Some((origin, normal));
|
||||
s.revision += 1;
|
||||
Ok(json!({"section": {"origin": origin, "normal": normal}}))
|
||||
let mut section = json!({"origin": origin, "normal": normal});
|
||||
if let Some(i) = axis {
|
||||
section["axis"] = json!(["X", "Y", "Z"].get(i).copied().unwrap_or("?"));
|
||||
section["at"] = json!(axis_component(origin, i));
|
||||
}
|
||||
Ok(json!({"section": section}))
|
||||
}
|
||||
|
||||
fn axis_component(v: Vec3, i: usize) -> f64 {
|
||||
match i {
|
||||
0 => v.x,
|
||||
1 => v.y,
|
||||
_ => v.z,
|
||||
}
|
||||
}
|
||||
|
||||
+118
-35
@@ -9,6 +9,13 @@ use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The `connect` parameter every drawing command takes (see `connect_points`).
|
||||
macro_rules! connect_doc {
|
||||
() => {
|
||||
"; connect?: bool (default true: a new point landing exactly on an existing point, the origin too, is made coincident with it)"
|
||||
};
|
||||
}
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
|
||||
.at("SOLID", "CREATE")
|
||||
@@ -21,71 +28,71 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("line")
|
||||
.key("L")
|
||||
.enabled(in_sketch)
|
||||
.params("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)"),
|
||||
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!())),
|
||||
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect")
|
||||
.key("R")
|
||||
.enabled(in_sketch)
|
||||
.params("p0, p1: opposite corners [x,y]; construction?"),
|
||||
.params(concat!("p0, p1: opposite corners [x,y]; construction?. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!())),
|
||||
CommandSpec::new("sketch.rectangle.three_point", "3-Point Rectangle", rect_three_point)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect3")
|
||||
.enabled(in_sketch)
|
||||
.params("p0, p1: first edge; p2: point on the opposite edge"),
|
||||
.params(concat!("p0, p1: first edge; p2: point on the opposite edge", connect_doc!())),
|
||||
CommandSpec::new("sketch.rectangle.center", "Center Rectangle", rect_center)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect_center")
|
||||
.enabled(in_sketch)
|
||||
.params("center, corner"),
|
||||
.params(concat!("center, corner. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!())),
|
||||
CommandSpec::new("sketch.circle.center", "Center Diameter Circle", circle_center)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle")
|
||||
.key("C")
|
||||
.enabled(in_sketch)
|
||||
.params("center: [x,y] or point ref, radius | diameter: number"),
|
||||
.params(concat!("center: [x,y] or point ref, radius | diameter: number", connect_doc!())),
|
||||
CommandSpec::new("sketch.circle.two_point", "2-Point Circle", circle_two)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle2")
|
||||
.enabled(in_sketch)
|
||||
.params("p0, p1: diameter ends"),
|
||||
.params(concat!("p0, p1: diameter ends", connect_doc!())),
|
||||
CommandSpec::new("sketch.circle.three_point", "3-Point Circle", circle_three)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("circle3")
|
||||
.enabled(in_sketch)
|
||||
.params("p0, p1, p2"),
|
||||
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params("start, end, through"),
|
||||
.params(concat!("p0, p1, p2", connect_doc!())),
|
||||
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params(concat!("start, end, through", connect_doc!())),
|
||||
CommandSpec::new("sketch.arc.center_point", "Center Point Arc", arc_center)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("arc_center")
|
||||
.enabled(in_sketch)
|
||||
.params("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees"),
|
||||
.params(concat!("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees", connect_doc!())),
|
||||
CommandSpec::new("sketch.polygon.inscribed", "Inscribed Polygon", polygon_inscribed)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("polygon")
|
||||
.enabled(in_sketch)
|
||||
.params("center, radius (to vertices), sides, angle?: deg"),
|
||||
.params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!())),
|
||||
CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("polygon")
|
||||
.enabled(in_sketch)
|
||||
.params("center, radius (to edge midpoints), sides, angle?: deg"),
|
||||
.params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!())),
|
||||
CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("polygon")
|
||||
.enabled(in_sketch)
|
||||
.params("p0, p1: one edge, sides"),
|
||||
.params(concat!("p0, p1: one edge, sides", connect_doc!())),
|
||||
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("slot")
|
||||
.enabled(in_sketch)
|
||||
.params("p0, p1: arc centres, width"),
|
||||
.params(concat!("p0, p1: arc centres, width", connect_doc!())),
|
||||
CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("slot")
|
||||
.enabled(in_sketch)
|
||||
.params("p0, p1: overall ends, width"),
|
||||
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params("point: [x,y], id?"),
|
||||
.params(concat!("p0, p1: overall ends, width", connect_doc!())),
|
||||
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!())),
|
||||
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
|
||||
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current)",
|
||||
),
|
||||
@@ -218,6 +225,47 @@ pub(super) fn edit<T>(
|
||||
Ok((out, json!({"solved": rep.ok(), "dof": rep.dof})))
|
||||
}
|
||||
|
||||
/// How close (mm) a new point must land to an existing one to be connected to it.
|
||||
const CONNECT_TOL: f64 = 1e-6;
|
||||
|
||||
/// New points (index `first` on) that land on an existing point, the origin included, get a
|
||||
/// coincident constraint to it, as when a click snaps there; `connect: false` leaves them free.
|
||||
fn connect_points(sk: &mut Sketch, first: usize, p: &Value) -> Result<Vec<String>> {
|
||||
if !bool_(p, "connect").unwrap_or(true) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// The existing points by x, for a range lookup per new point.
|
||||
let mut old: Vec<(Vec2, usize)> = (0..first.min(sk.points.len())).filter_map(|i| Some((sk.point(i)?, i))).collect();
|
||||
old.sort_by(|a, b| a.0.x.total_cmp(&b.0.x));
|
||||
let mut ties = Vec::new();
|
||||
for q in first..sk.points.len() {
|
||||
let Some(at) = sk.point(q) else { continue };
|
||||
let from = old.partition_point(|(o, _)| o.x < at.x - CONNECT_TOL);
|
||||
let hit =
|
||||
old.iter().skip(from).take_while(|(o, _)| o.x <= at.x + CONNECT_TOL).filter(|(o, _)| o.dist(at) < CONNECT_TOL).map(|(_, i)| *i).min();
|
||||
if let Some(o) = hit {
|
||||
ties.push((o, q));
|
||||
}
|
||||
}
|
||||
ties.into_iter().map(|(o, q)| add_c(sk, ConstraintKind::Coincident { p: o, q })).collect()
|
||||
}
|
||||
|
||||
/// `edit` for drawing commands whose step returns (curve ids, constraint ids): the points it
|
||||
/// adds are connected to the existing points they land on (see [`connect_points`]).
|
||||
pub(super) fn draw(
|
||||
s: &mut Session,
|
||||
p: &Value,
|
||||
cmd: &str,
|
||||
f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result<(Vec<String>, Vec<String>)>,
|
||||
) -> Result<((Vec<String>, Vec<String>), Value)> {
|
||||
edit(s, p, cmd, false, |sk, doc| {
|
||||
let first = sk.points.len();
|
||||
let (curves, mut cons) = f(sk, doc)?;
|
||||
cons.extend(connect_points(sk, first, p)?);
|
||||
Ok((curves, cons))
|
||||
})
|
||||
}
|
||||
|
||||
/// A point argument: `[x, y]` (new point), a point reference string, or `{at, ref}`.
|
||||
#[derive(Clone)]
|
||||
pub(super) enum PArg {
|
||||
@@ -425,7 +473,7 @@ fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let closed = bool_(p, "closed").unwrap_or(false);
|
||||
let infer = bool_(p, "infer").unwrap_or(false);
|
||||
let ids = string_list(p, "ids");
|
||||
let ((curves, cons), info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let ((curves, cons), info) = draw(s, p, cmd, |sk, _| {
|
||||
let args: Vec<PArg> = list.iter().map(|v| parg(sk, v, cmd)).collect::<Result<_>>()?;
|
||||
let n = args.len();
|
||||
let segs = if closed { n } else { n - 1 };
|
||||
@@ -526,6 +574,27 @@ fn rect_constraints(sk: &mut Sketch, l: &[usize]) -> Result<Vec<String>> {
|
||||
Ok(cons)
|
||||
}
|
||||
|
||||
/// Which side and corner of an axis-aligned rectangle is which, from its lines and corner
|
||||
/// points as `polygon_lines` made them (counter-clockwise from the bottom-left corner), so a
|
||||
/// caller can dimension it without inspecting the sketch first.
|
||||
fn rect_names(sk: &Sketch, l: &[usize], pts: &[usize]) -> Value {
|
||||
let curve = |i: usize| l.get(i).and_then(|c| sk.curves.get(*c)).map(|c| c.id.clone());
|
||||
let point = |i: usize| pts.get(i).and_then(|q| sk.points.get(*q)).map(|q| q.id.clone());
|
||||
json!({
|
||||
"sides": {"bottom": curve(0), "right": curve(1), "top": curve(2), "left": curve(3)},
|
||||
"corners": {"bottom_left": point(0), "bottom_right": point(1), "top_right": point(2), "top_left": point(3)},
|
||||
})
|
||||
}
|
||||
|
||||
/// `result` plus the rectangle's named sides and corners.
|
||||
fn rect_result(sk_out: (Vec<String>, Vec<String>), names: Value, info: Value) -> Value {
|
||||
let mut out = result(sk_out, info);
|
||||
if let (Some(o), Value::Object(n)) = (out.as_object_mut(), names) {
|
||||
o.extend(n);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.rectangle.two_point";
|
||||
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
|
||||
@@ -533,13 +602,15 @@ fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "the corners must differ in x and y"));
|
||||
}
|
||||
let (lo, hi) = (Vec2::new(a.x.min(b.x), a.y.min(b.y)), Vec2::new(a.x.max(b.x), a.y.max(b.y)));
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (l, _) = polygon_lines(sk, &[lo, Vec2::new(hi.x, lo.y), hi, Vec2::new(lo.x, hi.y)])?;
|
||||
let cons = rect_constraints(sk, &l)?;
|
||||
let ((out, names), info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let first = sk.points.len();
|
||||
let (l, pts) = polygon_lines(sk, &[lo, Vec2::new(hi.x, lo.y), hi, Vec2::new(lo.x, hi.y)])?;
|
||||
let mut cons = rect_constraints(sk, &l)?;
|
||||
mark_construction(sk, &l, p);
|
||||
Ok((ids_of(sk, &l), cons))
|
||||
cons.extend(connect_points(sk, first, p)?);
|
||||
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
Ok(rect_result(out, names, info))
|
||||
}
|
||||
|
||||
fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -549,7 +620,8 @@ fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if h.x < 1e-9 || h.y < 1e-9 {
|
||||
return Err(bad(cmd, "the corner must differ from the centre in x and y"));
|
||||
}
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let ((out, names), info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let first = sk.points.len();
|
||||
let (l, pts) = polygon_lines(sk, &[c - h, Vec2::new(c.x + h.x, c.y - h.y), c + h, Vec2::new(c.x - h.x, c.y + h.y)])?;
|
||||
let mut cons = rect_constraints(sk, &l)?;
|
||||
// Both diagonals (construction), the centre at their crossing. Only the first carries
|
||||
@@ -567,9 +639,10 @@ fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
cons.push(add_c(sk, ConstraintKind::Midpoint { p: cp, l: diag })?);
|
||||
}
|
||||
mark_construction(sk, &l, p);
|
||||
Ok((ids_of(sk, &l), cons))
|
||||
cons.extend(connect_points(sk, first, p)?);
|
||||
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
Ok(rect_result(out, names, info))
|
||||
}
|
||||
|
||||
fn rect_three_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -581,7 +654,7 @@ fn rect_three_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if w.abs() < 1e-9 {
|
||||
return Err(bad(cmd, "p2 must not be on the first edge"));
|
||||
}
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |sk, _| {
|
||||
let (l, _) = polygon_lines(sk, &[a, b, b + n * w, a + n * w])?;
|
||||
let mut cons = Vec::new();
|
||||
if let [l0, l1, l2, l3] = l[..] {
|
||||
@@ -609,7 +682,7 @@ fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = &super::sketch_project::snap_points(s, p, cmd)?;
|
||||
let r = radius_arg(p, cmd)?;
|
||||
let id = str_(p, "id").map(str::to_string);
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |sk, _| {
|
||||
let c = req_parg(sk, p, "center", cmd)?;
|
||||
let ci = sk.add_circle(c.pos(), r, c.idx(), id.as_deref())?;
|
||||
mark_construction(sk, &[ci], p);
|
||||
@@ -625,7 +698,7 @@ fn circle_two(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if r < 1e-9 {
|
||||
return Err(bad(cmd, "the points must differ"));
|
||||
}
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |sk, _| {
|
||||
let ci = sk.add_circle((a + b) * 0.5, r, None, None)?;
|
||||
Ok((ids_of(sk, &[ci]), Vec::new()))
|
||||
})?;
|
||||
@@ -650,7 +723,7 @@ fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.circle.three_point";
|
||||
let (a, b, c) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?, req_vec2(cmd, p, "p2")?);
|
||||
let (o, r) = circumcircle(a, b, c).ok_or_else(|| bad(cmd, "the points are collinear"))?;
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |sk, _| {
|
||||
let ci = sk.add_circle(o, r, None, None)?;
|
||||
Ok((ids_of(sk, &[ci]), Vec::new()))
|
||||
})?;
|
||||
@@ -661,7 +734,7 @@ fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.arc.three_point";
|
||||
let p = &super::sketch_project::snap_points(s, p, cmd)?;
|
||||
let id = str_(p, "id").map(str::to_string);
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |sk, _| {
|
||||
let (a, b) = (req_parg(sk, p, "start", cmd)?, req_parg(sk, p, "end", cmd)?);
|
||||
let t = req_vec2(cmd, p, "through")?;
|
||||
let (o, _) = circumcircle(a.pos(), t, b.pos()).ok_or_else(|| bad(cmd, "the points are collinear"))?;
|
||||
@@ -684,7 +757,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = &super::sketch_project::snap_points(s, p, cmd)?;
|
||||
let id = str_(p, "id").map(str::to_string);
|
||||
let sweep = num(p, "sweep");
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |sk, _| {
|
||||
let c = req_parg(sk, p, "center", cmd)?;
|
||||
let a = req_parg(sk, p, "start", cmd)?;
|
||||
if let Some(sw) = sweep {
|
||||
@@ -721,7 +794,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, start: f64, n: usize, inscribed: bool) -> Result<Value> {
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |sk, _| {
|
||||
let corners: Vec<Vec2> = (0..n).map(|i| center + Vec2::from_angle(start + std::f64::consts::TAU * i as f64 / n as f64) * r_vertex).collect();
|
||||
let (l, pts) = polygon_lines(sk, &corners)?;
|
||||
let mut cons = Vec::new();
|
||||
@@ -797,7 +870,7 @@ pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w:
|
||||
let h = w / 2.0;
|
||||
let d = (c1 - c0).normalized().ok_or_else(|| bad(cmd, "the slot ends must differ"))?;
|
||||
let n = d.perp();
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (out, info) = draw(s, p, cmd, |sk, _| {
|
||||
let pts: Vec<usize> =
|
||||
[c0 - n * h, c1 - n * h, c1 + n * h, c0 + n * h].iter().map(|q| sk.add_point(*q, None)).collect::<std::result::Result<_, _>>()?;
|
||||
let (p0, p1, p2, p3) = (pts[0], pts[1], pts[2], pts[3]);
|
||||
@@ -837,11 +910,12 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.point";
|
||||
let at = req_vec2(cmd, p, "point")?;
|
||||
let id = str_(p, "id").map(str::to_string);
|
||||
let (pid, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let ((pid, cons), info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let i = sk.add_point(at, id.as_deref())?;
|
||||
Ok(sk.points.get(i).map(|q| q.id.clone()).unwrap_or_default())
|
||||
let cons = connect_points(sk, i, p)?;
|
||||
Ok((sk.points.get(i).map(|q| q.id.clone()).unwrap_or_default(), cons))
|
||||
})?;
|
||||
Ok(json!({"point": pid, "sketch": info}))
|
||||
Ok(json!({"point": pid, "constraints": cons, "sketch": info}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -1049,6 +1123,15 @@ fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.constraint.coincident";
|
||||
// Already tied (e.g. connected when it was drawn there): nothing to add.
|
||||
let sk = s.doc.sketch(target_sketch(s, p, cmd)?)?;
|
||||
let pt = |k: &str| p.get(k).and_then(Value::as_str).and_then(|r| sk.resolve_point(r));
|
||||
if let (Some(a), Some(b)) = (pt("a"), pt("b"))
|
||||
&& let Some(c) = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a)))
|
||||
{
|
||||
let rep = solve(&mut sk.clone());
|
||||
return Ok(json!({"constraint": c.id, "existing": true, "sketch": {"solved": rep.ok(), "dof": rep.dof}}));
|
||||
}
|
||||
constrain(s, p, cmd, |sk| {
|
||||
let aref = p.get("a").and_then(Value::as_str).unwrap_or_default();
|
||||
let bref = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?;
|
||||
|
||||
@@ -99,7 +99,7 @@ fn curvature_polygon_arc_length_linear_diameter() {
|
||||
#[test]
|
||||
fn glyphs_and_snaps() {
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10], "connect": false}));
|
||||
let l = ids(&r["curves"]);
|
||||
let g = run(&mut s, "sketch.glyphs", json!({}));
|
||||
assert_eq!(g["glyphs"].as_array().unwrap().len(), 4);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Sketch Fillet and Chamfer at corners (#46): line–line, line–arc and arc–arc corners, the
|
||||
//! corners of a constrained rectangle, several corners at once, and radii too large for the
|
||||
//! corner (a clear error that leaves the sketch as it was).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn new_sketch() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn curves(r: &Value) -> Vec<String> {
|
||||
r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn sk(s: &Session) -> solvecraft_sketch::Sketch {
|
||||
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.clone()
|
||||
}
|
||||
|
||||
fn solved(s: &Session) -> bool {
|
||||
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().report.ok()
|
||||
}
|
||||
|
||||
fn area(s: &Session) -> f64 {
|
||||
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().profiles.iter().map(|p| p.area).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_and_chamfer_corners() {
|
||||
use std::f64::consts::PI;
|
||||
// A constrained 40 × 20 rectangle: a fillet r5 on one corner keeps it solved and closed,
|
||||
// with the area of the corner taken off.
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
|
||||
let c = curves(&r);
|
||||
let a0 = area(&s);
|
||||
run(&mut s, "sketch.fillet", json!({"a": c[0], "b": c[1], "radius": 5}));
|
||||
assert!(solved(&s));
|
||||
assert!((area(&s) - (a0 - (25.0 - PI * 25.0 / 4.0))).abs() < 1e-6, "{}", area(&s));
|
||||
// Several corners at once: the rest of the rectangle's corners.
|
||||
let pts: Vec<String> = {
|
||||
let k = sk(&s);
|
||||
let li = |id: &str| k.curve_index(id).unwrap();
|
||||
[(1, 2), (2, 3), (3, 0)]
|
||||
.iter()
|
||||
.map(|(x, y)| {
|
||||
let (cx, cy) = (&k.curves[li(&c[*x])].kind, &k.curves[li(&c[*y])].kind);
|
||||
let shared = cx.point_ids().into_iter().find(|p| cy.point_ids().contains(p)).unwrap();
|
||||
k.points[shared].id.clone()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
run(&mut s, "sketch.fillet", json!({"points": pts, "radius": 5}));
|
||||
assert!(solved(&s));
|
||||
assert!((area(&s) - (a0 - 4.0 * (25.0 - PI * 25.0 / 4.0))).abs() < 1e-6, "{}", area(&s));
|
||||
// Too large for a corner: a clear error, nothing changed.
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
|
||||
let c = curves(&r);
|
||||
let n = sk(&s).curves.len();
|
||||
let e = s.execute("sketch.fillet", &json!({"a": c[0], "b": c[1], "radius": 30})).unwrap_err().to_string();
|
||||
assert!(e.contains("too large") || e.contains("does not fit"), "{e}");
|
||||
assert_eq!(sk(&s).curves.len(), n);
|
||||
let e = s.execute("sketch.chamfer.equal_distance", &json!({"a": c[0], "b": c[1], "distance": 25})).unwrap_err().to_string();
|
||||
assert!(e.contains("too large") || e.contains("does not fit") || e.contains("longer"), "{e}");
|
||||
assert_eq!(sk(&s).curves.len(), n);
|
||||
// A line meeting an arc, and two arcs (a slot's end rounded into a line).
|
||||
let mut s = new_sketch();
|
||||
let l = curves(&run(&mut s, "sketch.line", json!({"points": [[-20, 10], [0, 10]]})))[0].clone();
|
||||
let a = curves(&run(&mut s, "sketch.arc.three_point", json!({"start": format!("{l}.end"), "end": [10, 0], "through": [7.0710678, 7.0710678]})))
|
||||
[0]
|
||||
.clone();
|
||||
run(&mut s, "sketch.fillet", json!({"a": l, "b": a, "radius": 2}));
|
||||
assert!(solved(&s));
|
||||
let mut s = new_sketch();
|
||||
let a1 = curves(&run(&mut s, "sketch.arc.three_point", json!({"start": [-10, 0], "end": [0, 0], "through": [-5, -5]})))[0].clone();
|
||||
let a2 = curves(&run(&mut s, "sketch.arc.three_point", json!({"start": format!("{a1}.end"), "end": [10, 0], "through": [5, 2]})))[0].clone();
|
||||
run(&mut s, "sketch.fillet", json!({"a": a1, "b": a2, "radius": 1}));
|
||||
assert!(solved(&s));
|
||||
// Chamfers keep a closed profile.
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
|
||||
let c = curves(&r);
|
||||
let a0 = area(&s);
|
||||
run(&mut s, "sketch.chamfer.equal_distance", json!({"a": c[0], "b": c[1], "distance": 4}));
|
||||
assert!(solved(&s));
|
||||
assert!((area(&s) - (a0 - 8.0)).abs() < 1e-6, "{}", area(&s));
|
||||
}
|
||||
@@ -17,7 +17,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_fillet")
|
||||
.enabled(in_sketch)
|
||||
.params("point: corner shared by two curves (\"l1.end\") | a, b: two lines or arcs meeting at a corner; radius: expr"),
|
||||
.params("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal) | a, b: two lines or arcs meeting at a corner; radius: expr"),
|
||||
CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_chamfer")
|
||||
@@ -376,11 +376,13 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if oi == ci {
|
||||
continue;
|
||||
}
|
||||
let Some(other) = sk.shape(oi) else { continue };
|
||||
for q in intersections(&unbounded, &other) {
|
||||
if on_bounded(&other, q).is_none() {
|
||||
continue;
|
||||
}
|
||||
// Lines and circles exactly; free-form curves (splines, ellipses, conics) by the
|
||||
// pieces of their polyline.
|
||||
let pieces: Vec<Shape> = match sk.shape(oi) {
|
||||
Some(other) => vec![other],
|
||||
None => sk.polyline(oi).windows(2).map(|w| Shape::Line { a: w[0], b: w[1] }).collect(),
|
||||
};
|
||||
for q in pieces.iter().flat_map(|other| intersections(&unbounded, other).into_iter().filter(|q| on_bounded(other, *q).is_some())) {
|
||||
let t = sh.param(q);
|
||||
let gap = match sh {
|
||||
Shape::Line { a, b } => {
|
||||
@@ -478,48 +480,67 @@ fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: us
|
||||
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.fillet";
|
||||
let r_expr = expr(p, "radius").ok_or_else(|| bad(cmd, "`radius` must be a number or an expression"))?;
|
||||
// Several corners at once (`points`), each like a single `point`.
|
||||
let corners: Vec<Value> = match p.get("points").and_then(Value::as_array) {
|
||||
Some(a) if !a.is_empty() && a.len() <= 500 => a.iter().map(|q| json!({ "point": q })).collect(),
|
||||
Some(_) => return Err(bad(cmd, "`points` must list 1…500 corners")),
|
||||
None => vec![p.clone()],
|
||||
};
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
|
||||
let r = doc.eval(&r_expr, solvecraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("radius: {e}")))?;
|
||||
if !(r > 1e-9 && r < 1e8) {
|
||||
return Err(bad(cmd, "radius must be positive"));
|
||||
}
|
||||
let (la, lb, c, fa, fb) = match corner(sk, p, cmd) {
|
||||
Ok(x) => x,
|
||||
// Not two lines: a corner with an arc.
|
||||
Err(e) => {
|
||||
let Some((arc, cons)) = round_corner(sk, p, r, cmd)? else { return Err(e) };
|
||||
let mut cons = cons;
|
||||
let pname = doc.new_model_param(&r_expr, "mm");
|
||||
cons.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?);
|
||||
return Ok((ids_of(sk, &[arc]), cons, pname));
|
||||
}
|
||||
};
|
||||
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
|
||||
let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
|
||||
let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
|
||||
let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs();
|
||||
if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-9 {
|
||||
return Err(bad(cmd, "the lines are parallel"));
|
||||
}
|
||||
let d = r / half.tan();
|
||||
let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?;
|
||||
let center = pc + bis * (r / half.sin());
|
||||
let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?;
|
||||
let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default());
|
||||
let ccw = (qa - center).cross(qb - center) > 0.0;
|
||||
let arc = if ccw {
|
||||
sk.add_arc(center, qa, qb, [None, Some(ta), Some(tb)], None)?
|
||||
} else {
|
||||
sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)?
|
||||
};
|
||||
let mut cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?];
|
||||
let pname = doc.new_model_param(&r_expr, "mm");
|
||||
cons.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?);
|
||||
Ok((ids_of(sk, &[arc]), cons, pname))
|
||||
let (mut arcs, mut cons) = (Vec::new(), Vec::new());
|
||||
for q in &corners {
|
||||
let (arc, mut c) = fillet_corner(sk, q, r, cmd)?;
|
||||
// One radius for them all: the first carries the dimension, the rest are equal to it.
|
||||
match arcs.first() {
|
||||
None => c.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?),
|
||||
Some(first) => c.push(add_c(sk, ConstraintKind::Equal { a: *first, b: arc })?),
|
||||
}
|
||||
cons.extend(c);
|
||||
arcs.push(arc);
|
||||
}
|
||||
Ok((ids_of(sk, &arcs), cons, pname))
|
||||
})?;
|
||||
Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "sketch": info}))
|
||||
}
|
||||
|
||||
/// Round one corner (`point` or `a`, `b`) with an arc of radius `r` tangent to both curves:
|
||||
/// the arc and the tangent constraints.
|
||||
fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec<String>)> {
|
||||
let (la, lb, c, fa, fb) = match corner(sk, p, cmd) {
|
||||
Ok(x) => x,
|
||||
// Not two lines: a corner with an arc.
|
||||
Err(e) => return round_corner(sk, p, r, cmd)?.ok_or(e),
|
||||
};
|
||||
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
|
||||
let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
|
||||
let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?;
|
||||
let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs();
|
||||
if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-9 {
|
||||
return Err(bad(cmd, "the lines are parallel"));
|
||||
}
|
||||
let d = r / half.tan();
|
||||
if d >= pa.dist(pc) - 1e-9 || d >= pb.dist(pc) - 1e-9 {
|
||||
return Err(bad(cmd, format!("the radius is too large for that corner (it needs {d:.3} mm of each line)")));
|
||||
}
|
||||
let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?;
|
||||
let center = pc + bis * (r / half.sin());
|
||||
let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?;
|
||||
let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default());
|
||||
let ccw = (qa - center).cross(qb - center) > 0.0;
|
||||
let arc = if ccw {
|
||||
sk.add_arc(center, qa, qb, [None, Some(ta), Some(tb)], None)?
|
||||
} else {
|
||||
sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)?
|
||||
};
|
||||
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?];
|
||||
Ok((arc, cons))
|
||||
}
|
||||
|
||||
/// Fillet a corner where a line meets an arc, or two arcs meet: the arc of radius `r` touching
|
||||
/// both, nearest the corner; both curves are cut back to it. None when the corner is not of
|
||||
/// that kind.
|
||||
@@ -847,6 +868,18 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !closed {
|
||||
joints.push(end_of(&pieces[n - 1]));
|
||||
}
|
||||
// A straight piece whose joints come out reversed (or meet) was overrun by its
|
||||
// neighbours: the offset is larger than the shape allows there.
|
||||
for (k, pc) in pieces.iter().enumerate() {
|
||||
if let Piece::Line(a, b) = pc {
|
||||
let (ja, jb) = (joints.get(k).copied(), joints.get(if closed { (k + 1) % n } else { k + 1 }).copied());
|
||||
if let (Some(ja), Some(jb)) = (ja, jb)
|
||||
&& (jb - ja).dot(*b - *a) <= TOL * a.dist(*b)
|
||||
{
|
||||
return Err(bad(cmd, "the offset is larger than the shape allows (the offset curves would cross)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let pts: Vec<usize> = joints.iter().map(|q| sk.add_point(*q, None)).collect::<std::result::Result<_, _>>()?;
|
||||
for (k, pc) in pieces.iter().enumerate() {
|
||||
let (pa, pb) = (pts[k], if closed { pts[(k + 1) % n] } else { pts[k + 1] });
|
||||
@@ -1149,3 +1182,11 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_modify_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_robust_tests.rs"]
|
||||
mod robust_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_corner_tests.rs"]
|
||||
mod corner_tests;
|
||||
|
||||
@@ -296,7 +296,8 @@ fn isoparametric_curves_of_a_cylinder() {
|
||||
let w = &sk.wires[sk.wire_index(r["wires"][0].as_str().unwrap()).unwrap()];
|
||||
let zs: Vec<f64> = w.pts.iter().map(|p| p.z).collect();
|
||||
assert!(zs.iter().cloned().fold(f64::MIN, f64::max) > 19.9 && zs.iter().cloned().fold(f64::MAX, f64::min) < 0.1, "{zs:?}");
|
||||
assert!(w.pts.iter().all(|p| (p.x - 6.0).abs() < 1e-3 && p.y.abs() < 1e-3));
|
||||
// Within the mesh's sag: the cut runs across facets unless a seam happens to lie there.
|
||||
assert!(w.pts.iter().all(|p| (p.x - 6.0).abs() < 1e-2 && p.y.abs() < 1e-2), "{:?}", w.pts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -395,3 +396,44 @@ fn sweeps_and_pipes_follow_3d_sketch_curves() {
|
||||
let v = st.bodies.last().unwrap().mesh().measure().volume;
|
||||
assert!((v - 4.0 * len).abs() < 0.05 * 4.0 * len, "{v} vs {}", 4.0 * len);
|
||||
}
|
||||
|
||||
/// #47: references to other bodies: an edge, a face's loops and a silhouette of a second body
|
||||
/// project into a sketch on the first; they follow an edit of that body (a parameter) and
|
||||
/// are reported lost, not crashed on, when it goes away.
|
||||
#[test]
|
||||
fn project_and_include_from_other_bodies() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "w", "expression": "10 mm"}));
|
||||
run(&mut s, "solid.box", json!({"corner": [0, 0, 0], "length": 40, "width": 30, "height": 5}));
|
||||
run(&mut s, "solid.box", json!({"corner": [50, 0, 0], "length": "w", "width": 10, "height": 20, "operation": "new"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base": [80, 5, 0], "radius": 4, "height": 10, "operation": "new"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 5]}, "project_edges": false}));
|
||||
// An edge on the second box's top, its top face's loop, the cylinder's silhouette.
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"edge": [55, 0, 20]}]}));
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"face": [55, 5, 20]}]}));
|
||||
run(&mut s, "sketch.include_3d", json!({"refs": [{"edge": [80, 1, 10]}]}));
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"body": "Body3"}]}));
|
||||
let si = inspect(&mut s);
|
||||
let lc = linked_curves(&si);
|
||||
// (Geometry already there is not drawn twice: the edge lies on the face's loop, the included
|
||||
// circle on the silhouette.)
|
||||
assert!(lc.len() >= 6, "{}", lc.len());
|
||||
// The projected edge of the second box runs from x 50 to 60 at y 0.
|
||||
let xs = |si: &Value| -> Vec<f64> {
|
||||
linked_curves(si).iter().flat_map(|c| [c["start_at"][0].as_f64().unwrap_or(0.0), c["end_at"][0].as_f64().unwrap_or(0.0)]).collect()
|
||||
};
|
||||
assert!(xs(&si).iter().any(|x| (x - 60.0).abs() < 1e-6), "{:?}", xs(&si));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Widen the second box: the projection follows (its far edge is at 70).
|
||||
run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
|
||||
let id = s.doc.features.iter().find(|f| matches!(f.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap().id;
|
||||
let si = run(&mut s, "sketch.inspect", json!({"sketch": id}));
|
||||
assert!(xs(&si).iter().any(|x| (x - 70.0).abs() < 1e-6), "{:?}", xs(&si));
|
||||
// Remove the cylinder: its silhouette and edge are lost, with a warning and no error.
|
||||
let cyl = s.doc.features.iter().find(|f| matches!(f.kind, solvecraft_doc::FeatureKind::Cylinder { .. })).unwrap().id;
|
||||
run(&mut s, "timeline.delete", json!({"feature": cyl}));
|
||||
let t = run(&mut s, "document.inspect", json!({}));
|
||||
let row = t["timeline"].as_array().unwrap().iter().find(|r| r["id"] == json!(id)).cloned().unwrap();
|
||||
assert!(row["error"].is_null(), "{row}");
|
||||
assert!(row["warning"].as_str().is_some_and(|w| w.contains("lost")), "{row}");
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ fn rect(kind: &str) -> (Session, Vec<String>) {
|
||||
let r = match kind {
|
||||
"center" => run(&mut s, "sketch.rectangle.center", json!({"center": [3, 2], "corner": [13, 9]})),
|
||||
"two" => run(&mut s, "sketch.rectangle.two_point", json!({"p0": [1, 1], "p1": [21, 15]})),
|
||||
_ => run(&mut s, "sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15]})),
|
||||
_ => run(&mut s, "sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15], "connect": false})),
|
||||
};
|
||||
let sides = ids(&r["curves"]);
|
||||
assert_eq!(sides.len(), 4, "{r}");
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Adversarial Trim, Extend and Offset cases (#45): each gives the expected result or a clear
|
||||
//! error, and the sketch always stays sound (it solves, every point is finite, nothing
|
||||
//! degenerate is left behind).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn new_sketch() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn curves(r: &Value) -> Vec<String> {
|
||||
r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, id: &str, p: Value) -> Vec<String> {
|
||||
match s.execute(id, &p) {
|
||||
Ok(r) => curves(&r),
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sketch is sound: it solves, every point is finite, no curve has collapsed.
|
||||
fn sound(s: &Session) {
|
||||
let id = s.active_sketch.unwrap();
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).unwrap();
|
||||
assert!(ss.report.ok(), "the sketch no longer solves: {:?}", ss.report.failing);
|
||||
for p in &ss.sketch.points {
|
||||
assert!(p.pos.is_finite(), "point {} is {:?}", p.id, p.pos);
|
||||
}
|
||||
for (i, c) in ss.sketch.curves.iter().enumerate() {
|
||||
let len: f64 = ss.sketch.segs(i).iter().map(|g| g.length()).sum();
|
||||
assert!(len.is_finite() && len > 1e-9, "curve {} has length {len}", c.id);
|
||||
}
|
||||
}
|
||||
|
||||
fn count(s: &Session) -> usize {
|
||||
let id = s.active_sketch.unwrap();
|
||||
s.model.state().sketch(id).unwrap().sketch.curves.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_cases() {
|
||||
// A line crossing a circle twice: trimming the chord leaves the two outer stubs.
|
||||
let mut s = new_sketch();
|
||||
add(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
|
||||
let l = add(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]]}))[0].clone();
|
||||
s.execute("sketch.trim", &json!({"curve": l, "at": [0, 0]})).unwrap();
|
||||
sound(&s);
|
||||
assert_eq!(count(&s), 3, "circle + two stubs");
|
||||
// Trimming at a tangent touch, at a curve end, on a curve with no crossings.
|
||||
let mut s = new_sketch();
|
||||
let c = add(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}))[0].clone();
|
||||
add(&mut s, "sketch.line", json!({"points": [[-20, 10], [20, 10]]}));
|
||||
let r = s.execute("sketch.trim", &json!({"curve": c, "at": [0, -10]}));
|
||||
sound(&s);
|
||||
let _ = r;
|
||||
let mut s = new_sketch();
|
||||
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))[0].clone();
|
||||
// No crossings: the whole line goes (Fusion deletes it).
|
||||
let r = s.execute("sketch.trim", &json!({"curve": l, "at": [5, 0]}));
|
||||
sound_or_empty(&s);
|
||||
assert!(r.is_ok() || r.is_err());
|
||||
// Overlapping collinear lines.
|
||||
let mut s = new_sketch();
|
||||
let a = add(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))[0].clone();
|
||||
add(&mut s, "sketch.line", json!({"points": [[5, 0], [15, 0]]}));
|
||||
let _ = s.execute("sketch.trim", &json!({"curve": a, "at": [10, 0]}));
|
||||
sound(&s);
|
||||
// A spline crossed by a line, trimmed between the crossings; an ellipse cut by a line.
|
||||
let mut s = new_sketch();
|
||||
let sp = add(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 10], [20, -10], [30, 0]]}))[0].clone();
|
||||
add(&mut s, "sketch.line", json!({"points": [[-5, 2], [35, 2]]}));
|
||||
s.execute("sketch.trim", &json!({"curve": sp, "at": [10, 9]})).unwrap();
|
||||
sound(&s);
|
||||
let mut s = new_sketch();
|
||||
let e = add(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))[0].clone();
|
||||
add(&mut s, "sketch.line", json!({"points": [[-15, 0], [15, 0]]}));
|
||||
s.execute("sketch.trim", &json!({"curve": e, "at": [0, -4]})).unwrap();
|
||||
sound(&s);
|
||||
// Hostile points.
|
||||
let mut s = new_sketch();
|
||||
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))[0].clone();
|
||||
for at in [json!([f64::MAX, 0]), json!([1e300, 1e300]), json!("x"), json!([0])] {
|
||||
let _ = s.execute("sketch.trim", &json!({"curve": l, "at": at}));
|
||||
sound(&s);
|
||||
}
|
||||
}
|
||||
|
||||
fn sound_or_empty(s: &Session) {
|
||||
if count(s) > 0 {
|
||||
sound(s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_cases() {
|
||||
// A line extends to a circle; to a spline; with nothing to reach it is a clear error.
|
||||
let mut s = new_sketch();
|
||||
add(&mut s, "sketch.circle.center", json!({"center": [20, 0], "radius": 5}));
|
||||
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 0]]}))[0].clone();
|
||||
s.execute("sketch.extend", &json!({"curve": l, "at": [5, 0]})).unwrap();
|
||||
sound(&s);
|
||||
let id = s.active_sketch.unwrap();
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).unwrap();
|
||||
let li = ss.sketch.curve_index(&l).unwrap();
|
||||
let end = ss.sketch.segs(li).iter().map(|g| g.end().x.max(g.start().x)).fold(f64::NEG_INFINITY, f64::max);
|
||||
assert!((end - 15.0).abs() < 1e-6, "{end}");
|
||||
drop(st);
|
||||
let mut s = new_sketch();
|
||||
add(&mut s, "sketch.spline.fit_point", json!({"points": [[20, -10], [22, 0], [20, 10]]}));
|
||||
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 0]]}))[0].clone();
|
||||
s.execute("sketch.extend", &json!({"curve": l, "at": [5, 0]})).unwrap();
|
||||
sound(&s);
|
||||
let mut s = new_sketch();
|
||||
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 0]]}))[0].clone();
|
||||
assert!(s.execute("sketch.extend", &json!({"curve": l, "at": [5, 0]})).is_err());
|
||||
sound(&s);
|
||||
// An arc extends along its circle to a line.
|
||||
let mut s = new_sketch();
|
||||
add(&mut s, "sketch.line", json!({"points": [[-20, -3], [20, -3]]}));
|
||||
let a = add(&mut s, "sketch.arc.three_point", json!({"start": [10, 0], "end": [0, 10], "through": [7.0710678, 7.0710678]}))[0].clone();
|
||||
let _ = s.execute("sketch.extend", &json!({"curve": a, "at": [10, 0]}));
|
||||
sound(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_cases() {
|
||||
// A rectangle offset outward and inward; inward past its middle vanishes (clear error).
|
||||
let mut s = new_sketch();
|
||||
let r = add(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
|
||||
let n0 = count(&s);
|
||||
s.execute("sketch.offset", &json!({"curves": [r[0].clone()], "distance": 2, "side": [-5, -5]})).unwrap();
|
||||
sound(&s);
|
||||
assert!(count(&s) > n0);
|
||||
let n1 = count(&s);
|
||||
s.execute("sketch.offset", &json!({"curves": [r[0].clone()], "distance": 2, "side": [10, 5]})).unwrap();
|
||||
sound(&s);
|
||||
assert!(count(&s) > n1);
|
||||
let n2 = count(&s);
|
||||
assert!(
|
||||
s.execute("sketch.offset", &json!({"curves": [r[0].clone()], "distance": 6, "side": [10, 5]})).is_err(),
|
||||
"inward 6 on a 10 wide rectangle"
|
||||
);
|
||||
assert_eq!(count(&s), n2, "a failed offset adds nothing");
|
||||
sound(&s);
|
||||
// A slot (lines and arcs), a spline chain, an open zig-zag whose inner offset self-crosses.
|
||||
let mut s = new_sketch();
|
||||
let slot = add(&mut s, "sketch.slot.center_to_center", json!({"p0": [0, 0], "p1": [20, 0], "width": 6}));
|
||||
if let Some(c) = slot.first() {
|
||||
s.execute("sketch.offset", &json!({"curves": [c], "distance": 1, "side": [10, 10]})).unwrap();
|
||||
sound(&s);
|
||||
}
|
||||
let mut s = new_sketch();
|
||||
let sp = add(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 6], [20, -6], [30, 0]]}))[0].clone();
|
||||
s.execute("sketch.offset", &json!({"curves": [sp], "distance": 1.5})).unwrap();
|
||||
sound(&s);
|
||||
let mut s = new_sketch();
|
||||
let z = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0], [10.5, 5], [11, 0], [20, 0]]}));
|
||||
let r = s.execute("sketch.offset", &json!({"curves": [z[0].clone()], "distance": 2, "side": [10, -5]}));
|
||||
sound(&s);
|
||||
let _ = r;
|
||||
// Hostile distances.
|
||||
let mut s = new_sketch();
|
||||
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))[0].clone();
|
||||
for d in [json!(0), json!(-1e308), json!(f64::MAX), json!("x"), json!(1e-12)] {
|
||||
let _ = s.execute("sketch.offset", &json!({"curves": [l.clone()], "distance": d}));
|
||||
sound(&s);
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ fn every_dimension_kind_has_a_drawable_frame() {
|
||||
#[test]
|
||||
fn dimension_text_is_stored_relative_to_the_dimension_and_follows_it() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]], "connect": false}));
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "text_at": [20, -8]}));
|
||||
let param = d["param"].as_str().unwrap_or_default().to_string();
|
||||
let text = |s: &Session| {
|
||||
@@ -126,7 +126,7 @@ fn dof(s: &mut Session) -> i64 {
|
||||
#[test]
|
||||
fn fixed_points_survive_drags_and_solves_and_unfix_frees_them() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0], [30, 20]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0], [30, 20]], "connect": false}));
|
||||
let free = dof(&mut s);
|
||||
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"}));
|
||||
assert_eq!(r["result"]["fixed"], true);
|
||||
@@ -333,7 +333,7 @@ fn two_fully_constrained_entities_conflict_and_nothing_moves() {
|
||||
fn line_pairs_keep_the_first_line() {
|
||||
let lines = || {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 2]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 2]], "connect": false}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[5, 20], [25, 35]]}));
|
||||
s
|
||||
};
|
||||
@@ -360,7 +360,7 @@ fn line_pairs_keep_the_first_line() {
|
||||
fn circle_pairs_keep_the_first_circle() {
|
||||
let circles = || {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5, "connect": false}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [30, 10], "radius": 8}));
|
||||
s
|
||||
};
|
||||
@@ -471,7 +471,7 @@ fn dragging_a_rectangle_side_moves_that_side_only() {
|
||||
#[test]
|
||||
fn dragging_circles_and_arcs() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5, "connect": false}));
|
||||
// The edge changes the radius about a fixed centre; the centre moves the circle.
|
||||
drag(&mut s, "c1", [5.0, 0.0], [0.0, 8.0]);
|
||||
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
|
||||
@@ -553,7 +553,7 @@ fn chain_follows_shared_and_coincident_ends() {
|
||||
#[test]
|
||||
fn constraints_and_dimensions_can_be_selected_and_deleted_together() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]], "connect": false}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
let ids: Vec<String> = sketch(&s).constraints.iter().map(|c| c.id.clone()).collect();
|
||||
|
||||
@@ -257,3 +257,18 @@ fn construction_lives_in_the_component_frame() {
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 20, 0]}));
|
||||
assert!(near(point(&s, "PinTop"), Vec3::new(50.0, 20.0, 10.0)), "{:?}", point(&s, "PinTop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plane_perpendicular_to_a_plane_through_a_line() {
|
||||
let mut s = part();
|
||||
// Through the box's front bottom edge (along X), square to the bottom: the front face's plane.
|
||||
run(&mut s, "construct.plane.perpendicular", json!({"plane": {"face": [20, 15, 0]}, "line": {"edge": [20, 0, 0]}, "name": "F"}));
|
||||
let f = plane(&s, "F");
|
||||
assert!(parallel(f.normal(), Vec3::Y) && on_plane(&f, Vec3::new(7.0, 0.0, 13.0)), "{f:?}");
|
||||
// Through the world Z axis, square to XZ: the YZ plane.
|
||||
run(&mut s, "construct.plane.perpendicular", json!({"plane": "XZ", "line": {"axis": "Z"}, "name": "G"}));
|
||||
let g = plane(&s, "G");
|
||||
assert!(parallel(g.normal(), Vec3::X) && on_plane(&g, Vec3::new(0.0, 5.0, 9.0)), "{g:?}");
|
||||
// A line square to the plane has no single answer.
|
||||
assert!(s.execute("construct.plane.perpendicular", &json!({"plane": "XY", "line": {"axis": "Z"}})).is_err());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Random modelling sessions for robustness testing (`solvecraft-cli fuzz`, docs/robustness.md).
|
||||
//!
|
||||
//! A seeded generator builds realistic feature sequences: a base (box, extruded sketch profile
|
||||
//! with an arc, revolved section), then joins and cuts of boxes, cylinders and spheres placed on
|
||||
//! a coarse grid against the body (so coincident faces and tangencies come up often), fillets
|
||||
//! and chamfers on random edges, shells, holes and patterns. Every step is checked: the command
|
||||
//! succeeds, every body is a sound solid, and the volume moves the right way.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
use solvecraft_kernel::fuzz::Rng;
|
||||
pub use solvecraft_kernel::fuzz::{BoolCase, boolean_case, boolean_shapes};
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
/// One step: the commands it runs (a sketch and its feature go together).
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Step {
|
||||
pub kind: &'static str,
|
||||
pub commands: Vec<(String, Value)>,
|
||||
/// How the total volume must move.
|
||||
#[serde(skip)]
|
||||
expect: Expect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Expect {
|
||||
Any,
|
||||
Grow,
|
||||
Shrink,
|
||||
}
|
||||
|
||||
/// What happened at a step.
|
||||
#[derive(Clone, Debug, Serialize, PartialEq)]
|
||||
pub struct Outcome {
|
||||
/// "ok", "unsupported" (a feature the kernel doesn't do yet), "rejected" (the generator's
|
||||
/// parameters were refused), "failed", "panic" (one that escaped every guard), "invalid" (a
|
||||
/// body that isn't a sound solid), "volume".
|
||||
pub kind: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
fn ok() -> Outcome {
|
||||
Outcome { kind: "ok".into(), detail: String::new() }
|
||||
}
|
||||
fn new(kind: &str, detail: impl Into<String>) -> Outcome {
|
||||
Outcome { kind: kind.into(), detail: detail.into() }
|
||||
}
|
||||
/// A failure the kernel should not have (not a known limitation or a refused input).
|
||||
pub fn is_bug(&self) -> bool {
|
||||
matches!(self.kind.as_str(), "failed" | "panic" | "invalid" | "volume")
|
||||
}
|
||||
}
|
||||
|
||||
/// A design's run: its steps, and what happened at the last one run.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Run {
|
||||
pub seed: u64,
|
||||
pub steps: Vec<Step>,
|
||||
pub outcome: Outcome,
|
||||
/// Steps refused as not supported (kind, message): undone, not part of the design.
|
||||
pub unsupported: Vec<(String, String)>,
|
||||
/// Steps whose generated parameters a command refused.
|
||||
pub rejected: usize,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
/// The commands as a script for `solvecraft-cli run`.
|
||||
pub fn script(&self) -> Value {
|
||||
Value::Array(self.steps.iter().flat_map(|s| s.commands.iter().map(|(c, p)| json!({"command": c, "params": p}))).collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn total_volume(s: &Session) -> f64 {
|
||||
s.model.state().bodies.iter().filter_map(|b| solvecraft_kernel::measure(&b.body).ok()).map(|m| m.volume).sum()
|
||||
}
|
||||
|
||||
fn classify(e: &EngineError) -> Outcome {
|
||||
let msg = e.to_string();
|
||||
match e {
|
||||
EngineError::Internal(..) => Outcome::new("panic", msg),
|
||||
EngineError::BadParams { .. } | EngineError::Disabled(..) | EngineError::UnknownCommand(_) => Outcome::new("rejected", msg),
|
||||
// A failed boolean is a failure even when it says "not supported" (coincident faces on
|
||||
// curved bodies): those are the cases to fix. Features the kernel doesn't do yet (a
|
||||
// fillet between curved faces) are limitations.
|
||||
// Picks the generator got wrong (a centroid off its face).
|
||||
EngineError::Other(_) if msg.starts_with("invalid input") => Outcome::new("rejected", msg),
|
||||
EngineError::Other(_) if msg.contains("boolean") => Outcome::new("failed", msg),
|
||||
EngineError::Other(_) if msg.contains("not supported") || msg.contains("only ") => Outcome::new("unsupported", msg),
|
||||
EngineError::Other(_) => Outcome::new("failed", msg),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one step and check it.
|
||||
fn apply(s: &mut Session, step: &Step) -> Outcome {
|
||||
let before = total_volume(s);
|
||||
for (c, p) in &step.commands {
|
||||
if let Err(e) = s.execute(c, p) {
|
||||
return classify(&e);
|
||||
}
|
||||
}
|
||||
let st = s.model.state();
|
||||
if let Some(f) = s.model.results.iter().find(|r| r.error.is_some()) {
|
||||
return Outcome::new("failed", format!("{}: {}", f.name, f.error.clone().unwrap_or_default()));
|
||||
}
|
||||
for b in &st.bodies {
|
||||
let bad = b.body.validity();
|
||||
if !bad.is_empty() {
|
||||
return Outcome::new("invalid", format!("{}: {}", b.name, bad.join("; ")));
|
||||
}
|
||||
}
|
||||
let after = total_volume(s);
|
||||
let slack = 1e-4 * before.max(after) + 1e-6;
|
||||
let wrong = match step.expect {
|
||||
Expect::Any => false,
|
||||
Expect::Grow => after < before - slack,
|
||||
Expect::Shrink => after > before + slack,
|
||||
};
|
||||
if wrong || !after.is_finite() {
|
||||
return Outcome::new("volume", format!("{} moved the volume the wrong way: {before:.4} → {after:.4}", step.kind));
|
||||
}
|
||||
Outcome::ok()
|
||||
}
|
||||
|
||||
fn step(kind: &'static str, expect: Expect, commands: Vec<(&str, Value)>) -> Step {
|
||||
Step { kind, expect, commands: commands.into_iter().map(|(c, p)| (c.to_string(), p)).collect() }
|
||||
}
|
||||
|
||||
/// The bodies' bounds (min, max).
|
||||
fn bounds(s: &Session) -> Option<(Vec3, Vec3)> {
|
||||
let st = s.model.state();
|
||||
let mut it = st.bodies.iter().map(|b| b.mesh().bounds());
|
||||
let first = it.next()?;
|
||||
Some(it.fold((first.min, first.max), |(lo, hi), b| {
|
||||
(Vec3::new(lo.x.min(b.min.x), lo.y.min(b.min.y), lo.z.min(b.min.z)), Vec3::new(hi.x.max(b.max.x), hi.y.max(b.max.y), hi.z.max(b.max.z)))
|
||||
}))
|
||||
}
|
||||
|
||||
const G: f64 = 2.5;
|
||||
|
||||
fn snap(x: f64) -> f64 {
|
||||
(x / G).round() * G
|
||||
}
|
||||
|
||||
/// A coordinate in the bounds: often exactly on a side (coincident faces), else on the grid.
|
||||
fn coord(r: &mut Rng, lo: f64, hi: f64) -> f64 {
|
||||
match r.below(4) {
|
||||
0 => lo,
|
||||
1 => hi,
|
||||
_ => snap(lo + (hi - lo) * r.unit()),
|
||||
}
|
||||
}
|
||||
|
||||
fn base(r: &mut Rng) -> Step {
|
||||
match r.below(3) {
|
||||
0 => {
|
||||
let (l, w, h) = (r.grid(10.0, 60.0, G), r.grid(10.0, 60.0, G), r.grid(5.0, 40.0, G));
|
||||
step("box", Expect::Any, vec![("solid.box", json!({"length": l, "width": w, "height": h}))])
|
||||
}
|
||||
1 => {
|
||||
// A profile of lines closed by an arc, extruded.
|
||||
let (w, h) = (r.grid(15.0, 50.0, G), r.grid(10.0, 40.0, G));
|
||||
let bulge = r.grid(2.5, (w / 2.0).min(15.0), G);
|
||||
let notch = r.chance(0.5);
|
||||
let mut pts = vec![json!([0, 0]), json!([w, 0]), json!([w, h])];
|
||||
if notch {
|
||||
let nx = snap(w * 0.6);
|
||||
pts.push(json!([nx, h]));
|
||||
pts.push(json!([nx, snap(h * 0.5)]));
|
||||
pts.push(json!([snap(w * 0.3), snap(h * 0.5)]));
|
||||
pts.push(json!([snap(w * 0.3), h]));
|
||||
}
|
||||
pts.push(json!([0, h]));
|
||||
let d = r.grid(5.0, 40.0, G);
|
||||
step(
|
||||
"extruded profile",
|
||||
Expect::Any,
|
||||
vec![
|
||||
("sketch.create", json!({"plane": "XY"})),
|
||||
("sketch.line", json!({"points": pts})),
|
||||
("sketch.arc.three_point", json!({"start": [0, h], "end": [0, 0], "through": [-bulge, h / 2.0]})),
|
||||
("sketch.finish", json!({})),
|
||||
("Extrude", json!({"distance": d})),
|
||||
],
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
let (r0, r1, h) = (r.grid(0.0, 10.0, G), r.grid(12.5, 30.0, G), r.grid(5.0, 30.0, G));
|
||||
let angle = r.pick(&["360 deg", "180 deg", "90 deg", "270 deg"]).unwrap_or("360 deg");
|
||||
step(
|
||||
"revolved section",
|
||||
Expect::Any,
|
||||
vec![
|
||||
("sketch.create", json!({"plane": "XZ"})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [r0, 0], "p1": [r1, h]})),
|
||||
("sketch.finish", json!({})),
|
||||
("Revolve", json!({"axis": "Z", "angle": angle})),
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn random_point(r: &mut Rng, lo: Vec3, hi: Vec3) -> [f64; 3] {
|
||||
[coord(r, lo.x, hi.x), coord(r, lo.y, hi.y), coord(r, lo.z, hi.z)]
|
||||
}
|
||||
|
||||
/// The next step for the design as it stands (None: nothing fits, try another).
|
||||
fn next_step(r: &mut Rng, s: &Session, last_feature: &Option<String>) -> Option<Step> {
|
||||
let (lo, hi) = bounds(s)?;
|
||||
let st = s.model.state();
|
||||
let body = st.bodies.get(r.below(st.bodies.len()))?;
|
||||
let tol = (body.body.size() * 1e-3).max(1e-3);
|
||||
Some(match r.below(12) {
|
||||
0 | 1 => {
|
||||
let a = random_point(r, lo, hi);
|
||||
let size = [r.grid(2.5, 25.0, G), r.grid(2.5, 25.0, G), r.grid(2.5, 25.0, G)];
|
||||
let join = r.chance(0.5);
|
||||
let (kind, expect, op) = if join { ("join box", Expect::Grow, "join") } else { ("cut box", Expect::Shrink, "cut") };
|
||||
step(kind, expect, vec![("solid.box", json!({"corner": a, "length": size[0], "width": size[1], "height": size[2], "operation": op}))])
|
||||
}
|
||||
2 | 3 => {
|
||||
let base = random_point(r, lo, hi);
|
||||
let axis = r.pick(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, -1.0]])?;
|
||||
let join = r.chance(0.4);
|
||||
let (kind, expect, op) = if join { ("join cylinder", Expect::Grow, "join") } else { ("cut cylinder", Expect::Shrink, "cut") };
|
||||
let p = json!({"base": base, "axis": axis, "radius": r.grid(1.25, 10.0, 1.25), "height": r.grid(2.5, 30.0, G), "operation": op});
|
||||
step(kind, expect, vec![("solid.cylinder", p)])
|
||||
}
|
||||
4 => {
|
||||
let c = random_point(r, lo, hi);
|
||||
step("cut sphere", Expect::Shrink, vec![("solid.sphere", json!({"center": c, "radius": r.grid(2.5, 10.0, G), "operation": "cut"}))])
|
||||
}
|
||||
5 | 6 => {
|
||||
let edges = body.body.edges(tol).ok()?;
|
||||
let n = 1 + r.below(3);
|
||||
let picks: Vec<[f64; 3]> = (0..n).filter_map(|_| edges.get(r.below(edges.len()))).map(|e| [e.mid.x, e.mid.y, e.mid.z]).collect();
|
||||
let rad = r.pick(&[0.5, 1.0, 2.0, 2.5, 3.0])?;
|
||||
step("fillet", Expect::Any, vec![("solid.fillet", json!({"edges": picks, "radius": rad}))])
|
||||
}
|
||||
7 => {
|
||||
let edges = body.body.edges(tol).ok()?;
|
||||
let n = 1 + r.below(3);
|
||||
let picks: Vec<[f64; 3]> = (0..n).filter_map(|_| edges.get(r.below(edges.len()))).map(|e| [e.mid.x, e.mid.y, e.mid.z]).collect();
|
||||
step("chamfer", Expect::Shrink, vec![("solid.chamfer", json!({"edges": picks, "distance": r.pick(&[0.5, 1.0, 2.0])?}))])
|
||||
}
|
||||
8 => {
|
||||
let faces: Vec<_> = body.body.faces(tol).ok()?.into_iter().filter(|f| f.plane_normal.is_some()).collect();
|
||||
let f = faces.get(r.below(faces.len()))?;
|
||||
let p = [f.centroid.x, f.centroid.y, f.centroid.z];
|
||||
step("shell", Expect::Shrink, vec![("solid.shell", json!({"faces": [p], "thickness": r.pick(&[1.0, 1.5, 2.0])?}))])
|
||||
}
|
||||
9 => {
|
||||
let faces: Vec<_> = body.body.faces(tol).ok()?.into_iter().filter(|f| f.plane_normal.is_some()).collect();
|
||||
let f = faces.get(r.below(faces.len()))?;
|
||||
let p = [f.centroid.x, f.centroid.y, f.centroid.z];
|
||||
let mut h = json!({"position": p, "diameter": r.grid(2.0, 8.0, 1.0)});
|
||||
if r.chance(0.5) {
|
||||
h["depth"] = json!(r.grid(2.5, 20.0, G));
|
||||
if r.chance(0.5) {
|
||||
h["type"] = json!("drilled");
|
||||
}
|
||||
}
|
||||
step("hole", Expect::Shrink, vec![("solid.hole", h)])
|
||||
}
|
||||
10 => {
|
||||
let name = last_feature.clone()?;
|
||||
let dir = r.pick(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])?;
|
||||
let p = json!({"features": [name], "dir1": dir, "count1": 2 + r.below(2), "spacing1": r.grid(5.0, 20.0, G)});
|
||||
step("rectangular pattern", Expect::Any, vec![("solid.pattern.rectangular", p)])
|
||||
}
|
||||
_ => {
|
||||
let name = last_feature.clone()?;
|
||||
let p = json!({"features": [name], "axis": "Z", "count": 2 + r.below(5)});
|
||||
step("circular pattern", Expect::Any, vec![("solid.pattern.circular", p)])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate and run a design of up to `max_steps` steps, stopping at the first problem. Steps
|
||||
/// refused as unsupported (or with parameters the command rejects) are undone and counted.
|
||||
pub fn run(seed: u64, max_steps: usize) -> Run {
|
||||
run_with(seed, max_steps, &mut |_| {})
|
||||
}
|
||||
|
||||
/// [`run`], telling `on_step` about each step before it runs (to see where a hang happened).
|
||||
pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) -> Run {
|
||||
let mut r = Rng::new(seed);
|
||||
let mut s = Session::default();
|
||||
let mut run = Run { seed, steps: Vec::new(), outcome: Outcome::ok(), unsupported: Vec::new(), rejected: 0 };
|
||||
let mut last_feature: Option<String> = None;
|
||||
let n = 2 + r.below(max_steps.max(2) - 1);
|
||||
let mut tries = 0;
|
||||
while run.steps.len() < n && tries < n * 4 {
|
||||
tries += 1;
|
||||
let st = if run.steps.is_empty() { Some(base(&mut r)) } else { next_step(&mut r, &s, &last_feature) };
|
||||
let Some(st) = st else { continue };
|
||||
let before = s.doc.features.len();
|
||||
on_step(&st);
|
||||
let out = apply(&mut s, &st);
|
||||
if out.kind == "rejected" || out.kind == "unsupported" {
|
||||
// Not this one: undo what it added and try another.
|
||||
while s.doc.features.len() > before {
|
||||
s.doc_mut().features.pop();
|
||||
}
|
||||
s.refresh();
|
||||
if out.kind == "rejected" {
|
||||
run.rejected += 1;
|
||||
} else {
|
||||
run.unsupported.push((st.kind.to_string(), out.detail));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let kind = st.kind;
|
||||
run.steps.push(st);
|
||||
if out.kind != "ok" {
|
||||
run.outcome = out;
|
||||
return run;
|
||||
}
|
||||
if matches!(kind, "join box" | "cut box" | "join cylinder" | "cut cylinder" | "cut sphere" | "hole") {
|
||||
last_feature = s.doc.features.last().map(|f| f.name.clone());
|
||||
}
|
||||
}
|
||||
run
|
||||
}
|
||||
|
||||
/// Run fixed steps (a recorded or minimised design): the index and outcome of the first step
|
||||
/// that isn't ok (or the last step's, ok).
|
||||
pub fn replay(steps: &[Step]) -> (usize, Outcome) {
|
||||
let mut s = Session::default();
|
||||
for (i, st) in steps.iter().enumerate() {
|
||||
let out = apply(&mut s, st);
|
||||
if out.kind != "ok" {
|
||||
return (i, out);
|
||||
}
|
||||
}
|
||||
(steps.len().saturating_sub(1), Outcome::ok())
|
||||
}
|
||||
|
||||
/// The start of a message, to tell failures apart (numbers vary between versions of a case).
|
||||
fn gist(d: &str) -> String {
|
||||
d.chars().take_while(|c| !c.is_ascii_digit()).take(60).collect()
|
||||
}
|
||||
|
||||
/// A failing design cut down: steps are dropped while the last one still fails the same way.
|
||||
pub fn minimise(run: &Run) -> Run {
|
||||
let mut steps = run.steps.clone();
|
||||
let (kind, g) = (run.outcome.kind.clone(), gist(&run.outcome.detail));
|
||||
let same = |st: &[Step]| {
|
||||
let (i, o) = replay(st);
|
||||
i + 1 == st.len() && o.kind == kind && gist(&o.detail) == g
|
||||
};
|
||||
let mut changed = true;
|
||||
while changed && steps.len() > 1 {
|
||||
changed = false;
|
||||
let mut i = 0;
|
||||
while i + 1 < steps.len() {
|
||||
let mut t = steps.clone();
|
||||
t.remove(i);
|
||||
if same(&t) {
|
||||
steps = t;
|
||||
changed = true;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (_, outcome) = replay(&steps);
|
||||
Run { seed: run.seed, steps, outcome, unsupported: Vec::new(), rejected: 0 }
|
||||
}
|
||||
@@ -9,7 +9,9 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod frames;
|
||||
pub mod fuzz;
|
||||
pub mod legacy_ids;
|
||||
pub mod licences;
|
||||
pub mod params;
|
||||
pub mod recovery;
|
||||
pub mod sample;
|
||||
@@ -432,6 +434,10 @@ mod delete_face_tests;
|
||||
#[cfg(test)]
|
||||
mod extent_tests;
|
||||
#[cfg(test)]
|
||||
mod pattern_suppress_tests;
|
||||
#[cfg(test)]
|
||||
mod profile_refs_tests;
|
||||
#[cfg(test)]
|
||||
mod split_face_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! SolveCraft's licence texts and the third-party licences, built into every program so a single
|
||||
//! downloaded executable carries them (Help ▸ About ▸ Licences, `solvecraft-cli licences`).
|
||||
//! THIRD-PARTY-LICENSES.txt is written by `cargo xtask licences` and checked by `cargo xtask ci`.
|
||||
|
||||
pub const LICENSE_MIT: &str = include_str!("../../../LICENSE-MIT");
|
||||
pub const LICENSE_APACHE: &str = include_str!("../../../LICENSE-APACHE");
|
||||
pub const NOTICE: &str = include_str!("../../../NOTICE");
|
||||
pub const THIRD_PARTY: &str = include_str!("../../../THIRD-PARTY-LICENSES.txt");
|
||||
|
||||
/// Everything, as one text.
|
||||
pub fn all() -> String {
|
||||
let rule = "=".repeat(80);
|
||||
format!(
|
||||
"SolveCraft is licensed under the MIT License or the Apache License, Version 2.0, at your option.\n\n{rule}\nNOTICE\n{rule}\n\n{NOTICE}\n{rule}\nLICENSE-MIT\n{rule}\n\n{LICENSE_MIT}\n{rule}\nLICENSE-APACHE\n{rule}\n\n{LICENSE_APACHE}\n{rule}\n{THIRD_PARTY}"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn the_licences_are_built_in() {
|
||||
assert!(super::LICENSE_MIT.contains("MIT License") && super::LICENSE_APACHE.contains("Apache License"));
|
||||
assert!(super::THIRD_PARTY.contains("truck") && super::THIRD_PARTY.contains("egui"));
|
||||
assert!(super::all().contains("NOTICE"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! A pattern of a suppressed feature has nothing to copy (#42): its copies go too, and the
|
||||
//! pattern says so.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(s: &Session, body: &str) -> f64 {
|
||||
s.model.state().body(body).map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppressing_a_patterned_feature_removes_its_copies() {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/acceptance/sensor_enclosure.json")).unwrap();
|
||||
s.run_script(&script).unwrap();
|
||||
let full = volume(&s, "Case");
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Standoff", "suppressed": true}));
|
||||
let without = volume(&s, "Case");
|
||||
// All four standoffs go (each 116.8 mm³), not just the seed.
|
||||
assert!((full - without - 4.0 * 116.8).abs() < 2.0, "{full} → {without}");
|
||||
let pattern = s.doc.find_feature("RectangularPattern1").unwrap().id;
|
||||
let r = s.model.result(pattern).unwrap();
|
||||
assert!(r.error.is_none() && r.warning.as_deref().is_some_and(|w| w.contains("Standoff")), "{r:?}");
|
||||
// Back on: the four standoffs return.
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Standoff", "suppressed": false}));
|
||||
assert!((volume(&s, "Case") - full).abs() < 1e-6 * full);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Profile picks survive recomputes (#30): picked by index, they are kept by the curves around
|
||||
//! them, so a face sketch whose projected outline is rebuilt still extrudes the same region.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The demo enclosure (80 × 60 × 30, rounded, shelled) with a USB port cut through a side, the
|
||||
/// port picked by index as the Extrude dialog does.
|
||||
fn enclosure() -> Session {
|
||||
let mut s = Session::default();
|
||||
let script = json!([
|
||||
{"command": "parameters.change", "params": {"name": "width", "expression": "80 mm", "comment": "outside, X"}},
|
||||
{"command": "parameters.change", "params": {"name": "depth", "expression": "60 mm", "comment": "outside, Y"}},
|
||||
{"command": "parameters.change", "params": {"name": "height", "expression": "30 mm"}},
|
||||
{"command": "parameters.change", "params": {"name": "wall", "expression": "2 mm"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Outline"}},
|
||||
{"command": "sketch.rectangle.center", "params": {"center": [0, 0], "corner": [40, 30]}},
|
||||
{"command": "sketch.constraint.coincident", "params": {"a": "p5", "b": "origin"}},
|
||||
{"command": "sketch.dimension", "params": {"entities": ["l1"], "value": "width"}},
|
||||
{"command": "sketch.dimension", "params": {"entities": ["l2"], "value": "depth"}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": "height", "body_name": "Enclosure"}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[40, 30, 15], [-40, 30, 15], [-40, -30, 15], [40, -30, 15]], "radius": 6}},
|
||||
{"command": "solid.shell", "params": {"faces": [[0, 0, 30]], "thickness": "wall"}},
|
||||
{"command": "sketch.create", "params": {"plane": {"face": [40, 10, 20]}, "name": "USB port"}},
|
||||
{"command": "sketch.rectangle.center", "params": {"center": [0, 12], "corner": [6, 15.5]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": "-wall", "operation": "cut", "profiles": [1]}}
|
||||
]);
|
||||
s.run_script(&script).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn bodies(s: &Session) -> Vec<(String, f64)> {
|
||||
s.model.state().bodies.iter().map(|b| (b.name.clone(), b.body.tessellate(1e-2).unwrap().measure().volume)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_port_picked_by_index_stays_a_cut_after_a_width_change() {
|
||||
let mut s = enclosure();
|
||||
let f = s.doc.features.last().unwrap();
|
||||
let solvecraft_doc::FeatureKind::Extrude { profiles, .. } = &f.kind else { panic!("{:?}", f.kind) };
|
||||
assert!(matches!(profiles, solvecraft_doc::ProfileSel::Curves { .. }), "stored by curves: {profiles:?}");
|
||||
let before = bodies(&s);
|
||||
assert_eq!(before.len(), 1, "{before:?}");
|
||||
run(&mut s, "parameters.change", json!({"name": "width", "expression": "100 mm"}));
|
||||
let after = bodies(&s);
|
||||
assert_eq!(after.len(), 1, "the port is still a cut, not a new body: {after:?}");
|
||||
// The longer box less the same port: more wall and floor, the port still taken out.
|
||||
assert!(after[0].1 > before[0].1 + 1000.0, "{before:?} {after:?}");
|
||||
let id = s.doc.features.last().unwrap().id;
|
||||
assert!(s.model.result(id).is_some_and(|r| r.error.is_none()), "{:?}", s.model.result(id));
|
||||
}
|
||||
|
||||
/// A pick the sketch no longer has is an error on the feature, never a different profile.
|
||||
#[test]
|
||||
fn a_lost_profile_is_an_error_not_another_pick() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10], "ids": ["a", "b", "c", "d"]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [30, 5], "radius": 3, "id": "ring"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let ring = s.model.state().sketches[0].profiles.iter().position(|p| p.outer_curves == ["ring"]).unwrap();
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "profiles": [ring], "distance": 5}));
|
||||
let n = s.model.state().bodies.len();
|
||||
assert_eq!(n, 1);
|
||||
// Delete the picked circle: the extrude fails with a message instead of taking the square.
|
||||
let sk = s.doc.find_feature("S").unwrap().id;
|
||||
let picked = match &s.doc.features.last().unwrap().kind {
|
||||
solvecraft_doc::FeatureKind::Extrude { profiles: solvecraft_doc::ProfileSel::Curves { loops }, .. } => loops[0].clone(),
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
run(&mut s, "sketch.edit", json!({"sketch": sk}));
|
||||
run(&mut s, "sketch.delete", json!({"entities": picked}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(s.model.state().bodies.is_empty(), "no silent re-pick: {:?}", bodies(&s));
|
||||
let id = s.doc.features.last().unwrap().id;
|
||||
assert!(s.model.result(id).is_some_and(|r| r.error.is_some()), "{:?}", s.model.result(id));
|
||||
}
|
||||
@@ -62,3 +62,38 @@ fn a_tool_that_misses_is_refused() {
|
||||
assert!(s.execute("solid.split_face", &json!({"faces": [[10, 15, 20]], "plane": {"origin": [100, 0, 0], "normal": [1, 0, 0]}})).is_err());
|
||||
assert_eq!(s.doc.features.len(), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_keeps_split_faces_through_later_features() {
|
||||
let mut s = boxed();
|
||||
run(&mut s, "solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}}));
|
||||
// A later feature rebuilds the body; the split stays a split in the file.
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 0]], "radius": 2}));
|
||||
let (f, _) = faces_volume(&mut s, "B");
|
||||
assert_eq!(f, 8);
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-split-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let step = dir.join("split.step");
|
||||
run(&mut s, "file.export", json!({"path": step.to_string_lossy()}));
|
||||
let text = std::fs::read_to_string(&step).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// Six box faces, the top's second piece and the fillet.
|
||||
assert_eq!(text.matches("ADVANCED_FACE(").count(), 8, "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_pieces_count_and_move_apart() {
|
||||
let mut s = boxed();
|
||||
run(&mut s, "solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}}));
|
||||
// Measure counts the two halves of the top as two faces.
|
||||
let m = run(&mut s, "inspect.measure", json!({ "bodies": ["B"] }))["bodies"][0].clone();
|
||||
assert_eq!(m["faces"].as_u64(), Some(7), "{m}");
|
||||
// Offset Face on one half moves that half only (Fusion): a 20 × 30 step 5 high.
|
||||
run(&mut s, "solid.offset_face", json!({"faces": [[10, 15, 20]], "distance": 5}));
|
||||
let (_, v) = faces_volume(&mut s, "B");
|
||||
assert!((v - 27000.0).abs() < 1e-6 * 27000.0, "{v}");
|
||||
// And the other half down 3: a 20 × 30 pocket.
|
||||
run(&mut s, "solid.offset_face", json!({"faces": [[30, 15, 20]], "distance": -3}));
|
||||
let (_, v) = faces_volume(&mut s, "B");
|
||||
assert!((v - 25200.0).abs() < 1e-6 * 25200.0, "{v}");
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ fn rel(a: f64, b: f64) -> f64 {
|
||||
}
|
||||
|
||||
mod component_frames;
|
||||
mod robustness;
|
||||
|
||||
/// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole.
|
||||
#[test]
|
||||
@@ -636,6 +637,19 @@ fn section_analysis() {
|
||||
assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 0, 0]}})).is_err());
|
||||
run(&mut s, "inspect.section", json!({"clear": true}));
|
||||
assert!(s.section.is_none());
|
||||
// XZ's normal is -Y: an offset moves the cut to -Y, `at` places it at a world Y, and the
|
||||
// result says where the cut landed either way.
|
||||
let r = run(&mut s, "inspect.section", json!({"plane": "XZ", "offset": 10}));
|
||||
assert_eq!((r["section"]["axis"].as_str(), r["section"]["at"].as_f64()), (Some("Y"), Some(-10.0)), "{r}");
|
||||
for (plane, at, flip) in [("XZ", -282.5, false), ("XZ", 7.0, true), ("XY", 3.0, false), ("YZ", -4.0, true)] {
|
||||
let r = run(&mut s, "inspect.section", json!({"plane": plane, "at": at, "flip": flip}));
|
||||
assert_eq!(r["section"]["at"].as_f64(), Some(at), "{plane} {r}");
|
||||
let (o, _) = s.section.unwrap();
|
||||
assert!(((o.x + o.y + o.z) - at).abs() < 1e-12, "{plane} {o:?}");
|
||||
}
|
||||
assert!(s.execute("inspect.section", &json!({"plane": "XZ", "at": 1, "offset": 1})).is_err());
|
||||
assert!(s.execute("inspect.section", &json!({"plane": {"normal": [0, 1, 0]}, "at": 1})).is_err());
|
||||
run(&mut s, "inspect.section", json!({"clear": true}));
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
@@ -1068,6 +1082,29 @@ fn chord_and_variable_fillets() {
|
||||
let removed = (1.0 - PI / 4.0) * 20.0 * (4.0 + 10.0 + 25.0) / 3.0;
|
||||
assert!(rel(v, 6000.0 - removed) < 1e-4, "{v}");
|
||||
assert!(s.execute("solid.fillet", &json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable"})).is_err());
|
||||
// A radius through a middle point (a user parameter), straight between: 2 → 4 → 2.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "B"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "waist", "expression": "4 mm"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.fillet",
|
||||
json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable", "radius2": 2, "start": [10, 0, 30], "mid": [[0.5, "waist"]]}),
|
||||
);
|
||||
let vol = |s: &Session| solvecraft_kernel::measure(&s.world_state().body("B").unwrap().body).unwrap().volume;
|
||||
let removed = 2.0 * (1.0 - PI / 4.0) * 10.0 * (4.0 + 8.0 + 16.0) / 3.0;
|
||||
assert!(rel(vol(&s), 6000.0 - removed) < 1e-4, "{}", vol(&s));
|
||||
// The middle radius follows its parameter.
|
||||
run(&mut s, "parameters.change", json!({"name": "waist", "expression": "3 mm"}));
|
||||
let removed = 2.0 * (1.0 - PI / 4.0) * 10.0 * (4.0 + 6.0 + 9.0) / 3.0;
|
||||
assert!(rel(vol(&s), 6000.0 - removed) < 1e-4, "{}", vol(&s));
|
||||
assert!(
|
||||
s.execute(
|
||||
"solid.fillet",
|
||||
&json!({"edges": [[10, 10, 30]], "radius": 2, "type": "variable", "radius2": 2, "start": [10, 0, 30], "mid": [[1.5, 3]]})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// Components export as a STEP assembly: products, occurrences, placements.
|
||||
@@ -1823,3 +1860,71 @@ fn boundary_fill_cells() {
|
||||
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap_or(0.0), 2.0 * 10.0 * 20.0 * 20.0) < 1e-9, "{m}");
|
||||
assert!(s.execute("SurfaceSculpt", &json!({"tools": ["A"], "cells": [[100, 100, 100]]})).is_err());
|
||||
}
|
||||
|
||||
/// Rectangles name their sides and corners, so they can be dimensioned without inspecting the
|
||||
/// sketch first, whichever way round the corners were given.
|
||||
#[test]
|
||||
fn rectangle_names_sides_and_corners() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [40, 30], "p1": [0, 0]}));
|
||||
let pos = |s: &Session, id: &Value| {
|
||||
let sk = s.doc.sketch(s.active_sketch.unwrap()).unwrap();
|
||||
sk.point(sk.resolve_point(id.as_str().unwrap()).unwrap()).unwrap()
|
||||
};
|
||||
for (corner, at) in [("bottom_left", [0.0, 0.0]), ("bottom_right", [40.0, 0.0]), ("top_right", [40.0, 30.0]), ("top_left", [0.0, 30.0])] {
|
||||
let p = pos(&s, &r["corners"][corner]);
|
||||
assert!((p.x - at[0]).abs() < 1e-9 && (p.y - at[1]).abs() < 1e-9, "{corner}: {p:?} in {r}");
|
||||
}
|
||||
for (side, from, to) in [
|
||||
("bottom", "bottom_left", "bottom_right"),
|
||||
("right", "bottom_right", "top_right"),
|
||||
("top", "top_right", "top_left"),
|
||||
("left", "top_left", "bottom_left"),
|
||||
] {
|
||||
let line = r["sides"][side].as_str().unwrap();
|
||||
assert!(r["curves"].as_array().unwrap().iter().any(|c| c == line), "{r}");
|
||||
assert_eq!(pos(&s, &json!(format!("{line}.start"))), pos(&s, &r["corners"][from]), "{side} in {r}");
|
||||
assert_eq!(pos(&s, &json!(format!("{line}.end"))), pos(&s, &r["corners"][to]), "{side} in {r}");
|
||||
}
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": r["corners"]["bottom_left"], "b": "origin"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [r["sides"]["bottom"]], "value": 50}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [r["sides"]["left"]], "value": 20}));
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(si["dof"], 0, "{si}");
|
||||
let p = pos(&s, &r["corners"]["top_right"]);
|
||||
assert!((p.x - 50.0).abs() < 1e-6 && (p.y - 20.0).abs() < 1e-6, "{p:?}");
|
||||
|
||||
let c = run(&mut s, "sketch.rectangle.center", json!({"center": [100, 0], "corner": [90, 5]}));
|
||||
let p = pos(&s, &c["corners"]["bottom_left"]);
|
||||
assert!((p.x - 90.0).abs() < 1e-9 && (p.y + 5.0).abs() < 1e-9, "{c}");
|
||||
assert_eq!(pos(&s, &json!(format!("{}.start", c["sides"]["top"].as_str().unwrap()))), pos(&s, &c["corners"]["top_right"]), "{c}");
|
||||
}
|
||||
|
||||
/// Points drawn exactly onto an existing point (the origin, a corner) are connected to it;
|
||||
/// `connect: false` leaves them free, and tying them again by hand is a no-op, not a conflict.
|
||||
#[test]
|
||||
fn drawing_on_existing_points_connects_to_them() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let n = |v: &Value| v["constraints"].as_array().map_or(0, Vec::len);
|
||||
// Horizontal/vertical x4, plus the corner on the origin.
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
assert_eq!(n(&r), 5, "{r}");
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [r["curves"][0]], "value": 40}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [r["curves"][1]], "value": 30}));
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(si["dof"], 0, "fully constrained without a hand-made coincident: {si}");
|
||||
let again = run(&mut s, "sketch.constraint.coincident", json!({"a": "origin", "b": "l1.start"}));
|
||||
assert_eq!(again["existing"], true, "{again}");
|
||||
|
||||
// A line from a corner, a circle on a corner and a point on the origin connect too.
|
||||
assert_eq!(n(&run(&mut s, "sketch.line", json!({"points": [[40, 30], [60, 30]]}))), 1);
|
||||
assert_eq!(n(&run(&mut s, "sketch.circle.center", json!({"center": [0, 30], "radius": 3}))), 1);
|
||||
assert_eq!(n(&run(&mut s, "sketch.point", json!({"point": [0, 0]}))), 1);
|
||||
// Close is not on it, and `connect: false` opts out.
|
||||
assert_eq!(n(&run(&mut s, "sketch.point", json!({"point": [40.001, 0]}))), 0);
|
||||
assert_eq!(n(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, -10]], "connect": false}))), 0);
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_ne!(si["status"], "conflict", "{si}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Cases found by the robustness runs (`solvecraft-cli fuzz`, docs/robustness.md), cut down to
|
||||
//! the steps that matter, and a fixed set of seeds that must keep running clean.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{run, volume};
|
||||
use crate::Session;
|
||||
use crate::fuzz;
|
||||
|
||||
/// Run a script; every command must succeed and every body be a sound solid.
|
||||
fn script(steps: Value) -> Session {
|
||||
let mut s = Session::default();
|
||||
for st in steps.as_array().into_iter().flatten() {
|
||||
let (c, p) = (st["command"].as_str().unwrap_or(""), &st["params"]);
|
||||
if let Err(e) = s.execute(c, p) {
|
||||
panic!("{c} {p}: {e}");
|
||||
}
|
||||
}
|
||||
for b in &s.model.state().bodies {
|
||||
let bad = b.body.validity();
|
||||
assert!(bad.is_empty(), "{}: {bad:?}", b.name);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Shell of a notched plate closed by a shallow arc, opening a notch wall: the arc's cylinder
|
||||
/// meets the plates at an angle, so its corners need more than one linear step to move.
|
||||
#[test]
|
||||
fn shell_with_a_shallow_arc_side() {
|
||||
script(json!([
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.line", "params": {"points": [[0, 0], [37.5, 0], [37.5, 37.5], [22.5, 37.5], [22.5, 20.0], [12.5, 20.0], [12.5, 37.5], [0, 37.5]]}},
|
||||
{"command": "sketch.arc.three_point", "params": {"end": [0, 0], "start": [0, 37.5], "through": [-5.0, 18.75]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 7.5}},
|
||||
{"command": "solid.shell", "params": {"faces": [[12.5, 28.75, 3.75]], "thickness": 2.0}}
|
||||
]));
|
||||
}
|
||||
|
||||
/// Shell of a plate with a hole whose wall meets the plate's arc side: the edge where the two
|
||||
/// cylinders meet is neither a line nor an arc, and is rebuilt through its moved points.
|
||||
#[test]
|
||||
fn shell_with_a_hole_through_a_curved_side() {
|
||||
script(json!([
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.line", "params": {"points": [[0, 0], [42.5, 0], [42.5, 20.0], [0, 20.0]]}},
|
||||
{"command": "sketch.arc.three_point", "params": {"end": [0, 0], "start": [0, 20.0], "through": [-10.0, 10.0]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 27.5}},
|
||||
{"command": "solid.cylinder", "params": {"axis": [1.0, 0.0, 0.0], "base": [-10.000000000000002, 12.5, 12.5], "height": 17.5, "operation": "cut", "radius": 5.0}},
|
||||
{"command": "solid.shell", "params": {"faces": [[42.5, 10.0, 13.75]], "thickness": 2.0}}
|
||||
]));
|
||||
}
|
||||
|
||||
/// Shell of a body in two pieces (a cylinder joined clear of a ring): each piece is shelled.
|
||||
#[test]
|
||||
fn shell_of_a_body_in_two_pieces() {
|
||||
script(json!([
|
||||
{"command": "sketch.create", "params": {"plane": "XZ"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [2.5, 0], "p1": [22.5, 17.5]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.revolve", "params": {"angle": "360 deg", "axis": "Z"}},
|
||||
{"command": "solid.cylinder", "params": {"axis": [0.0, 1.0, 0.0], "base": [-22.5, 22.5, 0.0], "height": 30.0, "operation": "join", "radius": 10.0}},
|
||||
{"command": "solid.shell", "params": {"faces": [[-22.5, 22.5, 0.0]], "thickness": 2.0}}
|
||||
]));
|
||||
}
|
||||
|
||||
/// A shell thicker than a step under a notch would turn the cavity inside out: a clear "not
|
||||
/// supported yet" error instead of a failed boolean.
|
||||
#[test]
|
||||
fn shell_thicker_than_a_step_says_so() {
|
||||
let mut s = Session::default();
|
||||
for (c, p) in [
|
||||
("solid.box", json!({"height": 7.5, "length": 30.0, "width": 35.0})),
|
||||
("solid.box", json!({"corner": [0.0, 30.0, 2.5], "height": 22.5, "length": 22.5, "operation": "cut", "width": 20.0})),
|
||||
] {
|
||||
s.execute(c, &p).unwrap();
|
||||
}
|
||||
let e = s.execute("solid.shell", &json!({"faces": [[18.75, 35.0, 2.5]], "thickness": 1.5})).unwrap_err();
|
||||
assert!(e.to_string().contains("not supported yet"), "{e}");
|
||||
}
|
||||
|
||||
/// Panics inside the kernel are caught natively, but abort the web build (wasm can't unwind).
|
||||
/// Building the samples must not panic at all (the Plastic Enclosure's bosses used to, 8 times).
|
||||
#[test]
|
||||
fn samples_build_without_panics() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let me = std::thread::current().id();
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let c2 = count.clone();
|
||||
let prev = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if std::thread::current().id() == me {
|
||||
c2.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
let _ = info;
|
||||
}));
|
||||
let mut failed = Vec::new();
|
||||
for sample in crate::sample::samples() {
|
||||
let mut s = Session::default();
|
||||
for c in (sample.script)()["commands"].as_array().into_iter().flatten() {
|
||||
let _ = s.execute(c["command"].as_str().unwrap_or(""), &c["params"]);
|
||||
}
|
||||
let n = count.swap(0, Ordering::SeqCst);
|
||||
if n > 0 {
|
||||
failed.push(format!("{}: {n} panic(s)", sample.name));
|
||||
}
|
||||
}
|
||||
std::panic::set_hook(prev);
|
||||
assert!(failed.is_empty(), "{failed:?}");
|
||||
}
|
||||
|
||||
fn total_volume(s: &Session) -> f64 {
|
||||
s.model.state().bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).map(|m| m.volume).unwrap_or(0.0)).sum()
|
||||
}
|
||||
|
||||
/// Volume of a box L×W×H with its vertical edges rounded (R) and its bottom loop filleted
|
||||
/// (ρ ≤ R): the straight runs lose (1 − π/4)ρ² each, the corners a quarter turn of that
|
||||
/// (Pappus, about the corner's axis).
|
||||
fn rounded_box(l: f64, w: f64, h: f64, r: f64, rho: f64) -> f64 {
|
||||
use std::f64::consts::PI;
|
||||
let a = (1.0 - PI / 4.0) * rho * rho;
|
||||
let xbar = rho * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI));
|
||||
l * w * h - (4.0 - PI) * r * r * h - a * (2.0 * (l - 2.0 * r) + 2.0 * (w - 2.0 * r)) - 2.0 * PI * (r - xbar) * a
|
||||
}
|
||||
|
||||
/// Box, fillets, shell: a 60×40×30 box with its vertical edges rounded r5 and its bottom loop
|
||||
/// r3, shelled 2 mm with the top open. The bottom round runs round the corners as spindle tori
|
||||
/// (the tube wider than its circle), which the offset now takes.
|
||||
#[test]
|
||||
fn shell_of_a_filleted_box() {
|
||||
let s = script(json!([
|
||||
{"command": "solid.box", "params": {"length": 60, "width": 40, "height": 30}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[0, 0, 15], [60, 0, 15], [60, 40, 15], [0, 40, 15]], "radius": 5}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[30, 0, 0], [60, 20, 0], [30, 40, 0], [0, 20, 0]], "radius": 3}},
|
||||
{"command": "solid.shell", "params": {"faces": [[30, 20, 30]], "thickness": 2}}
|
||||
]));
|
||||
let want = rounded_box(60.0, 40.0, 30.0, 5.0, 3.0) - rounded_box(56.0, 36.0, 28.0, 3.0, 1.0);
|
||||
let v = total_volume(&s);
|
||||
assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}");
|
||||
}
|
||||
|
||||
/// The first tutorial part: a 60×40×15 plate with its top front edge and front right edge
|
||||
/// rounded r2, shelled with the top open. At 2 mm the rounds' inner sides collapse to sharp
|
||||
/// edges (as in Fusion); at 1.5 mm the top's round, tangent to the open top, keeps its
|
||||
/// tangency (every face moves in, the opening is swept out through the top).
|
||||
#[test]
|
||||
fn shell_of_the_tutorial_plate() {
|
||||
let base = json!([
|
||||
{"command": "solid.box", "params": {"length": 60, "width": 40, "height": 15}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[30, 0, 15], [60, 0, 7]], "radius": 2}}
|
||||
]);
|
||||
let s0 = script(base.clone());
|
||||
let body = total_volume(&s0);
|
||||
// 2 mm: the cavity is a sharp 56 × 36 box from z = 2 up through the top.
|
||||
let mut steps = base.as_array().cloned().unwrap_or_default();
|
||||
steps.push(json!({"command": "solid.shell", "params": {"faces": [[30, 20, 15]], "thickness": 2}}));
|
||||
let v = total_volume(&script(Value::Array(steps)));
|
||||
let want = body - 56.0 * 36.0 * 13.0;
|
||||
assert!((v - want).abs() < 1e-4 * want, "{v} vs {want}");
|
||||
// 1.5 mm: a 57 × 37 × 12 cavity with r0.5 rounds, opened by its top face (57 × 36.5)
|
||||
// swept up through the top (estimated: the rounds' mitre is left out, hence 1e-3).
|
||||
let mut steps = base.as_array().cloned().unwrap_or_default();
|
||||
steps.push(json!({"command": "solid.shell", "params": {"faces": [[30, 20, 15]], "thickness": 1.5}}));
|
||||
let v = total_volume(&script(Value::Array(steps)));
|
||||
let a = (1.0 - std::f64::consts::PI / 4.0) * 0.25;
|
||||
let want = body - (57.0 * 37.0 * 12.0 - a * (57.0 + 12.0)) - 57.0 * 36.5 * 1.5;
|
||||
assert!((v - want).abs() < 1e-3 * want, "{v} vs {want}");
|
||||
}
|
||||
|
||||
/// Shell after a drilled hole (its tip a cone): the cone moves like the other faces.
|
||||
#[test]
|
||||
fn shell_after_a_drilled_hole() {
|
||||
for open in [[30.0, 0.0, 7.5], [10.0, 10.0, 15.0]] {
|
||||
script(json!([
|
||||
{"command": "solid.box", "params": {"length": 60, "width": 40, "height": 15}},
|
||||
{"command": "solid.hole", "params": {"position": [30, 20, 15], "diameter": 5, "depth": 8, "type": "drilled"}},
|
||||
{"command": "solid.shell", "params": {"faces": [open], "thickness": 2}}
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rounding a box's edges one at a time: an edge running into an earlier round (of the same
|
||||
/// radius or another) takes off about what each round alone would (the corner where they meet
|
||||
/// is small).
|
||||
#[test]
|
||||
fn fillet_into_an_earlier_round() {
|
||||
let box_ = json!({"command": "solid.box", "params": {"length": 60, "width": 40, "height": 15}});
|
||||
for r2 in [2.0, 3.0] {
|
||||
let s = script(json!([
|
||||
box_,
|
||||
{"command": "solid.fillet", "params": {"edges": [[30, 0, 15]], "radius": 2}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[60, 0, 7]], "radius": r2}}
|
||||
]));
|
||||
let lost = |r: f64, len: f64| (1.0 - PI / 4.0) * r * r * len;
|
||||
let want = 36000.0 - lost(2.0, 60.0) - lost(r2, 15.0);
|
||||
let v = total_volume(&s);
|
||||
assert!((v - want).abs() < 2.0, "r2 {r2}: {v} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Seeds that run clean: no failure, no invalid body, no volume going the wrong way.
|
||||
#[test]
|
||||
fn fixed_seeds_run_clean() {
|
||||
for seed in FIXED_SEEDS {
|
||||
let r = fuzz::run(*seed, 8);
|
||||
assert!(!r.outcome.is_bug(), "seed {seed}: {:?}\n{}", r.outcome, r.script());
|
||||
}
|
||||
}
|
||||
|
||||
const FIXED_SEEDS: &[u64] = &[3, 5, 11, 16, 21, 29, 33, 35];
|
||||
|
||||
/// A drilled hole as deep as the plate: the drill point breaks through the bottom.
|
||||
#[test]
|
||||
fn hole_as_deep_as_the_plate() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 50, "width": 25, "height": 10}));
|
||||
let v0 = volume(&mut s);
|
||||
run(&mut s, "solid.hole", json!({"position": [25, 12.5, 10], "diameter": 6, "depth": 10, "type": "drilled"}));
|
||||
let v = volume(&mut s);
|
||||
assert!(v < v0 - PI * 9.0 * 9.0 && v > v0 - PI * 9.0 * 10.0 - 1.0, "{v0} {v}");
|
||||
// Other sizes and places, the defaults, and a tapped hole.
|
||||
for (i, p) in [
|
||||
json!({"position": [10, 10, 10], "diameter": 5, "depth": 10, "type": "drilled"}),
|
||||
json!({"position": [40, 15, 10], "diameter": 5, "depth": 10}),
|
||||
json!({"position": [12, 6, 10], "diameter": 8.5, "depth": 10}),
|
||||
json!({"position": [30, 7, 10], "thread": "M6", "depth": 10}),
|
||||
json!({"position": [20, 18, 10], "diameter": 4, "depth": 10, "tip_angle": 90}),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let before = volume(&mut s);
|
||||
let r = s.execute("solid.hole", &p);
|
||||
assert!(r.is_ok(), "{i} {p}: {r:?}");
|
||||
assert!(volume(&mut s) < before - 1.0, "{i} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An enclosure built in the usual order: shell, bosses, then the lip on the rim.
|
||||
#[test]
|
||||
fn lip_after_boss() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
|
||||
run(&mut s, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
|
||||
run(&mut s, "plastic.boss", json!({"position": [10, 10, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}));
|
||||
run(&mut s, "plastic.boss", json!({"position": [70, 40, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}));
|
||||
let v0 = volume(&mut s);
|
||||
run(&mut s, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2}));
|
||||
let v = volume(&mut s);
|
||||
assert!(v > v0 + 1.0, "{v0} {v}");
|
||||
// Bosses up to the rim, with ribs, against a wall; lips, grooves and outside rims.
|
||||
let bosses = [
|
||||
json!({"position": [15, 15, 2], "diameter": 8, "height": 28, "hole_diameter": 3}),
|
||||
// Ribs out to the inner walls (15 − 4 − 9 = 2), and short of them.
|
||||
json!({"position": [15, 15, 2], "diameter": 8, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 9}),
|
||||
json!({"position": [15, 15, 2], "diameter": 8, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 6}),
|
||||
json!({"position": [5.5, 25, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}),
|
||||
json!({"position": [40, 25, 2], "diameter": 8, "height": 20, "draft": "1 deg", "fillet": 1}),
|
||||
];
|
||||
let lips = [
|
||||
json!({"face": [1, 25, 30], "width": 1, "height": 2}),
|
||||
json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove"}),
|
||||
json!({"face": [1, 25, 30], "width": 1, "height": 2, "side": "outside"}),
|
||||
];
|
||||
for (i, b) in bosses.iter().enumerate() {
|
||||
for (j, l) in lips.iter().enumerate() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
|
||||
run(&mut s, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
|
||||
let r = s.execute("plastic.boss", b);
|
||||
assert!(r.is_ok(), "boss {i}: {r:?}");
|
||||
let v0 = volume(&mut s);
|
||||
let r = s.execute("plastic.lip", l);
|
||||
assert!(r.is_ok(), "boss {i} lip {j}: {r:?}");
|
||||
assert!((volume(&mut s) - v0).abs() > 1.0, "boss {i} lip {j}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A lip, a groove and an outside lip on the rim of a rounded, shelled enclosure (#29: the
|
||||
/// lip's walls continue the shell's walls, which a boolean join couldn't do).
|
||||
#[test]
|
||||
fn lip_on_a_rounded_shelled_rim() {
|
||||
use std::f64::consts::PI;
|
||||
let base = json!([
|
||||
{"command": "solid.box", "params": {"corner": [-40, -30, 0], "length": 80, "width": 60, "height": 30}},
|
||||
{"command": "solid.fillet", "params": {"edges": [[40, 30, 15], [-40, 30, 15], [-40, -30, 15], [40, -30, 15]], "radius": 6}},
|
||||
{"command": "solid.shell", "params": {"faces": [[0, 0, 30]], "thickness": 2}}
|
||||
]);
|
||||
let v0 = total_volume(&script(base.clone()));
|
||||
let ar = |w: f64, h: f64, r: f64| w * h - (4.0 - PI) * r * r;
|
||||
let inside = ar(78.0, 58.0, 5.0) - ar(76.0, 56.0, 4.0);
|
||||
for (extra, want) in [
|
||||
(json!({}), v0 + inside * 1.5),
|
||||
(json!({"side": "outside"}), v0 + (ar(80.0, 60.0, 6.0) - ar(78.0, 58.0, 5.0)) * 1.5),
|
||||
// (The groove's band comes from the rim's meshed outline: within its sag.)
|
||||
(json!({"type": "groove"}), v0 - inside * 1.5),
|
||||
] {
|
||||
let mut p = json!({"face": [0, 29, 30], "width": 1, "height": 1.5});
|
||||
if let (Some(o), Some(e)) = (p.as_object_mut(), extra.as_object()) {
|
||||
o.extend(e.clone());
|
||||
}
|
||||
let mut steps = base.as_array().cloned().unwrap_or_default();
|
||||
steps.push(json!({"command": "plastic.lip", "params": p}));
|
||||
let v = total_volume(&script(Value::Array(steps)));
|
||||
assert!((v - want).abs() < 1e-3 * want, "{extra}: {v} vs {want}");
|
||||
}
|
||||
}
|
||||
@@ -124,16 +124,28 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), ..Default::default() };
|
||||
let b = bounds(s);
|
||||
sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target);
|
||||
// Grid on XY.
|
||||
// Grid on the ground plane (XY with Z up, XZ with Y up).
|
||||
let (minor, major) = grid_step(cam.half_height());
|
||||
let ext = (cam.half_height() * 3.0 / major).ceil() * major;
|
||||
let c = Vec3::new((cam.target.x / major).round() * major, (cam.target.y / major).round() * major, 0.0);
|
||||
let tf = cam.to_frame(cam.target);
|
||||
let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0);
|
||||
let w = |p: Vec3| cam.from_frame(p);
|
||||
let n = ((ext / minor) as i64).clamp(1, 400);
|
||||
for i in -n..=n {
|
||||
let t = i as f64 * minor;
|
||||
let col = if (t / major).fract().abs() < 1e-9 { colors::GRID_MAJOR } else { colors::GRID };
|
||||
sc.lines.push(SceneLine { points: vec![c + Vec3::new(t, -ext, 0.0), c + Vec3::new(t, ext, 0.0)], color: col, width: 0.6, on_top: false });
|
||||
sc.lines.push(SceneLine { points: vec![c + Vec3::new(-ext, t, 0.0), c + Vec3::new(ext, t, 0.0)], color: col, width: 0.6, on_top: false });
|
||||
sc.lines.push(SceneLine {
|
||||
points: vec![w(c + Vec3::new(t, -ext, 0.0)), w(c + Vec3::new(t, ext, 0.0))],
|
||||
color: col,
|
||||
width: 0.6,
|
||||
on_top: false,
|
||||
});
|
||||
sc.lines.push(SceneLine {
|
||||
points: vec![w(c + Vec3::new(-ext, t, 0.0)), w(c + Vec3::new(ext, t, 0.0))],
|
||||
color: col,
|
||||
width: 0.6,
|
||||
on_top: false,
|
||||
});
|
||||
}
|
||||
let axis = cam.half_height() * 0.25;
|
||||
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
|
||||
@@ -142,7 +154,17 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
for body in &st.bodies {
|
||||
let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name));
|
||||
let mesh = body.mesh();
|
||||
sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color: if selected { colors::BODY_SELECTED } else { colors::BODY } });
|
||||
// The body's appearance (or imported colour) and faces with looks of their own, as in
|
||||
// the viewport.
|
||||
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let color =
|
||||
if selected { colors::BODY_SELECTED } else { s.doc.body_color(body).map_or(colors::BODY, |[r, g, b]| Rgb(to8(r), to8(g), to8(b))) };
|
||||
let face_colors = if selected {
|
||||
Vec::new()
|
||||
} else {
|
||||
s.doc.face_colors(body).into_iter().map(|(f, l)| (f as u32, Rgb(l.color[0], l.color[1], l.color[2]))).collect()
|
||||
};
|
||||
sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color, face_colors });
|
||||
for (i, e) in mesh.edges.iter().enumerate() {
|
||||
if mesh.seams.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
@@ -236,3 +258,39 @@ fn constructs(s: &Session) -> Vec<(u64, String, solvecraft_doc::construct::Const
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The CPU renderer (snapshots) shows appearances as the viewport does: a body's colour and
|
||||
/// faces with looks of their own.
|
||||
#[test]
|
||||
fn snapshot_scene_carries_appearances() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
s.execute("solid.cylinder", &json!({"diameter": 14, "height": 30, "base": [50, 15, 0], "operation": "new"})).unwrap();
|
||||
s.execute("appearance.assign", &json!({"bodies": ["Body2"], "color": [220, 60, 40]})).unwrap();
|
||||
s.execute("appearance.assign", &json!({"faces": [[20, 15, 20]], "body": "Body1", "color": [40, 170, 80]})).unwrap();
|
||||
let cam = home_camera(&s);
|
||||
let sc = scene(&s, &cam);
|
||||
assert!(sc.meshes.iter().any(|m| m.color == Rgb(220, 60, 40)));
|
||||
assert!(sc.meshes.iter().any(|m| m.face_colors.iter().any(|(_, c)| *c == Rgb(40, 170, 80))));
|
||||
// And the rendered picture has the green top.
|
||||
let img = solvecraft_render::render(&sc, &cam, 200, 140);
|
||||
assert!(img.rgba.chunks(4).any(|p| p[1] > 120 && p[0] < 90 && p[2] < 110), "no green pixels");
|
||||
}
|
||||
|
||||
/// Curved surfaces get a dark silhouette line in snapshots where they turn away.
|
||||
#[test]
|
||||
fn snapshots_draw_silhouettes_on_curved_surfaces() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.sphere", &json!({"diameter": 20})).unwrap();
|
||||
let cam = home_camera(&s);
|
||||
let img = solvecraft_render::render(&scene(&s, &cam), &cam, 400, 400);
|
||||
let dark = img.rgba.chunks(4).filter(|p| p[0] < 70 && p[1] < 70 && p[2] < 75).count();
|
||||
assert!(dark > 40, "silhouette pixels: {dark}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
return Err(KernelError::Invalid("too many edges".into()));
|
||||
}
|
||||
// Coplanar neighbours as one face (as after a join), so corners look as they should.
|
||||
let mut cur = Body::new(crate::heal::heal(body.deep_copy(), body.size()))?;
|
||||
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
|
||||
// Every edge of a convex planar body: rounded as a whole (sphere corners).
|
||||
if shape == Shape::Round && edges.len() >= 6 {
|
||||
let solid = cur.deep_copy();
|
||||
@@ -700,7 +700,7 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
|
||||
}
|
||||
}
|
||||
// Coplanar neighbours as one face (as after a join), so corners look as they should.
|
||||
let mut cur = Body::new(crate::heal::heal(body.deep_copy(), body.size()))?;
|
||||
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
|
||||
if let Some(groups) = loop_groups(&cur, edges)? {
|
||||
for g in groups {
|
||||
let size = cur.size();
|
||||
|
||||
@@ -33,7 +33,7 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result<Body> {
|
||||
if edges.iter().any(|(p, r)| !(p.is_finite() && r.is_finite() && *r > 1e-6 && *r < 1e6)) {
|
||||
return Err(KernelError::Invalid("fillet size must be positive".into()));
|
||||
}
|
||||
let mut cur = Body::new(crate::heal::heal(body.deep_copy(), body.size()))?;
|
||||
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
|
||||
let mut sel = edges.to_vec();
|
||||
let mut first: Option<KernelError> = None;
|
||||
// Earlier rounds at the edges' ends come off and are blended again with the new edges.
|
||||
|
||||
@@ -56,11 +56,78 @@ fn edge_frame(faces: &[&mt::Face], edge: &mt::Edge) -> Result<EdgeFrame> {
|
||||
|
||||
/// Fillet one straight edge with radius `r0` at its end nearest `start` and `r1` at the other.
|
||||
pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64) -> Result<Body> {
|
||||
fillet_variable_points(body, edge_at, start, &[(0.0, r0), (1.0, r1)], false)
|
||||
}
|
||||
|
||||
/// The radius along the edge as a B-spline of the edge parameter (0 at the edge's front):
|
||||
/// degree, knots and control radii.
|
||||
struct RadiusLaw {
|
||||
deg: usize,
|
||||
knots: Vec<f64>,
|
||||
radii: Vec<f64>,
|
||||
}
|
||||
|
||||
impl RadiusLaw {
|
||||
/// Through (position, radius) points sorted by position from 0 to 1: straight between them,
|
||||
/// or `smooth` (cubic pieces with Catmull-Rom slopes, C¹ through every point).
|
||||
fn through(pts: &[(f64, f64)], smooth: bool) -> RadiusLaw {
|
||||
if !smooth || pts.len() < 3 {
|
||||
let mut knots = vec![0.0];
|
||||
knots.extend(pts.iter().map(|p| p.0));
|
||||
knots.push(1.0);
|
||||
return RadiusLaw { deg: 1, knots, radii: pts.iter().map(|p| p.1).collect() };
|
||||
}
|
||||
let n = pts.len();
|
||||
let at = |i: usize| pts.get(i).copied().unwrap_or((0.0, 0.0));
|
||||
let slope = |i: usize| {
|
||||
let (a, b) = (at(i.saturating_sub(1)), at((i + 1).min(n - 1)));
|
||||
if b.0 > a.0 { (b.1 - a.1) / (b.0 - a.0) } else { 0.0 }
|
||||
};
|
||||
let mut knots = vec![0.0; 4];
|
||||
let mut radii = vec![at(0).1];
|
||||
for i in 0..n - 1 {
|
||||
let ((t0, r0), (t1, r1)) = (at(i), at(i + 1));
|
||||
let h = t1 - t0;
|
||||
radii.extend([r0 + slope(i) * h / 3.0, r1 - slope(i + 1) * h / 3.0, r1]);
|
||||
knots.extend(if i + 2 == n { vec![1.0; 4] } else { vec![t1; 3] });
|
||||
}
|
||||
RadiusLaw { deg: 3, knots, radii }
|
||||
}
|
||||
|
||||
/// Greville abscissae: where each control radius sits along the edge.
|
||||
fn greville(&self) -> Vec<f64> {
|
||||
(0..self.radii.len()).map(|j| self.knots.iter().skip(j + 1).take(self.deg).sum::<f64>() / self.deg as f64).collect()
|
||||
}
|
||||
|
||||
/// The radius at `t` (sampled for checks).
|
||||
fn eval(&self, t: f64) -> f64 {
|
||||
let kv = mt::KnotVec::from(self.knots.clone());
|
||||
let b = kv.try_bspline_basis_functions(self.deg, t.clamp(0.0, 1.0)).unwrap_or_default();
|
||||
b.iter().zip(&self.radii).map(|(x, r)| x * r).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fillet one straight edge with a radius that varies along it: (position, radius) points with
|
||||
/// positions 0 (the end nearest `start`) to 1 (the other end) and any between, the radius
|
||||
/// straight between them or `smooth`.
|
||||
pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &[(f64, f64)], smooth: bool) -> Result<Body> {
|
||||
body.require_brep("fillet")?;
|
||||
for r in [r0, r1] {
|
||||
if !(r.is_finite() && r > 1e-6 && r < 1e6) {
|
||||
if points.len() < 2 || points.len() > 100 {
|
||||
return Err(KernelError::Invalid("a variable radius needs 2…100 points".into()));
|
||||
}
|
||||
for (t, r) in points {
|
||||
if !(r.is_finite() && *r > 1e-6 && *r < 1e6) {
|
||||
return Err(KernelError::Invalid("fillet radii must be positive".into()));
|
||||
}
|
||||
if !(t.is_finite() && (0.0..=1.0).contains(t)) {
|
||||
return Err(KernelError::Invalid("radius positions run from 0 to 1 along the edge".into()));
|
||||
}
|
||||
}
|
||||
let mut pts = points.to_vec();
|
||||
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-9);
|
||||
if pts.first().map(|p| p.0) != Some(0.0) || pts.last().map(|p| p.0) != Some(1.0) || pts.len() < 2 {
|
||||
return Err(KernelError::Invalid("give the radius at both ends of the edge".into()));
|
||||
}
|
||||
let edge = crate::surfaces::edge_near(body, edge_at).ok_or_else(|| KernelError::Invalid(format!("no edge at {edge_at:?}")))?;
|
||||
let size = body.size();
|
||||
@@ -78,8 +145,17 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64
|
||||
let faces: Vec<&mt::Face> = shell.face_iter().collect();
|
||||
let fr = edge_frame(&faces, &edge)?;
|
||||
let (v0, v1) = (edge.absolute_front().clone(), edge.absolute_back().clone());
|
||||
// r0 at the end nearest `start` (the edge keeps its own direction).
|
||||
let (ra, rb) = if vtx(&v1).dist(start) < vtx(&v0).dist(start) { (r1, r0) } else { (r0, r1) };
|
||||
// Positions from the end nearest `start` (the edge keeps its own direction).
|
||||
if vtx(&v1).dist(start) < vtx(&v0).dist(start) {
|
||||
pts = pts.iter().rev().map(|(t, r)| (1.0 - t, *r)).collect();
|
||||
}
|
||||
let law = RadiusLaw::through(&pts, smooth);
|
||||
let (ra, rb) = (law.eval(0.0), law.eval(1.0));
|
||||
// The largest radius along the edge decides whether the blend fits.
|
||||
let rmax = (0..=200).map(|k| law.eval(k as f64 / 200.0)).fold(0.0, f64::max);
|
||||
if (0..=200).any(|k| law.eval(k as f64 / 200.0) <= 1e-6) {
|
||||
return Err(KernelError::Invalid("the radius must stay positive along the edge".into()));
|
||||
}
|
||||
let (p0, p1) = (vtx(&v0), vtx(&v1));
|
||||
let d = (p1 - p0).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?;
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
@@ -114,7 +190,7 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64
|
||||
}
|
||||
let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
|
||||
let along = (other - p).dot(t);
|
||||
if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= back(r) + tol {
|
||||
if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= back(r.max(rmax)) + tol {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
}
|
||||
@@ -133,8 +209,18 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64
|
||||
};
|
||||
let c0 = builder::circle_arc(&a0, &b0, p3(arc_mid(p0, s0, ra)));
|
||||
let c1 = builder::circle_arc(&a1, &b1, p3(arc_mid(p1, s1, rb)));
|
||||
let la = builder::line(&a0, &a1);
|
||||
let lb = builder::line(&b0, &b1);
|
||||
// Contact curves: the edge moved along each face by the setback of the radius there.
|
||||
let g = law.greville();
|
||||
let contact = |t: Vec3, a: &mt::Vertex, b: &mt::Vertex| -> Result<mt::Edge> {
|
||||
if law.deg == 1 && law.radii.len() == 2 {
|
||||
return Ok(builder::line(a, b));
|
||||
}
|
||||
let ctrl: Vec<mt::Point3> = g.iter().zip(&law.radii).map(|(gj, rj)| p3(p0 + (p1 - p0) * *gj + t * back(*rj))).collect();
|
||||
let c = mt::BSplineCurve::try_new(mt::KnotVec::from(law.knots.clone()), ctrl).map_err(|e| KernelError::Failed(format!("fillet: {e}")))?;
|
||||
mt::Edge::try_new(a, b, mt::Curve::BSplineCurve(c)).map_err(|e| KernelError::Failed(format!("fillet: {e}")))
|
||||
};
|
||||
let la = contact(fr.t1, &a0, &a1)?;
|
||||
let lb = contact(fr.t2, &b0, &b1)?;
|
||||
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
for (end, a, b) in [(end0, &a0, &b0), (end1, &a1, &b1)] {
|
||||
for (e, nv) in [(&end.e1, a), (&end.e2, b)] {
|
||||
@@ -166,8 +252,11 @@ pub fn fillet_variable(body: &Body, edge_at: Vec3, start: Vec3, r0: f64, r1: f64
|
||||
// weight sin(φ/2)) at each end, joined linearly.
|
||||
let w = (fr.phi / 2.0).sin();
|
||||
let h = |p: Vec3, wt: f64| mt::Vector4::new(p.x * wt, p.y * wt, p.z * wt, wt);
|
||||
let ctrl = vec![vec![h(pa0, 1.0), h(pa1, 1.0)], vec![h(p0, w), h(p1, w)], vec![h(pb0, 1.0), h(pb1, 1.0)]];
|
||||
let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::bezier_knot(1)), ctrl);
|
||||
let row = |t: Vec3, wt: f64, off: bool| -> Vec<mt::Vector4> {
|
||||
g.iter().zip(&law.radii).map(|(gj, rj)| h(p0 + (p1 - p0) * *gj + if off { t * back(*rj) } else { Vec3::ZERO }, wt)).collect()
|
||||
};
|
||||
let ctrl = vec![row(fr.t1, 1.0, true), row(fr.t1, w, false), row(fr.t2, 1.0, true)];
|
||||
let bsp = mt::BSplineSurface::new((mt::KnotVec::bezier_knot(2), mt::KnotVec::from(law.knots.clone())), ctrl);
|
||||
let mut surf = mt::Surface::NurbsSurface(mt::NurbsSurface::new(bsp));
|
||||
// Outward: away from the arc centre on a convex edge, toward it on a concave one.
|
||||
let (c, mid) = (p0 + fr.t1 * s0 + fr.n1 * (ra * fr.side), arc_mid(p0, s0, ra));
|
||||
@@ -260,6 +349,36 @@ mod tests {
|
||||
assert!(fillet_variable(&b, Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0), 2.0, 50.0).is_err());
|
||||
}
|
||||
|
||||
/// A radius through a middle point: straight pieces (exact: each piece removes
|
||||
/// (1 − π/4) L (a² + ab + b²) / 3), and smooth (the removed volume integrates the law).
|
||||
#[test]
|
||||
fn variable_radius_through_a_middle_point() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
|
||||
let (at, start) = (Vec3::new(10.0, 10.0, 30.0), Vec3::new(10.0, 0.0, 30.0));
|
||||
let k = 1.0 - std::f64::consts::FRAC_PI_4;
|
||||
let pts = [(0.0, 2.0), (0.5, 4.0), (1.0, 2.0)];
|
||||
let f = fillet_variable_points(&b, at, start, &pts, false).unwrap();
|
||||
let removed = 2.0 * k * 10.0 * (4.0 + 8.0 + 16.0) / 3.0;
|
||||
let v = measure(&f).unwrap().volume;
|
||||
assert!(rel(v, 6000.0 - removed) < 1e-4, "{v} vs {}", 6000.0 - removed);
|
||||
assert_eq!(f.unmeshed_faces(), 0);
|
||||
let s = fillet_variable_points(&b, at, start, &pts, true).unwrap();
|
||||
let law = RadiusLaw::through(&pts, true);
|
||||
let n = 20000;
|
||||
let integral: f64 = (0..n).map(|i| law.eval((i as f64 + 0.5) / n as f64).powi(2)).sum::<f64>() / n as f64 * 20.0;
|
||||
assert!((law.eval(0.5) - 4.0).abs() < 1e-12 && (law.eval(0.0) - 2.0).abs() < 1e-12);
|
||||
let v = measure(&s).unwrap().volume;
|
||||
assert!(rel(v, 6000.0 - k * integral) < 1e-4, "{v} vs {}", 6000.0 - k * integral);
|
||||
// Unsorted, from the other end: the same.
|
||||
let r = fillet_variable_points(&b, at, Vec3::new(10.0, 20.0, 30.0), &[(1.0, 2.0), (0.0, 2.0), (0.5, 4.0)], true).unwrap();
|
||||
assert!(rel(measure(&r).unwrap().volume, v) < 1e-9);
|
||||
// A middle radius too big for the faces, missing ends, bad positions: refused.
|
||||
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (0.5, 40.0), (1.0, 2.0)], false).is_err());
|
||||
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (0.5, 4.0)], false).is_err());
|
||||
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (f64::NAN, 4.0), (1.0, 2.0)], false).is_err());
|
||||
assert!(fillet_variable_points(&b, at, start, &[(0.0, 2.0), (1.0, -1.0)], false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chord_length_on_a_right_angle() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
|
||||
|
||||
@@ -19,6 +19,9 @@ pub struct Body {
|
||||
pub(crate) color: Option<[f32; 3]>,
|
||||
/// Opacity and face colours for export (set by the document; operations drop it).
|
||||
pub(crate) paint: Option<std::sync::Arc<Paint>>,
|
||||
/// Lines the user split faces along (Split Face), as polylines: export keeps the faces on
|
||||
/// either side apart where it would otherwise join pieces of one surface.
|
||||
pub(crate) splits: Option<std::sync::Arc<Vec<Vec<Vec3>>>>,
|
||||
}
|
||||
|
||||
/// How a body looks beyond its colour, for export: its opacity and its faces' own colours.
|
||||
@@ -278,6 +281,7 @@ impl Body {
|
||||
}
|
||||
&& self.color == other.color
|
||||
&& self.paint == other.paint
|
||||
&& self.splits == other.splits
|
||||
}
|
||||
|
||||
pub(crate) fn new(solid: Solid) -> Result<Body> {
|
||||
@@ -302,7 +306,7 @@ impl Body {
|
||||
if v < 0.0 {
|
||||
solid.not();
|
||||
}
|
||||
Ok(Body { solid: std::sync::Arc::new(solid), mesh: None, color: None, paint: None })
|
||||
Ok(Body { solid: std::sync::Arc::new(solid), mesh: None, color: None, paint: None, splits: None })
|
||||
}
|
||||
|
||||
/// A deep copy of the solid that can be mutated without affecting other bodies.
|
||||
@@ -335,6 +339,21 @@ impl Body {
|
||||
self
|
||||
}
|
||||
|
||||
/// Lines faces were split along on purpose (Split Face), as polylines.
|
||||
pub fn split_lines(&self) -> Option<&std::sync::Arc<Vec<Vec<Vec3>>>> {
|
||||
self.splits.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn split_keep(&self) -> &[Vec<Vec3>] {
|
||||
self.splits.as_ref().map(|l| l.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// The same body with the lines its faces were split along on purpose.
|
||||
pub fn with_split_lines(mut self, s: Option<std::sync::Arc<Vec<Vec<Vec3>>>>) -> Body {
|
||||
self.splits = s.filter(|l| !l.is_empty());
|
||||
self
|
||||
}
|
||||
|
||||
/// Opacity and face colours for export, if any.
|
||||
pub fn paint(&self) -> Option<&Paint> {
|
||||
self.paint.as_deref()
|
||||
@@ -597,6 +616,31 @@ impl Body {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
self.solid.vertex_iter().filter(|v| seen.insert(v.id())).count()
|
||||
}
|
||||
/// What is wrong with the body as a solid: shells that aren't closed and consistently
|
||||
/// oriented, faces that don't mesh, a volume that isn't positive. Empty when it is sound.
|
||||
pub fn validity(&self) -> Vec<String> {
|
||||
if let Some(m) = &self.mesh {
|
||||
return if crate::meshbody::is_closed(m) { Vec::new() } else { vec!["the mesh is not closed".into()] };
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (i, sh) in self.solid.boundaries().iter().enumerate() {
|
||||
let c = sh.shell_condition();
|
||||
if c != truck_topology::shell::ShellCondition::Closed {
|
||||
out.push(format!("shell {i} is {c:?}, not closed"));
|
||||
}
|
||||
}
|
||||
let missing = self.unmeshed_faces();
|
||||
if missing > 0 {
|
||||
out.push(format!("{missing} face(s) don't mesh"));
|
||||
}
|
||||
match crate::measure(self) {
|
||||
Ok(m) if m.volume.is_finite() && m.volume > 0.0 => {}
|
||||
Ok(m) => out.push(format!("volume {}", m.volume)),
|
||||
Err(e) => out.push(format!("no measure: {e}")),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn shell_count(&self) -> usize {
|
||||
match &self.mesh {
|
||||
Some(_) => 1,
|
||||
|
||||
+139
-1
@@ -205,6 +205,13 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
|
||||
let radial = (c - o) - axis * (c - o).dot(axis);
|
||||
let toward = axis.cross(radial).dot(plane.normal());
|
||||
let angle = if toward < 0.0 { -angle } else { angle };
|
||||
// A profile with an edge on the axis: no zero-area face where that edge turns on itself.
|
||||
if r.holes.is_empty()
|
||||
&& let Some(b) = revolve_partial_on_axis(plane, &r.outer, axis_origin, d, angle)?
|
||||
{
|
||||
out.push(b);
|
||||
continue;
|
||||
}
|
||||
let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?;
|
||||
out.push(Body::new(solid)?);
|
||||
}
|
||||
@@ -226,6 +233,9 @@ pub fn box_solid(a: Vec3, b: Vec3) -> Result<Body> {
|
||||
v.pop().ok_or_else(|| KernelError::Failed("box".into()))
|
||||
}
|
||||
|
||||
/// Where primitives start their circles (radians from the frame's x axis).
|
||||
pub(crate) const SEAM_ANGLE: f64 = 0.6131;
|
||||
|
||||
/// Cylinder with base centre `base`, axis direction `axis`, radius and height.
|
||||
pub fn cylinder(base: Vec3, axis: Vec3, radius: f64, height: f64) -> Result<Body> {
|
||||
let plane = Plane::from_normal(base, axis).ok_or_else(|| KernelError::Invalid("cylinder axis".into()))?;
|
||||
@@ -233,7 +243,9 @@ pub fn cylinder(base: Vec3, axis: Vec3, radius: f64, height: f64) -> Result<Body
|
||||
if radius <= 1e-6 {
|
||||
return Err(KernelError::Invalid("radius must be positive".into()));
|
||||
}
|
||||
let r = Region2 { outer: Loop2::circle(Vec2::ZERO, radius), holes: vec![] };
|
||||
// The seams at an odd angle: on the frame's axes they would lie exactly where faces placed
|
||||
// on a grid cut the cylinder (along a seam edge, which booleans don't survive).
|
||||
let r = Region2 { outer: Loop2::circle_from(Vec2::ZERO, radius, SEAM_ANGLE), holes: vec![] };
|
||||
let mut v = extrude(&plane, &[r], 0.0, finite(height, "height")?)?;
|
||||
v.pop().ok_or_else(|| KernelError::Failed("cylinder".into()))
|
||||
}
|
||||
@@ -367,6 +379,132 @@ fn half_disc(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Option<(Vec2, f64)> {
|
||||
(same && (total - std::f64::consts::PI).abs() < 1e-6).then_some((c, r))
|
||||
}
|
||||
|
||||
/// A partial revolve of a loop with one edge on the axis: the other edges sweep their faces,
|
||||
/// the two caps (the profile at the start and at the end) share the axis edge. `None` when the
|
||||
/// loop isn't like that.
|
||||
fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2, angle: f64) -> Result<Option<Body>> {
|
||||
use mt::{BoundedCurve, Invertible, ParametricCurve, ParametricSurface3D, SearchNearestParameter, Transformed};
|
||||
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
|
||||
let n = lp.segs.len();
|
||||
let axis_segs: Vec<usize> =
|
||||
(0..n).filter(|i| lp.segs.get(*i).is_some_and(|s| matches!(s, Seg2::Line { a, b } if on_axis(*a) && on_axis(*b)))).collect();
|
||||
let [k] = axis_segs[..] else { return Ok(None) };
|
||||
// The open chain after the axis segment, back around to it: only its ends on the axis.
|
||||
let chain: Vec<Seg2> = (1..n).filter_map(|j| lp.segs.get((k + j) % n).copied()).collect();
|
||||
if chain.is_empty() || chain.iter().skip(1).any(|s| on_axis(s.start())) || chain.iter().any(|s| on_axis(s.mid())) {
|
||||
return Ok(None);
|
||||
}
|
||||
// Built with the axis through the world origin, then moved into place.
|
||||
let shift = plane.to_world(axis_origin);
|
||||
let local = Plane { origin: plane.origin - shift, ..*plane };
|
||||
let plane = &local;
|
||||
let axis = plane.dir_to_world(d);
|
||||
let rot = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle));
|
||||
let half = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle * 0.5));
|
||||
let turn = |m: &mt::Matrix4, p: Vec3| from_p3(mt::EuclideanSpace::from_vec((m * p3(p).to_homogeneous()).truncate()));
|
||||
guard("revolve", || {
|
||||
let fail = |m: &str| KernelError::Failed(format!("revolve: {m}"));
|
||||
let pts: Vec<Vec3> = chain.iter().map(|s| plane.to_world(s.start())).chain(chain.last().map(|s| plane.to_world(s.end()))).collect();
|
||||
let last = pts.len() - 1;
|
||||
let pv: Vec<mt::Vertex> = pts.iter().map(|p| builder::vertex(p3(*p))).collect();
|
||||
// The ends stay where they are (on the axis).
|
||||
let qv: Vec<mt::Vertex> = pts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
if i == 0 || i == last { pv.get(i).cloned().unwrap_or_else(|| builder::vertex(p3(*p))) } else { builder::vertex(p3(turn(&rot, *p))) }
|
||||
})
|
||||
.collect();
|
||||
let mut e: Vec<mt::Edge> = Vec::new();
|
||||
let mut er: Vec<mt::Edge> = Vec::new();
|
||||
for (i, sg) in chain.iter().enumerate() {
|
||||
let (Some(a), Some(b), Some(qa), Some(qb)) = (pv.get(i), pv.get(i + 1), qv.get(i), qv.get(i + 1)) else { return Err(fail("chain")) };
|
||||
let edge = match *sg {
|
||||
Seg2::Line { .. } => builder::line(a, b),
|
||||
Seg2::Arc { .. } => builder::circle_arc(a, b, p3(plane.to_world(sg.mid()))),
|
||||
Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => {
|
||||
crate::freeform::edge(plane, sg, a, b).unwrap_or_else(|| builder::line(a, b))
|
||||
}
|
||||
};
|
||||
let curve = edge.oriented_curve().transformed(rot);
|
||||
er.push(mt::Edge::new(qa, qb, curve));
|
||||
e.push(edge);
|
||||
}
|
||||
// Arcs swept by the vertices off the axis.
|
||||
let mut arcs: Vec<Option<mt::Edge>> = vec![None; pts.len()];
|
||||
for i in 1..last {
|
||||
let (Some(a), Some(b), Some(p)) = (pv.get(i), qv.get(i), pts.get(i)) else { continue };
|
||||
if let Some(slot) = arcs.get_mut(i) {
|
||||
*slot = Some(builder::circle_arc(a, b, p3(turn(&half, *p))));
|
||||
}
|
||||
}
|
||||
// Side faces: each edge's swept surface, oriented to agree with its boundary.
|
||||
let mut faces: Vec<mt::Face> = Vec::new();
|
||||
for (i, sg) in chain.iter().enumerate() {
|
||||
let (Some(ei), Some(eri)) = (e.get(i), er.get(i)) else { return Err(fail("edges")) };
|
||||
let mut ws: Vec<mt::Edge> = vec![ei.clone()];
|
||||
if let Some(Some(c)) = arcs.get(i + 1) {
|
||||
ws.push(c.clone());
|
||||
}
|
||||
ws.push(eri.inverse());
|
||||
if let Some(Some(c)) = arcs.get(i) {
|
||||
ws.push(c.inverse());
|
||||
}
|
||||
let wire: mt::Wire = ws.into();
|
||||
let radial = matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9));
|
||||
let f = if radial {
|
||||
builder::try_attach_plane(&[wire]).map_err(|er| fail(&er.to_string()))?
|
||||
} else {
|
||||
let template = builder::rsweep(ei, mt::Point3::new(0.0, 0.0, 0.0), v3(axis), mt::Rad(angle));
|
||||
let mut surface = template.face_iter().next().map(|f| f.oriented_surface()).ok_or_else(|| fail("swept face"))?;
|
||||
// The loop's turning (Newell) against the surface normal halfway round.
|
||||
let mut nw = Vec3::ZERO;
|
||||
let poly: Vec<Vec3> = wire
|
||||
.edge_iter()
|
||||
.flat_map(|ed| {
|
||||
let c = ed.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
(0..8).map(move |j| from_p3(c.subs(t0 + (t1 - t0) * j as f64 / 8.0))).collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
for (j, p) in poly.iter().enumerate() {
|
||||
if let Some(q) = poly.get((j + 1) % poly.len()) {
|
||||
nw += p.cross(*q);
|
||||
}
|
||||
}
|
||||
let probe = turn(&half, plane.to_world(sg.mid()));
|
||||
let sn = surface.search_nearest_parameter(p3(probe), None, 100).map(|(u, v)| surface.normal(u, v));
|
||||
if sn.is_some_and(|m| nw.dot(Vec3::new(m.x, m.y, m.z)) < 0.0) {
|
||||
surface = surface.inverse();
|
||||
}
|
||||
mt::Face::try_new(vec![wire], surface).map_err(|er| fail(&er.to_string()))?
|
||||
};
|
||||
faces.push(f);
|
||||
}
|
||||
// Caps: the profile at the start and at the end, sharing the axis edge.
|
||||
let (Some(a0), Some(an)) = (pv.first(), pv.get(last)) else { return Err(fail("ends")) };
|
||||
let axis_edge = builder::line(an, a0);
|
||||
let start: mt::Wire = e.iter().rev().map(|x| x.inverse()).chain(std::iter::once(axis_edge.inverse())).collect::<Vec<_>>().into();
|
||||
let end: mt::Wire = er.iter().cloned().chain(std::iter::once(axis_edge.clone())).collect::<Vec<_>>().into();
|
||||
let cs = builder::try_attach_plane(&[start]).map_err(|er| fail(&er.to_string()))?;
|
||||
let ce = builder::try_attach_plane(&[end]).map_err(|er| fail(&er.to_string()))?;
|
||||
let mut last_err = String::new();
|
||||
for flip in 0..4 {
|
||||
let mut all = faces.clone();
|
||||
all.push(if flip & 1 == 0 { cs.clone() } else { cs.inverse() });
|
||||
all.push(if flip & 2 == 0 { ce.clone() } else { ce.inverse() });
|
||||
match Solid::try_new(vec![all.into()]) {
|
||||
Ok(solid) => {
|
||||
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
|
||||
return Body::new(solid).map(Some);
|
||||
}
|
||||
Err(er) => last_err = er.to_string(),
|
||||
}
|
||||
}
|
||||
Err(fail(&last_err))
|
||||
})
|
||||
}
|
||||
|
||||
fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Result<Option<Body>> {
|
||||
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
|
||||
let n = lp.segs.len();
|
||||
|
||||
@@ -42,8 +42,11 @@ fn planes(b: &Body) -> Vec<(usize, PlaneFace)> {
|
||||
|
||||
/// Push the planar faces of `b` lying in the plane (n, d) — n their outward normal — along n
|
||||
/// by `delta` (negative: inward). Their neighbours must stand perpendicular to the plane.
|
||||
fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
|
||||
match push_faces_exact(b, n, d, delta) {
|
||||
pub(crate) fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
|
||||
// The exact rebuild, unless it came out unsound (a curved side it couldn't re-sweep).
|
||||
match push_faces_exact(b, n, d, delta)
|
||||
.and_then(|r| if r.validity().is_empty() { Ok(r) } else { Err(KernelError::Failed("pushing a face: an unsound result".into())) })
|
||||
{
|
||||
Ok(r) => Ok(r),
|
||||
// Faces of planes and cylinders: every face follows (any neighbours).
|
||||
Err(e) => {
|
||||
@@ -66,7 +69,7 @@ fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
|
||||
}
|
||||
}
|
||||
|
||||
fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
|
||||
pub(crate) fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
|
||||
let size = b.size();
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
let fail = |m: &str| KernelError::Failed(format!("pushing a face: {m}"));
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Robustness checks on random inputs (`solvecraft-cli fuzz`, docs/robustness.md): a small
|
||||
//! seeded random generator, and the boolean volume identity on random pairs of primitives
|
||||
//! placed on a coarse grid, so coincident faces and tangencies come up often.
|
||||
|
||||
use serde::Serialize;
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::{Body, BoolOp, boolean, box_solid, cylinder, measure, sphere, torus};
|
||||
|
||||
/// xorshift64*: small, fast, and the same everywhere (wasm included).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
pub fn new(seed: u64) -> Rng {
|
||||
// Spread nearby seeds apart (splitmix64), never zero.
|
||||
let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
Rng((z ^ (z >> 31)) | 1)
|
||||
}
|
||||
pub fn next_u64(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
/// 0…n−1 (0 when n is 0).
|
||||
pub fn below(&mut self, n: usize) -> usize {
|
||||
if n == 0 { 0 } else { (self.next_u64() % n as u64) as usize }
|
||||
}
|
||||
/// In [0, 1).
|
||||
pub fn unit(&mut self) -> f64 {
|
||||
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
pub fn chance(&mut self, p: f64) -> bool {
|
||||
self.unit() < p
|
||||
}
|
||||
pub fn pick<T: Copy>(&mut self, xs: &[T]) -> Option<T> {
|
||||
xs.get(self.below(xs.len())).copied()
|
||||
}
|
||||
/// A multiple of `step` in [lo, hi].
|
||||
pub fn grid(&mut self, lo: f64, hi: f64, step: f64) -> f64 {
|
||||
let n = ((hi - lo) / step).floor().max(0.0) as usize;
|
||||
lo + step * self.below(n + 1) as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// A primitive used by the boolean checks, as plain data (so a failure can be written down).
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub enum Shape {
|
||||
Box { a: [f64; 3], b: [f64; 3] },
|
||||
Cylinder { base: [f64; 3], axis: [f64; 3], radius: f64, height: f64 },
|
||||
Sphere { center: [f64; 3], radius: f64 },
|
||||
Torus { center: [f64; 3], major: f64, minor: f64 },
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
pub fn body(&self) -> crate::Result<Body> {
|
||||
let v = |p: &[f64; 3]| Vec3::new(p[0], p[1], p[2]);
|
||||
match self {
|
||||
Shape::Box { a, b } => box_solid(v(a), v(b)),
|
||||
Shape::Cylinder { base, axis, radius, height } => cylinder(v(base), v(axis), *radius, *height),
|
||||
Shape::Sphere { center, radius } => sphere(v(center), *radius),
|
||||
Shape::Torus { center, major, minor } => torus(v(center), *major, *minor),
|
||||
}
|
||||
}
|
||||
|
||||
fn random(r: &mut Rng) -> Shape {
|
||||
let g = 2.5;
|
||||
let mut p = || [r.grid(0.0, 30.0, g), r.grid(0.0, 30.0, g), r.grid(0.0, 30.0, g)];
|
||||
let at = p();
|
||||
match r.below(10) {
|
||||
0..=4 => {
|
||||
let size = [r.grid(5.0, 30.0, g), r.grid(5.0, 30.0, g), r.grid(5.0, 30.0, g)];
|
||||
Shape::Box { a: at, b: [at[0] + size[0], at[1] + size[1], at[2] + size[2]] }
|
||||
}
|
||||
5..=7 => {
|
||||
let axis = r.pick(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]).unwrap_or([0.0, 0.0, 1.0]);
|
||||
Shape::Cylinder { base: at, axis, radius: r.grid(2.5, 10.0, g), height: r.grid(5.0, 30.0, g) }
|
||||
}
|
||||
8 => Shape::Sphere { center: at, radius: r.grid(2.5, 12.5, g) },
|
||||
_ => {
|
||||
let minor = r.grid(2.5, 5.0, g);
|
||||
Shape::Torus { center: at, major: minor + r.grid(2.5, 10.0, g), minor }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One boolean check: the shapes, what went wrong (none when the identity holds).
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct BoolCase {
|
||||
pub seed: u64,
|
||||
pub a: Shape,
|
||||
pub b: Shape,
|
||||
/// "ok", or the kind of failure: "error", "invalid", "identity".
|
||||
pub outcome: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
fn vol(b: &Option<Body>) -> crate::Result<f64> {
|
||||
match b {
|
||||
Some(b) => Ok(measure(b)?.volume),
|
||||
None => Ok(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two primitives [`boolean_case`] uses for a seed.
|
||||
pub fn boolean_shapes(seed: u64) -> (Shape, Shape) {
|
||||
let mut r = Rng::new(seed);
|
||||
(Shape::random(&mut r), Shape::random(&mut r))
|
||||
}
|
||||
|
||||
/// Union, intersection and cut of two random primitives: each result is a sound solid, and
|
||||
/// V(A∪B) + V(A∩B) = V(A) + V(B) and V(A−B) = V(A) − V(A∩B), within 0.2 %.
|
||||
pub fn boolean_case(seed: u64) -> BoolCase {
|
||||
let (a, b) = boolean_shapes(seed);
|
||||
let mut case = BoolCase { seed, a: a.clone(), b: b.clone(), outcome: "ok".into(), detail: String::new() };
|
||||
let fail = |c: &mut BoolCase, kind: &str, d: String| {
|
||||
c.outcome = kind.into();
|
||||
c.detail = d;
|
||||
};
|
||||
let (Ok(ba), Ok(bb)) = (a.body(), b.body()) else {
|
||||
fail(&mut case, "error", "a primitive could not be built".into());
|
||||
return case;
|
||||
};
|
||||
let mut res = Vec::new();
|
||||
for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] {
|
||||
match boolean(&ba, &bb, op) {
|
||||
Ok(x) => {
|
||||
if let Some(body) = &x {
|
||||
let bad = body.validity();
|
||||
if !bad.is_empty() {
|
||||
fail(&mut case, "invalid", format!("{op:?}: {}", bad.join("; ")));
|
||||
return case;
|
||||
}
|
||||
}
|
||||
res.push(x);
|
||||
}
|
||||
Err(e) => {
|
||||
fail(&mut case, "error", format!("{op:?}: {e}"));
|
||||
return case;
|
||||
}
|
||||
}
|
||||
}
|
||||
let v = |b: &Option<Body>| vol(b).unwrap_or(f64::NAN);
|
||||
let (va, vb) = (v(&Some(ba)), v(&Some(bb)));
|
||||
let [u, i, c] = &res[..] else { return case };
|
||||
let (vu, vi, vc) = (v(u), v(i), v(c));
|
||||
let tol = 2e-3 * (va + vb);
|
||||
let e1 = (vu + vi - va - vb).abs();
|
||||
let e2 = (vc - (va - vi)).abs();
|
||||
if !(e1 <= tol && e2 <= tol) {
|
||||
fail(&mut case, "identity", format!("A {va:.3}, B {vb:.3}, A∪B {vu:.3}, A∩B {vi:.3}, A−B {vc:.3}: off by {e1:.3} and {e2:.3}"));
|
||||
}
|
||||
case
|
||||
}
|
||||
@@ -79,7 +79,26 @@ fn inside(p: Vec3, edges: &[mt::Edge], n: Vec3) -> bool {
|
||||
|
||||
/// Merge each group of edge-connected coplanar faces into one face. Returns None when nothing
|
||||
/// changed or the result would not be a valid solid.
|
||||
fn merge_coplanar(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
|
||||
/// Do the points lie along one of the lines faces were split along on purpose (polylines;
|
||||
/// chords of a curved line sag by under a tenth of their length)?
|
||||
pub(crate) fn on_split_line(pts: &[Vec3], keep: &[Vec<Vec3>]) -> bool {
|
||||
!pts.is_empty()
|
||||
&& keep.iter().any(|line| {
|
||||
let seg = line.windows(2).filter_map(|w| Some(w.first()?.dist(*w.get(1)?))).fold(0.0, f64::max);
|
||||
let tol = 1e-6 * (1.0 + seg) + if line.len() > 2 { seg * 0.1 } else { 0.0 };
|
||||
pts.iter().all(|q| line.windows(2).any(|w| w.first().zip(w.get(1)).is_some_and(|(a, b)| q.dist_to_segment(*a, *b) <= tol)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Points along an edge (ends and three inside).
|
||||
pub(crate) fn edge_points(e: &mt::Edge) -> Vec<Vec3> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
(0..=4).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 4.0))).collect()
|
||||
}
|
||||
|
||||
fn merge_coplanar(shell: &mt::Shell, tol: f64, keep: &[Vec<Vec3>]) -> Option<mt::Shell> {
|
||||
let faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
|
||||
let planes: Vec<Option<(Vec3, f64)>> = faces.iter().map(plane_of).collect();
|
||||
let mut parent: Vec<usize> = (0..faces.len()).collect();
|
||||
@@ -90,9 +109,11 @@ fn merge_coplanar(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
|
||||
}
|
||||
}
|
||||
let mut merged_any = false;
|
||||
for fs in by_edge.values() {
|
||||
let edges: HashMap<mt::EdgeID, mt::Edge> = shell.edge_iter().map(|e| (e.id(), e)).collect();
|
||||
for (id, fs) in &by_edge {
|
||||
if let [a, b] = fs[..]
|
||||
&& a != b
|
||||
&& (keep.is_empty() || !edges.get(id).is_some_and(|e| on_split_line(&edge_points(e), keep)))
|
||||
&& let (Some(Some((na, da))), Some(Some((nb, db)))) = (planes.get(a), planes.get(b))
|
||||
&& na.dot(*nb) > 1.0 - 1e-9
|
||||
&& (da - db).abs() < tol
|
||||
@@ -378,6 +399,11 @@ fn planar_surfaces(shell: &mt::Shell, tol: f64) -> Option<mt::Shell> {
|
||||
|
||||
/// Heal a solid; returns the input unchanged when nothing applies or healing fails.
|
||||
pub fn heal(solid: Solid, size: f64) -> Solid {
|
||||
heal_keep(solid, size, &[])
|
||||
}
|
||||
|
||||
/// [`heal`], keeping faces apart along lines they were split along on purpose.
|
||||
pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec<Vec3>]) -> Solid {
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
let mut shells = Vec::new();
|
||||
let mut changed = false;
|
||||
@@ -391,7 +417,7 @@ pub fn heal(solid: Solid, size: f64) -> Solid {
|
||||
cur = m;
|
||||
changed = true;
|
||||
}
|
||||
if let Some(m) = merge_coplanar(&cur, tol) {
|
||||
if let Some(m) = merge_coplanar(&cur, tol, keep) {
|
||||
cur = m;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -141,3 +141,18 @@ fn trimmed_surfaces_sew_into_a_solid() {
|
||||
let m = measure(&imp.bodies[0].body).unwrap();
|
||||
assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drill_point_round_trips_through_iges() {
|
||||
// A blind hole with a cone tip: the tip's face closes at its apex (its seam runs out to the
|
||||
// apex and straight back).
|
||||
use solvecraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let plate = box_solid(Vec3::new(-20.0, -15.0, 0.0), Vec3::new(20.0, 15.0, 10.0)).unwrap();
|
||||
let profile =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 2.0), Vec2::new(4.0, 5.0), Vec2::new(4.0, 11.0), Vec2::new(0.0, 11.0)]), holes: vec![] };
|
||||
let hole = crate::revolve(&Plane::XZ, &[profile], Vec2::ZERO, Vec2::new(0.0, 1.0), std::f64::consts::TAU).unwrap().pop().unwrap();
|
||||
let drilled = boolean(&plate, &hole, BoolOp::Cut).unwrap().unwrap();
|
||||
let v = 12000.0 - std::f64::consts::PI * 16.0 * (5.0 + 1.0);
|
||||
assert!(rel(measure(&drilled).unwrap().volume, v) < 1e-3);
|
||||
round_trip(&drilled);
|
||||
}
|
||||
|
||||
@@ -21,10 +21,12 @@ mod coplanar;
|
||||
mod curveblend;
|
||||
mod delete_face;
|
||||
mod freeform;
|
||||
pub mod fuzz;
|
||||
mod heal;
|
||||
mod helix;
|
||||
mod iges_in;
|
||||
mod iges_out;
|
||||
mod lip;
|
||||
mod loopblend;
|
||||
mod measure;
|
||||
mod meshbody;
|
||||
@@ -48,13 +50,14 @@ mod topo;
|
||||
|
||||
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet};
|
||||
pub use blend_corner::fillet_radii;
|
||||
pub use blend_variable::{fillet_chord, fillet_variable};
|
||||
pub use blend_variable::{fillet_chord, fillet_variable, fillet_variable_points};
|
||||
pub use body::{Body, EdgeInfo, FaceInfo, FacePaint, Paint};
|
||||
pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
|
||||
pub use delete_face::delete_faces;
|
||||
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh};
|
||||
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
|
||||
pub use iges_out::iges_export_bodies;
|
||||
pub use lip::raise_band;
|
||||
pub use measure::{BodyMeasure, measure};
|
||||
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||
pub use offset::with_same_surface;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Raising a band of a planar face (a plastic part's lip): the band along one of the face's
|
||||
//! boundary loops, a set width into the face, is pushed out along the face's normal. Built
|
||||
//! directly from the body's faces (the band's walls continue the walls below, which a boolean
|
||||
//! union can't join: their faces would coincide).
|
||||
|
||||
use solvecraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::body::{Body, Solid, from_p3, p3, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
fn fail(m: &str) -> KernelError {
|
||||
KernelError::Failed(format!("raising a band: {m}"))
|
||||
}
|
||||
|
||||
/// A boundary edge of the face, as its ends and (for an arc) a point halfway.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Piece {
|
||||
a: Vec3,
|
||||
b: Vec3,
|
||||
mid: Option<Vec3>,
|
||||
}
|
||||
|
||||
/// The planar face of `body` at `at`: its index and outward normal.
|
||||
fn face_at(body: &Body, at: Vec3) -> Result<(usize, Vec3)> {
|
||||
let size = body.size();
|
||||
let mesh = body.tessellate((size * 1e-3).max(1e-3))?;
|
||||
let near = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter_map(|(t, f)| {
|
||||
let [a, b, c] = mesh.tri(t)?;
|
||||
let n = (b - a).cross(c - a).normalized()?;
|
||||
let q = at - n * (at - a).dot(n);
|
||||
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-9);
|
||||
inside.then_some(((at - a).dot(n).abs(), *f as usize))
|
||||
})
|
||||
.min_by(|x, y| x.0.total_cmp(&y.0));
|
||||
let (d, f) = near.ok_or_else(|| fail("no face there"))?;
|
||||
if d > size * 1e-3 {
|
||||
return Err(fail("no face there"));
|
||||
}
|
||||
let face = body.solid.face_iter().nth(f).ok_or_else(|| fail("face"))?;
|
||||
let mt::Surface::Plane(pl) = face.oriented_surface() else { return Err(fail("the face must be planar")) };
|
||||
let n = pl.normal();
|
||||
Ok((f, Vec3::new(n.x, n.y, n.z).normalized().ok_or_else(|| fail("normal"))?))
|
||||
}
|
||||
|
||||
/// The face's loop moved `w` into the face (to the left of its edges, as a face's boundary has
|
||||
/// its inside on the left), corners mitred where the offsets don't already meet.
|
||||
fn offset_loop(pieces: &[Piece], n: Vec3, w: f64, tol: f64) -> Result<Vec<Piece>> {
|
||||
let left = |a: Vec3, b: Vec3| n.cross(b - a).normalized();
|
||||
let mut out: Vec<Piece> = Vec::new();
|
||||
for p in pieces {
|
||||
out.push(match p.mid {
|
||||
None => {
|
||||
let l = left(p.a, p.b).ok_or_else(|| fail("zero edge"))?;
|
||||
Piece { a: p.a + l * w, b: p.b + l * w, mid: None }
|
||||
}
|
||||
Some(m) => {
|
||||
// Each point moves square to the arc (along its radius), to the left.
|
||||
let (la, lm, lb) =
|
||||
(left(p.a, m).ok_or_else(|| fail("arc"))?, left(p.a, p.b).ok_or_else(|| fail("arc"))?, left(m, p.b).ok_or_else(|| fail("arc"))?);
|
||||
let c = circle_centre(p.a, m, p.b).ok_or_else(|| fail("arc"))?;
|
||||
let radial = |q: Vec3, hint: Vec3| {
|
||||
let r = (q - c).normalized().unwrap_or(hint);
|
||||
if r.dot(hint) >= 0.0 { r } else { -r }
|
||||
};
|
||||
let _ = (la, lb);
|
||||
Piece { a: p.a + radial(p.a, lm) * w, b: p.b + radial(p.b, lm) * w, mid: Some(m + radial(m, lm) * w) }
|
||||
}
|
||||
});
|
||||
}
|
||||
// Joints: where the offsets don't meet (sharp corners of lines), the lines are extended or
|
||||
// cut back to their crossing.
|
||||
let k = out.len();
|
||||
for i in 0..k {
|
||||
let j = (i + 1) % k;
|
||||
let (Some(pi), Some(pj)) = (out.get(i).copied(), out.get(j).copied()) else { continue };
|
||||
if pi.b.dist(pj.a) < tol {
|
||||
if let Some(x) = out.get_mut(j) {
|
||||
x.a = pi.b;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match (pi.mid, pj.mid) {
|
||||
(None, None) => {
|
||||
let (d1, d2) = (pi.b - pi.a, pj.b - pj.a);
|
||||
let w0 = pj.a - pi.a;
|
||||
let cr = d1.cross(d2);
|
||||
let den = cr.len2();
|
||||
if den < 1e-24 {
|
||||
return Err(fail("parallel edges at a corner"));
|
||||
}
|
||||
let t = w0.cross(d2).dot(cr) / den;
|
||||
let x = pi.a + d1 * t;
|
||||
if let Some(e) = out.get_mut(i) {
|
||||
e.b = x;
|
||||
}
|
||||
if let Some(e) = out.get_mut(j) {
|
||||
e.a = x;
|
||||
}
|
||||
}
|
||||
_ => return Err(fail("an arc meeting another edge at a corner")),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn circle_centre(a: Vec3, m: Vec3, b: Vec3) -> Option<Vec3> {
|
||||
let (u, v) = (b - a, m - a);
|
||||
let w = u.cross(v);
|
||||
let d = 2.0 * w.len2();
|
||||
if d < 1e-300 {
|
||||
return None;
|
||||
}
|
||||
Some(a + (v.cross(w) * u.len2() + w.cross(u) * v.len2()) * (1.0 / d))
|
||||
}
|
||||
|
||||
/// Raise the band of the planar face at `at` that runs `width` wide along its outer loop
|
||||
/// (`outside`) or its inner loop, by `height` along the face's normal.
|
||||
pub fn raise_band(body: &Body, at: Vec3, width: f64, height: f64, outside: bool) -> Result<Body> {
|
||||
body.require_brep("a lip")?;
|
||||
if !(width > 1e-6 && height > 1e-6 && width.is_finite() && height.is_finite()) {
|
||||
return Err(KernelError::Invalid("the band's width and height must be positive".into()));
|
||||
}
|
||||
if body.solid.boundaries().len() != 1 {
|
||||
return Err(fail("a body in one piece"));
|
||||
}
|
||||
let size = body.size();
|
||||
let tol = (size * 1e-7).max(1e-9) * 100.0;
|
||||
let (fi, n) = face_at(body, at)?;
|
||||
let faces: Vec<mt::Face> = body.solid.face_iter().cloned().collect();
|
||||
let f = faces.get(fi).ok_or_else(|| fail("face"))?;
|
||||
let wires = f.boundaries();
|
||||
if wires.len() != 2 {
|
||||
return Err(fail("the face must have an outer and one inner loop (a rim)"));
|
||||
}
|
||||
// Outer loop: turning positively about n.
|
||||
let turn = |w: &mt::Wire| -> f64 {
|
||||
let pts: Vec<Vec3> = w.vertex_iter().map(|v| from_p3(v.point())).collect();
|
||||
let mut s = Vec3::ZERO;
|
||||
for (k, p) in pts.iter().enumerate() {
|
||||
if let Some(q) = pts.get((k + 1) % pts.len()) {
|
||||
s += p.cross(*q);
|
||||
}
|
||||
}
|
||||
s.dot(n)
|
||||
};
|
||||
let (w0, w1) = (wires.first().ok_or_else(|| fail("loop"))?, wires.get(1).ok_or_else(|| fail("loop"))?);
|
||||
let (outer, inner) = if turn(w0) >= turn(w1) { (w0, w1) } else { (w1, w0) };
|
||||
let (along, other) = if outside { (outer, inner) } else { (inner, outer) };
|
||||
let pieces: Vec<Piece> = along
|
||||
.edge_iter()
|
||||
.map(|e| {
|
||||
let (a, b) = (from_p3(e.front().point()), from_p3(e.back().point()));
|
||||
match crate::offset::arc_mid(e, size * 1e-6) {
|
||||
Some(None) => Ok(Piece { a, b, mid: None }),
|
||||
Some(Some(m)) => Ok(Piece { a, b, mid: Some(m) }),
|
||||
None => Err(fail("edges must be lines or arcs")),
|
||||
}
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let moved = offset_loop(&pieces, n, width, tol)?;
|
||||
guard("lip", || {
|
||||
// The band's other edge, on the face.
|
||||
let verts: Vec<mt::Vertex> = moved.iter().map(|p| builder::vertex(p3(p.a))).collect();
|
||||
let k = verts.len();
|
||||
let mut cedges: Vec<mt::Edge> = Vec::with_capacity(k);
|
||||
for (i, p) in moved.iter().enumerate() {
|
||||
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % k)) else { return Err(fail("vertex")) };
|
||||
cedges.push(match p.mid {
|
||||
None => builder::line(a, b),
|
||||
Some(m) => builder::circle_arc(a, b, p3(m)),
|
||||
});
|
||||
}
|
||||
let c: mt::Wire = cedges.into();
|
||||
// The rest of the face: its other loop, and the band's edge as a loop of its own.
|
||||
// (The band's edge runs the face's way round: a hole when the band follows the inner
|
||||
// loop, the outer loop when it follows the outer.)
|
||||
let rest = mt::Face::try_new(vec![other.clone(), c.clone()], f.oriented_surface()).map_err(|e| fail(&e.to_string()))?;
|
||||
// Walls: the face's loop and the band's edge swept out; the band's top.
|
||||
let up = v3(n * height);
|
||||
let wall_along: mt::Shell = builder::tsweep(along, up);
|
||||
let wall_c: mt::Shell = builder::tsweep(&c, up);
|
||||
let top_of = |sh: &mt::Shell| -> Vec<mt::Wire> {
|
||||
sh.extract_boundaries().into_iter().filter(|w| w.vertex_iter().all(|v| (from_p3(v.point()) - at).dot(n) > height * 0.5)).collect()
|
||||
};
|
||||
let flipped = |sh: &mt::Shell, yes: bool| -> mt::Shell {
|
||||
if yes { sh.face_iter().map(|x| x.inverse()).collect::<Vec<_>>().into() } else { sh.clone() }
|
||||
};
|
||||
let mut last = String::new();
|
||||
for flip in 0..8u32 {
|
||||
let (wa, wc) = (flipped(&wall_along, flip & 1 == 1), flipped(&wall_c, flip & 2 == 2));
|
||||
let mut tops = top_of(&wa);
|
||||
tops.extend(top_of(&wc));
|
||||
if tops.len() != 2 {
|
||||
return Err(fail("the band's top"));
|
||||
}
|
||||
// The top closes both walls: its loops run against theirs.
|
||||
let tops: Vec<mt::Wire> = tops.iter().map(|w| w.inverse()).collect();
|
||||
let Ok(top) = builder::try_attach_plane(&tops) else { continue };
|
||||
let top = if flip & 4 == 4 { top.inverse() } else { top };
|
||||
let mut all: Vec<mt::Face> = faces.iter().enumerate().filter(|(i, _)| *i != fi).map(|(_, x)| x.clone()).collect();
|
||||
all.push(rest.clone());
|
||||
all.extend(wa.face_iter().cloned());
|
||||
all.extend(wc.face_iter().cloned());
|
||||
all.push(top);
|
||||
match Solid::try_new(vec![all.into()]) {
|
||||
Ok(s) => {
|
||||
let b = Body::new(s)?;
|
||||
if b.validity().is_empty() {
|
||||
return Ok(b);
|
||||
}
|
||||
last = b.validity().join("; ");
|
||||
}
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
}
|
||||
Err(fail(&last))
|
||||
})
|
||||
}
|
||||
@@ -101,7 +101,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
|
||||
t.swap(1, 2);
|
||||
}
|
||||
}
|
||||
Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None, paint: None })
|
||||
Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None, paint: None, splits: None })
|
||||
}
|
||||
|
||||
/// Wind every connected piece one way: a triangle that runs a side in the same direction as
|
||||
@@ -147,7 +147,7 @@ fn unify_winding(m: &mut TriMesh) {
|
||||
}
|
||||
|
||||
/// Every triangle side is shared by exactly two triangles in opposite directions.
|
||||
fn is_closed(m: &TriMesh) -> bool {
|
||||
pub(crate) fn is_closed(m: &TriMesh) -> bool {
|
||||
let mut count: HashMap<(u32, u32), i32> = HashMap::new();
|
||||
for t in &m.triangles {
|
||||
for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
|
||||
@@ -185,6 +185,6 @@ impl Body {
|
||||
t.swap(1, 2);
|
||||
}
|
||||
}
|
||||
Some(Body { solid: self.solid.clone(), mesh: Some(Arc::new(out)), color: self.color, paint: self.paint.clone() })
|
||||
Some(Body { solid: self.solid.clone(), mesh: Some(Arc::new(out)), color: self.color, paint: self.paint.clone(), splits: self.splits.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
+333
-22
@@ -64,6 +64,15 @@ impl Surf {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The gradient of [`Surf::dist`] (the normal before orientation).
|
||||
fn grad(&self, p: Vec3) -> Option<Vec3> {
|
||||
match *self {
|
||||
Surf::Plane { n, .. } => Some(n),
|
||||
Surf::Cylinder { convex, .. } | Surf::Sphere { convex, .. } | Surf::Cone { convex, .. } | Surf::Torus { convex, .. } => {
|
||||
self.normal(p).map(|n| if convex { n } else { -n })
|
||||
}
|
||||
}
|
||||
}
|
||||
fn moved(&self, s: f64) -> Option<Surf> {
|
||||
match *self {
|
||||
Surf::Plane { n, d } => Some(Surf::Plane { n, d: d + s }),
|
||||
@@ -222,6 +231,10 @@ pub(crate) fn surf_of(f: &mt::Face, tol: f64) -> Option<Surf> {
|
||||
return Some(t);
|
||||
}
|
||||
let (p0, n0) = *s.first()?;
|
||||
// Flat though not built as a plane (a radial line swept round an axis).
|
||||
if s.iter().all(|(p, n)| n.dot(n0) > 1.0 - 1e-9 && (*p - p0).dot(n0).abs() < tol) {
|
||||
return Some(Surf::Plane { n: n0, d: n0.dot(p0) });
|
||||
}
|
||||
// The normal most square to the first (a half cylinder's two ends are opposite).
|
||||
let far = s.iter().map(|x| x.1).min_by(|a, b| a.dot(n0).abs().total_cmp(&b.dot(n0).abs()))?;
|
||||
let Some(a) = n0.cross(far).normalized() else { return sphere_or_cone(&s, tol) };
|
||||
@@ -277,7 +290,9 @@ fn torus_of(f: &mt::Face, s: &[(Vec3, Vec3)], tol: f64) -> Option<Surf> {
|
||||
}
|
||||
let x = crate::polyhedron::solve3_pub(m, rhs)?;
|
||||
let (big, h, r) = (x[0], x[1], x[2]);
|
||||
if !(big > 1e-9 && r.abs() > 1e-9 && r.abs() < big) {
|
||||
// (A tube wider than its circle, a spindle torus, is fine: blends use its outer part, where
|
||||
// each point's tube centre is the one on its own side of the axis.)
|
||||
if !(big > 1e-9 && r.abs() > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let t = Surf::Torus { o, a, big, h, r: r.abs(), convex: r > 0.0 };
|
||||
@@ -354,9 +369,26 @@ fn sphere_or_cone(s: &[(Vec3, Vec3)], tol: f64) -> Option<Surf> {
|
||||
/// n_i · δ = s_i for each face's normal n_i at p, then checked against the moved surfaces.
|
||||
fn move_point(p: Vec3, old: &[Surf], shifts: &[f64], new: &[Surf], tol: f64) -> Option<Vec3> {
|
||||
let ns: Vec<Vec3> = old.iter().map(|sf| sf.normal(p)).collect::<Option<_>>()?;
|
||||
let mut q = p + span_step(&ns, shifts)?;
|
||||
// The step is exact for planes; a curved face meeting another at an angle needs a few
|
||||
// Newton steps onto the moved surfaces.
|
||||
for _ in 0..30 {
|
||||
let d: Vec<f64> = new.iter().map(|sf| sf.dist(q)).collect();
|
||||
if d.iter().all(|x| x.abs() < tol) {
|
||||
return Some(q);
|
||||
}
|
||||
let gs: Vec<Vec3> = new.iter().map(|sf| sf.grad(q)).collect::<Option<_>>()?;
|
||||
let rhs: Vec<f64> = d.iter().map(|x| -x).collect();
|
||||
q += span_step(&gs, &rhs)?;
|
||||
}
|
||||
new.iter().all(|sf| sf.dist(q).abs() < tol).then_some(q)
|
||||
}
|
||||
|
||||
/// The smallest move `v` (in the span of `ns`) with `n_i · v = s_i` (least squares).
|
||||
fn span_step(ns: &[Vec3], s: &[f64]) -> Option<Vec3> {
|
||||
// Orthonormal basis of the normals' span (tangent faces share a normal).
|
||||
let mut basis: Vec<Vec3> = Vec::new();
|
||||
for n in &ns {
|
||||
for n in ns {
|
||||
let mut v = *n;
|
||||
for e in &basis {
|
||||
v = v - *e * v.dot(*e);
|
||||
@@ -369,13 +401,13 @@ fn move_point(p: Vec3, old: &[Surf], shifts: &[f64], new: &[Surf], tol: f64) ->
|
||||
let r = basis.len();
|
||||
let mut m = [[0.0f64; 3]; 3];
|
||||
let mut rhs = [0.0f64; 3];
|
||||
for (n, s) in ns.iter().zip(shifts) {
|
||||
for (n, si) in ns.iter().zip(s) {
|
||||
let row: Vec<f64> = basis.iter().map(|e| n.dot(*e)).collect();
|
||||
for i in 0..r {
|
||||
for j in 0..r {
|
||||
m[i][j] += row[i] * row[j];
|
||||
}
|
||||
rhs[i] += row[i] * s;
|
||||
rhs[i] += row[i] * si;
|
||||
}
|
||||
}
|
||||
// Pad unused dimensions with the identity.
|
||||
@@ -383,15 +415,79 @@ fn move_point(p: Vec3, old: &[Surf], shifts: &[f64], new: &[Surf], tol: f64) ->
|
||||
row[i] = 1.0;
|
||||
}
|
||||
let c = crate::polyhedron::solve3_pub(m, rhs)?;
|
||||
let mut q = p;
|
||||
let mut v = Vec3::ZERO;
|
||||
for (k, e) in basis.iter().enumerate() {
|
||||
q += *e * c[k];
|
||||
v += *e * c[k];
|
||||
}
|
||||
new.iter().all(|sf| sf.dist(q).abs() < tol).then_some(q)
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// A line or circular arc through an edge (by samples): `None` for other curves.
|
||||
fn arc_mid(e: &mt::Edge, tol: f64) -> Option<Option<Vec3>> {
|
||||
/// Maximum straight edges per face checked pairwise for crossings.
|
||||
const MAX_CROSSING_EDGES: usize = 2000;
|
||||
|
||||
/// Where the moved straight edges of a planar face cross each other (or a loop of straight
|
||||
/// edges turns round), if they do. Faces next to a collapsed round are left to the other checks.
|
||||
fn crossed_loops(
|
||||
faces: &[mt::Face],
|
||||
old: &[Surf],
|
||||
newpos: &HashMap<mt::VertexID, Vec3>,
|
||||
edge_collapsed: &dyn Fn(&mt::Edge) -> bool,
|
||||
size: f64,
|
||||
) -> Option<Vec3> {
|
||||
let eps = size * 1e-9;
|
||||
for (f, sf) in faces.iter().zip(old) {
|
||||
let Surf::Plane { n, .. } = *sf else { continue };
|
||||
if f.edge_iter().any(|e| edge_collapsed(&e)) {
|
||||
continue;
|
||||
}
|
||||
let (u, w) = (n.any_perp(), n.cross(n.any_perp()));
|
||||
let flat = |p: Vec3| (p.dot(u), p.dot(w));
|
||||
// Straight edges as (vertex ids, old ends, new ends) in the plane.
|
||||
let mut segs: Vec<([mt::VertexID; 2], [(f64, f64); 2])> = Vec::new();
|
||||
for wire in f.absolute_boundaries() {
|
||||
let (mut area_old, mut area_new, mut straight) = (0.0, 0.0, true);
|
||||
for e in wire.edge_iter() {
|
||||
let (v0, v1) = (e.front(), e.back());
|
||||
let (Some(q0), Some(q1)) = (newpos.get(&v0.id()), newpos.get(&v1.id())) else { return None };
|
||||
let (o0, o1, n0, n1) = (flat(from_p3(v0.point())), flat(from_p3(v1.point())), flat(*q0), flat(*q1));
|
||||
area_old += o0.0 * o1.1 - o1.0 * o0.1;
|
||||
area_new += n0.0 * n1.1 - n1.0 * n0.1;
|
||||
if arc_mid(e, size * 1e-6) == Some(None) {
|
||||
segs.push(([v0.id(), v1.id()], [n0, n1]));
|
||||
} else {
|
||||
straight = false;
|
||||
}
|
||||
}
|
||||
if straight && area_old.abs() > eps && area_old * area_new <= 0.0 {
|
||||
return wire.vertex_iter().next().and_then(|v| newpos.get(&v.id()).copied());
|
||||
}
|
||||
}
|
||||
if segs.len() > MAX_CROSSING_EDGES {
|
||||
continue;
|
||||
}
|
||||
let side = |a: (f64, f64), c: (f64, f64), p: (f64, f64)| (c.0 - a.0) * (p.1 - a.1) - (c.1 - a.1) * (p.0 - a.0);
|
||||
for (i, (ia, [a0, a1])) in segs.iter().enumerate() {
|
||||
for (ib, [b0, b1]) in segs.iter().skip(i + 1) {
|
||||
if ia.iter().any(|v| ib.contains(v)) {
|
||||
continue;
|
||||
}
|
||||
let (s0, s1) = (side(*a0, *a1, *b0), side(*a0, *a1, *b1));
|
||||
let (t0, t1) = (side(*b0, *b1, *a0), side(*b0, *b1, *a1));
|
||||
let tol = eps * size;
|
||||
if ((s0 > tol && s1 < -tol) || (s0 < -tol && s1 > tol)) && ((t0 > tol && t1 < -tol) || (t0 < -tol && t1 > tol)) {
|
||||
let k = t0 / (t0 - t1);
|
||||
let (x, y) = (a0.0 + (a1.0 - a0.0) * k, a0.1 + (a1.1 - a0.1) * k);
|
||||
let d = n.dot(newpos.get(&ia[0]).copied().unwrap_or_default());
|
||||
return Some(u * x + w * y + n * d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn arc_mid(e: &mt::Edge, tol: f64) -> Option<Option<Vec3>> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
@@ -423,8 +519,13 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
let mut old = Vec::with_capacity(faces.len());
|
||||
let mut new = Vec::with_capacity(faces.len());
|
||||
let mut shifts = Vec::with_capacity(faces.len());
|
||||
// Faces moved to nothing (a round thinner than the move): they go, their neighbours meet.
|
||||
let mut collapsed: Vec<bool> = Vec::with_capacity(faces.len());
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let sf = surf_of(f, tol * 100.0).ok_or_else(|| fail("only bodies with planes, cylinders, cones, spheres and tori"))?;
|
||||
let sf = surf_of(f, tol * 100.0).ok_or_else(|| {
|
||||
let at = samples(f).first().map(|(p, _)| format!(" (a face at [{:.2}, {:.2}, {:.2}])", p.x, p.y, p.z)).unwrap_or_default();
|
||||
fail(&format!("only bodies with planes, cylinders, cones, spheres and tori{at}"))
|
||||
})?;
|
||||
let n = match sf {
|
||||
Surf::Plane { n, .. } => n,
|
||||
Surf::Cylinder { a, .. } | Surf::Cone { a, .. } | Surf::Torus { a, .. } => a.any_perp(),
|
||||
@@ -432,7 +533,16 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
};
|
||||
let s = shift(i, n);
|
||||
old.push(sf);
|
||||
new.push(sf.moved(s).ok_or_else(|| fail("a cylinder shrinks to nothing"))?);
|
||||
match sf.moved(s) {
|
||||
Some(x) => {
|
||||
new.push(x);
|
||||
collapsed.push(false);
|
||||
}
|
||||
None => {
|
||||
new.push(sf);
|
||||
collapsed.push(true);
|
||||
}
|
||||
}
|
||||
shifts.push(s);
|
||||
}
|
||||
let pick = |ids: &[usize]| -> (Vec<Surf>, Vec<f64>, Vec<Surf>) {
|
||||
@@ -459,11 +569,102 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
}
|
||||
}
|
||||
}
|
||||
let is_collapsed = |i: usize| collapsed.get(i).copied().unwrap_or(false);
|
||||
let any_collapsed = collapsed.iter().any(|c| *c);
|
||||
// A collapsed face's place is taken by the faces around it (through other collapsed ones).
|
||||
let mut nbrs: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
for fs in efaces.values() {
|
||||
for a in fs {
|
||||
for b2 in fs {
|
||||
if a != b2 {
|
||||
let e = nbrs.entry(*a).or_default();
|
||||
if !e.contains(b2) {
|
||||
e.push(*b2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let expand = |fs: &[usize]| -> Vec<usize> {
|
||||
let mut out: Vec<usize> = fs.iter().copied().filter(|i| !is_collapsed(*i)).collect();
|
||||
let mut queue: Vec<usize> = fs.iter().copied().filter(|i| is_collapsed(*i)).collect();
|
||||
let mut seen = queue.clone();
|
||||
while let Some(c) = queue.pop() {
|
||||
for nb in nbrs.get(&c).into_iter().flatten() {
|
||||
if is_collapsed(*nb) {
|
||||
if !seen.contains(nb) {
|
||||
seen.push(*nb);
|
||||
queue.push(*nb);
|
||||
}
|
||||
} else if !out.contains(nb) {
|
||||
out.push(*nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
// Each edge's ends and middle, and the faces either side.
|
||||
let edge_pts: Vec<(Vec<usize>, [Vec3; 3])> = b
|
||||
.solid
|
||||
.edge_iter()
|
||||
.filter_map(|e| {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
Some((
|
||||
efaces.get(&e.id())?.clone(),
|
||||
[from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point()), from_p3(c.subs((t0 + t1) / 2.0))],
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let mut newpos: HashMap<mt::VertexID, Vec3> = HashMap::new();
|
||||
for v in b.solid.vertex_iter() {
|
||||
let fs = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?;
|
||||
let (o, s, n) = pick(fs);
|
||||
let at = vfaces.get(&v.id()).ok_or_else(|| fail("vertex"))?;
|
||||
let p = from_p3(v.point());
|
||||
let fs: Vec<usize> = if at.iter().any(|i| is_collapsed(*i)) {
|
||||
// On a collapsed round: its own faces, then the faces along the rounds it is on,
|
||||
// nearest first, until they fix a point.
|
||||
let group = expand(at);
|
||||
let mut own: Vec<usize> = at.iter().copied().filter(|i| !is_collapsed(*i)).collect();
|
||||
let mut cand: Vec<(f64, usize)> = group
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|nb| !own.contains(nb))
|
||||
.map(|nb| {
|
||||
let d = edge_pts
|
||||
.iter()
|
||||
.filter(|(fs, _)| fs.contains(&nb) && fs.iter().any(|i| is_collapsed(*i)))
|
||||
.flat_map(|(_, q)| q.iter().map(|x| x.dist(p)))
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
(d, nb)
|
||||
})
|
||||
.collect();
|
||||
cand.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||||
let rank = |ids: &[usize]| -> usize {
|
||||
let mut basis: Vec<Vec3> = Vec::new();
|
||||
for i in ids {
|
||||
let Some(nv) = old.get(*i).and_then(|sf| sf.normal(p)) else { continue };
|
||||
let mut w = nv;
|
||||
for e in &basis {
|
||||
w = w - *e * w.dot(*e);
|
||||
}
|
||||
if let Some(u) = w.normalized().filter(|_| w.len() > 1e-6) {
|
||||
basis.push(u);
|
||||
}
|
||||
}
|
||||
basis.len()
|
||||
};
|
||||
for (_, c) in cand {
|
||||
if rank(&own) >= 3 {
|
||||
break;
|
||||
}
|
||||
own.push(c);
|
||||
}
|
||||
own
|
||||
} else {
|
||||
at.clone()
|
||||
};
|
||||
let (o, s, n) = pick(&fs);
|
||||
// A cone's apex goes with the cone (its normal is not defined there).
|
||||
let apex = o.iter().zip(&n).find_map(|(a, b)| match (a, b) {
|
||||
(Surf::Cone { v: va, .. }, Surf::Cone { v: vb, .. }) if va.dist(p) < size * 1e-6 => Some(*vb),
|
||||
@@ -475,18 +676,109 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
};
|
||||
newpos.insert(v.id(), q);
|
||||
}
|
||||
// An edge turned round: faces crossed over (a wall thinner than the move), which would
|
||||
// leave the body inside out.
|
||||
let edge_collapsed = |e: &mt::Edge| efaces.get(&e.id()).is_some_and(|fs| fs.iter().any(|i| is_collapsed(*i)));
|
||||
for e in b.solid.edge_iter() {
|
||||
if edge_collapsed(&e) {
|
||||
continue;
|
||||
}
|
||||
let (f0, f1) = (e.absolute_front(), e.absolute_back());
|
||||
let (Some(q0), Some(q1)) = (newpos.get(&f0.id()), newpos.get(&f1.id())) else { continue };
|
||||
let (old_v, new_v) = (from_p3(f1.point()) - from_p3(f0.point()), *q1 - *q0);
|
||||
if old_v.len() > size * 1e-6 && old_v.dot(new_v) < 0.0 {
|
||||
return Err(fail("not supported yet: the move is larger than a wall or step is thick (faces would cross)"));
|
||||
}
|
||||
}
|
||||
// A plane's loops crossing after the move: a rim or ledge narrower than the move (the hole
|
||||
// in a top face sliding past its outer edge) turns part of the face inside out, though no
|
||||
// edge turns round.
|
||||
if let Some(at) = crossed_loops(&faces, &old, &newpos, &edge_collapsed, size) {
|
||||
return Err(fail(&format!(
|
||||
"not supported yet: the move is larger than a rim or ledge near [{:.1}, {:.1}, {:.1}] is wide (a face would turn inside out)",
|
||||
at.x, at.y, at.z
|
||||
)));
|
||||
}
|
||||
guard("offset", || {
|
||||
let verts: HashMap<mt::VertexID, mt::Vertex> = newpos.iter().map(|(k, p)| (*k, builder::vertex(p3(*p)))).collect();
|
||||
let mut edges: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
// Vertices of collapsed faces that land together become one.
|
||||
let on_collapsed: std::collections::HashSet<mt::VertexID> = faces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| is_collapsed(*i))
|
||||
.flat_map(|(_, f)| f.vertex_iter().map(|v| v.id()).collect::<Vec<_>>())
|
||||
.collect();
|
||||
let mut verts: HashMap<mt::VertexID, mt::Vertex> = HashMap::new();
|
||||
let mut welded: Vec<(Vec3, mt::Vertex)> = Vec::new();
|
||||
for v in b.solid.vertex_iter() {
|
||||
let Some(p) = newpos.get(&v.id()).copied() else { continue };
|
||||
let nv = if any_collapsed && on_collapsed.contains(&v.id()) {
|
||||
match welded.iter().find(|(q, _)| q.dist(p) < size * 1e-6) {
|
||||
Some((_, w)) => w.clone(),
|
||||
None => {
|
||||
let w = builder::vertex(p3(p));
|
||||
welded.push((p, w.clone()));
|
||||
w
|
||||
}
|
||||
}
|
||||
} else {
|
||||
builder::vertex(p3(p))
|
||||
};
|
||||
verts.insert(v.id(), nv);
|
||||
}
|
||||
// Old edge → new edge, and whether it runs the other way (merged with its twin).
|
||||
let mut edges: HashMap<mt::EdgeID, (mt::Edge, bool)> = HashMap::new();
|
||||
let mut merged: HashMap<(mt::VertexID, mt::VertexID), mt::Edge> = HashMap::new();
|
||||
for e in b.solid.edge_iter() {
|
||||
if edges.contains_key(&e.id()) {
|
||||
continue;
|
||||
}
|
||||
let (Some(a), Some(c)) = (verts.get(&e.absolute_front().id()), verts.get(&e.absolute_back().id())) else { return Err(fail("edge")) };
|
||||
if from_p3(a.point()).dist(from_p3(c.point())) < size * 1e-9 {
|
||||
let touches = edge_collapsed(&e);
|
||||
if a == c || from_p3(a.point()).dist(from_p3(c.point())) < size * 1e-9 {
|
||||
if touches {
|
||||
continue;
|
||||
}
|
||||
return Err(fail("an edge vanishes (the offset is too large)"));
|
||||
}
|
||||
let kind = arc_mid(&e, size * 1e-6).ok_or_else(|| fail("edges must be lines or arcs"))?;
|
||||
if efaces.get(&e.id()).is_some_and(|fs| fs.iter().all(|i| is_collapsed(*i))) {
|
||||
continue;
|
||||
}
|
||||
if touches {
|
||||
// Along a collapsed round: a straight edge, shared with its twin from the
|
||||
// round's other side.
|
||||
let ne = match merged.get(&(a.id(), c.id())).or_else(|| merged.get(&(c.id(), a.id()))) {
|
||||
Some(x) => x.clone(),
|
||||
None => {
|
||||
let x = builder::line(a, c);
|
||||
merged.insert((a.id(), c.id()), x.clone());
|
||||
x
|
||||
}
|
||||
};
|
||||
let flipped = ne.front() != a;
|
||||
edges.insert(e.id(), (ne, flipped));
|
||||
continue;
|
||||
}
|
||||
let Some(kind) = arc_mid(&e, size * 1e-6) else {
|
||||
// Another curve (where two cylinders meet): its points moved onto the moved
|
||||
// faces, through a smooth curve.
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let fs = efaces.get(&e.id()).ok_or_else(|| fail("edge faces"))?;
|
||||
let (o, s, n) = pick(fs);
|
||||
let cv = e.curve();
|
||||
let (t0, t1) = cv.range_tuple();
|
||||
let k = 16;
|
||||
let mut pts = vec![from_p3(a.point())];
|
||||
for j in 1..k {
|
||||
let p = from_p3(cv.subs(t0 + (t1 - t0) * j as f64 / k as f64));
|
||||
pts.push(
|
||||
move_point(p, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("an edge whose faces don't meet the same way after the move"))?,
|
||||
);
|
||||
}
|
||||
pts.push(from_p3(c.point()));
|
||||
let curve = crate::build::interpolate_cubic(&pts).ok_or_else(|| fail("edge curve"))?;
|
||||
edges.insert(e.id(), (mt::Edge::new(a, c, mt::Curve::BSplineCurve(curve)), false));
|
||||
continue;
|
||||
};
|
||||
let ne = match kind {
|
||||
None => builder::line(a, c),
|
||||
Some(m) => {
|
||||
@@ -497,16 +789,21 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
builder::circle_arc(a, c, p3(m2))
|
||||
}
|
||||
};
|
||||
edges.insert(e.id(), ne);
|
||||
edges.insert(e.id(), (ne, false));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
if is_collapsed(i) {
|
||||
continue;
|
||||
}
|
||||
let wires: Vec<mt::Wire> = f
|
||||
.absolute_boundaries()
|
||||
.iter()
|
||||
.map(|w| {
|
||||
w.edge_iter()
|
||||
.filter_map(|e| edges.get(&e.id()).map(|ne| if e.front() == e.absolute_front() { ne.clone() } else { ne.inverse() }))
|
||||
.filter_map(|e| {
|
||||
edges.get(&e.id()).map(|(ne, flip)| if (e.front() == e.absolute_front()) != *flip { ne.clone() } else { ne.inverse() })
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
})
|
||||
@@ -540,8 +837,16 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
|
||||
}
|
||||
out.push(nf);
|
||||
}
|
||||
let shell: mt::Shell = out.into();
|
||||
let solid = Solid::try_new(vec![shell]).map_err(|e| fail(&e.to_string()))?;
|
||||
// One shell per shell of the body (pieces, voids), less the collapsed faces.
|
||||
let mut shells: Vec<mt::Shell> = Vec::new();
|
||||
let mut it = out.into_iter();
|
||||
let mut k = 0;
|
||||
for sh in b.solid.boundaries() {
|
||||
let keep = (k..k + sh.len()).filter(|i| !is_collapsed(*i)).count();
|
||||
k += sh.len();
|
||||
shells.push(it.by_ref().take(keep).collect::<Vec<_>>().into());
|
||||
}
|
||||
let solid = Solid::try_new(shells).map_err(|e| fail(&e.to_string()))?;
|
||||
Body::new(solid)
|
||||
})
|
||||
}
|
||||
@@ -746,7 +1051,7 @@ pub(crate) fn draft_walls(b: &Body, chosen: &[usize], neutral: &solvecraft_geom:
|
||||
}
|
||||
|
||||
/// The chosen faces and every face on the same surface joined to them (the halves of a hole's
|
||||
/// wall), as face indices.
|
||||
/// wall), as face indices. Pieces of a face split on purpose (Split Face) stay apart.
|
||||
pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec<usize> {
|
||||
let faces: Vec<mt::Face> = b.solid.face_iter().cloned().collect();
|
||||
let size = b.size();
|
||||
@@ -766,6 +1071,7 @@ pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec<usize> {
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let keep = b.split_keep();
|
||||
let mut out: Vec<usize> = chosen.to_vec();
|
||||
let mut grew = true;
|
||||
while grew {
|
||||
@@ -777,7 +1083,12 @@ pub fn with_same_surface(b: &Body, chosen: &[usize]) -> Vec<usize> {
|
||||
let Some(Some(si)) = surfs.get(i) else { continue };
|
||||
let joins = out.iter().any(|j| {
|
||||
surfs.get(*j).and_then(|x| x.as_ref()).is_some_and(|sj| same(si, sj))
|
||||
&& faces.get(*j).is_some_and(|g| g.edge_iter().any(|e| f.edge_iter().any(|x| x.id() == e.id())))
|
||||
&& faces.get(*j).is_some_and(|g| {
|
||||
g.edge_iter().any(|e| {
|
||||
f.edge_iter().any(|x| x.id() == e.id())
|
||||
&& (keep.is_empty() || !crate::heal::on_split_line(&crate::heal::edge_points(&e), keep))
|
||||
})
|
||||
})
|
||||
});
|
||||
if joins {
|
||||
out.push(i);
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3, p3, v3};
|
||||
use crate::body::{Body, Solid, from_p3, p3, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -162,6 +162,25 @@ thread_local! {
|
||||
/// everything or an intersection of disjoint bodies. Results are checked against volume bounds
|
||||
/// and retried with shifted copies and other tolerances when they fail or look wrong.
|
||||
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
let r = boolean_whole(a, b, op);
|
||||
// Bodies in several pieces: when the whole failed (or came out empty), a piece at a time.
|
||||
let retry = match &r {
|
||||
Err(_) => true,
|
||||
Ok(None) => op != BoolOp::Intersect,
|
||||
Ok(Some(_)) => false,
|
||||
};
|
||||
if retry && (a.solid.boundaries().len() > 1 || b.solid.boundaries().len() > 1) {
|
||||
let (la, lb) = (a.lumps()?, b.lumps()?);
|
||||
if (la.len() > 1 || lb.len() > 1)
|
||||
&& let Ok(Some(x)) = by_lumps(la, lb, op)
|
||||
{
|
||||
return Ok(Some(x));
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
a.require_brep("a boolean")?;
|
||||
b.require_brep("a boolean")?;
|
||||
let size = a.size().max(b.size());
|
||||
@@ -241,6 +260,7 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
}
|
||||
}
|
||||
let mut empty_votes = 0;
|
||||
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
|
||||
for (attempt, j) in JITTER.iter().enumerate() {
|
||||
let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01);
|
||||
for k in [2e-3, 5e-4] {
|
||||
@@ -267,7 +287,7 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
empty_votes += 1;
|
||||
last = "empty result".into();
|
||||
}
|
||||
Ok(s) => match Body::new(crate::heal::heal(s, size)) {
|
||||
Ok(s) => match Body::new(crate::heal::heal_keep(s, size, &keep)) {
|
||||
Ok(body) => {
|
||||
let (ok, v) = check(&body);
|
||||
if ok {
|
||||
@@ -338,6 +358,80 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
Err(KernelError::Failed(format!("boolean {op:?}: {last}")))
|
||||
}
|
||||
|
||||
/// A boolean of bodies given as their pieces; the result's pieces in one body.
|
||||
fn by_lumps(la: Vec<Body>, lb: Vec<Body>, op: BoolOp) -> Result<Option<Body>> {
|
||||
let boxes_meet = |x: &Body, y: &Body| -> bool {
|
||||
let (Ok(mx), Ok(my)) = (x.tessellate(x.size() * 0.05 + 1e-3), y.tessellate(y.size() * 0.05 + 1e-3)) else { return true };
|
||||
let (bx, by) = (mx.bounds(), my.bounds());
|
||||
let g = (x.size() + y.size()) * 0.01;
|
||||
bx.min.x <= by.max.x + g
|
||||
&& by.min.x <= bx.max.x + g
|
||||
&& bx.min.y <= by.max.y + g
|
||||
&& by.min.y <= bx.max.y + g
|
||||
&& bx.min.z <= by.max.z + g
|
||||
&& by.min.z <= bx.max.z + g
|
||||
};
|
||||
let mut out: Vec<Body> = Vec::new();
|
||||
match op {
|
||||
BoolOp::Cut => {
|
||||
for x in la {
|
||||
let mut cur = Some(x);
|
||||
for y in &lb {
|
||||
cur = match cur {
|
||||
Some(c) if boxes_meet(&c, y) => boolean(&c, y, BoolOp::Cut)?,
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
out.extend(cur);
|
||||
}
|
||||
}
|
||||
BoolOp::Intersect => {
|
||||
for x in &la {
|
||||
for y in &lb {
|
||||
if boxes_meet(x, y)
|
||||
&& let Some(r) = boolean(x, y, BoolOp::Intersect)?
|
||||
{
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BoolOp::Union => {
|
||||
out = la;
|
||||
for y in lb {
|
||||
let mut cur = y;
|
||||
let mut rest = Vec::new();
|
||||
for x in out {
|
||||
if boxes_meet(&x, &cur) {
|
||||
match boolean(&x, &cur, BoolOp::Union)? {
|
||||
Some(u) if u.solid.boundaries().len() == 1 => cur = u,
|
||||
_ => rest.push(x),
|
||||
}
|
||||
} else {
|
||||
rest.push(x);
|
||||
}
|
||||
}
|
||||
rest.push(cur);
|
||||
out = rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
join_pieces(out)
|
||||
}
|
||||
|
||||
/// Bodies (apart from each other) as one body of several pieces.
|
||||
pub(crate) fn join_pieces(pieces: Vec<Body>) -> Result<Option<Body>> {
|
||||
match pieces.len() {
|
||||
0 => Ok(None),
|
||||
1 => Ok(pieces.into_iter().next()),
|
||||
_ => {
|
||||
let shells: Vec<mt::Shell> = pieces.iter().flat_map(|p| p.solid.boundaries().clone()).collect();
|
||||
let solid = guard("pieces", || Solid::try_new(shells).map_err(|e| KernelError::Failed(e.to_string())))?;
|
||||
Body::new(solid).map(Some)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Affine transform by a column-major 4×4 matrix (rotations, translations, reflections).
|
||||
pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
|
||||
if m.iter().flatten().any(|x| !x.is_finite()) {
|
||||
|
||||
@@ -192,7 +192,7 @@ pub fn draft(b: &Body, faces: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64)
|
||||
|
||||
/// Draft of a non-convex body: walls (planes, cylinders) next to caps square to `pull`.
|
||||
fn draft_walls(b: &Body, at: &[Vec3], neutral: &Plane, pull: Vec3, angle: f64) -> Result<Body> {
|
||||
let healed = Body::new(crate::heal::heal(b.deep_copy(), b.size()))?;
|
||||
let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?;
|
||||
let size = healed.size();
|
||||
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
|
||||
let mut chosen: Vec<usize> = Vec::new();
|
||||
@@ -338,7 +338,7 @@ pub fn round_all_edges(b: &Body, r: f64) -> Result<Body> {
|
||||
/// by the thickness (open faces outward, past the body) keeping the body's topology, which
|
||||
/// gives the cavity; the shell is the body minus the cavity.
|
||||
fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
||||
let healed = Body::new(crate::heal::heal(b.deep_copy(), b.size()))?;
|
||||
let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?;
|
||||
let size = healed.size();
|
||||
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
|
||||
// Faces to open: the face of the triangle nearest each point.
|
||||
@@ -361,21 +361,77 @@ fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
|
||||
return Err(KernelError::Invalid("select at least one face to remove".into()));
|
||||
}
|
||||
let margin = thickness + size * 0.05;
|
||||
let cavity = offset_planar(&healed, |fi, _| if opened.contains(&fi) { margin } else { -thickness })?;
|
||||
match crate::ops::boolean(&healed, &cavity, crate::BoolOp::Cut) {
|
||||
// Open faces pushed out past the body: the simple way, unless a face tangent to an open
|
||||
// one (a fillet along its edge) can't follow; then every face moves in (tangencies keep)
|
||||
// and the opening is the open face's copy swept out through the body.
|
||||
let cavity = match offset_planar(&healed, |fi, _| if opened.contains(&fi) { margin } else { -thickness }) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return shell_by_opening(&healed, &opened, thickness, margin).map_err(|_| e),
|
||||
};
|
||||
let r = crate::ops::boolean(&healed, &cavity, crate::BoolOp::Cut);
|
||||
if r.is_err()
|
||||
&& let Ok(x) = shell_by_opening(&healed, &opened, thickness, margin)
|
||||
{
|
||||
return Ok(x);
|
||||
}
|
||||
match r {
|
||||
Ok(Some(r)) => Ok(r),
|
||||
Ok(None) => Err(KernelError::Failed("the shell removed everything".into())),
|
||||
// Healing can leave faces the boolean dislikes (revolved patches): the body as it was.
|
||||
Err(e) if healed.face_count() == b.face_count() => {
|
||||
// Healing can leave faces the boolean dislikes (revolved patches): the body as it was,
|
||||
// its faces to open found again.
|
||||
Err(e) => {
|
||||
let raw = b.tessellate((size * 1e-3).max(1e-3)).map_err(|_| e.clone())?;
|
||||
let mut opened_raw: Vec<usize> = Vec::new();
|
||||
for p in open {
|
||||
let near = raw
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&raw.tri_face)
|
||||
.filter_map(|(t, f)| raw.tri(t).map(|[x, y, z]| (point_tri(*p, x, y, z), *f as usize)))
|
||||
.min_by(|a, c| a.0.total_cmp(&c.0));
|
||||
if let Some((d, f)) = near
|
||||
&& d < size * 1e-3 + 1e-6
|
||||
&& !opened_raw.contains(&f)
|
||||
{
|
||||
opened_raw.push(f);
|
||||
}
|
||||
}
|
||||
let opened = opened_raw;
|
||||
let cavity = offset_planar(b, |fi, _| if opened.contains(&fi) { margin } else { -thickness }).map_err(|_| e.clone())?;
|
||||
crate::ops::boolean(b, &cavity, crate::BoolOp::Cut)
|
||||
.map_err(|_| e)?
|
||||
.ok_or_else(|| KernelError::Failed("the shell removed everything".into()))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// A shell made from the cavity with every face moved in by the thickness (open faces too),
|
||||
/// joined with each open face's moved copy swept out through the body.
|
||||
fn shell_by_opening(b: &Body, opened: &[usize], thickness: f64, reach: f64) -> Result<Body> {
|
||||
let closed = offset_planar(b, |_, _| -thickness)?;
|
||||
let faces: Vec<mt::Face> = closed.solid.face_iter().cloned().collect();
|
||||
let mut cavity = closed.clone();
|
||||
for fi in opened {
|
||||
let f = faces.get(*fi).ok_or_else(|| KernelError::Failed("shell: open face".into()))?;
|
||||
let mt::Surface::Plane(pl) = f.oriented_surface() else {
|
||||
return Err(unsupported_shell("the open face must be planar here"));
|
||||
};
|
||||
let n = pl.normal();
|
||||
let n = Vec3::new(n.x, n.y, n.z).normalized().ok_or_else(|| KernelError::Failed("shell: open face normal".into()))?;
|
||||
let prism = guard("shell opening", || {
|
||||
let solid: Solid = builder::tsweep(f, v3(n * (thickness + reach)));
|
||||
Ok(solid)
|
||||
})?;
|
||||
let prism = Body::new(prism)?;
|
||||
cavity = crate::ops::boolean(&cavity, &prism, crate::BoolOp::Union)?.ok_or_else(|| KernelError::Failed("shell: opening".into()))?;
|
||||
}
|
||||
crate::ops::boolean(b, &cavity, crate::BoolOp::Cut)?.ok_or_else(|| KernelError::Failed("the shell removed everything".into()))
|
||||
}
|
||||
|
||||
fn unsupported_shell(m: &str) -> KernelError {
|
||||
KernelError::Failed(format!("not supported yet: {m}"))
|
||||
}
|
||||
|
||||
fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
let n = (b - a).cross(c - a);
|
||||
let Some(nn) = n.normalized() else { return f64::INFINITY };
|
||||
@@ -422,7 +478,7 @@ pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result<Body> {
|
||||
if !distance.is_finite() || distance.abs() > 1e6 {
|
||||
return Err(KernelError::Invalid("offset distance".into()));
|
||||
}
|
||||
let healed = Body::new(crate::heal::heal(b.deep_copy(), b.size()))?;
|
||||
let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?.with_split_lines(b.splits.clone());
|
||||
let size = healed.size();
|
||||
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
|
||||
let mut chosen: Vec<usize> = Vec::new();
|
||||
@@ -443,5 +499,24 @@ pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result<Body> {
|
||||
}
|
||||
}
|
||||
let chosen = crate::offset::with_same_surface(&healed, &chosen);
|
||||
offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 })
|
||||
match offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 }) {
|
||||
Ok(r) => Ok(r),
|
||||
// A piece of a split face (its neighbour stays): the piece swept along its normal
|
||||
// joins on (outward) or cuts in, with walls along the split lines.
|
||||
Err(e) if !healed.split_keep().is_empty() => press_pieces(&healed, &chosen, distance).map_err(|_| e),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn press_pieces(b: &Body, chosen: &[usize], distance: f64) -> Result<Body> {
|
||||
let faces: Vec<mt::Face> = b.solid.face_iter().cloned().collect();
|
||||
let mut cur = b.clone();
|
||||
for fi in chosen {
|
||||
let f = faces.get(*fi).ok_or_else(|| KernelError::Failed("offset: face".into()))?;
|
||||
let n = crate::blend::plane_normal(f).ok_or_else(|| KernelError::Failed("offset: only planar pieces".into()))?;
|
||||
let tool = guard("offset face", || Body::new(builder::tsweep(f, crate::body::v3(n * distance))))?;
|
||||
let op = if distance > 0.0 { crate::BoolOp::Union } else { crate::BoolOp::Cut };
|
||||
cur = crate::ops::boolean(&cur, &tool, op)?.ok_or_else(|| KernelError::Failed("offset: the piece moved off the body".into()))?;
|
||||
}
|
||||
Ok(cur.with_split_lines(b.splits.clone()))
|
||||
}
|
||||
|
||||
@@ -845,6 +845,16 @@ pub fn split_faces(body: &Body, faces: &[usize], tool: &SplitTool) -> Result<Bod
|
||||
/// Like [`split_faces`], with the body face each face of the result came from (in face order).
|
||||
pub fn split_faces_with_map(body: &Body, faces: &[usize], tool: &SplitTool) -> Result<(Body, Vec<usize>)> {
|
||||
let (b, imp) = split_any(body, faces, tool)?;
|
||||
// The new edges, remembered so that export keeps the pieces apart.
|
||||
let mut lines: Vec<Vec<Vec3>> = body.splits.as_ref().map(|l| l.to_vec()).unwrap_or_default();
|
||||
lines.extend(imp.new_edges.iter().filter_map(|e| {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let n = if matches!(c, mt::Curve::Line(_)) { 1 } else { 64 };
|
||||
let pts: Vec<Vec3> = (0..=n).map(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / n as f64))).collect();
|
||||
pts.iter().all(|p| p.is_finite()).then_some(pts)
|
||||
}));
|
||||
let b = b.with_split_lines(Some(std::sync::Arc::new(lines)));
|
||||
if let Some(missed) = faces.iter().find(|f| imp.made.get(f).copied().unwrap_or(0) == 0) {
|
||||
return Err(KernelError::Invalid(format!("the tool does not cross face {missed}")));
|
||||
}
|
||||
@@ -867,7 +877,7 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
|
||||
}
|
||||
let shells = std::mem::take(&mut imp.shells);
|
||||
let solid = mt::Solid::new_unchecked(shells.into_iter().map(mt::Shell::from).collect());
|
||||
Ok((Body { solid: std::sync::Arc::new(solid), mesh: None, color: body.color, paint: None }, imp))
|
||||
Ok((Body { solid: std::sync::Arc::new(solid), mesh: None, color: body.color, paint: None, splits: body.splits.clone() }, imp))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1156,6 +1166,42 @@ mod tests {
|
||||
assert!(rel(v, 6000.0 - std::f64::consts::PI * 4.0 * 30.0) < 1e-3, "{v}");
|
||||
}
|
||||
|
||||
/// Faces after a STEP round trip.
|
||||
fn round_trip_faces(b: &Body) -> usize {
|
||||
let ex = crate::ExportBody { name: "B".into(), body: b, color: None };
|
||||
let text = crate::step_export_bodies(&[ex], &crate::StepHeader::default()).unwrap();
|
||||
let imp = crate::step_import(&text).unwrap();
|
||||
imp.bodies.iter().map(|x| x.body.face_count()).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_keeps_faces_split_on_purpose() {
|
||||
// A box top split by a plane, and a cylinder's side split by a plane across its axis.
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
|
||||
let pl = Plane::new(Vec3::new(4.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
|
||||
let s = split_faces(&b, &[top(&b, 30.0)], &SplitTool::Plane(pl)).unwrap();
|
||||
assert_eq!(s.face_count(), 7);
|
||||
assert_eq!(round_trip_faces(&s), 7);
|
||||
let c = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
|
||||
let side = c.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_none()).unwrap();
|
||||
let mid = Plane::new(Vec3::new(0.0, 0.0, 5.0), Vec3::X, Vec3::Y).unwrap();
|
||||
let cs = split_faces(&c, &[side], &SplitTool::Plane(mid)).unwrap();
|
||||
let n = cs.face_count();
|
||||
assert!(n > c.face_count());
|
||||
// The pieces stay apart; the cylinder's incidental seam pieces still join as before.
|
||||
assert_eq!(round_trip_faces(&cs), round_trip_faces(&c) + 1);
|
||||
// Without the record the export joins the top's pieces again (as it does for pieces a
|
||||
// boolean left).
|
||||
assert_eq!(round_trip_faces(&s.clone().with_split_lines(None)), 6);
|
||||
// A split after a split keeps both lines.
|
||||
let pl2 = Plane::new(Vec3::new(0.0, 12.0, 0.0), Vec3::X, Vec3::Z).unwrap();
|
||||
let tops: Vec<usize> =
|
||||
s.faces(0.01).unwrap().iter().enumerate().filter(|(_, f)| (f.centroid.z - 30.0).abs() < 1e-6).map(|(i, _)| i).collect();
|
||||
let s2 = split_faces(&s, &tops, &SplitTool::Plane(pl2)).unwrap();
|
||||
assert_eq!(s2.face_count(), 9);
|
||||
assert_eq!(round_trip_faces(&s2), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_curve_splits_a_cylinder_side_and_a_circle_splits_a_top() {
|
||||
let c = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
|
||||
@@ -1184,7 +1230,8 @@ mod tests {
|
||||
let mut tops: Vec<f64> = s.faces(0.01).unwrap().iter().filter(|f| (f.centroid.z - 20.0).abs() < 1e-6).map(|f| f.area).collect();
|
||||
tops.sort_by(f64::total_cmp);
|
||||
let pi = std::f64::consts::PI;
|
||||
assert!(rel(tops[0], pi * 16.0) < 1e-3 && rel(tops[1], pi * 84.0) < 1e-3, "{tops:?}");
|
||||
// (Face areas come from meshes: within their sag, which depends on where seams lie.)
|
||||
assert!(rel(tops[0], pi * 16.0) < 2e-3 && rel(tops[1], pi * 84.0) < 2e-3, "{tops:?}");
|
||||
}
|
||||
|
||||
/// A plane through a hole in a plate's top: the top splits in two, each piece holding
|
||||
|
||||
@@ -299,6 +299,10 @@ fn plan(face: &mt::Face) -> R<Option<Plan>> {
|
||||
_ => chains.push((seam, vec![e])),
|
||||
}
|
||||
}
|
||||
// A cone's tip: the seam runs from the ring to the apex and straight back.
|
||||
if let [(true, sm), (false, ring)] = chains.as_slice() {
|
||||
return apex_plan(face, &s, (d, mid, period), sm, ring, &wires, outer_idx).map(Some);
|
||||
}
|
||||
let [(true, s1), (false, r1), (true, s2), (false, r2)] = chains.as_slice() else {
|
||||
return Err(format!("unsupported seam loop layout ({} runs)", chains.len()));
|
||||
};
|
||||
@@ -358,6 +362,72 @@ fn plan(face: &mt::Face) -> R<Option<Plan>> {
|
||||
Ok(Some(Plan { cuts: c1.into_iter().chain(c2).collect(), faces: (fa, fb) }))
|
||||
}
|
||||
|
||||
/// Split a face closing at an apex (loop: seam out to the apex, seam back, the ring) along the
|
||||
/// iso-line opposite the seam, from the ring to the apex.
|
||||
fn apex_plan(
|
||||
face: &mt::Face,
|
||||
s: &mt::Surface,
|
||||
(d, mid, period): (Dir, f64, f64),
|
||||
sm: &[mt::Edge],
|
||||
ring: &[mt::Edge],
|
||||
wires: &[mt::Wire],
|
||||
outer_idx: usize,
|
||||
) -> R<Plan> {
|
||||
let h = sm.len() / 2;
|
||||
if !sm.len().is_multiple_of(2) || h == 0 {
|
||||
return Err("seam edges are not used back and forth".into());
|
||||
}
|
||||
let (out, back) = (sm.get(..h).ok_or("seam")?, sm.get(h..).ok_or("seam")?);
|
||||
if out.iter().map(mt::Edge::id).collect::<Vec<_>>() != back.iter().rev().map(mt::Edge::id).collect::<Vec<_>>() {
|
||||
return Err("seam edges are not used back and forth".into());
|
||||
}
|
||||
let apex = out.last().ok_or("seam")?.back().clone();
|
||||
let (k, t, q) = crossing(s, d, ring, mid, period)?;
|
||||
let seam_line: Vec<mt::Point3> = out.iter().flat_map(|e| samples(&e.oriented_curve(), 16).into_iter().map(|x| x.1)).collect();
|
||||
let iso = iso_curve(s, d, mid, q, apex.point(), &seam_line)?;
|
||||
let (ra, rb, c, vq) = split_chain(ring, k, t, q)?;
|
||||
let e = mt::Edge::new_unchecked(&vq, &apex, iso);
|
||||
// Face A: out to the apex, down the split line, the ring's second part; face B the rest.
|
||||
let mut wa: Vec<mt::Edge> = out.to_vec();
|
||||
wa.push(e.inverse());
|
||||
wa.extend(rb.iter().cloned());
|
||||
let mut wb: Vec<mt::Edge> = vec![e];
|
||||
wb.extend(back.iter().cloned());
|
||||
wb.extend(ra);
|
||||
let (wire_a, wire_b): (mt::Wire, mt::Wire) = (wa.into(), wb.into());
|
||||
if !wire_a.is_closed() || !wire_b.is_closed() {
|
||||
return Err("split loops do not close".into());
|
||||
}
|
||||
let side_a = rb
|
||||
.iter()
|
||||
.flat_map(|e| samples(&e.oriented_curve(), 4))
|
||||
.filter_map(|(_, p)| uv_of(s, p).map(|uv| coord(d, uv)))
|
||||
.find(|w| (w - mid).abs() > period * 1e-3)
|
||||
.map(|w| w < mid)
|
||||
.ok_or("cannot place the split halves")?;
|
||||
let (mut ha, mut hb) = (vec![wire_a], vec![wire_b]);
|
||||
for (i, w) in wires.iter().enumerate() {
|
||||
if i == outer_idx {
|
||||
continue;
|
||||
}
|
||||
let ws: Vec<f64> = w.edge_iter().flat_map(|e| samples(&e.curve(), 4)).filter_map(|(_, p)| uv_of(s, p).map(|uv| coord(d, uv))).collect();
|
||||
if ws.iter().all(|x| (*x < mid) == side_a) {
|
||||
ha.push(w.clone());
|
||||
} else if ws.iter().all(|x| (*x < mid) != side_a) {
|
||||
hb.push(w.clone());
|
||||
} else {
|
||||
return Err("a hole crosses the split line".into());
|
||||
}
|
||||
}
|
||||
let mut fa = mt::Face::new_unchecked(ha, s.clone());
|
||||
let mut fb = mt::Face::new_unchecked(hb, s.clone());
|
||||
if !face.orientation() {
|
||||
fa.invert();
|
||||
fb.invert();
|
||||
}
|
||||
Ok(Plan { cuts: c.into_iter().collect(), faces: (fa, fb) })
|
||||
}
|
||||
|
||||
/// Replace cut edges in a face's loops.
|
||||
fn recut(face: &mt::Face, cuts: &[(mt::Edge, (mt::Edge, mt::Edge))]) -> mt::Face {
|
||||
let touches = face.absolute_boundaries().iter().flat_map(|w| w.edge_iter()).any(|e| cuts.iter().any(|(c, _)| c.id() == e.id()));
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::step_in::p21::{self, Param};
|
||||
use crate::{KernelError, Result};
|
||||
@@ -832,7 +834,13 @@ fn face_edges(ex: &p21::Exchange, face: &p21::Entity) -> Option<Vec<Vec<(u64, bo
|
||||
/// Faces that truck splits (a revolution in two or three turns) joined back into one face per
|
||||
/// analytic surface, by dropping the edges between them. Faces whose union would lose all its
|
||||
/// edges, or whose leftover edges do not close into loops, stay apart.
|
||||
fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>) -> HashMap<u64, V3> {
|
||||
fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>, keep: &[Vec<Vec3>]) -> HashMap<u64, V3> {
|
||||
// An edge on a line the user split along: every sample within a hair of the polyline.
|
||||
let on_split = |ex: &p21::Exchange, e: u64| -> bool {
|
||||
let Some(pts) = edge_samples(ex, e) else { return false };
|
||||
let pts: Vec<Vec3> = pts.iter().map(|q| Vec3::new(q[0], q[1], q[2])).collect();
|
||||
crate::heal::on_split_line(&pts, keep)
|
||||
};
|
||||
let mut merged_surfaces = std::collections::HashSet::new();
|
||||
let mut next = ex.entities.keys().max().copied().unwrap_or(0) + 1;
|
||||
let shells: Vec<u64> = ex.entities.iter().filter(|(_, e)| matches!(e.name(), "CLOSED_SHELL" | "OPEN_SHELL")).map(|(i, _)| *i).collect();
|
||||
@@ -923,7 +931,7 @@ fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>)
|
||||
let all: Vec<(u64, bool)> = ea.into_iter().chain(eb).flatten().collect();
|
||||
let shared: std::collections::HashSet<u64> =
|
||||
all.iter().filter(|(e, d)| all.iter().any(|(e2, d2)| e2 == e && d2 != d)).map(|(e, _)| *e).collect();
|
||||
if shared.is_empty() {
|
||||
if shared.is_empty() || shared.iter().any(|e| on_split(ex, *e)) {
|
||||
continue;
|
||||
}
|
||||
// Chain oriented edges into loops by vertices (None: they do not close).
|
||||
@@ -1200,7 +1208,8 @@ fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap<u64, V
|
||||
}
|
||||
}
|
||||
let before = shell_face_count(&ex);
|
||||
let merged = if merge { merge_split_faces(&mut ex, &mut paint) } else { Default::default() };
|
||||
let keep = b.split_lines().map(|l| l.as_slice()).unwrap_or(&[]);
|
||||
let merged = if merge { merge_split_faces(&mut ex, &mut paint, keep) } else { Default::default() };
|
||||
MERGES.with(|m| m.set(m.get() + before.saturating_sub(shell_face_count(&ex))));
|
||||
Ok((ex, merged, paint))
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ impl Body {
|
||||
/// A body of faces as they are (no orientation fix: a surface has no inside).
|
||||
fn surface_body(faces: Vec<mt::Face>) -> Body {
|
||||
let shell = mt::Shell::from(faces);
|
||||
Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None }
|
||||
Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None, splits: None }
|
||||
}
|
||||
|
||||
/// A body from faces: a solid when they close, else a surface.
|
||||
@@ -31,7 +31,7 @@ fn body_of(faces: Vec<mt::Face>) -> Result<Body> {
|
||||
if shell.shell_condition() == mt::ShellCondition::Closed {
|
||||
Body::new(mt::Solid::new_unchecked(vec![shell]))
|
||||
} else {
|
||||
Ok(Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None })
|
||||
Ok(Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None, splits: None })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -782,6 +782,27 @@ fn shell_non_convex() {
|
||||
assert!(rel(v, outer - cavity) < 1e-6, "{v} vs {}", outer - cavity);
|
||||
}
|
||||
|
||||
/// A basin: a slab with a tapered pocket and a 4 mm front rim, shelled from below. A wall
|
||||
/// thicker than the rim (with the pocket's slope) would turn the top face inside out: refused,
|
||||
/// not a body with wrong mass properties.
|
||||
#[test]
|
||||
fn shell_thicker_than_a_rim_is_refused() {
|
||||
let slab = box_solid(Vec3::ZERO, Vec3::new(100.0, 60.0, 30.0)).unwrap();
|
||||
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| Loop2::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]);
|
||||
let at = |z: f64| Plane::new(Vec3::new(0.0, 0.0, z), Vec3::X, Vec3::Y).unwrap();
|
||||
let pocket = loft(&[(at(30.0), rect(20.0, 4.0, 80.0, 50.0)), (at(10.0), rect(30.0, 20.0, 70.0, 40.0))]).unwrap();
|
||||
let basin = boolean(&slab, &pocket, BoolOp::Cut).unwrap().unwrap();
|
||||
let open = [Vec3::new(50.0, 30.0, 0.0)];
|
||||
let e = shell(&basin, &open, 6.0).unwrap_err().to_string();
|
||||
assert!(e.contains("rim or ledge"), "{e}");
|
||||
let (whole, thin) = (measure(&basin).unwrap(), measure(&shell(&basin, &open, 1.0).unwrap()).unwrap());
|
||||
assert!(thin.volume > 0.0 && thin.volume < whole.volume, "{} vs {}", thin.volume, whole.volume);
|
||||
let (lo, hi) = (thin.bbox.min, thin.bbox.max);
|
||||
let c = thin.centroid;
|
||||
assert!(c.x > lo.x && c.x < hi.x && c.y > lo.y && c.y < hi.y && c.z > lo.z && c.z < hi.z, "{c:?}");
|
||||
assert!((c.x - 50.0).abs() < 1e-3, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_drilled_hole() {
|
||||
@@ -1297,3 +1318,120 @@ fn tight_bend_counts() {
|
||||
let m = measure(&b).unwrap().merged;
|
||||
assert_eq!((m.faces, m.edges, m.vertices), (5, 6, 4), "{m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn boolean_identity_survey() {
|
||||
let n: u64 = std::env::var("N").ok().and_then(|x| x.parse().ok()).unwrap_or(100);
|
||||
let seeds: Vec<u64> = match std::env::var("SEEDS") {
|
||||
Ok(s) => s.split(',').filter_map(|x| x.parse().ok()).collect(),
|
||||
Err(_) => (0..n).collect(),
|
||||
};
|
||||
let mut bad = 0;
|
||||
for seed in seeds {
|
||||
let c = crate::fuzz::boolean_case(seed);
|
||||
if c.outcome != "ok" {
|
||||
bad += 1;
|
||||
println!("{seed}: {} {} | {:?} | {:?}", c.outcome, c.detail, c.a, c.b);
|
||||
}
|
||||
}
|
||||
println!("{bad}/{n} failed");
|
||||
}
|
||||
|
||||
/// A quarter turn of a rectangle with an edge on the axis: no zero-area face along the axis, so
|
||||
/// a cylinder can be cut through the corner (it failed in every boolean attempt before).
|
||||
#[test]
|
||||
fn partial_revolve_on_the_axis() {
|
||||
let sq =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0), Vec2::new(20.0, 7.5), Vec2::new(0.0, 7.5)]), holes: vec![] };
|
||||
let pie = revolve(&Plane::XZ, &[sq], Vec2::ZERO, Vec2::Y, std::f64::consts::FRAC_PI_2).unwrap().pop().unwrap();
|
||||
assert!(pie.validity().is_empty(), "{:?}", pie.validity());
|
||||
let v = measure(&pie).unwrap().volume;
|
||||
assert!((v - PI * 400.0 / 4.0 * 7.5).abs() < 1e-3 * v, "{v}");
|
||||
assert_eq!(measure(&pie).unwrap().merged.faces, 5);
|
||||
let cyl = cylinder(Vec3::new(2.5, 0.0, 5.0), Vec3::Z, 3.75, 22.5).unwrap();
|
||||
let cut = boolean(&pie, &cyl, BoolOp::Cut).unwrap().unwrap();
|
||||
assert!(cut.validity().is_empty());
|
||||
let vc = measure(&cut).unwrap().volume;
|
||||
assert!(vc < v - 1.0 && vc > v - PI * 3.75 * 3.75 * 2.5, "{vc}");
|
||||
}
|
||||
|
||||
/// Boolean identities on primitives the robustness runs found failing: cylinders tangent to box
|
||||
/// faces from inside and out, crossing cylinders touching along a line (their seams used to lie
|
||||
/// exactly where the other body cut them).
|
||||
#[test]
|
||||
fn boolean_identity_on_tangent_primitives() {
|
||||
for seed in [1, 32, 169] {
|
||||
let c = crate::fuzz::boolean_case(seed);
|
||||
assert_eq!(c.outcome, "ok", "seed {seed}: {} {:?} {:?}", c.detail, c.a, c.b);
|
||||
}
|
||||
}
|
||||
|
||||
/// A body in two pieces: booleans go a piece at a time.
|
||||
#[test]
|
||||
fn boolean_of_a_body_in_pieces() {
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
|
||||
let b = box_solid(Vec3::new(20.0, 0.0, 0.0), Vec3::new(30.0, 10.0, 10.0)).unwrap();
|
||||
let two = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
|
||||
assert_eq!(two.shell_count(), 2);
|
||||
let tool = cylinder(Vec3::new(-5.0, 5.0, 5.0), Vec3::X, 2.0, 40.0).unwrap();
|
||||
let cut = boolean(&two, &tool, BoolOp::Cut).unwrap().unwrap();
|
||||
assert_eq!(cut.shell_count(), 2);
|
||||
assert!(cut.validity().is_empty(), "{:?}", cut.validity());
|
||||
let v = measure(&cut).unwrap().volume;
|
||||
assert!((v - (2000.0 - 2.0 * PI * 4.0 * 10.0)).abs() < 1.0, "{v}");
|
||||
}
|
||||
|
||||
/// Measuring a body of 5000 faces (a modelled thread is like that) counts right (it took 13 s,
|
||||
/// a per-face recomputation of the body's size; now under a second).
|
||||
#[test]
|
||||
fn measure_of_a_body_with_many_faces() {
|
||||
let n = 5000;
|
||||
let pts: Vec<Vec2> = (0..n)
|
||||
.map(|i| {
|
||||
let a = std::f64::consts::TAU * i as f64 / n as f64;
|
||||
Vec2::new(20.0 * a.cos(), 20.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap();
|
||||
let m = measure(&b).unwrap();
|
||||
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (n + 2, 3 * n, 2 * n), "{:?}", m.merged);
|
||||
}
|
||||
|
||||
/// The time it takes (run alone, the suite's other tests share the machine):
|
||||
/// `cargo test --release -p solvecraft-kernel perf_measure_5000_faces -- --ignored`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn perf_measure_5000_faces() {
|
||||
let pts: Vec<Vec2> = (0..5000)
|
||||
.map(|i| {
|
||||
let a = std::f64::consts::TAU * i as f64 / 5000.0;
|
||||
Vec2::new(20.0 * a.cos(), 20.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap();
|
||||
let t = std::time::Instant::now();
|
||||
measure(&b).unwrap();
|
||||
assert!(t.elapsed().as_secs_f64() < 1.5, "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// A lip on a rounded, shelled rim (#29): the band along the rim's inner (or outer) edge raised,
|
||||
/// its walls continuing the walls below. Exactly the band's area times its height is added.
|
||||
#[test]
|
||||
fn raise_band_on_a_rounded_rim() {
|
||||
let b = box_solid(Vec3::new(-40.0, -30.0, 0.0), Vec3::new(40.0, 30.0, 30.0)).unwrap();
|
||||
let f =
|
||||
fillet(&b, &[Vec3::new(40.0, 30.0, 15.0), Vec3::new(-40.0, 30.0, 15.0), Vec3::new(-40.0, -30.0, 15.0), Vec3::new(40.0, -30.0, 15.0)], 6.0)
|
||||
.unwrap();
|
||||
let sh = shell(&f, &[Vec3::new(0.0, 0.0, 30.0)], 2.0).unwrap();
|
||||
let v0 = measure(&sh).unwrap().volume;
|
||||
// A rounded rectangle's area.
|
||||
let ar = |w: f64, h: f64, r: f64| w * h - (4.0 - PI) * r * r;
|
||||
for outside in [false, true] {
|
||||
let lip = crate::lip::raise_band(&sh, Vec3::new(0.0, 29.0, 30.0), 1.0, 1.5, outside).unwrap();
|
||||
assert!(lip.validity().is_empty(), "{:?}", lip.validity());
|
||||
let band = if outside { ar(80.0, 60.0, 6.0) - ar(78.0, 58.0, 5.0) } else { ar(78.0, 58.0, 5.0) - ar(76.0, 56.0, 4.0) };
|
||||
let v = measure(&lip).unwrap().volume;
|
||||
assert!((v - (v0 + band * 1.5)).abs() < 1e-5 * v, "outside {outside}: {v} vs {}", v0 + band * 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ impl Surf {
|
||||
}
|
||||
fn same(&self, o: &Surf, tol: f64) -> bool {
|
||||
match (*self, *o) {
|
||||
(Surf::Plane { n, d }, Surf::Plane { n: n2, d: d2 }) => n.dot(n2) > 1.0 - 1e-6 && (d - d2).abs() < tol,
|
||||
(Surf::Plane { n, d }, Surf::Plane { n: n2, d: d2 }) => n.dot(n2) > 1.0 - 1e-9 && (d - d2).abs() < tol,
|
||||
(Surf::Cylinder { axis, p, r }, Surf::Cylinder { axis: a2, p: p2, r: r2 }) => {
|
||||
let off = p2 - p;
|
||||
axis.dot(a2).abs() > 1.0 - 1e-6 && (r - r2).abs() < tol && (off - axis * off.dot(axis)).len() < tol
|
||||
@@ -382,13 +382,14 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
*a += (q - p).cross(r - p).len() * 0.5;
|
||||
}
|
||||
}
|
||||
let degenerate: Vec<bool> = area.iter().map(|a| *a < (b.size() * 1e-6).powi(2)).collect();
|
||||
let tiny = (b.size() * 1e-6).powi(2);
|
||||
let degenerate: Vec<bool> = area.iter().map(|a| *a < tiny).collect();
|
||||
let is_deg = |f: usize| degenerate.get(f).copied().unwrap_or(false);
|
||||
|
||||
// Edge → faces, edge → vertices. Vertices and edges lying on each other count once (two
|
||||
// tangent holes touch along a line that both bodies' pieces carry): welded by position,
|
||||
// edges by their ends and the point halfway along them.
|
||||
let q = tol * 10.0;
|
||||
let q = (b.size() * 1e-6).max(1e-9);
|
||||
let cell = |p: Vec3| ((p.x / q).round() as i64, (p.y / q).round() as i64, (p.z / q).round() as i64);
|
||||
let mut edge_faces: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
let mut edge_verts: HashMap<usize, (usize, usize)> = HashMap::new();
|
||||
@@ -447,10 +448,14 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
});
|
||||
smooth && seen > 0
|
||||
};
|
||||
// Pieces of a face split on purpose count apart.
|
||||
let b_keep = b.split_keep();
|
||||
let keep_split = !b_keep.is_empty();
|
||||
let mut fp: Vec<usize> = (0..nf).collect();
|
||||
for (ei, faces) in &edge_faces {
|
||||
let other = |f: usize| matches!(surfs.get(f), Some(Surf::Other(_)));
|
||||
if let [a, b] = faces[..]
|
||||
&& !(keep_split && poly(*ei).is_some_and(|pl| crate::heal::on_split_line(pl, b_keep)))
|
||||
&& (same_surface(&surfs, &verts, poly(*ei), a, b, tol * 10.0)
|
||||
|| (other(a) && other(b) && exact_smooth(a, b, poly(*ei)) && {
|
||||
let (Some(va), Some(vb)) = (verts.get(a), verts.get(b)) else { return Ok(TopoCounts::default()) };
|
||||
@@ -524,7 +529,7 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
}
|
||||
}
|
||||
let mut ep: Vec<usize> = (0..remaining.len()).collect();
|
||||
let mut merge_vertex: Vec<usize> = Vec::new();
|
||||
let mut merge_vertex: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||
for (v, es) in ° {
|
||||
if let [i, j] = es[..]
|
||||
&& let (Some(a), Some(b)) = (remaining.get(i), remaining.get(j))
|
||||
@@ -534,7 +539,7 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
if let Some(s) = ep.get_mut(ri) {
|
||||
*s = rj;
|
||||
}
|
||||
merge_vertex.push(*v);
|
||||
merge_vertex.insert(*v);
|
||||
}
|
||||
}
|
||||
let mut edge_groups: BTreeMap<usize, bool> = BTreeMap::new(); // root → has a real end vertex
|
||||
|
||||
+97
-15
@@ -72,10 +72,11 @@ impl StandardView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Smooth ease-in-out on [0, 1] (cubic).
|
||||
/// Smooth ease-in-out on [0, 1] (smoothstep: at its fastest, mid-way, 1.5 times the average
|
||||
/// speed, so long turns stay gentle).
|
||||
pub fn ease_in_out(t: f64) -> f64 {
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 1.0 };
|
||||
if t < 0.5 { 4.0 * t * t * t } else { 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 }
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// A unit quaternion (w, x, y, z).
|
||||
@@ -162,8 +163,13 @@ pub struct CameraAnim {
|
||||
impl CameraAnim {
|
||||
/// The usual length of a view change, in seconds.
|
||||
pub const DURATION: f64 = 0.5;
|
||||
/// A view change: half a second, longer for turns past a quarter turn (so a big turn, such
|
||||
/// as from the home view to the bottom, never sweeps more than about 9 degrees a frame).
|
||||
pub fn new(from: Camera, to: Camera, now: f64) -> CameraAnim {
|
||||
CameraAnim { from, to, start: now, duration: CameraAnim::DURATION }
|
||||
let dy = (to.yaw - from.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
|
||||
let turn = dy.abs().max((to.pitch - from.pitch).abs());
|
||||
let k = if turn.is_finite() { (turn / std::f64::consts::FRAC_PI_2).clamp(1.0, 2.0) } else { 1.0 };
|
||||
CameraAnim { from, to, start: now, duration: CameraAnim::DURATION * k }
|
||||
}
|
||||
/// The camera at time `now`, and whether the move has finished.
|
||||
pub fn sample(&self, now: f64) -> (Camera, bool) {
|
||||
@@ -175,7 +181,42 @@ impl CameraAnim {
|
||||
}
|
||||
}
|
||||
|
||||
/// Orbit camera around `target` (Z up).
|
||||
/// Which world axis points up in the view (#3): Z (the default) or Y, as most STEP files from
|
||||
/// other systems are modelled.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UpAxis {
|
||||
#[default]
|
||||
Z,
|
||||
Y,
|
||||
}
|
||||
|
||||
impl UpAxis {
|
||||
/// The world's up direction.
|
||||
pub fn dir(self) -> Vec3 {
|
||||
match self {
|
||||
UpAxis::Z => Vec3::Z,
|
||||
UpAxis::Y => Vec3::Y,
|
||||
}
|
||||
}
|
||||
/// A world vector in the camera's Z-up frame (yaw turns about the frame's Z).
|
||||
pub fn to_frame(self, v: Vec3) -> Vec3 {
|
||||
match self {
|
||||
UpAxis::Z => v,
|
||||
UpAxis::Y => Vec3::new(v.x, -v.z, v.y),
|
||||
}
|
||||
}
|
||||
/// A Z-up frame vector in the world.
|
||||
pub fn from_frame(self, v: Vec3) -> Vec3 {
|
||||
match self {
|
||||
UpAxis::Z => v,
|
||||
UpAxis::Y => Vec3::new(v.x, v.z, -v.y),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Orbit camera around `target`. Yaw and pitch are about the up axis (Z, or Y for a Y-up
|
||||
/// world): with Y up, the front view looks along −Z and the top view down −Y.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Camera {
|
||||
pub target: Vec3,
|
||||
@@ -187,12 +228,15 @@ pub struct Camera {
|
||||
pub distance: f64,
|
||||
/// Vertical field of view (radians); 0 = orthographic.
|
||||
pub fov: f64,
|
||||
/// The world's up axis.
|
||||
#[serde(default)]
|
||||
pub up: UpAxis,
|
||||
}
|
||||
|
||||
impl Default for Camera {
|
||||
fn default() -> Self {
|
||||
let (yaw, pitch) = StandardView::Iso.angles();
|
||||
Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0 }
|
||||
Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0, up: UpAxis::Z }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +244,19 @@ impl Camera {
|
||||
/// Direction from the target toward the eye.
|
||||
pub fn back(&self) -> Vec3 {
|
||||
let (cp, sp) = (self.pitch.cos(), self.pitch.sin());
|
||||
Vec3::new(-self.yaw.sin() * cp, -self.yaw.cos() * cp, sp).normalized().unwrap_or(Vec3::Z)
|
||||
self.up.from_frame(Vec3::new(-self.yaw.sin() * cp, -self.yaw.cos() * cp, sp).normalized().unwrap_or(Vec3::Z))
|
||||
}
|
||||
/// The world's up direction.
|
||||
pub fn up_dir(&self) -> Vec3 {
|
||||
self.up.dir()
|
||||
}
|
||||
/// A world vector in the camera's Z-up frame (where yaw and pitch are measured).
|
||||
pub fn to_frame(&self, v: Vec3) -> Vec3 {
|
||||
self.up.to_frame(v)
|
||||
}
|
||||
/// A Z-up frame vector in the world.
|
||||
pub fn from_frame(&self, v: Vec3) -> Vec3 {
|
||||
self.up.from_frame(v)
|
||||
}
|
||||
pub fn eye(&self) -> Vec3 {
|
||||
self.target + self.back() * self.distance
|
||||
@@ -208,8 +264,8 @@ impl Camera {
|
||||
/// Camera basis: right, up, back (orthonormal).
|
||||
pub fn basis(&self) -> (Vec3, Vec3, Vec3) {
|
||||
let b = self.back();
|
||||
let right = Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0);
|
||||
let up = b.cross(right).normalized().unwrap_or(Vec3::Z);
|
||||
let right = self.up.from_frame(Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0));
|
||||
let up = b.cross(right).normalized().unwrap_or(self.up.dir());
|
||||
(right, up, b)
|
||||
}
|
||||
pub fn view(&self) -> Mat4 {
|
||||
@@ -311,7 +367,7 @@ impl Camera {
|
||||
/// The same camera looking from direction `back` (target → eye). Straight up or down
|
||||
/// keeps the yaw, rounded to the nearest quarter turn so the view stays square.
|
||||
pub fn looking_from(&self, back: Vec3) -> Camera {
|
||||
let Some(b) = back.normalized() else { return *self };
|
||||
let Some(b) = self.up.to_frame(back).normalized() else { return *self };
|
||||
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
|
||||
let pitch = b.z.clamp(-1.0, 1.0).asin().clamp(-lim, lim);
|
||||
let yaw = if b.x.hypot(b.y) < 1e-6 {
|
||||
@@ -349,6 +405,7 @@ impl Camera {
|
||||
pitch,
|
||||
distance: a.distance + (b.distance - a.distance) * s,
|
||||
fov: a.fov + (b.fov - a.fov) * s,
|
||||
up: b.up,
|
||||
}
|
||||
}
|
||||
pub fn is_valid(&self) -> bool {
|
||||
@@ -396,10 +453,10 @@ mod tests {
|
||||
let anim = CameraAnim::new(a, b, 10.0);
|
||||
// Endpoints are exact.
|
||||
assert_eq!(anim.sample(10.0).0, a);
|
||||
assert_eq!(anim.sample(10.0 + CameraAnim::DURATION), (b, true));
|
||||
assert_eq!(anim.sample(10.0 + anim.duration), (b, true));
|
||||
assert_eq!(anim.sample(99.0), (b, true));
|
||||
// Midpoint: half way in heading, elevation, target and distance.
|
||||
let (m, done) = anim.sample(10.0 + CameraAnim::DURATION / 2.0);
|
||||
let (m, done) = anim.sample(10.0 + anim.duration / 2.0);
|
||||
assert!(!done && m.is_valid());
|
||||
let dy = (b.yaw - a.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
|
||||
assert!((m.yaw - (a.yaw + dy / 2.0)).abs() < 1e-12 && (m.pitch - (a.pitch + b.pitch) / 2.0).abs() < 1e-12);
|
||||
@@ -476,7 +533,7 @@ mod tests {
|
||||
for a in &cams {
|
||||
for b in &cams {
|
||||
let anim = CameraAnim::new(*a, *b, 0.0);
|
||||
let frames = 30;
|
||||
let frames = (anim.duration * 60.0).round() as usize;
|
||||
let mut prev = anim.sample(0.0).0;
|
||||
let (mut steps, mut done_at) = (Vec::new(), None);
|
||||
for f in 1..=frames + 2 {
|
||||
@@ -495,11 +552,36 @@ mod tests {
|
||||
let total_turn: f64 = steps.iter().map(|s| s.1).sum();
|
||||
let total_move: f64 = steps.iter().map(|s| s.2).sum();
|
||||
for (f, turn, moved) in &steps {
|
||||
// The eased curve's fastest frame is 1.5x the average: allow 3x.
|
||||
assert!(*turn <= 3.0 * total_turn / frames as f64 + 1e-9, "turn jump at frame {f}: {turn} of {total_turn} ({a:?} -> {b:?})");
|
||||
assert!(*moved <= 3.0 * total_move / frames as f64 + 1e-9, "move jump at frame {f}: {moved} of {total_move}");
|
||||
// The eased curve's fastest frame is 1.5x the average: allow 2x.
|
||||
assert!(*turn <= 2.0 * total_turn / frames as f64 + 1e-9, "turn jump at frame {f}: {turn} of {total_turn} ({a:?} -> {b:?})");
|
||||
assert!(*moved <= 2.0 * total_move / frames as f64 + 1e-9, "move jump at frame {f}: {moved} of {total_move}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// With Y up: the front view looks along −Z (eye on +Z), the top view looks down −Y with X
|
||||
/// to the right, orbiting turns about Y, and the picture's up is the world's up.
|
||||
#[test]
|
||||
fn y_up_views() {
|
||||
let mut c = Camera { up: UpAxis::Y, distance: 100.0, ..Default::default() };
|
||||
c.set_view(StandardView::Front);
|
||||
assert!(c.back().dist(Vec3::Z) < 1e-9, "front eye {:?}", c.back());
|
||||
let (r, u, b) = c.basis();
|
||||
assert!(r.dist(Vec3::X) < 1e-9 && u.dist(Vec3::Y) < 1e-9 && b.dist(Vec3::Z) < 1e-9);
|
||||
c.set_view(StandardView::Top);
|
||||
assert!(c.back().dist(Vec3::Y) < 1e-5, "top eye {:?}", c.back());
|
||||
assert!(c.basis().0.dist(Vec3::X) < 1e-9);
|
||||
// Orbiting sideways keeps the eye's height above the XZ ground.
|
||||
c.set_view(StandardView::Iso);
|
||||
let h = c.back().y;
|
||||
c.orbit(120.0, 0.0);
|
||||
assert!((c.back().y - h).abs() < 1e-12);
|
||||
// Looking from a world direction lands on it.
|
||||
let d = Vec3::new(1.0, 1.0, 1.0).normalized().unwrap();
|
||||
assert!(c.looking_from(d).back().dist(d) < 1e-9);
|
||||
// Frames round-trip.
|
||||
let v = Vec3::new(1.0, 2.0, 3.0);
|
||||
assert!(UpAxis::Y.from_frame(UpAxis::Y.to_frame(v)).dist(v) < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
mod camera;
|
||||
mod raster;
|
||||
|
||||
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, ease_in_out};
|
||||
pub use raster::{Canvas, Rgb, Scene, SceneLine, SceneMesh, render, render_png};
|
||||
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out};
|
||||
pub use raster::{Canvas, Rgb, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent};
|
||||
|
||||
@@ -19,6 +19,8 @@ impl Rgb {
|
||||
pub struct SceneMesh {
|
||||
pub mesh: Arc<Mesh>,
|
||||
pub color: Rgb,
|
||||
/// Faces in colours of their own: (B-rep face index, colour).
|
||||
pub face_colors: Vec<(u32, Rgb)>,
|
||||
}
|
||||
|
||||
pub struct SceneLine {
|
||||
@@ -102,13 +104,18 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
let view_dir = cam.back();
|
||||
for sm in &scene.meshes {
|
||||
let m = &sm.mesh;
|
||||
let base = sm.color.f();
|
||||
let body = sm.color.f();
|
||||
let pts: Vec<Option<[f64; 3]>> = m.positions.iter().map(|p| project(*p)).collect();
|
||||
for t in &m.triangles {
|
||||
for (ti, t) in m.triangles.iter().enumerate() {
|
||||
let face = m.tri_face.get(ti).copied();
|
||||
let base = sm.face_colors.iter().find(|(f, _)| Some(*f) == face).map_or(body, |(_, c)| c.f());
|
||||
let (Some(Some(a)), Some(Some(b)), Some(Some(cc))) = (pts.get(t[0] as usize), pts.get(t[1] as usize), pts.get(t[2] as usize)) else {
|
||||
continue;
|
||||
};
|
||||
let ns: Vec<Vec3> = t.iter().map(|k| m.normals.get(*k as usize).copied().unwrap_or(Vec3::Z)).collect();
|
||||
// A curved surface (its normals turn across the triangle) gets a silhouette line
|
||||
// where it turns away from the eye.
|
||||
let curved = ns.iter().any(|n| n.dist(ns[0]) > 1e-6);
|
||||
let (x0, x1) = (a[0].min(b[0]).min(cc[0]).floor().max(0.0), a[0].max(b[0]).max(cc[0]).ceil().min(sw as f64 - 1.0));
|
||||
let (y0, y1) = (a[1].min(b[1]).min(cc[1]).floor().max(0.0), a[1].max(b[1]).max(cc[1]).ceil().min(sh as f64 - 1.0));
|
||||
if !(x0 <= x1 && y0 <= y1) {
|
||||
@@ -134,7 +141,15 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
*d = z;
|
||||
}
|
||||
let n = (ns[0] * w0 + ns[1] * w1 + ns[2] * w2).normalized().unwrap_or(Vec3::Z);
|
||||
c.put(x, y, shade(n, view_dir, base), 1.0);
|
||||
let mut col = shade(n, view_dir, base);
|
||||
let ndv = n.dot(view_dir).abs();
|
||||
if curved && ndv < 0.14 {
|
||||
let a = 1.0 - ndv / 0.14;
|
||||
for (k, e) in col.iter_mut().zip([0.07, 0.08, 0.10]) {
|
||||
*k = *k * (1.0 - a) + e * a;
|
||||
}
|
||||
}
|
||||
c.put(x, y, col, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,12 +215,38 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
/// Render to PNG bytes.
|
||||
pub fn render_png(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Option<Vec<u8>> {
|
||||
let c = render(scene, cam, w, h);
|
||||
let img = image::RgbaImage::from_raw(c.w as u32, c.h as u32, c.rgba)?;
|
||||
png(c.w, c.h, c.rgba)
|
||||
}
|
||||
|
||||
/// RGBA pixels (`w × h`) to PNG bytes.
|
||||
pub fn png(w: usize, h: usize, rgba: Vec<u8>) -> Option<Vec<u8>> {
|
||||
let img = image::RgbaImage::from_raw(u32::try_from(w).ok()?, u32::try_from(h).ok()?, rgba)?;
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, image::ImageFormat::Png).ok()?;
|
||||
Some(buf.into_inner())
|
||||
}
|
||||
|
||||
/// Render on a transparent background: the scene drawn over black and over white, and the
|
||||
/// alpha recovered from the difference (exact for the anti-aliased edges).
|
||||
pub fn render_transparent(scene: &mut Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
let keep = scene.background;
|
||||
scene.background = Some((Rgb(0, 0, 0), Rgb(0, 0, 0)));
|
||||
let black = render(scene, cam, w, h);
|
||||
scene.background = Some((Rgb(255, 255, 255), Rgb(255, 255, 255)));
|
||||
let mut out = render(scene, cam, w, h);
|
||||
scene.background = keep;
|
||||
for (o, b) in out.rgba.as_chunks_mut::<4>().0.iter_mut().zip(black.rgba.as_chunks::<4>().0) {
|
||||
// white - black = 255 (1 - alpha) in every channel; average them.
|
||||
let d: u32 = (0..3).map(|k| u32::from(o[k].saturating_sub(b[k]))).sum();
|
||||
let a = 255.0 - d as f64 / 3.0;
|
||||
for k in 0..3 {
|
||||
o[k] = if a > 0.5 { (f64::from(b[k]) * 255.0 / a).round().clamp(0.0, 255.0) as u8 } else { 0 };
|
||||
}
|
||||
o[3] = a.round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -222,8 +263,12 @@ mod tests {
|
||||
seams: vec![],
|
||||
edge_faces: vec![],
|
||||
};
|
||||
let scene =
|
||||
Scene { meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(200, 30, 30) }], lines: vec![], background: None, radius: 20.0 };
|
||||
let scene = Scene {
|
||||
meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(200, 30, 30), face_colors: Vec::new() }],
|
||||
lines: vec![],
|
||||
background: None,
|
||||
radius: 20.0,
|
||||
};
|
||||
let mut cam = Camera { distance: 50.0, ..Default::default() };
|
||||
cam.set_view(StandardView::Front);
|
||||
let c = render(&scene, &cam, 64, 64);
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
//! The agent cursor (#35): a second, clearly marked pointer that acts out what an agent does
|
||||
//! over the control channel or MCP. Before an `engine.execute` runs, the pointer glides to the
|
||||
//! command's toolbar button (or its panel when the button sits in a drop-down) and clicks it,
|
||||
//! then glides to each pick in the parameters (faces, edges and points projected to the screen,
|
||||
//! sketch points, the plane of a new sketch), and only then does the command run. The reply
|
||||
//! waits for the animation, so an agent's timing stays honest. With "follow camera" on, a new
|
||||
//! sketch turns the view to face it and finishing the sketch turns back.
|
||||
//!
|
||||
//! The pointer is drawn by the app, so screen recordings capture it. It is off by default (View
|
||||
//! settings on the navigation bar, or `ui.agent_cursor {show, speed, follow_camera}`); a call
|
||||
//! with `"animate": false` skips it.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use egui::{Color32, FontId, Pos2, Shape, Stroke, pos2, vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::geom::{Vec2, Vec3};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::control::ControlResponse;
|
||||
|
||||
/// How fast the pointer moves: 1× to 5×, or Instant (no animation: the command runs at once
|
||||
/// and the pointer jumps to where it acted, with a click ripple). Saved and sent as a number,
|
||||
/// or "instant"; the old "slow" and "normal" read as 1×.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Speed {
|
||||
Times(f64),
|
||||
Instant,
|
||||
}
|
||||
|
||||
impl Default for Speed {
|
||||
fn default() -> Self {
|
||||
Speed::Times(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Speed {
|
||||
/// From a number (1–5) or a name ("instant"; "slow" and "normal" are 1×).
|
||||
pub fn from_value(v: &Value) -> Option<Speed> {
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64().filter(|x| x.is_finite() && (1.0..=5.0).contains(x)).map(Speed::Times),
|
||||
Value::String(s) => match s.to_ascii_lowercase().as_str() {
|
||||
"instant" => Some(Speed::Instant),
|
||||
"slow" | "normal" => Some(Speed::Times(1.0)),
|
||||
"fast" => Some(Speed::Times(2.0)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> String {
|
||||
match self {
|
||||
Speed::Instant => "Instant".into(),
|
||||
Speed::Times(x) => {
|
||||
let s = format!("{x:.1}");
|
||||
format!("{}×", s.trim_end_matches(".0"))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Seconds to glide to a stop, and to click there.
|
||||
fn times(self) -> (f64, f64) {
|
||||
match self {
|
||||
Speed::Instant => (0.0, 0.0),
|
||||
Speed::Times(x) => {
|
||||
let x = x.clamp(1.0, 5.0);
|
||||
(0.45 / x, 0.25 / x)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Speed {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
Speed::Instant => s.serialize_str("instant"),
|
||||
Speed::Times(x) => s.serialize_f64(*x),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Speed {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
Ok(Speed::from_value(&v).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
/// The speed as a slider: 1× to 5× in half steps, and Instant at the far right.
|
||||
pub fn speed_slider(ui: &mut egui::Ui, speed: &mut Speed) -> egui::Response {
|
||||
let mut v = match *speed {
|
||||
Speed::Times(x) => x.clamp(1.0, 5.0),
|
||||
Speed::Instant => 5.5,
|
||||
};
|
||||
let r = ui.add(
|
||||
egui::Slider::new(&mut v, 1.0..=5.5).step_by(0.5).custom_formatter(|v, _| if v > 5.25 { "Instant".into() } else { Speed::Times(v).label() }),
|
||||
);
|
||||
if r.changed() {
|
||||
*speed = if v > 5.25 { Speed::Instant } else { Speed::Times(v) };
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The settings (kept with the other view settings).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
pub show: bool,
|
||||
pub speed: Speed,
|
||||
pub follow_camera: bool,
|
||||
}
|
||||
|
||||
/// A request held back while the pointer acts it out.
|
||||
pub struct Job {
|
||||
method: String,
|
||||
params: Value,
|
||||
reply: Sender<ControlResponse>,
|
||||
stops: VecDeque<Pos2>,
|
||||
/// The leg under way: where it started and when.
|
||||
from: Pos2,
|
||||
started: f64,
|
||||
/// Clicked at the current stop (waiting out the click).
|
||||
clicked: bool,
|
||||
}
|
||||
|
||||
/// The pointer's state.
|
||||
#[derive(Default)]
|
||||
pub struct AgentCursor {
|
||||
pub pos: Option<Pos2>,
|
||||
job: Option<Job>,
|
||||
/// Click ripples: where and when.
|
||||
ripples: Vec<(Pos2, f64)>,
|
||||
/// The view before a followed sketch (restored when it finishes).
|
||||
saved_view: Option<solvecraft_engine::render::Camera>,
|
||||
}
|
||||
|
||||
impl AgentCursor {
|
||||
/// A request is being acted out (later requests wait behind it).
|
||||
pub fn busy(&self) -> bool {
|
||||
self.job.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys whose [x, y, z] values are directions or amounts, not places to point at.
|
||||
const NOT_PLACES: [&str; 14] =
|
||||
["translate", "dir1", "dir2", "axis", "direction", "normal", "pull", "color", "x_dir", "y_dir", "scale", "values", "limits", "size"];
|
||||
|
||||
/// Where the pointer goes for a command: its toolbar button (or its panel), then each pick.
|
||||
pub fn stops(app: &SolveApp, command: &str, params: &Value) -> Vec<Pos2> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(spec) = solvecraft_engine::find_command(command) {
|
||||
let button = crate::scenario::handle_at(&format!("toolbar:{}", spec.id))
|
||||
.or_else(|| crate::scenario::handle_at(&format!("panel:{}:{}", spec.tab, spec.panel)));
|
||||
out.extend(button);
|
||||
}
|
||||
let Some(rect) = app.viewport.rect else { return out };
|
||||
let proj = crate::viewport::projection(app, rect);
|
||||
for w in picks(app, command, params) {
|
||||
if let Some(p) = proj.to_screen(w).filter(|p| rect.contains(*p)) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The places a command's parameters pick, in the world: [x, y, z] points (faces, edges,
|
||||
/// vertices, positions), active-sketch [x, y] points, and a new sketch's plane.
|
||||
pub fn picks(app: &SolveApp, command: &str, params: &Value) -> Vec<Vec3> {
|
||||
let id = solvecraft_engine::find_command(command).map_or(command, |c| c.id);
|
||||
let mut out = Vec::new();
|
||||
if id == "sketch.create"
|
||||
&& let Some(n) = params.get("plane").and_then(Value::as_str)
|
||||
&& let Some((_, _, q)) = crate::viewport::origin_planes(app).into_iter().find(|(name, _, _)| *name == n)
|
||||
{
|
||||
out.push((q[0] + q[1] + q[2] + q[3]) / 4.0);
|
||||
}
|
||||
let st = app.session.world_state();
|
||||
let sketch = app.session.active_sketch.and_then(|s| st.sketch(s)).map(|ss| ss.plane);
|
||||
walk(params, None, &mut |key, v| {
|
||||
let num = |i: usize| v.get(i).and_then(Value::as_f64).filter(|x| x.is_finite());
|
||||
let place = !key.is_some_and(|k| NOT_PLACES.contains(&k));
|
||||
match (v.as_array().map(Vec::len), num(0), num(1), num(2)) {
|
||||
(Some(3), Some(x), Some(y), Some(z)) if place => out.push(Vec3::new(x, y, z)),
|
||||
(Some(2), Some(x), Some(y), None) if place => {
|
||||
if let Some(pl) = &sketch {
|
||||
out.push(pl.to_world(Vec2::new(x, y)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
// A profile feature without picks takes the profiles of the active (else the last) sketch:
|
||||
// point at them.
|
||||
if out.is_empty() && matches!(id, "solid.extrude" | "solid.revolve") {
|
||||
let ss = app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty()));
|
||||
if let Some(ss) = ss {
|
||||
out.extend(ss.profiles.iter().take(4).map(|p| ss.plane.to_world(p.region.interior_point())));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Visit every value with the key it sits under (array items inherit their array's key).
|
||||
fn walk<'a>(v: &'a Value, key: Option<&'a str>, f: &mut dyn FnMut(Option<&'a str>, &'a Value)) {
|
||||
match v {
|
||||
Value::Object(m) => {
|
||||
for (k, x) in m {
|
||||
walk(x, Some(k.as_str()), f);
|
||||
}
|
||||
}
|
||||
Value::Array(a) => {
|
||||
// A point is a leaf; a list of points or other values is walked.
|
||||
if a.len() >= 2 && a.len() <= 3 && a.iter().all(Value::is_number) {
|
||||
f(key, v);
|
||||
} else {
|
||||
for x in a {
|
||||
walk(x, key, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Take over a control request when the pointer should act it out first: returns the request
|
||||
/// back when it should simply run.
|
||||
pub fn intercept(app: &mut SolveApp, method: &str, params: &Value, reply: &Sender<ControlResponse>) -> bool {
|
||||
let s = app.ui.agent_cursor;
|
||||
if !s.show || !matches!(method, "engine.execute" | "command") {
|
||||
return false;
|
||||
}
|
||||
if params.get("animate").and_then(Value::as_bool) == Some(false) {
|
||||
return false;
|
||||
}
|
||||
let Some(cmd) = params.get("command").or(params.get("id")).and_then(Value::as_str) else { return false };
|
||||
let inner = params.get("params").cloned().unwrap_or(json!({}));
|
||||
let stops: VecDeque<Pos2> = stops(app, cmd, &inner).into();
|
||||
if s.speed == Speed::Instant {
|
||||
// No animation: the command runs now; the pointer jumps to where it acted.
|
||||
if let Some(last) = stops.back() {
|
||||
app.agent.pos = Some(*last);
|
||||
app.agent.ripples.push((*last, app.now));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if stops.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let from = app.cursor_start();
|
||||
app.agent.job =
|
||||
Some(Job { method: method.to_string(), params: params.clone(), reply: reply.clone(), stops, from, started: app.now, clicked: false });
|
||||
true
|
||||
}
|
||||
|
||||
impl SolveApp {
|
||||
/// Where the agent pointer starts: where it was, else the middle of the viewport.
|
||||
fn cursor_start(&self) -> Pos2 {
|
||||
self.agent.pos.or_else(|| self.viewport.rect.map(|r| r.center())).unwrap_or(pos2(600.0, 400.0))
|
||||
}
|
||||
}
|
||||
|
||||
fn ease(t: f64) -> f64 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// Move the pointer along; when the last stop is clicked, run the held request and reply.
|
||||
pub fn step(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let now = app.now;
|
||||
app.agent.ripples.retain(|(_, t)| now - t < 0.6);
|
||||
if !app.agent.ripples.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let (glide, click) = app.ui.agent_cursor.speed.times();
|
||||
let Some(job) = app.agent.job.as_mut() else { return };
|
||||
ctx.request_repaint();
|
||||
let Some(&to) = job.stops.front() else {
|
||||
finish(app, ctx);
|
||||
return;
|
||||
};
|
||||
let t = if glide > 0.0 { (now - job.started) / glide } else { 1.0 };
|
||||
if !job.clicked {
|
||||
let e = ease(t) as f32;
|
||||
app.agent.pos = Some(job.from + (to - job.from) * e);
|
||||
if t >= 1.0 {
|
||||
job.clicked = true;
|
||||
job.started = now;
|
||||
app.agent.ripples.push((to, now));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if now - job.started >= click {
|
||||
job.stops.pop_front();
|
||||
job.from = to;
|
||||
job.started = now;
|
||||
job.clicked = false;
|
||||
if job.stops.is_empty() {
|
||||
finish(app, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the held request now (as a call without animation) and send its reply.
|
||||
fn finish(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(job) = app.agent.job.take() else { return };
|
||||
let mut params = job.params;
|
||||
params["animate"] = json!(false);
|
||||
let cmd = params.get("command").or(params.get("id")).and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let id = solvecraft_engine::find_command(&cmd).map_or(cmd.clone(), |c| c.id.to_string());
|
||||
let follow = app.ui.agent_cursor.follow_camera;
|
||||
if follow && id == "sketch.create" {
|
||||
app.agent.saved_view = Some(app.cam);
|
||||
}
|
||||
let (req, _rx) = crate::control::ControlRequest::new(job.method, params);
|
||||
let out = match crate::control::handle(app, ctx, &req) {
|
||||
crate::control::Outcome::Done(v) => v,
|
||||
crate::control::Outcome::Screenshot { .. } => json!({"ok": false, "error": "not a command"}),
|
||||
};
|
||||
let ran = out.get("ok").and_then(Value::as_bool).unwrap_or(false);
|
||||
if follow && ran {
|
||||
if id == "sketch.create" {
|
||||
crate::dialogs::look_at_sketch(app);
|
||||
} else if id == "sketch.finish"
|
||||
&& let Some(cam) = app.agent.saved_view.take()
|
||||
{
|
||||
app.animate_to(cam);
|
||||
}
|
||||
}
|
||||
let _ = job.reply.send(out);
|
||||
}
|
||||
|
||||
/// The pointer's colour: a violet no part of the UI uses.
|
||||
const AGENT: Color32 = Color32::from_rgb(168, 85, 247);
|
||||
|
||||
/// Draw the pointer, its "Agent" tag and the click ripples over everything.
|
||||
pub fn paint(app: &SolveApp, ctx: &egui::Context) {
|
||||
if !app.ui.agent_cursor.show {
|
||||
return;
|
||||
}
|
||||
let Some(p) = app.agent.pos else { return };
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Debug, egui::Id::new("sc_agent_cursor")));
|
||||
for (at, t0) in &app.agent.ripples {
|
||||
let k = ((app.now - t0) / 0.6).clamp(0.0, 1.0) as f32;
|
||||
painter.circle_stroke(*at, 6.0 + 22.0 * k, Stroke::new(3.0 * (1.0 - k) + 0.5, AGENT.gamma_multiply(1.0 - k)));
|
||||
}
|
||||
// An arrow pointer, tip at `p`.
|
||||
let pts: Vec<Pos2> =
|
||||
[(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
|
||||
painter.add(Shape::convex_polygon(pts.clone(), AGENT, Stroke::NONE));
|
||||
painter.add(Shape::closed_line(pts, Stroke::new(1.5, Color32::WHITE)));
|
||||
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), Color32::WHITE);
|
||||
let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
|
||||
painter.rect_filled(r, 4.0, AGENT);
|
||||
painter.galley(r.min + vec2(5.0, 2.0), label, Color32::WHITE);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use solvecraft_engine::Session;
|
||||
|
||||
fn app() -> SolveApp {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let mut a = SolveApp::new(s, Services::default());
|
||||
a.viewport.rect = Some(egui::Rect::from_min_size(pos2(250.0, 120.0), vec2(1100.0, 760.0)));
|
||||
a.fit_view();
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_are_the_places_in_the_parameters() {
|
||||
let a = app();
|
||||
// Points and lists of points, not directions or colours.
|
||||
let p = json!({"edges": [[20, 0, 20], [40, 15, 20]], "position": [5, 5, 20], "translate": [1, 2, 3], "color": [255, 0, 0], "radius": 2});
|
||||
let w = picks(&a, "solid.fillet", &p);
|
||||
assert_eq!(w.len(), 3, "{w:?}");
|
||||
assert!(w.contains(&Vec3::new(20.0, 0.0, 20.0)) && w.contains(&Vec3::new(5.0, 5.0, 20.0)));
|
||||
// A new sketch's origin plane is a place too (the middle of its square).
|
||||
assert_eq!(picks(&a, "sketch.create", &json!({"plane": "XY"})).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_land_where_the_viewport_draws_them() {
|
||||
let a = app();
|
||||
let rect = a.viewport.rect.unwrap();
|
||||
let proj = crate::viewport::projection(&a, rect);
|
||||
let at = Vec3::new(20.0, 15.0, 20.0);
|
||||
let s = stops(&a, "solid.hole", &json!({"position": [20, 15, 20], "diameter": 5}));
|
||||
let want = proj.to_screen(at).unwrap();
|
||||
assert!(s.iter().any(|p| p.distance(want) < 0.01), "{s:?} vs {want:?}");
|
||||
assert!(rect.contains(want));
|
||||
// Off-screen places are skipped rather than pointed at outside the view.
|
||||
assert!(stops(&a, "solid.hole", &json!({"position": [1e6, 0, 0]})).iter().all(|p| rect.contains(*p)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speeds_read_old_and_new_settings() {
|
||||
// Saved settings from before #79: slow and normal are 1×, instant stays.
|
||||
for (v, want) in [
|
||||
(json!("slow"), Speed::Times(1.0)),
|
||||
(json!("normal"), Speed::Times(1.0)),
|
||||
(json!("instant"), Speed::Instant),
|
||||
(json!(3), Speed::Times(3.0)),
|
||||
] {
|
||||
assert_eq!(Speed::from_value(&v), Some(want), "{v}");
|
||||
let s: Settings = serde_json::from_value(json!({"show": true, "speed": v})).unwrap();
|
||||
assert_eq!(s.speed, want);
|
||||
}
|
||||
assert_eq!(Speed::from_value(&json!(9)), None);
|
||||
assert_eq!(serde_json::to_value(Speed::Instant).unwrap(), json!("instant"));
|
||||
assert_eq!(serde_json::to_value(Speed::Times(2.5)).unwrap(), json!(2.5));
|
||||
assert_eq!((Speed::Times(1.0).label(), Speed::Times(2.5).label(), Speed::Instant.label()), ("1×".into(), "2.5×".into(), "Instant".into()));
|
||||
// Faster glides take less time.
|
||||
assert!(Speed::Times(5.0).times().0 < Speed::Times(1.0).times().0);
|
||||
}
|
||||
}
|
||||
+129
-90
@@ -17,6 +17,17 @@ use crate::context_menu::{Item, Target};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{SolveApp, icons};
|
||||
|
||||
thread_local! {
|
||||
/// The rows drawn this frame: (label, row, fold arrow area, open). For automation
|
||||
/// (`ui.at {browser | chevron}`, `ui.browser`).
|
||||
static DRAWN: std::cell::RefCell<Vec<(String, Rect, Option<Rect>, Option<bool>)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// The rows on screen: (label, row rect, fold arrow area, open).
|
||||
pub fn drawn_rows() -> Vec<(String, Rect, Option<Rect>, Option<bool>)> {
|
||||
DRAWN.with(|d| d.borrow().clone())
|
||||
}
|
||||
|
||||
/// Browser state kept between frames.
|
||||
#[derive(Default)]
|
||||
pub struct TreeState {
|
||||
@@ -48,13 +59,18 @@ pub struct TreeState {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OccMove {
|
||||
pub occurrence: u64,
|
||||
/// Distances along X, Y, Z (mm) and turns about X, Y, Z (degrees) from where it was.
|
||||
pub translate: [f64; 3],
|
||||
pub angle_deg: f64,
|
||||
pub angles: [f64; 3],
|
||||
/// The occurrence's origin when the move began: what it turns about.
|
||||
pub pivot: Option<solvecraft_engine::geom::Vec3>,
|
||||
/// The values when the current triad drag began.
|
||||
drag0: Option<([f64; 3], [f64; 3])>,
|
||||
}
|
||||
|
||||
impl OccMove {
|
||||
pub fn new(occurrence: u64) -> Self {
|
||||
OccMove { occurrence, translate: [0.0; 3], angle_deg: 0.0 }
|
||||
OccMove { occurrence, translate: [0.0; 3], angles: [0.0; 3], pivot: None, drag0: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +262,8 @@ struct RowResp {
|
||||
hovered: bool,
|
||||
/// The row's response (drag and drop).
|
||||
resp: Option<egui::Response>,
|
||||
/// The press began on the fold arrow, eye or radio (not a drag of the row).
|
||||
on_control: bool,
|
||||
}
|
||||
|
||||
fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
@@ -256,7 +274,10 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
ui.allocate_space(r.size());
|
||||
return RowResp { rect: Some(r), ..Default::default() };
|
||||
}
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag());
|
||||
// A stable id per row (not the layout position), so a press and its release stay on the
|
||||
// same row while rows above open or close.
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::hover());
|
||||
let resp = ui.interact(r, id, Sense::click_and_drag());
|
||||
// While something is dragged (an appearance swatch, drawn under the pointer) `hovered` is
|
||||
// off; the row under the pointer still counts, so a drop lands on it.
|
||||
let dragged_over = egui::DragAndDrop::has_any_payload(ui.ctx()) && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p));
|
||||
@@ -269,6 +290,27 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
let mut x = r.left() + 2.0 + row.depth as f32 * INDENT;
|
||||
let cy = r.center().y;
|
||||
let ink = if row.dim { t.text_dim } else { t.icon };
|
||||
// The row is one widget: where a click lands decides what it does (the fold arrow with
|
||||
// padding, the eye, the radio, or the row itself), so a click on the arrow never also
|
||||
// selects the row or starts a drag.
|
||||
let fold_r = Rect::from_min_max(pos2(x - 4.0, r.top()), pos2(x + 15.0, r.bottom()));
|
||||
let eye_r = Rect::from_center_size(pos2(x + 14.0 + 8.0, cy), vec2(19.0, r.height()));
|
||||
let radio_r = Rect::from_center_size(pos2(x + 32.0 + 6.0, cy), vec2(15.0, r.height()));
|
||||
let at = |p: Pos2| -> u8 {
|
||||
if row.fold.is_some() && fold_r.contains(p) {
|
||||
1
|
||||
} else if row.eye.is_some() && eye_r.contains(p) {
|
||||
2
|
||||
} else if row.radio.is_some() && radio_r.contains(p) {
|
||||
3
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
DRAWN.with(|d| d.borrow_mut().push((row.label.to_string(), r, row.fold.map(|_| fold_r), row.fold)));
|
||||
let press_on = ui.input(|i| i.pointer.press_origin()).map_or(0, at);
|
||||
let click_on = resp.interact_pointer_pos().map_or(0, at);
|
||||
let hover_on = resp.hover_pos().map_or(0, at);
|
||||
// Fold arrow.
|
||||
if let Some(open) = row.fold {
|
||||
let ar = Rect::from_center_size(pos2(x + 6.0, cy), vec2(12.0, 12.0));
|
||||
@@ -277,9 +319,10 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
} else {
|
||||
vec![pos2(ar.left() + 4.0, ar.top() + 2.0), pos2(ar.right() - 3.0, ar.center().y), pos2(ar.left() + 4.0, ar.bottom() - 2.0)]
|
||||
};
|
||||
ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE));
|
||||
out.fold = ui.interact(ar.expand(3.0), id.with("fold"), Sense::click()).clicked();
|
||||
crate::scenario::publish_handle(&format!("fold:{}", row.label), ar.center());
|
||||
let ink = if hover_on == 1 { t.text } else { t.text_dim };
|
||||
ui.painter().add(egui::Shape::convex_polygon(pts, ink, Stroke::NONE));
|
||||
out.fold = resp.clicked() && click_on == 1;
|
||||
crate::scenario::publish_handle(&format!("fold:{}", row.label), fold_r.center());
|
||||
}
|
||||
x += 14.0;
|
||||
// Eye.
|
||||
@@ -289,9 +332,7 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
if !vis {
|
||||
ui.painter().line_segment([er.left_bottom() + vec2(2.0, -2.0), er.right_top() + vec2(-2.0, 2.0)], Stroke::new(1.4, t.text_dim));
|
||||
}
|
||||
let e = ui.interact(er.expand(2.0), id.with("eye"), Sense::click());
|
||||
out.eye = e.clicked();
|
||||
e.on_hover_text(if vis { "Hide" } else { "Show" });
|
||||
out.eye = resp.clicked() && click_on == 2;
|
||||
}
|
||||
x += 18.0;
|
||||
// Activation radio (components).
|
||||
@@ -301,9 +342,7 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
if active {
|
||||
ui.painter().circle_filled(rc, 2.8, t.accent);
|
||||
}
|
||||
let rr = ui.interact(Rect::from_center_size(rc, vec2(14.0, 14.0)), id.with("radio"), Sense::click());
|
||||
out.radio = rr.clicked();
|
||||
rr.on_hover_text(if active { "Active component" } else { "Activate" });
|
||||
out.radio = resp.clicked() && click_on == 3;
|
||||
x += 15.0;
|
||||
}
|
||||
if !row.icon.is_empty() {
|
||||
@@ -322,9 +361,20 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
|
||||
bx += 15.0;
|
||||
}
|
||||
crate::scenario::publish_handle(&format!("row:{}", row.label), r.center());
|
||||
let tip = match hover_on {
|
||||
1 => Some(if row.fold == Some(true) { "Collapse" } else { "Expand" }),
|
||||
2 => Some(if row.eye == Some(true) { "Hide" } else { "Show" }),
|
||||
3 => Some(if row.radio == Some(true) { "Active component" } else { "Activate" }),
|
||||
_ => None,
|
||||
};
|
||||
let resp = match tip {
|
||||
Some(t) => resp.on_hover_text(t),
|
||||
None => resp,
|
||||
};
|
||||
out.on_control = press_on != 0;
|
||||
out.clicked = resp.clicked() && click_on == 0;
|
||||
out.double = resp.double_clicked() && click_on == 0;
|
||||
out.resp = Some(resp.clone());
|
||||
out.clicked = resp.clicked();
|
||||
out.double = resp.double_clicked();
|
||||
if resp.secondary_clicked() {
|
||||
out.secondary = resp.interact_pointer_pos().or(Some(r.left_bottom()));
|
||||
}
|
||||
@@ -424,6 +474,7 @@ struct Actions {
|
||||
pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
app.tree.order.clear();
|
||||
DRAWN.with(|d| d.borrow_mut().clear());
|
||||
let mut acts = Actions::default();
|
||||
egui::Panel::left("sc_browser")
|
||||
.exact_size(250.0)
|
||||
@@ -447,7 +498,7 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if let Some((at, target)) = acts.menu {
|
||||
crate::context_menu::open_for(app, at, target);
|
||||
}
|
||||
occurrence_panel(app, ui.ctx());
|
||||
occurrence_panel(app, ui);
|
||||
capture_prompt(app, ui.ctx());
|
||||
canvas_panel(app, ui.ctx());
|
||||
redefine_panel(app, ui.ctx());
|
||||
@@ -1227,7 +1278,7 @@ fn entry_row(
|
||||
}
|
||||
let Some(resp) = &r.resp else { return };
|
||||
// Drag: the selected items of this folder when this one is selected, else just this one.
|
||||
if resp.drag_started() {
|
||||
if resp.drag_started() && !r.on_control {
|
||||
let keys: Vec<String> = if e.selected { selected_keys(app, comp, folder) } else { vec![e.key.clone()] };
|
||||
resp.dnd_set_drag_payload(Drag { comp, folder, keys });
|
||||
}
|
||||
@@ -1311,20 +1362,24 @@ fn apply_drop(app: &mut SolveApp, d: DropAction) {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Move/Copy of an occurrence: offsets and a rotation about Z, pending until Capture Position.
|
||||
fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
fn occurrence_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let Some(mut m) = app.tree.occurrence_move.clone() else { return };
|
||||
let ctx = ui.ctx().clone();
|
||||
if m.pivot.is_none() {
|
||||
m.pivot = occurrence_origin(app, m.occurrence);
|
||||
}
|
||||
let name = app.session.doc.occurrences.iter().find(|o| o.id == m.occurrence).map(|o| o.name.clone()).unwrap_or_default();
|
||||
let mut open = true;
|
||||
let mut action: Option<&str> = None;
|
||||
let before = (m.translate, m.angle_deg);
|
||||
crate::frame::window(ctx, format!("Move: {name}"), crate::frame::Width::Normal)
|
||||
let before = (m.translate, m.angles);
|
||||
crate::frame::window(&ctx, format!("Move: {name}"), crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("sc_occ_move"))
|
||||
.pivot(egui::Align2::LEFT_TOP)
|
||||
.default_pos(panel_pos(app))
|
||||
.open(&mut open)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.show(ctx, |ui| {
|
||||
.show(&ctx, |ui| {
|
||||
egui::Grid::new("sc_occ_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
|
||||
for (k, axis) in ["X distance", "Y distance", "Z distance"].iter().enumerate() {
|
||||
ui.label(*axis);
|
||||
@@ -1333,9 +1388,13 @@ fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label("Z angle");
|
||||
ui.add(egui::DragValue::new(&mut m.angle_deg).speed(1.0).suffix(" deg"));
|
||||
ui.end_row();
|
||||
for (k, axis) in ["X angle", "Y angle", "Z angle"].iter().enumerate() {
|
||||
ui.label(*axis);
|
||||
if let Some(v) = m.angles.get_mut(k) {
|
||||
ui.add(egui::DragValue::new(v).speed(1.0).suffix(" deg"));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
@@ -1347,14 +1406,9 @@ fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
});
|
||||
gizmo(app, ctx, &mut m);
|
||||
if (m.translate, m.angle_deg) != before {
|
||||
// The pending move is the typed offset from where the occurrence is now.
|
||||
let _ = app.session.execute("component.revert_position", &json!({}));
|
||||
let _ = app.run(
|
||||
"occurrence.move",
|
||||
json!({ "occurrence": m.occurrence, "translate": m.translate, "axis": [0, 0, 1], "angle": m.angle_deg.to_radians(), "capture": false }),
|
||||
);
|
||||
triad(app, ui, &mut m);
|
||||
if (m.translate, m.angles) != before {
|
||||
apply_occurrence_move(app, &m);
|
||||
}
|
||||
match action {
|
||||
Some("capture") => {
|
||||
@@ -1373,6 +1427,50 @@ fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pending move: turned about X, then Y, then Z through the starting origin, then moved.
|
||||
pub fn apply_occurrence_move(app: &mut SolveApp, m: &OccMove) {
|
||||
use crate::gizmo;
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
let a = m.angles.map(f64::to_radians);
|
||||
let r = gizmo::mul(&gizmo::mul(&gizmo::rotation(Vec3::Z, a[2]), &gizmo::rotation(Vec3::Y, a[1])), &gizmo::rotation(Vec3::X, a[0]));
|
||||
let (axis, angle) = gizmo::axis_angle(&r);
|
||||
let o = m.pivot.unwrap_or(Vec3::ZERO);
|
||||
let _ = app.session.execute("component.revert_position", &json!({}));
|
||||
let _ = app.run(
|
||||
"occurrence.move",
|
||||
json!({ "occurrence": m.occurrence, "translate": m.translate, "axis": [axis.x, axis.y, axis.z], "angle": angle, "origin": [o.x, o.y, o.z], "capture": false }),
|
||||
);
|
||||
}
|
||||
|
||||
/// The shared move triad at the occurrence's origin: arrows and squares move it, rings turn it.
|
||||
fn triad(app: &mut SolveApp, ui: &mut egui::Ui, m: &mut OccMove) {
|
||||
use crate::gizmo::{self, Change};
|
||||
let (Some(rect), Some(pivot)) = (app.viewport.rect, m.pivot) else { return };
|
||||
let proj = crate::viewport::projection(app, rect);
|
||||
let center = pivot + solvecraft_engine::geom::Vec3::new(m.translate[0], m.translate[1], m.translate[2]);
|
||||
let layer = egui::LayerId::new(egui::Order::Middle, egui::Id::new("sc_occ_triad_layer"));
|
||||
let mut gui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(rect));
|
||||
let painter = gui.painter().with_clip_rect(rect);
|
||||
let t = gizmo::Triad { center, translate: true, rotate: true };
|
||||
let step = crate::canvas::snap_step(app.cam.half_height());
|
||||
let Some(g) = gizmo::show(&mut gui, &painter, &proj, egui::Id::new("sc_occ_triad"), &t, step) else { return };
|
||||
if g.started || m.drag0.is_none() {
|
||||
m.drag0 = Some((m.translate, m.angles));
|
||||
}
|
||||
let (t0, a0) = m.drag0.unwrap_or((m.translate, m.angles));
|
||||
match g.change {
|
||||
Change::Translate(v) => m.translate = [t0[0] + v.x, t0[1] + v.y, t0[2] + v.z],
|
||||
Change::Rotate { axis, angle } => {
|
||||
if let Some(slot) = m.angles.get_mut(axis) {
|
||||
*slot = a0.get(axis).copied().unwrap_or(0.0) + angle.to_degrees();
|
||||
}
|
||||
}
|
||||
}
|
||||
if g.done {
|
||||
m.drag0 = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Dimensions of finished sketches whose dimensions are shown (Show Dimensions), drawn over the
|
||||
/// viewport like the sketch being edited draws its own (`crate::dim_view`), but not editable.
|
||||
pub fn dims_overlay(app: &SolveApp, ctx: &egui::Context) {
|
||||
@@ -1517,65 +1615,6 @@ fn occurrence_origin(app: &SolveApp, occurrence: u64) -> Option<solvecraft_engin
|
||||
Some(solvecraft_engine::doc::apply_point(&solvecraft_engine::doc::mat_mul(&parent, &own), solvecraft_engine::geom::Vec3::ZERO))
|
||||
}
|
||||
|
||||
/// Screen length of the gizmo's arrows.
|
||||
const GIZMO_PX: f32 = 80.0;
|
||||
|
||||
/// Arrows along X, Y and Z at the occurrence's origin: dragging one moves it along that axis.
|
||||
fn gizmo(app: &mut SolveApp, ctx: &egui::Context, m: &mut OccMove) {
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
let t = Tokens::get();
|
||||
let (Some(rect), Some(o)) = (app.viewport.rect, occurrence_origin(app, m.occurrence)) else { return };
|
||||
let proj = crate::viewport::projection(app, rect);
|
||||
let Some(p0) = proj.to_screen(o) else { return };
|
||||
if !rect.contains(p0) {
|
||||
return;
|
||||
}
|
||||
let axes = [(Vec3::X, t.axis_x), (Vec3::Y, t.axis_y), (Vec3::Z, t.axis_z)];
|
||||
// Per axis: the screen direction and pixels per millimetre.
|
||||
let mut arms = Vec::new();
|
||||
for (k, (a, col)) in axes.iter().enumerate() {
|
||||
let Some(p1) = proj.to_screen(o + *a) else { continue };
|
||||
let d = p1 - p0;
|
||||
let px_per_mm = d.length();
|
||||
if px_per_mm < 1e-6 || !px_per_mm.is_finite() {
|
||||
continue;
|
||||
}
|
||||
arms.push((k, d / px_per_mm, px_per_mm, *col));
|
||||
}
|
||||
let bbox = Rect::from_center_size(p0, vec2(2.0 * GIZMO_PX + 30.0, 2.0 * GIZMO_PX + 30.0)).intersect(rect);
|
||||
egui::Area::new(egui::Id::new("sc_occ_gizmo")).fixed_pos(bbox.min).order(egui::Order::Middle).show(ctx, |ui| {
|
||||
let (_, _) = ui.allocate_exact_size(bbox.size(), Sense::hover());
|
||||
let painter = ui.painter_at(bbox);
|
||||
for (k, dir, px_per_mm, col) in &arms {
|
||||
// An axis seen end-on has no arrow to drag.
|
||||
let len = GIZMO_PX * dir.length().min(1.0);
|
||||
let tip = p0 + *dir * len;
|
||||
let handle =
|
||||
Rect::from_center_size(p0 + *dir * (len * 0.6), vec2(len * 0.8, len * 0.8)).intersect(Rect::from_two_pos(p0, tip).expand(9.0));
|
||||
let resp = ui.interact(handle, ui.id().with(("arm", *k)), Sense::drag());
|
||||
let lit = resp.hovered() || resp.dragged();
|
||||
let w = if lit { 4.0 } else { 2.5 };
|
||||
painter.line_segment([p0 + *dir * 10.0, tip], Stroke::new(w, *col));
|
||||
let n = vec2(-dir.y, dir.x);
|
||||
painter.add(egui::Shape::convex_polygon(vec![tip + *dir * 10.0, tip + n * 5.5, tip - n * 5.5], *col, Stroke::NONE));
|
||||
if resp.dragged() {
|
||||
let mm = resp.drag_delta().dot(*dir) / px_per_mm;
|
||||
if let Some(v) = m.translate.get_mut(*k)
|
||||
&& mm.is_finite()
|
||||
{
|
||||
*v += f64::from(mm);
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
&& let Some(v) = m.translate.get_mut(*k)
|
||||
{
|
||||
*v = (*v * 100.0).round() / 100.0;
|
||||
}
|
||||
}
|
||||
painter.circle(p0, 6.0, t.panel, Stroke::new(1.5, t.accent));
|
||||
});
|
||||
}
|
||||
|
||||
/// Redefine Sketch Plane: pick an origin or construction plane, or the selected planar face.
|
||||
fn redefine_panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(id) = app.tree.redefine else { return };
|
||||
|
||||
@@ -13,7 +13,7 @@ use solvecraft_engine::geom::{Mesh, Vec3};
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{Dialog, Kind};
|
||||
use crate::theme::Tokens;
|
||||
use crate::viewport::Proj;
|
||||
use crate::viewport::{Proj, projected_polygon};
|
||||
|
||||
/// Where the dialog's geometry is: a base point and, for values that can be dragged, the
|
||||
/// direction a growing value points.
|
||||
@@ -145,6 +145,13 @@ fn format_len(v: f64) -> String {
|
||||
format!("{} mm", if s == "-0" { "0" } else { s })
|
||||
}
|
||||
|
||||
/// The offset plane preview: a square of half side `h` around `c`, square to `n`.
|
||||
fn offset_plane_quad(proj: &Proj, c: Vec3, n: Vec3, h: f64) -> Option<Vec<Pos2>> {
|
||||
let u = n.cross(if n.z.abs() < 0.9 { Vec3::Z } else { Vec3::X }).normalized()?;
|
||||
let v = n.cross(u);
|
||||
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].iter().map(|(a, b)| proj.to_screen(c + u * (a * h) + v * (b * h))).collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog, base: Vec3, bs: Pos2, dir: Option<Vec3>) {
|
||||
let t = Tokens::get();
|
||||
@@ -168,16 +175,9 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
// An offset plane shows where it will be.
|
||||
if let (Kind::OffsetPlane { offset }, Some(n)) = (&d.kind, dir)
|
||||
&& let Ok(off) = app.session.doc.eval(offset, ValueKind::Length)
|
||||
&& let Some(u) = n.cross(if n.z.abs() < 0.9 { Vec3::Z } else { Vec3::X }).normalized()
|
||||
&& let Some(q) = offset_plane_quad(proj, base + n * off, n, half_height * 0.25)
|
||||
{
|
||||
let v = n.cross(u);
|
||||
let h = half_height * 0.25;
|
||||
let c = base + n * off;
|
||||
let q: Vec<Pos2> =
|
||||
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].iter().filter_map(|(a, b)| proj.to_screen(c + u * (a * h) + v * (b * h))).collect();
|
||||
if q.len() == 4 {
|
||||
painter.add(egui::Shape::convex_polygon(q, t.construction_plane, Stroke::new(1.2, t.origin_plane_edge)));
|
||||
}
|
||||
painter.extend(projected_polygon(&q, t.construction_plane, Stroke::new(1.2, t.origin_plane_edge)));
|
||||
}
|
||||
// Holes: the depth arrow points into the material (empty depth: through all); the
|
||||
// diameter is typed in the box.
|
||||
@@ -412,6 +412,30 @@ fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn offset_plane_preview_stays_within_its_square() {
|
||||
let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let mut worst = f32::NEG_INFINITY;
|
||||
for n in [Vec3::X, Vec3::Y, Vec3::Z] {
|
||||
for dir in crate::viewport::orbit_directions() {
|
||||
let cam = solvecraft_engine::render::Camera::default().looking_from(dir);
|
||||
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
|
||||
let h = cam.half_height() * 0.25;
|
||||
let (Some(q), Some(c), Some(side)) =
|
||||
(offset_plane_quad(&proj, Vec3::ZERO, n, h), proj.to_screen(Vec3::ZERO), proj.to_screen(cam.basis().0 * h))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// The square's half diagonal on screen (an orthographic view), plus stroke and feathering.
|
||||
let limit = c.distance(side) * 2f32.sqrt() + 3.0;
|
||||
for p in crate::viewport::drawn_points(projected_polygon(&q, Color32::GRAY, Stroke::new(1.2, Color32::BLACK))) {
|
||||
worst = worst.max(p.distance(c) - limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(worst <= 0.0, "a vertex {worst} px past the square");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapping_steps_and_formatting() {
|
||||
assert_eq!(snap_step(40.0), 1.0);
|
||||
|
||||
@@ -46,6 +46,12 @@ pub enum Target {
|
||||
Joint {
|
||||
id: u64,
|
||||
},
|
||||
/// A command (toolbar button, panel drop-down row, S box row).
|
||||
ToolbarCommand {
|
||||
id: String,
|
||||
},
|
||||
/// The workspace switcher.
|
||||
Workspace,
|
||||
/// The Named Views folder, and one view (saved or standard) in it.
|
||||
NamedViews,
|
||||
NamedView {
|
||||
@@ -228,6 +234,8 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec<String> {
|
||||
| Target::Origin
|
||||
| Target::Units
|
||||
| Target::Joint { .. }
|
||||
| Target::ToolbarCommand { .. }
|
||||
| Target::Workspace
|
||||
| Target::NamedViews
|
||||
| Target::NamedView { .. } => Vec::new(),
|
||||
}
|
||||
@@ -290,11 +298,19 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
|
||||
Target::Origin => crate::browser::origin_items(app),
|
||||
Target::NamedViews => vec![act("ui.newView", "New Named View", "perspective")],
|
||||
Target::Joint { id } => joint_items(app, *id),
|
||||
Target::ToolbarCommand { id } => crate::toolbar_custom::command_items(app, id),
|
||||
Target::Workspace => {
|
||||
let mut v = vec![act("ui.workspace", "Design", "box").key("current")];
|
||||
for w in ["Generative Design", "Render", "Animation", "Simulation", "Manufacture", "Drawing"] {
|
||||
v.push(act("ui.workspace", w, "").key("coming later").on(false));
|
||||
}
|
||||
v
|
||||
}
|
||||
Target::Units => crate::prefs::UNITS
|
||||
.iter()
|
||||
.map(|u| {
|
||||
let label = if app.session.doc.units == *u { format!("{u} ✓") } else { (*u).to_string() };
|
||||
cmd(app, "document.units", &label).with(json!({ "units": u }))
|
||||
let item = cmd(app, "document.units", u).with(json!({ "units": u }));
|
||||
if app.session.doc.units == *u { item.key("current") } else { item }
|
||||
})
|
||||
.collect(),
|
||||
Target::NamedView { name } => crate::browser::view_items(app, name),
|
||||
@@ -773,6 +789,8 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
}
|
||||
}
|
||||
"ui.canvasVisible" => drop(app.run("canvas.edit", p)),
|
||||
"ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.shortcutPin" => crate::toolbar_custom::run(app, &item.id, &p),
|
||||
"ui.workspace" => {}
|
||||
"ui.editJoint" | "ui.driveJoint" => {
|
||||
if let Some(id) = id_of(&p, "joint") {
|
||||
let d = if item.id == "ui.editJoint" {
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
//! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_cursor, msaa,
|
||||
//! length_decimals, angle_decimals); returns them all
|
||||
//! - `ui.capture {action?: capture|revert|cancel}`: the Capture Position question
|
||||
//! - `ui.agent_cursor {show?, speed?: 1–5 (times) | "instant", follow_camera?}`: the agent cursor
|
||||
//! (#35); while it is shown, `engine.execute` first glides to the command's button and picks
|
||||
//! and replies once the command has run (`"animate": false` skips that one call)
|
||||
//! - `ui.resize {width, height}`, `app.quit`
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
@@ -95,6 +98,22 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
wrap(r)
|
||||
}
|
||||
"ui.agent_cursor" => {
|
||||
let s = &mut app.ui.agent_cursor;
|
||||
if let Some(v) = p.get("show").and_then(Value::as_bool) {
|
||||
s.show = v;
|
||||
}
|
||||
if let Some(v) = p.get("follow_camera").and_then(Value::as_bool) {
|
||||
s.follow_camera = v;
|
||||
}
|
||||
if let Some(v) = p.get("speed") {
|
||||
match crate::agent_cursor::Speed::from_value(v) {
|
||||
Some(sp) => s.speed = sp,
|
||||
None => return err("speed must be a number from 1 to 5, or \"instant\""),
|
||||
}
|
||||
}
|
||||
ok(json!(app.ui.agent_cursor))
|
||||
}
|
||||
"engine.script" => wrap(app.session.run_script(p).map(|v| json!(v)).map_err(|e| e.to_string())),
|
||||
"engine.commands" => wrap(app.session.execute("engine.commands", &json!({})).map_err(|e| e.to_string())),
|
||||
"document.inspect" => wrap(app.session.execute("document.inspect", p).map_err(|e| e.to_string())),
|
||||
@@ -111,6 +130,8 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
"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,
|
||||
"documents": app.docs.count(),
|
||||
"quit_requested": app.quit_requested,
|
||||
"quitting": app.docs.quitting.is_some(),
|
||||
})),
|
||||
"ui.documents" => ok(crate::documents::control(app, p)),
|
||||
"ui.home" => {
|
||||
@@ -266,6 +287,20 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
None => err(format!("handle {h} is not shown")),
|
||||
};
|
||||
}
|
||||
// A browser row by its label: the row itself, or its fold arrow.
|
||||
for (k, chevron) in [("browser", false), ("chevron", true)] {
|
||||
if let Some(label) = p.get(k).and_then(Value::as_str) {
|
||||
let rows = crate::browser::drawn_rows();
|
||||
let found = rows
|
||||
.iter()
|
||||
.find(|r| r.0 == label)
|
||||
.and_then(|r| if chevron { r.2.map(|f| f.center()) } else { Some(r.1.left_center() + egui::vec2(120.0, 0.0)) });
|
||||
return match found {
|
||||
Some(q) => ok(json!([q.x, q.y])),
|
||||
None => err(format!("no browser row `{label}`{} on screen", if chevron { " with a fold arrow" } else { "" })),
|
||||
};
|
||||
}
|
||||
}
|
||||
if let Some(d) = p.get("dimension").and_then(Value::as_str) {
|
||||
return match crate::dim_view::text_at(d) {
|
||||
Some(q) => ok(json!([q.x, q.y])),
|
||||
@@ -405,6 +440,11 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
None => ok(json!({"command": id, "key": crate::keymap::effective(app, id)})),
|
||||
}
|
||||
}
|
||||
"ui.browser" => ok(json!({
|
||||
"rows": crate::browser::drawn_rows().iter().map(|(label, r, _, open)| json!({"label": label, "y": r.center().y, "open": open})).collect::<Vec<_>>(),
|
||||
"selection": app.session.selection,
|
||||
"picked_components": app.tree.picked_components,
|
||||
})),
|
||||
"ui.capture" => match s("action") {
|
||||
Some(a) => match crate::browser::answer_capture(app, a) {
|
||||
Ok(()) => ok(json!({"pending_moves": app.session.pending_moves.len()})),
|
||||
@@ -412,7 +452,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
},
|
||||
None => ok(json!({"question": app.tree.capture_prompt, "pending_moves": app.session.pending_moves.len()})),
|
||||
},
|
||||
"ui.prefs" => wrap(crate::prefs::set(app, p)),
|
||||
"ui.prefs" => wrap(crate::prefs::control(app, p)),
|
||||
"ui.help" => match s("item") {
|
||||
Some(i @ ("about" | "shortcuts" | "report")) => {
|
||||
crate::help::run(app, ctx, i);
|
||||
@@ -459,9 +499,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
}
|
||||
"app.quit" => {
|
||||
app.quit_requested = true;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
ok(Value::Null)
|
||||
// `force`: quit without asking about unsaved designs.
|
||||
if p.get("force").and_then(Value::as_bool).unwrap_or(false) {
|
||||
app.quit_requested = true;
|
||||
} else {
|
||||
crate::documents::request_quit(app);
|
||||
}
|
||||
if app.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
ok(json!({"quitting": app.quit_requested, "asking": app.docs.closing.is_some()}))
|
||||
}
|
||||
other => err(format!("unknown method `{other}`")),
|
||||
}
|
||||
|
||||
+572
-110
File diff suppressed because it is too large
Load Diff
@@ -250,7 +250,8 @@ fn drive_value_takes_the_keyboard() {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
|
||||
let frame = |app: &mut SolveApp, events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() };
|
||||
ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx()))
|
||||
// Nothing renders here: drop the frame's texture changes deliberately.
|
||||
ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx())).textures_delta.clear();
|
||||
};
|
||||
frame(&mut app, vec![]);
|
||||
frame(&mut app, vec![]);
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Dialogs for the surface tools and face splitting (#19): Patch, Stitch, Thicken, Extend,
|
||||
//! Trim and Split Face. Each has a live preview and Edit Feature.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solvecraft_engine::Sel;
|
||||
use solvecraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solvecraft_engine::doc::{FaceAt, FeatureKind, PlaneRef};
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{Kind, OP_LABELS, OPS, combo, field, op_index, row_label};
|
||||
use crate::selection::{BODIES, EDGES, FACES, PLANES, PROFILES, SelInput};
|
||||
|
||||
/// A surface tool dialog's values.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Surf {
|
||||
Patch,
|
||||
Stitch { tolerance: String },
|
||||
Thicken { thickness: String, symmetric: bool, operation: usize },
|
||||
Extend { distance: String },
|
||||
Trim,
|
||||
SplitFace,
|
||||
}
|
||||
|
||||
impl Surf {
|
||||
pub fn title(&self) -> &'static str {
|
||||
match self {
|
||||
Surf::Patch => "PATCH",
|
||||
Surf::Stitch { .. } => "STITCH",
|
||||
Surf::Thicken { .. } => "THICKEN",
|
||||
Surf::Extend { .. } => "EXTEND",
|
||||
Surf::Trim => "TRIM",
|
||||
Surf::SplitFace => "SPLIT FACE",
|
||||
}
|
||||
}
|
||||
|
||||
/// The value on the canvas.
|
||||
pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> {
|
||||
Some(match self {
|
||||
Surf::Thicken { thickness, .. } => ("Thickness", ValueKind::Length, thickness),
|
||||
Surf::Extend { distance } => ("Distance", ValueKind::Length, distance),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(k: &Surf) -> Vec<SelInput> {
|
||||
match k {
|
||||
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
|
||||
Surf::Stitch { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
|
||||
Surf::Thicken { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
|
||||
Surf::Extend { .. } => vec![SelInput::new("Edges", EDGES, true)],
|
||||
Surf::Trim => vec![
|
||||
SelInput::new("Surface", BODIES, false),
|
||||
SelInput::new("Trimming tool", PLANES | FACES, false),
|
||||
SelInput::new("Side to keep", FACES, false),
|
||||
],
|
||||
Surf::SplitFace => vec![SelInput::new("Faces to split", FACES, true), SelInput::new("Splitting tool", PLANES | FACES, false)],
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog for a surface command.
|
||||
pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
let k = match id {
|
||||
"surface.patch" => Surf::Patch,
|
||||
"surface.stitch" => Surf::Stitch { tolerance: String::new() },
|
||||
"surface.thicken" => Surf::Thicken { thickness: "2 mm".into(), symmetric: false, operation: 0 },
|
||||
"surface.extend" => Surf::Extend { distance: "10 mm".into() },
|
||||
"surface.trim" => Surf::Trim,
|
||||
"solid.split_face" => Surf::SplitFace,
|
||||
_ => return None,
|
||||
};
|
||||
let i = inputs(&k);
|
||||
Some((Kind::Surface(k), i))
|
||||
}
|
||||
|
||||
/// The rows after the inputs; true when Enter was pressed in a field.
|
||||
pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
|
||||
let mut enter = false;
|
||||
match k {
|
||||
Surf::Stitch { tolerance } => {
|
||||
row_label(ui, "Tolerance");
|
||||
let r = ui.add(egui::TextEdit::singleline(tolerance).hint_text("tight"));
|
||||
crate::params_dialog::complete(ui, &r, tolerance);
|
||||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Thicken { thickness, symmetric, operation } => {
|
||||
row_label(ui, "Thickness");
|
||||
enter |= field(ui, thickness);
|
||||
ui.end_row();
|
||||
row_label(ui, "Direction");
|
||||
let mut dir = usize::from(*symmetric);
|
||||
combo(ui, "th_dir", &["One Side", "Symmetric"], &mut dir);
|
||||
*symmetric = dir == 1;
|
||||
ui.end_row();
|
||||
row_label(ui, "Operation");
|
||||
combo(ui, "th_op", &OP_LABELS, operation);
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Extend { distance } => {
|
||||
row_label(ui, "Distance");
|
||||
enter |= field(ui, distance);
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Patch | Surf::Trim | Surf::SplitFace => {}
|
||||
}
|
||||
enter
|
||||
}
|
||||
|
||||
fn items(inputs: &[SelInput], i: usize) -> &[Sel] {
|
||||
inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn pt(v: Vec3) -> Value {
|
||||
json!([v.x, v.y, v.z])
|
||||
}
|
||||
|
||||
fn bodies(inputs: &[SelInput], i: usize) -> Vec<String> {
|
||||
items(inputs, i).iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// A splitting or trimming tool: a plane by name, a planar face as its plane, or any other
|
||||
/// face as {body, point} (its surface extended).
|
||||
fn tool(app: &SolveApp, sel: Option<&Sel>) -> Result<(&'static str, Value), String> {
|
||||
match sel {
|
||||
Some(Sel::Plane { name }) => Ok(("plane", json!(name))),
|
||||
Some(Sel::Face { body, index, point }) => match crate::dialogs::planar_face(&app.session, body, *index) {
|
||||
Some((_, n)) => Ok(("plane", json!({"origin": pt(*point), "normal": pt(n)}))),
|
||||
None => Ok(("tool", json!({"body": body, "point": pt(*point)}))),
|
||||
},
|
||||
_ => Err("pick a plane or a face as the tool".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The command a surface dialog makes.
|
||||
pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
|
||||
let need = |i: usize, what: &str| if items(inputs, i).is_empty() { Err(format!("select {what} first")) } else { Ok(()) };
|
||||
let (cmd, mut p) = match k {
|
||||
Surf::Patch => {
|
||||
need(0, "a boundary (sketch profiles or a loop of edges)")?;
|
||||
let profiles: Vec<(u64, usize)> =
|
||||
items(inputs, 0).iter().filter_map(|x| if let Sel::Profile { sketch, index } = x { Some((*sketch, *index)) } else { None }).collect();
|
||||
if let Some((sk, _)) = profiles.first() {
|
||||
let idx: Vec<usize> = profiles.iter().filter(|(s, _)| s == sk).map(|(_, i)| *i).collect();
|
||||
("surface.patch", json!({"sketch": sk, "profiles": idx}))
|
||||
} else {
|
||||
let edges: Vec<Value> =
|
||||
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect();
|
||||
let body = items(inputs, 0).iter().find_map(|x| if let Sel::Edge { body, .. } = x { Some(body.clone()) } else { None });
|
||||
("surface.patch", json!({"edges": edges, "body": body}))
|
||||
}
|
||||
}
|
||||
Surf::Stitch { tolerance } => {
|
||||
need(0, "the surfaces to stitch")?;
|
||||
let mut p = json!({ "bodies": bodies(inputs, 0) });
|
||||
if !tolerance.trim().is_empty() {
|
||||
p["tolerance"] = json!(tolerance);
|
||||
}
|
||||
("surface.stitch", p)
|
||||
}
|
||||
Surf::Thicken { thickness, symmetric, operation } => {
|
||||
need(0, "the surfaces to thicken")?;
|
||||
(
|
||||
"surface.thicken",
|
||||
json!({"bodies": bodies(inputs, 0), "thickness": thickness, "symmetric": symmetric, "operation": OPS.get(*operation).copied().unwrap_or("new")}),
|
||||
)
|
||||
}
|
||||
Surf::Extend { distance } => {
|
||||
need(0, "the edges to extend")?;
|
||||
let body =
|
||||
items(inputs, 0).iter().find_map(|x| if let Sel::Edge { body, .. } = x { Some(body.clone()) } else { None }).unwrap_or_default();
|
||||
let edges: Vec<Value> =
|
||||
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect();
|
||||
("surface.extend", json!({"body": body, "edges": edges, "distance": distance}))
|
||||
}
|
||||
Surf::Trim => {
|
||||
need(0, "the surface to trim")?;
|
||||
need(1, "the trimming tool")?;
|
||||
need(2, "the side to keep (a point on it)")?;
|
||||
let body = bodies(inputs, 0).into_iter().next().unwrap_or_default();
|
||||
let (key, t) = tool(app, items(inputs, 1).first())?;
|
||||
let keep = items(inputs, 2)
|
||||
.first()
|
||||
.and_then(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None })
|
||||
.ok_or("pick the side to keep")?;
|
||||
let mut p = json!({"body": body, "keep": keep});
|
||||
p[key] = t;
|
||||
("surface.trim", p)
|
||||
}
|
||||
Surf::SplitFace => {
|
||||
need(0, "the faces to split")?;
|
||||
need(1, "the splitting tool")?;
|
||||
let faces: Vec<Value> =
|
||||
items(inputs, 0).iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect();
|
||||
let body = items(inputs, 0).iter().find_map(|x| if let Sel::Face { body, .. } = x { Some(body.clone()) } else { None });
|
||||
let (key, t) = tool(app, items(inputs, 1).first())?;
|
||||
let mut p = json!({"faces": faces, "body": body});
|
||||
p[key] = t;
|
||||
("solid.split_face", p)
|
||||
}
|
||||
};
|
||||
if let Value::Object(m) = &mut p {
|
||||
for (k, v) in extra {
|
||||
m.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
}
|
||||
Ok(vec![(cmd.to_string(), p)])
|
||||
}
|
||||
|
||||
/// A tool as a selection again: a plane, else the face at the stored point.
|
||||
fn tool_sel(app: &SolveApp, plane: &PlaneRef, tool: Option<&FaceAt>) -> Option<Sel> {
|
||||
match tool {
|
||||
Some(t) => crate::dialogs::face_sel(&app.session, t.point),
|
||||
None => crate::dialogs::plane_sel(&app.session, plane),
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog for editing a surface feature.
|
||||
pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelInput>, Map<String, Value>)> {
|
||||
let s = &app.session;
|
||||
let edge = |p: &Vec3| crate::dialogs::edge_sel(s, *p);
|
||||
let (k, fill): (Surf, Vec<Vec<Sel>>) = match kind {
|
||||
FeatureKind::Patch { sketch, edges, .. } => {
|
||||
let mut sels: Vec<Sel> = edges.iter().filter_map(edge).collect();
|
||||
if let Some(sk) = sketch {
|
||||
let st = s.world_state();
|
||||
if let Some(ss) = st.sketch(*sk) {
|
||||
sels.extend((0..ss.profiles.len()).map(|index| Sel::Profile { sketch: *sk, index }));
|
||||
}
|
||||
}
|
||||
(Surf::Patch, vec![sels])
|
||||
}
|
||||
FeatureKind::Stitch { bodies, tolerance } => (
|
||||
Surf::Stitch { tolerance: if tolerance.trim() == "0" { String::new() } else { tolerance.clone() } },
|
||||
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
|
||||
),
|
||||
FeatureKind::Thicken { bodies, thickness, symmetric, operation, .. } => (
|
||||
Surf::Thicken { thickness: thickness.clone(), symmetric: *symmetric, operation: op_index(operation) },
|
||||
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
|
||||
),
|
||||
FeatureKind::SurfaceExtend { edges, distance, .. } => {
|
||||
(Surf::Extend { distance: distance.clone() }, vec![edges.iter().filter_map(edge).collect()])
|
||||
}
|
||||
FeatureKind::SurfaceTrim { body, plane, tool, keep } => (
|
||||
Surf::Trim,
|
||||
vec![
|
||||
vec![Sel::Body { name: body.clone() }],
|
||||
tool_sel(app, plane, tool.as_ref()).into_iter().collect(),
|
||||
crate::dialogs::face_sel(s, *keep).into_iter().collect(),
|
||||
],
|
||||
),
|
||||
FeatureKind::SplitFace { faces, plane, tool, .. } => (
|
||||
Surf::SplitFace,
|
||||
vec![faces.iter().filter_map(|p| crate::dialogs::face_sel(s, *p)).collect(), tool_sel(app, plane, tool.as_ref()).into_iter().collect()],
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
let mut ins = inputs(&k);
|
||||
for (inp, items) in ins.iter_mut().zip(fill) {
|
||||
inp.items = items;
|
||||
}
|
||||
Some((Kind::Surface(k), ins, Map::new()))
|
||||
}
|
||||
+168
-20
@@ -1,7 +1,8 @@
|
||||
//! Several open designs, as tabs in the application bar. The active design lives in the app as
|
||||
//! always (`app.session`, `app.cam`…); the others wait here with their own session (undo,
|
||||
//! selection), camera and per-design view state, and are swapped in when their tab is picked.
|
||||
//! Closing a design with unsaved changes asks first.
|
||||
//! Closing a design with unsaved changes asks first, and so does quitting: one "save changes?"
|
||||
//! per design with unsaved changes ([`request_quit`]).
|
||||
|
||||
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
@@ -48,6 +49,8 @@ pub struct Documents {
|
||||
pub active: usize,
|
||||
/// A tab waiting for the answer to "save changes?".
|
||||
pub closing: Option<usize>,
|
||||
/// Quitting: the tabs answered so far with Don't Save (`None`: not quitting).
|
||||
pub quitting: Option<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Documents {
|
||||
@@ -214,6 +217,44 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Quit (the title bar's ×, File ▸ Quit, the window being closed): asks "save changes?" for each
|
||||
/// design with unsaved changes, then sets `app.quit_requested` for the host to close the window.
|
||||
/// Cancel, or a Save that didn't save, stays open.
|
||||
pub fn request_quit(app: &mut SolveApp) {
|
||||
if app.docs.quitting.is_none() {
|
||||
app.docs.quitting = Some(Vec::new());
|
||||
}
|
||||
quit_step(app);
|
||||
}
|
||||
|
||||
/// Ask about the next design with unsaved changes, or quit when none is left.
|
||||
fn quit_step(app: &mut SolveApp) {
|
||||
let Some(skip) = app.docs.quitting.clone() else { return };
|
||||
if app.docs.closing.is_some() {
|
||||
return;
|
||||
}
|
||||
match (0..app.docs.count()).find(|i| !skip.contains(i) && dirty(app, *i)) {
|
||||
Some(i) => {
|
||||
switch(app, i);
|
||||
app.docs.closing = Some(i);
|
||||
}
|
||||
None => {
|
||||
// Don't Save means discard: no recovery copy is kept for the next launch either.
|
||||
for i in skip {
|
||||
switch(app, i);
|
||||
app.session.mark_saved();
|
||||
}
|
||||
app.docs.quitting = None;
|
||||
app.quit_requested = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is any open design unsaved (the web page asks before it is left)?
|
||||
pub fn any_dirty(app: &SolveApp) -> bool {
|
||||
(0..app.docs.count()).any(|i| dirty(app, i))
|
||||
}
|
||||
|
||||
fn dirty(app: &SolveApp, i: usize) -> bool {
|
||||
if i == app.docs.active || app.docs.slots.is_empty() {
|
||||
return app.session.is_dirty();
|
||||
@@ -236,18 +277,28 @@ pub fn tabs_info(app: &SolveApp) -> Vec<(String, bool)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The control channel's `ui.documents`: list, `new`, `switch` or `close` (by index; `force`
|
||||
/// closes without asking).
|
||||
/// The control channel's `ui.documents`: list, `new`, `switch`, `close` (by index; `force`
|
||||
/// closes without asking) or `quit`; `answer`: save | dont_save | cancel answers "save changes?".
|
||||
pub fn control(app: &mut SolveApp, p: &Value) -> Value {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(|x| x as usize);
|
||||
match p.get("action").and_then(Value::as_str) {
|
||||
Some("new") => new_design(app),
|
||||
Some("switch") => switch(app, i.unwrap_or(app.docs.active)),
|
||||
Some("close") => close(app, i.unwrap_or(app.docs.active), p.get("force").and_then(Value::as_bool).unwrap_or(false)),
|
||||
Some("quit") => request_quit(app),
|
||||
_ => {}
|
||||
}
|
||||
let answer = match p.get("answer").and_then(Value::as_str) {
|
||||
Some("save") => Some(Answer::Save),
|
||||
Some("dont_save") => Some(Answer::DontSave),
|
||||
Some("cancel") => Some(Answer::Cancel),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(a) = answer {
|
||||
answer_prompt(app, a);
|
||||
}
|
||||
let tabs: Vec<Value> = tabs_info(app).into_iter().map(|(name, dirty)| json!({"name": name, "dirty": dirty})).collect();
|
||||
json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing})
|
||||
json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing, "quitting": app.docs.quitting.is_some()})
|
||||
}
|
||||
|
||||
/// The document tabs in the application bar, from `x0` to `x1`: click to switch, × to close,
|
||||
@@ -375,50 +426,81 @@ pub fn layout(n: usize, active: usize, room: f32) -> (usize, usize, f32) {
|
||||
(first, fit, MIN_TAB)
|
||||
}
|
||||
|
||||
/// The "save changes?" prompt for a tab being closed.
|
||||
/// What "save changes?" was answered.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Answer {
|
||||
Save,
|
||||
DontSave,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// The "save changes?" prompt for a tab being closed, or for each unsaved design when quitting.
|
||||
pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
quit_step(app);
|
||||
let Some(i) = app.docs.closing else { return };
|
||||
let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default();
|
||||
let mut answer: Option<u8> = None;
|
||||
crate::frame::window(ctx, "Save changes?", crate::frame::Width::Normal)
|
||||
let mut answer: Option<Answer> = None;
|
||||
crate::frame::window(ctx, format!("Save changes to {name}?"), crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("sc_close_prompt"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.label(format!("{name} has unsaved changes."));
|
||||
ui.label(format!("{name} has unsaved changes. If you don't save, they are lost."));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Save").clicked() {
|
||||
answer = Some(0);
|
||||
answer = Some(Answer::Save);
|
||||
}
|
||||
if ui.button("Don't Save").clicked() {
|
||||
answer = Some(1);
|
||||
answer = Some(Answer::DontSave);
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
answer = Some(2);
|
||||
answer = Some(Answer::Cancel);
|
||||
}
|
||||
});
|
||||
});
|
||||
if ctx.input(|x| x.key_pressed(egui::Key::Escape)) {
|
||||
answer = Some(2);
|
||||
answer = Some(Answer::Cancel);
|
||||
app.esc_handled = true;
|
||||
}
|
||||
if let Some(a) = answer {
|
||||
answer_prompt(app, a);
|
||||
}
|
||||
}
|
||||
|
||||
/// Act on the "save changes?" answer (also the control channel's `ui.documents {answer}`).
|
||||
pub fn answer_prompt(app: &mut SolveApp, answer: Answer) {
|
||||
let Some(i) = app.docs.closing else { return };
|
||||
match answer {
|
||||
Some(0) => {
|
||||
Answer::Save => {
|
||||
switch(app, i);
|
||||
crate::toolbar::save(app);
|
||||
// Saved (not cancelled in the file picker): close it.
|
||||
if !app.session.is_dirty() {
|
||||
let saved = !app.session.is_dirty();
|
||||
app.docs.closing = None;
|
||||
if app.docs.quitting.is_some() {
|
||||
// Quit only once it is saved; a cancelled picker or a failed save stays open.
|
||||
if !saved {
|
||||
app.docs.quitting = None;
|
||||
}
|
||||
} else if saved {
|
||||
close(app, app.docs.active, true);
|
||||
} else {
|
||||
app.docs.closing = None;
|
||||
}
|
||||
}
|
||||
Some(1) => close(app, i, true),
|
||||
Some(2) => app.docs.closing = None,
|
||||
_ => {}
|
||||
Answer::DontSave => {
|
||||
if let Some(skip) = app.docs.quitting.as_mut() {
|
||||
skip.push(i);
|
||||
app.docs.closing = None;
|
||||
} else {
|
||||
close(app, i, true);
|
||||
}
|
||||
}
|
||||
Answer::Cancel => {
|
||||
app.docs.closing = None;
|
||||
app.docs.quitting = None;
|
||||
}
|
||||
}
|
||||
quit_step(app);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -473,6 +555,72 @@ mod tests {
|
||||
assert!(active_is_blank(&a) && a.home.open);
|
||||
}
|
||||
|
||||
/// Quitting asks once per design with unsaved changes: Don't Save moves on (and keeps no
|
||||
/// recovery copy), Save saves and moves on, and the app quits after the last answer.
|
||||
#[test]
|
||||
fn quit_asks_about_each_unsaved_design_then_quits() {
|
||||
let mut a = app();
|
||||
request_quit(&mut a);
|
||||
assert!(a.quit_requested, "nothing unsaved: quits at once");
|
||||
|
||||
let mut a = app();
|
||||
let file = std::env::temp_dir().join(format!("solvecraft-quit-save-{}.solvecraft", std::process::id()));
|
||||
let _ = std::fs::remove_file(&file);
|
||||
a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
a.run("file.save_as", json!({"path": file.to_string_lossy()})).unwrap();
|
||||
a.run("solid.sphere", json!({"diameter": 4})).unwrap();
|
||||
new_design(&mut a);
|
||||
a.run("solid.sphere", json!({"diameter": 8})).unwrap();
|
||||
assert!(dirty(&a, 0) && dirty(&a, 1));
|
||||
|
||||
request_quit(&mut a);
|
||||
assert!(!a.quit_requested);
|
||||
assert_eq!(a.docs.closing, Some(0), "the first unsaved design is asked about first");
|
||||
assert_eq!(a.docs.active, 0, "and shown");
|
||||
answer_prompt(&mut a, Answer::Save);
|
||||
assert!(!dirty(&a, 0), "saved");
|
||||
assert_eq!(
|
||||
std::fs::read(&file).ok().and_then(|b| solvecraft_engine::io::read_design(&b).ok()).map(|d| d.features.len()),
|
||||
Some(2),
|
||||
"the file holds the change"
|
||||
);
|
||||
assert_eq!(a.docs.closing, Some(1), "then the next one");
|
||||
assert!(!a.quit_requested);
|
||||
answer_prompt(&mut a, Answer::DontSave);
|
||||
assert_eq!(a.docs.closing, None);
|
||||
assert!(a.quit_requested, "every design answered: quit");
|
||||
assert!(!any_dirty(&a), "Don't Save discards: nothing is kept for recovery");
|
||||
let _ = std::fs::remove_file(&file);
|
||||
}
|
||||
|
||||
/// Cancel, Esc's answer, or a Save that saves nothing (a never-saved design whose Save As
|
||||
/// picker is cancelled) stay open with the changes.
|
||||
#[test]
|
||||
fn quit_cancelled_or_unsaved_stays_open() {
|
||||
let mut a = app();
|
||||
a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
request_quit(&mut a);
|
||||
assert_eq!(a.docs.closing, Some(0));
|
||||
answer_prompt(&mut a, Answer::Cancel);
|
||||
assert!(!a.quit_requested && a.docs.closing.is_none() && a.docs.quitting.is_none());
|
||||
assert!(a.session.is_dirty(), "the changes are still there");
|
||||
|
||||
// No Save As picker answer (the default services have none): not saved, so no quit.
|
||||
request_quit(&mut a);
|
||||
answer_prompt(&mut a, Answer::Save);
|
||||
assert!(!a.quit_requested && a.docs.quitting.is_none() && a.session.is_dirty());
|
||||
|
||||
// A picked file: saved, then quit.
|
||||
let file = std::env::temp_dir().join(format!("solvecraft-quit-saveas-{}.solvecraft", std::process::id()));
|
||||
let _ = std::fs::remove_file(&file);
|
||||
let to = file.to_string_lossy().to_string();
|
||||
a.services.pick_save = Some(Box::new(move |_: &str, _: &[&str]| Some(to.clone())));
|
||||
request_quit(&mut a);
|
||||
answer_prompt(&mut a, Answer::Save);
|
||||
assert!(a.quit_requested && file.exists());
|
||||
let _ = std::fs::remove_file(&file);
|
||||
}
|
||||
|
||||
/// A design built elsewhere replaces a blank one and gets a tab otherwise.
|
||||
#[test]
|
||||
fn adopt_reuses_a_blank_design() {
|
||||
|
||||
@@ -10,7 +10,7 @@ use egui::{Color32, Pos2, Rect, Sense, Shape, Stroke, vec2};
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::viewport::Proj;
|
||||
use crate::viewport::{Proj, projected_polygon};
|
||||
|
||||
/// A part of the triad.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -264,7 +264,7 @@ pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::I
|
||||
}
|
||||
for (i, q) in &sh.planes {
|
||||
let c = col(Handle::Plane(*i), *i);
|
||||
painter.add(Shape::convex_polygon(q.clone(), c.gamma_multiply(if hot(Handle::Plane(*i)) { 0.7 } else { 0.35 }), Stroke::new(1.0, c)));
|
||||
painter.extend(projected_polygon(q, c.gamma_multiply(if hot(Handle::Plane(*i)) { 0.7 } else { 0.35 }), Stroke::new(1.0, c)));
|
||||
}
|
||||
for (i, a, b) in &sh.arrows {
|
||||
arrow(painter, *a, *b, col(Handle::Axis(*i), *i), hot(Handle::Axis(*i)));
|
||||
@@ -413,6 +413,27 @@ pub fn axis_angle(m: &[[f64; 3]; 3]) -> (Vec3, f64) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plane_squares_stay_within_the_triad() {
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let t = Triad { center: Vec3::ZERO, translate: true, rotate: false };
|
||||
// The squares reach 0.45 √2 of the arrow length from the centre, plus stroke and feathering.
|
||||
let limit = (0.45 * 2f64.sqrt() * LEN_PX) as f32 + 3.0;
|
||||
let mut worst = 0.0f32;
|
||||
for dir in crate::viewport::orbit_directions() {
|
||||
let cam = solvecraft_engine::render::Camera::default().looking_from(dir);
|
||||
let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect };
|
||||
let l = mm_per_px(&proj, t.center).unwrap() * LEN_PX;
|
||||
let sh = shapes(&proj, &t, l).unwrap();
|
||||
for (_, q) in &sh.planes {
|
||||
for p in crate::viewport::drawn_points(projected_polygon(q, Color32::GRAY, Stroke::new(1.0, Color32::BLACK))) {
|
||||
worst = worst.max(p.distance(sh.ball));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(worst <= limit, "a vertex {worst} px from the centre (limit {limit})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotations_compose_to_one_axis_angle() {
|
||||
let r = mul(&rotation(Vec3::Z, 0.7), &rotation(Vec3::X, 0.4));
|
||||
|
||||
+170
-70
@@ -173,6 +173,9 @@ struct Batches {
|
||||
tris: Option<Batch>,
|
||||
trans: Option<Batch>,
|
||||
glass: Option<Batch>,
|
||||
/// The glass triangles as built, and the view direction they were last sorted for.
|
||||
glass_cpu: Vec<u8>,
|
||||
glass_sorted_for: Option<[f32; 3]>,
|
||||
lines: Option<Batch>,
|
||||
hidden: Option<Batch>,
|
||||
overlays: Option<Batch>,
|
||||
@@ -189,7 +192,9 @@ impl Batches {
|
||||
if let Some(sc) = slot.lock().ok().and_then(|mut s| s.take()) {
|
||||
self.tris = upload(device, "sc_tris", &sc.tris, TRI_SIZE);
|
||||
self.trans = upload(device, "sc_trans", &sc.trans, TRI_SIZE);
|
||||
self.glass = upload(device, "sc_glass", &sc.glass, TRI_SIZE);
|
||||
self.glass = upload_with(device, "sc_glass", &sc.glass, TRI_SIZE, wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST);
|
||||
self.glass_cpu = sc.glass;
|
||||
self.glass_sorted_for = None;
|
||||
self.lines = upload(device, "sc_lines", &sc.lines, LINE_SIZE);
|
||||
self.hidden = upload(device, "sc_hidden", &sc.hidden, LINE_SIZE);
|
||||
self.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE);
|
||||
@@ -214,8 +219,6 @@ struct Resources {
|
||||
line: wgpu::RenderPipeline,
|
||||
/// Lines behind the model (depth test >).
|
||||
line_hidden: wgpu::RenderPipeline,
|
||||
/// Silhouettes: back faces pushed out a little on screen, drawn dark behind the model.
|
||||
outline: wgpu::RenderPipeline,
|
||||
overlay: wgpu::RenderPipeline,
|
||||
uniform: wgpu::Buffer,
|
||||
bind: wgpu::BindGroup,
|
||||
@@ -271,7 +274,7 @@ const SHADER: &str = r#"
|
||||
struct U {
|
||||
vp: mat4x4<f32>,
|
||||
back: vec4<f32>, // xyz = toward the eye, w = 1 for linear output
|
||||
screen: vec4<f32>, // viewport width, height (px), depth bias
|
||||
screen: vec4<f32>, // viewport width, height (px), depth bias, silhouettes on (1)
|
||||
clip: vec4<f32>, // section plane: normal, d (zero normal: no section)
|
||||
cap: vec4<f32>, // colour of the inside of cut bodies
|
||||
ana: vec4<f32>, // surface analysis: mode (1 zebra, 2 draft, 3 curvature), parameter
|
||||
@@ -318,18 +321,41 @@ fn vs_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c:
|
||||
/// Opaque model surfaces: cut by the section plane, the inside shows as a flat cap colour.
|
||||
@fragment
|
||||
fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
// Derivatives first, in uniform control flow (browsers' WGSL compilers reject them after
|
||||
// a branch that returns; the whole shader module then fails and nothing is drawn).
|
||||
let k = curvature(i);
|
||||
let ndv = dot(normalize(i.n), normalize(u.back.xyz));
|
||||
let fw = fwidth(ndv);
|
||||
if (clipped(i.wp)) { discard; }
|
||||
if (!front && length(u.clip.xyz) > 0.5) {
|
||||
return out_color(u.cap);
|
||||
// The cut face: the cap colour, hatched at 45 degrees on screen.
|
||||
let h = fract((i.pos.x + i.pos.y) / 9.0);
|
||||
return out_color(vec4<f32>(select(u.cap.rgb, u.cap.rgb * 0.62, h < 0.22), u.cap.a));
|
||||
}
|
||||
if (u.ana.x > 0.5 && length(i.n) > 0.5) {
|
||||
return analysis(i);
|
||||
return analysis(i, k);
|
||||
}
|
||||
return shade(i);
|
||||
let c = shade(i);
|
||||
// Silhouettes (styles with edges): where a curved surface turns away from the eye, a line
|
||||
// about 1.5 px wide, on the visible surface only (depth-correct).
|
||||
if (u.screen.w > 0.5 && length(i.n) > 0.5 && fw > 1e-6) {
|
||||
let a = 1.0 - smoothstep(fw * 0.8, fw * 2.0, abs(ndv));
|
||||
return vec4<f32>(mix(c.rgb, out_color(vec4<f32>(0.07, 0.08, 0.10, 1.0)).rgb, a), c.a);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
/// Surface analysis colours: zebra stripes, draft angle bands, curvature map.
|
||||
fn analysis(i: TOut) -> vec4<f32> {
|
||||
/// How fast the normal turns across the pixel (per mm).
|
||||
fn curvature(i: TOut) -> f32 {
|
||||
let nn = normalize(i.n);
|
||||
let kx = length(dpdx(nn)) / max(length(dpdx(i.wp)), 1e-6);
|
||||
let ky = length(dpdy(nn)) / max(length(dpdy(i.wp)), 1e-6);
|
||||
return max(kx, ky);
|
||||
}
|
||||
|
||||
/// Surface analysis colours: zebra stripes, draft angle bands, curvature map (`k`: the
|
||||
/// surface's curvature here, worked out by the caller).
|
||||
fn analysis(i: TOut, k: f32) -> vec4<f32> {
|
||||
let v = normalize(u.back.xyz);
|
||||
var n = normalize(i.n);
|
||||
if (dot(n, v) < 0.0) { n = -n; }
|
||||
@@ -385,10 +411,7 @@ fn analysis(i: TOut) -> vec4<f32> {
|
||||
return out_color(vec4<f32>(c * lit, 1.0));
|
||||
}
|
||||
// Curvature from how fast the normal turns across the pixel.
|
||||
let nn = normalize(i.n);
|
||||
let kx = length(dpdx(nn)) / max(length(dpdx(i.wp)), 1e-6);
|
||||
let ky = length(dpdy(nn)) / max(length(dpdy(i.wp)), 1e-6);
|
||||
let t = clamp(max(kx, ky) * u.ana.y, 0.0, 1.0);
|
||||
let t = clamp(k * u.ana.y, 0.0, 1.0);
|
||||
let c = select(mix(vec3<f32>(0.10, 0.80, 0.25), vec3<f32>(0.90, 0.15, 0.10), t * 2.0 - 1.0),
|
||||
mix(vec3<f32>(0.15, 0.35, 0.95), vec3<f32>(0.10, 0.80, 0.25), t * 2.0), t < 0.5);
|
||||
return out_color(vec4<f32>(c * lit, 1.0));
|
||||
@@ -465,28 +488,6 @@ fn fs_line(i: LOut) -> @location(0) vec4<f32> {
|
||||
return out_color(i.c);
|
||||
}
|
||||
|
||||
/// Silhouettes: back faces pushed out along their screen-space normal by about 1.5 px.
|
||||
@vertex
|
||||
fn vs_outline(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec4<f32>) -> TOut {
|
||||
var o: TOut;
|
||||
o.pos = u.vp * vec4<f32>(p, 1.0);
|
||||
let nc = (u.vp * vec4<f32>(n, 0.0)).xy * u.screen.xy;
|
||||
if (length(nc) > 1e-6) {
|
||||
let d = normalize(nc) * 1.5 * 2.0 / u.screen.xy;
|
||||
o.pos = vec4<f32>(o.pos.xy + d * o.pos.w, o.pos.zw);
|
||||
}
|
||||
o.n = vec3<f32>(0.0);
|
||||
o.c = c;
|
||||
o.wp = p;
|
||||
return o;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_outline(i: TOut) -> @location(0) vec4<f32> {
|
||||
if (clipped(i.wp)) { discard; }
|
||||
return out_color(vec4<f32>(0.07, 0.08, 0.10, 1.0));
|
||||
}
|
||||
|
||||
struct IOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@@ -511,8 +512,8 @@ fn vs_img(@location(0) p: vec3<f32>, @location(1) uv: vec2<f32>, @location(2) a:
|
||||
|
||||
@fragment
|
||||
fn fs_img(i: IOut) -> @location(0) vec4<f32> {
|
||||
if (clipped(i.wp)) { discard; }
|
||||
let c = textureSample(img_t, img_s, i.uv);
|
||||
if (clipped(i.wp)) { discard; }
|
||||
return out_color(vec4<f32>(c.rgb, c.a * i.a));
|
||||
}
|
||||
|
||||
@@ -742,31 +743,6 @@ impl Resources {
|
||||
xray: pipeline("sc_xray", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Always, false), alpha),
|
||||
line: pipeline("sc_lines", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
|
||||
line_hidden: pipeline("sc_lines_hidden", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::Greater, false), alpha),
|
||||
outline: device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("sc_outline"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_outline"),
|
||||
buffers: &[Some(tri_layout())],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
cull_mode: Some(wgpu::Face::Front),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: depth(wgpu::CompareFunction::Less, false),
|
||||
multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false },
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_outline"),
|
||||
targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: alpha, write_mask: wgpu::ColorWrites::ALL })],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
}),
|
||||
overlay: pipeline("sc_overlay", "vs_line", "fs_line_all", line_layout(), depth(wgpu::CompareFunction::Always, false), alpha),
|
||||
uniform,
|
||||
bind,
|
||||
@@ -779,19 +755,44 @@ impl Resources {
|
||||
}
|
||||
|
||||
fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize) -> Option<Batch> {
|
||||
upload_with(device, label, bytes, stride, wgpu::BufferUsages::VERTEX)
|
||||
}
|
||||
|
||||
fn upload_with(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize, usage: wgpu::BufferUsages) -> Option<Batch> {
|
||||
if bytes.len() < stride {
|
||||
return None;
|
||||
}
|
||||
Some(Batch {
|
||||
buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents: bytes,
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
}),
|
||||
buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some(label), contents: bytes, usage }),
|
||||
count: u32::try_from(bytes.len() / stride).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Triangles (3 vertices of `TRI_SIZE` bytes each, position first) ordered far to near along
|
||||
/// the view direction `back` (toward the eye), so blended glass draws back to front.
|
||||
pub fn sort_back_to_front(bytes: &[u8], back: [f32; 3]) -> Vec<u8> {
|
||||
let tri = TRI_SIZE * 3;
|
||||
let depth = |t: &[u8]| -> f32 {
|
||||
let f = |o: usize| t.get(o..o + 4).and_then(|b| b.try_into().ok()).map_or(0.0, f32::from_le_bytes);
|
||||
(0..3).map(|v| (0..3).map(|k| f(v * TRI_SIZE + k * 4) * back[k]).sum::<f32>()).sum::<f32>()
|
||||
};
|
||||
let mut tris: Vec<(f32, &[u8])> = bytes.chunks_exact(tri).map(|t| (depth(t), t)).collect();
|
||||
tris.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
tris.into_iter().flat_map(|(_, t)| t.iter().copied()).collect()
|
||||
}
|
||||
|
||||
impl Batches {
|
||||
/// Re-sort the glass for a new view direction (when it turned more than about 2 degrees).
|
||||
fn sort_glass(&mut self, queue: &wgpu::Queue, back: [f32; 3]) {
|
||||
let (Some(g), false) = (&self.glass, self.glass_cpu.is_empty()) else { return };
|
||||
let turned = self.glass_sorted_for.is_none_or(|s| s[0] * back[0] + s[1] * back[1] + s[2] * back[2] < 0.9994);
|
||||
if turned {
|
||||
queue.write_buffer(&g.buffer, 0, &sort_back_to_front(&self.glass_cpu, back));
|
||||
self.glass_sorted_for = Some(back);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the pipelines. Call once with the app's render state and its depth/MSAA settings.
|
||||
pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32) -> GpuTarget {
|
||||
let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, 0), samples };
|
||||
@@ -813,6 +814,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
res.model.take(device, self.key, &self.slot);
|
||||
res.highlight.take(device, self.hl_key, &self.hl_slot);
|
||||
res.preview.take(device, self.pv_key, &self.pv_slot);
|
||||
res.model.sort_glass(queue, self.back);
|
||||
queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out));
|
||||
if let Some(a) = &self.access
|
||||
&& a.version != res.access_version
|
||||
@@ -900,11 +902,9 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
tris(pass, &res.tri, &m.tris);
|
||||
tris(pass, &res.tri, &pv.tris);
|
||||
tris(pass, &res.tri_hl, &h.tris);
|
||||
if self.outline {
|
||||
tris(pass, &res.outline, &m.tris);
|
||||
}
|
||||
lines(pass, &res.line, &m.lines);
|
||||
lines(pass, &res.line_hidden, &m.hidden);
|
||||
lines(pass, &res.line_hidden, &h.hidden);
|
||||
lines(pass, &res.line, &pv.lines);
|
||||
lines(pass, &res.line, &h.lines);
|
||||
for (id, q) in &res.quads {
|
||||
@@ -930,7 +930,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec<u8> {
|
||||
let mut v: Vec<f32> = cb.view_proj.iter().flatten().copied().collect();
|
||||
v.extend([cb.back[0], cb.back[1], cb.back[2], if linear { 1.0 } else { 0.0 }]);
|
||||
v.extend([w.max(1.0), h.max(1.0), 2e-4, 0.0]);
|
||||
v.extend([w.max(1.0), h.max(1.0), 2e-4, if cb.outline { 1.0 } else { 0.0 }]);
|
||||
v.extend(cb.clip);
|
||||
v.extend(cb.cap);
|
||||
v.extend(cb.analysis);
|
||||
@@ -954,4 +954,104 @@ mod tests {
|
||||
let module = naga::front::wgsl::parse_str(super::SHADER).unwrap_or_else(|e| panic!("{}", e.emit_to_string(super::SHADER)));
|
||||
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap();
|
||||
}
|
||||
|
||||
/// Derivatives and implicit-lod sampling run only in uniform control flow: browsers' WGSL
|
||||
/// compilers (Tint) reject them after a branch, and the whole viewport shader then fails,
|
||||
/// a black screen on the web (naga accepts it). Every fragment entry point makes such calls
|
||||
/// (directly or through a helper) before its first branch or return.
|
||||
#[test]
|
||||
fn derivatives_only_in_uniform_control_flow() {
|
||||
// Without comments (their words are not code).
|
||||
let src: String = super::SHADER.lines().map(|l| l.split("//").next().unwrap_or("")).collect::<Vec<_>>().join("\n");
|
||||
let src = src.as_str();
|
||||
let derivs = ["dpdx(", "dpdy(", "fwidth(", "textureSample("];
|
||||
// Functions: name -> body.
|
||||
let mut fns: Vec<(String, String, bool)> = Vec::new();
|
||||
let mut rest = src;
|
||||
let mut fragment = false;
|
||||
while let Some(at) = rest.find("fn ") {
|
||||
let before = &rest[..at];
|
||||
if before.contains("@fragment") {
|
||||
fragment = true;
|
||||
}
|
||||
let Some(open) = rest[at..].find('{') else { break };
|
||||
let name = rest[at + 3..].split('(').next().unwrap_or("").trim().to_string();
|
||||
let start = at + open;
|
||||
let mut depth = 0;
|
||||
let mut end = start;
|
||||
for (k, ch) in rest[start..].char_indices() {
|
||||
match ch {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = start + k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fns.push((name, rest[start..=end].to_string(), fragment));
|
||||
fragment = false;
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
// Helpers that take derivatives (directly or through another helper).
|
||||
let mut uses: Vec<String> = Vec::new();
|
||||
for _ in 0..8 {
|
||||
for (n, body, _) in &fns {
|
||||
if !uses.contains(n) && (derivs.iter().any(|d| body.contains(d)) || uses.iter().any(|u| body.contains(&format!("{u}(")))) {
|
||||
uses.push(n.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (n, body, frag) in &fns {
|
||||
let calls: Vec<usize> = derivs
|
||||
.iter()
|
||||
.map(|d| d.to_string())
|
||||
.chain(uses.iter().map(|u| format!("{u}(")))
|
||||
.flat_map(|c| body.match_indices(&c).map(|(i, _)| i).collect::<Vec<_>>())
|
||||
.collect();
|
||||
if calls.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !*frag {
|
||||
// A helper: its own derivatives must come before any branch or return in it.
|
||||
let first = ["if (", "return", "discard"].iter().filter_map(|k| body.find(k)).min().unwrap_or(usize::MAX);
|
||||
for d in derivs {
|
||||
if let Some(i) = body.find(d) {
|
||||
assert!(i < first, "{n}: {d} after a branch");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let first = ["if (", "return", "discard"].iter().filter_map(|k| body.find(k)).min().unwrap_or(usize::MAX);
|
||||
for c in calls {
|
||||
assert!(c < first, "fragment {n}: a derivative after a branch or return: {}", &body[c..(c + 40).min(body.len())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod glass_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn glass_is_drawn_far_to_near() {
|
||||
// Two triangles at z = 5 and z = -5, given near first; looking down from +Z.
|
||||
let mut sc = GpuScene::default();
|
||||
for z in [5.0f32, -5.0] {
|
||||
for p in [[0.0, 0.0, z], [1.0, 0.0, z], [0.0, 1.0, z]] {
|
||||
sc.glass_tri(p, [0.0, 0.0, 1.0], [255, 0, 0, 100]);
|
||||
}
|
||||
}
|
||||
let sorted = sort_back_to_front(&sc.glass, [0.0, 0.0, 1.0]);
|
||||
let z0 = f32::from_le_bytes(sorted[8..12].try_into().unwrap());
|
||||
assert_eq!(z0, -5.0, "the far triangle first");
|
||||
assert_eq!(sorted.len(), sc.glass.len());
|
||||
// Seen from below, the order flips.
|
||||
let sorted = sort_back_to_front(&sc.glass, [0.0, 0.0, -1.0]);
|
||||
assert_eq!(f32::from_le_bytes(sorted[8..12].try_into().unwrap()), 5.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@ pub const BUILD_DATE: &str = env!("SOLVECRAFT_BUILD_DATE");
|
||||
/// The user and developer guide (docs/book, published by .github/workflows/pages.yml).
|
||||
pub const DOCS_URL: &str = "https://bherbruck.github.io/solvecraft/";
|
||||
const ATTRIBUTION: &str = include_str!("../../../ATTRIBUTION.md");
|
||||
const LICENSE_MIT: &str = include_str!("../../../LICENSE-MIT");
|
||||
const LICENSE_APACHE: &str = include_str!("../../../LICENSE-APACHE");
|
||||
use solvecraft_engine::licences::{LICENSE_APACHE, LICENSE_MIT, NOTICE, THIRD_PARTY};
|
||||
/// Fonts: what this build carries.
|
||||
pub const FONTS: &str = "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and \
|
||||
the MIT/Bitstream Vera licence; Noto Emoji and the emoji icon font, under the OFL and MIT). Sketch text uses Liberation Sans \
|
||||
@@ -112,17 +111,28 @@ fn about(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
});
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::CollapsingHeader::new("Attributions (ATTRIBUTION.md)").default_open(true).show(ui, |ui| {
|
||||
egui::CollapsingHeader::new("Attributions (ATTRIBUTION.md)").show(ui, |ui| {
|
||||
ui.label(RichText::new(ATTRIBUTION).monospace().size(11.0));
|
||||
});
|
||||
egui::CollapsingHeader::new("Fonts").show(ui, |ui| {
|
||||
ui.label(FONTS);
|
||||
});
|
||||
egui::CollapsingHeader::new("MIT licence").show(ui, |ui| {
|
||||
ui.label(RichText::new(LICENSE_MIT).monospace().size(11.0));
|
||||
});
|
||||
egui::CollapsingHeader::new("Apache licence 2.0").show(ui, |ui| {
|
||||
ui.label(RichText::new(LICENSE_APACHE).monospace().size(11.0));
|
||||
egui::CollapsingHeader::new("Licences").show(ui, |ui| {
|
||||
egui::CollapsingHeader::new("NOTICE").show(ui, |ui| {
|
||||
ui.label(RichText::new(NOTICE).monospace().size(11.0));
|
||||
});
|
||||
egui::CollapsingHeader::new("MIT licence").show(ui, |ui| {
|
||||
ui.label(RichText::new(LICENSE_MIT).monospace().size(11.0));
|
||||
});
|
||||
egui::CollapsingHeader::new("Apache licence 2.0").show(ui, |ui| {
|
||||
ui.label(RichText::new(LICENSE_APACHE).monospace().size(11.0));
|
||||
});
|
||||
egui::CollapsingHeader::new("Third-party licences").show(ui, |ui| {
|
||||
if ui.button("Copy all licences").clicked() {
|
||||
ctx.copy_text(solvecraft_engine::licences::all());
|
||||
}
|
||||
third_party(ui);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -131,6 +141,19 @@ fn about(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// THIRD-PARTY-LICENSES.txt (over 10 000 lines): only the lines in view are laid out.
|
||||
fn third_party(ui: &mut egui::Ui) {
|
||||
let lines: Vec<&str> = THIRD_PARTY.lines().collect();
|
||||
let font = egui::FontId::monospace(11.0);
|
||||
let row_h = ui.fonts_mut(|f| f.row_height(&font));
|
||||
egui::ScrollArea::both().id_salt("sc_third_party").max_height(360.0).show_rows(ui, row_h, lines.len(), |ui, rows| {
|
||||
for line in lines.get(rows).unwrap_or_default() {
|
||||
// One row per line (show_rows needs equal rows): long lines scroll sideways.
|
||||
ui.add(egui::Label::new(RichText::new(*line).font(font.clone())).extend());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -768,6 +768,25 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
pen.line(&[(15.0, 15.0), (19.0, 19.0)], a);
|
||||
pen.line(&[(19.0, 15.0), (15.0, 19.0)], a);
|
||||
}
|
||||
"patch" => {
|
||||
// A wire loop with a surface spanned across it.
|
||||
pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], Color32::from_rgb(170, 200, 240), ink);
|
||||
pen.line(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0), (4.0, 16.0)], a);
|
||||
}
|
||||
"stitch" => {
|
||||
// Two surface patches sewn along their shared edge.
|
||||
pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], Color32::from_rgb(170, 200, 240), ink);
|
||||
pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], Color32::from_rgb(150, 185, 235), ink);
|
||||
for y in [6.0, 9.0, 12.0] {
|
||||
pen.line(&[(10.0, y), (14.0, y + 1.5)], a);
|
||||
}
|
||||
}
|
||||
"thicken" => {
|
||||
// A curved sheet given thickness: its offset copy and the side walls.
|
||||
pen.arc(12.0, 20.0, 11.0, 3.6, 5.8, ink);
|
||||
pen.arc(12.0, 20.0, 7.0, 3.6, 5.8, a);
|
||||
pen.arrow((12.0, 9.0), (12.0, 13.5), a);
|
||||
}
|
||||
"extend" => {
|
||||
pen.line(&[(20.0, 3.0), (20.0, 21.0)], ink);
|
||||
pen.line(&[(3.0, 12.0), (10.0, 12.0)], ink);
|
||||
|
||||
@@ -13,13 +13,14 @@ use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Keys the application handles before any command shortcut.
|
||||
pub const RESERVED: &[&str] = &["Escape", "S", "V", "F2", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O"];
|
||||
pub const RESERVED: &[&str] =
|
||||
&["Escape", "S", "V", "F2", "F6", "Delete", "Backspace", "Ctrl+Z", "Ctrl+Y", "Ctrl+S", "Ctrl+N", "Ctrl+O", "Ctrl+Comma"];
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Keymap {
|
||||
/// Command id → key (`""`: no key). Only the changed ones.
|
||||
pub custom: BTreeMap<String, String>,
|
||||
/// The Keyboard Shortcuts window.
|
||||
/// Asks for the Keyboard Shortcuts page of Preferences (File and Help menus).
|
||||
pub open: bool,
|
||||
/// Opened from Help: a list to read, not to edit.
|
||||
pub read_only: bool,
|
||||
@@ -113,11 +114,10 @@ pub fn reset(app: &mut SolveApp, id: Option<&str>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Keyboard Shortcuts window.
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if !app.keymap.open {
|
||||
return;
|
||||
}
|
||||
/// The Keyboard Shortcuts page of Preferences: search, rebind (click, then press the key), clear,
|
||||
/// reset; a key another command uses is reported and taken over only on request.
|
||||
pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
// A key press while waiting for one binds it (Esc cancels).
|
||||
if let Some(id) = app.keymap.capturing.clone() {
|
||||
let press = ctx.input(|i| {
|
||||
@@ -142,18 +142,20 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let mut open = true;
|
||||
let mut action: Option<(String, &str)> = None;
|
||||
crate::frame::window(ctx, "Keyboard Shortcuts", crate::frame::Width::Wide)
|
||||
.id(egui::Id::new("sc_keymap"))
|
||||
.open(&mut open)
|
||||
.default_size([520.0, 480.0])
|
||||
.show(ctx, |ui| {
|
||||
{
|
||||
{
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut app.keymap.filter).hint_text("Filter commands or keys").desired_width(300.0));
|
||||
if !app.keymap.read_only && ui.button("Reset All").clicked() {
|
||||
action = Some((String::new(), "reset_all"));
|
||||
}
|
||||
if ui.button("Export…").on_hover_text("Keys, S box favourites and the toolbar, as a file").clicked() {
|
||||
action = Some((String::new(), "export"));
|
||||
}
|
||||
if !app.keymap.read_only && ui.button("Import…").clicked() {
|
||||
action = Some((String::new(), "import"));
|
||||
}
|
||||
});
|
||||
if let Some(m) = &app.keymap.message {
|
||||
ui.label(RichText::new(m).color(t.warning));
|
||||
@@ -170,7 +172,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
ui.separator();
|
||||
let q = app.keymap.filter.to_lowercase();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::ScrollArea::both().max_height(330.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
egui::Grid::new("sc_keymap_grid").num_columns(4).striped(true).spacing([10.0, 4.0]).show(ui, |ui| {
|
||||
for c in command_specs() {
|
||||
let key = effective(app, c.id).unwrap_or_default();
|
||||
@@ -193,8 +195,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
continue;
|
||||
}
|
||||
let b =
|
||||
egui::Button::new(RichText::new(text).color(if changed { t.accent } else { t.text })).min_size(egui::vec2(110.0, 0.0));
|
||||
let b = egui::Button::new(RichText::new(text).color(if changed { t.accent } else { t.text })).min_size(egui::vec2(80.0, 0.0));
|
||||
if ui.add(b).on_hover_text("Click, then press the new key").clicked() {
|
||||
action = Some((c.id.to_string(), "capture"));
|
||||
}
|
||||
@@ -210,7 +211,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
match action {
|
||||
Some((id, "capture")) => {
|
||||
app.keymap.capturing = Some(id);
|
||||
@@ -220,6 +222,21 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
Some((id, "clear")) => drop(bind(app, &id, "", false)),
|
||||
Some((id, "reset")) => reset(app, Some(&id)),
|
||||
Some((_, "reset_all")) => reset(app, None),
|
||||
Some((_, "export")) => {
|
||||
let text = crate::toolbar_custom::export(app).to_string();
|
||||
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f("SolveCraft shortcuts.json", &["json"])) {
|
||||
app.keymap.message = std::fs::write(&path, text).err().map(|e| format!("export: {e}"));
|
||||
}
|
||||
}
|
||||
Some((_, "import")) => {
|
||||
if let Some(path) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
let r = std::fs::read_to_string(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|t| serde_json::from_str::<Value>(&t).map_err(|e| e.to_string()))
|
||||
.and_then(|v| crate::toolbar_custom::import(app, &v));
|
||||
app.keymap.message = r.err().map(|e| format!("import: {e}"));
|
||||
}
|
||||
}
|
||||
Some((pair, "replace")) => {
|
||||
if let Some((id, key)) = pair.split_once('\n') {
|
||||
let _ = bind(app, id, key, true);
|
||||
@@ -233,15 +250,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !open {
|
||||
app.keymap.open = false;
|
||||
app.keymap.capturing = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the window waiting for a key (so the key must not also run a command)?
|
||||
/// Stop waiting for a key (the dialog closed).
|
||||
pub fn stop_capture(app: &mut SolveApp) {
|
||||
app.keymap.capturing = None;
|
||||
app.keymap.conflict = None;
|
||||
app.keymap.message = None;
|
||||
}
|
||||
|
||||
/// Is the page waiting for a key (so the key must not also run a command)?
|
||||
pub fn capturing(app: &SolveApp) -> bool {
|
||||
app.keymap.open && app.keymap.capturing.is_some()
|
||||
app.prefs_window.open && app.prefs_window.section == crate::prefs::PAGE_SHORTCUTS && app.keymap.capturing.is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+112
-7
@@ -9,6 +9,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod access;
|
||||
pub mod agent_cursor;
|
||||
pub mod browser;
|
||||
pub mod canvas;
|
||||
pub mod context_menu;
|
||||
@@ -22,6 +23,7 @@ pub mod dialogs_move;
|
||||
pub mod dialogs_parts;
|
||||
pub mod dialogs_plastic;
|
||||
pub mod dialogs_sheet;
|
||||
pub mod dialogs_surface;
|
||||
pub mod dim_view;
|
||||
pub mod documents;
|
||||
pub mod drag_snap;
|
||||
@@ -35,6 +37,23 @@ pub mod inference;
|
||||
pub mod keymap;
|
||||
#[cfg(test)]
|
||||
mod menu_tests;
|
||||
#[cfg(test)]
|
||||
mod status_tests {
|
||||
#[test]
|
||||
fn the_status_bar_names_commands_by_label() {
|
||||
assert_eq!(super::command_label("solid.fillet"), "Fillet");
|
||||
assert_eq!(
|
||||
super::friendly_error("solid.shell", "invalid parameters for `solid.shell`: offset: must be positive"),
|
||||
"Shell: offset: must be positive"
|
||||
);
|
||||
assert_eq!(super::friendly_error("edit.undo", "nothing to undo"), "Undo: nothing to undo");
|
||||
let mut app = super::SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
app.run("solid.box", serde_json::json!({"length": 5, "width": 5, "height": 5})).unwrap();
|
||||
assert_eq!(app.session.log.last().map(String::as_str), Some("Box done"));
|
||||
let _ = app.run("edit.redo", serde_json::json!({}));
|
||||
assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last());
|
||||
}
|
||||
}
|
||||
pub mod palette;
|
||||
pub mod params_dialog;
|
||||
pub mod prefs;
|
||||
@@ -52,10 +71,12 @@ pub mod sketch_dims;
|
||||
mod sketch_edit_tests;
|
||||
pub mod sketch_palette;
|
||||
pub mod sketch_tools;
|
||||
pub mod stand_up;
|
||||
pub mod theme;
|
||||
pub mod timeline;
|
||||
pub mod titlebar;
|
||||
pub mod toolbar;
|
||||
pub mod toolbar_custom;
|
||||
pub mod tools;
|
||||
pub mod viewport;
|
||||
pub mod workspace;
|
||||
@@ -95,6 +116,8 @@ pub struct UiState {
|
||||
pub visual_style: u8,
|
||||
/// A soft shadow on the ground under the model (seen from above).
|
||||
pub ground_shadow: bool,
|
||||
/// The world's up axis (Z or Y; #3): the camera, view cube, home view and ground follow it.
|
||||
pub up_axis: solvecraft_engine::render::UpAxis,
|
||||
/// Sketches hidden one by one, and finished sketches shown although a feature uses them.
|
||||
pub hidden_sketches: Vec<u64>,
|
||||
pub shown_sketches: Vec<u64>,
|
||||
@@ -104,6 +127,8 @@ pub struct UiState {
|
||||
pub shown_dims: Vec<u64>,
|
||||
/// Bodies locked in the browser (no move or delete).
|
||||
pub locked_bodies: Vec<String>,
|
||||
/// The agent cursor: shown, speed, follow camera (#35).
|
||||
pub agent_cursor: agent_cursor::Settings,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
@@ -124,11 +149,13 @@ impl Default for UiState {
|
||||
pick_bodies: false,
|
||||
visual_style: 0,
|
||||
ground_shadow: true,
|
||||
up_axis: Default::default(),
|
||||
hidden_sketches: Vec::new(),
|
||||
shown_sketches: Vec::new(),
|
||||
hidden_profiles: Vec::new(),
|
||||
shown_dims: Vec::new(),
|
||||
locked_bodies: Vec::new(),
|
||||
agent_cursor: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,6 +199,8 @@ pub struct SolveApp {
|
||||
pub keymap: keymap::Keymap,
|
||||
/// The Help menu and the About window.
|
||||
pub help: help::HelpState,
|
||||
/// Toolbar buttons the user pinned, removed or reordered.
|
||||
pub toolbar_custom: toolbar_custom::ToolbarCustom,
|
||||
/// Preferences kept between runs, and their window.
|
||||
pub preferences: prefs::Prefs,
|
||||
pub prefs_window: prefs::PrefsWindow,
|
||||
@@ -186,6 +215,8 @@ pub struct SolveApp {
|
||||
pub autosave: Option<solvecraft_engine::recovery::Autosaver>,
|
||||
/// Designs left by crashed apps, offered for recovery.
|
||||
pub recoverable: Vec<solvecraft_engine::recovery::Entry>,
|
||||
/// The bodies of a just-opened Y-up file, offered to be stood up (`stand_up`).
|
||||
pub stand_up: Option<Vec<String>>,
|
||||
/// The last command was a big operation: autosave after it.
|
||||
pub big_operation: bool,
|
||||
/// Autosave interval (minutes; 0 = only after big operations). A preference.
|
||||
@@ -199,6 +230,8 @@ pub struct SolveApp {
|
||||
/// The modifiers of the synthetic press in progress (a Ctrl-drag keeps Ctrl while it moves).
|
||||
pub synthetic_mods: egui::Modifiers,
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
/// The agent cursor acting out control requests.
|
||||
pub agent: agent_cursor::AgentCursor,
|
||||
pending_shots: Vec<(u64, Option<String>, std::sync::mpsc::Sender<Value>, f64)>,
|
||||
queued_shots: Vec<(u64, f64, u32)>,
|
||||
shot_token: u64,
|
||||
@@ -231,6 +264,7 @@ impl SolveApp {
|
||||
sbox: shortcut_box::ShortcutBox::default(),
|
||||
keymap: keymap::Keymap::default(),
|
||||
help: help::HelpState::default(),
|
||||
toolbar_custom: toolbar_custom::ToolbarCustom::default(),
|
||||
preferences: prefs::Prefs::default(),
|
||||
prefs_window: prefs::PrefsWindow::default(),
|
||||
preview: preview::PreviewState::default(),
|
||||
@@ -240,6 +274,7 @@ impl SolveApp {
|
||||
quit_requested: false,
|
||||
autosave: None,
|
||||
recoverable: Vec::new(),
|
||||
stand_up: None,
|
||||
big_operation: false,
|
||||
autosave_minutes: 5.0,
|
||||
custom_titlebar: false,
|
||||
@@ -248,6 +283,7 @@ impl SolveApp {
|
||||
synthetic: Vec::new(),
|
||||
synthetic_mods: egui::Modifiers::default(),
|
||||
control_rx: None,
|
||||
agent: Default::default(),
|
||||
pending_shots: Vec::new(),
|
||||
queued_shots: Vec::new(),
|
||||
shot_token: 0,
|
||||
@@ -280,7 +316,10 @@ impl SolveApp {
|
||||
"recent": self.home.recent,
|
||||
"shortcut_box": self.sbox.prefs(),
|
||||
"shortcuts": self.keymap.prefs(),
|
||||
"toolbar": self.toolbar_custom.prefs(),
|
||||
"autosave_minutes": self.autosave_minutes,
|
||||
"ground_shadow": self.ui.ground_shadow,
|
||||
"agent_cursor": self.ui.agent_cursor,
|
||||
"preferences": self.preferences,
|
||||
})
|
||||
.to_string()
|
||||
@@ -331,9 +370,22 @@ impl SolveApp {
|
||||
if let Some(s) = v.get("shortcuts") {
|
||||
self.keymap.load(s);
|
||||
}
|
||||
if let Some(t) = v.get("toolbar") {
|
||||
self.toolbar_custom.load(t);
|
||||
}
|
||||
if let Some(p) = v.get("preferences").and_then(|p| serde_json::from_value::<prefs::Prefs>(p.clone()).ok()) {
|
||||
self.preferences = p;
|
||||
}
|
||||
// Saved before the theme was a preference: the Dark/Light flag says which.
|
||||
if v.get("preferences").and_then(|p| p.get("theme")).is_none() {
|
||||
self.preferences.theme = if self.ui.dark { "dark" } else { "light" }.into();
|
||||
}
|
||||
if let Some(g) = flag("ground_shadow") {
|
||||
self.ui.ground_shadow = g;
|
||||
}
|
||||
if let Some(a) = v.get("agent_cursor").and_then(|a| serde_json::from_value(a.clone()).ok()) {
|
||||
self.ui.agent_cursor = a;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||||
@@ -352,7 +404,7 @@ impl SolveApp {
|
||||
}
|
||||
match &r {
|
||||
Ok(_) => {
|
||||
self.session.echo(format!("{id}: done"));
|
||||
self.session.echo(format!("{} done", command_label(id)));
|
||||
if matches!(id, "file.new" | "doc.open") {
|
||||
self.cam = Camera::default();
|
||||
self.fit_view();
|
||||
@@ -364,8 +416,9 @@ impl SolveApp {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.session.echo(format!("{id}: {e}"));
|
||||
self.set_status(e.clone(), true);
|
||||
let msg = friendly_error(id, e);
|
||||
self.session.echo(msg.clone());
|
||||
self.set_status(msg, true);
|
||||
}
|
||||
}
|
||||
if self.session.active_sketch.is_some() && self.ui.tab != "SKETCH" {
|
||||
@@ -536,8 +589,12 @@ impl SolveApp {
|
||||
}
|
||||
|
||||
/// Is the origin widget shown (always while picking a sketch plane)?
|
||||
/// Origin planes and axes are drawn and pickable. Hidden ones only come back while Create
|
||||
/// Sketch has nothing else to offer (no body shown).
|
||||
pub fn origin_visible(&self) -> bool {
|
||||
self.ui.show_origin || self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Sketch))
|
||||
self.ui.show_origin
|
||||
|| (self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Sketch))
|
||||
&& !self.session.world_state().bodies.iter().any(|b| !self.ui.hidden_bodies.contains(&b.name)))
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, s: impl Into<String>, error: bool) {
|
||||
@@ -549,6 +606,7 @@ impl SolveApp {
|
||||
documents::prepare_open(self);
|
||||
if self.run("doc.open", json!({ "path": path })).is_ok() {
|
||||
self.fit_view();
|
||||
stand_up::offer(self, path, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,8 +614,10 @@ impl SolveApp {
|
||||
/// current design.
|
||||
pub fn insert_path(&mut self, path: &str) {
|
||||
let cmd = if solvecraft_engine::io::is_mesh_path(path) { "file.insert_mesh" } else { "file.insert_step" };
|
||||
let before: Vec<String> = self.session.world_state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
if let Ok(r) = self.run(cmd, json!({ "path": path })) {
|
||||
self.fit_view();
|
||||
stand_up::offer(self, path, &before);
|
||||
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
|
||||
self.set_status(format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or("")), false);
|
||||
}
|
||||
@@ -576,6 +636,16 @@ impl SolveApp {
|
||||
}
|
||||
|
||||
/// Frame the model at once (programmatic use; the toolbar animates).
|
||||
/// Make `up` the world's up axis: the camera turns to the same standard view in that world
|
||||
/// (home) and frames the model.
|
||||
pub fn set_up_axis(&mut self, up: solvecraft_engine::render::UpAxis) {
|
||||
self.ui.up_axis = up;
|
||||
self.cam_anim = None;
|
||||
self.cam.up = up;
|
||||
self.cam.set_view(StandardView::Iso);
|
||||
self.fit_view();
|
||||
}
|
||||
|
||||
pub fn fit_view(&mut self) {
|
||||
self.cam_anim = None;
|
||||
self.cam = self.fitted(self.cam);
|
||||
@@ -598,7 +668,18 @@ impl SolveApp {
|
||||
if !to.is_valid() {
|
||||
return;
|
||||
}
|
||||
self.cam_anim = Some(CameraAnim::new(self.cam, to, self.now));
|
||||
// Preferences › Navigation: animated (at a chosen speed) or immediate.
|
||||
if !self.preferences.look_at_anim {
|
||||
self.cam = to;
|
||||
self.cam_anim = None;
|
||||
return;
|
||||
}
|
||||
let mut anim = CameraAnim::new(self.cam, to, self.now);
|
||||
let speed = f64::from(self.preferences.look_at_speed);
|
||||
if speed.is_finite() && speed > 0.0 {
|
||||
anim.duration /= speed;
|
||||
}
|
||||
self.cam_anim = Some(anim);
|
||||
}
|
||||
|
||||
/// Animated view changes for the view cube and navigation bar.
|
||||
@@ -641,6 +722,7 @@ impl SolveApp {
|
||||
/// Per-frame logic before layout.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
self.esc_handled = false;
|
||||
prefs::apply(self, ctx);
|
||||
if !self.styled || theme::is_dark() != self.ui.dark {
|
||||
theme::set_dark(self.ui.dark);
|
||||
theme::apply(ctx);
|
||||
@@ -651,8 +733,12 @@ impl SolveApp {
|
||||
self.fitted = true;
|
||||
}
|
||||
self.now = ctx.input(|i| i.time);
|
||||
if self.cam.up != self.ui.up_axis {
|
||||
self.set_up_axis(self.ui.up_axis);
|
||||
}
|
||||
self.step_view_animation(ctx);
|
||||
self.drain_control(ctx);
|
||||
agent_cursor::step(self, ctx);
|
||||
if !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -724,21 +810,28 @@ impl SolveApp {
|
||||
sketch_palette::show(self, ui.ctx());
|
||||
params_dialog::show(self, ui.ctx());
|
||||
recovery_ui::show(self, ui.ctx());
|
||||
stand_up::show(self, ui.ctx());
|
||||
context_menu::show(self, ui.ctx());
|
||||
delete::show(self, ui.ctx());
|
||||
if self.custom_titlebar {
|
||||
titlebar::resize_zones(ui);
|
||||
}
|
||||
shortcut_box::show(self, ui.ctx());
|
||||
keymap::show(self, ui.ctx());
|
||||
prefs::show(self, ui.ctx());
|
||||
agent_cursor::paint(self, ui.ctx());
|
||||
self.frame_ms = now_ms() - t0;
|
||||
}
|
||||
|
||||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||||
let Some(rx) = self.control_rx.take() else { return };
|
||||
while let Ok(req) = rx.try_recv() {
|
||||
// While the agent cursor acts out a request, later ones wait their turn.
|
||||
while !self.agent.busy() {
|
||||
let Ok(req) = rx.try_recv() else { break };
|
||||
let reply = req.reply.clone();
|
||||
if agent_cursor::intercept(self, &req.method, &req.params, &reply) {
|
||||
ctx.request_repaint();
|
||||
continue;
|
||||
}
|
||||
match control::handle(self, ctx, &req) {
|
||||
control::Outcome::Done(v) => {
|
||||
let _ = reply.send(v);
|
||||
@@ -805,6 +898,18 @@ impl SolveApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A command's name as people see it (its label), for the status bar.
|
||||
pub fn command_label(id: &str) -> &str {
|
||||
solvecraft_engine::find_command(id).map_or(id, |c| c.label)
|
||||
}
|
||||
|
||||
/// An engine error with the command's label instead of its id: "Shell: offset must be …".
|
||||
pub fn friendly_error(id: &str, e: &str) -> String {
|
||||
let label = command_label(id);
|
||||
let msg = e.replace(&format!("invalid parameters for `{id}`: "), "").replace(&format!("{id}: "), "").replace(&format!("`{id}`"), label);
|
||||
if msg.starts_with(label) { msg } else { format!("{label}: {msg}") }
|
||||
}
|
||||
|
||||
pub fn now_ms() -> f64 {
|
||||
use web_time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
|
||||
|
||||
@@ -455,3 +455,76 @@ fn f2_renames_the_selected_item() {
|
||||
app.run("select.clear", json!({})).unwrap();
|
||||
assert!(!context_menu::rename_selection(&mut app, pos2(0.0, 0.0)), "nothing selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_moves_turn_about_their_origin_then_move() {
|
||||
let mut app = sample_app();
|
||||
let b = body(&app);
|
||||
app.run("component.from_bodies", json!({"bodies": [b]})).unwrap();
|
||||
let occ = app.session.doc.occurrences[0].id;
|
||||
let mut m = crate::browser::OccMove::new(occ);
|
||||
m.pivot = Some(solvecraft_engine::geom::Vec3::new(5.0, 0.0, 0.0));
|
||||
m.translate = [10.0, 0.0, 0.0];
|
||||
m.angles = [0.0, 0.0, 90.0];
|
||||
crate::browser::apply_occurrence_move(&mut app, &m);
|
||||
let t = *app.session.pending_moves.get(&occ).unwrap();
|
||||
// The pivot lands 10 mm along X; X turns into Y.
|
||||
let p = solvecraft_engine::doc::apply_point(&t, solvecraft_engine::geom::Vec3::new(5.0, 0.0, 0.0));
|
||||
let x = solvecraft_engine::doc::apply_vector(&t, solvecraft_engine::geom::Vec3::X);
|
||||
assert!((p.x - 15.0).abs() < 1e-9 && p.y.abs() < 1e-9, "{p:?}");
|
||||
assert!((x.y - 1.0).abs() < 1e-9, "{x:?}");
|
||||
// Changing the values again starts from where it was, not from the last move.
|
||||
m.translate = [0.0, 0.0, 0.0];
|
||||
m.angles = [0.0; 3];
|
||||
crate::browser::apply_occurrence_move(&mut app, &m);
|
||||
assert_eq!(*app.session.pending_moves.get(&occ).unwrap(), solvecraft_engine::doc::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolbar_pins_removes_reorders_and_keeps_its_layout() {
|
||||
use crate::toolbar_custom as tc;
|
||||
let mut app = sample_app();
|
||||
app.ui.tab = "SOLID".into();
|
||||
let ids = |app: &SolveApp, panel: &str| -> Vec<&'static str> {
|
||||
let lists = tc::panel_lists("SOLID");
|
||||
let (_, cmds, n) = lists.iter().find(|(p, ..)| *p == panel).unwrap();
|
||||
tc::buttons(app, "SOLID", panel, cmds, *n).iter().map(|c| c.id).collect()
|
||||
};
|
||||
let create = ids(&app, "CREATE");
|
||||
// Pin a command that isn't a button yet: it lands at the end of its panel.
|
||||
let extra =
|
||||
tc::panel_lists("SOLID").iter().find(|(p, ..)| *p == "CREATE").unwrap().1.iter().map(|c| c.id).find(|id| !create.contains(id)).unwrap();
|
||||
act(&mut app, &Target::ToolbarCommand { id: extra.into() }, "Pin to Toolbar");
|
||||
assert_eq!(ids(&app, "CREATE").last(), Some(&extra));
|
||||
// Remove a default button.
|
||||
act(&mut app, &Target::ToolbarCommand { id: create[0].into() }, "Remove from Toolbar");
|
||||
assert!(!ids(&app, "CREATE").contains(&create[0]));
|
||||
// Reorder: the pinned one before the second default; then into another panel.
|
||||
tc::reorder(&mut app, "SOLID", extra, "CREATE", Some(create[1]));
|
||||
assert_eq!(ids(&app, "CREATE")[0], extra);
|
||||
tc::reorder(&mut app, "SOLID", extra, "MODIFY", None);
|
||||
assert!(!ids(&app, "CREATE").contains(&extra) && ids(&app, "MODIFY").last() == Some(&extra));
|
||||
// Pin to Shortcuts goes to the S box's favourites.
|
||||
act(&mut app, &Target::ToolbarCommand { id: "Extrude".into() }, "Pin to Shortcuts");
|
||||
assert!(app.sbox.pinned.iter().any(|p| p == "Extrude"));
|
||||
// Kept between runs, and exported and imported with the shortcuts.
|
||||
let mut other = sample_app();
|
||||
other.load_prefs(&app.prefs());
|
||||
assert_eq!(other.toolbar_custom, app.toolbar_custom);
|
||||
let file = tc::export(&app);
|
||||
let mut third = sample_app();
|
||||
tc::import(&mut third, &file).unwrap();
|
||||
assert_eq!(third.toolbar_custom, app.toolbar_custom);
|
||||
assert!(tc::import(&mut third, &json!({"format": "nope"})).is_err());
|
||||
// Reset Toolbar puts the tab back.
|
||||
act(&mut app, &Target::ToolbarCommand { id: extra.into() }, "Reset Toolbar");
|
||||
assert_eq!(ids(&app, "CREATE"), create);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_workspace_menu_lists_design_and_greys_the_rest() {
|
||||
let app = sample_app();
|
||||
let items = context_menu::items(&app, &Target::Workspace);
|
||||
assert!(find(&items, "Design").enabled);
|
||||
assert!(!find(&items, "Render").enabled && find(&items, "Render").shortcut == "coming later");
|
||||
}
|
||||
|
||||
@@ -188,6 +188,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
});
|
||||
// The title bar's close button, for scenarios and demos.
|
||||
if let Some(r) = ctx.memory(|m| m.area_rect(egui::Id::new("sc_params_dialog"))) {
|
||||
crate::scenario::publish_handle("params:close", egui::pos2(r.right() - 16.0, r.top() + 15.0));
|
||||
crate::scenario::publish_handle("params:title", egui::pos2(r.center().x, r.top() + 15.0));
|
||||
}
|
||||
if !open {
|
||||
app.params = None;
|
||||
return;
|
||||
@@ -271,6 +276,7 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R
|
||||
te = te.text_color(t.error);
|
||||
}
|
||||
let resp = ui.add_sized([170.0, 20.0], te);
|
||||
crate::scenario::publish_handle(&format!("param:{}", r.name), resp.rect.center());
|
||||
let completed = complete(ui, &resp, &mut e);
|
||||
if resp.changed() || completed {
|
||||
d.editing.insert(r.name.clone(), e.clone());
|
||||
|
||||
+537
-118
@@ -1,7 +1,12 @@
|
||||
//! Preferences (File › Preferences), in the spirit of Fusion's: General (theme, units of new
|
||||
//! designs, autosave interval), Navigation (mouse mapping preset, zoom direction, orbit centre), Display
|
||||
//! (grid, perspective, visual style with or without edges, display precision, anti-aliasing), Sketch and Selection. Everything
|
||||
//! applies at once and is kept between runs, except anti-aliasing, which needs a restart.
|
||||
//! Preferences, one dialog in the spirit of Fusion's, opened from the gear at the top right of the
|
||||
//! window, File › Preferences or Ctrl+, (Cmd+, on macOS): General (theme, UI scale, units of new
|
||||
//! designs, autosave, display precision, the agent cursor), Navigation (mouse presets, zoom
|
||||
//! direction, zoom to the cursor, orbit centre, Look At animation), Keyboard Shortcuts (search,
|
||||
//! rebind, conflicts; `keymap`), Design (default modelling orientation, Y up or Z up), Graphics
|
||||
//! (visual style, grid, shadow, perspective, anti-aliasing), Sketch and Selection. Changes apply
|
||||
//! at once; Cancel puts back what was there when the dialog opened, OK keeps them, Apply keeps
|
||||
//! them and stays open, and Restore Defaults resets the page shown. Everything is kept between
|
||||
//! runs, except anti-aliasing, which needs a restart.
|
||||
|
||||
use egui::{Modifiers, Pos2, RichText};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -14,7 +19,13 @@ use crate::viewport::NavMode;
|
||||
|
||||
pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"];
|
||||
/// Mouse mappings: (id, label).
|
||||
pub const NAV_PRESETS: [(&str, &str); 3] = [("fusion", "Fusion"), ("solidworks", "SolidWorks"), ("inventor", "Inventor")];
|
||||
pub const NAV_PRESETS: [(&str, &str); 5] =
|
||||
[("fusion", "Fusion"), ("solidworks", "SolidWorks"), ("inventor", "Inventor"), ("alias", "Alias"), ("tinkercad", "Tinkercad")];
|
||||
/// The dialog's pages, in order.
|
||||
pub const PAGES: [&str; 7] = ["General", "Navigation", "Keyboard Shortcuts", "Design", "Graphics", "Sketch", "Selection"];
|
||||
pub const PAGE_SHORTCUTS: usize = 2;
|
||||
pub const PAGE_DESIGN: usize = 3;
|
||||
pub const THEMES: [(&str, &str); 3] = [("dark", "Dark"), ("light", "Light"), ("system", "System")];
|
||||
pub const MSAA: [u32; 4] = [1, 2, 4, 8];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -33,6 +44,19 @@ pub struct Prefs {
|
||||
/// Decimals shown for lengths and angles.
|
||||
pub length_decimals: u8,
|
||||
pub angle_decimals: u8,
|
||||
/// `dark`, `light` or `system` (follow the operating system).
|
||||
pub theme: String,
|
||||
/// Interface scale (1 = 100 %).
|
||||
pub ui_scale: f32,
|
||||
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
|
||||
pub zoom_to_cursor: bool,
|
||||
/// View changes (view cube, Look At, Home) animate; `look_at_speed` scales how fast.
|
||||
pub look_at_anim: bool,
|
||||
pub look_at_speed: f32,
|
||||
/// Default modelling orientation: `z` (Z up) or `y` (Y up) (#3).
|
||||
pub up_axis: String,
|
||||
/// After a STEP or IGES file opens, offer to stand a Y-up part up (`stand_up`).
|
||||
pub offer_stand_up: bool,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -45,6 +69,13 @@ impl Default for Prefs {
|
||||
msaa: 4,
|
||||
length_decimals: 2,
|
||||
angle_decimals: 1,
|
||||
theme: "dark".into(),
|
||||
ui_scale: 1.0,
|
||||
zoom_to_cursor: true,
|
||||
look_at_anim: true,
|
||||
look_at_speed: 1.0,
|
||||
up_axis: "z".into(),
|
||||
offer_stand_up: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +84,127 @@ impl Default for Prefs {
|
||||
#[derive(Default)]
|
||||
pub struct PrefsWindow {
|
||||
pub open: bool,
|
||||
section: usize,
|
||||
/// The page shown (index into [`PAGES`]).
|
||||
pub section: usize,
|
||||
/// Everything as it was when the dialog opened (or at the last Apply), for Cancel.
|
||||
snapshot: Option<Snapshot>,
|
||||
}
|
||||
|
||||
/// Open the dialog at a page.
|
||||
pub fn open_at(app: &mut SolveApp, page: usize) {
|
||||
app.prefs_window.open = true;
|
||||
app.prefs_window.section = page.min(PAGES.len() - 1);
|
||||
}
|
||||
|
||||
/// Every setting the dialog changes, for Cancel and Restore Defaults.
|
||||
#[derive(Clone)]
|
||||
struct Snapshot {
|
||||
prefs: Prefs,
|
||||
dark: bool,
|
||||
grid: bool,
|
||||
shadow: bool,
|
||||
perspective: bool,
|
||||
style: u8,
|
||||
pick_bodies: bool,
|
||||
agent: crate::agent_cursor::Settings,
|
||||
autosave: f64,
|
||||
auto_project: bool,
|
||||
keys: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
fn take(app: &SolveApp) -> Snapshot {
|
||||
Snapshot {
|
||||
prefs: app.preferences.clone(),
|
||||
dark: app.ui.dark,
|
||||
grid: app.ui.show_grid,
|
||||
shadow: app.ui.ground_shadow,
|
||||
perspective: app.ui.perspective,
|
||||
style: app.ui.visual_style,
|
||||
pick_bodies: app.ui.pick_bodies,
|
||||
agent: app.ui.agent_cursor,
|
||||
autosave: app.autosave_minutes,
|
||||
auto_project: app.session.auto_project,
|
||||
keys: app.keymap.custom.clone(),
|
||||
}
|
||||
}
|
||||
fn restore(self, app: &mut SolveApp) {
|
||||
let msaa = app.preferences.msaa;
|
||||
app.preferences = self.prefs;
|
||||
app.ui.dark = self.dark;
|
||||
app.ui.show_grid = self.grid;
|
||||
app.ui.ground_shadow = self.shadow;
|
||||
app.ui.perspective = self.perspective;
|
||||
app.ui.visual_style = self.style;
|
||||
app.ui.pick_bodies = self.pick_bodies;
|
||||
app.ui.agent_cursor = self.agent;
|
||||
app.autosave_minutes = self.autosave;
|
||||
set_autosave(app);
|
||||
app.session.auto_project = self.auto_project;
|
||||
app.keymap.custom = self.keys;
|
||||
if app.preferences.msaa != msaa {
|
||||
save_graphics(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore Defaults for one page.
|
||||
fn defaults(app: &mut SolveApp, page: usize) {
|
||||
let d = Prefs::default();
|
||||
let p = &mut app.preferences;
|
||||
match page {
|
||||
0 => {
|
||||
p.theme = d.theme;
|
||||
p.ui_scale = d.ui_scale;
|
||||
p.default_units = d.default_units;
|
||||
p.length_decimals = d.length_decimals;
|
||||
p.angle_decimals = d.angle_decimals;
|
||||
app.ui.dark = true;
|
||||
app.ui.agent_cursor = Default::default();
|
||||
app.autosave_minutes = 5.0;
|
||||
set_autosave(app);
|
||||
}
|
||||
1 => {
|
||||
p.nav = d.nav;
|
||||
p.zoom_reverse = d.zoom_reverse;
|
||||
p.zoom_to_cursor = d.zoom_to_cursor;
|
||||
p.orbit_cursor = d.orbit_cursor;
|
||||
p.look_at_anim = d.look_at_anim;
|
||||
p.look_at_speed = d.look_at_speed;
|
||||
}
|
||||
PAGE_SHORTCUTS => crate::keymap::reset(app, None),
|
||||
PAGE_DESIGN => {
|
||||
p.up_axis = d.up_axis;
|
||||
p.offer_stand_up = d.offer_stand_up;
|
||||
}
|
||||
4 => {
|
||||
let msaa = p.msaa != d.msaa;
|
||||
p.msaa = d.msaa;
|
||||
app.ui.visual_style = 0;
|
||||
app.ui.show_grid = true;
|
||||
app.ui.ground_shadow = true;
|
||||
app.ui.perspective = false;
|
||||
if msaa {
|
||||
save_graphics(app);
|
||||
}
|
||||
}
|
||||
5 => app.session.auto_project = true,
|
||||
_ => app.ui.pick_bodies = false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings that follow the preferences every frame: the theme when it follows the system, and
|
||||
/// the interface scale.
|
||||
pub fn apply(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if app.preferences.theme == "system"
|
||||
&& let Some(t) = ctx.system_theme()
|
||||
{
|
||||
app.ui.dark = t == egui::Theme::Dark;
|
||||
}
|
||||
let z = app.preferences.ui_scale.clamp(0.5, 2.5);
|
||||
if z.is_finite() && (ctx.zoom_factor() - z).abs() > 1e-3 {
|
||||
ctx.set_zoom_factor(z);
|
||||
}
|
||||
}
|
||||
|
||||
/// A new, empty design takes the preferred units (not an undo step, not a change to save).
|
||||
@@ -80,7 +231,8 @@ pub fn set_autosave(app: &mut SolveApp) {
|
||||
/// Which navigation a mouse drag does under the preset.
|
||||
pub fn nav_mode(app: &SolveApp, middle: bool, secondary: bool, mods: Modifiers) -> Option<NavMode> {
|
||||
if secondary && !middle {
|
||||
return Some(NavMode::Orbit);
|
||||
// Tinkercad: Shift+right pans; everywhere else right drag orbits.
|
||||
return Some(if app.preferences.nav == "tinkercad" && mods.shift { NavMode::Pan } else { NavMode::Orbit });
|
||||
}
|
||||
if !middle {
|
||||
return None;
|
||||
@@ -103,6 +255,10 @@ pub fn nav_mode(app: &SolveApp, middle: bool, secondary: bool, mods: Modifiers)
|
||||
} else {
|
||||
NavMode::Pan
|
||||
}),
|
||||
// Alias: middle pans, Ctrl+middle zooms (right drag orbits).
|
||||
"alias" => Some(if ctrl { NavMode::Zoom } else { NavMode::Pan }),
|
||||
// Tinkercad: middle pans.
|
||||
"tinkercad" => Some(NavMode::Pan),
|
||||
// Fusion: middle pans, Shift+middle orbits.
|
||||
_ => Some(if mods.shift { NavMode::Orbit } else { NavMode::Pan }),
|
||||
}
|
||||
@@ -198,141 +354,311 @@ fn save_graphics(app: &SolveApp) {
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
// File › Keyboard Shortcuts and Help › Keyboard Shortcuts open this dialog at that page.
|
||||
if app.keymap.open {
|
||||
app.keymap.open = false;
|
||||
open_at(app, PAGE_SHORTCUTS);
|
||||
}
|
||||
if !app.prefs_window.open {
|
||||
app.prefs_window.snapshot = None;
|
||||
return;
|
||||
}
|
||||
if app.prefs_window.snapshot.is_none() {
|
||||
app.prefs_window.snapshot = Some(Snapshot::take(app));
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let mut open = true;
|
||||
let sections = ["General", "Navigation", "Display", "Sketch", "Selection"];
|
||||
let mut button: Option<&str> = None;
|
||||
let before = app.preferences.clone();
|
||||
crate::frame::window(ctx, "Preferences", crate::frame::Width::Wide)
|
||||
.id(egui::Id::new("sc_prefs"))
|
||||
.open(&mut open)
|
||||
.default_size([560.0, 360.0])
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
// A fixed page area: the vertical separator fills the height it is given, so an
|
||||
// auto-sized one would grow every frame.
|
||||
ui.allocate_ui_with_layout(egui::vec2(700.0, 400.0), egui::Layout::left_to_right(egui::Align::Min), |ui| {
|
||||
ui.set_min_size(egui::vec2(700.0, 400.0));
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(120.0);
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
ui.set_width(140.0);
|
||||
for (i, s) in PAGES.iter().enumerate() {
|
||||
if ui.selectable_label(app.prefs_window.section == i, *s).clicked() {
|
||||
app.prefs_window.section = i;
|
||||
app.keymap.read_only = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
egui::Grid::new("sc_prefs_grid").num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section {
|
||||
0 => {
|
||||
ui.label("Theme");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut app.ui.dark, true, "Dark");
|
||||
ui.radio_value(&mut app.ui.dark, false, "Light");
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Units of new designs");
|
||||
egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| {
|
||||
for u in UNITS {
|
||||
ui.selectable_value(&mut app.preferences.default_units, u.to_string(), u);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Autosave every");
|
||||
if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() {
|
||||
set_autosave(app);
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.label(RichText::new("0 turns it off").size(11.0).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
1 => {
|
||||
ui.label("Mouse");
|
||||
egui::ComboBox::from_id_salt("pref_nav")
|
||||
.selected_text(NAV_PRESETS.iter().find(|(id, _)| *id == app.preferences.nav).map_or("Fusion", |x| x.1))
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, label) in NAV_PRESETS {
|
||||
if ui.selectable_value(&mut app.preferences.nav, id.to_string(), label).clicked() {
|
||||
app.preferences.zoom_reverse = id == "solidworks";
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
let how = match app.preferences.nav.as_str() {
|
||||
"solidworks" => "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms",
|
||||
"inventor" => "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms",
|
||||
_ => "Middle drag pans, Shift+middle orbits",
|
||||
};
|
||||
ui.label(RichText::new(format!("{how}; right drag orbits; the wheel zooms at the cursor")).size(11.0).color(t.text_dim));
|
||||
ui.end_row();
|
||||
ui.label("Reverse zoom direction");
|
||||
ui.checkbox(&mut app.preferences.zoom_reverse, "");
|
||||
ui.end_row();
|
||||
ui.label("Orbit around");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre");
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, true, "Point under the cursor");
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
2 => {
|
||||
ui.label("Grid");
|
||||
ui.checkbox(&mut app.ui.show_grid, "");
|
||||
ui.end_row();
|
||||
ui.label("Perspective view");
|
||||
ui.checkbox(&mut app.ui.perspective, "");
|
||||
ui.end_row();
|
||||
ui.label("Visual style");
|
||||
let styles = ["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"];
|
||||
egui::ComboBox::from_id_salt("pref_style").selected_text(styles[usize::from(app.ui.visual_style.min(3))]).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
for (i, s) in styles.iter().enumerate() {
|
||||
ui.selectable_value(&mut app.ui.visual_style, i as u8, *s);
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.end_row();
|
||||
ui.label("Length decimals");
|
||||
ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6));
|
||||
ui.end_row();
|
||||
ui.label("Angle decimals");
|
||||
ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6));
|
||||
ui.end_row();
|
||||
ui.label("Anti-aliasing");
|
||||
egui::ComboBox::from_id_salt("pref_msaa").selected_text(format!("{}×", app.preferences.msaa)).show_ui(ui, |ui| {
|
||||
for m in MSAA {
|
||||
ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×"));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.label(RichText::new("Anti-aliasing applies the next time SolveCraft starts").size(11.0).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
3 => {
|
||||
ui.label("Project face edges into new sketches");
|
||||
let mut auto = app.session.auto_project;
|
||||
if ui.checkbox(&mut auto, "").changed() {
|
||||
let _ = app.run("sketch.auto_project", json!({ "value": auto }));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
_ => {
|
||||
ui.label("Click selects whole bodies");
|
||||
ui.checkbox(&mut app.ui.pick_bodies, "");
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
// A fixed width, so the window keeps its size from page to page.
|
||||
ui.set_width(540.0);
|
||||
ui.label(RichText::new(PAGES.get(app.prefs_window.section).copied().unwrap_or_default()).strong().size(14.0));
|
||||
ui.add_space(6.0);
|
||||
page(app, ui, &t);
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Restore Defaults").on_hover_text("Reset the settings on this page").clicked() {
|
||||
button = Some("defaults");
|
||||
}
|
||||
crate::frame::right_aligned(ui, 700.0, 170.0, |ui| {
|
||||
if ui.button("OK").clicked() {
|
||||
button = Some("ok");
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
button = Some("cancel");
|
||||
}
|
||||
if ui.button("Apply").clicked() {
|
||||
button = Some("apply");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if app.preferences.msaa != before.msaa {
|
||||
save_graphics(app);
|
||||
}
|
||||
if !open {
|
||||
app.prefs_window.open = false;
|
||||
if app.preferences.up_axis != before.up_axis {
|
||||
up_axis_changed(app);
|
||||
}
|
||||
let esc = ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !crate::keymap::capturing(app);
|
||||
match button {
|
||||
Some("defaults") => defaults(app, app.prefs_window.section),
|
||||
Some("apply") => app.prefs_window.snapshot = Some(Snapshot::take(app)),
|
||||
Some("ok") => close(app, false),
|
||||
Some("cancel") => close(app, true),
|
||||
_ if !open || esc => close(app, !open || esc),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the dialog; `revert`: put back what was there when it opened (Cancel, Esc, ×).
|
||||
fn close(app: &mut SolveApp, revert: bool) {
|
||||
if let Some(s) = app.prefs_window.snapshot.take()
|
||||
&& revert
|
||||
{
|
||||
let up = app.preferences.up_axis.clone();
|
||||
s.restore(app);
|
||||
if app.preferences.up_axis != up {
|
||||
up_axis_changed(app);
|
||||
}
|
||||
}
|
||||
app.prefs_window.open = false;
|
||||
app.keymap.read_only = false;
|
||||
crate::keymap::stop_capture(app);
|
||||
}
|
||||
|
||||
/// The default orientation changed: the view turns to match.
|
||||
fn up_axis_changed(app: &mut SolveApp) {
|
||||
app.animate_view("home");
|
||||
}
|
||||
|
||||
/// One page of the dialog.
|
||||
fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let note = |ui: &mut egui::Ui, s: &str| {
|
||||
ui.label("");
|
||||
ui.label(RichText::new(s).size(11.0).color(t.text_dim));
|
||||
ui.end_row();
|
||||
};
|
||||
if app.prefs_window.section == PAGE_SHORTCUTS {
|
||||
crate::keymap::page(app, ui);
|
||||
return;
|
||||
}
|
||||
egui::Grid::new(("sc_prefs_grid", app.prefs_window.section)).num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section {
|
||||
0 => {
|
||||
ui.label("Theme");
|
||||
ui.horizontal(|ui| {
|
||||
for (id, label) in THEMES {
|
||||
if ui.radio(app.preferences.theme == id, label).clicked() {
|
||||
app.preferences.theme = id.to_string();
|
||||
if id != "system" {
|
||||
app.ui.dark = id == "dark";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("UI scale");
|
||||
ui.add(egui::Slider::new(&mut app.preferences.ui_scale, 0.75..=2.0).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0)));
|
||||
ui.end_row();
|
||||
ui.label("Units of new designs");
|
||||
egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| {
|
||||
for u in UNITS {
|
||||
ui.selectable_value(&mut app.preferences.default_units, u.to_string(), u);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Autosave every");
|
||||
if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() {
|
||||
set_autosave(app);
|
||||
}
|
||||
ui.end_row();
|
||||
note(ui, "0 turns it off");
|
||||
ui.label("Length decimals");
|
||||
ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6));
|
||||
ui.end_row();
|
||||
ui.label("Angle decimals");
|
||||
ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6));
|
||||
ui.end_row();
|
||||
ui.label("Agent cursor");
|
||||
let s = &mut app.ui.agent_cursor;
|
||||
ui.checkbox(&mut s.show, "Show the pointer an AI agent moves (#35)");
|
||||
ui.end_row();
|
||||
ui.label("Agent cursor speed");
|
||||
ui.add_enabled_ui(s.show, |ui| {
|
||||
crate::agent_cursor::speed_slider(ui, &mut s.speed);
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
ui.add_enabled(s.show, egui::Checkbox::new(&mut s.follow_camera, "Follow the camera (Look At sketches)"));
|
||||
ui.end_row();
|
||||
}
|
||||
1 => {
|
||||
ui.label("Pan, zoom, orbit");
|
||||
egui::ComboBox::from_id_salt("pref_nav")
|
||||
.selected_text(NAV_PRESETS.iter().find(|(id, _)| *id == app.preferences.nav).map_or("Fusion", |x| x.1))
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, label) in NAV_PRESETS {
|
||||
if ui.selectable_value(&mut app.preferences.nav, id.to_string(), label).clicked() {
|
||||
app.preferences.zoom_reverse = id == "solidworks";
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, &format!("{}; the wheel zooms", nav_help(&app.preferences.nav)));
|
||||
ui.label("Reverse zoom direction");
|
||||
ui.checkbox(&mut app.preferences.zoom_reverse, "");
|
||||
ui.end_row();
|
||||
ui.label("Zoom to the cursor");
|
||||
ui.checkbox(&mut app.preferences.zoom_to_cursor, "")
|
||||
.on_hover_text("The wheel zooms toward the point under the cursor (off: toward the view's centre)");
|
||||
ui.end_row();
|
||||
ui.label("Orbit around");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre");
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, true, "Point under the cursor");
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Animate view changes");
|
||||
ui.checkbox(&mut app.preferences.look_at_anim, "View cube, Look At, Home and Fit");
|
||||
ui.end_row();
|
||||
ui.label("Animation speed");
|
||||
ui.add_enabled(
|
||||
app.preferences.look_at_anim,
|
||||
egui::Slider::new(&mut app.preferences.look_at_speed, 0.5..=3.0).step_by(0.25).custom_formatter(|v, _| format!("{v:.2}×")),
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
PAGE_DESIGN => {
|
||||
ui.label("Default modelling orientation");
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut app.preferences.up_axis, "z".to_string(), "Z up");
|
||||
ui.radio_value(&mut app.preferences.up_axis, "y".to_string(), "Y up");
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, "Which axis points up in the views (view cube, Home, Look At). Designs are unchanged.");
|
||||
ui.label("Offer to stand up Y-up files");
|
||||
ui.checkbox(&mut app.preferences.offer_stand_up, "")
|
||||
.on_hover_text("After a STEP or IGES file opens with Z up, offer a quarter turn about X");
|
||||
ui.end_row();
|
||||
}
|
||||
4 => {
|
||||
ui.label("Visual style");
|
||||
let styles = ["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"];
|
||||
egui::ComboBox::from_id_salt("pref_style").selected_text(styles[usize::from(app.ui.visual_style.min(3))]).show_ui(ui, |ui| {
|
||||
for (i, s) in styles.iter().enumerate() {
|
||||
ui.selectable_value(&mut app.ui.visual_style, i as u8, *s);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Grid");
|
||||
ui.checkbox(&mut app.ui.show_grid, "");
|
||||
ui.end_row();
|
||||
ui.label("Ground shadow");
|
||||
ui.checkbox(&mut app.ui.ground_shadow, "");
|
||||
ui.end_row();
|
||||
ui.label("Perspective view");
|
||||
ui.checkbox(&mut app.ui.perspective, "");
|
||||
ui.end_row();
|
||||
ui.label("Anti-aliasing");
|
||||
egui::ComboBox::from_id_salt("pref_msaa").selected_text(format!("{}×", app.preferences.msaa)).show_ui(ui, |ui| {
|
||||
for m in MSAA {
|
||||
ui.selectable_value(&mut app.preferences.msaa, m, format!("{m}×"));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, "Anti-aliasing applies the next time SolveCraft starts");
|
||||
ui.label("Renderer");
|
||||
ui.label(match (&app.viewport.gpu, &app.help.gpu) {
|
||||
(Some(_), Some(g)) => format!("GPU: {g}"),
|
||||
(Some(_), None) => "GPU".to_string(),
|
||||
_ => "CPU (software)".to_string(),
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
5 => {
|
||||
ui.label("Project face edges into new sketches");
|
||||
let mut auto = app.session.auto_project;
|
||||
if ui.checkbox(&mut auto, "").changed() {
|
||||
let _ = app.run("sketch.auto_project", json!({ "value": auto }));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
_ => {
|
||||
ui.label("Click selects whole bodies");
|
||||
ui.checkbox(&mut app.ui.pick_bodies, "");
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What the mouse does under a preset.
|
||||
pub fn nav_help(preset: &str) -> &'static str {
|
||||
match preset {
|
||||
"solidworks" => "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms, right drag orbits",
|
||||
"inventor" => "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms, right drag orbits",
|
||||
"alias" => "Alt+right or right drag orbits, middle drag pans, Ctrl+middle zooms",
|
||||
"tinkercad" => "Right drag orbits, middle or Shift+right drag pans",
|
||||
_ => "Middle drag pans, Shift+middle orbits, right drag orbits",
|
||||
}
|
||||
}
|
||||
|
||||
/// The control channel's `ui.prefs`: `open` (a page name) opens the dialog there, `button`
|
||||
/// presses OK, Cancel, Apply or Restore Defaults, `stand_up: true` answers the stand-up offer;
|
||||
/// any other keys set preferences. Returns them all, the open page and the offer.
|
||||
pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let mut rest = p.as_object().cloned().unwrap_or_default();
|
||||
if let Some(page) = rest.remove("open") {
|
||||
let name = page.as_str().unwrap_or("General");
|
||||
let i = PAGES.iter().position(|x| x.eq_ignore_ascii_case(name)).ok_or_else(|| format!("no page `{name}` ({})", PAGES.join(", ")))?;
|
||||
open_at(app, i);
|
||||
if app.prefs_window.snapshot.is_none() {
|
||||
app.prefs_window.snapshot = Some(Snapshot::take(app));
|
||||
}
|
||||
}
|
||||
if rest.remove("stand_up").and_then(|v| v.as_bool()) == Some(true) {
|
||||
crate::stand_up::stand_up(app)?;
|
||||
}
|
||||
let button = rest.remove("button");
|
||||
let mut out = set(app, &serde_json::Value::Object(rest))?;
|
||||
match button.as_ref().and_then(|b| b.as_str()) {
|
||||
Some("ok") => close(app, false),
|
||||
Some("cancel") => close(app, true),
|
||||
Some("apply") => app.prefs_window.snapshot = Some(Snapshot::take(app)),
|
||||
Some("defaults") => defaults(app, app.prefs_window.section),
|
||||
Some(b) => return Err(format!("unknown button `{b}` (ok, cancel, apply, defaults)")),
|
||||
None => {}
|
||||
}
|
||||
if button.is_some() {
|
||||
out = serde_json::to_value(&app.preferences).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert("dialog".into(), json!(app.prefs_window.open.then(|| PAGES.get(app.prefs_window.section).copied().unwrap_or_default())));
|
||||
o.insert("stand_up_offer".into(), json!(app.stand_up));
|
||||
o.insert("dark".into(), json!(app.ui.dark));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Set preferences from JSON (control channel); returns them all.
|
||||
@@ -351,13 +677,26 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
|
||||
return Err(format!("units must be one of {}", UNITS.join(", ")));
|
||||
}
|
||||
if !NAV_PRESETS.iter().any(|(id, _)| *id == new.nav) || !MSAA.contains(&new.msaa) {
|
||||
return Err("`nav` must be fusion, solidworks or inventor and `msaa` 1, 2, 4 or 8".into());
|
||||
return Err("`nav` must be fusion, solidworks, inventor, alias or tinkercad and `msaa` 1, 2, 4 or 8".into());
|
||||
}
|
||||
if !THEMES.iter().any(|(id, _)| *id == new.theme) || !["z", "y"].contains(&new.up_axis.as_str()) {
|
||||
return Err("`theme` must be dark, light or system and `up_axis` z or y".into());
|
||||
}
|
||||
if !(0.5..=2.5).contains(&new.ui_scale) || !(0.25..=4.0).contains(&new.look_at_speed) {
|
||||
return Err("`ui_scale` must be 0.5…2.5 and `look_at_speed` 0.25…4".into());
|
||||
}
|
||||
if new.theme != app.preferences.theme && new.theme != "system" {
|
||||
app.ui.dark = new.theme == "dark";
|
||||
}
|
||||
let up_changed = new.up_axis != app.preferences.up_axis;
|
||||
let msaa_changed = new.msaa != app.preferences.msaa;
|
||||
app.preferences = new;
|
||||
if msaa_changed {
|
||||
save_graphics(app);
|
||||
}
|
||||
if up_changed {
|
||||
up_axis_changed(app);
|
||||
}
|
||||
serde_json::to_value(&app.preferences).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -390,6 +729,86 @@ mod tests {
|
||||
assert_eq!(format_length(25.4, "in", 3), "1.000 in");
|
||||
}
|
||||
|
||||
/// The dialog: changes apply at once, Cancel puts back what was there when it opened, Apply
|
||||
/// keeps them, Restore Defaults resets one page, and the shortcuts menus open its page.
|
||||
#[test]
|
||||
fn preferences_dialog_cancel_apply_and_defaults() {
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
control(&mut app, &json!({"open": "general"})).unwrap();
|
||||
assert!(app.prefs_window.open && app.prefs_window.section == 0);
|
||||
control(&mut app, &json!({"theme": "light", "ui_scale": 1.25, "zoom_to_cursor": false})).unwrap();
|
||||
assert!(!app.ui.dark, "light applies at once");
|
||||
app.keymap.custom.insert("solid.extrude".into(), "Shift+E".into());
|
||||
let v = control(&mut app, &json!({"button": "cancel"})).unwrap();
|
||||
assert_eq!(v["dialog"], serde_json::Value::Null);
|
||||
assert!(app.ui.dark && app.preferences.theme == "dark" && app.preferences.ui_scale == 1.0 && app.preferences.zoom_to_cursor);
|
||||
assert!(app.keymap.custom.is_empty(), "a rebinding made while the dialog was open is undone too");
|
||||
|
||||
control(&mut app, &json!({"open": "Navigation", "nav": "alias", "look_at_anim": false})).unwrap();
|
||||
control(&mut app, &json!({"button": "apply"})).unwrap();
|
||||
control(&mut app, &json!({"nav": "tinkercad"})).unwrap();
|
||||
control(&mut app, &json!({"button": "cancel"})).unwrap();
|
||||
assert_eq!(app.preferences.nav, "alias", "Cancel goes back to the last Apply");
|
||||
let to = {
|
||||
let mut c = app.cam;
|
||||
c.set_view(solvecraft_engine::render::StandardView::Top);
|
||||
c
|
||||
};
|
||||
app.animate_to(to);
|
||||
assert!(app.cam_anim.is_none() && app.cam == to, "no animation: the view jumps");
|
||||
|
||||
control(&mut app, &json!({"open": "Navigation"})).unwrap();
|
||||
control(&mut app, &json!({"button": "defaults"})).unwrap();
|
||||
assert_eq!(app.preferences.nav, "fusion");
|
||||
assert!(app.preferences.look_at_anim);
|
||||
control(&mut app, &json!({"button": "ok"})).unwrap();
|
||||
assert!(!app.prefs_window.open);
|
||||
|
||||
// File and Help › Keyboard Shortcuts open the same dialog at its page.
|
||||
app.keymap.open = true;
|
||||
let ctx = egui::Context::default();
|
||||
let _ = ctx.run_ui(Default::default(), |ui| show(&mut app, ui.ctx()));
|
||||
assert!(app.prefs_window.open && app.prefs_window.section == PAGE_SHORTCUTS && !app.keymap.open);
|
||||
assert!(control(&mut app, &json!({"open": "Plugins"})).is_err());
|
||||
assert!(set(&mut app, &json!({"up_axis": "x"})).is_err());
|
||||
assert!(set(&mut app, &json!({"theme": "sepia"})).is_err());
|
||||
}
|
||||
|
||||
/// A STEP file opened Z-up is offered to be stood up: a quarter turn about X, undoable.
|
||||
#[test]
|
||||
fn a_y_up_step_file_can_be_stood_up() {
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-standup-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("tall_in_y.step").to_string_lossy().to_string();
|
||||
let mut src = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
src.run("solid.box", json!({"length": 10, "width": 40, "height": 5})).unwrap();
|
||||
src.run("file.export", json!({"path": path})).unwrap();
|
||||
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
app.open_path(&path);
|
||||
let offered = app.stand_up.clone().unwrap_or_default();
|
||||
assert_eq!(offered.len(), 1, "the file's body is offered");
|
||||
let size = |app: &SolveApp| solvecraft_engine::view::bounds(&app.session);
|
||||
assert!((size(&app).max.y - size(&app).min.y - 40.0).abs() < 1e-6);
|
||||
control(&mut app, &json!({"stand_up": true})).unwrap();
|
||||
let b = size(&app);
|
||||
assert!((b.max.z - b.min.z - 40.0).abs() < 1e-6, "Y became Z: {b:?}");
|
||||
assert!(app.stand_up.is_none());
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!((size(&app).max.y - size(&app).min.y - 40.0).abs() < 1e-6, "one undo step");
|
||||
|
||||
// Not offered when turned off, or with Y up as the default orientation.
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
app.preferences.offer_stand_up = false;
|
||||
app.open_path(&path);
|
||||
assert!(app.stand_up.is_none());
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
app.preferences.up_axis = "y".into();
|
||||
app.open_path(&path);
|
||||
assert!(app.stand_up.is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orbiting_around_the_cursor_keeps_that_point_still() {
|
||||
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
|
||||
|
||||
@@ -219,3 +219,184 @@ fn chamfer_types_send_their_values() {
|
||||
let e = crate::dialogs::for_feature(&app, ch, None).unwrap();
|
||||
assert!(matches!(&e.kind, Kind::Fillet { chamfer: true, ctype: 2, angle, flip: true, .. } if angle == "30 deg"), "{:?}", e.kind);
|
||||
}
|
||||
|
||||
/// Apply a dialog's commands to the session; the new feature's id.
|
||||
fn apply(app: &mut SolveApp, d: &Dialog) -> u64 {
|
||||
for (id, p) in apply_commands(app, d).unwrap() {
|
||||
app.session.execute(&id, &p).unwrap();
|
||||
}
|
||||
app.session.doc.features.last().unwrap().id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_direction_and_tangent_chain_round_trip() {
|
||||
let mut app = boxed();
|
||||
let top = face(&app, Vec3::new(20.0, 15.0, 20.0));
|
||||
let mut d = start(&mut app, vec![top], "solid.shell");
|
||||
// Tangent Chain is on, as in Fusion; Direction Outside.
|
||||
assert_eq!(d.extra.get("tangent_chain"), Some(&json!(true)));
|
||||
d.extra.insert("direction".into(), json!("outside"));
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert_eq!((p["direction"].clone(), p["tangent_chain"].clone()), (json!("outside"), json!(true)));
|
||||
let id = apply(&mut app, &d);
|
||||
let e = crate::dialogs::for_feature(&app, id, None).unwrap();
|
||||
assert_eq!(e.extra.get("direction"), Some(&json!("outside")));
|
||||
assert_eq!(e.extra.get("tangent_chain"), Some(&json!(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_fillet_round_trip() {
|
||||
let mut app = boxed();
|
||||
let e0 = edge(&app, Vec3::new(40.0, 15.0, 20.0));
|
||||
let mut d = start(&mut app, vec![e0], "solid.fillet");
|
||||
d.inputs[0].items.truncate(1);
|
||||
d.extra.insert("type".into(), json!("variable"));
|
||||
d.extra.insert("radius2".into(), json!("4 mm"));
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert_eq!((p["type"].clone(), p["radius2"].clone()), (json!("variable"), json!("4 mm")));
|
||||
assert!(p["start"].is_array(), "{p}");
|
||||
let id = apply(&mut app, &d);
|
||||
let e = crate::dialogs::for_feature(&app, id, None).unwrap();
|
||||
assert_eq!(e.extra.get("type"), Some(&json!("variable")));
|
||||
assert_eq!(e.extra.get("radius2"), Some(&json!("4 mm")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_fillet_mid_radii_round_trip() {
|
||||
let mut app = boxed();
|
||||
let e0 = edge(&app, Vec3::new(40.0, 15.0, 20.0));
|
||||
let mut d = start(&mut app, vec![e0], "solid.fillet");
|
||||
d.inputs[0].items.truncate(1);
|
||||
d.extra.insert("type".into(), json!("variable"));
|
||||
d.extra.insert("radius2".into(), json!("2 mm"));
|
||||
d.extra.insert("mid".into(), json!([[0.5, "4 mm"]]));
|
||||
d.extra.insert("smooth".into(), json!(true));
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert_eq!((p["mid"].clone(), p["smooth"].clone()), (json!([[0.5, "4 mm"]]), json!(true)), "{p}");
|
||||
let id = apply(&mut app, &d);
|
||||
let e = crate::dialogs::for_feature(&app, id, None).unwrap();
|
||||
assert_eq!(e.extra.get("mid"), Some(&json!([[0.5, "4 mm"]])));
|
||||
assert_eq!(e.extra.get("smooth"), Some(&json!(true)));
|
||||
// All middle radii removed: a plain two-radius fillet again.
|
||||
let mut d = e;
|
||||
d.extra.insert("mid".into(), json!([]));
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
// (an edit: the feature's new parameters)
|
||||
assert_eq!(p["params"]["mid"], json!([]), "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_options_reach_their_commands() {
|
||||
let mut app = boxed();
|
||||
// Thread: Modeled.
|
||||
let side = face(&app, Vec3::new(20.0, 0.0, 10.0));
|
||||
let mut d = start(&mut app, vec![side.clone()], "solid.thread");
|
||||
d.extra.insert("modeled".into(), json!(true));
|
||||
assert_eq!(apply_commands(&app, &d).unwrap()[0].1["modeled"], json!(true));
|
||||
// Combine into a new component.
|
||||
app.dialog = None;
|
||||
let mut d = start(&mut app, vec![], "solid.combine");
|
||||
d.extra.insert("new_component".into(), json!(true));
|
||||
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }];
|
||||
d.inputs[1].items = vec![Sel::Body { name: "Body2".into() }];
|
||||
assert_eq!(apply_commands(&app, &d).unwrap()[0].1["new_component"], json!(true));
|
||||
// Hole: To Object sends the place to drill to instead of a depth.
|
||||
app.dialog = None;
|
||||
let top = face(&app, Vec3::new(20.0, 15.0, 20.0));
|
||||
let mut d = start(&mut app, vec![top], "solid.hole");
|
||||
d.inputs.push(crate::selection::SelInput::new("To Object", crate::selection::FACES | crate::selection::VERTICES, false));
|
||||
d.inputs[1].items = vec![face(&app, Vec3::new(20.0, 15.0, 0.0))];
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert!(p["to"].is_array() && p.get("depth").is_none(), "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_fill_finds_the_cells_and_fills_the_ticked_ones() {
|
||||
let mut app = app_with(json!([
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20}},
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20, "corner": [10, 10, 0], "operation": "new"}}
|
||||
]));
|
||||
let mut d = start(&mut app, vec![], "SurfaceSculpt");
|
||||
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }, Sel::Body { name: "Body2".into() }];
|
||||
app.dialog = Some(d);
|
||||
// The dialog finds the cells on its next frame.
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(Default::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())).textures_delta.clear();
|
||||
let mut d = app.dialog.take().unwrap();
|
||||
let Kind::BoundaryFill { cells, .. } = &mut d.kind else { panic!() };
|
||||
// Only A, only B, and both.
|
||||
assert_eq!(cells.len(), 3, "{cells:?}");
|
||||
assert!(cells.iter().any(|c| c.1 == "Inside Body1, Body2"));
|
||||
// Fill just the overlap.
|
||||
for c in cells.iter_mut() {
|
||||
c.2 = c.1 == "Inside Body1, Body2";
|
||||
}
|
||||
let id = apply(&mut app, &d);
|
||||
assert!(app.session.model.result(id).is_some_and(|r| r.error.is_none()));
|
||||
// A new body filling the 10 x 10 x 20 overlap.
|
||||
let vols: Vec<f64> = app.session.model.state().bodies.iter().map(|b| b.mesh().measure().volume).collect();
|
||||
assert!(vols.len() == 3 && vols.iter().any(|v| (v - 2000.0).abs() < 1.0), "{vols:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_snaps_to_centres_and_edge_middles() {
|
||||
let mut app = boxed();
|
||||
let mut d = start(&mut app, vec![], "solid.align");
|
||||
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }];
|
||||
d.inputs[1].items = vec![face(&app, Vec3::new(20.0, 15.0, 0.0))];
|
||||
d.inputs[2].items = vec![edge(&app, Vec3::new(20.0, 0.0, 20.0))];
|
||||
d.extra.insert("from_snap".into(), json!("center"));
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert_eq!(p["from"]["snap"], json!("face_center"));
|
||||
assert_eq!(p["to"]["snap"], json!("edge_mid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_dialogs_make_their_commands() {
|
||||
// A planar patch of a sketch's rectangle, then thickened; Edit Feature brings both back.
|
||||
let mut app = app_with(json!([
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [30, 20]}},
|
||||
{"command": "sketch.finish", "params": {}}
|
||||
]));
|
||||
let sk = app.session.doc.features.last().unwrap().id;
|
||||
let mut d = start(&mut app, vec![], "surface.patch");
|
||||
d.inputs[0].items = vec![Sel::Profile { sketch: sk, index: 0 }];
|
||||
let patch = {
|
||||
for (id, p) in apply_commands(&app, &d).unwrap() {
|
||||
app.session.execute(&id, &p).unwrap();
|
||||
}
|
||||
app.session.doc.features.last().unwrap().id
|
||||
};
|
||||
let surf = app.session.model.state().bodies.last().unwrap().name.clone();
|
||||
assert!(matches!(crate::dialogs::for_feature(&app, patch, None).unwrap().kind, Kind::Surface(_)));
|
||||
app.dialog = None;
|
||||
let mut d = start(&mut app, vec![], "surface.thicken");
|
||||
d.inputs[0].items = vec![Sel::Body { name: surf }];
|
||||
let (id, p) = apply_commands(&app, &d).unwrap().remove(0);
|
||||
assert_eq!(id, "surface.thicken");
|
||||
app.session.execute(&id, &p).unwrap();
|
||||
let v = app.session.model.state().bodies.iter().map(|b| b.mesh().measure().volume).fold(0.0, f64::max);
|
||||
assert!((v - 30.0 * 20.0 * 2.0).abs() < 1.0, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_body_by_a_curved_face_sends_it_as_a_tool() {
|
||||
let mut app = app_with(json!([
|
||||
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}},
|
||||
{"command": "solid.cylinder", "params": {"diameter": 20, "height": 40, "base": [20, 15, -10], "operation": "new"}}
|
||||
]));
|
||||
let st = app.session.model.state();
|
||||
let cyl = st.bodies.iter().find(|b| b.name != "Body1").unwrap();
|
||||
let m = cyl.mesh();
|
||||
// A point on the round side.
|
||||
let p = Vec3::new(30.0, 15.0, 5.0);
|
||||
let t = m.triangles.iter().position(|t| crate::dialogs::point_tri_dist(p, m.tri(t).unwrap()) < 0.3).unwrap();
|
||||
let side = Sel::Face { body: cyl.name.clone(), index: m.tri_face[t] as usize, point: p };
|
||||
drop(st);
|
||||
let mut d = start(&mut app, vec![], "solid.split_body");
|
||||
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }];
|
||||
d.inputs[1].items = vec![side];
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert!(p["tool"]["point"].is_array() && p.get("plane").is_none(), "{p}");
|
||||
}
|
||||
|
||||
@@ -270,12 +270,19 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built {
|
||||
}
|
||||
let nf = am.tri_face.iter().max().map_or(0, |m| *m as usize + 1);
|
||||
let added: Vec<bool> = (0..nf).map(|f| old.as_ref().is_none_or(|bm| face_is_new(&am, f, bm))).collect();
|
||||
// Material removed from a body (a hole, a pocket): its new faces also show through the
|
||||
// body in the cut colour, so the cut is seen from any side.
|
||||
let cut = old.as_ref().is_some_and(|bm| am.measure().volume < bm.measure().volume - 1e-6);
|
||||
for (t, f) in am.triangles.iter().zip(&am.tri_face) {
|
||||
let col = if added.get(*f as usize).copied().unwrap_or(true) { c.added } else { c.body };
|
||||
let new = added.get(*f as usize).copied().unwrap_or(true);
|
||||
let col = if new { c.added } else { c.body };
|
||||
for k in t {
|
||||
let i = *k as usize;
|
||||
if let (Some(p), Some(n)) = (am.positions.get(i), am.normals.get(i)) {
|
||||
sc.tri(p.to_f32(), n.to_f32(), col);
|
||||
if new && cut {
|
||||
sc.xray_tri(p.to_f32(), n.to_f32(), c.removed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,6 +371,19 @@ mod tests {
|
||||
bytes.chunks(stride).filter(|v| v.get(24..28) == Some(&col[..])).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_shows_its_cut_through_the_body() {
|
||||
let mut s = solvecraft_engine::Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let p = s.preview(&[("solid.hole".to_string(), json!({"position": [20, 15, 20], "diameter": 5, "depth": 10}))]).unwrap();
|
||||
let b = build(&p.before, &p.after, colors());
|
||||
// The hole's walls are drawn see-through in the cut colour (seen from any side).
|
||||
assert!(count(&b.scene.xray, 28, colors().removed) > 0);
|
||||
// A feature that only adds material shows no cut.
|
||||
let p = s.preview(&[("solid.box".to_string(), json!({"length": 10, "width": 10, "height": 30, "operation": "join"}))]).unwrap();
|
||||
assert_eq!(count(&build(&p.before, &p.after, colors()).scene.xray, 28, colors().removed), 0);
|
||||
}
|
||||
|
||||
fn plate() -> Session {
|
||||
let mut s = Session::default();
|
||||
for (id, p) in [
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`,
|
||||
//! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}`
|
||||
//! or `{"sketch": [x,y]}` (the sketch being edited)
|
||||
//! - `{"queued": "engine.execute", "params": {…}, "min_frames"?: n, "max_frames"?: n}`: a request sent the way an
|
||||
//! agent's arrive (through the app's request queue, answered between frames, so the agent
|
||||
//! cursor can act it out first); fails if the answer came in fewer than `min_frames` frames
|
||||
//! - `{"call": "ui.view", "params": {…}}`: any other control request (`fail: true` expects an
|
||||
//! error)
|
||||
//! - `{"make_image": "pic.png"}`: a picture in the scenario's folder
|
||||
@@ -21,9 +24,12 @@
|
||||
//! - `{"camera": {"yaw": rad, "pitch": rad}}` puts the camera at an orbit; `{"view_anim": "top",
|
||||
//! "max_deg": 10}` animates to a view and fails if any frame turns the view more than that
|
||||
//! - AT may also be `{"plane": "XY"}` (the middle of an origin plane's square), `{"axis": "Z"}`,
|
||||
//! `{"dimension": "d1"}` or `{"handle": "arrow"}` (a manipulator handle: see [`publish_handle`])
|
||||
//! `{"dimension": "d1"}`, `{"handle": "arrow"}` (a manipulator handle: see [`publish_handle`]),
|
||||
//! `{"browser": "Bodies"}` (a browser row by its label) or `{"chevron": "Bodies"}` (its fold arrow)
|
||||
//! - `{"autosave": true}`: autosave into the scenario's recovery folder; `{"restart": "crash" |
|
||||
//! "close"}`: the app dies (or closes) and a new one starts on the same folders
|
||||
//! - `{"script": "examples/…/x.json"}`: run a command script through the engine; `"$DIR"` in a
|
||||
//! `call` step's string parameters is the scenario's folder
|
||||
//! - `{"note": "…"}`: a comment; `{"pending": "why"}`: the scenario waits for a fix (its test
|
||||
//! is ignored)
|
||||
//! - `{"expect": {…}}`: checks, see [`check`]; `{"until": {…}}` waits (frames) until they pass
|
||||
@@ -92,6 +98,16 @@ pub fn clear_handles() {
|
||||
COUNTS.with(|h| h.borrow_mut().clear());
|
||||
}
|
||||
|
||||
/// `v` with "$DIR" in its strings replaced by `dir`.
|
||||
fn with_dir(v: Value, dir: &str) -> Value {
|
||||
match v {
|
||||
Value::String(t) => Value::String(t.replace("$DIR", dir)),
|
||||
Value::Array(a) => Value::Array(a.into_iter().map(|x| with_dir(x, dir)).collect()),
|
||||
Value::Object(o) => Value::Object(o.into_iter().map(|(k, x)| (k, with_dir(x, dir))).collect()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh folder for one scenario's files.
|
||||
fn scratch_dir() -> PathBuf {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@@ -112,6 +128,8 @@ pub struct Harness {
|
||||
pub dir: PathBuf,
|
||||
/// The file the next Open picker returns (a `choose_file` step).
|
||||
next_file: Rc<RefCell<Option<String>>>,
|
||||
/// The app's request queue, opened by the first queued request.
|
||||
queue: Option<std::sync::mpsc::Sender<ControlRequest>>,
|
||||
time: f64,
|
||||
size: egui::Vec2,
|
||||
}
|
||||
@@ -139,6 +157,7 @@ impl Harness {
|
||||
output: Default::default(),
|
||||
dir: scratch_dir(),
|
||||
next_file: Rc::new(RefCell::new(None)),
|
||||
queue: None,
|
||||
time: 0.0,
|
||||
size: egui::vec2(1600.0, 1000.0),
|
||||
};
|
||||
@@ -201,6 +220,28 @@ impl Harness {
|
||||
}
|
||||
|
||||
/// One control request, then frames until its input has been handled.
|
||||
/// Send a request through the app's queue, as the control channel does, and run frames
|
||||
/// until it is answered: (the reply, frames it took).
|
||||
pub fn call_queued(&mut self, method: &str, params: Value) -> (Value, usize) {
|
||||
let tx = self.queue.get_or_insert_with(|| {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
self.app.control_rx = Some(rx);
|
||||
tx
|
||||
});
|
||||
let (req, rx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
return (json!({"ok": false, "error": "the request queue is closed"}), 0);
|
||||
}
|
||||
for n in 1..=3000 {
|
||||
self.frame();
|
||||
if let Ok(v) = rx.try_recv() {
|
||||
self.settle();
|
||||
return (v, n);
|
||||
}
|
||||
}
|
||||
(json!({"ok": false, "error": "no answer in 3000 frames"}), 3000)
|
||||
}
|
||||
|
||||
pub fn call(&mut self, method: &str, params: Value) -> Value {
|
||||
let (req, _rx) = ControlRequest::new(method, params);
|
||||
let out = match handle(&mut self.app, &self.ctx, &req) {
|
||||
@@ -328,8 +369,36 @@ impl Harness {
|
||||
let [x1, y1] = self.at(d.get(1).unwrap_or(&Value::Null))?;
|
||||
let steps = s.get("steps").cloned().unwrap_or(json!(8));
|
||||
self.call("ui.drag", json!({"x0": x0, "y0": y0, "x1": x1, "y1": y1, "steps": steps, "shift": flag("shift"), "ctrl": flag("ctrl")}))
|
||||
} else if let Some(m) = s.get("queued").and_then(Value::as_str) {
|
||||
let (v, frames) = self.call_queued(m, s.get("params").cloned().unwrap_or(json!({})));
|
||||
let min = s.get("min_frames").and_then(Value::as_u64).unwrap_or(0);
|
||||
if (frames as u64) < min {
|
||||
return Err(format!("{m} was answered after {frames} frames: the agent cursor did not act it out ({min})"));
|
||||
}
|
||||
if let Some(max) = s.get("max_frames").and_then(Value::as_u64)
|
||||
&& frames as u64 > max
|
||||
{
|
||||
return Err(format!("{m} was answered after {frames} frames, more than {max}"));
|
||||
}
|
||||
v
|
||||
} else if let Some(f) = s.get("script").and_then(Value::as_str) {
|
||||
// A command script (`{"commands": [{command, params}]}`, path from the repository
|
||||
// root) run through the engine, as `solvecraft-cli run` does.
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(f);
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{f}: {e}"))?;
|
||||
let v: Value = serde_json::from_str(&text).map_err(|e| format!("{f}: {e}"))?;
|
||||
for (i, c) in v["commands"].as_array().into_iter().flatten().enumerate() {
|
||||
let r = self.call("engine.execute", json!({"command": c["command"], "params": c.get("params").cloned().unwrap_or(json!({}))}));
|
||||
if r["ok"] != true {
|
||||
return Err(format!("{f}: step {} ({}): {}", i + 1, c["command"], r["error"]));
|
||||
}
|
||||
}
|
||||
self.frames(2);
|
||||
return Ok(());
|
||||
} else if let Some(m) = s.get("call").and_then(Value::as_str) {
|
||||
self.call(m, s.get("params").cloned().unwrap_or(json!({})))
|
||||
// "$DIR" in a string parameter is the scenario's folder.
|
||||
let params = with_dir(s.get("params").cloned().unwrap_or(json!({})), &self.dir.to_string_lossy());
|
||||
self.call(m, params)
|
||||
} else if s.get("note").is_some() || s.get("pending").is_some() {
|
||||
return Ok(());
|
||||
} else if let Some(name) = s.get("shot").and_then(Value::as_str) {
|
||||
@@ -455,7 +524,7 @@ fn approx(got: f64, want: &Value, what: &str) -> Result<(), String> {
|
||||
/// Checks: `bodies` (count), `volume` / `area` (total, number or [value, rel tol]), `faces`,
|
||||
/// `features` (count), `errors` (timeline features in error, default 0), `dialog` (null: none
|
||||
/// open, or a word its kind must contain), `tool` (null or command id), `sketching` (bool),
|
||||
/// `selection` (count), `param` ({name: value}), `sketch_status` (solved|…), `dof`, `query`
|
||||
/// `selection` (count), `glyphs` ({constraint id: count}, none overlapping), `param` ({name: value}), `sketch_status` (solved|…), `dof`, `query`
|
||||
/// ({command, params?, path, value}: a command's result).
|
||||
pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
let doc = h.call("document.inspect", json!({"measure": true}))["result"].clone();
|
||||
@@ -513,6 +582,30 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
return Err(format!("handle order: got {xs:?}"));
|
||||
}
|
||||
}
|
||||
"camera" => {
|
||||
// {"camera": {"yaw_deg": y, "pitch_deg": p, "back"?: [x,y,z]}}: where the camera
|
||||
// looks from (yaw and pitch in its up-axis frame, ±0.5°),
|
||||
// once any view animation has finished.
|
||||
for _ in 0..240 {
|
||||
if h.app.cam_anim.is_none() {
|
||||
break;
|
||||
}
|
||||
h.frame();
|
||||
}
|
||||
let (yaw, pitch) = (h.app.cam.yaw.to_degrees(), h.app.cam.pitch.to_degrees());
|
||||
let near = |got: f64, want: &Value| want.as_f64().is_none_or(|w| ((got - w + 540.0).rem_euclid(360.0) - 180.0).abs() < 0.5);
|
||||
if !near(yaw, &v["yaw_deg"]) || !near(pitch, &v["pitch_deg"]) {
|
||||
return Err(format!("camera yaw {yaw:.2}°, pitch {pitch:.2}°, want {v}"));
|
||||
}
|
||||
// "back": the world direction from the target to the eye.
|
||||
if let Some(want) = v["back"].as_array() {
|
||||
let b = h.app.cam.back();
|
||||
let w = |i: usize| want.get(i).and_then(Value::as_f64).unwrap_or(f64::NAN);
|
||||
if (b.x - w(0)).abs() > 1e-3 || (b.y - w(1)).abs() > 1e-3 || (b.z - w(2)).abs() > 1e-3 {
|
||||
return Err(format!("camera looks from {b:?}, want {want:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
"count" => {
|
||||
// {"count": {"name": n, "value": x}}: a published count.
|
||||
let n = v["name"].as_str().unwrap_or_default();
|
||||
@@ -578,6 +671,21 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
return Err(format!("tool: got {}, want {v}", ui["tool"]));
|
||||
}
|
||||
}
|
||||
"glyphs" => {
|
||||
// {constraint id: glyphs drawn for it}; glyphs never overlap one another.
|
||||
let g = crate::sketch_tools::glyph_positions();
|
||||
for (id, n) in v.as_object().ok_or("glyphs takes {constraint id: count}")? {
|
||||
let got = g.iter().filter(|(x, _)| x == id).count();
|
||||
if n.as_u64() != Some(got as u64) {
|
||||
return Err(format!("glyphs of {id}: got {got}, want {n} (drawn: {g:?})"));
|
||||
}
|
||||
}
|
||||
for (i, (a, p)) in g.iter().enumerate() {
|
||||
if let Some((b, _)) = g.iter().skip(i + 1).find(|(_, q)| q.distance(*p) < 14.0) {
|
||||
return Err(format!("glyphs of {a} and {b} overlap at {p:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
"sketching" => {
|
||||
if doc["active_sketch"].is_null() == v.as_bool().unwrap_or(false) {
|
||||
return Err(format!("sketching: want {v}, active sketch {}", doc["active_sketch"]));
|
||||
@@ -738,6 +846,20 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
return Err(format!("start page open: got {}, want {v}", ui["home"]));
|
||||
}
|
||||
}
|
||||
"prefs" => {
|
||||
// {key: value…}: those entries of `ui.prefs` (preferences, `dialog`, `dark`…).
|
||||
let got = h.call("ui.prefs", json!({}))["result"].clone();
|
||||
for (pk, pv) in v.as_object().into_iter().flatten() {
|
||||
if got[pk.as_str()] != *pv {
|
||||
return Err(format!("preference {pk}: got {}, want {pv}", got[pk.as_str()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
"quit_requested" | "quitting" => {
|
||||
if ui[k] != *v {
|
||||
return Err(format!("{k}: got {}, want {v}", ui[k]));
|
||||
}
|
||||
}
|
||||
"documents" => {
|
||||
if ui["documents"] != *v {
|
||||
return Err(format!("open designs: got {}, want {v}", ui["documents"]));
|
||||
@@ -748,6 +870,27 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
return Err(format!("design name: got {}, want {v}", doc["name"]));
|
||||
}
|
||||
}
|
||||
"browser_has" | "browser_lacks" => {
|
||||
// Browser rows on screen, by label.
|
||||
let r = h.call("ui.browser", json!({}));
|
||||
let labels: Vec<String> =
|
||||
r["result"]["rows"].as_array().into_iter().flatten().filter_map(|x| x["label"].as_str().map(str::to_string)).collect();
|
||||
for want in v.as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
let there = labels.iter().any(|l| l == want);
|
||||
if there != (k.as_str() == "browser_has") {
|
||||
return Err(format!("browser row `{want}` {}: rows {labels:?}", if there { "still shown" } else { "missing" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
"browser_selected" => {
|
||||
// Nothing selected and no component picked (a fold click selects nothing).
|
||||
let r = h.call("ui.browser", json!({}));
|
||||
let none = r["result"]["selection"].as_array().is_none_or(|a| a.is_empty())
|
||||
&& r["result"]["picked_components"].as_array().is_none_or(|a| a.is_empty());
|
||||
if none == v.as_bool().unwrap_or(false) {
|
||||
return Err(format!("browser selection: {}", r["result"]));
|
||||
}
|
||||
}
|
||||
"query" => {
|
||||
// {command, params?, path: [keys or indices], value}: a command's result at path.
|
||||
let cmd = v["command"].as_str().ok_or("query needs `command`")?;
|
||||
|
||||
@@ -152,6 +152,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
let mut picked: Option<&'static str> = None;
|
||||
let mut pin: Option<&'static str> = None;
|
||||
let mut menu: Option<(Pos2, &'static str)> = None;
|
||||
let h = 380.0;
|
||||
let pos = pos2(
|
||||
at.x.clamp(screen.left() + 4.0, (screen.right() - W - 4.0).max(screen.left())),
|
||||
@@ -237,6 +238,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
} else if resp.clicked() && enabled {
|
||||
picked = Some(id);
|
||||
}
|
||||
if resp.secondary_clicked() {
|
||||
menu = Some((resp.interact_pointer_pos().unwrap_or(r.left_bottom()), id));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -244,6 +248,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if let Some(id) = pin {
|
||||
app.sbox.toggle_pin(id);
|
||||
}
|
||||
// A row's menu: pin to the toolbar or the shortcuts.
|
||||
if let Some((at, id)) = menu {
|
||||
crate::context_menu::open_for(app, at, crate::context_menu::Target::ToolbarCommand { id: id.to_string() });
|
||||
return;
|
||||
}
|
||||
if enter
|
||||
&& picked.is_none()
|
||||
&& let Some((id, ..)) = list.get(app.sbox.index)
|
||||
|
||||
@@ -291,7 +291,7 @@ pub fn refine_snap(
|
||||
|
||||
/// The constraint glyph under a screen position (its constraint id).
|
||||
pub fn glyph_at(pos: Pos2) -> Option<String> {
|
||||
GLYPHS.with(|g| g.borrow().iter().find(|(_, c)| c.distance(pos) <= 7.0).map(|(id, _)| id.clone()))
|
||||
GLYPHS.with(|g| g.borrow().iter().find(|(_, c)| c.distance(pos) <= GLYPH * 0.6).map(|(id, _)| id.clone()))
|
||||
}
|
||||
|
||||
/// Constraint glyphs drawn last frame: (constraint id, screen centre).
|
||||
@@ -304,7 +304,7 @@ pub fn click_glyph(app: &mut SolveApp, _proj: &Proj, pos: Pos2) -> bool {
|
||||
if app.session.active_sketch.is_none() {
|
||||
return false;
|
||||
}
|
||||
let hit = GLYPHS.with(|g| g.borrow().iter().find(|(_, c)| c.distance(pos) <= 7.0).map(|(id, _)| id.clone()));
|
||||
let hit = GLYPHS.with(|g| g.borrow().iter().find(|(_, c)| c.distance(pos) <= GLYPH * 0.6).map(|(id, _)| id.clone()));
|
||||
let used = hit.is_some();
|
||||
PICKED.with(|p| *p.borrow_mut() = hit.clone());
|
||||
if let Some(id) = hit {
|
||||
@@ -891,7 +891,10 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) {
|
||||
|
||||
/// Constraint glyphs of the active sketch: a small badge per constraint at its anchor
|
||||
/// (conflicting ones red).
|
||||
fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
/// Glyph size and spacing (screen pixels).
|
||||
const GLYPH: f32 = 15.0;
|
||||
|
||||
fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option<Pos2>) {
|
||||
let Some(sid) = active_sketch(app) else { return };
|
||||
let st = app.session.world_state();
|
||||
let Some(ss) = st.sketch(sid) else { return };
|
||||
@@ -900,87 +903,172 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
return;
|
||||
}
|
||||
let tk = crate::theme::Tokens::get();
|
||||
let mut placed: Vec<Pos2> = Vec::new();
|
||||
let picked = PICKED.with(|p| p.borrow().clone());
|
||||
let mut drawn: Vec<(String, Pos2)> = Vec::new();
|
||||
// Where each glyph goes: beside its entity (to its left, away from the line), every entity
|
||||
// of a two-entity constraint getting one; glyphs that would overlap step along.
|
||||
let mut placed: Vec<(String, Pos2, Pos2)> = Vec::new();
|
||||
for c in &ss.sketch.constraints {
|
||||
if c.kind.is_dimension() {
|
||||
continue;
|
||||
}
|
||||
let Some(at) = glyph_anchor(&ss.sketch, &c.kind) else { continue };
|
||||
let Some(mut sp) = proj.to_screen(ss.plane.to_world(at)) else { continue };
|
||||
sp += egui::vec2(9.0, -9.0);
|
||||
// Keep glyphs at one spot from overlapping.
|
||||
while placed.iter().any(|q| q.distance(sp) < 12.0) {
|
||||
sp += egui::vec2(13.0, 0.0);
|
||||
for (at, dir) in glyph_anchors(&ss.sketch, &c.kind) {
|
||||
let Some(base) = proj.to_screen(ss.plane.to_world(at)) else { continue };
|
||||
// The entity's direction on screen, and its side.
|
||||
let side = match dir.and_then(|d| proj.to_screen(ss.plane.to_world(at + d))) {
|
||||
Some(q) => {
|
||||
let t = (q - base).normalized();
|
||||
if t.length() > 0.5 { egui::vec2(-t.y, t.x) } else { egui::vec2(0.7, -0.7) }
|
||||
}
|
||||
None => egui::vec2(0.7, -0.7),
|
||||
};
|
||||
let step = if dir.is_some() { egui::vec2(side.y, -side.x) } else { egui::vec2(1.0, 0.0) };
|
||||
let mut sp = base + side * (GLYPH * 0.9);
|
||||
for _ in 0..12 {
|
||||
if !placed.iter().any(|(_, q, _)| q.distance(sp) < GLYPH + 1.0) {
|
||||
break;
|
||||
}
|
||||
sp += step * (GLYPH + 2.0);
|
||||
}
|
||||
placed.push((c.id.clone(), sp, base));
|
||||
}
|
||||
placed.push(sp);
|
||||
let failing = ss.report.failing.contains(&c.id);
|
||||
let selected = picked.as_deref() == Some(c.id.as_str())
|
||||
|| app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::SketchConstraint { id } if *id == c.id));
|
||||
}
|
||||
let hovered = hover.and_then(|h| placed.iter().find(|(_, q, _)| q.distance(h) <= GLYPH * 0.6).map(|(id, _, _)| id.clone()));
|
||||
// The hovered (or selected) constraint's entities lit up.
|
||||
if let Some(id) = hovered.as_ref().or(picked.as_ref())
|
||||
&& let Some(c) = ss.sketch.constraints.iter().find(|c| &c.id == id)
|
||||
{
|
||||
let (cs, ps) = constraint_entities(&c.kind);
|
||||
for ci in cs {
|
||||
let pts: Vec<Pos2> = ss.sketch.polyline(ci).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect();
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(3.0, tk.accent.gamma_multiply(0.7))));
|
||||
}
|
||||
for pi in ps {
|
||||
if let Some(q) = ss.sketch.point(pi).and_then(|q| proj.to_screen(ss.plane.to_world(q))) {
|
||||
painter.circle_filled(q, 4.5, tk.accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut drawn: Vec<(String, Pos2)> = Vec::new();
|
||||
for (id, sp, base) in &placed {
|
||||
let Some(c) = ss.sketch.constraints.iter().find(|c| &c.id == id) else { continue };
|
||||
let failing = ss.report.failing.contains(id);
|
||||
let selected = picked.as_deref() == Some(id.as_str())
|
||||
|| app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::SketchConstraint { id: x } if x == id));
|
||||
let lit = selected || hovered.as_deref() == Some(id.as_str());
|
||||
let col = if failing {
|
||||
tk.error
|
||||
} else if selected {
|
||||
} else if lit {
|
||||
tk.accent
|
||||
} else {
|
||||
tk.text_dim
|
||||
tk.glyph_text
|
||||
};
|
||||
drawn.push((c.id.clone(), sp));
|
||||
let r = egui::Rect::from_center_size(sp, egui::vec2(12.0, 12.0));
|
||||
painter.rect_filled(r, 2.0, tk.panel.gamma_multiply(0.85));
|
||||
painter.rect_stroke(r, 2.0, Stroke::new(0.8, col), egui::StrokeKind::Inside);
|
||||
painter.text(sp, egui::Align2::CENTER_CENTER, glyph_letter(c.kind.name()), egui::FontId::proportional(9.0), col);
|
||||
// A faint leader back to the entity when the glyph was pushed away from it.
|
||||
if sp.distance(*base) > GLYPH * 1.6 {
|
||||
painter.line_segment([*base, *sp], Stroke::new(0.8, tk.text_dim.gamma_multiply(0.6)));
|
||||
}
|
||||
let r = egui::Rect::from_center_size(*sp, egui::vec2(GLYPH, GLYPH));
|
||||
painter.rect_filled(r, 3.0, if lit { tk.accent_soft } else { tk.glyph_bg });
|
||||
painter.rect_stroke(r, 3.0, Stroke::new(1.0, if failing || lit { col } else { tk.glyph_edge }), egui::StrokeKind::Inside);
|
||||
match glyph_icon(c.kind.name()) {
|
||||
Some(icon) => crate::icons::paint(painter, r.shrink(2.0), icon, col, tk.glyph_bg, col),
|
||||
None => {
|
||||
painter.text(*sp, egui::Align2::CENTER_CENTER, glyph_letter(c.kind.name()), egui::FontId::proportional(10.5), col);
|
||||
}
|
||||
}
|
||||
drawn.push((id.clone(), *sp));
|
||||
}
|
||||
if let (Some(id), Some(h)) = (hovered, hover)
|
||||
&& let Some(c) = ss.sketch.constraints.iter().find(|c| c.id == id)
|
||||
{
|
||||
let label = format!("{} ({id})", c.kind.name());
|
||||
let at = h + egui::vec2(12.0, 12.0);
|
||||
let g = painter.layout_no_wrap(label, egui::FontId::proportional(11.0), tk.text);
|
||||
painter.rect_filled(egui::Rect::from_min_size(at, g.size()).expand(3.0), 3.0, tk.panel);
|
||||
painter.galley(at, g, tk.text);
|
||||
}
|
||||
GLYPHS.with(|g| *g.borrow_mut() = drawn);
|
||||
}
|
||||
|
||||
/// Toolbar icons used as glyphs (H and V stay letters, which tell them apart).
|
||||
fn glyph_icon(name: &str) -> Option<&'static str> {
|
||||
Some(match name {
|
||||
"Coincident" => "c_coincident",
|
||||
"Parallel" => "c_parallel",
|
||||
"Perpendicular" => "c_perpendicular",
|
||||
"Collinear" => "c_collinear",
|
||||
"Tangent" => "c_tangent",
|
||||
"Smooth" => "c_smooth",
|
||||
"Equal" => "c_equal",
|
||||
"Concentric" => "c_concentric",
|
||||
"MidPoint" => "c_midpoint",
|
||||
"Symmetry" => "c_symmetry",
|
||||
"Fix" => "c_fix",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The curves and points a constraint ties together.
|
||||
fn constraint_entities(k: &solvecraft_engine::sketch::ConstraintKind) -> (Vec<usize>, Vec<usize>) {
|
||||
use solvecraft_engine::sketch::ConstraintKind::*;
|
||||
match *k {
|
||||
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![], vec![p, q]),
|
||||
Fix { p } => (vec![], vec![p]),
|
||||
Midpoint { p, l } => (vec![l], vec![p]),
|
||||
PointOnCurve { p, c } => (vec![c], vec![p]),
|
||||
Symmetric { p, q, l } => (vec![l], vec![p, q]),
|
||||
Horizontal { l } | Vertical { l } => (vec![l], vec![]),
|
||||
Parallel { a, b }
|
||||
| Perpendicular { a, b }
|
||||
| Collinear { a, b }
|
||||
| Equal { a, b }
|
||||
| Concentric { a, b }
|
||||
| Tangent { a, b }
|
||||
| Smooth { a, b } => (vec![a, b], vec![]),
|
||||
_ => (vec![], vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
fn glyph_letter(name: &str) -> &'static str {
|
||||
match name {
|
||||
"Coincident" => "\u{25cf}",
|
||||
"Horizontal" => "H",
|
||||
"Vertical" => "V",
|
||||
"Parallel" => "//",
|
||||
"Perpendicular" => "\u{22a5}",
|
||||
"Collinear" => "\u{2261}",
|
||||
"Tangent" => "T",
|
||||
"Smooth" => "G2",
|
||||
"Equal" => "=",
|
||||
"Concentric" => "\u{25ce}",
|
||||
"MidPoint" => "M",
|
||||
"Symmetry" => "[]",
|
||||
"Fix" => "\u{1f512}",
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
fn glyph_anchor(sk: &solvecraft_engine::sketch::Sketch, k: &solvecraft_engine::sketch::ConstraintKind) -> Option<Vec2> {
|
||||
/// Where a constraint's glyphs go: one per entity (the middle of a curve, with its direction
|
||||
/// there; a point, without).
|
||||
fn glyph_anchors(sk: &solvecraft_engine::sketch::Sketch, k: &solvecraft_engine::sketch::ConstraintKind) -> Vec<(Vec2, Option<Vec2>)> {
|
||||
use solvecraft_engine::sketch::ConstraintKind::*;
|
||||
// Half way along the curve.
|
||||
let mid = |c: usize| -> Option<Vec2> {
|
||||
// Half way along the curve, and the direction there.
|
||||
let mid = |c: usize| -> Option<(Vec2, Option<Vec2>)> {
|
||||
let poly = sk.polyline(c);
|
||||
let total: f64 = poly.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let mut acc = 0.0;
|
||||
for w in poly.windows(2) {
|
||||
let l = w[0].dist(w[1]);
|
||||
if acc + l >= total * 0.5 && l > 0.0 {
|
||||
return Some(w[0].lerp(w[1], (total * 0.5 - acc) / l));
|
||||
return Some((w[0].lerp(w[1], (total * 0.5 - acc) / l), (w[1] - w[0]).normalized()));
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
poly.first().copied()
|
||||
poly.first().map(|p| (*p, None))
|
||||
};
|
||||
let pt = |p: usize| sk.point(p).map(|q| (q, None));
|
||||
match *k {
|
||||
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } | PointOnCurve { p, .. } => sk.point(p),
|
||||
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => Some((sk.point(p)? + sk.point(q)?) * 0.5),
|
||||
Horizontal { l } | Vertical { l } => mid(l),
|
||||
Parallel { a, .. }
|
||||
| Perpendicular { a, .. }
|
||||
| Collinear { a, .. }
|
||||
| Equal { a, .. }
|
||||
| Concentric { a, .. }
|
||||
| Tangent { a, .. }
|
||||
| Smooth { a, .. } => mid(a),
|
||||
_ => None,
|
||||
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } | PointOnCurve { p, .. } => pt(p).into_iter().collect(),
|
||||
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => {
|
||||
sk.point(p).zip(sk.point(q)).map(|(a, b)| ((a + b) * 0.5, None)).into_iter().collect()
|
||||
}
|
||||
Horizontal { l } | Vertical { l } => mid(l).into_iter().collect(),
|
||||
Parallel { a, b }
|
||||
| Perpendicular { a, b }
|
||||
| Collinear { a, b }
|
||||
| Equal { a, b }
|
||||
| Concentric { a, b }
|
||||
| Tangent { a, b }
|
||||
| Smooth { a, b } => [mid(a), mid(b)].into_iter().flatten().collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,7 +1090,7 @@ fn snap_hint(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
/// Per frame, after the viewport is drawn: constraint glyphs and the text-entry box.
|
||||
pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) {
|
||||
SHIFT.with(|c| c.set(ui.input(|i| i.modifiers.shift)));
|
||||
glyphs(app, painter, proj);
|
||||
glyphs(app, painter, proj, ui.input(|i| i.pointer.hover_pos()));
|
||||
snap_hint(app, painter, proj);
|
||||
combs(app, painter, proj);
|
||||
if DEFERRED.with(|d| d.replace(false)) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user