mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Compare commits
111 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ac803ab52 | |||
| 63fd838f55 | |||
| 71a4f3061b | |||
| 1227fab860 | |||
| 9a2a3cef8b | |||
| 3086c32a9a | |||
| 5249ae84ef | |||
| f3a689f284 | |||
| f17ff913d6 | |||
| 6f16b31771 | |||
| ab23fc9906 | |||
| 54a86dc6f5 | |||
| acf4669f73 | |||
| e042a7b555 | |||
| 96ee326424 | |||
| d6cdec40da | |||
| 49bd89de13 | |||
| 90ce5261b3 | |||
| 81be4e74b8 | |||
| 0321778d38 | |||
| b5fd55cce6 | |||
| b1ec9fc1ce | |||
| 29796762de | |||
| aa268f0f0e | |||
| 4a41de72ec | |||
| 524ef6b8c2 | |||
| 74f70614cc | |||
| 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
|
||||
+188
-9
@@ -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,185 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
changes:
|
||||
name: Select checks
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
native: ${{ steps.paths.outputs.native }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-tests
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
- name: Compile tests
|
||||
run: cargo test --workspace --profile ci --locked --no-run
|
||||
- name: Run tests
|
||||
# Every test binary at once (cargo test runs them one after another).
|
||||
run: cargo xtask test --locked
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
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,161 @@
|
||||
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
|
||||
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"
|
||||
|
||||
+329
-95
@@ -1,136 +1,370 @@
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -47,3 +49,4 @@ Generated-in-code assets are original and have no file to list:
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+13
-7
@@ -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 xtask test # 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 xtask test` (or `cargo test --workspace --profile ci`) |
|
||||
| UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | part of `cargo test` |
|
||||
| Fusion oracle | `cargo xtask oracle` | needs the local oracle data (see below) |
|
||||
|
||||
@@ -101,8 +102,13 @@ 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. `cargo xtask test` builds the tests with it and then runs every test binary at once
|
||||
(`cargo test` runs them one after another); arguments after `--` go to each binary, e.g.
|
||||
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
|
||||
depend on wall-clock timing.
|
||||
|
||||
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
|
||||
SolveCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's
|
||||
@@ -117,8 +123,8 @@ install. If you work on geometry and want to check against it, ask in an issue.
|
||||
comment on the issue so it gets the `in progress` label and two people don't build the same
|
||||
thing. For anything bigger than a bug fix that has no issue yet, open one first.
|
||||
2. Branch from `main` and keep the PR to one topic.
|
||||
3. Run `cargo xtask ci` before pushing. It runs fmt, clippy (`-D warnings`), the tests in
|
||||
release mode, the asset and layer checks, and the wasm build check. A PR has to pass it.
|
||||
3. Run `cargo xtask ci` before pushing. It runs fmt, the asset, licence and layer checks,
|
||||
clippy (`-D warnings`), then the tests with the wasm build check alongside. A PR has to pass it.
|
||||
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
|
||||
the expected ones).
|
||||
|
||||
|
||||
Generated
+14
-15
@@ -1753,8 +1753,6 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
[[package]]
|
||||
name = "matext4cgmath"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6541e181de37f70f0aceb25441823a3d0efa9cc1d23f475a0d3926678949178"
|
||||
dependencies = [
|
||||
"cgmath",
|
||||
"katexit",
|
||||
@@ -3059,7 +3057,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3068,11 +3066,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"solvecraft-ui-egui",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3081,7 +3080,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3095,7 +3094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3111,7 +3110,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3119,7 +3118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
@@ -3131,7 +3130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3146,7 +3145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3154,7 +3153,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3163,7 +3162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3174,7 +3173,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3188,7 +3187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4487,7 +4486,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+17
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
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"
|
||||
@@ -83,3 +98,4 @@ debug = 0
|
||||
truck-shapeops = { path = "vendor/truck-shapeops" }
|
||||
truck-stepio = { path = "vendor/truck-stepio" }
|
||||
truck-meshalgo = { path = "vendor/truck-meshalgo" }
|
||||
matext4cgmath = { path = "vendor/matext4cgmath" }
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
-2
@@ -1,6 +1,6 @@
|
||||
# SolveCraft roadmap
|
||||
|
||||
Status as of **2026-10-09**. SolveCraft targets Fusion-style parametric modelling (sketch →
|
||||
Status as of **2026-10-10**. SolveCraft targets Fusion-style parametric modelling (sketch →
|
||||
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
|
||||
|
||||
## At a glance
|
||||
@@ -10,12 +10,19 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
|
||||
| Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) |
|
||||
| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) |
|
||||
| Tests | 596 (41 of them UI scenarios): solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command |
|
||||
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
||||
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
|
||||
|
||||
## Scope (owner decision, 2026-10-07)
|
||||
|
||||
The issue batch adds preservation of blind pockets in through cuts, associative projected
|
||||
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
|
||||
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
|
||||
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
|
||||
acceptance scenarios now include board resizes; STEP reopening is automated. Inspection in
|
||||
Fusion remains an external acceptance step. See [WIP reconciliation](docs/wip-reconciliation.md).
|
||||
|
||||
In scope, in priority order:
|
||||
1. SOLID and SKETCH (now).
|
||||
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
|
||||
|
||||
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(()) }
|
||||
|
||||
@@ -10,7 +10,7 @@ fn plane_param(pl: &Value) -> Value {
|
||||
let origin_zero = pl.get("origin").and_then(Value::as_array).is_none_or(|o| o.iter().all(|x| x.as_f64().unwrap_or(0.0).abs() < 1e-12));
|
||||
let std = match r.to_ascii_uppercase().as_str() {
|
||||
"XY" => Some(([1.0, 0.0, 0.0], [0.0, 1.0, 0.0])),
|
||||
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
|
||||
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, -1.0])),
|
||||
"YZ" => Some(([0.0, 1.0, 0.0], [0.0, 0.0, 1.0])),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -153,7 +153,8 @@ fn try_bytes(bytes: &[u8]) {
|
||||
#[test]
|
||||
fn damaged_and_hostile_design_files_never_panic() {
|
||||
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
|
||||
let mut failures = Vec::new();
|
||||
// The cases come from one seeded sequence; trying them is independent, so threads share it.
|
||||
let mut all_cases: Vec<(String, String, Vec<u8>)> = Vec::new();
|
||||
for p in corpus() {
|
||||
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let bytes = std::fs::read(&p).unwrap();
|
||||
@@ -198,14 +199,28 @@ fn damaged_and_hostile_design_files_never_panic() {
|
||||
cases.push(("features repeated".into(), v.to_string().into_bytes()));
|
||||
cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes()));
|
||||
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
|
||||
for (what, b) in cases {
|
||||
if std::env::var_os("CORPUS_TRACE").is_some() {
|
||||
eprintln!("{name}: {what}");
|
||||
}
|
||||
if std::panic::catch_unwind(|| try_bytes(&b)).is_err() {
|
||||
failures.push(format!("{name}: {what}"));
|
||||
}
|
||||
}
|
||||
all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b)));
|
||||
}
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
|
||||
let failures: Vec<String> = std::thread::scope(|scope| {
|
||||
let workers: Vec<_> = (0..threads)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
let mut failures = Vec::new();
|
||||
while let Some((name, what, b)) = all_cases.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
if std::env::var_os("CORPUS_TRACE").is_some() {
|
||||
eprintln!("{name}: {what}");
|
||||
}
|
||||
if std::panic::catch_unwind(|| try_bytes(b)).is_err() {
|
||||
failures.push(format!("{name}: {what}"));
|
||||
}
|
||||
}
|
||||
failures
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
|
||||
});
|
||||
assert!(failures.is_empty(), "panics:\n{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
@@ -22,10 +22,20 @@ fn killed_mid_save_the_old_file_survives() {
|
||||
assert_eq!(round_of(&path).as_deref(), Some("0"));
|
||||
let mut kills = 0;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let modified = || std::fs::metadata(&path).and_then(|m| m.modified()).ok();
|
||||
for i in 0..25u64 {
|
||||
let before = modified();
|
||||
let mut child = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "100000"]).spawn().unwrap();
|
||||
// Let it get into its save loop (building the design takes a moment), then kill it.
|
||||
std::thread::sleep(Duration::from_millis(150 + (i * 37) % 200));
|
||||
// Wait until it is in its save loop (its first save replaced the file) and has reached a
|
||||
// round that varies from kill to kill, however busy the machine is; then kill it a
|
||||
// varying while later.
|
||||
let target = i % 10;
|
||||
let reached = || modified() != before && round_of(&path).and_then(|r| r.parse::<u64>().ok()).is_some_and(|r| r >= target);
|
||||
let t0 = std::time::Instant::now();
|
||||
while !reached() && t0.elapsed() < Duration::from_secs(20) && child.try_wait().unwrap().is_none() {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis((i * 7) % 30));
|
||||
if child.try_wait().unwrap().is_none() {
|
||||
child.kill().unwrap();
|
||||
kills += 1;
|
||||
|
||||
@@ -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;
|
||||
@@ -354,7 +376,8 @@ mod web {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs, DEPTH_BITS, 1);
|
||||
// eframe's web painter has no stencil buffer.
|
||||
app.set_wgpu(rs, DEPTH_BITS, 0, 1);
|
||||
}
|
||||
if query().contains("sample") {
|
||||
if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) {
|
||||
|
||||
@@ -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);
|
||||
@@ -55,6 +61,7 @@ fn services() -> Services {
|
||||
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
|
||||
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
|
||||
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
|
||||
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
|
||||
.add_filter("All files", &["*"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
@@ -63,6 +70,23 @@ fn services() -> Services {
|
||||
rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
||||
show_in_folder: Some(Box::new(|path| {
|
||||
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
|
||||
"explorer"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
} else {
|
||||
"xdg-open"
|
||||
});
|
||||
command.arg(folder);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x08000000);
|
||||
}
|
||||
let _ = command.spawn();
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +112,8 @@ fn msaa() -> u16 {
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
/// The section cap marks where it shows in the stencil.
|
||||
const STENCIL_BITS: u8 = 8;
|
||||
const MSAA: u16 = 4;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
@@ -123,9 +149,22 @@ fn main() -> eframe::Result {
|
||||
.with_title_shown(false)
|
||||
.with_app_id("ai.storyteller.solvecraft"),
|
||||
depth_buffer: DEPTH_BITS,
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
multisampling: samples,
|
||||
..Default::default()
|
||||
};
|
||||
#[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,
|
||||
@@ -137,7 +176,7 @@ fn main() -> eframe::Result {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs, DEPTH_BITS, u32::from(samples));
|
||||
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, u32::from(samples));
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,14 @@ pub struct Canvas {
|
||||
pub flip: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub visible: bool,
|
||||
/// The component it belongs to (its plane is in that component's frame, so it moves with
|
||||
/// the component's occurrence); 0 = the root.
|
||||
#[serde(default, skip_serializing_if = "is_root")]
|
||||
pub component: u64,
|
||||
}
|
||||
|
||||
fn is_root(c: &u64) -> bool {
|
||||
*c == 0
|
||||
}
|
||||
|
||||
fn half() -> f64 {
|
||||
@@ -57,7 +65,7 @@ impl Canvas {
|
||||
let f = if self.flip { -1.0 } else { 1.0 };
|
||||
[rot(-hw * f, -hh), rot(hw * f, -hh), rot(hw * f, hh), rot(-hw * f, hh)]
|
||||
}
|
||||
/// Corners in world coordinates.
|
||||
/// Corners on `plane` (in whatever frame the plane is given).
|
||||
pub fn world_corners(&self, plane: &Plane) -> [Vec3; 4] {
|
||||
self.corners().map(|p| plane.to_world(p))
|
||||
}
|
||||
@@ -66,6 +74,19 @@ impl Canvas {
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Document {
|
||||
/// A canvas's plane in the world: resolved in its component's frame, then placed where the
|
||||
/// component's occurrence puts it.
|
||||
pub fn canvas_plane(&self, vals: &std::collections::BTreeMap<String, crate::expr::Value>, c: &Canvas) -> crate::Result<Plane> {
|
||||
let p = self.resolve_plane(vals, &c.plane, 0)?;
|
||||
let m = self.component_transform(c.component);
|
||||
if crate::is_identity(&m) {
|
||||
return Ok(p);
|
||||
}
|
||||
Ok(Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)).unwrap_or(p))
|
||||
}
|
||||
}
|
||||
|
||||
/// Image kind and pixel size from the file header (PNG or JPEG).
|
||||
pub fn image_info(b: &[u8]) -> Option<(&'static str, [u32; 2])> {
|
||||
if b.len() > 24 && b.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
|
||||
@@ -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()],
|
||||
|
||||
+94
-17
@@ -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
|
||||
@@ -123,7 +125,8 @@ pub struct Extent {
|
||||
/// Optional second side distance (two-sided extrude), along the negative normal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub distance2: Option<String>,
|
||||
/// Start offset from the sketch plane (expression).
|
||||
/// Start offset from the sketch plane (expression), along the sketch normal whatever the
|
||||
/// direction.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub start_offset: Option<String>,
|
||||
/// Through all bodies in the direction(s) instead of `distance`.
|
||||
@@ -771,8 +774,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 {
|
||||
@@ -929,6 +940,66 @@ fn z_axis() -> Vec3 {
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
/// Sketches the feature is built from (profiles, paths, points): deleting one of them
|
||||
/// deletes the feature. Every kind is listed, so a new one has to say what it uses.
|
||||
pub fn input_sketches(&self) -> Vec<u64> {
|
||||
use FeatureKind as K;
|
||||
match self {
|
||||
K::Extrude { sketch, .. }
|
||||
| K::Revolve { sketch, .. }
|
||||
| K::Emboss { sketch, .. }
|
||||
| K::Rib { sketch, .. }
|
||||
| K::SheetBase { sketch, .. }
|
||||
| K::SheetContour { sketch, .. } => vec![*sketch],
|
||||
K::Sweep { sketch, path_sketch, .. } => vec![*sketch, *path_sketch],
|
||||
K::Pipe { path_sketch, .. } | K::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } => vec![*path_sketch],
|
||||
K::Loft { sections, .. } => sections.iter().map(|s| s.sketch).collect(),
|
||||
K::SheetFold { sketch, .. } | K::SplitFace { sketch, .. } | K::Patch { sketch, .. } => sketch.iter().copied().collect(),
|
||||
K::Hole { points, .. } => points.iter().map(|p| p.sketch).collect(),
|
||||
K::Sketch { .. }
|
||||
| K::Pattern { .. }
|
||||
| K::Fillet { .. }
|
||||
| K::Chamfer { .. }
|
||||
| K::Box { .. }
|
||||
| K::Cylinder { .. }
|
||||
| K::Sphere { .. }
|
||||
| K::Torus { .. }
|
||||
| K::Combine { .. }
|
||||
| K::Mirror { .. }
|
||||
| K::Thread { .. }
|
||||
| K::Shell { .. }
|
||||
| K::Draft { .. }
|
||||
| K::ConstructionPlane { .. }
|
||||
| K::Split { .. }
|
||||
| K::ConstructionAxis { .. }
|
||||
| K::ConstructionPoint { .. }
|
||||
| K::Scale { .. }
|
||||
| K::OffsetFace { .. }
|
||||
| K::DeleteFace { .. }
|
||||
| K::BoundingSolid { .. }
|
||||
| K::ReplaceFace { .. }
|
||||
| K::Align { .. }
|
||||
| K::Coil { .. }
|
||||
| K::SheetFlange { .. }
|
||||
| K::SheetHem { .. }
|
||||
| K::SheetUnfold { .. }
|
||||
| K::SheetConvert { .. }
|
||||
| K::Boss { .. }
|
||||
| K::Lip { .. }
|
||||
| K::SnapFit { .. }
|
||||
| K::Rest { .. }
|
||||
| K::BoundaryFill { .. }
|
||||
| K::Remove { .. }
|
||||
| K::Stitch { .. }
|
||||
| K::Thicken { .. }
|
||||
| K::SurfaceExtend { .. }
|
||||
| K::SurfaceTrim { .. }
|
||||
| K::Move { .. }
|
||||
| K::Import { .. }
|
||||
| K::MeshImport { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A sheet metal feature (its result depends on the sheet metal rules).
|
||||
pub fn is_plastic(&self) -> bool {
|
||||
matches!(self, FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. })
|
||||
@@ -1078,6 +1149,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, .. }
|
||||
@@ -1091,6 +1176,7 @@ impl FeatureKind {
|
||||
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
|
||||
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
|
||||
FeatureKind::Thread { face, .. } => vec![face],
|
||||
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1132,8 +1218,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, .. } => {
|
||||
@@ -1594,18 +1681,7 @@ impl Document {
|
||||
pub fn delete_feature(&mut self, id: u64) -> Result<Vec<u64>> {
|
||||
let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?;
|
||||
let mut gone = vec![id];
|
||||
for f in &self.features {
|
||||
match &f.kind {
|
||||
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } if *sketch == id => gone.push(f.id),
|
||||
FeatureKind::Sweep { sketch, path_sketch, .. } if *sketch == id || *path_sketch == id => gone.push(f.id),
|
||||
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.sketch == id) => gone.push(f.id),
|
||||
FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id),
|
||||
FeatureKind::Emboss { sketch, .. } | FeatureKind::Rib { sketch, .. } if *sketch == id => gone.push(f.id),
|
||||
FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } if *sketch == id => gone.push(f.id),
|
||||
FeatureKind::SheetFold { sketch: Some(s), .. } | FeatureKind::SplitFace { sketch: Some(s), .. } if *s == id => gone.push(f.id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id));
|
||||
self.features.retain(|f| !gone.contains(&f.id));
|
||||
// Configuration columns of deleted features go with them.
|
||||
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect();
|
||||
@@ -1794,7 +1870,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)
|
||||
}
|
||||
|
||||
+131
-12
@@ -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()
|
||||
@@ -371,6 +377,10 @@ pub struct Model {
|
||||
empty: Arc<ModelState>,
|
||||
/// Features recomputed by the last `evaluate`.
|
||||
pub last_recomputed: usize,
|
||||
/// Features whose result the last `evaluate` took from the cache instead of recomputing: the
|
||||
/// same feature on the same inputs was evaluated before (an edit set back, undo, redo, a
|
||||
/// configuration row). Their geometry did change since the previous evaluation.
|
||||
pub last_restored: usize,
|
||||
/// Results by inputs, shared with this model's copies.
|
||||
pub cache: Arc<std::sync::Mutex<EvalCache>>,
|
||||
/// Evaluate everything from scratch (no reuse; tests compare the two).
|
||||
@@ -461,6 +471,22 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Where components are placed, for a sketch that sees other components' geometry (on a face,
|
||||
/// or with projections): it is resolved in its own component's view of them.
|
||||
fn placement_digest(doc: &Document, f: &Feature) -> u64 {
|
||||
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 };
|
||||
if doc.occurrences.is_empty() || (!matches!(plane, PlaneRef::Face { .. }) && sketch.links.is_empty()) {
|
||||
return 0;
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
for o in &doc.occurrences {
|
||||
o.id.hash(&mut h);
|
||||
o.transform.iter().flatten().for_each(|x| x.to_bits().hash(&mut h));
|
||||
}
|
||||
serde_json::to_string(&doc.body_offsets).unwrap_or_default().hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn now() -> web_time::Instant {
|
||||
web_time::Instant::now()
|
||||
}
|
||||
@@ -499,11 +525,12 @@ impl Model {
|
||||
let mut out: Vec<FeatureResult> = Vec::with_capacity(doc.features.len());
|
||||
let mut reuse = !self.no_cache;
|
||||
let mut recomputed = 0;
|
||||
let mut restored = 0;
|
||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||
let ctx = context_digest(doc, &vals);
|
||||
for (i, f) in doc.features.iter().enumerate() {
|
||||
let rolled_back = i >= marker;
|
||||
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back);
|
||||
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back) ^ placement_digest(doc, f);
|
||||
// Sheet metal features also depend on the rules (and the parameters they use).
|
||||
if f.kind.is_sheet() {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
@@ -559,10 +586,11 @@ impl Model {
|
||||
continue;
|
||||
}
|
||||
// The same feature on the same inputs: its earlier result.
|
||||
let key = cache_key(f, fp, fingerprint(0, f, &vals, false), ctx, &state);
|
||||
let key = cache_key(f, fp, fingerprint(0, f, &vals, false) ^ placement_digest(doc, f), ctx, &state);
|
||||
if !self.no_cache
|
||||
&& let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key))
|
||||
{
|
||||
restored += 1;
|
||||
if c.error.is_none() {
|
||||
state = c.state.clone();
|
||||
}
|
||||
@@ -646,6 +674,7 @@ impl Model {
|
||||
}
|
||||
self.results = out;
|
||||
self.last_recomputed = recomputed;
|
||||
self.last_restored = restored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1392,6 +1421,20 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
|
||||
}
|
||||
let mut tools = Vec::new();
|
||||
for r in ®ions {
|
||||
if *operation == Operation::Join {
|
||||
let exact = kernel::extrude(&ss.plane, std::slice::from_ref(r), lo + off, hi + off)?;
|
||||
if exact.iter().all(|tool| {
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| targets.is_empty() || targets.contains(&b.name))
|
||||
.any(|b| kernel::can_join_touching_faces(&b.body, tool))
|
||||
}) {
|
||||
// Preserve a whole touching face: extending into a chamfer replaces
|
||||
// the sewable seam with a coincident curved intersection.
|
||||
tools.extend(exact);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets);
|
||||
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
|
||||
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
|
||||
@@ -1942,11 +1985,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
|
||||
@@ -2257,17 +2318,25 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::Align { .. }
|
||||
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st),
|
||||
FeatureKind::Sketch { plane, sketch } => {
|
||||
let fst = state_in_frame(doc, st, f.component);
|
||||
let (plane, face_warning) = match plane {
|
||||
PlaneRef::Face { plane: picked, at, name } => face_plane(st, picked, *at, name.as_deref()),
|
||||
other => (doc.resolve_plane_in(vals, other, 0, Some(st))?, None),
|
||||
PlaneRef::Face { plane: picked, at, name } => face_plane(&fst, picked, *at, name.as_deref()),
|
||||
other => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
|
||||
};
|
||||
let mut sk = sketch.clone();
|
||||
let mut warns = crate::project::refresh_links(doc, vals, st, &plane, &mut sk);
|
||||
let mut warns = crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk);
|
||||
drop(fst);
|
||||
sk.refresh_offsets()?;
|
||||
if let Some(w) = face_warning {
|
||||
warns.insert(0, w);
|
||||
}
|
||||
doc.apply_dimension_values(vals, &mut sk)?;
|
||||
let report = solve(&mut sk);
|
||||
let mut report = solve(&mut sk);
|
||||
if !sk.offsets.is_empty() {
|
||||
// Source dimensions may have moved curves during the solve.
|
||||
sk.refresh_offsets()?;
|
||||
report = solve(&mut sk);
|
||||
}
|
||||
if !report.ok() {
|
||||
warns.insert(0, format!("the sketch constraints conflict ({})", report.failing.join(", ")));
|
||||
}
|
||||
@@ -2509,7 +2578,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 +2594,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 +2665,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
|
||||
}
|
||||
@@ -2783,6 +2856,52 @@ fn unique_body_name(st: &ModelState, base: &str) -> String {
|
||||
(2..).map(|i| format!("{base} ({i})")).find(|n| st.body(n).is_none()).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
/// The model as a component sees it: bodies and sketches of components placed differently from
|
||||
/// it are moved into its frame (through both placements), so a sketch on another component's
|
||||
/// face finds the face where it is shown and projects its edges there. The model itself when
|
||||
/// every placement agrees (the usual case).
|
||||
pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
|
||||
use std::borrow::Cow;
|
||||
if doc.occurrences.is_empty() && doc.body_offsets.is_empty() {
|
||||
return Cow::Borrowed(st);
|
||||
}
|
||||
let Some(inv) = crate::mat_inverse(&doc.component_transform(comp)) else { return Cow::Borrowed(st) };
|
||||
let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let rel = |c: u64, body: Option<&str>| {
|
||||
let m = crate::mat_mul(&inv, &doc.component_transform(c));
|
||||
match body.and_then(|b| doc.body_offsets.get(b)) {
|
||||
Some(o) => crate::mat_mul(&m, o),
|
||||
None => m,
|
||||
}
|
||||
};
|
||||
let body_m: Vec<Mat> = st.bodies.iter().map(|b| rel(doc.body_component(&b.name, b.feature), Some(&b.name))).collect();
|
||||
let sketch_m: Vec<Mat> = st.sketches.iter().map(|s| rel(comp_of_feature(s.feature), None)).collect();
|
||||
if body_m.iter().chain(&sketch_m).all(crate::is_identity) {
|
||||
return Cow::Borrowed(st);
|
||||
}
|
||||
let mut out = st.clone();
|
||||
for (b, m) in out.bodies.iter_mut().zip(&body_m) {
|
||||
if !crate::is_identity(m)
|
||||
&& let Ok(t) = kernel::transform_matrix(&b.body, *m)
|
||||
{
|
||||
// Moving a body keeps its faces (and their order): it keeps their names.
|
||||
let names = crate::naming::face_names(b);
|
||||
let mut nb = ModelBody::new(b.name.clone(), t, b.feature);
|
||||
nb.component = b.component;
|
||||
nb.names = crate::naming::NameCell::known(names);
|
||||
*b = nb;
|
||||
}
|
||||
}
|
||||
for (s, m) in out.sketches.iter_mut().zip(&sketch_m) {
|
||||
if !crate::is_identity(m)
|
||||
&& let Some(np) = Plane::new(crate::apply_point(m, s.plane.origin), crate::apply_vector(m, s.plane.x), crate::apply_vector(m, s.plane.y))
|
||||
{
|
||||
s.plane = np;
|
||||
}
|
||||
}
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// The model placed in the world: each component's bodies, sketches and threads through every
|
||||
/// occurrence of it (bodies of the first placement keep their names; further instances get the
|
||||
/// occurrence path in brackets). Without components this is the model itself.
|
||||
|
||||
@@ -613,7 +613,12 @@ fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, le
|
||||
/// The body a placed plastic feature joins: by name, else the one under the point.
|
||||
fn body_under(st: &ModelState, body: &Option<String>, p: Vec3) -> Result<String> {
|
||||
let i = body_at(st, body, &[p])?;
|
||||
st.bodies.get(i).map(|b| b.name.clone()).ok_or_else(|| DocError::Invalid("there is no body".into()))
|
||||
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("there is no body".into()))?;
|
||||
let tolerance = (b.body.size() * 1e-5).max(1e-4);
|
||||
if crate::project::nearest_face(&b.mesh(), p).is_none_or(|(_, _, distance)| distance > tolerance) {
|
||||
return Err(DocError::Invalid("the plastic feature's placement is no longer on its body face".into()));
|
||||
}
|
||||
Ok(b.name.clone())
|
||||
}
|
||||
|
||||
/// Axes with z along `z` (and x along `x` when given).
|
||||
@@ -783,11 +788,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 {
|
||||
|
||||
@@ -18,13 +18,15 @@
|
||||
//! ["*"]` (every body), which is what they did, so those designs keep their shape.
|
||||
//! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means
|
||||
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
|
||||
//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become
|
||||
//! explicit planes retaining their previous axes, including offset-plane expressions.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 5;
|
||||
pub const FORMAT: u32 = 6;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solvecraft/{n}")
|
||||
@@ -37,7 +39,30 @@ pub fn version_of(s: &str) -> Option<u32> {
|
||||
|
||||
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
||||
type JsonStep = fn(&mut Value) -> Result<()>;
|
||||
const JSON_STEPS: [JsonStep; 4] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5];
|
||||
const JSON_STEPS: [JsonStep; 5] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6];
|
||||
|
||||
/// Old XZ references retain their exact frame, including nested offset and angled planes.
|
||||
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
|
||||
/// dimensions, profiles, extrude directions or parameter expressions in saved designs.
|
||||
fn v5_to_v6(v: &mut Value) -> Result<()> {
|
||||
fn preserve(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
let old_xz = o.get("type").and_then(Value::as_str) == Some("origin")
|
||||
&& o.get("name").and_then(Value::as_str).is_some_and(|n| n.eq_ignore_ascii_case("XZ") || n.eq_ignore_ascii_case("FRONT"));
|
||||
if old_xz {
|
||||
*v = serde_json::json!({"type": "custom", "plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}});
|
||||
} else {
|
||||
o.values_mut().for_each(preserve);
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(preserve),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
preserve(v);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Features in designs with components keep reaching every body.
|
||||
fn v4_to_v5(v: &mut Value) -> Result<()> {
|
||||
@@ -198,6 +223,32 @@ mod tests {
|
||||
assert_eq!(back, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_xz_and_nested_offsets_keep_their_frame() {
|
||||
let mut doc = Document::new("old XZ");
|
||||
let id = doc
|
||||
.add_feature(
|
||||
crate::FeatureKind::ConstructionPlane {
|
||||
plane: crate::PlaneRef::Offset { base: Box::new(crate::PlaneRef::Origin { name: "XZ".into() }), distance: "7 mm".into() },
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
json["format"] = Value::from("solvecraft/5");
|
||||
let migrated = read(&json.to_string()).unwrap();
|
||||
let crate::FeatureKind::ConstructionPlane { plane } = &migrated.feature(id).unwrap().kind else { panic!("plane") };
|
||||
let (values, errors) = migrated.param_values();
|
||||
assert!(errors.is_empty());
|
||||
let p = migrated.resolve_plane(&values, plane, 0).unwrap();
|
||||
assert_eq!(p.y, solvecraft_geom::Vec3::Z);
|
||||
assert_eq!(p.normal(), -solvecraft_geom::Vec3::Y);
|
||||
assert_eq!(p.origin, solvecraft_geom::Vec3::new(0.0, -7.0, 0.0));
|
||||
assert_eq!(read(&migrated.to_json()).unwrap(), migrated);
|
||||
let fresh = doc.resolve_plane(&values, &crate::PlaneRef::Origin { name: "XZ".into() }, 0).unwrap();
|
||||
assert_eq!(fresh.normal(), solvecraft_geom::Vec3::Y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_upgraded_newer_refused_others_rejected() {
|
||||
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
|
||||
|
||||
@@ -29,7 +29,7 @@ pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_iden
|
||||
pub use document::*;
|
||||
pub use eval::{
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices,
|
||||
world_state,
|
||||
state_in_frame, world_state,
|
||||
};
|
||||
pub use params::{ParamRow, ParamUser, cycles};
|
||||
pub use solvecraft_kernel as kernel;
|
||||
|
||||
@@ -34,6 +34,12 @@ impl NameCell {
|
||||
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
|
||||
NameCell { origin, names: Arc::new(OnceLock::new()) }
|
||||
}
|
||||
/// Names already known (a moved copy of a body keeps its faces' names).
|
||||
pub fn known(names: Arc<Vec<String>>) -> Self {
|
||||
let cell = OnceLock::new();
|
||||
let _ = cell.set(names);
|
||||
NameCell { origin: None, names: Arc::new(cell) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Deeper chains than this are named by position only (bounded work and stack).
|
||||
|
||||
@@ -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, .. } => {
|
||||
|
||||
@@ -273,7 +273,20 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelS
|
||||
let mut warn = Vec::new();
|
||||
let links = sk.links.clone();
|
||||
for l in &links {
|
||||
match resolve(doc, vals, st, plane, l.kind, &l.source) {
|
||||
let mut source = l.source.clone();
|
||||
if let (Some(name), LinkSource::Face { at, .. }) = (&l.face_name, &mut source) {
|
||||
if let Some((p, split)) = crate::naming::point_on_face(st, name, *at) {
|
||||
*at = p;
|
||||
if split {
|
||||
warn.push(format!("projected face `{name}` was split; using the nearest piece"));
|
||||
}
|
||||
} else {
|
||||
sk.set_link_lost(&l.id, true);
|
||||
warn.push(format!("projected face `{name}` no longer exists"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match resolve(doc, vals, st, plane, l.kind, &source) {
|
||||
Ok(r) => {
|
||||
sk.set_link_source(&l.id, r.source);
|
||||
let _ = sk.set_link_wires(&l.id, &r.wires);
|
||||
@@ -472,7 +485,7 @@ fn tilted_circle(plane: &Plane, pts: &[Vec3]) -> Option<Vec<LinkGeom>> {
|
||||
let (c, n, r, closed) = circle3(pts)?;
|
||||
let sn = plane.normal();
|
||||
let cosang = n.dot(sn).abs();
|
||||
if cosang > 1.0 - 1e-12 || cosang < 1e-9 {
|
||||
if !(1e-9..=1.0 - 1e-12).contains(&cosang) {
|
||||
return None;
|
||||
}
|
||||
if closed {
|
||||
|
||||
@@ -899,7 +899,7 @@ impl SheetBody {
|
||||
return Err(DocError::Invalid("the hem is shorter than its bend".into()));
|
||||
}
|
||||
// A hem folds back over the face it starts from (toward the bottom unless flipped).
|
||||
let up = !top == flip;
|
||||
let up = top != flip;
|
||||
let up = !up;
|
||||
self.flanges.push(SheetFlange {
|
||||
parent: panel,
|
||||
|
||||
@@ -81,6 +81,26 @@ fn parametric_plate_reevaluates() {
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
|
||||
}
|
||||
|
||||
/// Setting a parameter back takes the earlier results from the cache: nothing is recomputed, but
|
||||
/// the evaluation reports the features it restored (their geometry did change back).
|
||||
#[test]
|
||||
fn set_back_reports_restored_features() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
|
||||
doc.set_param("width", "50 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
|
||||
doc.set_param("width", "40 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (0, 2));
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 24000.0) < 1e-9);
|
||||
// No change: nothing recomputed and nothing restored.
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_fillet_cut_timeline() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
@@ -275,3 +295,53 @@ fn parameter_cycles_are_found() {
|
||||
assert!(errs["a"].contains("circular reference: a → b → c → a"), "{}", errs["a"]);
|
||||
assert!(vals.contains_key("d") && errs.contains_key("a") && errs.contains_key("e"));
|
||||
}
|
||||
|
||||
/// Features that use a sketch's points or regions go with it (not only profile and path users).
|
||||
#[test]
|
||||
fn input_sketches_cover_points_and_patches() {
|
||||
let hole: FeatureKind = serde_json::from_value(serde_json::json!({
|
||||
"type": "hole", "position": [0, 0, 0], "direction": [0, 0, -1], "diameter": "3",
|
||||
"points": {"sketch": 7, "ids": ["p1"]},
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(hole.input_sketches(), vec![7]);
|
||||
let patch: FeatureKind = serde_json::from_value(serde_json::json!({"type": "patch", "sketch": 9})).unwrap();
|
||||
assert_eq!(patch.input_sketches(), vec![9]);
|
||||
let pipe: FeatureKind = serde_json::from_value(serde_json::json!({"type": "pipe", "path_sketch": 72, "path": ["l1"], "diameter": "4"})).unwrap();
|
||||
assert_eq!(pipe.input_sketches(), vec![72]);
|
||||
}
|
||||
|
||||
/// Guard for `FeatureKind::input_sketches`: a variant with a field that refers to a sketch
|
||||
/// (a field named `*sketch*` holding an id, sketch points or loft sections) must not sit in the
|
||||
/// arm that returns no sketches. New variants are caught by the compiler (the match has no
|
||||
/// wildcard); this catches a sketch field added to an existing variant.
|
||||
#[test]
|
||||
fn input_sketches_lists_every_variant_with_a_sketch_field() {
|
||||
let src = include_str!("document.rs");
|
||||
let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap();
|
||||
let mut with_sketch = Vec::new();
|
||||
let mut variant = "";
|
||||
for line in body.lines() {
|
||||
let t = line.trim();
|
||||
if line.starts_with(" ") && !line.starts_with(" ") && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
|
||||
variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
let Some((name, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue };
|
||||
let refers = (name.contains("sketch") && (ty.contains("u64") || ty.contains("SketchPoints")))
|
||||
|| ty.contains("SketchPoints")
|
||||
|| ty.contains("LoftSection");
|
||||
if refers && !line.trim_start().starts_with("//") && !with_sketch.contains(&variant) {
|
||||
with_sketch.push(variant);
|
||||
}
|
||||
}
|
||||
assert!(with_sketch.len() >= 10, "the FeatureKind parse found too few sketch users: {with_sketch:?}");
|
||||
let f = src.split("pub fn input_sketches(&self)").nth(1).unwrap();
|
||||
let empty_arm = f.split("=> Vec::new()").next().and_then(|s| s.rsplit("=> ").next()).unwrap();
|
||||
for v in with_sketch {
|
||||
assert!(
|
||||
!empty_arm.contains(&format!("K::{v} {{")),
|
||||
"FeatureKind::{v} has a sketch field but input_sketches says it uses no sketch: deleting the sketch would leave it broken"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Booleans that failed, panicked or hung, replayed from the scripts in
|
||||
//! `examples/regressions/` (each script's description says what went wrong).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn replay(script: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(script).unwrap();
|
||||
s.run_script(&script).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn volume(s: &Session, body: &str) -> f64 {
|
||||
s.model.state().body(body).map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Slab less a 557.68 × 100 × 120 pocket and the loft below its floor (556.68 × 99 down 4 mm to
|
||||
/// 80 × 80; prismatoid: h/6 · (A_top + A_bottom + 4 A_mid)).
|
||||
fn slab_pocket_loft() -> (f64, f64) {
|
||||
let slab = 900.0 * 480.0 * 160.0;
|
||||
let loft = 4.0 / 6.0 * (556.68 * 99.0 + 80.0 * 80.0 + 4.0 * (556.68 + 80.0) / 2.0 * (99.0 + 80.0) / 2.0);
|
||||
(slab - 557.68 * 100.0 * 120.0, loft)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loft_cut_beside_a_round_hole() {
|
||||
let s = replay(include_str!("../../../examples/regressions/loft_cut_beside_round_hole.json"));
|
||||
let (pocketed, loft) = slab_pocket_loft();
|
||||
let hole = std::f64::consts::PI * 17.5 * 17.5 * 25.0;
|
||||
let want = pocketed - loft - hole;
|
||||
let got = volume(&s, "Body1");
|
||||
assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shallow_loft_cut_from_the_top_face() {
|
||||
let s = replay(include_str!("../../../examples/regressions/shallow_loft_cut_from_top_face.json"));
|
||||
let (_, loft) = slab_pocket_loft();
|
||||
let want = 900.0 * 480.0 * 160.0 - loft;
|
||||
let got = volume(&s, "Body1");
|
||||
assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oblique_cut_through_a_small_arc_matches_a_split() {
|
||||
let scripts = [
|
||||
include_str!("../../../examples/regressions/oblique_cut_of_cylinder.json"),
|
||||
include_str!("../../../examples/regressions/oblique_cut_of_cylinder_hang.json"),
|
||||
];
|
||||
// The same bar split by the prism's tilted top: the piece above it is the cut's result.
|
||||
let mut split = Session::default();
|
||||
let script: Value = serde_json::from_str(scripts[0]).unwrap();
|
||||
let steps = script.get("commands").and_then(Value::as_array).unwrap();
|
||||
split.run_script(&Value::Array(steps[..5].to_vec())).unwrap();
|
||||
let whole = volume(&split, "Tool");
|
||||
split.execute("solid.split_body", &json!({"body": "Tool", "plane": {"origin": [0, -472, 853.91], "normal": [0, 15.44, 40]}})).unwrap();
|
||||
let above = volume(&split, "Tool");
|
||||
assert!(above < whole - 10.0, "{above} of {whole}");
|
||||
for script in scripts {
|
||||
let s = replay(script);
|
||||
let got = volume(&s, "Tool");
|
||||
assert!((got - above).abs() < 1e-6 * above, "{got} vs {above}");
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use serde_json::{Value, json};
|
||||
use solvecraft_doc::FeatureKind;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::params::{bad, bool_, str_};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
@@ -34,6 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).noundo(),
|
||||
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
|
||||
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
|
||||
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
|
||||
or folder?: bodies|sketches|construction|origin (all of it). View state: not saved, not an undo step",
|
||||
),
|
||||
];
|
||||
|
||||
const FOLDERS: [&str; 3] = ["bodies", "sketches", "construction"];
|
||||
@@ -155,6 +159,83 @@ fn order(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
const ORIGIN: [&str; 7] = ["O", "X", "Y", "Z", "XY", "XZ", "YZ"];
|
||||
|
||||
/// A browser item whose visibility can change.
|
||||
enum Shown {
|
||||
Sketch(u64),
|
||||
Body(String),
|
||||
/// Origin items and construction geometry, by name.
|
||||
Origin(String),
|
||||
}
|
||||
|
||||
fn is_construction(k: &FeatureKind) -> bool {
|
||||
matches!(k, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. })
|
||||
}
|
||||
|
||||
fn shown_item(s: &Session, key: &str) -> Option<Shown> {
|
||||
if let Some(f) = s.doc.find_feature(key) {
|
||||
if matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
return Some(Shown::Sketch(f.id));
|
||||
}
|
||||
if is_construction(&f.kind) {
|
||||
return Some(Shown::Origin(f.name.clone()));
|
||||
}
|
||||
}
|
||||
if s.world_state().bodies.iter().any(|b| b.name == key) {
|
||||
return Some(Shown::Body(key.to_string()));
|
||||
}
|
||||
ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
|
||||
}
|
||||
|
||||
fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "browser.visibility";
|
||||
let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?;
|
||||
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
|
||||
(Some(_), Some(_)) => return Err(bad(cmd, "give `items` or `folder`, not both")),
|
||||
(Some("bodies"), None) => s.world_state().bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(),
|
||||
(Some("sketches"), None) => s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)).collect(),
|
||||
(Some("construction"), None) => s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())).collect(),
|
||||
(Some("origin"), None) => ORIGIN.iter().map(|o| Shown::Origin(o.to_string())).collect(),
|
||||
(Some(f), None) => return Err(bad(cmd, format!("unknown folder `{f}` (bodies, sketches, construction, origin)"))),
|
||||
(None, Some(_)) => {
|
||||
let keys = keys(p, "items", cmd)?;
|
||||
if keys.is_empty() {
|
||||
return Err(bad(cmd, "`items` must list sketches, bodies or construction geometry"));
|
||||
}
|
||||
// Every item is found before anything changes.
|
||||
keys.iter()
|
||||
.map(|k| shown_item(s, k).ok_or_else(|| bad(cmd, format!("no sketch, body or construction item `{k}`"))))
|
||||
.collect::<Result<_>>()?
|
||||
}
|
||||
(None, None) => return Err(bad(cmd, "`items` or `folder` is required")),
|
||||
};
|
||||
let v = &mut s.visibility;
|
||||
for it in &items {
|
||||
match it {
|
||||
Shown::Sketch(id) => {
|
||||
v.hidden_sketches.retain(|x| x != id);
|
||||
v.shown_sketches.retain(|x| x != id);
|
||||
if visible { v.shown_sketches.push(*id) } else { v.hidden_sketches.push(*id) }
|
||||
}
|
||||
Shown::Body(n) => {
|
||||
v.hidden_bodies.retain(|x| x != n);
|
||||
if !visible {
|
||||
v.hidden_bodies.push(n.clone());
|
||||
}
|
||||
}
|
||||
Shown::Origin(n) => {
|
||||
v.hidden_origin.retain(|x| x != n);
|
||||
if !visible {
|
||||
v.hidden_origin.push(n.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.revision += 1;
|
||||
Ok(json!({ "visible": visible, "changed": items.len(), "visibility": s.visibility }))
|
||||
}
|
||||
|
||||
fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "document.units";
|
||||
let u = str_(p, "units").ok_or_else(|| bad(cmd, "`units` is required"))?;
|
||||
@@ -339,6 +420,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.body_components.insert(name.to_string(), c);
|
||||
}
|
||||
rename_key(d, "bodies", &old, name);
|
||||
for b in s.visibility.hidden_bodies.iter_mut().filter(|b| **b == old) {
|
||||
*b = name.to_string();
|
||||
}
|
||||
Ok(json!({"body": old, "name": name}))
|
||||
}
|
||||
|
||||
@@ -411,7 +495,9 @@ mod tests {
|
||||
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
|
||||
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
|
||||
let z = zmax(&mut s);
|
||||
assert!(z > 9.0, "the profile stands up on XZ: {z}");
|
||||
assert!(z.abs() < 1e-6, "positive sketch Y on XZ extends toward -Z: {z}");
|
||||
let ymin = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap();
|
||||
assert!((ymin + 20.0).abs() < 1e-6);
|
||||
assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err());
|
||||
assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err());
|
||||
}
|
||||
@@ -441,6 +527,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();
|
||||
@@ -461,6 +564,60 @@ mod tests {
|
||||
assert_eq!(s.doc.named_views.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_sketches_hide_and_show() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let floor = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [50, 0]], "construction": true}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let drain = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.point", json!({"point": [25, 5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Floor"}));
|
||||
let body = s.model.state().bodies[0].name.clone();
|
||||
let drain_name = s.doc.feature(drain).unwrap().name.clone();
|
||||
let cam = solvecraft_render::Camera::default();
|
||||
let lines = |s: &Session| crate::view::scene(s, &cam).lines.len();
|
||||
let shown = |s: &mut Session| {
|
||||
let d = run(s, "document.inspect", json!({}));
|
||||
d["sketches"].as_array().unwrap().iter().filter(|x| x["visible"] == true).count()
|
||||
};
|
||||
let (n0, undo0, doc0) = (lines(&s), s.undo.len(), s.doc.clone());
|
||||
assert_eq!(shown(&mut s), 2);
|
||||
// By id and by name; the view loses their lines, the design and its history don't change.
|
||||
let r = run(&mut s, "browser.visibility", json!({"items": [floor, drain_name], "visible": false}));
|
||||
assert_eq!(r["changed"], 2);
|
||||
assert_eq!(shown(&mut s), 0);
|
||||
assert!(lines(&s) < n0);
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc0) && s.undo.len() == undo0, "view state, not a design change");
|
||||
run(&mut s, "browser.visibility", json!({"folder": "sketches", "visible": true}));
|
||||
assert_eq!(lines(&s), n0);
|
||||
// Bodies (following a rename) and construction planes.
|
||||
run(&mut s, "browser.visibility", json!({"items": [body.clone(), "Floor", "xy"], "visible": false}));
|
||||
assert!(crate::view::scene(&s, &cam).meshes.is_empty());
|
||||
assert_eq!(s.visibility.hidden_origin, vec!["Floor".to_string(), "XY".to_string()]);
|
||||
run(&mut s, "body.rename", json!({"body": body, "name": "Tray"}));
|
||||
assert_eq!(s.visibility.hidden_bodies, vec!["Tray".to_string()]);
|
||||
let d = run(&mut s, "document.inspect", json!({}));
|
||||
assert_eq!(d["bodies"][0]["visible"], false);
|
||||
// Hostile input is refused and changes nothing.
|
||||
let before = s.visibility.clone();
|
||||
for p in [
|
||||
json!({"items": [floor]}),
|
||||
json!({"items": [floor], "visible": "no"}),
|
||||
json!({"items": [floor, "Nope"], "visible": false}),
|
||||
json!({"items": [], "visible": false}),
|
||||
json!({"items": "Floor", "visible": false}),
|
||||
json!({"folder": "sketches", "items": [floor], "visible": false}),
|
||||
json!({"folder": "nope", "visible": false}),
|
||||
json!({"visible": false}),
|
||||
] {
|
||||
assert!(s.execute("browser.visibility", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.visibility, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_hold_items_and_follow_renames() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -10,6 +10,12 @@ use crate::{EngineError, Result, Sel, Session, Snapshot};
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
|
||||
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
|
||||
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
|
||||
.noundo()
|
||||
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
|
||||
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint)
|
||||
.noundo()
|
||||
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"),
|
||||
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
|
||||
CommandSpec::new("timeline.delete", "Delete", delete)
|
||||
.at("SOLID", "MODIFY")
|
||||
@@ -28,8 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature)
|
||||
.params("feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}}"),
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
|
||||
"feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \
|
||||
restored: features whose earlier result for the same inputs was reused",
|
||||
),
|
||||
CommandSpec::new("select.set", "Select", select_set)
|
||||
.noundo()
|
||||
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"),
|
||||
@@ -58,6 +66,21 @@ fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"redone": label}))
|
||||
}
|
||||
|
||||
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"checkpoint": s.checkpoint()}))
|
||||
}
|
||||
|
||||
fn restore_checkpoint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "edit.restore_checkpoint";
|
||||
let id = p.get("checkpoint").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`checkpoint` must be an id from edit.checkpoint"))?;
|
||||
if bool_(p, "forget") == Some(true) {
|
||||
s.drop_checkpoint(id);
|
||||
return Ok(json!({"checkpoint": id, "restored": false}));
|
||||
}
|
||||
s.restore_checkpoint(id)?;
|
||||
Ok(json!({"checkpoint": id, "restored": true}))
|
||||
}
|
||||
|
||||
fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.model = solvecraft_doc::Model::new();
|
||||
s.refresh();
|
||||
@@ -204,7 +227,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
|
||||
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
|
||||
}
|
||||
|
||||
fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -307,7 +330,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
|
||||
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
|
||||
}
|
||||
|
||||
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("extrude")
|
||||
.key("E")
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
@@ -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, .. } => {
|
||||
@@ -547,7 +581,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new = format!("({old}) + ({distance})");
|
||||
let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?;
|
||||
let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?;
|
||||
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed")}));
|
||||
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed"), "restored": out.get("restored")}));
|
||||
}
|
||||
// Another curved face moves along its normal.
|
||||
let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?;
|
||||
@@ -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)"))),
|
||||
};
|
||||
|
||||
@@ -170,6 +170,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
|
||||
"profiles": profiles,
|
||||
"links": sk.links,
|
||||
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link})).collect::<Vec<_>>(),
|
||||
"visible": crate::view::sketch_shown(s, id),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -207,18 +208,27 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.collect();
|
||||
let with_measure = bool_(p, "measure").unwrap_or(false);
|
||||
let bodies: Vec<Value> =
|
||||
st.bodies.iter().map(|b| if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) }).collect();
|
||||
let bodies: Vec<Value> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let mut v = if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) };
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("visible".into(), json!(!s.visibility.hidden_bodies.contains(&b.name)));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let sketches: Vec<Value> = st
|
||||
.sketches
|
||||
.iter()
|
||||
.map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len()}))
|
||||
.map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len(), "visible": crate::view::sketch_shown(s, ss.feature)}))
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"name": s.doc.name, "units": s.doc.units, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
|
||||
"active_sketch": s.active_sketch, "marker": marker,
|
||||
"params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches,
|
||||
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(),
|
||||
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(), "hidden_origin": s.visibility.hidden_origin,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -55,13 +55,13 @@ fn densify(pts: &[Vec3]) -> Vec<Vec3> {
|
||||
|
||||
/// The mesh edge a selection means: by index if it still passes through the point, otherwise
|
||||
/// the nearest.
|
||||
fn edge_points(m: &Mesh, index: usize, point: Vec3) -> Option<Vec<Vec3>> {
|
||||
fn edge_index(m: &Mesh, index: usize, point: Vec3) -> Option<usize> {
|
||||
let near = |e: &Vec<Vec3>| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||||
let tol = m.bounds().diagonal().max(1.0) * 1e-6;
|
||||
if let Some(e) = m.edges.get(index).filter(|e| near(e) < tol) {
|
||||
return Some(e.clone());
|
||||
if m.edges.get(index).is_some_and(|e| near(e) < tol) {
|
||||
return Some(index);
|
||||
}
|
||||
m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).cloned()
|
||||
m.edges.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b))).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
fn face_tris(m: &Mesh, f: usize) -> Vec<[Vec3; 3]> {
|
||||
@@ -75,15 +75,23 @@ fn describe(s: &Session, sel: &Sel) -> Option<(Geo, Option<Vec3>, Value)> {
|
||||
Sel::Vertex { point, .. } => (Geo::Points(vec![*point]), None, json!({"type": "vertex", "position": point})),
|
||||
Sel::Edge { body, index, point } => {
|
||||
let m = st.body(body)?.mesh();
|
||||
let pts = edge_points(&m, *index, *point)?;
|
||||
let index = edge_index(&m, *index, *point)?;
|
||||
let pts = m.edges.get(index)?;
|
||||
let rim = m.circular_rim(index);
|
||||
if rim.len() > 1 {
|
||||
let pieces: Vec<&Vec<Vec3>> = rim.iter().filter_map(|&i| m.edges.get(i)).collect();
|
||||
let len: f64 = pieces.iter().map(|p| polyline_len(p)).sum();
|
||||
let points = pieces.iter().flat_map(|p| densify(p)).collect();
|
||||
return Some((Geo::Points(points), None, json!({"type": "edge", "length_mm": len})));
|
||||
}
|
||||
let (a, b) = (*pts.first()?, *pts.last()?);
|
||||
let straight = (b - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(b))));
|
||||
let len = polyline_len(&pts);
|
||||
(Geo::Points(densify(&pts)), straight, json!({"type": "edge", "length_mm": len}))
|
||||
let len = polyline_len(pts);
|
||||
(Geo::Points(densify(pts)), straight, json!({"type": "edge", "length_mm": len}))
|
||||
}
|
||||
Sel::Face { body, index, .. } => {
|
||||
let m = st.body(body)?.mesh();
|
||||
let tris = face_tris(&m, *index);
|
||||
let tris = m.surface_patch(*index).into_iter().flat_map(|f| face_tris(&m, f)).collect::<Vec<_>>();
|
||||
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
|
||||
let normals: Vec<Vec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect();
|
||||
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
|
||||
@@ -150,10 +158,41 @@ fn closest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> {
|
||||
pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.measure";
|
||||
let list = items.as_array().ok_or_else(|| bad(cmd, "`items` must be a list of selections"))?;
|
||||
if list.is_empty() || list.len() > 2 {
|
||||
if list.is_empty() || list.len() > 1000 {
|
||||
return Err(bad(cmd, "measure one or two items"));
|
||||
}
|
||||
let sels: Vec<Sel> = list.iter().map(|v| serde_json::from_value::<Sel>(v.clone()).map_err(|e| bad(cmd, e.to_string()))).collect::<Result<_>>()?;
|
||||
let st = s.world_state();
|
||||
let mut sels = Vec::new();
|
||||
for value in list {
|
||||
let mut sel = serde_json::from_value::<Sel>(value.clone()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
// Two kernel halves of one visible item are one measurement target.
|
||||
match &mut sel {
|
||||
Sel::Edge { body, index, point } => {
|
||||
if let Some(b) = st.body(body) {
|
||||
let m = b.mesh();
|
||||
let resolved = edge_index(&m, *index, *point).unwrap_or(*index);
|
||||
*index = m.circular_rim(resolved).first().copied().unwrap_or(resolved);
|
||||
}
|
||||
}
|
||||
Sel::Face { body, index, .. } => {
|
||||
if let Some(b) = st.body(body) {
|
||||
*index = b.mesh().surface_patch(*index).first().copied().unwrap_or(*index);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let duplicate = sels.iter().any(|x| match (x, &sel) {
|
||||
(Sel::Edge { body: a, index: i, .. }, Sel::Edge { body: b, index: j, .. })
|
||||
| (Sel::Face { body: a, index: i, .. }, Sel::Face { body: b, index: j, .. }) => a == b && i == j,
|
||||
_ => false,
|
||||
});
|
||||
if !duplicate {
|
||||
sels.push(sel);
|
||||
}
|
||||
if sels.len() > 2 {
|
||||
return Err(bad(cmd, "measure one or two items"));
|
||||
}
|
||||
}
|
||||
let mut described = Vec::new();
|
||||
for x in &sels {
|
||||
described.push(describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces or bodies)"))?);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,7 +12,8 @@ use crate::{EngineError, Result, Session};
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
|
||||
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\" (new parameters default to their value's unit), comment?, \
|
||||
favorite?: bool, new_name?: rename (references follow); or delete: name",
|
||||
favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \
|
||||
restored: features whose earlier result for the same inputs was reused (e.g. a value set back)",
|
||||
),
|
||||
CommandSpec::new("parameters.list", "List Parameters", list).noundo().params(
|
||||
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
|
||||
@@ -107,6 +108,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
out["value"] = json!(vals.get(&name).map(|v| v.v));
|
||||
out["display_value"] = json!(s.doc.param_display_value(&vals, &name));
|
||||
out["recomputed"] = json!(s.model.last_recomputed);
|
||||
out["restored"] = json!(s.model.last_restored);
|
||||
out["errors"] = json!(failing(s));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,25 @@ fn width_follows_an_expression() {
|
||||
assert!(e.contains("expected a length, got an angle"), "{e}");
|
||||
}
|
||||
|
||||
/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the
|
||||
/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken
|
||||
/// for "nothing changed". timeline.edit reports the same.
|
||||
#[test]
|
||||
fn setting_a_parameter_back_reports_restored_features() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}");
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}");
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
|
||||
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}");
|
||||
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "10"}}}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(1)), "{r}");
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_inputs_are_named_and_editable() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -231,7 +231,7 @@ fn cyl(s: &mut Session, d: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
|
||||
/// A solid of revolution about z from a closed (r, z) outline, as one body or joined.
|
||||
fn revolve(s: &mut Session, pts: &[(f64, f64)], op: &str) -> Result<()> {
|
||||
run(s, "sketch.create", json!({"plane": "XZ"}))?;
|
||||
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, *z]).collect();
|
||||
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, -*z]).collect();
|
||||
run(s, "sketch.line", json!({"points": points, "closed": true}))?;
|
||||
run(s, "sketch.finish", json!({}))?;
|
||||
run(s, "solid.revolve", json!({"axis": "y", "operation": op}))?;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+154
-47
@@ -1,7 +1,7 @@
|
||||
//! Sketch commands: create/edit/finish sketches, draw curves, constraints and dimensions.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{FeatureKind, PlaneRef};
|
||||
use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind};
|
||||
use solvecraft_geom::{Plane, Vec2, Vec3};
|
||||
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
|
||||
|
||||
@@ -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,73 +28,73 @@ 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)",
|
||||
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
|
||||
),
|
||||
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
@@ -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 {
|
||||
@@ -311,8 +359,10 @@ pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
|
||||
}
|
||||
Some(v @ Value::Object(o)) => {
|
||||
if let Some(fp) = o.get("face").and_then(vec3) {
|
||||
// A planar face of a body at this point.
|
||||
let st = s.model.state();
|
||||
// A planar face of a body at this point, as the active component sees the model
|
||||
// (another component's face where it is placed).
|
||||
let model = s.model.state();
|
||||
let st = solvecraft_doc::state_in_frame(&s.doc, &model, s.active_component);
|
||||
let mut found = None;
|
||||
let mut face_name = None;
|
||||
for b in &st.bodies {
|
||||
@@ -358,7 +408,10 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo
|
||||
let s1 = (b - a).cross(p - a).dot(n);
|
||||
let s2 = (c - b).cross(p - b).dot(n);
|
||||
let s3 = (a - c).cross(p - c).dot(n);
|
||||
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0) || (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
|
||||
(s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0)
|
||||
|| (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0)
|
||||
// Exact curved boundaries can sit just outside their tessellated chords.
|
||||
|| p.dist_to_segment(a, b).min(p.dist_to_segment(b, c)).min(p.dist_to_segment(c, a)) <= tol * 0.1
|
||||
})
|
||||
}
|
||||
|
||||
@@ -425,7 +478,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 +579,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 +607,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 +625,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 +644,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 +659,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 +687,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 +703,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 +728,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 +739,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 +762,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 +799,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 +875,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 +915,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 +1128,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"))?;
|
||||
@@ -1249,7 +1337,9 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let is_line = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. }));
|
||||
let is_circle = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. }));
|
||||
let (k, cur): (ConstraintKind, f64) = match (e0, e1) {
|
||||
// The other reading of an angle between two lines (see below).
|
||||
let mut alt: Option<(ConstraintKind, f64)> = None;
|
||||
let (mut k, mut cur): (ConstraintKind, f64) = match (e0, e1) {
|
||||
((None, Some(c)), None) if is_line(c) => {
|
||||
let (a, b) = line_pts(sk, c).unwrap_or_default();
|
||||
match ty.as_str() {
|
||||
@@ -1326,7 +1416,14 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ang = -ang;
|
||||
std::mem::swap(&mut ka, &mut kb);
|
||||
}
|
||||
(ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang)
|
||||
// Two lines make two angles, θ and 180° − θ, whichever way they were
|
||||
// drawn: the acute one unless the value asks for the other.
|
||||
let direct = (ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang);
|
||||
let supplement =
|
||||
(ConstraintKind::Angle { a: kb, b: ka, value: std::f64::consts::PI - ang, flip: true }, std::f64::consts::PI - ang);
|
||||
let (acute, obtuse) = if ang <= std::f64::consts::FRAC_PI_2 { (direct, supplement) } else { (supplement, direct) };
|
||||
alt = Some(obtuse);
|
||||
acute
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1343,7 +1440,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
c.driven = true;
|
||||
}
|
||||
before = None;
|
||||
return Ok((String::new(), name, cur));
|
||||
return Ok((String::new(), name, measured(is_angle, cur)));
|
||||
}
|
||||
let (unit, default_expr) =
|
||||
if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) };
|
||||
@@ -1354,20 +1451,30 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(err) = errs.get(&pname) {
|
||||
return Err(bad(cmd, format!("value: {err}")));
|
||||
}
|
||||
let _ = vals;
|
||||
// An angle between two lines dimensions whichever of its two readings is nearer the value.
|
||||
if let (Some((ka, ca)), Some(want)) = (alt, vals.get(&pname).and_then(|v| v.to_kind(Kind::Angle).ok()))
|
||||
&& (want - ca).abs() < (want - cur).abs()
|
||||
{
|
||||
(k, cur) = (ka, ca);
|
||||
}
|
||||
let name = k.name();
|
||||
let id = sk.add_constraint(k, Some(pname.clone()))?;
|
||||
place_text(sk, &id, text_at);
|
||||
Ok((pname, name, cur))
|
||||
Ok((pname, name, measured(is_angle, cur)))
|
||||
})?;
|
||||
if driven {
|
||||
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current, "sketch": info}));
|
||||
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}));
|
||||
}
|
||||
if reject_redundant(before, &info, cmd).is_err() {
|
||||
return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true"));
|
||||
}
|
||||
let v = s.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default();
|
||||
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current, "sketch": info}))
|
||||
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info}))
|
||||
}
|
||||
|
||||
/// A dimension's measured value as reported, in the unit its expression is written in.
|
||||
fn measured(is_angle: bool, v: f64) -> (f64, &'static str) {
|
||||
if is_angle { (round6(v.to_degrees()), "deg") } else { (round6(v), "mm") }
|
||||
}
|
||||
|
||||
/// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the
|
||||
|
||||
@@ -57,6 +57,8 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
|
||||
return Err(bad(cmd, "`width` must be positive"));
|
||||
}
|
||||
let opacity = num(p, "opacity").unwrap_or(if decal { 1.0 } else { 0.5 }).clamp(0.0, 1.0);
|
||||
// It belongs to the active component (its plane is in that frame) and moves with it.
|
||||
let component = s.active_component;
|
||||
let doc = s.doc_mut();
|
||||
let id = doc.canvases.iter().map(|c| c.id).max().unwrap_or(0) + 1;
|
||||
let base = if decal { "Decal" } else { "Canvas" };
|
||||
@@ -74,6 +76,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
|
||||
opacity,
|
||||
flip: bool_(p, "flip").unwrap_or(false),
|
||||
visible: true,
|
||||
component,
|
||||
});
|
||||
Ok(json!({"canvas": id, "name": name, "pixels": pixels}))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -201,3 +201,65 @@ fn drawing_onto_a_curve_keeps_the_point_on_it() {
|
||||
let cc = sk.center(sk.curve_index(&c).unwrap()).unwrap();
|
||||
assert!((st.dist(cc) - 8.0).abs() < 1e-7, "{st:?} {cc:?}");
|
||||
}
|
||||
|
||||
/// A faucet spout path: a vertical riser, a horizontal top and an outlet sloping down, joined
|
||||
/// by fillets. The riser points up and the outlet down-left, so their directions are 150° apart
|
||||
/// while the lines meet at 30°.
|
||||
fn spout() -> Session {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[795, 955], [795, 1068], [689, 1068], [661, 1020]], "ids": ["l1", "l2", "l3"]}));
|
||||
run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 50}));
|
||||
run(&mut s, "sketch.fillet", json!({"point": "l2.end", "radius": 35}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "vertical"}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "horizontal"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 795}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 955}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l2.start"], "type": "vertical", "value": 1068}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l3.end", "l1.start"], "type": "horizontal", "value": 134}));
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l3.end"], "type": "vertical", "value": 1020}));
|
||||
assert_eq!(d["sketch"]["dof"], 1, "{d}");
|
||||
s
|
||||
}
|
||||
|
||||
/// The angle (degrees, 0..=90) between two lines of the active sketch.
|
||||
fn line_angle(s: &Session, a: &str, b: &str) -> f64 {
|
||||
let sk = sketch(s);
|
||||
let dir = |l: &str| {
|
||||
let c = sk.curve_index(l).unwrap();
|
||||
let (p, q) = crate::cmd::sketch::line_pts(&sk, c).unwrap();
|
||||
q - p
|
||||
};
|
||||
let (u, v) = (dir(a), dir(b));
|
||||
let t = u.cross(v).abs().atan2(u.dot(v)).to_degrees();
|
||||
t.min(180.0 - t)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_angle_between_lines_takes_the_reading_nearest_its_value_and_reports_degrees() {
|
||||
// The riser and the outlet are not adjacent and meet at 30°; their directions are 150°
|
||||
// apart. Asking for 30° used to swing the outlet round and was refused as over-constraining.
|
||||
for ents in [["l1", "l3"], ["l3", "l1"]] {
|
||||
let mut s = spout();
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ents, "type": "angle", "value": "30 deg"}));
|
||||
assert_eq!(d["sketch"]["dof"], 0, "{d}");
|
||||
assert_eq!(d["unit"], "deg", "{d}");
|
||||
assert!((d["measured"].as_f64().unwrap() - 30.3).abs() < 0.1, "{d}");
|
||||
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
|
||||
}
|
||||
// The other reading still works, and the adjacent top line measures 60° the same way.
|
||||
let mut s = spout();
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"], "value": "150 deg"}));
|
||||
assert_eq!(d["sketch"]["dof"], 0, "{d}");
|
||||
assert!((line_angle(&s, "l1", "l3") - 30.0).abs() < 1e-6);
|
||||
let mut s = spout();
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2", "l3"], "value": "60 deg"}));
|
||||
assert!((d["measured"].as_f64().unwrap() - 59.74).abs() < 0.01, "{d}");
|
||||
// Without a value the acute angle is kept as it is.
|
||||
let mut s = spout();
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l3"]}));
|
||||
let e = d["expression"].as_str().unwrap();
|
||||
assert!(e.ends_with(" deg") && (e.trim_end_matches(" deg").parse::<f64>().unwrap() - 30.3).abs() < 0.1, "{d}");
|
||||
// Lengths report millimetres.
|
||||
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "driven": true}));
|
||||
assert_eq!(d["unit"], "mm", "{d}");
|
||||
}
|
||||
|
||||
@@ -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 !(1e-6..=std::f64::consts::FRAC_PI_2 - 1e-9).contains(&half) {
|
||||
return Err(bad(cmd, "the lines are parallel"));
|
||||
}
|
||||
let d = r / half.tan();
|
||||
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.
|
||||
@@ -792,6 +813,12 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let ci = sk.add_circle(cc, nr, Some(c), None)?;
|
||||
made.push(ci);
|
||||
sk.offsets.push(solvecraft_sketch::OffsetChain {
|
||||
sources: vec![(ids[0].clone(), true)],
|
||||
outputs: ids_of(sk, &made),
|
||||
distance: r - nr,
|
||||
closed: true,
|
||||
});
|
||||
return Ok((ids_of(sk, &made), cons));
|
||||
}
|
||||
let pool: Vec<usize> = if use_chain {
|
||||
@@ -847,6 +874,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] });
|
||||
@@ -883,7 +922,13 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((ids_of(sk, &made), cons))
|
||||
let outputs = ids_of(sk, &made);
|
||||
if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) {
|
||||
let sources = order.iter().filter_map(|(i, forward)| sk.curves.get(*i).map(|c| (c.id.clone(), *forward))).collect();
|
||||
sk.offsets.push(solvecraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed });
|
||||
sk.refresh_offsets()?;
|
||||
}
|
||||
Ok((outputs, cons))
|
||||
})?;
|
||||
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
|
||||
}
|
||||
@@ -1149,3 +1194,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;
|
||||
|
||||
@@ -140,8 +140,20 @@ pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, ki
|
||||
let plane = sketch_plane(doc, id)?;
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
// The model as the sketch's component sees it.
|
||||
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let st = solvecraft_doc::state_in_frame(doc, &st, comp);
|
||||
let r = resolve(doc, &vals, &st, &plane, kind, &src)?;
|
||||
Ok(sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?)
|
||||
let name = if let LinkSource::Face { at, .. } = &r.source {
|
||||
solvecraft_doc::naming::face_names_at(&st, &[*at]).into_iter().next().filter(|n| !n.is_empty())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let link = sk.add_link_with_wires(kind, r.source, &r.geom, &r.wires)?;
|
||||
if let Some(l) = sk.links.iter_mut().find(|l| l.id == link) {
|
||||
l.face_name = name;
|
||||
}
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
fn link_json(sk: &Sketch, link: &str) -> Value {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ enum K {
|
||||
Plane,
|
||||
/// A body name, or a list of them.
|
||||
Body,
|
||||
/// A point (or a list of picks) for the sketch being made or edited: into that sketch's
|
||||
/// component frame, whichever component the geometry under it belongs to (a sketch in the
|
||||
/// root on a moved component's face finds the face where it is shown).
|
||||
Here,
|
||||
/// A plane object for a new sketch or canvas: into the active component's frame.
|
||||
HerePlane,
|
||||
}
|
||||
|
||||
use K::*;
|
||||
@@ -56,11 +62,12 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
"plastic.lip" | "sheet.convert" => &[("face", Pick), ("body", Body)],
|
||||
"sheet.flange" | "sheet.hem" => &[("edges", Picks), ("body", Body)],
|
||||
"sheet.fold" => &[("points", Picks), ("fixed", Pick), ("body", Body)],
|
||||
"sketch.create" | "sketch.redefine" => &[("plane", Plane)],
|
||||
"canvas.decal" => &[("face", Pick)],
|
||||
"sketch.intersect" => &[("refs", Picks)],
|
||||
"sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Pick), ("body", Body)],
|
||||
"sketch.intersection_curve" => &[("a", Picks), ("b", Picks)],
|
||||
"sketch.create" | "sketch.redefine" => &[("plane", HerePlane)],
|
||||
"canvas.decal" => &[("face", Here)],
|
||||
"canvas.insert" => &[("plane", HerePlane), ("at", Here)],
|
||||
"sketch.project" | "sketch.intersect" | "sketch.include_3d" => &[("refs", Here), ("ref", Here)],
|
||||
"sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Here)],
|
||||
"sketch.intersection_curve" => &[("a", Here), ("b", Here)],
|
||||
"inspect.curvature_comb" | "inspect.minimum_radius" | "inspect.isocurve" => &[("edges", Picks), ("faces", Picks)],
|
||||
"parts.insert" | "parts.fastener" => &[("at", Pick), ("point", Make), ("direction", Dir)],
|
||||
"appearance.assign" => &[("faces", Picks), ("bodies", Body), ("body", Body)],
|
||||
@@ -75,6 +82,8 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
struct Frames {
|
||||
bodies: Vec<(String, String, Mat)>,
|
||||
active: Mat,
|
||||
/// The frame of the sketch being edited (its component), else the active component's.
|
||||
sketch: Mat,
|
||||
}
|
||||
|
||||
fn frames(s: &Session) -> Frames {
|
||||
@@ -104,7 +113,8 @@ fn frames(s: &Session) -> Frames {
|
||||
(world, own, m)
|
||||
})
|
||||
.collect();
|
||||
Frames { bodies, active: doc.component_transform(s.active_component) }
|
||||
let sketch_comp = s.active_sketch.and_then(|id| doc.feature(id)).map(|f| f.component).unwrap_or(s.active_component);
|
||||
Frames { bodies, active: doc.component_transform(s.active_component), sketch: doc.component_transform(sketch_comp) }
|
||||
}
|
||||
|
||||
/// Does any placement move anything (so the world differs from the components' frames)?
|
||||
@@ -171,12 +181,14 @@ struct Mapper<'a> {
|
||||
/// The frame of the command's first pick (its directions turn with it).
|
||||
first: Option<Mat>,
|
||||
changed: bool,
|
||||
/// Picks go into the sketch's frame (`Here`), not the frame of the body under them.
|
||||
here: bool,
|
||||
}
|
||||
|
||||
impl Mapper<'_> {
|
||||
fn pick(&mut self, v: &Value, body: Option<&str>) -> Option<Value> {
|
||||
let p = vec3(v)?;
|
||||
let m = frame_at(self.s, &self.f, p, body).unwrap_or(self.f.active);
|
||||
let m = if self.here { self.f.sketch } else { frame_at(self.s, &self.f, p, body).unwrap_or(self.f.active) };
|
||||
self.first.get_or_insert(m);
|
||||
self.local_point(&m, p)
|
||||
}
|
||||
@@ -325,14 +337,14 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
|
||||
return None;
|
||||
}
|
||||
let obj = params.as_object()?;
|
||||
let mut m = Mapper { s, f: frames(s), first: None, changed: false };
|
||||
let mut m = Mapper { s, f: frames(s), first: None, changed: false, here: false };
|
||||
let body = obj.get("body").and_then(Value::as_str).map(str::to_string);
|
||||
let mut out: Map<String, Value> = obj.clone();
|
||||
// Picks first: their frame turns the directions.
|
||||
for pass in [true, false] {
|
||||
for (k, kind) in keys {
|
||||
let Some(v) = obj.get(*k) else { continue };
|
||||
let is_pick = matches!(kind, Pick | Picks | Plane);
|
||||
let is_pick = matches!(kind, Pick | Picks | Plane | Here | HerePlane);
|
||||
if is_pick != pass {
|
||||
continue;
|
||||
}
|
||||
@@ -343,6 +355,19 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
|
||||
Dir => m.dir(v),
|
||||
Plane => m.plane(v),
|
||||
Body => m.body(v),
|
||||
Here | HerePlane => {
|
||||
// A new sketch or canvas goes into the active component.
|
||||
let keep = m.f.sketch;
|
||||
if *kind == HerePlane {
|
||||
m.f.sketch = m.f.active;
|
||||
}
|
||||
m.here = true;
|
||||
// Body names stay: the sketch's view of the model keeps them.
|
||||
let r = if *kind == HerePlane { m.plane(v) } else { m.picks(v, None) };
|
||||
m.here = false;
|
||||
m.f.sketch = keep;
|
||||
r
|
||||
}
|
||||
};
|
||||
if let Some(n) = new {
|
||||
out.insert((*k).to_string(), n);
|
||||
|
||||
@@ -144,3 +144,106 @@ fn sketch_on_a_face_follows_a_moved_body() {
|
||||
let c = sketch_point_world(&mut s, "OnTop", "p1");
|
||||
assert!((c.z - 25.0).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
/// A 1 x 1 PNG for canvases.
|
||||
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
|
||||
/// Canvases belong to the active component and move with its occurrence; one put on a moved
|
||||
/// component's face from the root sits on the face (and stays the root's).
|
||||
#[test]
|
||||
fn canvases_move_with_their_component() {
|
||||
let mut s = moved_part();
|
||||
let part = s.active_component;
|
||||
assert_ne!(part, 0);
|
||||
run(&mut s, "canvas.insert", json!({"data": PNG, "plane": "XY", "name": "Plan"}));
|
||||
// From the root, on the moved block's top face (world).
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "canvas.insert", json!({"data": PNG, "plane": {"face": [65, 10, 10]}, "name": "Lid", "width": 10}));
|
||||
run(&mut s, "canvas.insert", json!({"data": PNG, "plane": "XZ", "name": "Root"}));
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let world = |s: &Session, name: &str| {
|
||||
let c = s.doc.canvases.iter().find(|c| c.name == name).unwrap();
|
||||
(c.component, s.doc.canvas_plane(&vals, c).unwrap())
|
||||
};
|
||||
let (c, p) = world(&s, "Plan");
|
||||
assert_eq!(c, part);
|
||||
assert!(near(p.origin, Vec3::new(50.0, 0.0, 0.0)), "{:?}", p.origin);
|
||||
let (c, p) = world(&s, "Lid");
|
||||
assert_eq!(c, 0, "the active component's");
|
||||
let lid = s.doc.canvases.iter().find(|c| c.name == "Lid").unwrap();
|
||||
let centre = p.to_world(lid.center);
|
||||
assert!(near(centre, Vec3::new(65.0, 10.0, 10.0)), "{centre:?}");
|
||||
assert_eq!(world(&s, "Root").0, 0);
|
||||
// Moving the occurrence carries its canvases, not the root's.
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 40, 0]}));
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let plan = s.doc.canvases.iter().find(|c| c.name == "Plan").unwrap();
|
||||
assert!(near(s.doc.canvas_plane(&vals, plan).unwrap().origin, Vec3::new(50.0, 40.0, 0.0)));
|
||||
let lid = s.doc.canvases.iter().find(|c| c.name == "Lid").unwrap();
|
||||
let centre = s.doc.canvas_plane(&vals, lid).unwrap().to_world(lid.center);
|
||||
assert!(near(centre, Vec3::new(65.0, 10.0, 10.0)), "{centre:?}");
|
||||
let root = s.doc.canvases.iter().find(|c| c.name == "Root").unwrap();
|
||||
assert!(near(s.doc.canvas_plane(&vals, root).unwrap().origin, Vec3::ZERO));
|
||||
// The component survives a save and reopen.
|
||||
let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(back.canvases, s.doc.canvases);
|
||||
}
|
||||
|
||||
/// World points of a sketch's curves' points (sorted).
|
||||
fn sketch_world_points(s: &mut Session, sketch: &str) -> Vec<Vec3> {
|
||||
let si = run(s, "sketch.inspect", json!({ "sketch": sketch }));
|
||||
si["points"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let w = &p["world"];
|
||||
Vec3::new(w[0].as_f64().unwrap(), w[1].as_f64().unwrap(), w[2].as_f64().unwrap())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A block "C" (40 x 30 x 20) in its own component, the root active again.
|
||||
fn block_in_component() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "C"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The user's "big square": a sketch made in the root on a moved component's face lies on the
|
||||
/// face where it is shown, its projected loop on the face's edges (moved and turned), and it
|
||||
/// follows when the occurrence moves on.
|
||||
#[test]
|
||||
fn root_sketch_on_a_moved_components_face() {
|
||||
let mut s = block_in_component();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "translate": [100, 0, 0]}));
|
||||
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [120, 15, 20]}, "name": "Top"}));
|
||||
assert!(!r["projected"].is_null(), "the face's edges are projected: {r}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let pts = sketch_world_points(&mut s, "Top");
|
||||
assert!(pts.len() >= 4, "{pts:?}");
|
||||
// Every point but the sketch's origin (the model origin on the face plane).
|
||||
for p in pts.iter().filter(|p| !near(**p, Vec3::new(0.0, 0.0, 20.0))) {
|
||||
assert!((p.z - 20.0).abs() < 1e-6 && p.x > 100.0 - 1e-6 && p.x < 140.0 + 1e-6 && p.y > -1e-6 && p.y < 30.0 + 1e-6, "{p:?}");
|
||||
}
|
||||
for corner in [Vec3::new(100.0, 0.0, 20.0), Vec3::new(140.0, 30.0, 20.0)] {
|
||||
assert!(pts.iter().any(|p| near(*p, corner)), "{corner:?} in {pts:?}");
|
||||
}
|
||||
assert_eq!(s.doc.find_feature("Top").unwrap().component, 0, "the sketch is the root's");
|
||||
|
||||
// Turned 90 degrees about Z (around the world origin): the top face is now x -30..0, y 0..40.
|
||||
let mut s = block_in_component();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "axis": [0, 0, 1], "angle": "90 deg", "origin": [0, 0, 0]}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [-15, 20, 20]}, "name": "Top"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let pts = sketch_world_points(&mut s, "Top");
|
||||
for corner in [Vec3::new(0.0, 0.0, 20.0), Vec3::new(-30.0, 40.0, 20.0)] {
|
||||
assert!(pts.iter().any(|p| near(*p, corner)), "{corner:?} in {pts:?}");
|
||||
}
|
||||
// Projected geometry stays on the face when the occurrence moves on (the link re-resolves).
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "C:1", "translate": [0, 0, 5]}));
|
||||
let pts = sketch_world_points(&mut s, "Top");
|
||||
assert!(pts.iter().any(|p| near(*p, Vec3::new(-30.0, 40.0, 25.0))), "{pts:?}");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -166,6 +168,36 @@ pub struct Session {
|
||||
pub section: Option<(Vec3, Vec3)>,
|
||||
/// Surface analysis shading the model (zebra, draft, curvature map).
|
||||
pub analysis: Option<SurfaceAnalysis>,
|
||||
/// What is hidden or shown in the view (`browser.visibility`).
|
||||
pub visibility: Visibility,
|
||||
/// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of
|
||||
/// commands are undone however long the undo history is.
|
||||
pub(crate) checkpoints: Vec<Checkpoint>,
|
||||
next_checkpoint: u64,
|
||||
}
|
||||
|
||||
/// The design, active sketch and undo history at an `edit.checkpoint`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Checkpoint {
|
||||
pub id: u64,
|
||||
pub doc: Arc<Document>,
|
||||
pub active_sketch: Option<u64>,
|
||||
pub undo: Vec<Snapshot>,
|
||||
pub redo: Vec<Snapshot>,
|
||||
}
|
||||
|
||||
/// Items hidden or shown one by one (view state, not part of the design and not an undo step).
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Visibility {
|
||||
/// Bodies by name.
|
||||
pub hidden_bodies: Vec<String>,
|
||||
/// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction geometry, by name.
|
||||
pub hidden_origin: Vec<String>,
|
||||
/// Sketches by feature id.
|
||||
pub hidden_sketches: Vec<u64>,
|
||||
/// Finished sketches shown although a feature uses them.
|
||||
pub shown_sketches: Vec<u64>,
|
||||
}
|
||||
|
||||
/// A way of shading the model's faces to judge their shape (view state, not part of the design).
|
||||
@@ -186,6 +218,8 @@ pub enum SurfaceAnalysis {
|
||||
}
|
||||
|
||||
const MAX_UNDO: usize = 200;
|
||||
/// Checkpoints kept (the oldest go first).
|
||||
const MAX_CHECKPOINTS: usize = 16;
|
||||
const MAX_LOG: usize = 2000;
|
||||
|
||||
impl Default for Session {
|
||||
@@ -217,6 +251,9 @@ impl Session {
|
||||
clipboard: Vec::new(),
|
||||
section: None,
|
||||
analysis: None,
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
visibility: Visibility::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +374,44 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember the design, active sketch and undo history; returns the checkpoint's id.
|
||||
pub fn checkpoint(&mut self) -> u64 {
|
||||
let id = self.next_checkpoint;
|
||||
self.next_checkpoint += 1;
|
||||
self.checkpoints.push(Checkpoint {
|
||||
id,
|
||||
doc: self.doc.clone(),
|
||||
active_sketch: self.active_sketch,
|
||||
undo: self.undo.clone(),
|
||||
redo: self.redo.clone(),
|
||||
});
|
||||
if self.checkpoints.len() > MAX_CHECKPOINTS {
|
||||
self.checkpoints.remove(0);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// Go back to checkpoint `id` as if nothing since had happened (the undo history too). It
|
||||
/// and later checkpoints are used up.
|
||||
pub fn restore_checkpoint(&mut self, id: u64) -> Result<()> {
|
||||
let i = self.checkpoints.iter().position(|c| c.id == id).ok_or_else(|| EngineError::Other(format!("no checkpoint {id}")))?;
|
||||
let Some(c) = self.checkpoints.drain(i..).next() else { return Err(EngineError::Other(format!("no checkpoint {id}"))) };
|
||||
self.doc = c.doc;
|
||||
self.active_sketch = c.active_sketch;
|
||||
self.undo = c.undo;
|
||||
self.redo = c.redo;
|
||||
self.selection.clear();
|
||||
self.refresh();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget checkpoint `id` (and later ones) without going back.
|
||||
pub fn drop_checkpoint(&mut self, id: u64) {
|
||||
if let Some(i) = self.checkpoints.iter().position(|c| c.id == id) {
|
||||
self.checkpoints.truncate(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// A scratch copy of the session (same design and model, no history or log) to try
|
||||
/// commands on without touching this one.
|
||||
pub fn scratch(&self) -> Session {
|
||||
@@ -358,6 +433,9 @@ impl Session {
|
||||
clipboard: self.clipboard.clone(),
|
||||
section: None,
|
||||
analysis: None,
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
visibility: Visibility::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +503,8 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod boolean_regression_tests;
|
||||
#[cfg(test)]
|
||||
mod construct_tests;
|
||||
#[cfg(test)]
|
||||
@@ -432,6 +512,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));
|
||||
}
|
||||
@@ -140,7 +140,11 @@ impl Autosaver {
|
||||
let _ = fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
let id = format!("{}-{}", unix_now(), std::process::id());
|
||||
// Unique within the process too: an app started again in the same second (tests that
|
||||
// "crash" and restart) must not take over the crashed app's entry.
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let id = format!("{}-{}-{seq}", unix_now(), std::process::id());
|
||||
let lock = OpenOptions::new().create(true).truncate(false).write(true).open(file(dir, &id, "lock")).map_err(io)?;
|
||||
lock.lock().map_err(io)?;
|
||||
Ok(Autosaver { dir: dir.to_path_buf(), id, interval, lock: Some(lock), last: None, last_at: Instant::now(), has_entry: false })
|
||||
@@ -225,6 +229,21 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_app_restarted_in_the_same_second_still_offers_the_crashed_ones_work() {
|
||||
let d = dir("restart");
|
||||
let mut s = Session::default();
|
||||
let mut a = Autosaver::new(&d, Duration::from_secs(3600)).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
assert!(a.save(&s).unwrap());
|
||||
drop(a);
|
||||
let b = Autosaver::new(&d, Duration::from_secs(3600)).unwrap();
|
||||
let found = orphans(&d);
|
||||
assert_eq!(found.len(), 1, "the crashed app's entry is offered");
|
||||
assert_ne!(found[0].id, b.id);
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_drops_the_entry_and_a_clean_close_leaves_nothing() {
|
||||
let d = dir("clean");
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
+206
-28
@@ -20,7 +20,23 @@ fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_extrude_from_a_chamfered_top_face() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 20}));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": 2}));
|
||||
let base = volume(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [0, 0, 20]}}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
assert!(rel(volume(&mut s), base + PI * 64.0 * 5.0) < 5e-4);
|
||||
}
|
||||
|
||||
mod component_frames;
|
||||
mod robustness;
|
||||
|
||||
/// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole.
|
||||
#[test]
|
||||
@@ -278,8 +294,6 @@ fn hostile_params_never_panic() {
|
||||
"chain",
|
||||
"at",
|
||||
"curve",
|
||||
"major",
|
||||
"minor",
|
||||
"minor_radius",
|
||||
"apex",
|
||||
"rho",
|
||||
@@ -297,32 +311,47 @@ fn hostile_params_never_panic() {
|
||||
"kind",
|
||||
"prefix",
|
||||
];
|
||||
let mut internal = Vec::new();
|
||||
for spec in command_specs() {
|
||||
if matches!(spec.id, "doc.open" | "file.save" | "file.save_as" | "file.export" | "file.save_mesh" | "sketch.export_dxf") {
|
||||
continue; // file system side effects are covered by their own tests
|
||||
}
|
||||
for v in hostile_values() {
|
||||
for k in keys {
|
||||
if k == "path" && spec.id.starts_with("parameters.") {
|
||||
continue; // parameter files: covered by their own tests (no stray files)
|
||||
}
|
||||
let mut s = Session::default();
|
||||
// A model with a sketch, a body and an active sketch, so most commands get far.
|
||||
let _ = s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10}));
|
||||
let _ = s.execute("sketch.create", &json!({"plane": "XY"}));
|
||||
let _ = s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [5, 5]}));
|
||||
let p = json!({ k: v.clone() });
|
||||
if let Err(EngineError::Internal(id, msg)) = s.execute(spec.id, &p) {
|
||||
internal.push(format!("{id} {p}: {msg}"));
|
||||
}
|
||||
// Whole-value hostility too.
|
||||
if let Err(EngineError::Internal(id, msg)) = s.execute(spec.id, &v) {
|
||||
internal.push(format!("{id} {v}: {msg}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Commands are independent; worker threads take them one at a time (their costs vary a lot).
|
||||
let ids: Vec<&str> = command_specs()
|
||||
.iter()
|
||||
.map(|spec| spec.id)
|
||||
.filter(|id| !matches!(*id, "doc.open" | "file.save" | "file.save_as" | "file.export" | "file.save_mesh" | "sketch.export_dxf")) // file system side effects are covered by their own tests
|
||||
.collect();
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
|
||||
let internal: Vec<String> = std::thread::scope(|scope| {
|
||||
let workers: Vec<_> = (0..threads)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
let mut internal = Vec::new();
|
||||
while let Some(&id) = ids.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
for v in hostile_values() {
|
||||
for k in keys {
|
||||
if k == "path" && id.starts_with("parameters.") {
|
||||
continue; // parameter files: covered by their own tests (no stray files)
|
||||
}
|
||||
let mut s = Session::default();
|
||||
// A model with a sketch, a body and an active sketch, so most commands get far.
|
||||
let _ = s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10}));
|
||||
let _ = s.execute("sketch.create", &json!({"plane": "XY"}));
|
||||
let _ = s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [5, 5]}));
|
||||
let p = json!({ k: v.clone() });
|
||||
if let Err(EngineError::Internal(id, msg)) = s.execute(id, &p) {
|
||||
internal.push(format!("{id} {p}: {msg}"));
|
||||
}
|
||||
// Whole-value hostility too.
|
||||
if let Err(EngineError::Internal(id, msg)) = s.execute(id, &v) {
|
||||
internal.push(format!("{id} {v}: {msg}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
internal
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
|
||||
});
|
||||
assert!(internal.is_empty(), "commands panicked:\n{}", internal.join("\n"));
|
||||
}
|
||||
|
||||
@@ -636,6 +665,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);
|
||||
}
|
||||
@@ -797,6 +839,51 @@ fn pipe_scale_offset_bounding_and_materials() {
|
||||
assert!(s.execute("material.assign", &json!({"bodies": ["Cube"], "material": "unobtainium"})).is_err());
|
||||
}
|
||||
|
||||
/// Regression: deleting a pipe's path sketch left the pipe in the timeline ("unknown sketch").
|
||||
#[test]
|
||||
fn deleting_a_path_sketch_deletes_its_pipe() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]], "ids": ["l1"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let pipe = run(&mut s, "solid.pipe", json!({"path_sketch": "Path", "path": ["l1"], "diameter": 4}));
|
||||
let deps = run(&mut s, "timeline.dependents", json!({"feature": "Path"}));
|
||||
assert_eq!(deps["deleted_with_it"].as_array().map(Vec::len), Some(1), "{deps} (pipe {pipe})");
|
||||
let r = run(&mut s, "timeline.delete", json!({"features": ["Path"]}));
|
||||
assert_eq!(r["deleted"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
assert!(s.doc.features.is_empty(), "{:?}", s.doc.features.iter().map(|f| &f.name).collect::<Vec<_>>());
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()));
|
||||
}
|
||||
|
||||
/// Regression: batches were rolled back by undoing as many steps as the undo history grew,
|
||||
/// which is none once the history is full, leaving their sketches behind.
|
||||
#[test]
|
||||
fn checkpoint_restores_with_a_full_undo_history() {
|
||||
let mut s = Session::default();
|
||||
for i in 0..MAX_UNDO + 5 {
|
||||
run(&mut s, "parameters.add", json!({"name": format!("p{i}"), "expression": "1 mm"}));
|
||||
}
|
||||
assert_eq!(s.undo.len(), MAX_UNDO);
|
||||
let labels: Vec<String> = s.undo.iter().map(|u| u.label.clone()).collect();
|
||||
let doc = s.doc.clone();
|
||||
let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "TapHole"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 2}));
|
||||
assert!(s.active_sketch.is_some());
|
||||
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id}));
|
||||
assert!(Arc::ptr_eq(&s.doc, &doc) && s.active_sketch.is_none());
|
||||
assert!(s.doc.find_feature("TapHole").is_none());
|
||||
assert_eq!(s.undo.iter().map(|u| u.label.clone()).collect::<Vec<_>>(), labels);
|
||||
// Used up: a second restore is an error, not a jump somewhere else.
|
||||
assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err());
|
||||
// Forgetting one leaves the design alone.
|
||||
let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Kept"}));
|
||||
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id, "forget": true}));
|
||||
assert!(s.doc.find_feature("Kept").is_some());
|
||||
assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_occurrences_and_world_placement() {
|
||||
let mut s = Session::default();
|
||||
@@ -1068,6 +1155,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 +1933,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}");
|
||||
}
|
||||
}
|
||||
+91
-11
@@ -41,10 +41,11 @@ pub mod colors {
|
||||
pub fn bounds(s: &Session) -> Aabb3 {
|
||||
let st = s.world_state();
|
||||
let mut b = Aabb3::EMPTY;
|
||||
for body in &st.bodies {
|
||||
let v = &s.visibility;
|
||||
for body in st.bodies.iter().filter(|x| !v.hidden_bodies.contains(&x.name)) {
|
||||
b = b.union(&body.mesh().bounds());
|
||||
}
|
||||
for ss in &st.sketches {
|
||||
for ss in st.sketches.iter().filter(|x| !v.hidden_sketches.contains(&x.feature)) {
|
||||
if let Some((lo, hi)) = ss.sketch.bounds() {
|
||||
for p in [lo, hi] {
|
||||
b.add(ss.plane.to_world(p));
|
||||
@@ -121,28 +122,50 @@ pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool {
|
||||
/// The scene for a headless render.
|
||||
pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
let st = s.world_state();
|
||||
let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), ..Default::default() };
|
||||
let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), clip: s.section.map(|(o, n)| (n, n.dot(o))), ..Default::default() };
|
||||
let b = bounds(s);
|
||||
sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target);
|
||||
// Grid on 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)] {
|
||||
sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, width: 1.6, on_top: false });
|
||||
}
|
||||
for body in &st.bodies {
|
||||
for body in st.bodies.iter().filter(|b| !s.visibility.hidden_bodies.contains(&b.name)) {
|
||||
let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name));
|
||||
let mesh = body.mesh();
|
||||
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;
|
||||
@@ -152,8 +175,7 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
}
|
||||
for ss in &st.sketches {
|
||||
let active = s.active_sketch == Some(ss.feature);
|
||||
// Finished sketches are hidden once a feature uses them, like Fusion's default.
|
||||
if !active && sketch_consumed(s, ss.feature) {
|
||||
if !sketch_shown(s, ss.feature) {
|
||||
continue;
|
||||
}
|
||||
for (pts, col, cons) in sketch_lines(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) {
|
||||
@@ -163,6 +185,13 @@ pub fn scene(s: &Session, cam: &Camera) -> Scene {
|
||||
sc
|
||||
}
|
||||
|
||||
/// Is a sketch drawn? The active one is; others unless hidden. Finished sketches are hidden
|
||||
/// once a feature uses them, like Fusion's default, unless shown one by one.
|
||||
pub fn sketch_shown(s: &Session, id: u64) -> bool {
|
||||
let v = &s.visibility;
|
||||
s.active_sketch == Some(id) || (!v.hidden_sketches.contains(&id) && (v.shown_sketches.contains(&id) || !sketch_consumed(s, id)))
|
||||
}
|
||||
|
||||
/// Is a sketch used by a later feature?
|
||||
pub fn sketch_consumed(s: &Session, id: u64) -> bool {
|
||||
use solvecraft_doc::FeatureKind;
|
||||
@@ -236,3 +265,54 @@ 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}");
|
||||
}
|
||||
|
||||
/// A section shows in snapshots: the cut part is gone and the cut face is drawn.
|
||||
#[test]
|
||||
fn snapshots_show_the_section() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let cam = home_camera(&s);
|
||||
let whole = solvecraft_render::render(&scene(&s, &cam), &cam, 200, 140).rgba;
|
||||
s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap();
|
||||
let sc = scene(&s, &cam);
|
||||
assert!(sc.clip.is_some());
|
||||
let cut = solvecraft_render::render(&sc, &cam, 200, 140).rgba;
|
||||
let changed = whole.chunks(4).zip(cut.chunks(4)).filter(|(a, b)| a != b).count();
|
||||
assert!(changed > 500, "{changed} pixels changed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,71 @@ impl Mesh {
|
||||
pub fn face_edges(&self, f: u32) -> Vec<usize> {
|
||||
self.edge_faces.iter().enumerate().filter(|(_, fs)| fs.contains(&f)).map(|(i, _)| i).collect()
|
||||
}
|
||||
/// Pieces of one surface joined across hidden seams, including `face` itself.
|
||||
pub fn surface_patch(&self, face: usize) -> Vec<usize> {
|
||||
let mut patch = vec![face];
|
||||
let mut todo = vec![face];
|
||||
while let Some(f) = todo.pop() {
|
||||
for (ei, fs) in self.edge_faces.iter().enumerate() {
|
||||
if !self.seams.get(ei).copied().unwrap_or(false) || !fs.iter().any(|x| *x as usize == f) {
|
||||
continue;
|
||||
}
|
||||
for &next in fs {
|
||||
let next = next as usize;
|
||||
if !patch.contains(&next) {
|
||||
patch.push(next);
|
||||
todo.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
patch.sort_unstable();
|
||||
patch
|
||||
}
|
||||
|
||||
/// A complete circular rim may be split into several kernel edges. Keep its pieces
|
||||
/// together for picking, without joining other tangent curves or changing edge indices.
|
||||
pub fn circular_rim(&self, edge: usize) -> Vec<usize> {
|
||||
let single = vec![edge];
|
||||
let Some(p) = self.edges.get(edge).filter(|p| p.len() >= 3) else { return single };
|
||||
let (Some(&a), Some(&b), Some(&c)) = (p.first(), p.get(p.len() / 2), p.last()) else { return single };
|
||||
let (u, v) = (b - a, c - a);
|
||||
let n = u.cross(v);
|
||||
if !(n.len2() > u.len2() * v.len2() * 1e-12) {
|
||||
return single;
|
||||
}
|
||||
let center = a + (v.cross(n) * u.len2() + n.cross(u) * v.len2()) / (2.0 * n.len2());
|
||||
let radius = center.dist(a);
|
||||
let normal = n.normalized().unwrap_or(Vec3::ZERO);
|
||||
let tol = (radius * 1e-6).max(1e-9);
|
||||
let mut rim = self.tangent_chain(edge, 2f64.to_radians());
|
||||
if rim.len() < 2 || !radius.is_finite() {
|
||||
return single;
|
||||
}
|
||||
// All samples must lie on this circle, and every end must join exactly one other
|
||||
// piece. Open arcs, ellipses, and branching tangent chains stay separate.
|
||||
for &i in &rim {
|
||||
let Some(points) = self.edges.get(i) else { return single };
|
||||
if points.iter().any(|q| !q.is_finite() || (q.dist(center) - radius).abs() > tol || (*q - center).dot(normal).abs() > tol) {
|
||||
return single;
|
||||
}
|
||||
for end in [points.first(), points.last()].into_iter().flatten() {
|
||||
let joins = rim
|
||||
.iter()
|
||||
.filter(|&&j| j != i)
|
||||
.filter_map(|&j| self.edges.get(j))
|
||||
.flat_map(|q| [q.first(), q.last()])
|
||||
.flatten()
|
||||
.filter(|q| q.dist(*end) < tol)
|
||||
.count();
|
||||
if joins != 1 {
|
||||
return single;
|
||||
}
|
||||
}
|
||||
}
|
||||
rim.sort_unstable();
|
||||
rim
|
||||
}
|
||||
/// The edges reached from `e` through vertices where the next edge continues smoothly
|
||||
/// (angle between the end tangents below `max_angle`), including `e` itself.
|
||||
pub fn tangent_chain(&self, e: usize, max_angle: f64) -> Vec<usize> {
|
||||
|
||||
@@ -17,8 +17,8 @@ pub struct Plane {
|
||||
impl Plane {
|
||||
/// Top: sketch x = +X, y = +Y, normal +Z.
|
||||
pub const XY: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y };
|
||||
/// Front: sketch x = +X, y = +Z, normal −Y.
|
||||
pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
|
||||
/// Front: sketch x = +X, y = −Z, normal +Y.
|
||||
pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::new(0.0, 0.0, -1.0) };
|
||||
/// Right: sketch x = +Y, y = +Z, normal +X.
|
||||
pub const YZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::Y, y: Vec3::Z };
|
||||
|
||||
|
||||
@@ -28,10 +28,10 @@ fn raycast_hits_front_face() {
|
||||
#[test]
|
||||
fn planes() {
|
||||
assert_eq!(Plane::XY.normal(), Vec3::Z);
|
||||
assert_eq!(Plane::XZ.normal(), Vec3::new(0.0, -1.0, 0.0));
|
||||
assert_eq!(Plane::XZ.normal(), Vec3::Y);
|
||||
assert_eq!(Plane::YZ.normal(), Vec3::X);
|
||||
let p = Plane::XZ.to_world(Vec2::new(3.0, 4.0));
|
||||
assert_eq!(p, Vec3::new(3.0, 0.0, 4.0));
|
||||
assert_eq!(p, Vec3::new(3.0, 0.0, -4.0));
|
||||
assert_eq!(Plane::XZ.to_local(p), Vec2::new(3.0, 4.0));
|
||||
let hit = Plane::XY.offset(5.0).intersect_ray(Vec3::new(1.0, 2.0, 10.0), Vec3::new(0.0, 0.0, -2.0)).unwrap();
|
||||
assert!(hit.dist(Vec3::new(1.0, 2.0, 5.0)) < 1e-12);
|
||||
@@ -84,6 +84,46 @@ fn tangent_chains_follow_smooth_edges() {
|
||||
assert_eq!(m.face_edges(1), vec![1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_rims_group_only_closed_cocircular_edges() {
|
||||
let arc = |start: f64, sweep: f64, yscale: f64| -> Vec<Vec3> {
|
||||
(0..=32)
|
||||
.map(|i| {
|
||||
let a = start + sweep * i as f64 / 32.0;
|
||||
Vec3::new(10.0 * a.cos(), yscale * 10.0 * a.sin(), 5.0)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let pi = std::f64::consts::PI;
|
||||
let mut m = Mesh {
|
||||
positions: vec![Vec3::new(-10.0, -10.0, 5.0), Vec3::new(10.0, 10.0, 5.0)],
|
||||
edges: vec![arc(0.0, pi, 1.0), arc(2.0 * pi, -pi, 1.0)],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(m.circular_rim(0), vec![0, 1]);
|
||||
assert_eq!(m.circular_rim(1), vec![0, 1]);
|
||||
// A hidden seam cannot form a selectable rim.
|
||||
m.seams = vec![false, true];
|
||||
assert_eq!(m.circular_rim(0), vec![0]);
|
||||
m.seams.clear();
|
||||
m.edges[1] = arc(pi, pi / 2.0, 1.0);
|
||||
assert_eq!(m.circular_rim(0), vec![0]);
|
||||
// A closed, smooth ellipse is not a circle.
|
||||
m.edges = vec![arc(0.0, pi, 0.7), arc(pi, pi, 0.7)];
|
||||
assert_eq!(m.circular_rim(0), vec![0]);
|
||||
assert_eq!(m.circular_rim(99), vec![99]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_patches_cross_only_hidden_seams() {
|
||||
let mut m = Mesh { edge_faces: vec![vec![0, 1], vec![1, 2], vec![2, 3]], seams: vec![true, true, false], ..Default::default() };
|
||||
assert_eq!(m.surface_patch(0), vec![0, 1, 2]);
|
||||
assert_eq!(m.surface_patch(2), vec![0, 1, 2]);
|
||||
assert_eq!(m.surface_patch(3), vec![3]);
|
||||
m.seams.clear();
|
||||
assert_eq!(m.surface_patch(0), vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubic_and_conic_segments_measure_exactly() {
|
||||
use crate::{Loop2, Seg2, Vec2};
|
||||
|
||||
@@ -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,
|
||||
|
||||
+195
-2
@@ -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();
|
||||
@@ -657,13 +795,68 @@ pub enum PathSeg {
|
||||
},
|
||||
}
|
||||
|
||||
impl PathSeg {
|
||||
/// Unit directions of the path where the segment starts and ends.
|
||||
fn tangents(&self) -> Option<(Vec3, Vec3)> {
|
||||
match *self {
|
||||
PathSeg::Line { a, b } => {
|
||||
let t = (b - a).normalized()?;
|
||||
Some((t, t))
|
||||
}
|
||||
PathSeg::Arc { a, center, axis, angle } => {
|
||||
let k = axis.normalized()?;
|
||||
let end = rotate_about(a, center, k, angle);
|
||||
Some((k.cross(a - center).normalized()?, k.cross(end - center).normalized()?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Points along the segment (an arc within `tol` of its chords), without its start.
|
||||
fn points_after_start(&self, tol: f64) -> Vec<Vec3> {
|
||||
match *self {
|
||||
PathSeg::Line { b, .. } => vec![b],
|
||||
PathSeg::Arc { a, center, axis, angle } => {
|
||||
let Some(k) = axis.normalized() else { return vec![a] };
|
||||
let r = (a - center).len();
|
||||
let step = if r > tol { 2.0 * (1.0 - tol / r).acos() } else { angle.abs() };
|
||||
let n = ((angle.abs() / step.max(1e-6)).ceil() as usize).clamp(1, 512);
|
||||
(1..=n).map(|i| rotate_about(a, center, k, angle * i as f64 / n as f64)).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `p` turned by `angle` about the unit axis `k` through `center` (right hand).
|
||||
fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 {
|
||||
let v = p - center;
|
||||
let (s, c) = angle.sin_cos();
|
||||
center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c))
|
||||
}
|
||||
|
||||
/// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile
|
||||
/// perpendicular to the path (lines translate it, arcs rotate it).
|
||||
/// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners
|
||||
/// is swept as a polyline, mitred at the corners.
|
||||
pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body> {
|
||||
region_ok(region)?;
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
||||
}
|
||||
// Segment by segment the profile only follows a path whose direction runs on across each
|
||||
// joint: at a corner the next line would slide the profile within its own plane.
|
||||
let ends: Vec<(Vec3, Vec3)> =
|
||||
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
|
||||
let corner = ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos());
|
||||
if corner {
|
||||
let first = match path.first() {
|
||||
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
|
||||
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
|
||||
};
|
||||
let mut pts = vec![first];
|
||||
for seg in path {
|
||||
pts.extend(seg.points_after_start(1e-2));
|
||||
}
|
||||
return crate::sweep_path::sweep_path(plane, region, &pts);
|
||||
}
|
||||
// The outer loop and each hole, swept along the path; the ends are capped with holes.
|
||||
let mut starts = vec![wire(plane, ®ion.outer.ccw())?];
|
||||
for h in ®ion.holes {
|
||||
|
||||
@@ -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,17 @@ 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 {
|
||||
// Edges a boolean made are intersection curves, which panic where they no longer
|
||||
// evaluate; the solid then stays as it is.
|
||||
crate::guard("heal", || Ok(heal_shells(&solid, size, keep))).ok().flatten().unwrap_or(solid)
|
||||
}
|
||||
|
||||
fn heal_shells(solid: &Solid, size: f64, keep: &[Vec<Vec3>]) -> Option<Solid> {
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
let mut shells = Vec::new();
|
||||
let mut changed = false;
|
||||
@@ -391,7 +423,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;
|
||||
}
|
||||
@@ -402,9 +434,9 @@ pub fn heal(solid: Solid, size: f64) -> Solid {
|
||||
shells.push(cur);
|
||||
}
|
||||
if !changed {
|
||||
return solid;
|
||||
return None;
|
||||
}
|
||||
Solid::try_new(shells).unwrap_or(solid)
|
||||
Solid::try_new(shells).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -141,3 +141,19 @@ 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 plane = Plane { y: Vec3::Z, ..Plane::XZ }; // Positive profile heights are world +Z.
|
||||
let hole = crate::revolve(&plane, &[profile], Vec2::ZERO, Vec2::new(0.0, 1.0), std::f64::consts::TAU).unwrap().pop().unwrap();
|
||||
let drilled = boolean(&plate, &hole, BoolOp::Cut).unwrap().unwrap();
|
||||
let v = 12000.0 - std::f64::consts::PI * 16.0 * (5.0 + 1.0);
|
||||
assert!(rel(measure(&drilled).unwrap().volume, v) < 1e-3);
|
||||
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,17 +50,18 @@ 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;
|
||||
pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
|
||||
pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix};
|
||||
pub use polybool::{faceted_boolean, planar_boolean};
|
||||
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
|
||||
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
|
||||
|
||||
@@ -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);
|
||||
|
||||
+136
-12
@@ -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)]
|
||||
@@ -15,6 +15,12 @@ pub enum BoolOp {
|
||||
Intersect,
|
||||
}
|
||||
|
||||
/// Whether a union can sew two matching planar faces without intersecting them.
|
||||
/// Callers can retain the exact touching tool instead of extending it into material.
|
||||
pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool {
|
||||
a.solid.boundaries().len() == 1 && b.solid.boundaries().len() == 1 && crate::coplanar::glue(a, b).is_some_and(|r| r.is_ok())
|
||||
}
|
||||
|
||||
/// Shifts applied to both operands on retries. The boolean classifies some faces by casting a
|
||||
/// ray whose direction is derived from point coordinates; moving both solids (and the result
|
||||
/// back) leaves the geometry unchanged but changes those rays.
|
||||
@@ -46,7 +52,10 @@ fn mismatch(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, result: &solve
|
||||
let hi = ba.max.min(bb.max);
|
||||
let all = ba.union(&bb);
|
||||
let mut boxes = vec![(all, 300usize)];
|
||||
if lo.x < hi.x && lo.y < hi.y && lo.z < hi.z {
|
||||
// A touching seam can overlap by round-off only. Samples there sit on the
|
||||
// operands' boundaries, where ray parity cannot classify membership reliably.
|
||||
let gap = all.diagonal().max(1.0) * 1e-9;
|
||||
if hi.x - lo.x > gap && hi.y - lo.y > gap && hi.z - lo.z > gap {
|
||||
boxes.push((solvecraft_geom::Aabb3 { min: lo, max: hi }, 900));
|
||||
}
|
||||
let (ia_idx, ib_idx, ir_idx) = (a.inside_index(), b.inside_index(), result.inside_index());
|
||||
@@ -162,10 +171,46 @@ 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
|
||||
}
|
||||
|
||||
/// Smallest side of the box where the bodies' vertex boxes overlap (infinite when they don't).
|
||||
fn meeting_size(a: &Body, b: &Body) -> f64 {
|
||||
let bbox = |x: &Body| {
|
||||
let mut bb = solvecraft_geom::Aabb3::EMPTY;
|
||||
for v in x.solid.vertex_iter() {
|
||||
bb.add(from_p3(v.point()));
|
||||
}
|
||||
bb
|
||||
};
|
||||
let (ba, bb) = (bbox(a), bbox(b));
|
||||
let s = solvecraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }.size();
|
||||
if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
|
||||
}
|
||||
|
||||
fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
a.require_brep("a boolean")?;
|
||||
b.require_brep("a boolean")?;
|
||||
let size = a.size().max(b.size());
|
||||
let tol_m = size * 5e-4;
|
||||
// The checks mesh both bodies; where they meet in a region much thinner than they are (a
|
||||
// thin part trimmed by a long one), a step from their size would facet the small faces
|
||||
// there coarsely enough to fail the membership test on a correct result.
|
||||
let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5);
|
||||
let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok());
|
||||
let (va, vb) = match &meshes {
|
||||
(Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume),
|
||||
@@ -241,6 +286,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 +313,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 guard("boolean result", || Body::new(crate::heal::heal_keep(s, size, &keep))) {
|
||||
Ok(body) => {
|
||||
let (ok, v) = check(&body);
|
||||
if ok {
|
||||
@@ -338,6 +384,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()) {
|
||||
@@ -395,6 +515,15 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle:
|
||||
/// Split a body with a plane: the parts on the positive and the negative side of the plane
|
||||
/// (only the non-empty ones).
|
||||
pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec<Body>> {
|
||||
let mut out = Vec::new();
|
||||
for positive in [true, false] {
|
||||
out.extend(half_space(body, plane, positive)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The part of a body on one side of a plane (`None` when nothing is there).
|
||||
pub(crate) fn half_space(body: &Body, plane: &solvecraft_geom::Plane, positive: bool) -> Result<Option<Body>> {
|
||||
body.require_brep("split")?;
|
||||
let size = body.size();
|
||||
let mut bb = solvecraft_geom::Aabb3::EMPTY;
|
||||
@@ -412,12 +541,7 @@ pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec
|
||||
]),
|
||||
holes: vec![],
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (lo, hi) in [(0.0, reach), (-reach, 0.0)] {
|
||||
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
|
||||
if let Some(part) = boolean(body, &half, BoolOp::Intersect)? {
|
||||
out.push(part);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
let (lo, hi) = if positive { (0.0, reach) } else { (-reach, 0.0) };
|
||||
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
|
||||
boolean(body, &half, BoolOp::Intersect)
|
||||
}
|
||||
|
||||
@@ -306,12 +306,118 @@ pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
a.require_brep("a boolean")?;
|
||||
b.require_brep("a boolean")?;
|
||||
let (Some(pa), Some(pb)) = (planar_polys(a), planar_polys(b)) else {
|
||||
if let Some(r) = sliced_planar_boolean(a, b, op) {
|
||||
return r;
|
||||
}
|
||||
return Err(KernelError::Failed("not supported yet: coincident faces on curved bodies".into()));
|
||||
};
|
||||
let size = a.size().max(b.size());
|
||||
polys_boolean(pa, pb, size, op, size * 1e-9)
|
||||
}
|
||||
|
||||
/// [`planar_boolean`] of a body with curved faces and an all-planar one, when a slab between
|
||||
/// two parallel planes holds `b` and none of `a`'s curved faces (a pocket cut beside a round
|
||||
/// hole): `a` is split across the slab, the middle piece takes the exact boolean, and the
|
||||
/// pieces outside are joined back on. `None` when no such slab exists.
|
||||
fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
let pb = planar_polys(b)?;
|
||||
let size = a.size().max(b.size());
|
||||
let tol = (a.size() * 1e-3).max(1e-3);
|
||||
let infos = a.faces(tol).ok()?;
|
||||
let mesh = a.tessellate(tol).ok()?;
|
||||
let mut curved: HashMap<u32, solvecraft_geom::Aabb3> = HashMap::new();
|
||||
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if infos.get(*fi as usize)?.plane_normal.is_some() {
|
||||
continue;
|
||||
}
|
||||
let bx = curved.entry(*fi).or_insert(solvecraft_geom::Aabb3::EMPTY);
|
||||
for p in mesh.tri(t)? {
|
||||
bx.add(p);
|
||||
}
|
||||
}
|
||||
let mut tool = solvecraft_geom::Aabb3::EMPTY;
|
||||
for p in pb.iter().flat_map(|p| &p.v) {
|
||||
tool.add(*p);
|
||||
}
|
||||
let at = |v: Vec3, k: usize| [v.x, v.y, v.z].get(k).copied().unwrap_or(0.0);
|
||||
let axes = [Vec3::X, Vec3::Y, Vec3::Z];
|
||||
// Planar faces of `a` square to an axis: a cut must not land in one of their planes.
|
||||
let flat: Vec<(Vec3, Vec3)> = infos.iter().filter_map(|f| Some((f.plane_normal?, f.centroid))).collect();
|
||||
let gap = size * 1e-4;
|
||||
let place = |k: usize, near: f64, far: f64| -> Option<f64> {
|
||||
let axis = *axes.get(k)?;
|
||||
[0.5, 0.37, 0.63, 0.25, 0.75]
|
||||
.iter()
|
||||
.map(|t| near + (far - near) * t)
|
||||
.find(|x| !flat.iter().any(|(n, c)| n.dot(axis).abs() > 1.0 - 1e-9 && (at(*c, k) - x).abs() < gap))
|
||||
};
|
||||
// An axis along which every curved face lies clear of the tool: the cuts either side.
|
||||
let (k, lo, hi) = (0..3).find_map(|k| {
|
||||
let (t0, t1) = (at(tool.min, k), at(tool.max, k));
|
||||
let (mut below, mut above) = (None::<f64>, None::<f64>);
|
||||
for bx in curved.values() {
|
||||
let (c0, c1) = (at(bx.min, k), at(bx.max, k));
|
||||
if c1 < t0 - gap {
|
||||
below = Some(below.map_or(c1, |x| x.max(c1)));
|
||||
} else if c0 > t1 + gap {
|
||||
above = Some(above.map_or(c0, |x| x.min(c0)));
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let lo = match below {
|
||||
Some(c) => Some(place(k, t0, c)?),
|
||||
None => None,
|
||||
};
|
||||
let hi = match above {
|
||||
Some(c) => Some(place(k, t1, c)?),
|
||||
None => None,
|
||||
};
|
||||
Some((k, lo, hi))
|
||||
})?;
|
||||
let (u, w) = match k {
|
||||
0 => (Vec3::Y, Vec3::Z),
|
||||
1 => (Vec3::Z, Vec3::X),
|
||||
_ => (Vec3::X, Vec3::Y),
|
||||
};
|
||||
let axis = *axes.get(k)?;
|
||||
let run = || -> Result<Option<Body>> {
|
||||
let fail = |m: &str| KernelError::Failed(format!("planar boolean beside curved faces: {m}"));
|
||||
let mut middle = a.clone();
|
||||
let mut outside = Vec::new();
|
||||
for (cut, keep_positive) in [(lo, true), (hi, false)] {
|
||||
let Some(x) = cut else { continue };
|
||||
let plane = solvecraft_geom::Plane::new(axis * x, u, w).ok_or_else(|| fail("plane"))?;
|
||||
outside.extend(crate::ops::half_space(&middle, &plane, !keep_positive)?);
|
||||
middle = crate::ops::half_space(&middle, &plane, keep_positive)?.ok_or_else(|| fail("nothing beside the tool"))?;
|
||||
}
|
||||
let pm = planar_polys(&middle).ok_or_else(|| fail("the middle piece is not planar"))?;
|
||||
let r = polys_boolean(pm, pb, size, op, size * 1e-9)?;
|
||||
if op == BoolOp::Intersect {
|
||||
return Ok(r);
|
||||
}
|
||||
// The rebuild leaves the cut faces' edges split where other polygons were cut; healed,
|
||||
// they match the pieces outside edge for edge.
|
||||
let mut pieces: Vec<Body> = match r {
|
||||
Some(m) => vec![Body::new(crate::heal::heal_keep(m.deep_copy(), size, a.split_keep()))?],
|
||||
None => Vec::new(),
|
||||
};
|
||||
// Each piece outside meets the middle along its cut face (glued one shell to another).
|
||||
if pieces.iter().chain(&outside).any(|x| x.solid.boundaries().len() != 1) {
|
||||
return Err(fail("a piece in several parts"));
|
||||
}
|
||||
for o in outside {
|
||||
let joined = pieces.iter().enumerate().find_map(|(i, m)| Some((i, crate::coplanar::glue(m, &o)?.ok()?)));
|
||||
let (i, j) = joined.ok_or_else(|| fail("a piece does not join back"))?;
|
||||
if let Some(slot) = pieces.get_mut(i) {
|
||||
*slot = j;
|
||||
}
|
||||
}
|
||||
crate::ops::join_pieces(pieces)
|
||||
};
|
||||
Some(run())
|
||||
}
|
||||
|
||||
fn polys_boolean(pa: Vec<Poly>, pb: Vec<Poly>, size: f64, op: BoolOp, eps: f64) -> Result<Option<Body>> {
|
||||
let polys = csg(pa, pb, op, eps)?;
|
||||
if polys.is_empty() {
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
+128
-3
@@ -77,6 +77,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
|
||||
let mut hi = f64::NEG_INFINITY;
|
||||
let mut tops: Vec<&mt::Face> = Vec::new();
|
||||
let mut top_h = Vec::new();
|
||||
let mut bottom_h = Vec::new();
|
||||
for f in b.solid.face_iter() {
|
||||
let surf = f.oriented_surface();
|
||||
if let mt::Surface::Plane(p) = &surf {
|
||||
@@ -90,6 +91,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
|
||||
continue;
|
||||
}
|
||||
if n.dot(d) < -1.0 + 1e-9 {
|
||||
bottom_h.push(h);
|
||||
lo = lo.min(h);
|
||||
continue;
|
||||
}
|
||||
@@ -110,7 +112,7 @@ fn prism_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Prism> {
|
||||
}
|
||||
}
|
||||
// One height for every top (and bottom) cap.
|
||||
if top_h.iter().any(|h| (h - hi).abs() > tol) || !(lo.is_finite() && hi > lo + tol) {
|
||||
if top_h.iter().any(|h| (h - hi).abs() > tol) || bottom_h.iter().any(|h| (h - lo).abs() > tol) || !(lo.is_finite() && hi > lo + tol) {
|
||||
return None;
|
||||
}
|
||||
let mut regions = Vec::new();
|
||||
@@ -382,6 +384,129 @@ fn boolean2d(a: &[Region2], b: &[Region2], op: BoolOp, tol: f64) -> Option<Vec<R
|
||||
}
|
||||
|
||||
/// The boolean done on prism sections, or `None` when it doesn't apply.
|
||||
/// The pieces of a segment between the given points (those lying on it, inside it).
|
||||
fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
|
||||
if !matches!(seg, Seg2::Line { .. } | Seg2::Arc { .. }) {
|
||||
return None;
|
||||
}
|
||||
let mut ts: Vec<f64> = pts
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let t = seg.closest_param(*p);
|
||||
(t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t)
|
||||
})
|
||||
.collect();
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup_by(|x, y| (*x - *y).abs() < 1e-9);
|
||||
let mut out = Vec::new();
|
||||
let mut t0 = 0.0;
|
||||
for t1 in ts.into_iter().chain(std::iter::once(1.0)) {
|
||||
out.push(match *seg {
|
||||
Seg2::Line { .. } => Seg2::Line { a: seg.point_at(t0), b: seg.point_at(t1) },
|
||||
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius, start: start + sweep * t0, sweep: sweep * (t1 - t0) },
|
||||
_ => return None,
|
||||
});
|
||||
t0 = t1;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The union of two prisms along `d` stacked end to end (or overlapping) with different
|
||||
/// sections, sewn from its faces: each slab between the prisms' end levels has walls over its
|
||||
/// section's outline; each level has a face up over the section below less the one above, and
|
||||
/// a face down over the one above less the one below. Every outline is split where any other
|
||||
/// meets it, so the faces share their edges. `None` when a slab is empty or a curve isn't a
|
||||
/// line or an arc.
|
||||
fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option<Result<Body>> {
|
||||
use crate::sew::{EdgeSpec, FaceSpec, SurfSpec};
|
||||
let mut levels = vec![pa.lo, pa.hi, pb.lo, pb.hi];
|
||||
levels.sort_by(f64::total_cmp);
|
||||
levels.dedup_by(|x, y| (*x - *y).abs() < tol);
|
||||
let covers = |p: &Prism, z0: f64, z1: f64| p.lo <= z0 + tol && p.hi >= z1 - tol;
|
||||
let mut slabs: Vec<Vec<Region2>> = Vec::new();
|
||||
for w in levels.windows(2) {
|
||||
let (z0, z1) = (*w.first()?, *w.get(1)?);
|
||||
let s = match (covers(pa, z0, z1), covers(pb, z0, z1)) {
|
||||
(true, true) => boolean2d(&pa.regions, &pb.regions, BoolOp::Union, tol)?,
|
||||
(true, false) => pa.regions.clone(),
|
||||
(false, true) => pb.regions.clone(),
|
||||
(false, false) => return None,
|
||||
};
|
||||
slabs.push(oriented(&s));
|
||||
}
|
||||
// Faces at each level: up where the slab below sticks out, down where the one above does.
|
||||
let diff = |x: &[Region2], y: &[Region2]| -> Option<Vec<Region2>> {
|
||||
match (x.is_empty(), y.is_empty()) {
|
||||
(true, _) => Some(Vec::new()),
|
||||
(false, true) => Some(x.to_vec()),
|
||||
(false, false) => boolean2d(x, y, BoolOp::Cut, tol).map(|r| oriented(&r)),
|
||||
}
|
||||
};
|
||||
let mut caps: Vec<(f64, bool, Vec<Region2>)> = Vec::new();
|
||||
for (k, z) in levels.iter().enumerate() {
|
||||
let below: &[Region2] = if k == 0 { &[] } else { slabs.get(k - 1)? };
|
||||
let above: &[Region2] = slabs.get(k).map(Vec::as_slice).unwrap_or(&[]);
|
||||
caps.push((*z, true, diff(below, above)?));
|
||||
caps.push((*z, false, diff(above, below)?));
|
||||
}
|
||||
// Every segment end, everywhere: outlines split there.
|
||||
let loops_of =
|
||||
|rs: &[Region2]| -> Vec<Loop2> { rs.iter().flat_map(|r| std::iter::once(r.outer.clone()).chain(r.holes.iter().cloned())).collect() };
|
||||
let mut pts: Vec<Vec2> = Vec::new();
|
||||
for lp in slabs.iter().flat_map(|s| loops_of(s)).chain(caps.iter().flat_map(|c| loops_of(&c.2))) {
|
||||
for sg in &lp.segs {
|
||||
pts.push(sg.start());
|
||||
pts.push(sg.end());
|
||||
}
|
||||
}
|
||||
let at = |u: Vec2, z: f64| plane.to_world(u) + d * z;
|
||||
let edge = |sg: &Seg2, z: f64| -> EdgeSpec {
|
||||
match sg {
|
||||
Seg2::Arc { .. } => EdgeSpec::Arc { a: at(sg.start(), z), b: at(sg.end(), z), mid: at(sg.point_at(0.5), z) },
|
||||
_ => EdgeSpec::Line { a: at(sg.start(), z), b: at(sg.end(), z) },
|
||||
}
|
||||
};
|
||||
let mut faces: Vec<FaceSpec> = Vec::new();
|
||||
for (i, s) in slabs.iter().enumerate() {
|
||||
let (z0, z1) = (*levels.get(i)?, *levels.get(i + 1)?);
|
||||
for lp in loops_of(s) {
|
||||
for sg0 in &lp.segs {
|
||||
for sg in split_seg(sg0, &pts, tol)? {
|
||||
let (a, b) = (sg.start(), sg.end());
|
||||
let lp3 = vec![
|
||||
edge(&sg, z0),
|
||||
EdgeSpec::Line { a: at(b, z0), b: at(b, z1) },
|
||||
edge(&sg.reversed(), z1),
|
||||
EdgeSpec::Line { a: at(a, z1), b: at(a, z0) },
|
||||
];
|
||||
let tangent = plane.dir_to_world(sg.derivative(0.5));
|
||||
let outward = tangent.cross(d).normalized()?;
|
||||
let surface = match sg {
|
||||
Seg2::Arc { .. } => SurfSpec::Extruded { a: at(a, z0), b: at(b, z0), mid: at(sg.point_at(0.5), z0), dir: d * (z1 - z0) },
|
||||
_ => SurfSpec::Plane,
|
||||
};
|
||||
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (z, up, rs) in &caps {
|
||||
for r in rs {
|
||||
let mut loops = Vec::new();
|
||||
for lp in std::iter::once(&r.outer).chain(&r.holes) {
|
||||
let mut segs: Vec<Seg2> = Vec::new();
|
||||
for sg in &lp.segs {
|
||||
segs.extend(split_seg(sg, &pts, tol)?);
|
||||
}
|
||||
let segs: Vec<Seg2> = if *up { segs } else { segs.iter().rev().map(Seg2::reversed).collect() };
|
||||
loops.push(segs.iter().map(|sg| edge(sg, *z)).collect::<Vec<_>>());
|
||||
}
|
||||
faces.push(FaceSpec { loops, surface: SurfSpec::Plane, probe: None });
|
||||
}
|
||||
}
|
||||
Some(crate::sew::sew(&faces))
|
||||
}
|
||||
|
||||
pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
let size = a.size().max(b.size());
|
||||
let tol = (size * 1e-7).max(1e-9) * 10.0;
|
||||
@@ -423,10 +548,10 @@ pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
|
||||
return Some(Ok(None));
|
||||
}
|
||||
let (lo, hi) = if stacked {
|
||||
// Only when the sections are the same (the union adds nothing to either).
|
||||
// Different sections (a boss hanging over a plate's edge): sewn slab by slab.
|
||||
let (u, x, y) = (area(®ions), area(&pa.regions), area(&pb.regions));
|
||||
if (u - x).abs() > tol * (1.0 + u) || (u - y).abs() > tol * (1.0 + u) {
|
||||
return None;
|
||||
return sew_stack(&plane, d, &pa, &pb, tol).map(|r| r.map(Some));
|
||||
}
|
||||
(pa.lo.min(pb.lo), pa.hi.max(pb.hi))
|
||||
} else {
|
||||
|
||||
@@ -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,13 +1166,49 @@ 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();
|
||||
let m0 = measure(&c).unwrap();
|
||||
let side = c.faces(0.01).unwrap().iter().position(|f| f.plane_normal.is_none()).unwrap();
|
||||
// A horizontal line seen from the front (XZ sketch) at z = 5, swept along y.
|
||||
let xz = Plane::XZ;
|
||||
let xz = Plane { y: Vec3::Z, ..Plane::XZ };
|
||||
let tool = SplitTool::Curves { plane: xz, curves: vec![vec![Seg2::Line { a: Vec2::new(-20.0, 5.0), b: Vec2::new(20.0, 5.0) }]] };
|
||||
let s = split_faces(&c, &[side], &tool).unwrap();
|
||||
let m = measure(&s).unwrap();
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user