Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a1ab40ea0 | ||
|
|
eef2430bc7 | ||
|
|
a1a6e2b624 | ||
|
|
6b146942bd | ||
|
|
ce61aef4a6 | ||
|
|
7e0618d64b | ||
|
|
90fde44c32 | ||
|
|
cbdc4fe0d8 | ||
|
|
3b650cac5f | ||
|
|
50a74ac66d | ||
|
|
9c0632b07c | ||
|
|
bf2b64f597 | ||
|
|
3a1f05bacf |
No files matched your search
+21
-71
@@ -1,75 +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 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)).
|
||||
|
||||
## Fixed since v0.1.0
|
||||
**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`.
|
||||
|
||||
- **Quitting with unsaved changes now asks first** ([#33](https://github.com/bherbruck/solvecraft/issues/33)):
|
||||
"Save changes to <name>?" with Save, Don't Save and Cancel, once per unsaved design, for the
|
||||
title bar's ×, File ▸ Quit, Alt+F4 / Cmd+Q and the window manager. The web app asks through
|
||||
the browser's "leave site?" prompt.
|
||||
- **Web: a new design no longer shows a black screen** in browsers with WebGPU
|
||||
([#34](https://github.com/bherbruck/solvecraft/issues/34)); the viewport shader compiles there now.
|
||||
- **README demo:** a short GIF at the top shows an agent building an enclosure over MCP and then
|
||||
editing its parameters. The full video is attached below.
|
||||
|
||||
## Links
|
||||
|
||||
- **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)
|
||||
|
||||
## 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.1-windows-x86_64.zip` | Windows 10/11: `solvecraft.exe` (the app) and `solvecraft-cli.exe` |
|
||||
| `solvecraft-v0.1.1-linux-x86_64.tar.gz` | Linux x86_64 (glibc 2.35 or newer): `solvecraft` and `solvecraft-cli` |
|
||||
| `SolveCraft-v0.1.1-x86_64.AppImage` | Linux, the app as one file: `chmod +x` and run it |
|
||||
| `solvecraft-v0.1.1-macos-universal.zip` | macOS 11 or newer, Apple silicon and Intel: `SolveCraft.app` and `solvecraft-cli` |
|
||||
| `solvecraft-v0.1.1-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
|
||||
@@ -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@v4
|
||||
- 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'"
|
||||
+292
-85
@@ -1,7 +1,18 @@
|
||||
# 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 a tag `v<version>` (matching [workspace.package] version in Cargo.toml) and this builds and
|
||||
# packages SolveCraft for every platform and publishes the GitHub release "SolveCraft v<version>"
|
||||
# with SHA256SUMS.txt, .github/release-notes.md and GitHub's generated "What's Changed". 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: Actions > Release > Run workflow (publish off) builds everything and keeps it as
|
||||
# the release-assets artifact instead of releasing.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -9,128 +20,324 @@ on:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to release (must exist), e.g. v0.1.0"
|
||||
required: true
|
||||
publish:
|
||||
description: "Publish the release (off: a dry run; the files become the release-assets artifact)"
|
||||
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
|
||||
- id: v
|
||||
name: Resolve version
|
||||
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"
|
||||
. packaging/env.sh
|
||||
if [ "$GITHUB_REF_TYPE" = tag ] && [ "${GITHUB_REF_NAME#v}" != "$VERSION" ]; then
|
||||
echo "::error::tag $GITHUB_REF_NAME doesn't match the Cargo.toml version $VERSION"
|
||||
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@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
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@v4
|
||||
with:
|
||||
name: windows
|
||||
path: out/*
|
||||
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@v4
|
||||
- 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@v2
|
||||
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@v4
|
||||
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@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
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@v4
|
||||
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@v4
|
||||
- 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@v4
|
||||
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: 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"
|
||||
with:
|
||||
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@v4
|
||||
with:
|
||||
name: macos
|
||||
path: out/*
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
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@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
ref: ${{ env.TAG }}
|
||||
name: icons
|
||||
path: icons
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
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@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
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"
|
||||
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@v4
|
||||
with:
|
||||
name: web
|
||||
path: out/*
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs: [linux, windows, macos, web]
|
||||
name: GitHub Release
|
||||
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
|
||||
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: Publish the release
|
||||
if: github.event_name == 'push' || inputs.publish
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); else pre=(--latest); fi
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
echo "::error::Release $tag already exists. Bump the version before tagging."
|
||||
exit 1
|
||||
fi
|
||||
gh release create "$tag" --title "SolveCraft v$VERSION" --verify-tag --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
|
||||
{
|
||||
echo "Published **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.publish
|
||||
run: cp notes.md artifacts/release-notes.md
|
||||
- if: github.event_name == 'workflow_dispatch' && !inputs.publish
|
||||
uses: actions/upload-artifact@v4
|
||||
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/*
|
||||
@@ -40,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
|
||||
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
Generated
+13
-13
@@ -3059,7 +3059,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3072,7 +3072,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3081,7 +3081,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3095,7 +3095,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3111,7 +3111,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3119,7 +3119,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
@@ -3131,7 +3131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3146,7 +3146,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3154,7 +3154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3163,7 +3163,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3174,7 +3174,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3188,7 +3188,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4487,7 +4487,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,6 +10,8 @@
|
||||
//! 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)]
|
||||
@@ -35,6 +37,8 @@ const USAGE: &str = "usage:
|
||||
solvecraft-cli oracle <case-dir>... [--json]
|
||||
solvecraft-cli step-corpus <case-dir>... [--json]
|
||||
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 {
|
||||
@@ -55,6 +59,13 @@ fn main() -> ExitCode {
|
||||
Some("oracle") => oracle::run(&args[1..]),
|
||||
Some("step-corpus") => oracle::step_corpus(&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 +207,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) });
|
||||
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) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
pub mod cmd;
|
||||
pub mod frames;
|
||||
pub mod legacy_ids;
|
||||
pub mod licences;
|
||||
pub mod params;
|
||||
pub mod recovery;
|
||||
pub mod sample;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -72,10 +72,11 @@ impl StandardView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Smooth ease-in-out on [0, 1] (cubic).
|
||||
/// Smooth ease-in-out on [0, 1] (smoothstep: at its fastest, mid-way, 1.5 times the average
|
||||
/// speed, so long turns stay gentle).
|
||||
pub fn ease_in_out(t: f64) -> f64 {
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 1.0 };
|
||||
if t < 0.5 { 4.0 * t * t * t } else { 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 }
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// A unit quaternion (w, x, y, z).
|
||||
@@ -162,8 +163,13 @@ pub struct CameraAnim {
|
||||
impl CameraAnim {
|
||||
/// The usual length of a view change, in seconds.
|
||||
pub const DURATION: f64 = 0.5;
|
||||
/// A view change: half a second, longer for turns past a quarter turn (so a big turn, such
|
||||
/// as from the home view to the bottom, never sweeps more than about 9 degrees a frame).
|
||||
pub fn new(from: Camera, to: Camera, now: f64) -> CameraAnim {
|
||||
CameraAnim { from, to, start: now, duration: CameraAnim::DURATION }
|
||||
let dy = (to.yaw - from.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
|
||||
let turn = dy.abs().max((to.pitch - from.pitch).abs());
|
||||
let k = if turn.is_finite() { (turn / std::f64::consts::FRAC_PI_2).clamp(1.0, 2.0) } else { 1.0 };
|
||||
CameraAnim { from, to, start: now, duration: CameraAnim::DURATION * k }
|
||||
}
|
||||
/// The camera at time `now`, and whether the move has finished.
|
||||
pub fn sample(&self, now: f64) -> (Camera, bool) {
|
||||
@@ -396,10 +402,10 @@ mod tests {
|
||||
let anim = CameraAnim::new(a, b, 10.0);
|
||||
// Endpoints are exact.
|
||||
assert_eq!(anim.sample(10.0).0, a);
|
||||
assert_eq!(anim.sample(10.0 + CameraAnim::DURATION), (b, true));
|
||||
assert_eq!(anim.sample(10.0 + anim.duration), (b, true));
|
||||
assert_eq!(anim.sample(99.0), (b, true));
|
||||
// Midpoint: half way in heading, elevation, target and distance.
|
||||
let (m, done) = anim.sample(10.0 + CameraAnim::DURATION / 2.0);
|
||||
let (m, done) = anim.sample(10.0 + anim.duration / 2.0);
|
||||
assert!(!done && m.is_valid());
|
||||
let dy = (b.yaw - a.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
|
||||
assert!((m.yaw - (a.yaw + dy / 2.0)).abs() < 1e-12 && (m.pitch - (a.pitch + b.pitch) / 2.0).abs() < 1e-12);
|
||||
@@ -476,7 +482,7 @@ mod tests {
|
||||
for a in &cams {
|
||||
for b in &cams {
|
||||
let anim = CameraAnim::new(*a, *b, 0.0);
|
||||
let frames = 30;
|
||||
let frames = (anim.duration * 60.0).round() as usize;
|
||||
let mut prev = anim.sample(0.0).0;
|
||||
let (mut steps, mut done_at) = (Vec::new(), None);
|
||||
for f in 1..=frames + 2 {
|
||||
@@ -495,9 +501,9 @@ mod tests {
|
||||
let total_turn: f64 = steps.iter().map(|s| s.1).sum();
|
||||
let total_move: f64 = steps.iter().map(|s| s.2).sum();
|
||||
for (f, turn, moved) in &steps {
|
||||
// The eased curve's fastest frame is 1.5x the average: allow 3x.
|
||||
assert!(*turn <= 3.0 * total_turn / frames as f64 + 1e-9, "turn jump at frame {f}: {turn} of {total_turn} ({a:?} -> {b:?})");
|
||||
assert!(*moved <= 3.0 * total_move / frames as f64 + 1e-9, "move jump at frame {f}: {moved} of {total_move}");
|
||||
// The eased curve's fastest frame is 1.5x the average: allow 2x.
|
||||
assert!(*turn <= 2.0 * total_turn / frames as f64 + 1e-9, "turn jump at frame {f}: {turn} of {total_turn} ({a:?} -> {b:?})");
|
||||
assert!(*moved <= 2.0 * total_move / frames as f64 + 1e-9, "move jump at frame {f}: {moved} of {total_move}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,4 @@ mod camera;
|
||||
mod raster;
|
||||
|
||||
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, ease_in_out};
|
||||
pub use raster::{Canvas, Rgb, Scene, SceneLine, SceneMesh, render, render_png};
|
||||
pub use raster::{Canvas, Rgb, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent};
|
||||
@@ -200,12 +200,38 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
/// Render to PNG bytes.
|
||||
pub fn render_png(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Option<Vec<u8>> {
|
||||
let c = render(scene, cam, w, h);
|
||||
let img = image::RgbaImage::from_raw(c.w as u32, c.h as u32, c.rgba)?;
|
||||
png(c.w, c.h, c.rgba)
|
||||
}
|
||||
|
||||
/// RGBA pixels (`w × h`) to PNG bytes.
|
||||
pub fn png(w: usize, h: usize, rgba: Vec<u8>) -> Option<Vec<u8>> {
|
||||
let img = image::RgbaImage::from_raw(u32::try_from(w).ok()?, u32::try_from(h).ok()?, rgba)?;
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, image::ImageFormat::Png).ok()?;
|
||||
Some(buf.into_inner())
|
||||
}
|
||||
|
||||
/// Render on a transparent background: the scene drawn over black and over white, and the
|
||||
/// alpha recovered from the difference (exact for the anti-aliased edges).
|
||||
pub fn render_transparent(scene: &mut Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
|
||||
let keep = scene.background;
|
||||
scene.background = Some((Rgb(0, 0, 0), Rgb(0, 0, 0)));
|
||||
let black = render(scene, cam, w, h);
|
||||
scene.background = Some((Rgb(255, 255, 255), Rgb(255, 255, 255)));
|
||||
let mut out = render(scene, cam, w, h);
|
||||
scene.background = keep;
|
||||
for (o, b) in out.rgba.as_chunks_mut::<4>().0.iter_mut().zip(black.rgba.as_chunks::<4>().0) {
|
||||
// white - black = 255 (1 - alpha) in every channel; average them.
|
||||
let d: u32 = (0..3).map(|k| u32::from(o[k].saturating_sub(b[k]))).sum();
|
||||
let a = 255.0 - d as f64 / 3.0;
|
||||
for k in 0..3 {
|
||||
o[k] = if a > 0.5 { (f64::from(b[k]) * 255.0 / a).round().clamp(0.0, 255.0) as u8 } else { 0 };
|
||||
}
|
||||
o[3] = a.round().clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
//! The agent cursor (#35): a second, clearly marked pointer that acts out what an agent does
|
||||
//! over the control channel or MCP. Before an `engine.execute` runs, the pointer glides to the
|
||||
//! command's toolbar button (or its panel when the button sits in a drop-down) and clicks it,
|
||||
//! then glides to each pick in the parameters (faces, edges and points projected to the screen,
|
||||
//! sketch points, the plane of a new sketch), and only then does the command run. The reply
|
||||
//! waits for the animation, so an agent's timing stays honest. With "follow camera" on, a new
|
||||
//! sketch turns the view to face it and finishing the sketch turns back.
|
||||
//!
|
||||
//! The pointer is drawn by the app, so screen recordings capture it. It is off by default (View
|
||||
//! settings on the navigation bar, or `ui.agent_cursor {show, speed, follow_camera}`); a call
|
||||
//! with `"animate": false` skips it.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use egui::{Color32, FontId, Pos2, Shape, Stroke, pos2, vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::geom::{Vec2, Vec3};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::control::ControlResponse;
|
||||
|
||||
/// How fast the pointer moves.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Speed {
|
||||
/// No animation: the command runs at once.
|
||||
Instant,
|
||||
#[default]
|
||||
Normal,
|
||||
Slow,
|
||||
}
|
||||
|
||||
impl Speed {
|
||||
pub const ALL: [Speed; 3] = [Speed::Instant, Speed::Normal, Speed::Slow];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Speed::Instant => "Instant",
|
||||
Speed::Normal => "Normal",
|
||||
Speed::Slow => "Slow",
|
||||
}
|
||||
}
|
||||
/// Seconds to glide to a stop, and to click there.
|
||||
fn times(self) -> (f64, f64) {
|
||||
match self {
|
||||
Speed::Instant => (0.0, 0.0),
|
||||
Speed::Normal => (0.45, 0.25),
|
||||
Speed::Slow => (0.9, 0.45),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings (kept with the other view settings).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
pub show: bool,
|
||||
pub speed: Speed,
|
||||
pub follow_camera: bool,
|
||||
}
|
||||
|
||||
/// A request held back while the pointer acts it out.
|
||||
pub struct Job {
|
||||
method: String,
|
||||
params: Value,
|
||||
reply: Sender<ControlResponse>,
|
||||
stops: VecDeque<Pos2>,
|
||||
/// The leg under way: where it started and when.
|
||||
from: Pos2,
|
||||
started: f64,
|
||||
/// Clicked at the current stop (waiting out the click).
|
||||
clicked: bool,
|
||||
}
|
||||
|
||||
/// The pointer's state.
|
||||
#[derive(Default)]
|
||||
pub struct AgentCursor {
|
||||
pub pos: Option<Pos2>,
|
||||
job: Option<Job>,
|
||||
/// Click ripples: where and when.
|
||||
ripples: Vec<(Pos2, f64)>,
|
||||
/// The view before a followed sketch (restored when it finishes).
|
||||
saved_view: Option<solvecraft_engine::render::Camera>,
|
||||
}
|
||||
|
||||
impl AgentCursor {
|
||||
/// A request is being acted out (later requests wait behind it).
|
||||
pub fn busy(&self) -> bool {
|
||||
self.job.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys whose [x, y, z] values are directions or amounts, not places to point at.
|
||||
const NOT_PLACES: [&str; 14] =
|
||||
["translate", "dir1", "dir2", "axis", "direction", "normal", "pull", "color", "x_dir", "y_dir", "scale", "values", "limits", "size"];
|
||||
|
||||
/// Where the pointer goes for a command: its toolbar button (or its panel), then each pick.
|
||||
pub fn stops(app: &SolveApp, command: &str, params: &Value) -> Vec<Pos2> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(spec) = solvecraft_engine::find_command(command) {
|
||||
let button = crate::scenario::handle_at(&format!("toolbar:{}", spec.id))
|
||||
.or_else(|| crate::scenario::handle_at(&format!("panel:{}:{}", spec.tab, spec.panel)));
|
||||
out.extend(button);
|
||||
}
|
||||
let Some(rect) = app.viewport.rect else { return out };
|
||||
let proj = crate::viewport::projection(app, rect);
|
||||
for w in picks(app, command, params) {
|
||||
if let Some(p) = proj.to_screen(w).filter(|p| rect.contains(*p)) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The places a command's parameters pick, in the world: [x, y, z] points (faces, edges,
|
||||
/// vertices, positions), active-sketch [x, y] points, and a new sketch's plane.
|
||||
pub fn picks(app: &SolveApp, command: &str, params: &Value) -> Vec<Vec3> {
|
||||
let id = solvecraft_engine::find_command(command).map_or(command, |c| c.id);
|
||||
let mut out = Vec::new();
|
||||
if id == "sketch.create"
|
||||
&& let Some(n) = params.get("plane").and_then(Value::as_str)
|
||||
&& let Some((_, _, q)) = crate::viewport::origin_planes(app).into_iter().find(|(name, _, _)| *name == n)
|
||||
{
|
||||
out.push((q[0] + q[1] + q[2] + q[3]) / 4.0);
|
||||
}
|
||||
let st = app.session.world_state();
|
||||
let sketch = app.session.active_sketch.and_then(|s| st.sketch(s)).map(|ss| ss.plane);
|
||||
walk(params, None, &mut |key, v| {
|
||||
let num = |i: usize| v.get(i).and_then(Value::as_f64).filter(|x| x.is_finite());
|
||||
let place = !key.is_some_and(|k| NOT_PLACES.contains(&k));
|
||||
match (v.as_array().map(Vec::len), num(0), num(1), num(2)) {
|
||||
(Some(3), Some(x), Some(y), Some(z)) if place => out.push(Vec3::new(x, y, z)),
|
||||
(Some(2), Some(x), Some(y), None) if place => {
|
||||
if let Some(pl) = &sketch {
|
||||
out.push(pl.to_world(Vec2::new(x, y)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
// A profile feature without picks takes the profiles of the active (else the last) sketch:
|
||||
// point at them.
|
||||
if out.is_empty() && matches!(id, "solid.extrude" | "solid.revolve") {
|
||||
let ss = app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty()));
|
||||
if let Some(ss) = ss {
|
||||
out.extend(ss.profiles.iter().take(4).map(|p| ss.plane.to_world(p.region.interior_point())));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Visit every value with the key it sits under (array items inherit their array's key).
|
||||
fn walk<'a>(v: &'a Value, key: Option<&'a str>, f: &mut dyn FnMut(Option<&'a str>, &'a Value)) {
|
||||
match v {
|
||||
Value::Object(m) => {
|
||||
for (k, x) in m {
|
||||
walk(x, Some(k.as_str()), f);
|
||||
}
|
||||
}
|
||||
Value::Array(a) => {
|
||||
// A point is a leaf; a list of points or other values is walked.
|
||||
if a.len() >= 2 && a.len() <= 3 && a.iter().all(Value::is_number) {
|
||||
f(key, v);
|
||||
} else {
|
||||
for x in a {
|
||||
walk(x, key, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Take over a control request when the pointer should act it out first: returns the request
|
||||
/// back when it should simply run.
|
||||
pub fn intercept(app: &mut SolveApp, method: &str, params: &Value, reply: &Sender<ControlResponse>) -> bool {
|
||||
let s = app.ui.agent_cursor;
|
||||
if !s.show || s.speed == Speed::Instant || !matches!(method, "engine.execute" | "command") {
|
||||
return false;
|
||||
}
|
||||
if params.get("animate").and_then(Value::as_bool) == Some(false) {
|
||||
return false;
|
||||
}
|
||||
let Some(cmd) = params.get("command").or(params.get("id")).and_then(Value::as_str) else { return false };
|
||||
let inner = params.get("params").cloned().unwrap_or(json!({}));
|
||||
let stops: VecDeque<Pos2> = stops(app, cmd, &inner).into();
|
||||
if stops.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let from = app.cursor_start();
|
||||
app.agent.job =
|
||||
Some(Job { method: method.to_string(), params: params.clone(), reply: reply.clone(), stops, from, started: app.now, clicked: false });
|
||||
true
|
||||
}
|
||||
|
||||
impl SolveApp {
|
||||
/// Where the agent pointer starts: where it was, else the middle of the viewport.
|
||||
fn cursor_start(&self) -> Pos2 {
|
||||
self.agent.pos.or_else(|| self.viewport.rect.map(|r| r.center())).unwrap_or(pos2(600.0, 400.0))
|
||||
}
|
||||
}
|
||||
|
||||
fn ease(t: f64) -> f64 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// Move the pointer along; when the last stop is clicked, run the held request and reply.
|
||||
pub fn step(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let now = app.now;
|
||||
app.agent.ripples.retain(|(_, t)| now - t < 0.6);
|
||||
if !app.agent.ripples.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let (glide, click) = app.ui.agent_cursor.speed.times();
|
||||
let Some(job) = app.agent.job.as_mut() else { return };
|
||||
ctx.request_repaint();
|
||||
let Some(&to) = job.stops.front() else {
|
||||
finish(app, ctx);
|
||||
return;
|
||||
};
|
||||
let t = if glide > 0.0 { (now - job.started) / glide } else { 1.0 };
|
||||
if !job.clicked {
|
||||
let e = ease(t) as f32;
|
||||
app.agent.pos = Some(job.from + (to - job.from) * e);
|
||||
if t >= 1.0 {
|
||||
job.clicked = true;
|
||||
job.started = now;
|
||||
app.agent.ripples.push((to, now));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if now - job.started >= click {
|
||||
job.stops.pop_front();
|
||||
job.from = to;
|
||||
job.started = now;
|
||||
job.clicked = false;
|
||||
if job.stops.is_empty() {
|
||||
finish(app, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the held request now (as a call without animation) and send its reply.
|
||||
fn finish(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(job) = app.agent.job.take() else { return };
|
||||
let mut params = job.params;
|
||||
params["animate"] = json!(false);
|
||||
let cmd = params.get("command").or(params.get("id")).and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let id = solvecraft_engine::find_command(&cmd).map_or(cmd.clone(), |c| c.id.to_string());
|
||||
let follow = app.ui.agent_cursor.follow_camera;
|
||||
if follow && id == "sketch.create" {
|
||||
app.agent.saved_view = Some(app.cam);
|
||||
}
|
||||
let (req, _rx) = crate::control::ControlRequest::new(job.method, params);
|
||||
let out = match crate::control::handle(app, ctx, &req) {
|
||||
crate::control::Outcome::Done(v) => v,
|
||||
crate::control::Outcome::Screenshot { .. } => json!({"ok": false, "error": "not a command"}),
|
||||
};
|
||||
let ran = out.get("ok").and_then(Value::as_bool).unwrap_or(false);
|
||||
if follow && ran {
|
||||
if id == "sketch.create" {
|
||||
crate::dialogs::look_at_sketch(app);
|
||||
} else if id == "sketch.finish"
|
||||
&& let Some(cam) = app.agent.saved_view.take()
|
||||
{
|
||||
app.animate_to(cam);
|
||||
}
|
||||
}
|
||||
let _ = job.reply.send(out);
|
||||
}
|
||||
|
||||
/// The pointer's colour: a violet no part of the UI uses.
|
||||
const AGENT: Color32 = Color32::from_rgb(168, 85, 247);
|
||||
|
||||
/// Draw the pointer, its "Agent" tag and the click ripples over everything.
|
||||
pub fn paint(app: &SolveApp, ctx: &egui::Context) {
|
||||
if !app.ui.agent_cursor.show {
|
||||
return;
|
||||
}
|
||||
let Some(p) = app.agent.pos else { return };
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Debug, egui::Id::new("sc_agent_cursor")));
|
||||
for (at, t0) in &app.agent.ripples {
|
||||
let k = ((app.now - t0) / 0.6).clamp(0.0, 1.0) as f32;
|
||||
painter.circle_stroke(*at, 6.0 + 22.0 * k, Stroke::new(3.0 * (1.0 - k) + 0.5, AGENT.gamma_multiply(1.0 - k)));
|
||||
}
|
||||
// An arrow pointer, tip at `p`.
|
||||
let pts: Vec<Pos2> =
|
||||
[(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
|
||||
painter.add(Shape::convex_polygon(pts.clone(), AGENT, Stroke::NONE));
|
||||
painter.add(Shape::closed_line(pts, Stroke::new(1.5, Color32::WHITE)));
|
||||
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), Color32::WHITE);
|
||||
let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
|
||||
painter.rect_filled(r, 4.0, AGENT);
|
||||
painter.galley(r.min + vec2(5.0, 2.0), label, Color32::WHITE);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use solvecraft_engine::Session;
|
||||
|
||||
fn app() -> SolveApp {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let mut a = SolveApp::new(s, Services::default());
|
||||
a.viewport.rect = Some(egui::Rect::from_min_size(pos2(250.0, 120.0), vec2(1100.0, 760.0)));
|
||||
a.fit_view();
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_are_the_places_in_the_parameters() {
|
||||
let a = app();
|
||||
// Points and lists of points, not directions or colours.
|
||||
let p = json!({"edges": [[20, 0, 20], [40, 15, 20]], "position": [5, 5, 20], "translate": [1, 2, 3], "color": [255, 0, 0], "radius": 2});
|
||||
let w = picks(&a, "solid.fillet", &p);
|
||||
assert_eq!(w.len(), 3, "{w:?}");
|
||||
assert!(w.contains(&Vec3::new(20.0, 0.0, 20.0)) && w.contains(&Vec3::new(5.0, 5.0, 20.0)));
|
||||
// A new sketch's origin plane is a place too (the middle of its square).
|
||||
assert_eq!(picks(&a, "sketch.create", &json!({"plane": "XY"})).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_land_where_the_viewport_draws_them() {
|
||||
let a = app();
|
||||
let rect = a.viewport.rect.unwrap();
|
||||
let proj = crate::viewport::projection(&a, rect);
|
||||
let at = Vec3::new(20.0, 15.0, 20.0);
|
||||
let s = stops(&a, "solid.hole", &json!({"position": [20, 15, 20], "diameter": 5}));
|
||||
let want = proj.to_screen(at).unwrap();
|
||||
assert!(s.iter().any(|p| p.distance(want) < 0.01), "{s:?} vs {want:?}");
|
||||
assert!(rect.contains(want));
|
||||
// Off-screen places are skipped rather than pointed at outside the view.
|
||||
assert!(stops(&a, "solid.hole", &json!({"position": [1e6, 0, 0]})).iter().all(|p| rect.contains(*p)));
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@
|
||||
//! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_cursor, msaa,
|
||||
//! length_decimals, angle_decimals); returns them all
|
||||
//! - `ui.capture {action?: capture|revert|cancel}`: the Capture Position question
|
||||
//! - `ui.agent_cursor {show?, speed?: instant|normal|slow, follow_camera?}`: the agent cursor
|
||||
//! (#35); while it is shown, `engine.execute` first glides to the command's button and picks
|
||||
//! and replies once the command has run (`"animate": false` skips that one call)
|
||||
//! - `ui.resize {width, height}`, `app.quit`
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
@@ -95,6 +98,22 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
}
|
||||
wrap(r)
|
||||
}
|
||||
"ui.agent_cursor" => {
|
||||
let s = &mut app.ui.agent_cursor;
|
||||
if let Some(v) = p.get("show").and_then(Value::as_bool) {
|
||||
s.show = v;
|
||||
}
|
||||
if let Some(v) = p.get("follow_camera").and_then(Value::as_bool) {
|
||||
s.follow_camera = v;
|
||||
}
|
||||
if let Some(v) = p.get("speed") {
|
||||
match serde_json::from_value(v.clone()) {
|
||||
Ok(sp) => s.speed = sp,
|
||||
Err(_) => return err("speed must be instant, normal or slow"),
|
||||
}
|
||||
}
|
||||
ok(json!(app.ui.agent_cursor))
|
||||
}
|
||||
"engine.script" => wrap(app.session.run_script(p).map(|v| json!(v)).map_err(|e| e.to_string())),
|
||||
"engine.commands" => wrap(app.session.execute("engine.commands", &json!({})).map_err(|e| e.to_string())),
|
||||
"document.inspect" => wrap(app.session.execute("document.inspect", p).map_err(|e| e.to_string())),
|
||||
|
||||
+457
-62
@@ -9,7 +9,7 @@ use serde_json::{Value, json};
|
||||
use solvecraft_engine::Sel;
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solvecraft_engine::doc::{AxisRef, Direction, FeatureKind, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel};
|
||||
use solvecraft_engine::doc::{AxisRef, Direction, FeatureKind, FilletStyle, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel};
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
|
||||
use crate::SolveApp;
|
||||
@@ -17,8 +17,8 @@ use crate::selection::{self, AXES, Accept, BODIES, CURVES, EDGES, FACES, PLANAR_
|
||||
use crate::theme::Tokens;
|
||||
use crate::viewport::Hit;
|
||||
|
||||
const OPS: [&str; 4] = ["new", "join", "cut", "intersect"];
|
||||
const OP_LABELS: [&str; 4] = ["New Body", "Join", "Cut", "Intersect"];
|
||||
pub(crate) const OPS: [&str; 4] = ["new", "join", "cut", "intersect"];
|
||||
pub(crate) const OP_LABELS: [&str; 4] = ["New Body", "Join", "Cut", "Intersect"];
|
||||
const DIRS: [&str; 4] = ["positive", "negative", "symmetric", "positive"];
|
||||
/// The "Two sides" direction (a second distance the other way).
|
||||
pub const TWO_SIDES: usize = 3;
|
||||
@@ -124,6 +124,13 @@ pub enum Kind {
|
||||
},
|
||||
/// Move planar faces onto a target plane or face.
|
||||
ReplaceFace,
|
||||
/// Boundary Fill: the cells the tool bodies make (a point inside, a description, filled
|
||||
/// or not), found again when the tools change.
|
||||
BoundaryFill {
|
||||
operation: usize,
|
||||
cells: Vec<(Vec3, String, bool)>,
|
||||
of: Vec<Sel>,
|
||||
},
|
||||
/// Move bodies so a face or point meets another.
|
||||
Align {
|
||||
flip: bool,
|
||||
@@ -215,6 +222,8 @@ pub enum Kind {
|
||||
Assembly(crate::dialogs_assembly::Asm),
|
||||
/// Sheet metal (`dialogs_sheet`).
|
||||
Sheet(crate::dialogs_sheet::Sm),
|
||||
/// Surface tools and Split Face (`dialogs_surface`).
|
||||
Surface(crate::dialogs_surface::Surf),
|
||||
/// Plastic features (`dialogs_plastic`).
|
||||
Plastic(crate::dialogs_plastic::Pl),
|
||||
/// Appearance (`dialogs_appearance`).
|
||||
@@ -308,7 +317,12 @@ impl Dialog {
|
||||
),
|
||||
"solid.fillet" => Dialog::new(fillet_kind("2 mm", false), vec![SelInput::new("Edges", EDGES | FACES, true)]),
|
||||
"solid.chamfer" => Dialog::new(fillet_kind("1 mm", true), vec![SelInput::new("Edges", EDGES | FACES, true)]),
|
||||
"solid.shell" => Dialog::new(Kind::Shell { thickness: "2 mm".into() }, vec![SelInput::new("Faces/Body", FACES, true)]),
|
||||
"solid.shell" => {
|
||||
let mut d = Dialog::new(Kind::Shell { thickness: "2 mm".into() }, vec![SelInput::new("Faces/Body", FACES, true)]);
|
||||
// Fusion opens faces joined smoothly to the picked ones too.
|
||||
d.extra.insert("tangent_chain".into(), json!(true));
|
||||
d
|
||||
}
|
||||
"solid.draft" => Dialog::new(
|
||||
Kind::Draft { angle: "5 deg".into() },
|
||||
vec![SelInput::new("Faces", FACES, true), SelInput::new("Neutral plane", PLANES | PLANAR_FACES, false)],
|
||||
@@ -325,12 +339,15 @@ impl Dialog {
|
||||
Kind::ReplaceFace,
|
||||
vec![SelInput::new("Faces", PLANAR_FACES, true), SelInput::new("Target", PLANES | PLANAR_FACES, false)],
|
||||
),
|
||||
"SurfaceSculpt" => {
|
||||
Dialog::new(Kind::BoundaryFill { operation: 0, cells: Vec::new(), of: Vec::new() }, vec![SelInput::new("Tools", BODIES, true)])
|
||||
}
|
||||
"solid.align" => Dialog::new(
|
||||
Kind::Align { flip: false },
|
||||
vec![
|
||||
SelInput::new("Bodies", BODIES, true),
|
||||
SelInput::new("From", FACES | selection::VERTICES, false),
|
||||
SelInput::new("To", FACES | selection::VERTICES, false),
|
||||
SelInput::new("From", FACES | EDGES | selection::VERTICES, false),
|
||||
SelInput::new("To", FACES | EDGES | selection::VERTICES, false),
|
||||
],
|
||||
),
|
||||
"solid.remove" => Dialog::new(Kind::Remove, vec![SelInput::new("Bodies", BODIES, true)]),
|
||||
@@ -345,10 +362,9 @@ impl Dialog {
|
||||
Kind::AnglePlane { angle: "45 deg".into() },
|
||||
vec![SelInput::new("Plane", PLANES, false), SelInput::new("Axis", AXES, false)],
|
||||
),
|
||||
"solid.split_body" => Dialog::new(
|
||||
Kind::Split,
|
||||
vec![SelInput::new("Body to split", BODIES, false), SelInput::new("Splitting plane", PLANES | PLANAR_FACES, false)],
|
||||
),
|
||||
"solid.split_body" => {
|
||||
Dialog::new(Kind::Split, vec![SelInput::new("Body to split", BODIES, false), SelInput::new("Splitting tool", PLANES | FACES, false)])
|
||||
}
|
||||
"solid.scale" => Dialog::new(Kind::Scale { factor: "2".into() }, vec![SelInput::new("Bodies", BODIES, true)]),
|
||||
"solid.offset_face" => Dialog::new(Kind::OffsetFaces { distance: "2 mm".into() }, vec![SelInput::new("Faces", PLANAR_FACES, true)]),
|
||||
"solid.thread" => {
|
||||
@@ -412,6 +428,7 @@ impl Dialog {
|
||||
_ => {
|
||||
let (kind, inputs) = crate::dialogs_assembly::start(app, id)
|
||||
.or_else(|| crate::dialogs_sheet::start(app, id))
|
||||
.or_else(|| crate::dialogs_surface::start(app, id))
|
||||
.or_else(|| crate::dialogs_plastic::start(app, id))
|
||||
.or_else(|| crate::dialogs_appearance::start(app, id))
|
||||
.or_else(|| crate::dialogs_motion::start(app, id))
|
||||
@@ -515,6 +532,7 @@ impl Dialog {
|
||||
match &self.kind {
|
||||
Kind::Assembly(k) => return k.previews(),
|
||||
Kind::Sheet(k) => return k.previews(),
|
||||
Kind::Surface(_) => return true,
|
||||
Kind::Plastic(k) => return k.previews(),
|
||||
Kind::Part(_) => return true,
|
||||
_ => {}
|
||||
@@ -546,6 +564,7 @@ impl Dialog {
|
||||
| Kind::Hole { .. }
|
||||
| Kind::Primitive { .. }
|
||||
| Kind::Combine { .. }
|
||||
| Kind::BoundaryFill { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -572,6 +591,7 @@ impl Dialog {
|
||||
Kind::Scale { factor } => ("Scale", ValueKind::Unitless, factor),
|
||||
Kind::OffsetFaces { distance } => ("Distance", ValueKind::Length, distance),
|
||||
Kind::Sheet(k) => return k.primary(&self.inputs),
|
||||
Kind::Surface(k) => return k.primary(),
|
||||
Kind::Plastic(k) => return k.primary(),
|
||||
_ => return None,
|
||||
})
|
||||
@@ -762,6 +782,7 @@ fn title(k: &Kind) -> &'static str {
|
||||
Kind::Emboss { .. } => "EMBOSS",
|
||||
Kind::ReplaceFace => "REPLACE FACE",
|
||||
Kind::Align { .. } => "ALIGN",
|
||||
Kind::BoundaryFill { .. } => "BOUNDARY FILL",
|
||||
Kind::Remove => "REMOVE",
|
||||
Kind::PathPattern { .. } => "PATTERN ON PATH",
|
||||
Kind::Pipe { .. } => "PIPE",
|
||||
@@ -792,6 +813,7 @@ fn title(k: &Kind) -> &'static str {
|
||||
Kind::ConfirmDelete { .. } => "DELETE FEATURE",
|
||||
Kind::Assembly(k) => k.title(),
|
||||
Kind::Sheet(k) => k.title(),
|
||||
Kind::Surface(k) => k.title(),
|
||||
Kind::Plastic(k) => k.title(),
|
||||
Kind::Appearance(_) => "APPEARANCE",
|
||||
Kind::Motion(k) => k.title(),
|
||||
@@ -820,13 +842,17 @@ fn hint(inp: &SelInput) -> &'static str {
|
||||
|
||||
/// Width of the label column; longer labels are cut with "…".
|
||||
const LABEL_W: f32 = 92.0;
|
||||
/// The label column grows to fit its labels up to this, then long labels wrap.
|
||||
const LABEL_MAX_W: f32 = 150.0;
|
||||
|
||||
/// A row label in the dialog's label column.
|
||||
/// A row label in the dialog's label column (the grid sizes the column to its widest label).
|
||||
pub(crate) fn row_label(ui: &mut egui::Ui, text: &str) {
|
||||
ROW.with(|r| *r.borrow_mut() = text.to_string());
|
||||
ui.allocate_ui_with_layout(vec2(LABEL_W, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_width(LABEL_W);
|
||||
ui.add(egui::Label::new(text).truncate()).on_hover_text(text);
|
||||
let full = egui::WidgetText::from(text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
|
||||
let w = full.clamp(LABEL_W, LABEL_MAX_W);
|
||||
ui.allocate_ui_with_layout(vec2(w, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_width(w);
|
||||
ui.add(egui::Label::new(text).wrap());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1038,6 +1064,67 @@ use crate::frame::{DIALOG_MAX_W, DIALOG_MIN_W, DIALOG_WIDE_MAX_W};
|
||||
/// Width of value fields and choice boxes in a dialog.
|
||||
const FIELD_W: f32 = 124.0;
|
||||
|
||||
/// Find the Boundary Fill cells again when the tool bodies changed (keeping the choices of
|
||||
/// cells that are still there).
|
||||
fn update_fill_cells(app: &SolveApp, d: &mut Dialog) {
|
||||
let tools: Vec<Sel> = sels(d, 0).to_vec();
|
||||
let Kind::BoundaryFill { cells, of, .. } = &mut d.kind else { return };
|
||||
if *of == tools {
|
||||
return;
|
||||
}
|
||||
let names: Vec<String> = tools.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
|
||||
// Editing: the tools as they were before this feature (it may have removed them).
|
||||
let st = match d.editing {
|
||||
Some((id, _)) => app.session.model.state_before(id),
|
||||
None => app.session.world_state(),
|
||||
};
|
||||
let found = fill_cells(&st, &names);
|
||||
let kept: Vec<String> = cells.iter().filter(|c| c.2).map(|c| c.1.clone()).collect();
|
||||
*cells = found.into_iter().map(|(p, label)| (p, label.clone(), kept.is_empty() || kept.contains(&label))).collect();
|
||||
*of = tools;
|
||||
}
|
||||
|
||||
/// The cells a set of tool bodies makes: each region inside some of them and outside the rest,
|
||||
/// as a point inside it and which tools hold it. Found by sampling a grid over the tools.
|
||||
pub(crate) fn fill_cells(st: &solvecraft_engine::doc::ModelState, tools: &[String]) -> Vec<(Vec3, String)> {
|
||||
let bodies: Vec<_> = tools.iter().filter_map(|n| st.body(n).map(|b| (n.clone(), b.mesh()))).take(16).collect();
|
||||
if bodies.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let indexes: Vec<_> = bodies.iter().map(|(_, m)| m.inside_index()).collect();
|
||||
let bb = bodies.iter().fold(solvecraft_engine::geom::Aabb3::EMPTY, |b, (_, m)| b.union(&m.bounds()));
|
||||
let n = 20;
|
||||
let at = |i: usize, lo: f64, hi: f64| lo + (hi - lo) * (i as f64 + 0.5) / n as f64;
|
||||
// Signature (which tools hold the point) → the points with it.
|
||||
let mut groups: Vec<(u32, Vec<Vec3>)> = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
for k in 0..n {
|
||||
let p = Vec3::new(at(i, bb.min.x, bb.max.x), at(j, bb.min.y, bb.max.y), at(k, bb.min.z, bb.max.z));
|
||||
let sig = indexes.iter().enumerate().fold(0u32, |acc, (t, ix)| if ix.contains(p) { acc | (1 << t) } else { acc });
|
||||
if sig == 0 {
|
||||
continue;
|
||||
}
|
||||
match groups.iter_mut().find(|(g, _)| *g == sig) {
|
||||
Some((_, v)) => v.push(p),
|
||||
None => groups.push((sig, vec![p])),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
groups.sort_by_key(|(sig, _)| *sig);
|
||||
groups
|
||||
.into_iter()
|
||||
.filter_map(|(sig, pts)| {
|
||||
let c = pts.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / pts.len() as f64);
|
||||
// The sample nearest the middle (the middle itself may be outside a curved cell).
|
||||
let rep = pts.iter().min_by(|a, b| a.dist(c).total_cmp(&b.dist(c))).copied()?;
|
||||
let inside: Vec<&str> = bodies.iter().enumerate().filter(|(t, _)| sig & (1 << t) != 0).map(|(_, (name, _))| name.as_str()).collect();
|
||||
Some((rep, format!("Inside {}", inside.join(", "))))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Measure again when the picked items changed.
|
||||
fn update_measure(app: &SolveApp, d: &mut Dialog) {
|
||||
let items: Vec<Sel> = sels(d, 0).to_vec();
|
||||
@@ -1147,6 +1234,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
auto_operation(app, &mut d);
|
||||
update_measure(app, &mut d);
|
||||
update_fill_cells(app, &mut d);
|
||||
let t = Tokens::get();
|
||||
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
|
||||
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::VIEW_CUBE_CLEARANCE);
|
||||
@@ -1232,11 +1320,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
*direction = order.get(pos).copied().unwrap_or(0);
|
||||
ui.end_row();
|
||||
row_label(ui, "Extent Type");
|
||||
let mut ext = usize::from(*all);
|
||||
combo(ui, "ex_ext", &["Distance", "All"], &mut ext);
|
||||
*all = ext == 1;
|
||||
let cur = if to_object_on(&d.inputs) { 1 } else { usize::from(*all) * 2 };
|
||||
let mut ext = cur;
|
||||
combo(ui, "ex_ext", &["Distance", "To Object", "All"], &mut ext);
|
||||
*all = ext == 2;
|
||||
set_to_object(&mut d.inputs, &mut d.active, ext == 1);
|
||||
ui.end_row();
|
||||
if !*all {
|
||||
if ext == 0 {
|
||||
row_label(ui, "Distance");
|
||||
enter |= field(ui, distance);
|
||||
ui.end_row();
|
||||
@@ -1261,9 +1351,47 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Revolve { angle, operation } => {
|
||||
row_label(ui, "Angle");
|
||||
enter |= field(ui, angle);
|
||||
row_label(ui, "Type");
|
||||
let cur = usize::from(to_object_on(&d.inputs));
|
||||
let mut ty = cur;
|
||||
combo(ui, "rv_type", &["Angle", "To Object"], &mut ty);
|
||||
set_to_object(&mut d.inputs, &mut d.active, ty == 1);
|
||||
ui.end_row();
|
||||
if ty == 0 {
|
||||
// One Side, Two Sides (a second angle), Symmetric (the angle split).
|
||||
row_label(ui, "Direction");
|
||||
let cur = if d.extra.contains_key("angle2") {
|
||||
1
|
||||
} else if d.extra.get("direction").is_some() {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let mut dir = cur;
|
||||
combo(ui, "rv_dir", &["One Side", "Two Sides", "Symmetric"], &mut dir);
|
||||
if dir != cur {
|
||||
d.extra.remove("angle2");
|
||||
d.extra.remove("direction");
|
||||
match dir {
|
||||
1 => {
|
||||
d.extra.insert("angle2".into(), json!("90 deg"));
|
||||
}
|
||||
2 => {
|
||||
d.extra.insert("direction".into(), json!("symmetric"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
row_label(ui, "Angle");
|
||||
enter |= field(ui, angle);
|
||||
ui.end_row();
|
||||
if dir == 1 {
|
||||
row_label(ui, "Angle 2");
|
||||
enter |= extra_field(ui, &mut d.extra, "angle2", "90 deg");
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
row_label(ui, "Operation");
|
||||
combo(ui, "rv_op", &OP_LABELS, operation);
|
||||
ui.end_row();
|
||||
@@ -1274,10 +1402,26 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
combo(ui, "ch_type", &CHAMFER_TYPES, ctype);
|
||||
ui.end_row();
|
||||
}
|
||||
let mut rtype = 0;
|
||||
if !*chamfer {
|
||||
row_label(ui, "Radius Type");
|
||||
rtype = extra_choice(
|
||||
ui,
|
||||
&mut d.extra,
|
||||
"fi_type",
|
||||
"type",
|
||||
&[("Constant", "constant"), ("Chord Length", "chord"), ("Variable", "variable")],
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(
|
||||
ui,
|
||||
if !*chamfer {
|
||||
"Radius"
|
||||
match rtype {
|
||||
1 => "Chord Length",
|
||||
2 => "Start Radius",
|
||||
_ => "Radius",
|
||||
}
|
||||
} else if *ctype == 0 {
|
||||
"Distance"
|
||||
} else {
|
||||
@@ -1301,14 +1445,37 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.checkbox(flip, "");
|
||||
ui.end_row();
|
||||
}
|
||||
if !*chamfer && rtype == 2 {
|
||||
row_label(ui, "End Radius");
|
||||
enter |= extra_field(ui, &mut d.extra, "radius2", "1 mm");
|
||||
ui.end_row();
|
||||
// Which end of the first edge takes the start radius.
|
||||
row_label(ui, "Swap Ends");
|
||||
extra_check(ui, &mut d.extra, "swap_ends");
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, "Tangent Chain");
|
||||
ui.checkbox(chain, "");
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Shell { thickness } => {
|
||||
row_label(ui, "Inside Thickness");
|
||||
row_label(ui, "Tangent Chain");
|
||||
extra_check(ui, &mut d.extra, "tangent_chain");
|
||||
ui.end_row();
|
||||
let dir = d.extra.get("direction").and_then(Value::as_str).unwrap_or("inside").to_string();
|
||||
row_label(
|
||||
ui,
|
||||
match dir.as_str() {
|
||||
"outside" => "Outside Thickness",
|
||||
"both" => "Thickness",
|
||||
_ => "Inside Thickness",
|
||||
},
|
||||
);
|
||||
enter |= field(ui, thickness);
|
||||
ui.end_row();
|
||||
row_label(ui, "Direction");
|
||||
extra_choice(ui, &mut d.extra, "sh_dir", "direction", &[("Inside", "inside"), ("Outside", "outside"), ("Both", "both")]);
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Draft { angle } => {
|
||||
row_label(ui, "Angle");
|
||||
@@ -1359,10 +1526,35 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Align { flip } => {
|
||||
// Where on the picked face or edge to snap: as picked (a face turns to meet
|
||||
// the other, an edge at its middle), its centre, or a round one's centre.
|
||||
for (label, key) in [("From Snap", "from_snap"), ("To Snap", "to_snap")] {
|
||||
row_label(ui, label);
|
||||
extra_choice(ui, &mut d.extra, key, key, &[("As Picked", "picked"), ("Centre", "center"), ("Circle Centre", "circle")]);
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, "Flip");
|
||||
ui.checkbox(flip, "");
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::BoundaryFill { operation, cells, .. } => {
|
||||
row_label(ui, "Cells");
|
||||
if cells.is_empty() {
|
||||
ui.label(RichText::new("Pick the tool bodies").color(Tokens::get().text_dim));
|
||||
}
|
||||
ui.end_row();
|
||||
for (i, (_, label, on)) in cells.iter_mut().enumerate() {
|
||||
row_label(ui, &format!("Cell {}", i + 1));
|
||||
ui.checkbox(on, label.as_str());
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, "Operation");
|
||||
combo(ui, "bf_op", &OP_LABELS, operation);
|
||||
ui.end_row();
|
||||
row_label(ui, "Remove Tools");
|
||||
extra_check(ui, &mut d.extra, "remove_tools");
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::PathPattern { count, spacing } => {
|
||||
for (l, v) in [("Quantity", count), ("Spacing", spacing)] {
|
||||
row_label(ui, l);
|
||||
@@ -1396,7 +1588,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Thread { designation, length } => {
|
||||
// Fusion's order: Full Length, then the size; a length only when not full.
|
||||
// Fusion's order: Modeled, Full Length, then the size; a length only when not
|
||||
// full.
|
||||
row_label(ui, "Modeled");
|
||||
extra_check(ui, &mut d.extra, "modeled");
|
||||
ui.end_row();
|
||||
row_label(ui, "Full Length");
|
||||
let mut full = length.trim().is_empty();
|
||||
if ui.checkbox(&mut full, "").changed() {
|
||||
@@ -1454,10 +1650,19 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
k @ Kind::Move { .. } => enter |= crate::dialogs_move::rows(ui, k),
|
||||
Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } => {
|
||||
row_label(ui, "Extents");
|
||||
let mut ext = usize::from(opts.all);
|
||||
combo(ui, "hole_ext", &["Distance", "All"], &mut ext);
|
||||
opts.all = ext == 1;
|
||||
let cur = if to_object_on(&d.inputs) { 1 } else { usize::from(opts.all) * 2 };
|
||||
let mut ext = cur;
|
||||
combo(ui, "hole_ext", &["Distance", "To Object", "All"], &mut ext);
|
||||
opts.all = ext == 2;
|
||||
set_to_object(&mut d.inputs, &mut d.active, ext == 1);
|
||||
ui.end_row();
|
||||
if opts.multiple {
|
||||
// Every point of the picked points' sketches (standalone points and
|
||||
// circle centres), not just the picked ones.
|
||||
row_label(ui, "All Sketch Points");
|
||||
extra_check(ui, &mut d.extra, "all_points");
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, "Hole Type");
|
||||
combo(ui, "hole_kind", &HOLE_LABELS, kind);
|
||||
ui.end_row();
|
||||
@@ -1525,6 +1730,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
combo(ui, "cb_op", &OP_LABELS[1..], &mut op);
|
||||
*operation = op + 1;
|
||||
ui.end_row();
|
||||
row_label(ui, "New Component");
|
||||
extra_check(ui, &mut d.extra, "new_component");
|
||||
ui.end_row();
|
||||
row_label(ui, "Keep Tools");
|
||||
ui.checkbox(keep_tools, "");
|
||||
ui.end_row();
|
||||
@@ -1551,6 +1759,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
Kind::Assembly(k) => enter |= crate::dialogs_assembly::rows(app, ui, k, &mut d.inputs, &mut d.active),
|
||||
Kind::Sheet(k) => enter |= crate::dialogs_sheet::rows(app, ui, k, &mut d.inputs),
|
||||
Kind::Surface(k) => enter |= crate::dialogs_surface::rows(ui, k),
|
||||
Kind::Plastic(k) => enter |= crate::dialogs_plastic::rows(app, ui, k, &d.inputs),
|
||||
Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs),
|
||||
Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs),
|
||||
@@ -1664,6 +1873,78 @@ pub fn cancel(app: &mut SolveApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pick input an extent of To Object adds (a face, vertex or point to go to).
|
||||
const TO_OBJECT: &str = "To Object";
|
||||
|
||||
fn to_object_on(inputs: &[SelInput]) -> bool {
|
||||
inputs.iter().any(|i| i.label == TO_OBJECT)
|
||||
}
|
||||
|
||||
/// Add or remove the To Object input; the active input moves to it when it is added.
|
||||
fn set_to_object(inputs: &mut Vec<SelInput>, active: &mut usize, on: bool) {
|
||||
if on && !to_object_on(inputs) {
|
||||
inputs.push(SelInput::new(TO_OBJECT, FACES | selection::VERTICES, false));
|
||||
*active = inputs.len() - 1;
|
||||
} else if !on && to_object_on(inputs) {
|
||||
inputs.retain(|i| i.label != TO_OBJECT);
|
||||
*active = (*active).min(inputs.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// The picked To Object place.
|
||||
fn to_object_point(inputs: &[SelInput]) -> Option<Value> {
|
||||
inputs.iter().find(|i| i.label == TO_OBJECT)?.items.first().and_then(|x| match x {
|
||||
Sel::Face { point, .. } | Sel::Vertex { point, .. } | Sel::Edge { point, .. } => Some(pt(*point)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A To Object input filled from a stored place (editing).
|
||||
fn with_to_object(d: &mut Dialog, s: &Session, to: Option<Vec3>) {
|
||||
if let Some(p) = to {
|
||||
set_to_object(&mut d.inputs, &mut d.active, true);
|
||||
if let Some(inp) = d.inputs.iter_mut().find(|i| i.label == TO_OBJECT) {
|
||||
inp.items = vec![face_sel(s, p).unwrap_or(Sel::Vertex { body: String::new(), point: p })];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A choice row stored in the dialog's extra parameters as `key: value` (the first choice, the
|
||||
/// command's default, is stored as no key at all).
|
||||
fn extra_choice(ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, id: &str, key: &str, choices: &[(&str, &str)]) -> usize {
|
||||
let cur = extra.get(key).and_then(Value::as_str).and_then(|v| choices.iter().position(|(_, x)| *x == v)).unwrap_or(0);
|
||||
let mut i = cur;
|
||||
let labels: Vec<&str> = choices.iter().map(|(l, _)| *l).collect();
|
||||
combo(ui, id, &labels, &mut i);
|
||||
if i != cur {
|
||||
match choices.get(i) {
|
||||
Some((_, v)) if i > 0 => {
|
||||
extra.insert(key.into(), json!(v));
|
||||
}
|
||||
_ => {
|
||||
extra.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// A checkbox stored in the dialog's extra parameters.
|
||||
fn extra_check(ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, key: &str) {
|
||||
let mut on = extra.get(key).and_then(Value::as_bool).unwrap_or(false);
|
||||
if ui.checkbox(&mut on, "").changed() {
|
||||
extra.insert(key.into(), json!(on));
|
||||
}
|
||||
}
|
||||
|
||||
/// A value field stored in the dialog's extra parameters (`default` until edited).
|
||||
fn extra_field(ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, key: &str, default: &str) -> bool {
|
||||
let mut v = extra.get(key).and_then(Value::as_str).unwrap_or(default).to_string();
|
||||
let enter = field(ui, &mut v);
|
||||
extra.insert(key.into(), json!(v));
|
||||
enter
|
||||
}
|
||||
|
||||
const CHAMFER_TYPES: [&str; 3] = ["Equal Distance", "Two Distances", "Distance and Angle"];
|
||||
|
||||
/// A new Fillet or Chamfer dialog's values.
|
||||
@@ -1824,6 +2105,9 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
if *all {
|
||||
common["through_all"] = json!(true);
|
||||
}
|
||||
if to_object_on(&d.inputs) {
|
||||
common["to"] = to_object_point(&d.inputs).ok_or("select the object to extrude to")?;
|
||||
}
|
||||
let with = |extra: Value| -> Value {
|
||||
let mut p = common.clone();
|
||||
if let (Value::Object(m), Value::Object(e)) = (&mut p, extra) {
|
||||
@@ -1854,10 +2138,14 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
_ => return Err("select an axis".into()),
|
||||
};
|
||||
let (sketch, idx) = profiles();
|
||||
(
|
||||
"solid.revolve",
|
||||
json!({"sketch": sketch, "profiles": idx, "axis": axis, "angle": angle, "operation": OPS.get(*operation).copied().unwrap_or("new")}),
|
||||
)
|
||||
let mut p =
|
||||
json!({"sketch": sketch, "profiles": idx, "axis": axis, "angle": angle, "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||
if to_object_on(&d.inputs) {
|
||||
p["to"] = to_object_point(&d.inputs).ok_or("select the object to turn to")?;
|
||||
// A To Object turn has no second side.
|
||||
return Ok(vec![("solid.revolve".into(), p)]);
|
||||
}
|
||||
("solid.revolve", p)
|
||||
}
|
||||
Kind::Fillet { radius, chamfer, ctype, distance2, angle, flip, .. } => {
|
||||
need(0, "edges")?;
|
||||
@@ -1901,7 +2189,21 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
}
|
||||
("solid.chamfer", p)
|
||||
} else {
|
||||
("solid.fillet", json!({"edges": edges, "radius": radius}))
|
||||
let mut p = json!({"edges": edges, "radius": radius});
|
||||
if d.extra.get("type").and_then(Value::as_str) == Some("variable") {
|
||||
// The start radius goes at one end of the first edge (Swap Ends: the other).
|
||||
let ends = sels(d, 0).iter().find_map(|x| match x {
|
||||
Sel::Edge { body, index, .. } => {
|
||||
st.body(body).and_then(|b| b.mesh().edges.get(*index).and_then(|e| Some((*e.first()?, *e.last()?))))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let swap = d.extra.get("swap_ends").and_then(Value::as_bool).unwrap_or(false);
|
||||
if let Some((a, b)) = ends {
|
||||
p["start"] = pt(if swap { b } else { a });
|
||||
}
|
||||
}
|
||||
("solid.fillet", p)
|
||||
}
|
||||
}
|
||||
Kind::Shell { thickness } => {
|
||||
@@ -2035,10 +2337,16 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
need(2, "where to move it to")?;
|
||||
let mut p = json!({"bodies": body_names(0), "flip": flip});
|
||||
for (i, key) in [(1, "from"), (2, "to")] {
|
||||
match sels(d, i).first() {
|
||||
Some(Sel::Face { point, .. }) => p[format!("{key}_face")] = pt(*point),
|
||||
Some(Sel::Vertex { point, .. }) => p[key] = pt(*point),
|
||||
_ => return Err("pick faces or vertices".into()),
|
||||
let snap = d.extra.get(&format!("{key}_snap")).and_then(Value::as_str).unwrap_or("picked");
|
||||
match (sels(d, i).first(), snap) {
|
||||
(Some(Sel::Face { point, .. }), "center") => p[key] = json!({"snap": "face_center", "at": pt(*point)}),
|
||||
(Some(Sel::Face { point, .. } | Sel::Edge { point, .. }), "circle") => {
|
||||
p[key] = json!({"snap": "circle_center", "at": pt(*point)})
|
||||
}
|
||||
(Some(Sel::Face { point, .. }), _) => p[format!("{key}_face")] = pt(*point),
|
||||
(Some(Sel::Edge { point, .. }), _) => p[key] = json!({"snap": "edge_mid", "at": pt(*point)}),
|
||||
(Some(Sel::Vertex { point, .. }), _) => p[key] = pt(*point),
|
||||
_ => return Err("pick faces, edges or vertices".into()),
|
||||
}
|
||||
}
|
||||
("solid.align", p)
|
||||
@@ -2081,15 +2389,16 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
need(0, "the body to split")?;
|
||||
need(1, "the splitting plane")?;
|
||||
let body = body_names(0).into_iter().next().unwrap_or_default();
|
||||
let plane = match sels(d, 1).first() {
|
||||
Some(Sel::Plane { name }) => json!(name),
|
||||
Some(Sel::Face { body, index, point }) => {
|
||||
let (_, n) = planar_face(s, body, *index).ok_or("the face must be planar")?;
|
||||
json!({"origin": pt(*point), "normal": pt(n)})
|
||||
}
|
||||
_ => return Err("pick a plane or a planar face".into()),
|
||||
// A plane, a planar face's plane, or any other face (its surface extended).
|
||||
let p = match sels(d, 1).first() {
|
||||
Some(Sel::Plane { name }) => json!({"body": body, "plane": name}),
|
||||
Some(Sel::Face { body: b, index, point }) => match planar_face(s, b, *index) {
|
||||
Some((_, n)) => json!({"body": body, "plane": {"origin": pt(*point), "normal": pt(n)}}),
|
||||
None => json!({"body": body, "tool": {"body": b, "point": pt(*point)}}),
|
||||
},
|
||||
_ => return Err("pick a plane or a face".into()),
|
||||
};
|
||||
("solid.split_body", json!({"body": body, "plane": plane}))
|
||||
("solid.split_body", p)
|
||||
}
|
||||
Kind::Scale { factor } => {
|
||||
need(0, "bodies")?;
|
||||
@@ -2157,7 +2466,10 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
}
|
||||
}
|
||||
}
|
||||
places.extend(by_sketch.into_iter().map(|(sk, ids)| json!({"sketch": sk, "points": ids})));
|
||||
let all_points = d.extra.get("all_points").and_then(Value::as_bool).unwrap_or(false);
|
||||
places.extend(
|
||||
by_sketch.into_iter().map(|(sk, ids)| if all_points { json!({ "sketch": sk }) } else { json!({"sketch": sk, "points": ids}) }),
|
||||
);
|
||||
} else {
|
||||
places.extend(face_points(0).into_iter().map(|p| json!({ "position": p })));
|
||||
}
|
||||
@@ -2166,7 +2478,9 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
let mut params = place;
|
||||
params["diameter"] = json!(diameter);
|
||||
params["type"] = json!(ty);
|
||||
if !opts.all {
|
||||
if to_object_on(&d.inputs) {
|
||||
params["to"] = to_object_point(&d.inputs).ok_or("select the object to drill to")?;
|
||||
} else if !opts.all {
|
||||
params["depth"] = json!(depth);
|
||||
}
|
||||
if ty == "drilled" {
|
||||
@@ -2198,6 +2512,14 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
p.insert("operation".into(), json!(OPS.get(*operation).copied().unwrap_or("new")));
|
||||
(cmd, Value::Object(p))
|
||||
}
|
||||
Kind::BoundaryFill { operation, cells, .. } => {
|
||||
need(0, "the tool bodies")?;
|
||||
let picked: Vec<Value> = cells.iter().filter(|c| c.2).map(|c| pt(c.0)).collect();
|
||||
if picked.is_empty() {
|
||||
return Err("tick the cells to fill".into());
|
||||
}
|
||||
("SurfaceSculpt", json!({"tools": body_names(0), "cells": picked, "operation": OPS.get(*operation).copied().unwrap_or("new")}))
|
||||
}
|
||||
Kind::Combine { operation, keep_tools } => {
|
||||
need(0, "the target body")?;
|
||||
need(1, "tool bodies")?;
|
||||
@@ -2214,6 +2536,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences => return Ok(Vec::new()),
|
||||
Kind::Assembly(k) => return crate::dialogs_assembly::commands(app, k, &d.inputs),
|
||||
Kind::Sheet(k) => return crate::dialogs_sheet::commands(app, k, &d.inputs, &d.extra),
|
||||
Kind::Surface(k) => return crate::dialogs_surface::commands(app, k, &d.inputs, &d.extra),
|
||||
Kind::Plastic(k) => return crate::dialogs_plastic::commands(app, k, &d.inputs, &d.extra),
|
||||
Kind::Appearance(k) => return Ok(crate::dialogs_appearance::commands(k, &d.inputs)),
|
||||
Kind::Motion(k) => return crate::dialogs_motion::commands(app, k, &d.inputs),
|
||||
@@ -2286,14 +2609,13 @@ fn curves_sketch(app: &SolveApp, ids: &[String], not: Option<u64>) -> Option<u64
|
||||
/// sketch line).
|
||||
pub fn axis_of(app: &SolveApp, sel: &Sel) -> Option<(Vec3, Vec3)> {
|
||||
match sel {
|
||||
Sel::Axis { name } => Some((
|
||||
Vec3::ZERO,
|
||||
match name.as_str() {
|
||||
"X" => Vec3::X,
|
||||
"Y" => Vec3::Y,
|
||||
_ => Vec3::Z,
|
||||
},
|
||||
)),
|
||||
Sel::Axis { name } => match name.as_str() {
|
||||
"X" => Some((Vec3::ZERO, Vec3::X)),
|
||||
"Y" => Some((Vec3::ZERO, Vec3::Y)),
|
||||
"Z" => Some((Vec3::ZERO, Vec3::Z)),
|
||||
// A construction axis.
|
||||
n => solvecraft_engine::view::construction_axes(&app.session).into_iter().find(|(_, an, _, _)| an == n).map(|(_, _, o, d)| (o, d)),
|
||||
},
|
||||
Sel::SketchCurve { id } => {
|
||||
let st = app.session.world_state();
|
||||
let sid = curves_sketch(app, std::slice::from_ref(id), None)?;
|
||||
@@ -2377,7 +2699,7 @@ pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
|
||||
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p.len()).map(|x| x.1)
|
||||
}
|
||||
|
||||
fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
|
||||
pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
|
||||
match pl {
|
||||
PlaneRef::Origin { name } | PlaneRef::Construction { name } => Some(Sel::Plane { name: name.clone() }),
|
||||
PlaneRef::Custom { plane } | PlaneRef::Face { plane, .. } => face_sel(s, plane.origin),
|
||||
@@ -2385,7 +2707,7 @@ fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
|
||||
}
|
||||
}
|
||||
|
||||
fn op_index(o: &Operation) -> usize {
|
||||
pub(crate) fn op_index(o: &Operation) -> usize {
|
||||
match o {
|
||||
Operation::NewBody => 0,
|
||||
Operation::Join => 1,
|
||||
@@ -2431,6 +2753,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
start: extent.start_offset.clone().unwrap_or_default(),
|
||||
all: extent.through_all,
|
||||
};
|
||||
with_to_object(&mut d, s, extent.to);
|
||||
// The dialog has no "flip": a flipped extrude is a negative distance.
|
||||
if let Kind::Extrude { direction, distance, .. } = &mut d.kind
|
||||
&& *direction == 1
|
||||
@@ -2449,9 +2772,16 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, .. } => {
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, angle2, symmetric, to } => {
|
||||
let mut d = start("solid.revolve")?;
|
||||
d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation) };
|
||||
if let Some(a2) = angle2 {
|
||||
d.extra.insert("angle2".into(), json!(a2));
|
||||
}
|
||||
if *symmetric {
|
||||
d.extra.insert("direction".into(), json!("symmetric"));
|
||||
}
|
||||
with_to_object(&mut d, s, *to);
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
if let Some(inp) = d.inputs.get_mut(0) {
|
||||
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
|
||||
@@ -2487,14 +2817,40 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
*flip = *f;
|
||||
}
|
||||
}
|
||||
// A chord-length or variable fillet.
|
||||
if let FeatureKind::Fillet { style, .. } = &f.kind {
|
||||
match style {
|
||||
FilletStyle::Constant => {}
|
||||
FilletStyle::Chord => {
|
||||
d.extra.insert("type".into(), json!("chord"));
|
||||
}
|
||||
FilletStyle::Variable { radius2, start } => {
|
||||
d.extra.insert("type".into(), json!("variable"));
|
||||
d.extra.insert("radius2".into(), json!(radius2));
|
||||
// The start was the first edge's last point: ends swapped.
|
||||
let first_end = edges.first().and_then(|p| edge_sel(s, *p)).and_then(|x| match x {
|
||||
Sel::Edge { body, index, .. } => {
|
||||
s.world_state().body(&body).and_then(|b| b.mesh().edges.get(index).and_then(|e| e.last().copied()))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let swapped = first_end.is_some_and(|e| e.dist(*start) < 1e-6);
|
||||
d.extra.insert("swap_ends".into(), json!(swapped));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(inp) = d.inputs.first_mut() {
|
||||
inp.items = edges.iter().filter_map(|p| edge_sel(s, *p)).collect();
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Shell { faces, thickness, .. } => {
|
||||
FeatureKind::Shell { faces, thickness, direction, tangent_chain, .. } => {
|
||||
let mut d = start("solid.shell")?;
|
||||
d.kind = Kind::Shell { thickness: thickness.clone() };
|
||||
d.extra.insert("tangent_chain".into(), json!(tangent_chain));
|
||||
if !direction.is_empty() && direction != "inside" {
|
||||
d.extra.insert("direction".into(), json!(direction));
|
||||
}
|
||||
if let Some(inp) = d.inputs.first_mut() {
|
||||
inp.items = faces.iter().filter_map(|p| face_sel(s, *p)).collect();
|
||||
}
|
||||
@@ -2512,7 +2868,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
d.extra.insert("pull".into(), pt3(*pull));
|
||||
d
|
||||
}
|
||||
FeatureKind::Hole { position, direction, diameter, depth, hole, points, thread, .. } => {
|
||||
FeatureKind::Hole { position, direction, diameter, depth, hole, points, thread, to } => {
|
||||
let mut d = start("solid.hole")?;
|
||||
let mut k = hole_defaults();
|
||||
if let Kind::Hole { diameter: dia, depth: dep, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } = &mut k {
|
||||
@@ -2559,6 +2915,16 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
d.extra.insert("direction".into(), pt3(*direction));
|
||||
}
|
||||
}
|
||||
if to.is_some() {
|
||||
if let Kind::Hole { opts, .. } = &mut d.kind {
|
||||
opts.all = false;
|
||||
}
|
||||
with_to_object(&mut d, s, *to);
|
||||
}
|
||||
// A hole at every point of a sketch.
|
||||
if points.as_ref().is_some_and(|sp| sp.ids.is_empty()) {
|
||||
d.extra.insert("all_points".into(), json!(true));
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Box { corner, length, width, height, operation } => {
|
||||
@@ -2598,6 +2964,27 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
d.extra.insert("center".into(), pt3(*center));
|
||||
d
|
||||
}
|
||||
FeatureKind::BoundaryFill { tools, cells, operation, remove_tools } => {
|
||||
let mut d = start("SurfaceSculpt")?;
|
||||
// The cells again, ticked where a stored cell point lies in the same cell.
|
||||
let before = s.model.state_before(f.id);
|
||||
let found = fill_cells(&before, tools);
|
||||
let filled: Vec<String> =
|
||||
cells.iter().filter_map(|c| found.iter().min_by(|a, b| a.0.dist(*c).total_cmp(&b.0.dist(*c))).map(|x| x.1.clone())).collect();
|
||||
let items: Vec<Sel> = tools.iter().map(|n| Sel::Body { name: n.clone() }).collect();
|
||||
d.kind = Kind::BoundaryFill {
|
||||
operation: op_index(operation),
|
||||
cells: found.into_iter().map(|(p, l)| (p, l.clone(), filled.contains(&l))).collect(),
|
||||
of: items.clone(),
|
||||
};
|
||||
if let Some(inp) = d.inputs.first_mut() {
|
||||
inp.items = items;
|
||||
}
|
||||
if *remove_tools {
|
||||
d.extra.insert("remove_tools".into(), json!(true));
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Combine { target, tools, operation, keep_tools } => {
|
||||
let mut d = start("solid.combine")?;
|
||||
d.kind = Kind::Combine { operation: op_index(operation).max(1), keep_tools: *keep_tools };
|
||||
@@ -2745,13 +3132,16 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Split { body, plane, .. } => {
|
||||
FeatureKind::Split { body, plane, tool } => {
|
||||
let mut d = start("solid.split_body")?;
|
||||
if let Some(inp) = d.inputs.first_mut() {
|
||||
inp.items = vec![Sel::Body { name: body.clone() }];
|
||||
}
|
||||
if let Some(inp) = d.inputs.get_mut(1) {
|
||||
inp.items = plane_sel(s, plane).into_iter().collect();
|
||||
inp.items = match tool {
|
||||
Some(t) => face_sel(s, t.point).into_iter().collect(),
|
||||
None => plane_sel(s, plane).into_iter().collect(),
|
||||
};
|
||||
}
|
||||
d
|
||||
}
|
||||
@@ -2778,8 +3168,11 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Thread { face, designation, length, .. } => {
|
||||
FeatureKind::Thread { face, designation, length, modeled } => {
|
||||
let mut d = start("solid.thread")?;
|
||||
if *modeled {
|
||||
d.extra.insert("modeled".into(), json!(true));
|
||||
}
|
||||
d.kind = Kind::Thread { designation: designation.clone().unwrap_or_default(), length: length.clone().unwrap_or_default() };
|
||||
if let Some(inp) = d.inputs.first_mut() {
|
||||
inp.items = face_sel(s, *face).into_iter().collect();
|
||||
@@ -2852,7 +3245,9 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
d
|
||||
}
|
||||
other => {
|
||||
let (kind, inputs, extra) = crate::dialogs_sheet::for_feature(app, other).or_else(|| crate::dialogs_plastic::for_feature(app, other))?;
|
||||
let (kind, inputs, extra) = crate::dialogs_sheet::for_feature(app, other)
|
||||
.or_else(|| crate::dialogs_plastic::for_feature(app, other))
|
||||
.or_else(|| crate::dialogs_surface::for_feature(app, other))?;
|
||||
let mut d = Dialog::new(kind, inputs);
|
||||
d.extra = extra;
|
||||
d
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Dialogs for the surface tools and face splitting (#19): Patch, Stitch, Thicken, Extend,
|
||||
//! Trim and Split Face. Each has a live preview and Edit Feature.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solvecraft_engine::Sel;
|
||||
use solvecraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solvecraft_engine::doc::{FaceAt, FeatureKind, PlaneRef};
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{Kind, OP_LABELS, OPS, combo, field, op_index, row_label};
|
||||
use crate::selection::{BODIES, EDGES, FACES, PLANES, PROFILES, SelInput};
|
||||
|
||||
/// A surface tool dialog's values.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Surf {
|
||||
Patch,
|
||||
Stitch { tolerance: String },
|
||||
Thicken { thickness: String, symmetric: bool, operation: usize },
|
||||
Extend { distance: String },
|
||||
Trim,
|
||||
SplitFace,
|
||||
}
|
||||
|
||||
impl Surf {
|
||||
pub fn title(&self) -> &'static str {
|
||||
match self {
|
||||
Surf::Patch => "PATCH",
|
||||
Surf::Stitch { .. } => "STITCH",
|
||||
Surf::Thicken { .. } => "THICKEN",
|
||||
Surf::Extend { .. } => "EXTEND",
|
||||
Surf::Trim => "TRIM",
|
||||
Surf::SplitFace => "SPLIT FACE",
|
||||
}
|
||||
}
|
||||
|
||||
/// The value on the canvas.
|
||||
pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> {
|
||||
Some(match self {
|
||||
Surf::Thicken { thickness, .. } => ("Thickness", ValueKind::Length, thickness),
|
||||
Surf::Extend { distance } => ("Distance", ValueKind::Length, distance),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn inputs(k: &Surf) -> Vec<SelInput> {
|
||||
match k {
|
||||
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
|
||||
Surf::Stitch { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
|
||||
Surf::Thicken { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
|
||||
Surf::Extend { .. } => vec![SelInput::new("Edges", EDGES, true)],
|
||||
Surf::Trim => vec![
|
||||
SelInput::new("Surface", BODIES, false),
|
||||
SelInput::new("Trimming tool", PLANES | FACES, false),
|
||||
SelInput::new("Side to keep", FACES, false),
|
||||
],
|
||||
Surf::SplitFace => vec![SelInput::new("Faces to split", FACES, true), SelInput::new("Splitting tool", PLANES | FACES, false)],
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog for a surface command.
|
||||
pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
let k = match id {
|
||||
"surface.patch" => Surf::Patch,
|
||||
"surface.stitch" => Surf::Stitch { tolerance: String::new() },
|
||||
"surface.thicken" => Surf::Thicken { thickness: "2 mm".into(), symmetric: false, operation: 0 },
|
||||
"surface.extend" => Surf::Extend { distance: "10 mm".into() },
|
||||
"surface.trim" => Surf::Trim,
|
||||
"solid.split_face" => Surf::SplitFace,
|
||||
_ => return None,
|
||||
};
|
||||
let i = inputs(&k);
|
||||
Some((Kind::Surface(k), i))
|
||||
}
|
||||
|
||||
/// The rows after the inputs; true when Enter was pressed in a field.
|
||||
pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
|
||||
let mut enter = false;
|
||||
match k {
|
||||
Surf::Stitch { tolerance } => {
|
||||
row_label(ui, "Tolerance");
|
||||
let r = ui.add(egui::TextEdit::singleline(tolerance).hint_text("tight"));
|
||||
crate::params_dialog::complete(ui, &r, tolerance);
|
||||
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Thicken { thickness, symmetric, operation } => {
|
||||
row_label(ui, "Thickness");
|
||||
enter |= field(ui, thickness);
|
||||
ui.end_row();
|
||||
row_label(ui, "Direction");
|
||||
let mut dir = usize::from(*symmetric);
|
||||
combo(ui, "th_dir", &["One Side", "Symmetric"], &mut dir);
|
||||
*symmetric = dir == 1;
|
||||
ui.end_row();
|
||||
row_label(ui, "Operation");
|
||||
combo(ui, "th_op", &OP_LABELS, operation);
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Extend { distance } => {
|
||||
row_label(ui, "Distance");
|
||||
enter |= field(ui, distance);
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Patch | Surf::Trim | Surf::SplitFace => {}
|
||||
}
|
||||
enter
|
||||
}
|
||||
|
||||
fn items(inputs: &[SelInput], i: usize) -> &[Sel] {
|
||||
inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn pt(v: Vec3) -> Value {
|
||||
json!([v.x, v.y, v.z])
|
||||
}
|
||||
|
||||
fn bodies(inputs: &[SelInput], i: usize) -> Vec<String> {
|
||||
items(inputs, i).iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// A splitting or trimming tool: a plane by name, a planar face as its plane, or any other
|
||||
/// face as {body, point} (its surface extended).
|
||||
fn tool(app: &SolveApp, sel: Option<&Sel>) -> Result<(&'static str, Value), String> {
|
||||
match sel {
|
||||
Some(Sel::Plane { name }) => Ok(("plane", json!(name))),
|
||||
Some(Sel::Face { body, index, point }) => match crate::dialogs::planar_face(&app.session, body, *index) {
|
||||
Some((_, n)) => Ok(("plane", json!({"origin": pt(*point), "normal": pt(n)}))),
|
||||
None => Ok(("tool", json!({"body": body, "point": pt(*point)}))),
|
||||
},
|
||||
_ => Err("pick a plane or a face as the tool".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The command a surface dialog makes.
|
||||
pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
|
||||
let need = |i: usize, what: &str| if items(inputs, i).is_empty() { Err(format!("select {what} first")) } else { Ok(()) };
|
||||
let (cmd, mut p) = match k {
|
||||
Surf::Patch => {
|
||||
need(0, "a boundary (sketch profiles or a loop of edges)")?;
|
||||
let profiles: Vec<(u64, usize)> =
|
||||
items(inputs, 0).iter().filter_map(|x| if let Sel::Profile { sketch, index } = x { Some((*sketch, *index)) } else { None }).collect();
|
||||
if let Some((sk, _)) = profiles.first() {
|
||||
let idx: Vec<usize> = profiles.iter().filter(|(s, _)| s == sk).map(|(_, i)| *i).collect();
|
||||
("surface.patch", json!({"sketch": sk, "profiles": idx}))
|
||||
} else {
|
||||
let edges: Vec<Value> =
|
||||
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect();
|
||||
let body = items(inputs, 0).iter().find_map(|x| if let Sel::Edge { body, .. } = x { Some(body.clone()) } else { None });
|
||||
("surface.patch", json!({"edges": edges, "body": body}))
|
||||
}
|
||||
}
|
||||
Surf::Stitch { tolerance } => {
|
||||
need(0, "the surfaces to stitch")?;
|
||||
let mut p = json!({ "bodies": bodies(inputs, 0) });
|
||||
if !tolerance.trim().is_empty() {
|
||||
p["tolerance"] = json!(tolerance);
|
||||
}
|
||||
("surface.stitch", p)
|
||||
}
|
||||
Surf::Thicken { thickness, symmetric, operation } => {
|
||||
need(0, "the surfaces to thicken")?;
|
||||
(
|
||||
"surface.thicken",
|
||||
json!({"bodies": bodies(inputs, 0), "thickness": thickness, "symmetric": symmetric, "operation": OPS.get(*operation).copied().unwrap_or("new")}),
|
||||
)
|
||||
}
|
||||
Surf::Extend { distance } => {
|
||||
need(0, "the edges to extend")?;
|
||||
let body =
|
||||
items(inputs, 0).iter().find_map(|x| if let Sel::Edge { body, .. } = x { Some(body.clone()) } else { None }).unwrap_or_default();
|
||||
let edges: Vec<Value> =
|
||||
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect();
|
||||
("surface.extend", json!({"body": body, "edges": edges, "distance": distance}))
|
||||
}
|
||||
Surf::Trim => {
|
||||
need(0, "the surface to trim")?;
|
||||
need(1, "the trimming tool")?;
|
||||
need(2, "the side to keep (a point on it)")?;
|
||||
let body = bodies(inputs, 0).into_iter().next().unwrap_or_default();
|
||||
let (key, t) = tool(app, items(inputs, 1).first())?;
|
||||
let keep = items(inputs, 2)
|
||||
.first()
|
||||
.and_then(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None })
|
||||
.ok_or("pick the side to keep")?;
|
||||
let mut p = json!({"body": body, "keep": keep});
|
||||
p[key] = t;
|
||||
("surface.trim", p)
|
||||
}
|
||||
Surf::SplitFace => {
|
||||
need(0, "the faces to split")?;
|
||||
need(1, "the splitting tool")?;
|
||||
let faces: Vec<Value> =
|
||||
items(inputs, 0).iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect();
|
||||
let body = items(inputs, 0).iter().find_map(|x| if let Sel::Face { body, .. } = x { Some(body.clone()) } else { None });
|
||||
let (key, t) = tool(app, items(inputs, 1).first())?;
|
||||
let mut p = json!({"faces": faces, "body": body});
|
||||
p[key] = t;
|
||||
("solid.split_face", p)
|
||||
}
|
||||
};
|
||||
if let Value::Object(m) = &mut p {
|
||||
for (k, v) in extra {
|
||||
m.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
}
|
||||
Ok(vec![(cmd.to_string(), p)])
|
||||
}
|
||||
|
||||
/// A tool as a selection again: a plane, else the face at the stored point.
|
||||
fn tool_sel(app: &SolveApp, plane: &PlaneRef, tool: Option<&FaceAt>) -> Option<Sel> {
|
||||
match tool {
|
||||
Some(t) => crate::dialogs::face_sel(&app.session, t.point),
|
||||
None => crate::dialogs::plane_sel(&app.session, plane),
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog for editing a surface feature.
|
||||
pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec<SelInput>, Map<String, Value>)> {
|
||||
let s = &app.session;
|
||||
let edge = |p: &Vec3| crate::dialogs::edge_sel(s, *p);
|
||||
let (k, fill): (Surf, Vec<Vec<Sel>>) = match kind {
|
||||
FeatureKind::Patch { sketch, edges, .. } => {
|
||||
let mut sels: Vec<Sel> = edges.iter().filter_map(edge).collect();
|
||||
if let Some(sk) = sketch {
|
||||
let st = s.world_state();
|
||||
if let Some(ss) = st.sketch(*sk) {
|
||||
sels.extend((0..ss.profiles.len()).map(|index| Sel::Profile { sketch: *sk, index }));
|
||||
}
|
||||
}
|
||||
(Surf::Patch, vec![sels])
|
||||
}
|
||||
FeatureKind::Stitch { bodies, tolerance } => (
|
||||
Surf::Stitch { tolerance: if tolerance.trim() == "0" { String::new() } else { tolerance.clone() } },
|
||||
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
|
||||
),
|
||||
FeatureKind::Thicken { bodies, thickness, symmetric, operation, .. } => (
|
||||
Surf::Thicken { thickness: thickness.clone(), symmetric: *symmetric, operation: op_index(operation) },
|
||||
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
|
||||
),
|
||||
FeatureKind::SurfaceExtend { edges, distance, .. } => {
|
||||
(Surf::Extend { distance: distance.clone() }, vec![edges.iter().filter_map(edge).collect()])
|
||||
}
|
||||
FeatureKind::SurfaceTrim { body, plane, tool, keep } => (
|
||||
Surf::Trim,
|
||||
vec![
|
||||
vec![Sel::Body { name: body.clone() }],
|
||||
tool_sel(app, plane, tool.as_ref()).into_iter().collect(),
|
||||
crate::dialogs::face_sel(s, *keep).into_iter().collect(),
|
||||
],
|
||||
),
|
||||
FeatureKind::SplitFace { faces, plane, tool, .. } => (
|
||||
Surf::SplitFace,
|
||||
vec![faces.iter().filter_map(|p| crate::dialogs::face_sel(s, *p)).collect(), tool_sel(app, plane, tool.as_ref()).into_iter().collect()],
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
let mut ins = inputs(&k);
|
||||
for (inp, items) in ins.iter_mut().zip(fill) {
|
||||
inp.items = items;
|
||||
}
|
||||
Some((Kind::Surface(k), ins, Map::new()))
|
||||
}
|
||||
@@ -13,8 +13,7 @@ pub const BUILD_DATE: &str = env!("SOLVECRAFT_BUILD_DATE");
|
||||
/// The user and developer guide (docs/book, published by .github/workflows/pages.yml).
|
||||
pub const DOCS_URL: &str = "https://bherbruck.github.io/solvecraft/";
|
||||
const ATTRIBUTION: &str = include_str!("../../../ATTRIBUTION.md");
|
||||
const LICENSE_MIT: &str = include_str!("../../../LICENSE-MIT");
|
||||
const LICENSE_APACHE: &str = include_str!("../../../LICENSE-APACHE");
|
||||
use solvecraft_engine::licences::{LICENSE_APACHE, LICENSE_MIT, NOTICE, THIRD_PARTY};
|
||||
/// Fonts: what this build carries.
|
||||
pub const FONTS: &str = "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and \
|
||||
the MIT/Bitstream Vera licence; Noto Emoji and the emoji icon font, under the OFL and MIT). Sketch text uses Liberation Sans \
|
||||
@@ -112,17 +111,28 @@ fn about(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
});
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::CollapsingHeader::new("Attributions (ATTRIBUTION.md)").default_open(true).show(ui, |ui| {
|
||||
egui::CollapsingHeader::new("Attributions (ATTRIBUTION.md)").show(ui, |ui| {
|
||||
ui.label(RichText::new(ATTRIBUTION).monospace().size(11.0));
|
||||
});
|
||||
egui::CollapsingHeader::new("Fonts").show(ui, |ui| {
|
||||
ui.label(FONTS);
|
||||
});
|
||||
egui::CollapsingHeader::new("MIT licence").show(ui, |ui| {
|
||||
ui.label(RichText::new(LICENSE_MIT).monospace().size(11.0));
|
||||
});
|
||||
egui::CollapsingHeader::new("Apache licence 2.0").show(ui, |ui| {
|
||||
ui.label(RichText::new(LICENSE_APACHE).monospace().size(11.0));
|
||||
egui::CollapsingHeader::new("Licences").show(ui, |ui| {
|
||||
egui::CollapsingHeader::new("NOTICE").show(ui, |ui| {
|
||||
ui.label(RichText::new(NOTICE).monospace().size(11.0));
|
||||
});
|
||||
egui::CollapsingHeader::new("MIT licence").show(ui, |ui| {
|
||||
ui.label(RichText::new(LICENSE_MIT).monospace().size(11.0));
|
||||
});
|
||||
egui::CollapsingHeader::new("Apache licence 2.0").show(ui, |ui| {
|
||||
ui.label(RichText::new(LICENSE_APACHE).monospace().size(11.0));
|
||||
});
|
||||
egui::CollapsingHeader::new("Third-party licences").show(ui, |ui| {
|
||||
if ui.button("Copy all licences").clicked() {
|
||||
ctx.copy_text(solvecraft_engine::licences::all());
|
||||
}
|
||||
third_party(ui);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -131,6 +141,19 @@ fn about(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// THIRD-PARTY-LICENSES.txt (over 10 000 lines): only the lines in view are laid out.
|
||||
fn third_party(ui: &mut egui::Ui) {
|
||||
let lines: Vec<&str> = THIRD_PARTY.lines().collect();
|
||||
let font = egui::FontId::monospace(11.0);
|
||||
let row_h = ui.fonts_mut(|f| f.row_height(&font));
|
||||
egui::ScrollArea::both().id_salt("sc_third_party").max_height(360.0).show_rows(ui, row_h, lines.len(), |ui, rows| {
|
||||
for line in lines.get(rows).unwrap_or_default() {
|
||||
// One row per line (show_rows needs equal rows): long lines scroll sideways.
|
||||
ui.add(egui::Label::new(RichText::new(*line).font(font.clone())).extend());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -768,6 +768,25 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
pen.line(&[(15.0, 15.0), (19.0, 19.0)], a);
|
||||
pen.line(&[(19.0, 15.0), (15.0, 19.0)], a);
|
||||
}
|
||||
"patch" => {
|
||||
// A wire loop with a surface spanned across it.
|
||||
pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], Color32::from_rgb(170, 200, 240), ink);
|
||||
pen.line(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0), (4.0, 16.0)], a);
|
||||
}
|
||||
"stitch" => {
|
||||
// Two surface patches sewn along their shared edge.
|
||||
pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], Color32::from_rgb(170, 200, 240), ink);
|
||||
pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], Color32::from_rgb(150, 185, 235), ink);
|
||||
for y in [6.0, 9.0, 12.0] {
|
||||
pen.line(&[(10.0, y), (14.0, y + 1.5)], a);
|
||||
}
|
||||
}
|
||||
"thicken" => {
|
||||
// A curved sheet given thickness: its offset copy and the side walls.
|
||||
pen.arc(12.0, 20.0, 11.0, 3.6, 5.8, ink);
|
||||
pen.arc(12.0, 20.0, 7.0, 3.6, 5.8, a);
|
||||
pen.arrow((12.0, 9.0), (12.0, 13.5), a);
|
||||
}
|
||||
"extend" => {
|
||||
pen.line(&[(20.0, 3.0), (20.0, 21.0)], ink);
|
||||
pen.line(&[(3.0, 12.0), (10.0, 12.0)], ink);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod access;
|
||||
pub mod agent_cursor;
|
||||
pub mod browser;
|
||||
pub mod canvas;
|
||||
pub mod context_menu;
|
||||
@@ -22,6 +23,7 @@ pub mod dialogs_move;
|
||||
pub mod dialogs_parts;
|
||||
pub mod dialogs_plastic;
|
||||
pub mod dialogs_sheet;
|
||||
pub mod dialogs_surface;
|
||||
pub mod dim_view;
|
||||
pub mod documents;
|
||||
pub mod drag_snap;
|
||||
@@ -104,6 +106,8 @@ pub struct UiState {
|
||||
pub shown_dims: Vec<u64>,
|
||||
/// Bodies locked in the browser (no move or delete).
|
||||
pub locked_bodies: Vec<String>,
|
||||
/// The agent cursor: shown, speed, follow camera (#35).
|
||||
pub agent_cursor: agent_cursor::Settings,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
@@ -129,6 +133,7 @@ impl Default for UiState {
|
||||
hidden_profiles: Vec::new(),
|
||||
shown_dims: Vec::new(),
|
||||
locked_bodies: Vec::new(),
|
||||
agent_cursor: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +204,8 @@ pub struct SolveApp {
|
||||
/// The modifiers of the synthetic press in progress (a Ctrl-drag keeps Ctrl while it moves).
|
||||
pub synthetic_mods: egui::Modifiers,
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
/// The agent cursor acting out control requests.
|
||||
pub agent: agent_cursor::AgentCursor,
|
||||
pending_shots: Vec<(u64, Option<String>, std::sync::mpsc::Sender<Value>, f64)>,
|
||||
queued_shots: Vec<(u64, f64, u32)>,
|
||||
shot_token: u64,
|
||||
@@ -248,6 +255,7 @@ impl SolveApp {
|
||||
synthetic: Vec::new(),
|
||||
synthetic_mods: egui::Modifiers::default(),
|
||||
control_rx: None,
|
||||
agent: Default::default(),
|
||||
pending_shots: Vec::new(),
|
||||
queued_shots: Vec::new(),
|
||||
shot_token: 0,
|
||||
@@ -653,6 +661,7 @@ impl SolveApp {
|
||||
self.now = ctx.input(|i| i.time);
|
||||
self.step_view_animation(ctx);
|
||||
self.drain_control(ctx);
|
||||
agent_cursor::step(self, ctx);
|
||||
if !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -732,13 +741,20 @@ impl SolveApp {
|
||||
shortcut_box::show(self, ui.ctx());
|
||||
keymap::show(self, ui.ctx());
|
||||
prefs::show(self, ui.ctx());
|
||||
agent_cursor::paint(self, ui.ctx());
|
||||
self.frame_ms = now_ms() - t0;
|
||||
}
|
||||
|
||||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||||
let Some(rx) = self.control_rx.take() else { return };
|
||||
while let Ok(req) = rx.try_recv() {
|
||||
// While the agent cursor acts out a request, later ones wait their turn.
|
||||
while !self.agent.busy() {
|
||||
let Ok(req) = rx.try_recv() else { break };
|
||||
let reply = req.reply.clone();
|
||||
if agent_cursor::intercept(self, &req.method, &req.params, &reply) {
|
||||
ctx.request_repaint();
|
||||
continue;
|
||||
}
|
||||
match control::handle(self, ctx, &req) {
|
||||
control::Outcome::Done(v) => {
|
||||
let _ = reply.send(v);
|
||||
|
||||
@@ -219,3 +219,160 @@ fn chamfer_types_send_their_values() {
|
||||
let e = crate::dialogs::for_feature(&app, ch, None).unwrap();
|
||||
assert!(matches!(&e.kind, Kind::Fillet { chamfer: true, ctype: 2, angle, flip: true, .. } if angle == "30 deg"), "{:?}", e.kind);
|
||||
}
|
||||
|
||||
/// Apply a dialog's commands to the session; the new feature's id.
|
||||
fn apply(app: &mut SolveApp, d: &Dialog) -> u64 {
|
||||
for (id, p) in apply_commands(app, d).unwrap() {
|
||||
app.session.execute(&id, &p).unwrap();
|
||||
}
|
||||
app.session.doc.features.last().unwrap().id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_direction_and_tangent_chain_round_trip() {
|
||||
let mut app = boxed();
|
||||
let top = face(&app, Vec3::new(20.0, 15.0, 20.0));
|
||||
let mut d = start(&mut app, vec![top], "solid.shell");
|
||||
// Tangent Chain is on, as in Fusion; Direction Outside.
|
||||
assert_eq!(d.extra.get("tangent_chain"), Some(&json!(true)));
|
||||
d.extra.insert("direction".into(), json!("outside"));
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert_eq!((p["direction"].clone(), p["tangent_chain"].clone()), (json!("outside"), json!(true)));
|
||||
let id = apply(&mut app, &d);
|
||||
let e = crate::dialogs::for_feature(&app, id, None).unwrap();
|
||||
assert_eq!(e.extra.get("direction"), Some(&json!("outside")));
|
||||
assert_eq!(e.extra.get("tangent_chain"), Some(&json!(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_fillet_round_trip() {
|
||||
let mut app = boxed();
|
||||
let e0 = edge(&app, Vec3::new(40.0, 15.0, 20.0));
|
||||
let mut d = start(&mut app, vec![e0], "solid.fillet");
|
||||
d.inputs[0].items.truncate(1);
|
||||
d.extra.insert("type".into(), json!("variable"));
|
||||
d.extra.insert("radius2".into(), json!("4 mm"));
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert_eq!((p["type"].clone(), p["radius2"].clone()), (json!("variable"), json!("4 mm")));
|
||||
assert!(p["start"].is_array(), "{p}");
|
||||
let id = apply(&mut app, &d);
|
||||
let e = crate::dialogs::for_feature(&app, id, None).unwrap();
|
||||
assert_eq!(e.extra.get("type"), Some(&json!("variable")));
|
||||
assert_eq!(e.extra.get("radius2"), Some(&json!("4 mm")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_options_reach_their_commands() {
|
||||
let mut app = boxed();
|
||||
// Thread: Modeled.
|
||||
let side = face(&app, Vec3::new(20.0, 0.0, 10.0));
|
||||
let mut d = start(&mut app, vec![side.clone()], "solid.thread");
|
||||
d.extra.insert("modeled".into(), json!(true));
|
||||
assert_eq!(apply_commands(&app, &d).unwrap()[0].1["modeled"], json!(true));
|
||||
// Combine into a new component.
|
||||
app.dialog = None;
|
||||
let mut d = start(&mut app, vec![], "solid.combine");
|
||||
d.extra.insert("new_component".into(), json!(true));
|
||||
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }];
|
||||
d.inputs[1].items = vec![Sel::Body { name: "Body2".into() }];
|
||||
assert_eq!(apply_commands(&app, &d).unwrap()[0].1["new_component"], json!(true));
|
||||
// Hole: To Object sends the place to drill to instead of a depth.
|
||||
app.dialog = None;
|
||||
let top = face(&app, Vec3::new(20.0, 15.0, 20.0));
|
||||
let mut d = start(&mut app, vec![top], "solid.hole");
|
||||
d.inputs.push(crate::selection::SelInput::new("To Object", crate::selection::FACES | crate::selection::VERTICES, false));
|
||||
d.inputs[1].items = vec![face(&app, Vec3::new(20.0, 15.0, 0.0))];
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert!(p["to"].is_array() && p.get("depth").is_none(), "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_fill_finds_the_cells_and_fills_the_ticked_ones() {
|
||||
let mut app = app_with(json!([
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20}},
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20, "corner": [10, 10, 0], "operation": "new"}}
|
||||
]));
|
||||
let mut d = start(&mut app, vec![], "SurfaceSculpt");
|
||||
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }, Sel::Body { name: "Body2".into() }];
|
||||
app.dialog = Some(d);
|
||||
// The dialog finds the cells on its next frame.
|
||||
let ctx = egui::Context::default();
|
||||
let _ = ctx.run_ui(Default::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
|
||||
let mut d = app.dialog.take().unwrap();
|
||||
let Kind::BoundaryFill { cells, .. } = &mut d.kind else { panic!() };
|
||||
// Only A, only B, and both.
|
||||
assert_eq!(cells.len(), 3, "{cells:?}");
|
||||
assert!(cells.iter().any(|c| c.1 == "Inside Body1, Body2"));
|
||||
// Fill just the overlap.
|
||||
for c in cells.iter_mut() {
|
||||
c.2 = c.1 == "Inside Body1, Body2";
|
||||
}
|
||||
let id = apply(&mut app, &d);
|
||||
assert!(app.session.model.result(id).is_some_and(|r| r.error.is_none()));
|
||||
// A new body filling the 10 x 10 x 20 overlap.
|
||||
let vols: Vec<f64> = app.session.model.state().bodies.iter().map(|b| b.mesh().measure().volume).collect();
|
||||
assert!(vols.len() == 3 && vols.iter().any(|v| (v - 2000.0).abs() < 1.0), "{vols:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_snaps_to_centres_and_edge_middles() {
|
||||
let mut app = boxed();
|
||||
let mut d = start(&mut app, vec![], "solid.align");
|
||||
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }];
|
||||
d.inputs[1].items = vec![face(&app, Vec3::new(20.0, 15.0, 0.0))];
|
||||
d.inputs[2].items = vec![edge(&app, Vec3::new(20.0, 0.0, 20.0))];
|
||||
d.extra.insert("from_snap".into(), json!("center"));
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert_eq!(p["from"]["snap"], json!("face_center"));
|
||||
assert_eq!(p["to"]["snap"], json!("edge_mid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_dialogs_make_their_commands() {
|
||||
// A planar patch of a sketch's rectangle, then thickened; Edit Feature brings both back.
|
||||
let mut app = app_with(json!([
|
||||
{"command": "sketch.create", "params": {"plane": "XY"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [30, 20]}},
|
||||
{"command": "sketch.finish", "params": {}}
|
||||
]));
|
||||
let sk = app.session.doc.features.last().unwrap().id;
|
||||
let mut d = start(&mut app, vec![], "surface.patch");
|
||||
d.inputs[0].items = vec![Sel::Profile { sketch: sk, index: 0 }];
|
||||
let patch = {
|
||||
for (id, p) in apply_commands(&app, &d).unwrap() {
|
||||
app.session.execute(&id, &p).unwrap();
|
||||
}
|
||||
app.session.doc.features.last().unwrap().id
|
||||
};
|
||||
let surf = app.session.model.state().bodies.last().unwrap().name.clone();
|
||||
assert!(matches!(crate::dialogs::for_feature(&app, patch, None).unwrap().kind, Kind::Surface(_)));
|
||||
app.dialog = None;
|
||||
let mut d = start(&mut app, vec![], "surface.thicken");
|
||||
d.inputs[0].items = vec![Sel::Body { name: surf }];
|
||||
let (id, p) = apply_commands(&app, &d).unwrap().remove(0);
|
||||
assert_eq!(id, "surface.thicken");
|
||||
app.session.execute(&id, &p).unwrap();
|
||||
let v = app.session.model.state().bodies.iter().map(|b| b.mesh().measure().volume).fold(0.0, f64::max);
|
||||
assert!((v - 30.0 * 20.0 * 2.0).abs() < 1.0, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_body_by_a_curved_face_sends_it_as_a_tool() {
|
||||
let mut app = app_with(json!([
|
||||
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}},
|
||||
{"command": "solid.cylinder", "params": {"diameter": 20, "height": 40, "base": [20, 15, -10], "operation": "new"}}
|
||||
]));
|
||||
let st = app.session.model.state();
|
||||
let cyl = st.bodies.iter().find(|b| b.name != "Body1").unwrap();
|
||||
let m = cyl.mesh();
|
||||
// A point on the round side.
|
||||
let p = Vec3::new(30.0, 15.0, 5.0);
|
||||
let t = m.triangles.iter().position(|t| crate::dialogs::point_tri_dist(p, m.tri(t).unwrap()) < 0.3).unwrap();
|
||||
let side = Sel::Face { body: cyl.name.clone(), index: m.tri_face[t] as usize, point: p };
|
||||
drop(st);
|
||||
let mut d = start(&mut app, vec![], "solid.split_body");
|
||||
d.inputs[0].items = vec![Sel::Body { name: "Body1".into() }];
|
||||
d.inputs[1].items = vec![side];
|
||||
let p = apply_commands(&app, &d).unwrap().remove(0).1;
|
||||
assert!(p["tool"]["point"].is_array() && p.get("plane").is_none(), "{p}");
|
||||
}
|
||||
@@ -270,12 +270,19 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built {
|
||||
}
|
||||
let nf = am.tri_face.iter().max().map_or(0, |m| *m as usize + 1);
|
||||
let added: Vec<bool> = (0..nf).map(|f| old.as_ref().is_none_or(|bm| face_is_new(&am, f, bm))).collect();
|
||||
// Material removed from a body (a hole, a pocket): its new faces also show through the
|
||||
// body in the cut colour, so the cut is seen from any side.
|
||||
let cut = old.as_ref().is_some_and(|bm| am.measure().volume < bm.measure().volume - 1e-6);
|
||||
for (t, f) in am.triangles.iter().zip(&am.tri_face) {
|
||||
let col = if added.get(*f as usize).copied().unwrap_or(true) { c.added } else { c.body };
|
||||
let new = added.get(*f as usize).copied().unwrap_or(true);
|
||||
let col = if new { c.added } else { c.body };
|
||||
for k in t {
|
||||
let i = *k as usize;
|
||||
if let (Some(p), Some(n)) = (am.positions.get(i), am.normals.get(i)) {
|
||||
sc.tri(p.to_f32(), n.to_f32(), col);
|
||||
if new && cut {
|
||||
sc.xray_tri(p.to_f32(), n.to_f32(), c.removed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,6 +371,19 @@ mod tests {
|
||||
bytes.chunks(stride).filter(|v| v.get(24..28) == Some(&col[..])).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_shows_its_cut_through_the_body() {
|
||||
let mut s = solvecraft_engine::Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let p = s.preview(&[("solid.hole".to_string(), json!({"position": [20, 15, 20], "diameter": 5, "depth": 10}))]).unwrap();
|
||||
let b = build(&p.before, &p.after, colors());
|
||||
// The hole's walls are drawn see-through in the cut colour (seen from any side).
|
||||
assert!(count(&b.scene.xray, 28, colors().removed) > 0);
|
||||
// A feature that only adds material shows no cut.
|
||||
let p = s.preview(&[("solid.box".to_string(), json!({"length": 10, "width": 10, "height": 30, "operation": "join"}))]).unwrap();
|
||||
assert_eq!(count(&build(&p.before, &p.after, colors()).scene.xray, 28, colors().removed), 0);
|
||||
}
|
||||
|
||||
fn plate() -> Session {
|
||||
let mut s = Session::default();
|
||||
for (id, p) in [
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
//! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`,
|
||||
//! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}`
|
||||
//! or `{"sketch": [x,y]}` (the sketch being edited)
|
||||
//! - `{"queued": "engine.execute", "params": {…}, "min_frames"?: n}`: a request sent the way an
|
||||
//! agent's arrive (through the app's request queue, answered between frames, so the agent
|
||||
//! cursor can act it out first); fails if the answer came in fewer than `min_frames` frames
|
||||
//! - `{"call": "ui.view", "params": {…}}`: any other control request (`fail: true` expects an
|
||||
//! error)
|
||||
//! - `{"make_image": "pic.png"}`: a picture in the scenario's folder
|
||||
@@ -112,6 +115,8 @@ pub struct Harness {
|
||||
pub dir: PathBuf,
|
||||
/// The file the next Open picker returns (a `choose_file` step).
|
||||
next_file: Rc<RefCell<Option<String>>>,
|
||||
/// The app's request queue, opened by the first queued request.
|
||||
queue: Option<std::sync::mpsc::Sender<ControlRequest>>,
|
||||
time: f64,
|
||||
size: egui::Vec2,
|
||||
}
|
||||
@@ -139,6 +144,7 @@ impl Harness {
|
||||
output: Default::default(),
|
||||
dir: scratch_dir(),
|
||||
next_file: Rc::new(RefCell::new(None)),
|
||||
queue: None,
|
||||
time: 0.0,
|
||||
size: egui::vec2(1600.0, 1000.0),
|
||||
};
|
||||
@@ -201,6 +207,28 @@ impl Harness {
|
||||
}
|
||||
|
||||
/// One control request, then frames until its input has been handled.
|
||||
/// Send a request through the app's queue, as the control channel does, and run frames
|
||||
/// until it is answered: (the reply, frames it took).
|
||||
pub fn call_queued(&mut self, method: &str, params: Value) -> (Value, usize) {
|
||||
let tx = self.queue.get_or_insert_with(|| {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
self.app.control_rx = Some(rx);
|
||||
tx
|
||||
});
|
||||
let (req, rx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
return (json!({"ok": false, "error": "the request queue is closed"}), 0);
|
||||
}
|
||||
for n in 1..=3000 {
|
||||
self.frame();
|
||||
if let Ok(v) = rx.try_recv() {
|
||||
self.settle();
|
||||
return (v, n);
|
||||
}
|
||||
}
|
||||
(json!({"ok": false, "error": "no answer in 3000 frames"}), 3000)
|
||||
}
|
||||
|
||||
pub fn call(&mut self, method: &str, params: Value) -> Value {
|
||||
let (req, _rx) = ControlRequest::new(method, params);
|
||||
let out = match handle(&mut self.app, &self.ctx, &req) {
|
||||
@@ -328,6 +356,13 @@ impl Harness {
|
||||
let [x1, y1] = self.at(d.get(1).unwrap_or(&Value::Null))?;
|
||||
let steps = s.get("steps").cloned().unwrap_or(json!(8));
|
||||
self.call("ui.drag", json!({"x0": x0, "y0": y0, "x1": x1, "y1": y1, "steps": steps, "shift": flag("shift"), "ctrl": flag("ctrl")}))
|
||||
} else if let Some(m) = s.get("queued").and_then(Value::as_str) {
|
||||
let (v, frames) = self.call_queued(m, s.get("params").cloned().unwrap_or(json!({})));
|
||||
let min = s.get("min_frames").and_then(Value::as_u64).unwrap_or(0);
|
||||
if (frames as u64) < min {
|
||||
return Err(format!("{m} was answered after {frames} frames: the agent cursor did not act it out ({min})"));
|
||||
}
|
||||
v
|
||||
} else if let Some(m) = s.get("call").and_then(Value::as_str) {
|
||||
self.call(m, s.get("params").cloned().unwrap_or(json!({})))
|
||||
} else if s.get("note").is_some() || s.get("pending").is_some() {
|
||||
@@ -513,6 +548,21 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
return Err(format!("handle order: got {xs:?}"));
|
||||
}
|
||||
}
|
||||
"camera" => {
|
||||
// {"camera": {"yaw_deg": y, "pitch_deg": p}}: where the camera looks from (±0.5°),
|
||||
// once any view animation has finished.
|
||||
for _ in 0..240 {
|
||||
if h.app.cam_anim.is_none() {
|
||||
break;
|
||||
}
|
||||
h.frame();
|
||||
}
|
||||
let (yaw, pitch) = (h.app.cam.yaw.to_degrees(), h.app.cam.pitch.to_degrees());
|
||||
let near = |got: f64, want: &Value| want.as_f64().is_none_or(|w| ((got - w + 540.0).rem_euclid(360.0) - 180.0).abs() < 0.5);
|
||||
if !near(yaw, &v["yaw_deg"]) || !near(pitch, &v["pitch_deg"]) {
|
||||
return Err(format!("camera yaw {yaw:.2}°, pitch {pitch:.2}°, want {v}"));
|
||||
}
|
||||
}
|
||||
"count" => {
|
||||
// {"count": {"name": n, "value": x}}: a published count.
|
||||
let n = v["name"].as_str().unwrap_or_default();
|
||||
|
||||
@@ -60,6 +60,8 @@ pub struct Tokens {
|
||||
pub axis_z: Color32,
|
||||
/// Construction planes.
|
||||
pub construction_plane: Color32,
|
||||
/// Construction axes and points.
|
||||
pub construction_geom: Color32,
|
||||
// ---- bodies and live previews ----
|
||||
pub body: Color32,
|
||||
pub body_edge: Color32,
|
||||
@@ -164,6 +166,7 @@ impl Tokens {
|
||||
axis_y: Color32::from_rgb(0, 255, 0),
|
||||
axis_z: Color32::from_rgb(2, 2, 248),
|
||||
construction_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 70),
|
||||
construction_geom: Color32::from_rgb(232, 140, 48),
|
||||
body: Color32::from_rgb(176, 186, 198),
|
||||
body_edge: Color32::from_rgb(40, 44, 52),
|
||||
manipulator: Color32::from_rgb(40, 110, 220),
|
||||
|
||||
@@ -23,6 +23,8 @@ fn icon_of(k: &FeatureKind) -> &'static str {
|
||||
FeatureKind::Pattern { .. } => "pattern_circ",
|
||||
FeatureKind::Mirror { .. } => "mirror",
|
||||
FeatureKind::ConstructionPlane { .. } => "plane",
|
||||
FeatureKind::ConstructionAxis { .. } => "axis",
|
||||
FeatureKind::ConstructionPoint { .. } => "point",
|
||||
FeatureKind::Hole { .. } => "hole",
|
||||
FeatureKind::Loft { .. } => "loft",
|
||||
FeatureKind::Sweep { .. } => "sweep",
|
||||
|
||||
+306
-93
@@ -5,7 +5,7 @@ use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::Sel;
|
||||
use solvecraft_engine::geom::{Vec2, Vec3};
|
||||
use solvecraft_engine::render::{Camera, Mat4, Rgb, StandardView};
|
||||
use solvecraft_engine::render::{Camera, Mat4, Rgb};
|
||||
use solvecraft_engine::sketch::{ConstraintKind, CurveKind};
|
||||
use solvecraft_engine::view::{colors, grid_step, sketch_consumed, sketch_lines};
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -404,6 +404,43 @@ pub fn origin_axes(app: &SolveApp) -> Vec<(&'static str, Vec3)> {
|
||||
[("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().filter(|(n, _)| !app.ui.hidden_origin.iter().any(|h| h == n)).collect()
|
||||
}
|
||||
|
||||
/// Construction axes shown, as segments: across the model where the axis passes it (a little
|
||||
/// past it either side), at least the origin axes' length around the axis's origin: (name,
|
||||
/// from, to).
|
||||
pub fn construction_axis_segments(app: &SolveApp) -> Vec<(String, Vec3, Vec3)> {
|
||||
let h = origin_size(app.cam.half_height()) * AXIS_LEN;
|
||||
let bb = solvecraft_engine::view::bounds(&app.session);
|
||||
solvecraft_engine::view::construction_axes(&app.session)
|
||||
.into_iter()
|
||||
.filter(|(_, n, _, _)| !app.ui.hidden_origin.contains(n))
|
||||
.map(|(_, name, o, d)| {
|
||||
let (mut lo, mut hi) = (-h, h);
|
||||
if !bb.is_empty() {
|
||||
for i in 0..8 {
|
||||
let c = Vec3::new(
|
||||
if i & 1 == 0 { bb.min.x } else { bb.max.x },
|
||||
if i & 2 == 0 { bb.min.y } else { bb.max.y },
|
||||
if i & 4 == 0 { bb.min.z } else { bb.max.z },
|
||||
);
|
||||
let t = (c - o).dot(d);
|
||||
lo = lo.min(t - h * 0.3);
|
||||
hi = hi.max(t + h * 0.3);
|
||||
}
|
||||
}
|
||||
(name, o + d * lo, o + d * hi)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Construction points shown: (name, position).
|
||||
pub fn construction_points(app: &SolveApp) -> Vec<(String, Vec3)> {
|
||||
solvecraft_engine::view::construction_points(&app.session)
|
||||
.into_iter()
|
||||
.filter(|(_, n, _)| !app.ui.hidden_origin.contains(n))
|
||||
.map(|(_, n, p)| (n, p))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Construction planes drawn as squares around their origin: (name, normal, quad).
|
||||
pub fn construction_quads(app: &SolveApp) -> Vec<(String, Vec3, [Vec3; 4])> {
|
||||
let h = origin_size(app.cam.half_height()) * 1.2;
|
||||
@@ -648,6 +685,15 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Construction axes, picked like the origin axes.
|
||||
for (name, a, b) in construction_axis_segments(app) {
|
||||
if let (Some(a), Some(b)) = (proj.to_screen(a), proj.to_screen(b)) {
|
||||
let dd = seg_dist(pos, a, b);
|
||||
if dd < 5.0 && besta.as_ref().is_none_or(|(bd, _)| dd < *bd) {
|
||||
besta = Some((dd, Hit::Axis { name }));
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.extend(besta.map(|x| x.1));
|
||||
// Surfaces by depth: profiles (slightly preferred over a face they lie on), faces, planes.
|
||||
let mut surf: Vec<(f64, Hit)> = Vec::new();
|
||||
@@ -971,6 +1017,27 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
a += dash * 2.0;
|
||||
}
|
||||
}
|
||||
// Construction axes (dashed, the length of the origin axes either side) and points (a
|
||||
// small star), in the construction colour.
|
||||
for (name, a, b) in construction_axis_segments(app) {
|
||||
let on = sel.iter().any(|x| matches!(x, Sel::Axis { name: n } if *n == name)) || matches!(hover, Some(Hit::Axis { name: n }) if *n == name);
|
||||
let col = c4(if on { t.sel_edge } else { t.construction_geom });
|
||||
let w = if on { 3.0 } else { 1.6 };
|
||||
let len = a.dist(b);
|
||||
let dash = size * 0.08;
|
||||
let mut s = 0.0;
|
||||
while s < len {
|
||||
let e = (s + dash).min(len);
|
||||
sc.line(a.lerp(b, s / len).to_f32(), a.lerp(b, e / len).to_f32(), col, w, false);
|
||||
s += dash * 1.6;
|
||||
}
|
||||
}
|
||||
for (_, p) in construction_points(app) {
|
||||
let r = size * 0.04;
|
||||
for d in [Vec3::X, Vec3::Y, Vec3::Z] {
|
||||
sc.line((p - d * r).to_f32(), (p + d * r).to_f32(), c4(t.construction_geom), 2.0, true);
|
||||
}
|
||||
}
|
||||
// Selected things.
|
||||
for x in &sel {
|
||||
match x {
|
||||
@@ -1813,75 +1880,46 @@ pub(crate) fn cube_point(app: &SolveApp, rect: Rect, p: Vec3) -> Pos2 {
|
||||
/// Half a view cube face, in pixels.
|
||||
const CUBE_SCALE: f64 = 32.0;
|
||||
|
||||
/// The view cube (top right): click a face for a standard view; the house resets.
|
||||
/// The view cube (top right): a cube with chamfered corners. Its 14 targets are the 6 faces
|
||||
/// (orthographic views) and the 8 corner facets (iso views); the edges between them are not
|
||||
/// targets. Hover lights the face or facet under the pointer, a click turns the view to it, the
|
||||
/// house goes home.
|
||||
fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = Tokens::get();
|
||||
let c = pos2(rect.right() - 80.0, rect.top() + 80.0);
|
||||
let s = CUBE_SCALE;
|
||||
let mut lit = 0;
|
||||
let (r, u, b) = app.cam.basis();
|
||||
let to2 = |p: Vec3| -> (Pos2, f64) { (pos2(c.x + (p.dot(r) * s) as f32, c.y - (p.dot(u) * s) as f32), p.dot(b)) };
|
||||
let faces: [(Vec3, &str, StandardView); 6] = [
|
||||
(Vec3::new(0.0, -1.0, 0.0), "FRONT", StandardView::Front),
|
||||
(Vec3::new(0.0, 1.0, 0.0), "BACK", StandardView::Back),
|
||||
(Vec3::new(1.0, 0.0, 0.0), "RIGHT", StandardView::Right),
|
||||
(Vec3::new(-1.0, 0.0, 0.0), "LEFT", StandardView::Left),
|
||||
(Vec3::Z, "TOP", StandardView::Top),
|
||||
(Vec3::new(0.0, 0.0, -1.0), "BOTTOM", StandardView::Bottom),
|
||||
];
|
||||
let painter = ui.painter_at(rect);
|
||||
// Clicked view direction (target → eye): a face, or an edge/corner zone of a face.
|
||||
let mut clicked: Option<Vec3> = None;
|
||||
let hover = ui.input(|i| i.pointer.hover_pos());
|
||||
let mut order: Vec<usize> = (0..6).collect();
|
||||
order.sort_by(|a, b2| faces[*a].0.dot(b).total_cmp(&faces[*b2].0.dot(b)));
|
||||
for i in order {
|
||||
let (n, label, _) = faces[i];
|
||||
if n.dot(b) <= 1e-3 {
|
||||
continue;
|
||||
}
|
||||
let (e1, e2) = if n.z.abs() > 0.5 {
|
||||
(Vec3::X, Vec3::Y)
|
||||
} else if n.x.abs() > 0.5 {
|
||||
(Vec3::Y, Vec3::Z)
|
||||
} else {
|
||||
(Vec3::X, Vec3::Z)
|
||||
};
|
||||
let corners = [n - e1 - e2, n + e1 - e2, n + e1 + e2, n - e1 + e2];
|
||||
let pts: Vec<Pos2> = corners.iter().map(|p| to2(*p).0).collect();
|
||||
let poly_hover = hover.is_some_and(|h| point_in_poly(h, &pts));
|
||||
let targets = cube_targets();
|
||||
let shapes = cube_projected(&targets, (r, u, b), c);
|
||||
let hovered = hover.and_then(|h| cube_hit(&shapes, h));
|
||||
// Back to front: the faces and facets turned toward the camera.
|
||||
for (i, pts) in shapes.iter().rev() {
|
||||
let tg = &targets[*i];
|
||||
let n = tg.normal();
|
||||
let shade = (0.75 + 0.25 * n.dot(b)) as f32;
|
||||
painter.add(Shape::convex_polygon(pts.clone(), t.cube_face.gamma_multiply(shade).to_opaque(), Stroke::new(1.0, t.cube_edge)));
|
||||
// Hover zone: the outer band of a face picks the edge or corner it borders.
|
||||
let zone = hover.filter(|_| poly_hover).and_then(|h| cube_zone(&pts, h));
|
||||
if let Some((zi, zj)) = zone {
|
||||
let lo = |k: i32| match k {
|
||||
-1 => (-1.0, -1.0 + 2.0 * CUBE_BAND),
|
||||
0 => (-1.0 + 2.0 * CUBE_BAND, 1.0 - 2.0 * CUBE_BAND),
|
||||
_ => (1.0 - 2.0 * CUBE_BAND, 1.0),
|
||||
};
|
||||
let ((a0, a1), (b0, b1)) = (lo(zi), lo(zj));
|
||||
let zp: Vec<Pos2> = [(a0, b0), (a1, b0), (a1, b1), (a0, b1)].iter().map(|(x, y)| to2(n + e1 * *x + e2 * *y).0).collect();
|
||||
painter.add(Shape::convex_polygon(zp, t.accent_soft, Stroke::NONE));
|
||||
lit += 1;
|
||||
let fill = if tg.corner { t.cube_face.gamma_multiply(shade * 0.92) } else { t.cube_face.gamma_multiply(shade) };
|
||||
painter.add(Shape::convex_polygon(pts.clone(), fill.to_opaque(), Stroke::new(1.0, t.cube_edge)));
|
||||
if hovered == Some(*i) {
|
||||
painter.add(Shape::convex_polygon(pts.clone(), t.accent_soft, Stroke::new(1.0, t.accent)));
|
||||
}
|
||||
let center = to2(n).0;
|
||||
if n.dot(b) > 0.35 {
|
||||
painter.text(center, Align2::CENTER_CENTER, label, FontId::proportional(9.5), Color32::from_rgb(40, 44, 52));
|
||||
}
|
||||
if let Some((zi, zj)) = zone
|
||||
&& ui.input(|i| i.pointer.primary_clicked())
|
||||
{
|
||||
clicked = Some(n + e1 * zi as f64 + e2 * zj as f64);
|
||||
let centre = cube_centroid(pts);
|
||||
crate::scenario::publish_handle(&format!("cube:{}", tg.name), centre);
|
||||
if !tg.corner && n.dot(b) > 0.35 {
|
||||
painter.text(centre, Align2::CENTER_CENTER, tg.name.to_uppercase(), FontId::proportional(9.5), Color32::from_rgb(40, 44, 52));
|
||||
}
|
||||
}
|
||||
crate::scenario::publish_count("cube_highlights", f64::from(lit));
|
||||
crate::scenario::publish_count("cube_highlights", f64::from(u8::from(hovered.is_some())));
|
||||
if let Some(i) = hovered {
|
||||
crate::scenario::publish_handle(&format!("cube_hover:{}", targets[i].name), hover.unwrap_or(c));
|
||||
}
|
||||
// Clicked: the view from the face's or facet's direction (target → eye).
|
||||
let clicked = hovered.filter(|_| ui.input(|i| i.pointer.primary_clicked())).map(|i| targets[i].dir);
|
||||
// Axis triad at the cube's corner.
|
||||
let to2 = |p: Vec3| pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32);
|
||||
let o = Vec3::new(-1.0, -1.0, -1.0);
|
||||
for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] {
|
||||
let (a, _) = to2(o);
|
||||
let (bb, _) = to2(o + d * 2.6);
|
||||
painter.line_segment([a, bb], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2)));
|
||||
painter.line_segment([to2(o), to2(o + d * 2.6)], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2)));
|
||||
}
|
||||
let home = Rect::from_center_size(pos2(c.x - 52.0, c.y - 48.0), vec2(18.0, 18.0));
|
||||
let hr = ui.interact(home, ui.id().with("vc_home"), Sense::click());
|
||||
@@ -1895,35 +1933,109 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of the edge/corner band of a view cube face, as a fraction of the face.
|
||||
const CUBE_BAND: f64 = 0.2;
|
||||
/// How far along each edge a view cube corner is cut off (the cube's faces are at ±1).
|
||||
const CUBE_CHAMFER: f64 = 0.38;
|
||||
|
||||
/// Which of the 3×3 zones of a projected cube face (corners in order −−, +−, ++, −+) the point
|
||||
/// is over: (−1|0|1, −1|0|1).
|
||||
pub(crate) fn cube_zone(pts: &[Pos2], p: Pos2) -> Option<(i32, i32)> {
|
||||
let [c0, c1, _, c3] = pts else { return None };
|
||||
let (ux, uy) = ((c1.x - c0.x) as f64, (c1.y - c0.y) as f64);
|
||||
let (vx, vy) = ((c3.x - c0.x) as f64, (c3.y - c0.y) as f64);
|
||||
let det = ux * vy - uy * vx;
|
||||
if det.abs() < 1e-9 {
|
||||
return None;
|
||||
/// A view cube target: a face or a corner facet, its view direction (target → eye) and outline
|
||||
/// (cube units).
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CubeTarget {
|
||||
pub name: String,
|
||||
pub dir: Vec3,
|
||||
pub corner: bool,
|
||||
pub outline: Vec<Vec3>,
|
||||
}
|
||||
|
||||
impl CubeTarget {
|
||||
fn normal(&self) -> Vec3 {
|
||||
self.dir.normalized().unwrap_or(Vec3::Z)
|
||||
}
|
||||
let (px, py) = ((p.x - c0.x) as f64, (p.y - c0.y) as f64);
|
||||
let a = (px * vy - py * vx) / det;
|
||||
let b = (ux * py - uy * px) / det;
|
||||
if !(-1e-6..=1.0 + 1e-6).contains(&a) || !(-1e-6..=1.0 + 1e-6).contains(&b) {
|
||||
return None;
|
||||
}
|
||||
|
||||
/// The 14 targets: 6 octagonal faces and 8 triangular corner facets.
|
||||
pub(crate) fn cube_targets() -> Vec<CubeTarget> {
|
||||
let k = CUBE_CHAMFER;
|
||||
let mut out = Vec::new();
|
||||
for (n, name) in [
|
||||
(Vec3::new(0.0, -1.0, 0.0), "front"),
|
||||
(Vec3::new(0.0, 1.0, 0.0), "back"),
|
||||
(Vec3::new(1.0, 0.0, 0.0), "right"),
|
||||
(Vec3::new(-1.0, 0.0, 0.0), "left"),
|
||||
(Vec3::Z, "top"),
|
||||
(Vec3::new(0.0, 0.0, -1.0), "bottom"),
|
||||
] {
|
||||
let (e1, e2) = cube_face_axes(n);
|
||||
let uv =
|
||||
[(-1.0 + k, -1.0), (1.0 - k, -1.0), (1.0, -1.0 + k), (1.0, 1.0 - k), (1.0 - k, 1.0), (-1.0 + k, 1.0), (-1.0, 1.0 - k), (-1.0, -1.0 + k)];
|
||||
out.push(CubeTarget { name: name.into(), dir: n, corner: false, outline: uv.iter().map(|(a, b2)| n + e1 * *a + e2 * *b2).collect() });
|
||||
}
|
||||
let k = |t: f64| {
|
||||
if t < CUBE_BAND {
|
||||
-1
|
||||
} else if t > 1.0 - CUBE_BAND {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
for sz in [1.0, -1.0] {
|
||||
for sy in [-1.0, 1.0] {
|
||||
for sx in [1.0, -1.0] {
|
||||
let corner = Vec3::new(sx, sy, sz);
|
||||
let name = format!(
|
||||
"{}-{}-{}",
|
||||
if sz > 0.0 { "top" } else { "bottom" },
|
||||
if sy < 0.0 { "front" } else { "back" },
|
||||
if sx > 0.0 { "right" } else { "left" }
|
||||
);
|
||||
let outline = vec![corner - Vec3::X * (sx * k), corner - Vec3::Y * (sy * k), corner - Vec3::Z * (sz * k)];
|
||||
out.push(CubeTarget { name, dir: corner, corner: true, outline });
|
||||
}
|
||||
}
|
||||
};
|
||||
Some((k(a), k(b)))
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The targets turned toward the camera (basis `rub`), front first, as screen polygons around
|
||||
/// the cube's centre `c`.
|
||||
pub(crate) fn cube_projected(targets: &[CubeTarget], rub: (Vec3, Vec3, Vec3), c: Pos2) -> Vec<(usize, Vec<Pos2>)> {
|
||||
let (r, u, b) = rub;
|
||||
let to2 = |p: Vec3| pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32);
|
||||
let mut v: Vec<(usize, f64, Vec<Pos2>)> = targets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, tg)| tg.normal().dot(b) > 1e-3)
|
||||
.map(|(i, tg)| (i, tg.normal().dot(b), tg.outline.iter().map(|p| to2(*p)).collect()))
|
||||
.collect();
|
||||
v.sort_by(|a, b2| b2.1.total_cmp(&a.1));
|
||||
v.into_iter().map(|(i, _, p)| (i, p)).collect()
|
||||
}
|
||||
|
||||
/// The target under `p`. The faces and facets tile the cube, so a point on an edge between two
|
||||
/// of them (where neither polygon claims it) goes to the nearer one, within a few pixels.
|
||||
pub(crate) fn cube_hit(shapes: &[(usize, Vec<Pos2>)], p: Pos2) -> Option<usize> {
|
||||
if let Some((i, _)) = shapes.iter().find(|(_, pts)| point_in_poly(p, pts)) {
|
||||
return Some(*i);
|
||||
}
|
||||
shapes.iter().map(|(i, pts)| (*i, poly_distance(p, pts))).filter(|(_, d)| *d <= 3.0).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
fn poly_distance(p: Pos2, pts: &[Pos2]) -> f32 {
|
||||
(0..pts.len())
|
||||
.map(|i| {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
|
||||
let ab = b - a;
|
||||
let t = ((p - a).dot(ab) / ab.length_sq().max(1e-6)).clamp(0.0, 1.0);
|
||||
p.distance(a + ab * t)
|
||||
})
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
}
|
||||
|
||||
fn cube_centroid(pts: &[Pos2]) -> Pos2 {
|
||||
let n = pts.len().max(1) as f32;
|
||||
pos2(pts.iter().map(|p| p.x).sum::<f32>() / n, pts.iter().map(|p| p.y).sum::<f32>() / n)
|
||||
}
|
||||
|
||||
/// The in-face axes of a view cube face.
|
||||
fn cube_face_axes(n: Vec3) -> (Vec3, Vec3) {
|
||||
if n.z.abs() > 0.5 {
|
||||
(Vec3::X, Vec3::Y)
|
||||
} else if n.x.abs() > 0.5 {
|
||||
(Vec3::Y, Vec3::Z)
|
||||
} else {
|
||||
(Vec3::X, Vec3::Z)
|
||||
}
|
||||
}
|
||||
|
||||
fn point_in_poly(p: Pos2, poly: &[Pos2]) -> bool {
|
||||
@@ -1971,8 +2083,10 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
("home", "Home view"),
|
||||
("perspective", "Perspective / orthographic"),
|
||||
("style", "Visual style: shaded with edges, shaded, wireframe, shaded with hidden edges"),
|
||||
("settings", "Grid on/off"),
|
||||
("settings", "View settings: grid, ground shadow, agent cursor"),
|
||||
];
|
||||
let menu_id = ui.id().with("nav_view_menu");
|
||||
let mut menu_open = ui.data(|d| d.get_temp::<bool>(menu_id)).unwrap_or(false);
|
||||
let w = items.len() as f32 * 30.0 + 10.0;
|
||||
let bar = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0));
|
||||
let painter = ui.painter_at(rect);
|
||||
@@ -1985,7 +2099,7 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
"pan" => app.viewport.nav == Some(NavMode::Pan),
|
||||
"zoom" => app.viewport.nav == Some(NavMode::Zoom),
|
||||
"perspective" => app.ui.perspective,
|
||||
"settings" => app.ui.show_grid,
|
||||
"settings" => menu_open,
|
||||
"spin" => app.viewport.spin,
|
||||
_ => false,
|
||||
};
|
||||
@@ -2008,24 +2122,123 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
"lookat" => look_at_selection(app),
|
||||
"style" => app.ui.visual_style = (app.ui.visual_style + 1) % 4,
|
||||
"perspective" => app.ui.perspective = !app.ui.perspective,
|
||||
_ => app.ui.show_grid = !app.ui.show_grid,
|
||||
_ => menu_open = !menu_open,
|
||||
}
|
||||
}
|
||||
if *icon == "settings" {
|
||||
crate::scenario::publish_handle("nav:view", br.center());
|
||||
if menu_open {
|
||||
menu_open = view_menu(app, ui, br);
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(menu_id, menu_open));
|
||||
}
|
||||
|
||||
/// The View settings above the navigation bar's last button: grid, ground shadow and the agent
|
||||
/// cursor (#35). False once it should close (a click outside it).
|
||||
fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
|
||||
let area = egui::Area::new(egui::Id::new("sc_view_menu"))
|
||||
.order(egui::Order::Foreground)
|
||||
.pivot(Align2::RIGHT_BOTTOM)
|
||||
.fixed_pos(button.right_top() - vec2(0.0, 6.0));
|
||||
let r = area.show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_width(220.0);
|
||||
ui.label(egui::RichText::new("VIEW").strong().size(11.5));
|
||||
ui.checkbox(&mut app.ui.show_grid, "Grid");
|
||||
ui.checkbox(&mut app.ui.ground_shadow, "Ground shadow");
|
||||
ui.separator();
|
||||
let s = &mut app.ui.agent_cursor;
|
||||
let show = ui.checkbox(&mut s.show, "Show Agent Cursor");
|
||||
crate::scenario::publish_handle("view:agent_cursor", show.rect.center());
|
||||
ui.add_enabled_ui(s.show, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Speed");
|
||||
for sp in crate::agent_cursor::Speed::ALL {
|
||||
ui.selectable_value(&mut s.speed, sp, sp.label());
|
||||
}
|
||||
});
|
||||
ui.checkbox(&mut s.follow_camera, "Follow camera (Look At sketches)");
|
||||
});
|
||||
});
|
||||
});
|
||||
let clicked_out = ui.input(|i| i.pointer.any_pressed())
|
||||
&& ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !r.response.rect.contains(p) && !button.contains(p));
|
||||
!clicked_out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A camera basis looking from `dir` (target → eye), as the view cube draws with.
|
||||
fn basis_from(dir: Vec3) -> (Vec3, Vec3, Vec3) {
|
||||
let cam = solvecraft_engine::render::Camera::default().looking_from(dir);
|
||||
cam.basis()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_cube_zones() {
|
||||
// A square face 100 px wide, corners in order −−, +−, ++, −+ (screen y down).
|
||||
let pts = [pos2(0.0, 100.0), pos2(100.0, 100.0), pos2(100.0, 0.0), pos2(0.0, 0.0)];
|
||||
assert_eq!(cube_zone(&pts, pos2(50.0, 50.0)), Some((0, 0)));
|
||||
assert_eq!(cube_zone(&pts, pos2(5.0, 95.0)), Some((-1, -1)));
|
||||
assert_eq!(cube_zone(&pts, pos2(95.0, 50.0)), Some((1, 0)));
|
||||
assert_eq!(cube_zone(&pts, pos2(50.0, 5.0)), Some((0, 1)));
|
||||
assert_eq!(cube_zone(&pts, pos2(150.0, 50.0)), None);
|
||||
fn view_cube_has_14_targets_each_hit_where_drawn() {
|
||||
let targets = cube_targets();
|
||||
assert_eq!(targets.len(), 14);
|
||||
assert_eq!(targets.iter().filter(|t| t.corner).count(), 8);
|
||||
let c = pos2(100.0, 100.0);
|
||||
for (i, tg) in targets.iter().enumerate() {
|
||||
// Seen from a little off its own direction, its middle hits it.
|
||||
let shapes = cube_projected(&targets, basis_from(tg.dir + Vec3::new(0.11, 0.07, 0.05)), c);
|
||||
let pts = &shapes.iter().find(|(j, _)| *j == i).expect("visible").1;
|
||||
assert_eq!(cube_hit(&shapes, cube_centroid(pts)), Some(i), "{}", tg.name);
|
||||
}
|
||||
// From an iso view: 3 faces and the 4 facets turned toward it.
|
||||
let shapes = cube_projected(&targets, basis_from(Vec3::new(1.0, -1.0, 1.0)), c);
|
||||
assert_eq!(shapes.len(), 3 + 4);
|
||||
assert_eq!(cube_hit(&shapes, pos2(400.0, 400.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_cube_edges_are_not_targets() {
|
||||
let targets = cube_targets();
|
||||
let c = pos2(100.0, 100.0);
|
||||
let shapes = cube_projected(&targets, basis_from(Vec3::new(1.0, -1.2, 0.9)), c);
|
||||
let (r, u, _) = basis_from(Vec3::new(1.0, -1.2, 0.9));
|
||||
let to2 = |p: Vec3| pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32);
|
||||
let name = |i: Option<usize>| i.map(|i| targets[i].name.clone()).unwrap_or_default();
|
||||
// On the top-front edge, its middle and near its ends: a neighbouring face (or facet).
|
||||
for x in [-0.5, 0.0, 0.5] {
|
||||
let hit = name(cube_hit(&shapes, to2(Vec3::new(x, -1.0, 1.0))));
|
||||
assert!(hit == "top" || hit == "front", "edge point {x}: {hit}");
|
||||
}
|
||||
let end = name(cube_hit(&shapes, to2(Vec3::new(0.9, -1.0, 1.0))));
|
||||
assert!(["top", "front", "top-front-right"].contains(&end.as_str()), "{end}");
|
||||
// Every direction a click turns to is a face's or a corner's: none along an edge.
|
||||
for tg in &targets {
|
||||
let zeros = [tg.dir.x, tg.dir.y, tg.dir.z].iter().filter(|v| v.abs() < 1e-9).count();
|
||||
assert!(zeros == 2 || zeros == 0, "{}: {:?}", tg.name, tg.dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_axes_span_the_model_and_points_show() {
|
||||
let mut s = solvecraft_engine::Session::default();
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
s.execute("construct.axis.two_points", &json!({"a": {"vertex": [0, 0, 20]}, "b": {"vertex": [40, 30, 20]}})).unwrap();
|
||||
s.execute("construct.point.vertex", &json!({"point": {"vertex": [40, 0, 20]}})).unwrap();
|
||||
let mut app = SolveApp::new(s, crate::Services::default());
|
||||
let axes = construction_axis_segments(&app);
|
||||
assert_eq!(axes.len(), 1);
|
||||
let (name, a, b) = axes[0].clone();
|
||||
// The segment runs through both points and past them.
|
||||
let d = (b - a).normalized().unwrap();
|
||||
for p in [Vec3::new(0.0, 0.0, 20.0), Vec3::new(40.0, 30.0, 20.0)] {
|
||||
let t = (p - a).dot(d);
|
||||
assert!(t > 0.0 && t < a.dist(b) && (a + d * t).dist(p) < 1e-6, "{p:?} not on {a:?}..{b:?}");
|
||||
}
|
||||
assert_eq!(construction_points(&app).len(), 1);
|
||||
// It is an axis for dialogs (pattern directions, revolve axes), and hiding it hides it.
|
||||
let (o, dir) = crate::dialogs::axis_of(&app, &Sel::Axis { name: name.clone() }).unwrap();
|
||||
assert!(o.dist(Vec3::new(0.0, 0.0, 20.0)) < 1e-6 && dir.dist(d) < 1e-6);
|
||||
app.ui.hidden_origin.push(name);
|
||||
assert!(construction_axis_segments(&app).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{"note": "#35: with the agent cursor on, an agent's commands are acted out (the reply waits for the pointer) and give the same result as with it off"},
|
||||
{"call": "ui.agent_cursor", "params": {"show": true, "speed": "normal", "follow_camera": true}},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}, "min_frames": 30},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}, "min_frames": 45},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}, "min_frames": 30},
|
||||
{"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}, "min_frames": 30},
|
||||
{"expect": {"errors": 0, "bodies": 1, "volume": [10000, 1e-6]}},
|
||||
{"note": "A call with animate: false runs at once"},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}, "animate": false}},
|
||||
{"call": "ui.agent_cursor", "params": {"show": false}},
|
||||
{"note": "The same steps with the cursor off: a second, identical body"},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}},
|
||||
{"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}},
|
||||
{"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}},
|
||||
{"expect": {"errors": 0, "bodies": 2, "volume": [20000, 1e-6]}}
|
||||
]
|
||||
@@ -1,7 +1,4 @@
|
||||
[
|
||||
{
|
||||
"pending": "the last frame of an animation to Top turns the view 22.5 degrees (solvecraft-ui)"
|
||||
},
|
||||
{
|
||||
"note": "Animating to Top or Bottom from iso, front or any orbit turns the view smoothly to the end: no frame (the last included) turns it more than 10 degrees."
|
||||
},
|
||||
|
||||
@@ -1,113 +1,22 @@
|
||||
[
|
||||
{
|
||||
"pending": "view cube edge and corner hover light 1 patch, Fusion lights 2 and 3 (solvecraft-ui)"
|
||||
},
|
||||
{
|
||||
"note": "View cube hover from the home view: a face lights one patch, an edge the 2 patches on its faces, a corner the 3 around it (Fusion)."
|
||||
},
|
||||
{
|
||||
"call": "ui.view",
|
||||
"params": {
|
||||
"view": "home"
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"cube": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"count": {
|
||||
"name": "cube_highlights",
|
||||
"value": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"cube": [
|
||||
0,
|
||||
-0.9,
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"count": {
|
||||
"name": "cube_highlights",
|
||||
"value": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"cube": [
|
||||
1,
|
||||
-0.9,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"count": {
|
||||
"name": "cube_highlights",
|
||||
"value": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"cube": [
|
||||
0.9,
|
||||
-0.9,
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"count": {
|
||||
"name": "cube_highlights",
|
||||
"value": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"cube": [
|
||||
0.9,
|
||||
-1,
|
||||
0.9
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"count": {
|
||||
"name": "cube_highlights",
|
||||
"value": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": [
|
||||
800,
|
||||
500
|
||||
]
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"count": {
|
||||
"name": "cube_highlights",
|
||||
"value": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
{"note": "The view cube is a cube with chamfered corners. Its targets are the 6 faces and the 8 corner facets; hover lights exactly the one under the pointer (edges are not targets: a point on an edge goes to a neighbour), and a click turns the view to it."},
|
||||
{"call": "ui.view", "params": {"view": "home"}},
|
||||
{"move": {"cube": [0, 0, 1]}},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:top"]}},
|
||||
{"move": {"cube": [0.3, -1, 0.2]}},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:front"]}},
|
||||
{"note": "On the top-front edge: one target, the top or the front face."},
|
||||
{"move": {"cube": [0, -1, 1]}},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 1}}},
|
||||
{"move": {"cube": [0.873, -0.873, 0.873]}},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 1}, "shown_handles": ["cube_hover:top-front-right"]}},
|
||||
{"click": {"handle": "cube:top-front-right"}},
|
||||
{"expect": {"camera": {"yaw_deg": -45, "pitch_deg": 35.26}}},
|
||||
{"click": {"handle": "cube:front"}},
|
||||
{"expect": {"camera": {"yaw_deg": 0, "pitch_deg": 0}}},
|
||||
{"call": "ui.view", "params": {"view": "home"}},
|
||||
{"click": {"handle": "cube:top-back-right"}},
|
||||
{"expect": {"camera": {"yaw_deg": -135, "pitch_deg": 35.26}}},
|
||||
{"move": [800, 500]},
|
||||
{"expect": {"count": {"name": "cube_highlights", "value": 0}}}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{"note": "Help ▸ About ▸ Licences shows SolveCraft's licences and every third-party crate's, built into the program."},
|
||||
{"call": "ui.help", "params": {"item": "about"}},
|
||||
{"widget": "Licences"},
|
||||
{"expect": {"shown": "Third-party licences"}},
|
||||
{"widget": "Third-party licences"},
|
||||
{"expect": {"shown": "Copy all licences"}},
|
||||
{"expect": {"shown": "Third-party software in SolveCraft"}}
|
||||
]
|
||||
@@ -15,7 +15,8 @@
|
||||
- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. The
|
||||
wheel zooms at the cursor, the middle button pans and Shift+middle orbits (the mouse preset in
|
||||
Preferences switches to SolidWorks or Inventor style). The view cube and the navigation bar
|
||||
change the view. Right-click for the marking menu: eight commands around the cursor, plus a
|
||||
change the view: a cube face turns to its standard view, a chamfered corner to that corner's
|
||||
iso view, and the house goes home. Right-click for the marking menu: eight commands around the cursor, plus a
|
||||
menu for whatever is under it.
|
||||
- **Dialogs** dock on the right with a live preview. Most features also show drag handles and a
|
||||
value box in the viewport.
|
||||
|
||||
@@ -52,6 +52,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints
|
||||
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`), `zoom_reverse`, `orbit_cursor`, `msaa`, `length_decimals`, `angle_decimals` | sets preferences (kept between runs), returns them all |
|
||||
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
|
||||
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
|
||||
| `ui.agent_cursor` | `show` (bool), `speed`: `instant`, `normal`, `slow`, `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
|
||||
| `ui.resize` | `width`, `height` | |
|
||||
| `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# shellcheck config for the packaging scripts (CI: .github/workflows/packaging-lint.yml).
|
||||
source-path=SCRIPTDIR
|
||||
external-sources=true
|
||||
@@ -0,0 +1,65 @@
|
||||
# shellcheck shell=bash
|
||||
# Shared setup for the packaging scripts. Source it: `. "$(dirname "$0")/../env.sh"`.
|
||||
# Adapted from PhotoCraft's packaging (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
#
|
||||
# Exports:
|
||||
# ROOT workspace root
|
||||
# VERSION [workspace.package] version from Cargo.toml (override: SOLVECRAFT_VERSION)
|
||||
# DIST output directory for release artifacts (default: $ROOT/dist/release)
|
||||
# SOLVECRAFT_BUILD_DATE UTC build date, YYYY-MM-DD
|
||||
# CARGO_TARGET_DIR cargo's target dir (default: $ROOT/target)
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
export ROOT
|
||||
|
||||
# The version lives in one place: `[workspace.package] version` in the root Cargo.toml.
|
||||
workspace_version() {
|
||||
awk '
|
||||
/^\[/ { in_pkg = ($0 == "[workspace.package]") ; next }
|
||||
in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit }
|
||||
' "$ROOT/Cargo.toml"
|
||||
}
|
||||
|
||||
VERSION="${SOLVECRAFT_VERSION:-$(workspace_version)}"
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2
|
||||
exit 1
|
||||
fi
|
||||
export VERSION
|
||||
|
||||
DIST="${DIST:-$ROOT/dist/release}"
|
||||
mkdir -p "$DIST"
|
||||
DIST="$(cd "$DIST" && pwd)"
|
||||
export DIST
|
||||
|
||||
export SOLVECRAFT_BUILD_DATE="${SOLVECRAFT_BUILD_DATE:-$(date -u +%Y-%m-%d)}"
|
||||
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
|
||||
APP_ID=ai.storyteller.solvecraft
|
||||
export APP_ID
|
||||
|
||||
# Emit a GitHub Actions warning (plain stderr outside Actions).
|
||||
warn() {
|
||||
if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
|
||||
}
|
||||
|
||||
# Copy the licence, notice and readme files into a package directory. The programs also carry
|
||||
# them (Help > About > Licences, `solvecraft-cli licences`).
|
||||
copy_docs() {
|
||||
local dest="$1" f
|
||||
for f in README.md LICENSE-MIT LICENSE-APACHE NOTICE THIRD-PARTY-LICENSES.txt; do
|
||||
if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi
|
||||
done
|
||||
}
|
||||
|
||||
# The app icon in every size a package needs, drawn by the CLI (no image files are committed):
|
||||
# $1 = the solvecraft-cli to run, $2 = output dir. Writes hicolor/<n>x<n>/apps/$APP_ID.png,
|
||||
# solvecraft.ico and solvecraft-1024.png.
|
||||
make_icons() {
|
||||
local cli="$1" out="$2" s
|
||||
for s in 16 24 32 48 64 128 256 512; do
|
||||
mkdir -p "$out/hicolor/${s}x${s}/apps"
|
||||
"$cli" icon --size "$s" --out "$out/hicolor/${s}x${s}/apps/$APP_ID.png" >/dev/null
|
||||
done
|
||||
"$cli" icon --out "$out/solvecraft.ico" >/dev/null
|
||||
"$cli" icon --size 1024 --out "$out/solvecraft-1024.png" >/dev/null
|
||||
}
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and package SolveCraft for FreeBSD:
|
||||
#
|
||||
# $DIST/solvecraft-<version>-freebsd-x86_64.tar.gz a /usr/local-style tree:
|
||||
# solvecraft-<version>-freebsd-x86_64/{bin, share/applications, share/icons, share/mime,
|
||||
# share/metainfo, share/doc/solvecraft}
|
||||
#
|
||||
# Install by copying the tree's contents into /usr/local:
|
||||
# tar -xzf solvecraft-<version>-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local
|
||||
#
|
||||
# Usage: packaging/freebsd/package.sh [--skip-build]
|
||||
# Needs: bash, cargo, and the packages installed by release.yml's FreeBSD job.
|
||||
# Adapted from PhotoCraft's packaging (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
set -euo pipefail
|
||||
# shellcheck source=../env.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
|
||||
LINUX="$ROOT/packaging/linux"
|
||||
|
||||
SKIP_BUILD=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--skip-build) SKIP_BUILD=1; shift ;;
|
||||
-h | --help) sed -n '2,13p' "$0"; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# FreeBSD's uname says amd64; release file names use x86_64 like the Linux ones.
|
||||
case "$(uname -m)" in
|
||||
amd64 | x86_64) ARCH=x86_64 ;;
|
||||
arm64 | aarch64) ARCH=aarch64 ;;
|
||||
*) echo "unsupported architecture $(uname -m)" >&2; exit 2 ;;
|
||||
esac
|
||||
BASENAME="solvecraft-$VERSION-freebsd-$ARCH"
|
||||
echo "==> SolveCraft $VERSION for FreeBSD $ARCH"
|
||||
|
||||
# The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory.
|
||||
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}"
|
||||
if [ "$SKIP_BUILD" = 0 ]; then
|
||||
(cd "$ROOT" && cargo build --release --locked -p solvecraft -p solvecraft-cli)
|
||||
fi
|
||||
WORK="$CARGO_TARGET_DIR/freebsd-package"
|
||||
STAGE="$WORK/$BASENAME"
|
||||
BIN="$CARGO_TARGET_DIR/release"
|
||||
rm -rf "$WORK"
|
||||
|
||||
# FreeBSD's install(1) has no -D: create the directories first.
|
||||
mkdir -p "$STAGE/bin" "$STAGE/share/applications" "$STAGE/share/mime/packages" \
|
||||
"$STAGE/share/metainfo" "$STAGE/share/icons" "$STAGE/share/doc/solvecraft"
|
||||
install -m 755 "$BIN/solvecraft" "$BIN/solvecraft-cli" "$STAGE/bin/"
|
||||
strip "$STAGE/bin/solvecraft" "$STAGE/bin/solvecraft-cli" 2>/dev/null || true
|
||||
# The desktop entry, MIME type, metainfo and icons are the freedesktop files Linux ships.
|
||||
install -m 644 "$LINUX/$APP_ID.desktop" "$STAGE/share/applications/$APP_ID.desktop"
|
||||
install -m 644 "$LINUX/$APP_ID.mime.xml" "$STAGE/share/mime/packages/$APP_ID.xml"
|
||||
sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$SOLVECRAFT_BUILD_DATE/g" \
|
||||
"$LINUX/$APP_ID.metainfo.xml.in" >"$STAGE/share/metainfo/$APP_ID.metainfo.xml"
|
||||
make_icons "$STAGE/bin/solvecraft-cli" "$WORK/icons"
|
||||
cp -R "$WORK/icons/hicolor" "$STAGE/share/icons/"
|
||||
copy_docs "$STAGE/share/doc/solvecraft"
|
||||
|
||||
for f in bin/solvecraft bin/solvecraft-cli "share/applications/$APP_ID.desktop" \
|
||||
"share/icons/hicolor/256x256/apps/$APP_ID.png" share/doc/solvecraft/LICENSE-MIT \
|
||||
share/doc/solvecraft/LICENSE-APACHE share/doc/solvecraft/THIRD-PARTY-LICENSES.txt; do
|
||||
if [ ! -e "$STAGE/$f" ]; then echo "error: $f is missing from the package" >&2; exit 1; fi
|
||||
done
|
||||
|
||||
tar -C "$WORK" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME"
|
||||
echo "wrote $DIST/$BASENAME.tar.gz"
|
||||
"$STAGE/bin/solvecraft-cli" --version
|
||||
echo "==> done"
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/bin/sh
|
||||
# SolveCraft AppImage entry point (packaging/linux/AppRun; installed by package.sh).
|
||||
# Adapted from PhotoCraft's AppRun (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
#
|
||||
# On Wayland the compositor resolves a window's dock icon by taking the window's
|
||||
# app_id (ai.storyteller.solvecraft) and looking up an installed .desktop file.
|
||||
# An AppImage installs nothing, so every AppImage run would otherwise show the
|
||||
# generic "Wayland" icon. Best-effort desktop integration: on first run
|
||||
# (and whenever the AppImage's path changes) install the .desktop entry and the
|
||||
# hicolor icons into $XDG_DATA_HOME, then launch. Every failure here must leave
|
||||
# the launch untouched. Opt out with SOLVECRAFT_NO_DESKTOP_INTEGRATION=1.
|
||||
|
||||
set -u
|
||||
|
||||
APPDIR="$(dirname "$(readlink -f "$0")")"
|
||||
DESK_ID=ai.storyteller.solvecraft
|
||||
|
||||
integrate() {
|
||||
case "${SOLVECRAFT_NO_DESKTOP_INTEGRATION:-}" in
|
||||
1 | true | yes) return 0 ;;
|
||||
esac
|
||||
# The AppImage runtime exports $APPIMAGE; without it (an extracted AppDir) there
|
||||
# is no path to register and nothing to do.
|
||||
appimage="${APPIMAGE:-}"
|
||||
[ -n "$appimage" ] && [ -f "$appimage" ] || return 0
|
||||
appimage="$(readlink -f "$appimage")" || return 0
|
||||
# Exec= is parsed by the desktop: a path holding a quote, backquote, dollar, backslash,
|
||||
# percent or a line break would need escaping (or could add lines), so skip integration.
|
||||
case "$appimage" in
|
||||
*[\"\`\$\\%]* | *'
|
||||
'*) return 0 ;;
|
||||
esac
|
||||
home="${HOME:-}"
|
||||
[ -n "$home" ] || return 0
|
||||
data="${XDG_DATA_HOME:-$home/.local/share}"
|
||||
src_desktop="$APPDIR/usr/share/applications/$DESK_ID.desktop"
|
||||
[ -f "$src_desktop" ] || return 0
|
||||
|
||||
# Rewrite Exec to this AppImage (the binary is not on the host PATH) and drop
|
||||
# TryExec, which would hide the entry; everything else is shipped as packaged.
|
||||
apps="$data/applications"
|
||||
mkdir -p "$apps" 2>/dev/null || return 0
|
||||
tmp="$apps/.$DESK_ID.tmp.$$"
|
||||
esc="$(printf '%s' "$appimage" | sed 's/[\\&|]/\\&/g')"
|
||||
if sed -e "s|^Exec=.*|Exec=\"$esc\" %F|" -e '/^TryExec=/d' "$src_desktop" 2>/dev/null >"$tmp"; then
|
||||
if cmp -s "$tmp" "$apps/$DESK_ID.desktop"; then
|
||||
rm -f "$tmp"
|
||||
else
|
||||
if mv -f "$tmp" "$apps/$DESK_ID.desktop" 2>/dev/null; then
|
||||
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" >/dev/null 2>&1
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Icons: per-file compare so an unchanged install costs a handful of cmp calls.
|
||||
src_icons="$APPDIR/usr/share/icons/hicolor"
|
||||
[ -d "$src_icons" ] || return 0
|
||||
icons="$data/icons/hicolor"
|
||||
(cd "$src_icons" && find . -type f) | while IFS= read -r rel; do
|
||||
mkdir -p "$icons/$(dirname "$rel")" 2>/dev/null || continue
|
||||
if ! cmp -s "$src_icons/$rel" "$icons/$rel"; then
|
||||
cp "$src_icons/$rel" "$icons/$rel" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
integrate
|
||||
exec "$APPDIR/usr/bin/solvecraft" "$@"
|
||||
@@ -0,0 +1,14 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=SolveCraft
|
||||
GenericName=3D CAD
|
||||
Comment=Parametric 3D CAD: sketch, constrain, extrude, and change a parameter to rebuild
|
||||
Exec=solvecraft %f
|
||||
TryExec=solvecraft
|
||||
Icon=ai.storyteller.solvecraft
|
||||
Terminal=false
|
||||
StartupNotify=true
|
||||
StartupWMClass=solvecraft
|
||||
Categories=Graphics;3DGraphics;Engineering;
|
||||
Keywords=cad;3d;parametric;sketch;step;stl;modelling;modeling;
|
||||
MimeType=application/x-solvecraft;model/step;model/iges;model/stl;model/3mf;
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- AppStream metadata (software centres, Flathub). Template: package.sh substitutes @VERSION@
|
||||
and @DATE@ from Cargo.toml and the build date. -->
|
||||
<component type="desktop-application">
|
||||
<id>ai.storyteller.solvecraft</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT OR Apache-2.0</project_license>
|
||||
<name>SolveCraft</name>
|
||||
<summary>Parametric 3D CAD in the style of Fusion</summary>
|
||||
<developer id="io.github.bherbruck">
|
||||
<name>SolveCraft contributors</name>
|
||||
</developer>
|
||||
<description>
|
||||
<p>
|
||||
SolveCraft is an open-source parametric 3D CAD application written in 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.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Sketches with a constraint solver and parameter-driven dimensions</li>
|
||||
<li>Extrude, revolve, sweep, loft, fillet, chamfer, shell, holes and patterns</li>
|
||||
<li>Components and joints, sheet metal, and a plastic-enclosure subset</li>
|
||||
<li>STEP and IGES import and export; 3MF, STL and OBJ</li>
|
||||
<li>Every action is a command, scriptable from a CLI, a JSON control channel and an MCP server</li>
|
||||
</ul>
|
||||
</description>
|
||||
<launchable type="desktop-id">ai.storyteller.solvecraft.desktop</launchable>
|
||||
<icon type="stock">ai.storyteller.solvecraft</icon>
|
||||
<url type="homepage">https://bherbruck.github.io/solvecraft/</url>
|
||||
<url type="bugtracker">https://github.com/bherbruck/solvecraft/issues</url>
|
||||
<url type="help">https://bherbruck.github.io/solvecraft/</url>
|
||||
<categories>
|
||||
<category>Graphics</category>
|
||||
<category>3DGraphics</category>
|
||||
<category>Engineering</category>
|
||||
</categories>
|
||||
<keywords>
|
||||
<keyword>cad</keyword>
|
||||
<keyword>parametric</keyword>
|
||||
<keyword>step</keyword>
|
||||
</keywords>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>A sample part with its sketches and feature timeline</caption>
|
||||
<image>https://raw.githubusercontent.com/bherbruck/solvecraft/main/docs/screenshots/sample-plate.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<requires>
|
||||
<display_length compare="ge">760</display_length>
|
||||
</requires>
|
||||
<supports>
|
||||
<control>keyboard</control>
|
||||
<control>pointing</control>
|
||||
</supports>
|
||||
<provides>
|
||||
<binary>solvecraft</binary>
|
||||
<binary>solvecraft-cli</binary>
|
||||
<mediatype>application/x-solvecraft</mediatype>
|
||||
</provides>
|
||||
<content_rating type="oars-1.1"/>
|
||||
<releases>
|
||||
<release version="@VERSION@" date="@DATE@"/>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- shared-mime-info definitions for types the system database may not have.
|
||||
Installed to /usr/share/mime/packages/ (update-mime-database runs on install). -->
|
||||
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
|
||||
<mime-type type="application/x-solvecraft">
|
||||
<comment>SolveCraft design</comment>
|
||||
<icon name="ai.storyteller.solvecraft"/>
|
||||
<sub-class-of type="application/json"/>
|
||||
<glob pattern="*.solvecraft"/>
|
||||
</mime-type>
|
||||
</mime-info>
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# Repackage a SolveCraft Linux tarball as a single-file Flatpak bundle:
|
||||
#
|
||||
# $DIST/solvecraft-<version>-linux-<arch>.flatpak
|
||||
#
|
||||
# Usage: packaging/linux/flatpak-bundle.sh [--no-test] [TARBALL]
|
||||
#
|
||||
# TARBALL defaults to $DIST/solvecraft-<version>-linux-<arch>.tar.gz from package.sh. The bundle
|
||||
# is built for the host architecture (x86_64 or aarch64), which must match the tarball's.
|
||||
# Needs flatpak, flatpak-builder and the SVG pixbuf loader (librsvg2-common) for the host's
|
||||
# `appstreamcli compose`; the freedesktop runtime and SDK named in the manifest are
|
||||
# installed per-user from Flathub. Unless --no-test, the bundle is then installed per-user and
|
||||
# `solvecraft-cli --version` is run inside the sandbox as a smoke test.
|
||||
# Manifest: packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml.
|
||||
# Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
set -euo pipefail
|
||||
# shellcheck source=../env.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
|
||||
HERE="$ROOT/packaging/linux"
|
||||
FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
TEST=1
|
||||
TARBALL=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--no-test) TEST=0; shift ;;
|
||||
-h | --help) sed -n '2,14p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
*) TARBALL="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ;;
|
||||
aarch64 | arm64) ARCH=aarch64 ;;
|
||||
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
|
||||
esac
|
||||
BASENAME="solvecraft-$VERSION-linux-$ARCH"
|
||||
TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}"
|
||||
[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
|
||||
for tool in flatpak flatpak-builder; do
|
||||
command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; }
|
||||
done
|
||||
|
||||
echo "==> SolveCraft $VERSION Flatpak bundle for $ARCH from $(basename "$TARBALL")"
|
||||
|
||||
WORK="$CARGO_TARGET_DIR/flatpak-bundle"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/stage"
|
||||
tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1
|
||||
[ -x "$WORK/stage/bin/solvecraft" ] || { echo "error: $TARBALL has no bin/solvecraft" >&2; exit 1; }
|
||||
cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml"
|
||||
|
||||
flatpak remote-add --user --if-not-exists flathub "$FLATHUB"
|
||||
# --disable-rofiles-fuse: no FUSE in containers and CI runners.
|
||||
flatpak-builder --user --install-deps-from=flathub --disable-rofiles-fuse --force-clean \
|
||||
--default-branch=stable --repo="$WORK/repo" --state-dir="$WORK/state" "$WORK/build" "$WORK/$APP_ID.yml"
|
||||
|
||||
OUT="$DIST/$BASENAME.flatpak"
|
||||
# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing.
|
||||
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
|
||||
echo "wrote $OUT"
|
||||
|
||||
if [ "$TEST" = 1 ]; then
|
||||
flatpak install --user -y --noninteractive --reinstall "$OUT"
|
||||
flatpak info --user "$APP_ID"
|
||||
flatpak run --command=solvecraft-cli "$APP_ID" --version
|
||||
flatpak run --command=sh "$APP_ID" -c 'ls /app/share/applications /app/share/metainfo /app/share/mime/packages /app/share/icons/hicolor/256x256/apps /app/share/doc/solvecraft'
|
||||
fi
|
||||
echo "==> done"
|
||||
ls -lh "$OUT"
|
||||
@@ -0,0 +1,43 @@
|
||||
# Flatpak manifest for the single-file bundle attached to every GitHub release
|
||||
# (solvecraft-<version>-linux-<arch>.flatpak). Build it with packaging/linux/flatpak-bundle.sh.
|
||||
# Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
#
|
||||
# It repackages the release's own Linux tarball (solvecraft-<version>-linux-<arch>.tar.gz, built
|
||||
# on Ubuntu 22.04 by package.sh) instead of compiling Rust inside flatpak-builder: no network
|
||||
# during the build, and the same binaries as the AppImage/deb/rpm. The binaries need glibc >= 2.35
|
||||
# and load X11, Wayland, xkbcommon and Vulkan at runtime; the freedesktop runtime has all of
|
||||
# them, and the GPU driver comes from its org.freedesktop.Platform.GL extension.
|
||||
id: ai.storyteller.solvecraft
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: '26.08'
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: solvecraft
|
||||
finish-args:
|
||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
# X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
|
||||
- --share=ipc
|
||||
# GPU access for the wgpu viewport (Vulkan, or GL as a fallback).
|
||||
- --device=dri
|
||||
# Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend), so picked
|
||||
# files work anywhere without broad filesystem access. Documents is granted so files passed by
|
||||
# path (the command line, recent designs) work in the usual place.
|
||||
- --filesystem=xdg-documents
|
||||
# Deliberately not granted: network (the optional --control server listens on 127.0.0.1 in
|
||||
# the sandbox; `flatpak override --user --share=network ai.storyteller.solvecraft` exposes it to
|
||||
# host agents) and home/host filesystem access.
|
||||
modules:
|
||||
- name: solvecraft
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- install -Dm755 bin/solvecraft /app/bin/solvecraft
|
||||
- install -Dm755 bin/solvecraft-cli /app/bin/solvecraft-cli
|
||||
- install -Dm644 share/applications/ai.storyteller.solvecraft.desktop /app/share/applications/ai.storyteller.solvecraft.desktop
|
||||
- install -Dm644 share/mime/packages/ai.storyteller.solvecraft.xml /app/share/mime/packages/ai.storyteller.solvecraft.xml
|
||||
# Already has the release's version and date substituted by package.sh.
|
||||
- install -Dm644 share/metainfo/ai.storyteller.solvecraft.metainfo.xml /app/share/metainfo/ai.storyteller.solvecraft.metainfo.xml
|
||||
- mkdir -p /app/share/icons /app/share/doc && cp -R share/icons/hicolor /app/share/icons/ && cp -R share/doc/solvecraft /app/share/doc/
|
||||
sources:
|
||||
- type: dir
|
||||
path: stage
|
||||
@@ -0,0 +1,74 @@
|
||||
# .deb and .rpm packages (nfpm: https://nfpm.goreleaser.com). Run by package.sh, which stages
|
||||
# the file tree, substitutes its path for ${STAGE} (nfpm doesn't expand env vars in `src`) and
|
||||
# exports VERSION and NFPM_ARCH. Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0).
|
||||
name: solvecraft
|
||||
arch: ${NFPM_ARCH}
|
||||
platform: linux
|
||||
version: ${VERSION}
|
||||
# semver: 0.2.0-rc.1 becomes 0.2.0~rc.1 in deb/rpm, so pre-releases sort before the release.
|
||||
version_schema: semver
|
||||
release: 1
|
||||
section: graphics
|
||||
priority: optional
|
||||
maintainer: ${SOLVECRAFT_MAINTAINER}
|
||||
vendor: SolveCraft contributors
|
||||
homepage: https://bherbruck.github.io/solvecraft/
|
||||
license: MIT OR Apache-2.0
|
||||
description: |-
|
||||
Open-source parametric 3D CAD in the style of Fusion
|
||||
Sketches with a constraint solver, a parametric feature timeline, components and
|
||||
joints, sheet metal; STEP, IGES, 3MF, STL and OBJ. Includes solvecraft-cli, a
|
||||
headless command runner and MCP server for AI agents.
|
||||
|
||||
# The binaries link only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the
|
||||
# GPU (Vulkan, EGL/GL) are loaded at runtime with dlopen, so they aren't ELF NEEDED entries and
|
||||
# the package tools can't find them: list them here.
|
||||
overrides:
|
||||
deb:
|
||||
depends:
|
||||
- libc6 (>= 2.35)
|
||||
- libgcc-s1
|
||||
- libxkbcommon0
|
||||
- libxkbcommon-x11-0
|
||||
- libwayland-client0
|
||||
- libx11-6
|
||||
- libx11-xcb1
|
||||
- libxcb1
|
||||
- libxcursor1
|
||||
- libxi6
|
||||
- libvulkan1 | libegl1
|
||||
recommends:
|
||||
- mesa-vulkan-drivers
|
||||
- libegl-mesa0
|
||||
- xdg-desktop-portal
|
||||
rpm:
|
||||
# Soname provides rather than package names, so the same requirements resolve on Fedora/RHEL
|
||||
# and openSUSE.
|
||||
depends:
|
||||
- libxkbcommon.so.0()(64bit)
|
||||
- libxkbcommon-x11.so.0()(64bit)
|
||||
- libwayland-client.so.0()(64bit)
|
||||
- libX11.so.6()(64bit)
|
||||
- libX11-xcb.so.1()(64bit)
|
||||
- libxcb.so.1()(64bit)
|
||||
- libXcursor.so.1()(64bit)
|
||||
- libXi.so.6()(64bit)
|
||||
- (libvulkan.so.1()(64bit) or libEGL.so.1()(64bit))
|
||||
recommends:
|
||||
- mesa-vulkan-drivers
|
||||
- xdg-desktop-portal
|
||||
|
||||
contents:
|
||||
- src: ${STAGE}/usr/bin/solvecraft
|
||||
dst: /usr/bin/solvecraft
|
||||
file_info: { mode: 0755 }
|
||||
- src: ${STAGE}/usr/bin/solvecraft-cli
|
||||
dst: /usr/bin/solvecraft-cli
|
||||
file_info: { mode: 0755 }
|
||||
- src: ${STAGE}/usr/share/
|
||||
dst: /usr/share/
|
||||
type: tree
|
||||
|
||||
scripts:
|
||||
postinstall: packaging/linux/postinst.sh
|
||||
postremove: packaging/linux/postinst.sh
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and package SolveCraft for Linux (<arch> is x86_64 or aarch64):
|
||||
#
|
||||
# $DIST/solvecraft-<version>-linux-<arch>.AppImage any distro with glibc >= the build host's
|
||||
# $DIST/solvecraft-<version>-linux-<arch>.AppImage.zsync delta updates (needs zsyncmake)
|
||||
# $DIST/solvecraft-<version>-linux-<arch>.deb Debian, Ubuntu, Mint, Pop!_OS, ...
|
||||
# $DIST/solvecraft-<version>-linux-<arch>.rpm Fedora, openSUSE, RHEL, ...
|
||||
# $DIST/solvecraft-<version>-linux-<arch>.tar.gz plain FHS-style tree (bin/, share/)
|
||||
#
|
||||
# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"]
|
||||
#
|
||||
# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage
|
||||
# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04,
|
||||
# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli,
|
||||
# zsyncmake (the zsync package) for the AppImage's .zsync.
|
||||
# Adapted from PhotoCraft's packaging (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
set -euo pipefail
|
||||
# shellcheck source=../env.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
|
||||
HERE="$ROOT/packaging/linux"
|
||||
|
||||
SKIP_BUILD=0
|
||||
FORMATS="appimage deb rpm tar"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--skip-build) SKIP_BUILD=1; shift ;;
|
||||
--formats) FORMATS="$2"; shift 2 ;;
|
||||
-h | --help) sed -n '2,16p' "$0"; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) DEB_ARCH=amd64 ;;
|
||||
aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;;
|
||||
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
|
||||
esac
|
||||
export SOLVECRAFT_MAINTAINER="${SOLVECRAFT_MAINTAINER:-SolveCraft contributors <https://github.com/bherbruck/solvecraft>}"
|
||||
BASENAME="solvecraft-$VERSION-linux-$ARCH"
|
||||
|
||||
echo "==> SolveCraft $VERSION for Linux $ARCH ($FORMATS)"
|
||||
|
||||
if [ "$SKIP_BUILD" = 0 ]; then
|
||||
(cd "$ROOT" && cargo build --release --locked -p solvecraft -p solvecraft-cli)
|
||||
fi
|
||||
BIN="$CARGO_TARGET_DIR/release"
|
||||
WORK="$CARGO_TARGET_DIR/linux-package"
|
||||
STAGE="$WORK/root"
|
||||
rm -rf "$WORK"
|
||||
|
||||
# ---- stage an FHS tree (shared by every format) -------------------------------------------------
|
||||
install -Dm755 "$BIN/solvecraft" "$STAGE/usr/bin/solvecraft"
|
||||
install -Dm755 "$BIN/solvecraft-cli" "$STAGE/usr/bin/solvecraft-cli"
|
||||
strip "$STAGE/usr/bin/solvecraft" "$STAGE/usr/bin/solvecraft-cli" 2>/dev/null || true
|
||||
install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop"
|
||||
install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml"
|
||||
mkdir -p "$STAGE/usr/share/metainfo"
|
||||
sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$SOLVECRAFT_BUILD_DATE/g" \
|
||||
"$HERE/$APP_ID.metainfo.xml.in" >"$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
|
||||
make_icons "$STAGE/usr/bin/solvecraft-cli" "$WORK/icons"
|
||||
mkdir -p "$STAGE/usr/share/icons"
|
||||
cp -R "$WORK/icons/hicolor" "$STAGE/usr/share/icons/"
|
||||
mkdir -p "$STAGE/usr/share/doc/solvecraft"
|
||||
copy_docs "$STAGE/usr/share/doc/solvecraft"
|
||||
|
||||
if command -v desktop-file-validate >/dev/null; then
|
||||
desktop-file-validate "$STAGE/usr/share/applications/$APP_ID.desktop"
|
||||
fi
|
||||
if command -v appstreamcli >/dev/null; then
|
||||
appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
|
||||
fi
|
||||
|
||||
has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
|
||||
|
||||
# ---- .tar.gz ------------------------------------------------------------------------------------
|
||||
if has tar; then
|
||||
mkdir -p "$WORK/tar"
|
||||
cp -R "$STAGE/usr" "$WORK/tar/$BASENAME"
|
||||
tar -C "$WORK/tar" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME"
|
||||
echo "wrote $DIST/$BASENAME.tar.gz"
|
||||
fi
|
||||
|
||||
# ---- .deb / .rpm --------------------------------------------------------------------------------
|
||||
if has deb || has rpm; then
|
||||
command -v nfpm >/dev/null || { echo "error: nfpm not found (https://nfpm.goreleaser.com/install/)" >&2; exit 1; }
|
||||
export VERSION
|
||||
export NFPM_ARCH="$DEB_ARCH"
|
||||
# nfpm expands env vars in fields like `version` and `arch`, but not in `contents[].src`.
|
||||
sed "s|\${STAGE}|$STAGE|g" "$HERE/nfpm.yaml" >"$WORK/nfpm.yaml"
|
||||
for fmt in deb rpm; do
|
||||
if has "$fmt"; then (cd "$ROOT" && nfpm package -f "$WORK/nfpm.yaml" -p "$fmt" -t "$DIST/$BASENAME.$fmt"); fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ---- AppImage -----------------------------------------------------------------------------------
|
||||
if has appimage; then
|
||||
APPDIR="$WORK/SolveCraft.AppDir"
|
||||
cp -R "$STAGE" "$APPDIR"
|
||||
mv "$APPDIR/usr/share/doc" "$WORK/doc-unused"
|
||||
# AppRun is a script, not a symlink: it installs the .desktop entry and icons into
|
||||
# $XDG_DATA_HOME before launching, so Wayland compositors can resolve the window's app_id to
|
||||
# our icon.
|
||||
install -Dm755 "$HERE/AppRun" "$APPDIR/AppRun"
|
||||
cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop"
|
||||
cp "$WORK/icons/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png"
|
||||
ln -s "$APP_ID.png" "$APPDIR/.DirIcon"
|
||||
|
||||
TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
|
||||
if [ -z "$TOOL" ]; then
|
||||
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
|
||||
if [ ! -x "$TOOL" ]; then
|
||||
curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
|
||||
chmod +x "$TOOL"
|
||||
fi
|
||||
fi
|
||||
OUT="$DIST/$BASENAME.AppImage"
|
||||
APPDIR="$(cd "$APPDIR" && pwd)"
|
||||
TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")"
|
||||
rm -f "$OUT.zsync"
|
||||
# Update information: AppImageUpdate and the like fetch only the changed blocks of a newer
|
||||
# release through the .zsync published next to each AppImage. `latest` is the newest published
|
||||
# release that is not a pre-release.
|
||||
REPO="${GITHUB_REPOSITORY:-bherbruck/solvecraft}"
|
||||
UPDATE_INFO="gh-releases-zsync|${REPO%%/*}|${REPO#*/}|latest|solvecraft-*-linux-$ARCH.AppImage.zsync"
|
||||
# Extract-and-run: works without FUSE (containers, CI). With zsyncmake on the host appimagetool
|
||||
# also writes the .zsync, into its working directory, hence the cd.
|
||||
(cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT")
|
||||
echo "wrote $OUT"
|
||||
if [ -s "$OUT.zsync" ]; then
|
||||
echo "wrote $OUT.zsync"
|
||||
else
|
||||
warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it"
|
||||
fi
|
||||
fi
|
||||
|
||||
"$STAGE/usr/bin/solvecraft-cli" --version
|
||||
echo "==> done"
|
||||
ls -lh "$DIST"
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# deb/rpm post-install and post-remove: refresh the desktop, MIME and icon caches.
|
||||
# Every tool is optional; missing ones are skipped.
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
update-desktop-database -q /usr/share/applications || true
|
||||
fi
|
||||
if command -v update-mime-database >/dev/null 2>&1; then
|
||||
update-mime-database /usr/share/mime || true
|
||||
fi
|
||||
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
|
||||
fi
|
||||
exit 0
|
||||
@@ -0,0 +1,64 @@
|
||||
<?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">
|
||||
<!-- Template: packaging/macos/package.sh substitutes @VERSION@ (full, e.g. 0.2.0-rc.1) and
|
||||
@SHORT_VERSION@ (numeric X.Y.Z). Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0). -->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>SolveCraft</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>SolveCraft</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>SolveCraft</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>ai.storyteller.solvecraft</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>@SHORT_VERSION@</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>@SHORT_VERSION@</string>
|
||||
<key>SolveCraftVersion</key>
|
||||
<string>@VERSION@</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>SolveCraft</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.graphics-design</string>
|
||||
<!-- Must match MACOSX_DEPLOYMENT_TARGET in package.sh. -->
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||
<true/>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>SolveCraft design</string>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array><string>solvecraft</string></array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Owner</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>STEP, IGES and mesh files</string>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array><string>step</string><string>stp</string><string>igs</string><string>iges</string><string>3mf</string><string>stl</string></array>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and package SolveCraft for macOS:
|
||||
#
|
||||
# $DIST/solvecraft-<version>-macos-<arch>.dmg SolveCraft.app on a drag-to-Applications DMG
|
||||
# $DIST/solvecraft-cli-<version>-macos-<arch>.zip the headless CLI with the licence files
|
||||
#
|
||||
# Usage: packaging/macos/package.sh [--arch universal|aarch64|x86_64] [--skip-build]
|
||||
#
|
||||
# The app is ad-hoc signed (not notarized): the first launch needs right-click > Open.
|
||||
# Adapted from PhotoCraft's packaging (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
set -euo pipefail
|
||||
# shellcheck source=../env.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
|
||||
HERE="$ROOT/packaging/macos"
|
||||
|
||||
ARCH=universal
|
||||
SKIP_BUILD=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--arch) ARCH="$2"; shift 2 ;;
|
||||
--skip-build) SKIP_BUILD=1; shift ;;
|
||||
-h | --help) sed -n '2,10p' "$0"; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
case "$ARCH" in
|
||||
universal) TARGETS=(aarch64-apple-darwin x86_64-apple-darwin) ;;
|
||||
aarch64) TARGETS=(aarch64-apple-darwin) ;;
|
||||
x86_64) TARGETS=(x86_64-apple-darwin) ;;
|
||||
*) echo "unknown --arch $ARCH" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# Keep in sync with LSMinimumSystemVersion in Info.plist.in.
|
||||
export MACOSX_DEPLOYMENT_TARGET=11.0
|
||||
SHORT_VERSION="${VERSION%%-*}"
|
||||
WORK="$CARGO_TARGET_DIR/macos-package"
|
||||
APP="$WORK/SolveCraft.app"
|
||||
DMG="$DIST/solvecraft-$VERSION-macos-$ARCH.dmg"
|
||||
CLI_ZIP="$DIST/solvecraft-cli-$VERSION-macos-$ARCH.zip"
|
||||
|
||||
echo "==> SolveCraft $VERSION for macOS ($ARCH)"
|
||||
if [ "$SKIP_BUILD" = 0 ]; then
|
||||
args=()
|
||||
for t in "${TARGETS[@]}"; do args+=(--target "$t"); done
|
||||
(cd "$ROOT" && cargo build --release --locked -p solvecraft -p solvecraft-cli "${args[@]}")
|
||||
fi
|
||||
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/bin"
|
||||
for bin in solvecraft solvecraft-cli; do
|
||||
inputs=()
|
||||
for t in "${TARGETS[@]}"; do inputs+=("$CARGO_TARGET_DIR/$t/release/$bin"); done
|
||||
lipo -create -output "$WORK/bin/$bin" "${inputs[@]}"
|
||||
lipo -info "$WORK/bin/$bin"
|
||||
done
|
||||
|
||||
# ---- SolveCraft.app ----------------------------------------------------------------------------
|
||||
echo "==> assembling $APP"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
cp "$WORK/bin/solvecraft" "$APP/Contents/MacOS/SolveCraft"
|
||||
# The icon, drawn by the CLI (no image files are committed).
|
||||
SET="$WORK/SolveCraft.iconset"
|
||||
mkdir -p "$SET"
|
||||
for s in 16 32 128 256 512; do
|
||||
"$WORK/bin/solvecraft-cli" icon --size "$s" --out "$SET/icon_${s}x${s}.png" >/dev/null
|
||||
"$WORK/bin/solvecraft-cli" icon --size $((s * 2)) --out "$SET/icon_${s}x${s}@2x.png" >/dev/null
|
||||
done
|
||||
iconutil -c icns -o "$APP/Contents/Resources/SolveCraft.icns" "$SET"
|
||||
mkdir -p "$APP/Contents/Resources/Licenses"
|
||||
copy_docs "$APP/Contents/Resources/Licenses"
|
||||
sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" "$HERE/Info.plist.in" >"$APP/Contents/Info.plist"
|
||||
plutil -lint "$APP/Contents/Info.plist"
|
||||
printf 'APPL????' >"$APP/Contents/PkgInfo"
|
||||
# Ad-hoc signature over the whole bundle (Apple silicon refuses unsigned code).
|
||||
codesign --force --sign - --timestamp=none "$APP"
|
||||
codesign --verify --strict --deep --verbose=2 "$APP"
|
||||
|
||||
# ---- DMG ---------------------------------------------------------------------------------------
|
||||
echo "==> building $DMG"
|
||||
STAGE="$WORK/dmg"
|
||||
mkdir -p "$STAGE"
|
||||
ditto "$APP" "$STAGE/SolveCraft.app"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
rm -f "$DMG" "$WORK/raw.dmg"
|
||||
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
|
||||
# which is flaky on CI runners ("Resource busy").
|
||||
hdiutil makehybrid -hfs -hfs-volume-name "SolveCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
|
||||
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
|
||||
rm -f "$WORK/raw.dmg"
|
||||
|
||||
# Check the image as a user gets it: mount it and look inside.
|
||||
MNT="$(mktemp -d)"
|
||||
hdiutil attach -readonly -nobrowse -mountpoint "$MNT" "$DMG"
|
||||
ls -la "$MNT" "$MNT/SolveCraft.app/Contents" "$MNT/SolveCraft.app/Contents/MacOS" "$MNT/SolveCraft.app/Contents/Resources"
|
||||
test -L "$MNT/Applications" && test "$(readlink "$MNT/Applications")" = /Applications
|
||||
plutil -lint "$MNT/SolveCraft.app/Contents/Info.plist"
|
||||
case "$ARCH" in universal) WANT=(arm64 x86_64) ;; aarch64) WANT=(arm64) ;; *) WANT=(x86_64) ;; esac
|
||||
lipo "$MNT/SolveCraft.app/Contents/MacOS/SolveCraft" -verify_arch "${WANT[@]}"
|
||||
test -s "$MNT/SolveCraft.app/Contents/Resources/Licenses/THIRD-PARTY-LICENSES.txt"
|
||||
# Not --strict: the mounted HFS volume adds Finder info, which strict verification rejects.
|
||||
codesign --verify --deep --verbose=2 "$MNT/SolveCraft.app"
|
||||
"$MNT/SolveCraft.app/Contents/MacOS/SolveCraft" --version
|
||||
hdiutil detach "$MNT"
|
||||
|
||||
# ---- CLI ---------------------------------------------------------------------------------------
|
||||
echo "==> building $CLI_ZIP"
|
||||
CLI_DIR="$WORK/solvecraft-cli-$VERSION-macos-$ARCH"
|
||||
mkdir -p "$CLI_DIR"
|
||||
cp "$WORK/bin/solvecraft-cli" "$CLI_DIR/"
|
||||
codesign --force --sign - --timestamp=none --identifier ai.storyteller.solvecraft-cli "$CLI_DIR/solvecraft-cli"
|
||||
copy_docs "$CLI_DIR"
|
||||
rm -f "$CLI_ZIP"
|
||||
ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP"
|
||||
|
||||
"$WORK/bin/solvecraft-cli" --version
|
||||
echo "==> done"
|
||||
ls -lh "$DMG" "$CLI_ZIP"
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the browser version and zip it: $DIST/solvecraft-web-<version>.zip
|
||||
#
|
||||
# Usage: packaging/web/package.sh [--skip-build]
|
||||
#
|
||||
# Needs: trunk and the wasm32-unknown-unknown target. The zip holds a self-contained static site
|
||||
# in solvecraft-web-<version>/ that works from any URL path (public_url = "./" in Trunk.toml).
|
||||
# Saving designs needs HTTPS or localhost (the browser's private file storage).
|
||||
# Adapted from PhotoCraft's packaging (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
set -euo pipefail
|
||||
# shellcheck source=../env.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
|
||||
|
||||
if [ "${1:-}" != "--skip-build" ]; then
|
||||
command -v trunk >/dev/null || { echo "error: trunk not found (cargo install trunk --locked)" >&2; exit 1; }
|
||||
(cd "$ROOT/apps/solvecraft-web" && trunk build --release)
|
||||
fi
|
||||
|
||||
SITE="$ROOT/dist/web"
|
||||
[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; }
|
||||
# Paths must be relative so the site works under any prefix.
|
||||
if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then
|
||||
echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NAME="solvecraft-web-$VERSION"
|
||||
WORK="$CARGO_TARGET_DIR/web-package"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/$NAME"
|
||||
cp -R "$SITE/." "$WORK/$NAME/"
|
||||
copy_docs "$WORK/$NAME"
|
||||
rm -f "$DIST/$NAME.zip"
|
||||
(cd "$WORK" && zip -qr9 "$DIST/$NAME.zip" "$NAME")
|
||||
echo "wrote $DIST/$NAME.zip"
|
||||
ls -lh "$DIST/$NAME.zip"
|
||||
@@ -0,0 +1,133 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build and package SolveCraft for Windows.
|
||||
|
||||
.DESCRIPTION
|
||||
Produces, in $env:DIST (default: dist/release):
|
||||
solvecraft-<version>-windows-<arch>.msi per-machine installer (WiX v5)
|
||||
solvecraft-<version>-windows-<arch>-portable.zip solvecraft.exe + solvecraft-cli.exe + licences
|
||||
SolveCraft-<version>-windows-<arch>.exe the app alone (x64 only: a plain download)
|
||||
|
||||
The binaries link the C runtime statically (+crt-static), so nothing else needs installing.
|
||||
The icon comes from $env:SOLVECRAFT_ICONS (solvecraft.ico, made by `solvecraft-cli icon`), or
|
||||
is drawn by the CLI just built when this machine can run it. Nothing is signed.
|
||||
|
||||
Needs: Rust (MSVC toolchain + the target) and WiX v5:
|
||||
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
|
||||
|
||||
Adapted from PhotoCraft's packaging (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
|
||||
.EXAMPLE
|
||||
pwsh packaging/windows/package.ps1 -Arch x64
|
||||
pwsh packaging/windows/package.ps1 -Arch arm64 # cross-compiled; needs the MSVC ARM64 build tools
|
||||
#>
|
||||
param(
|
||||
[ValidateSet('x64', 'x86', 'arm64')] [string] $Arch = 'x64',
|
||||
[switch] $SkipBuild
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
|
||||
function Invoke-Native([string] $What, [scriptblock] $Block) {
|
||||
Write-Output "==> $What"
|
||||
& $Block
|
||||
if ($LASTEXITCODE -ne 0) { throw "$What failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
# The version lives in one place: [workspace.package] version in the root Cargo.toml.
|
||||
$Version = $env:SOLVECRAFT_VERSION
|
||||
if (-not $Version) {
|
||||
$inPkg = $false
|
||||
foreach ($line in Get-Content (Join-Path $Root 'Cargo.toml')) {
|
||||
if ($line -match '^\s*\[') { $inPkg = ($line.Trim() -eq '[workspace.package]'); continue }
|
||||
if ($inPkg -and $line -match '^\s*version\s*=\s*"([^"]+)"') { $Version = $Matches[1]; break }
|
||||
}
|
||||
}
|
||||
if (-not $Version) { throw 'could not read [workspace.package] version from Cargo.toml' }
|
||||
# MSI ProductVersion is numeric (major.minor.build); pre-release tags are dropped there.
|
||||
$MsiVersion = ($Version -split '-')[0]
|
||||
|
||||
$Target = switch ($Arch) { 'x64' { 'x86_64-pc-windows-msvc' } 'x86' { 'i686-pc-windows-msvc' } 'arm64' { 'aarch64-pc-windows-msvc' } }
|
||||
$Dist = if ($env:DIST) { $env:DIST } else { Join-Path $Root 'dist\release' }
|
||||
$TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root 'target' }
|
||||
New-Item -ItemType Directory -Force -Path $Dist | Out-Null
|
||||
|
||||
Write-Output "SolveCraft $Version for Windows $Arch ($Target)"
|
||||
|
||||
if (-not $SkipBuild) {
|
||||
# Static CRT: no VC++ redistributable needed. Scoped to the target so host build scripts and
|
||||
# proc-macros are unaffected.
|
||||
$flagVar = 'CARGO_TARGET_' + ($Target.ToUpper() -replace '-', '_') + '_RUSTFLAGS'
|
||||
[Environment]::SetEnvironmentVariable($flagVar, '-C target-feature=+crt-static')
|
||||
Invoke-Native "cargo build ($Target)" { cargo build --release --locked -p solvecraft -p solvecraft-cli --target $Target }
|
||||
}
|
||||
$Bin = Join-Path $TargetDir "$Target\release"
|
||||
|
||||
# Check both binaries' PE headers: Machine must match -Arch; Subsystem 2 = GUI (the app opens no
|
||||
# console window), 3 = console (the CLI's output reaches the terminal).
|
||||
function Get-PeHeader([string] $Path) {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Path)
|
||||
$pe = [BitConverter]::ToInt32($bytes, 0x3C)
|
||||
return @{ Machine = [BitConverter]::ToUInt16($bytes, $pe + 4); Subsystem = [BitConverter]::ToUInt16($bytes, $pe + 0x5C) }
|
||||
}
|
||||
$Machine = switch ($Arch) { 'x64' { 0x8664 } 'x86' { 0x14C } 'arm64' { 0xAA64 } }
|
||||
foreach ($check in @(@('solvecraft.exe', 2), @('solvecraft-cli.exe', 3))) {
|
||||
$h = Get-PeHeader (Join-Path $Bin $check[0])
|
||||
if ($h.Machine -ne $Machine) { throw "$($check[0]) is for machine 0x$('{0:X}' -f $h.Machine), expected 0x$('{0:X}' -f $Machine) ($Arch)" }
|
||||
if ($h.Subsystem -ne $check[1]) { throw "$($check[0]) has PE subsystem $($h.Subsystem), expected $($check[1])" }
|
||||
Write-Output "ok $($check[0]): $Arch, PE subsystem $($h.Subsystem)"
|
||||
}
|
||||
$Stage = Join-Path $TargetDir "windows-package\$Arch"
|
||||
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
|
||||
Copy-Item (Join-Path $Bin 'solvecraft.exe'), (Join-Path $Bin 'solvecraft-cli.exe') $Stage
|
||||
foreach ($f in 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'THIRD-PARTY-LICENSES.txt') { Copy-Item (Join-Path $Root $f) $Stage }
|
||||
|
||||
$HostArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
|
||||
$CanRun = ($Arch -ne 'arm64' -or $HostArch -eq 'arm64')
|
||||
$Icon = if ($env:SOLVECRAFT_ICONS) { Join-Path $env:SOLVECRAFT_ICONS 'solvecraft.ico' } else { Join-Path $Stage 'solvecraft.ico' }
|
||||
if (-not (Test-Path $Icon)) {
|
||||
if (-not $CanRun) { throw "no icon: set SOLVECRAFT_ICONS to a folder with solvecraft.ico (an $Arch CLI can't run on this $HostArch machine)" }
|
||||
Invoke-Native 'draw the icon' { & (Join-Path $Stage 'solvecraft-cli.exe') icon --out $Icon }
|
||||
}
|
||||
|
||||
# ---- MSI ---------------------------------------------------------------------------------------
|
||||
$Msi = Join-Path $Dist "solvecraft-$Version-windows-$Arch.msi"
|
||||
Invoke-Native 'wix build' {
|
||||
wix build (Join-Path $PSScriptRoot 'solvecraft.wxs') -arch $Arch `
|
||||
-ext WixToolset.UI.wixext -ext WixToolset.Util.wixext `
|
||||
-culture en-US -loc (Join-Path $PSScriptRoot 'solvecraft.en-us.wxl') `
|
||||
-d "Version=$MsiVersion" -d "DisplayVersion=$Version" -d "BinDir=$Stage" -d "IconPath=$Icon" `
|
||||
-o $Msi
|
||||
}
|
||||
Invoke-Native 'MSI shortcut icon validation (ICE50)' {
|
||||
wix msi validate $Msi -ice ICE50 -intermediateFolder (Join-Path $Stage 'msi-validation')
|
||||
}
|
||||
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb'))
|
||||
|
||||
# ---- portable zip ------------------------------------------------------------------------------
|
||||
$Portable = Join-Path $TargetDir "windows-package\solvecraft-$Version-windows-$Arch-portable"
|
||||
Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $Portable | Out-Null
|
||||
Copy-Item (Join-Path $Stage '*.exe') $Portable
|
||||
foreach ($f in 'README.md', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'THIRD-PARTY-LICENSES.txt') { Copy-Item (Join-Path $Root $f) $Portable }
|
||||
$Zip = Join-Path $Dist "solvecraft-$Version-windows-$Arch-portable.zip"
|
||||
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
|
||||
Compress-Archive -Path $Portable -DestinationPath $Zip
|
||||
|
||||
# ---- the app as a plain download (x64) ---------------------------------------------------------
|
||||
$Outputs = @($Msi, $Zip)
|
||||
if ($Arch -eq 'x64') {
|
||||
$Exe = Join-Path $Dist "SolveCraft-$Version-windows-$Arch.exe"
|
||||
Copy-Item (Join-Path $Stage 'solvecraft.exe') $Exe
|
||||
$Outputs += $Exe
|
||||
}
|
||||
|
||||
if ($CanRun) {
|
||||
Invoke-Native 'solvecraft-cli --version' { & (Join-Path $Stage 'solvecraft-cli.exe') --version }
|
||||
} else {
|
||||
Write-Output "skipping solvecraft-cli --version: an $Arch build doesn't run on this $HostArch machine"
|
||||
}
|
||||
Get-Item $Outputs | Format-Table Name, Length
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Overrides for the setup wizard's WixUI strings, so its pages name the version being installed
|
||||
([SolvecraftVersion] is the full version, pre-release tag included, set in solvecraft.wxs).
|
||||
Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
-->
|
||||
<WixLocalization Culture="en-US" xmlns="http://wixtoolset.org/schemas/v4/wxl">
|
||||
<String Id="WelcomeDlgTitle" Value="{\WixUI_Font_Bigger}Welcome to the [ProductName] [SolvecraftVersion] Setup Wizard" />
|
||||
<String Id="WelcomeDlgDescription" Value="The Setup Wizard will install [ProductName] version [SolvecraftVersion] on your computer. Click Next to continue or Cancel to exit the Setup Wizard." />
|
||||
<String Id="VerifyReadyDlgInstallTitle" Value="{\WixUI_Font_Title}Ready to install [ProductName] [SolvecraftVersion]" />
|
||||
<String Id="ExitDialogTitle" Value="{\WixUI_Font_Bigger}Completed the [ProductName] [SolvecraftVersion] Setup Wizard" />
|
||||
</WixLocalization>
|
||||
@@ -0,0 +1,122 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
SolveCraft MSI (WiX Toolset v5, plus the UI and Util extensions). Built by package.ps1:
|
||||
|
||||
wix build solvecraft.wxs -arch x64 -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext
|
||||
-culture en-US -loc solvecraft.en-us.wxl -d Version=1.2.3 -d DisplayVersion=1.2.3-rc.1
|
||||
-d BinDir=... -d IconPath=... -o out.msi
|
||||
|
||||
Per-machine install with a setup wizard: Welcome, install folder (Program Files\SolveCraft by
|
||||
default, remembered for upgrades), Ready, progress, and a Finish page with a ticked "Launch
|
||||
SolveCraft" box. Installs a Start Menu shortcut, solvecraft-cli, the licence files (including
|
||||
THIRD-PARTY-LICENSES.txt), makes SolveCraft the owner of `.solvecraft` and offers it under
|
||||
"Open with" for STEP, IGES, 3MF and STL. `msiexec /qn` installs silently.
|
||||
Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
-->
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
|
||||
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
||||
<Package
|
||||
Name="SolveCraft"
|
||||
Manufacturer="SolveCraft contributors"
|
||||
Version="$(var.Version)"
|
||||
UpgradeCode="6A145FD6-D285-428D-AE84-A69570CDF4DB"
|
||||
Scope="perMachine"
|
||||
Compressed="yes"
|
||||
Language="1033">
|
||||
<SummaryInformation Description="SolveCraft parametric 3D CAD" Manufacturer="SolveCraft contributors" />
|
||||
<!-- Same-version upgrades let successive pre-releases of one X.Y.Z replace each other. -->
|
||||
<MajorUpgrade AllowSameVersionUpgrades="yes"
|
||||
DowngradeErrorMessage="A newer version of SolveCraft is already installed." />
|
||||
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
|
||||
|
||||
<!-- Advertised shortcuts to an .exe need an .exe icon identifier (ICE50). -->
|
||||
<Icon Id="SolvecraftIcon.exe" SourceFile="$(var.IconPath)" />
|
||||
<Property Id="ARPPRODUCTICON" Value="SolvecraftIcon.exe" />
|
||||
<Property Id="ARPURLINFOABOUT" Value="https://bherbruck.github.io/solvecraft/" />
|
||||
<Property Id="ARPURLUPDATEINFO" Value="https://github.com/bherbruck/solvecraft/releases" />
|
||||
<Property Id="ARPHELPLINK" Value="https://github.com/bherbruck/solvecraft/issues" />
|
||||
|
||||
<StandardDirectory Id="ProgramFiles6432Folder">
|
||||
<Directory Id="INSTALLFOLDER" Name="SolveCraft" />
|
||||
</StandardDirectory>
|
||||
<StandardDirectory Id="ProgramMenuFolder" />
|
||||
|
||||
<!-- An upgrade installs where the user put the previous version (written by SolvecraftApp). -->
|
||||
<Property Id="INSTALLFOLDER">
|
||||
<RegistrySearch Id="PreviousInstallFolder" Root="HKLM" Key="Software\SolveCraft" Name="InstallDir" Type="raw" />
|
||||
</Property>
|
||||
|
||||
<Feature Id="Main" Title="SolveCraft" Level="1">
|
||||
<ComponentGroupRef Id="SolvecraftFiles" />
|
||||
</Feature>
|
||||
|
||||
<!-- WixUI_InstallDir without its licence page (MIT OR Apache-2.0: nothing to accept). -->
|
||||
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
|
||||
<UI>
|
||||
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg" Order="2" Condition="NOT Installed" />
|
||||
<Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2" />
|
||||
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchSolvecraft"
|
||||
Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT Installed" />
|
||||
</UI>
|
||||
|
||||
<Property Id="SolvecraftVersion" Value="$(var.DisplayVersion)" />
|
||||
<Property Id="WIXUI_EXITDIALOGOPTIONALTEXT" Value="SolveCraft $(var.DisplayVersion) is installed. You can start it any time from the Start menu." />
|
||||
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Launch SolveCraft" />
|
||||
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1" />
|
||||
<Property Id="WixShellExecTarget" Value="[#SolvecraftExe]" />
|
||||
<!-- Runs in the wizard's own (unelevated) process, so the app starts as the user. -->
|
||||
<CustomAction Id="LaunchSolvecraft" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)" DllEntry="WixShellExec" Impersonate="yes" Return="ignore" />
|
||||
</Package>
|
||||
|
||||
<Fragment>
|
||||
<ComponentGroup Id="SolvecraftFiles" Directory="INSTALLFOLDER">
|
||||
<Component Id="SolvecraftApp" Guid="290BEF27-C200-4069-AA43-7DE6A39CDB0A">
|
||||
<File Id="SolvecraftExe" Source="$(var.BinDir)\solvecraft.exe" KeyPath="yes">
|
||||
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="SolveCraft"
|
||||
Description="SolveCraft parametric 3D CAD" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
|
||||
Icon="SolvecraftIcon.exe" IconIndex="0" />
|
||||
</File>
|
||||
|
||||
<!-- .solvecraft: SolveCraft's own format; SolveCraft is its default app. -->
|
||||
<ProgId Id="Solvecraft.Design" Description="SolveCraft Design" Icon="SolvecraftExe" IconIndex="0">
|
||||
<Extension Id="solvecraft" ContentType="application/x-solvecraft">
|
||||
<Verb Id="open" Command="Open" TargetFile="SolvecraftExe" Argument=""%1"" />
|
||||
</Extension>
|
||||
</ProgId>
|
||||
<!-- STEP, IGES and meshes: offered under "Open with", never forced as the default. -->
|
||||
<ProgId Id="Solvecraft.Model" Description="3D model" Icon="SolvecraftExe" IconIndex="0" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Solvecraft.Model\shell\open\command" Value=""[#SolvecraftExe]" "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.step\OpenWithProgids" Name="Solvecraft.Model" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.stp\OpenWithProgids" Name="Solvecraft.Model" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.igs\OpenWithProgids" Name="Solvecraft.Model" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.iges\OpenWithProgids" Name="Solvecraft.Model" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.3mf\OpenWithProgids" Name="Solvecraft.Model" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.stl\OpenWithProgids" Name="Solvecraft.Model" Value="" Type="string" />
|
||||
|
||||
<!-- "Open with" app list and the Run dialog (Win+R "solvecraft"). -->
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\solvecraft.exe" Name="FriendlyAppName" Value="SolveCraft" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\solvecraft.exe\shell\open\command" Value=""[#SolvecraftExe]" "%1"" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\solvecraft.exe\SupportedTypes" Name=".solvecraft" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\solvecraft.exe\SupportedTypes" Name=".step" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\solvecraft.exe\SupportedTypes" Name=".stp" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\solvecraft.exe\SupportedTypes" Name=".igs" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\solvecraft.exe\SupportedTypes" Name=".iges" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\solvecraft.exe\SupportedTypes" Name=".3mf" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\solvecraft.exe\SupportedTypes" Name=".stl" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\solvecraft.exe" Value="[#SolvecraftExe]" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\SolveCraft" Name="InstallDir" Value="[INSTALLFOLDER]" Type="string" />
|
||||
</Component>
|
||||
|
||||
<Component Id="SolvecraftCli">
|
||||
<File Id="SolvecraftCliExe" Source="$(var.BinDir)\solvecraft-cli.exe" KeyPath="yes" />
|
||||
</Component>
|
||||
<!-- Several unversioned files: an explicit GUID (WIX0367). -->
|
||||
<Component Id="SolvecraftLicences" Guid="CDEDEE8D-4BE9-4872-A2EA-EE623A6C7E91">
|
||||
<File Id="LicenseMit" Source="$(var.BinDir)\LICENSE-MIT" KeyPath="yes" />
|
||||
<File Id="LicenseApache" Source="$(var.BinDir)\LICENSE-APACHE" />
|
||||
<File Id="Notice" Source="$(var.BinDir)\NOTICE" />
|
||||
<File Id="ThirdParty" Source="$(var.BinDir)\THIRD-PARTY-LICENSES.txt" />
|
||||
</Component>
|
||||
</ComponentGroup>
|
||||
</Fragment>
|
||||
</Wix>
|
||||
@@ -0,0 +1,180 @@
|
||||
//! `cargo xtask licences [--check]`: THIRD-PARTY-LICENSES.txt, the licences of every crate built
|
||||
//! into the shipped programs (solvecraft, solvecraft-cli, solvecraft-web), read from
|
||||
//! `cargo metadata` and the crates' own licence files. The programs embed it (Help ▸ About ▸
|
||||
//! Licences, `solvecraft-cli licences`), so a release download needs no licence files beside it.
|
||||
//! `--check` (part of `cargo xtask ci`) fails when the committed file is out of date, e.g. after a
|
||||
//! dependency changed.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cargo;
|
||||
|
||||
pub const FILE: &str = "THIRD-PARTY-LICENSES.txt";
|
||||
const ROOTS: &[&str] = &["solvecraft", "solvecraft-cli", "solvecraft-web"];
|
||||
|
||||
pub fn run(root: &Path, check: bool) -> Result<(), String> {
|
||||
let text = generate(root)?;
|
||||
let path = root.join(FILE);
|
||||
if check {
|
||||
let old = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
if old.replace("\r\n", "\n") != text {
|
||||
return Err(format!("{FILE} is out of date (dependencies changed): run `cargo xtask licences` and commit it"));
|
||||
}
|
||||
println!("licences: {FILE} is up to date");
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::write(&path, &text).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
println!("licences: wrote {FILE} ({} KB)", text.len() / 1024);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Crate {
|
||||
name: String,
|
||||
version: String,
|
||||
license: String,
|
||||
repository: String,
|
||||
/// (file name, text) of the licence files shipped in the crate.
|
||||
files: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
fn generate(root: &Path) -> Result<String, String> {
|
||||
let out = cargo().args(["metadata", "--format-version", "1", "--locked"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
|
||||
}
|
||||
let meta: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata JSON: {e}"))?;
|
||||
let packages: BTreeMap<&str, &Value> = meta["packages"].as_array().into_iter().flatten().filter_map(|p| Some((p["id"].as_str()?, p))).collect();
|
||||
let nodes: BTreeMap<&str, &Value> =
|
||||
meta["resolve"]["nodes"].as_array().into_iter().flatten().filter_map(|n| Some((n["id"].as_str()?, n))).collect();
|
||||
let members: BTreeSet<&str> = meta["workspace_members"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
|
||||
// Everything reachable from the shipped programs through normal (not dev) dependencies, on any
|
||||
// platform.
|
||||
let mut seen: BTreeSet<&str> = BTreeSet::new();
|
||||
let mut queue: VecDeque<&str> = packages
|
||||
.iter()
|
||||
.filter(|(id, p)| members.contains(*id) && ROOTS.contains(&p["name"].as_str().unwrap_or_default()))
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
while let Some(id) = queue.pop_front() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
for d in nodes.get(id).and_then(|n| n["deps"].as_array()).into_iter().flatten() {
|
||||
let normal = d["dep_kinds"].as_array().into_iter().flatten().any(|k| k["kind"].is_null());
|
||||
if normal && let Some(pkg) = d["pkg"].as_str() {
|
||||
queue.push_back(pkg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut crates = Vec::new();
|
||||
for id in seen {
|
||||
let Some(p) = packages.get(id) else { continue };
|
||||
if members.contains(id) {
|
||||
continue;
|
||||
}
|
||||
let s = |k: &str| p[k].as_str().unwrap_or_default().to_string();
|
||||
let dir = Path::new(p["manifest_path"].as_str().unwrap_or_default()).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
crates.push(Crate {
|
||||
name: s("name"),
|
||||
version: s("version"),
|
||||
license: s("license"),
|
||||
repository: s("repository"),
|
||||
files: licence_files(&dir, p),
|
||||
});
|
||||
}
|
||||
crates.sort_by(|a, b| (&a.name, &a.version).cmp(&(&b.name, &b.version)));
|
||||
let _ = root;
|
||||
Ok(render(&crates))
|
||||
}
|
||||
|
||||
/// The licence and notice files at the top of a crate's package (and its `license-file`).
|
||||
fn licence_files(dir: &Path, p: &Value) -> Vec<(String, String)> {
|
||||
let mut names: BTreeSet<String> = BTreeSet::new();
|
||||
if let Ok(rd) = std::fs::read_dir(dir) {
|
||||
for e in rd.flatten() {
|
||||
let n = e.file_name().to_string_lossy().to_string();
|
||||
let u = n.to_ascii_uppercase();
|
||||
if e.path().is_file() && ["LICENSE", "LICENCE", "COPYING", "NOTICE", "UNLICENSE", "COPYRIGHT"].iter().any(|k| u.starts_with(k)) {
|
||||
names.insert(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = p["license_file"].as_str() {
|
||||
names.insert(f.to_string());
|
||||
}
|
||||
names
|
||||
.into_iter()
|
||||
.filter_map(|n| {
|
||||
let text = std::fs::read_to_string(dir.join(&n)).ok()?;
|
||||
let text = text.replace("\r\n", "\n").trim().to_string();
|
||||
(!text.is_empty()).then(|| (Path::new(&n).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or(n), text))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render(crates: &[Crate]) -> String {
|
||||
let rule = "=".repeat(80);
|
||||
let mut out = format!(
|
||||
"Third-party software in SolveCraft\n\nSolveCraft (the desktop app, solvecraft-cli and the web app) is built from the {} crates below.\n\
|
||||
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.\n\
|
||||
SolveCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.\n\n",
|
||||
crates.len()
|
||||
);
|
||||
for c in crates {
|
||||
out += &format!("{} {} ({})\n", c.name, c.version, if c.license.is_empty() { "see its licence file" } else { &c.license });
|
||||
}
|
||||
// One copy of each distinct licence text, with the crates it covers.
|
||||
let mut by_text: BTreeMap<&str, Vec<String>> = BTreeMap::new();
|
||||
let mut without: Vec<&Crate> = Vec::new();
|
||||
for c in crates {
|
||||
if c.files.is_empty() {
|
||||
without.push(c);
|
||||
}
|
||||
for (_, text) in &c.files {
|
||||
by_text.entry(text.as_str()).or_default().push(format!("{} {}", c.name, c.version));
|
||||
}
|
||||
}
|
||||
let mut sections: Vec<(Vec<String>, &str)> = by_text.into_iter().map(|(t, v)| (v, t)).collect();
|
||||
sections.sort();
|
||||
for (names, text) in sections {
|
||||
out += &format!("\n{rule}\n{}\n{rule}\n\n{text}\n", wrap(&names.join(", ")));
|
||||
}
|
||||
if !without.is_empty() {
|
||||
out += &format!(
|
||||
"\n{rule}\nCrates whose package carries no licence file (their licence is named in Cargo.toml; the MIT and Apache-2.0\ntexts are as in SolveCraft's own LICENSE-MIT and LICENSE-APACHE)\n{rule}\n\n"
|
||||
);
|
||||
for c in without {
|
||||
out += &format!(
|
||||
"{} {}: {}{}\n",
|
||||
c.name,
|
||||
c.version,
|
||||
c.license,
|
||||
if c.repository.is_empty() { String::new() } else { format!(" ({})", c.repository) }
|
||||
);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Wrap a long list of crate names at 100 columns.
|
||||
fn wrap(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut col = 0;
|
||||
for w in s.split(' ') {
|
||||
if col > 0 && col + w.len() + 1 > 100 {
|
||||
out.push('\n');
|
||||
col = 0;
|
||||
} else if col > 0 {
|
||||
out.push(' ');
|
||||
col += 1;
|
||||
}
|
||||
out += w;
|
||||
col += w.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
+7
-1
@@ -6,6 +6,7 @@
|
||||
mod assets;
|
||||
mod book;
|
||||
mod layers;
|
||||
mod licences;
|
||||
mod parity;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -28,8 +29,11 @@ commands:
|
||||
book [--no-build]
|
||||
generate docs/book's command reference and screenshots, then mdbook build
|
||||
into target/book
|
||||
licences [--check]
|
||||
write THIRD-PARTY-LICENSES.txt (the licences of every shipped crate, embedded
|
||||
in the programs); --check fails when it is out of date
|
||||
wasm cargo check every library crate and the web app for wasm32-unknown-unknown
|
||||
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
|
||||
ci fmt --check, clippy -D warnings, test, assets, licences --check, layers, wasm (stops at first failure)
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -42,6 +46,7 @@ fn main() -> ExitCode {
|
||||
Some("oracle") => cmd_oracle(),
|
||||
Some("step-corpus") => cmd_step_corpus(rest.first().copied()),
|
||||
Some("book") => book::run(&root(), !rest.contains(&"--no-build")),
|
||||
Some("licences") => licences::run(&root(), rest.contains(&"--check")),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
@@ -305,6 +310,7 @@ fn cmd_ci() -> Result<(), String> {
|
||||
c.args(["test", "--workspace", "--release"]);
|
||||
run(c, "cargo test")?;
|
||||
assets::run(&root())?;
|
||||
licences::run(&root(), true)?;
|
||||
cmd_layers()?;
|
||||
cmd_wasm()?;
|
||||
eprintln!("ci: all gates passed");
|
||||
|
||||
Reference in new issue
Block a user