Compare commits

...
13 Commits
Author SHA1 Message Date
bherbruckandClaude Opus 5.5 0a1ab40ea0 View cube with chamfered corners: its only targets are the 6 faces (orthographic views) and the 8 corner facets (iso views); edges are not targets, a point on one goes to a neighbouring face or facet, and hover lights exactly the face or facet under the pointer. Design decision.
Fixes #15

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 20:28:16 +00:00
bherbruckandClaude Opus 5.5 eef2430bc7 Surface tool dialogs: Patch (sketch profiles or a loop of edges), Stitch (tolerance), Thicken (thickness, one side or symmetric, operation), Extend (edges, distance), Trim (tool plane or face, side to keep) and Split Face; Split Body takes any face as its tool (a curved face's surface extended). Live previews and Edit Feature; icons for patch, stitch and thicken drawn in code
Fixes #19

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 19:59:41 +00:00
bherbruckandClaude Opus 5.5 a1a6e2b624 Release packaging: the MSI's licence files get a component GUID, the mounted dmg is verified without --strict, shellcheck follows env.sh
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 19:50:26 +00:00
bherbruckandClaude Opus 5.5 6b146942bd Dialog rows for the newer engine options: Thread Modeled; Shell Tangent Chain (on by default) and Direction (inside, outside, both); Revolve Type (Angle, To Object) and Direction (one side, two sides with Angle 2, symmetric); Extrude and Hole extents To Object; Hole on all points of a sketch; Fillet Radius Type (constant, chord length, variable with an end radius and Swap Ends); Combine into a New Component; Align snaps (as picked, centre, circle centre; edges snap at their middle); Boundary Fill dialog (the tools' cells found by sampling, ticked cells filled, Remove Tools). Edit Feature restores them; tests for each mapping
Fixes #17

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 19:40:24 +00:00
bherbruckandClaude Opus 5.5 ce61aef4a6 Dialog labels show in full: the label column grows to its longest label (up to 150 px, then wraps) instead of cutting labels off. Feature previews that remove material (Hole, pockets) show the cut's new faces through the body in the cut colour, so the hole is seen from any side
Fixes #18

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 19:27:26 +00:00
bherbruckandClaude Opus 5.5 7e0618d64b Release pipeline after PhotoCraft's: AppImage/deb/rpm/Flatpak/tar.gz for Linux x86_64 and aarch64, FreeBSD, a macOS dmg and CLI zip, Windows x64/x86/arm64 MSI and portable zip (plus the plain x64 exe), web zip, SHA256SUMS.txt; version 0.1.2
Adapted from storytold/photocraft's release.yml and packaging/ (Apache-2.0; ATTRIBUTION.md and
NOTICE say so). Packages install the licence files, THIRD-PARTY-LICENSES.txt included. The app
icon is drawn by `solvecraft-cli icon` (a blue block with a bore, rendered on a transparent
background; .ico too), so no image is committed. Notes: a short header plus GitHub's generated
What's Changed. A dry run (Run workflow, publish off) keeps the files as the release-assets
artifact. packaging-lint.yml checks the workflows, scripts and metadata.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 19:18:29 +00:00
bherbruckandClaude Opus 5.5 90fde44c32 Construction axes and points in the viewport: axes dashed in the construction colour across the model (past it either side), points as small stars; axes are picked like the origin axes and work as axes in dialogs (pattern directions, revolve, rotate); hidden like construction planes; timeline icons
Fixes #16

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 19:07:19 +00:00
bherbruckandClaude Opus 5.5 cbdc4fe0d8 View cube hover lights the whole region a click turns to: a corner its 3 patches, an edge its 2 strips, a face its middle (one hit test for hover and click; test over all 26 regions). View animations ease with smoothstep and take longer for turns past a quarter turn, so no frame sweeps much more than 9 degrees; s38 and s39 enabled
Fixes #15

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 18:45:27 +00:00
bherbruckandClaude Opus 5.5 3b650cac5f Release workflow: the macOS job mounts the .dmg and checks it (the app, its Info.plist, both architectures, the Applications link)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 18:32:26 +00:00
bherbruckandClaude Opus 5.5 50a74ac66d Agent cursor: a violet pointer labelled Agent acts out control-channel and MCP commands. It glides to the command's toolbar button (or its panel) and clicks with a ripple, then to each pick (faces, edges and points projected to the screen, sketch points, a new sketch's plane, the profiles an extrude takes), then the command runs; the reply waits for it and later requests queue behind. Speed instant/normal/slow; follow camera turns to a new sketch and back on finish. View settings on the navigation bar (Show Agent Cursor, speed, follow camera; remembered, off by default); ui.agent_cursor {show, speed, follow_camera}; "animate": false per call. Tests: pick to screen mapping; scenario with the cursor on (replies wait for the animation) matching the cursor-off result
Fixes #35

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 18:21:47 +00:00
bherbruckandClaude Opus 5.5 9c0632b07c Release workflow: a dry run (Run workflow with publish off) keeps the assets as an artifact instead of releasing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 18:01:08 +00:00
bherbruckandClaude Opus 5.5 bf2b64f597 Releases offer plain programs; the licences are built in
Release assets are now the programs themselves: SolveCraft and solvecraft-cli .exe for Windows,
plain Linux binaries plus the AppImage, a macOS .dmg (SolveCraft.app with an Applications link) and
the universal CLI binary; only the web app stays a zip. The notes template takes {tag}, says to
chmod +x on Linux and macOS, and GitHub adds the change list.

Since no LICENSE or NOTICE file rides along any more, the programs carry them:
THIRD-PARTY-LICENSES.txt (written by `cargo xtask licences` from cargo metadata and each shipped
crate's own licence files, 419 crates; `cargo xtask ci` checks it is current) is embedded with
LICENSE-MIT, LICENSE-APACHE and NOTICE in solvecraft_engine::licences, shown in Help ▸ About ▸
Licences (third-party text laid out by visible rows) and printed by `solvecraft-cli licences`.
Scenario s44_about_licences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 17:53:19 +00:00
bherbruckandClaude Opus 5.5 3a1f05bacf Release workflow: only a tag with a suffix (v0.2.0-rc1) is a pre-release; v0.1.1 notes no longer say pre-release
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 17:05:03 +00:00
56 changed files with 15550 additions and 538 deletions

No files matched your search

+21 -71
View File
@@ -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.
-39
View File
@@ -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
-35
View File
@@ -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
+53
View File
@@ -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
View File
@@ -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/*
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
+74
View File
@@ -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) {
+1
View File
@@ -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;
+26
View File
@@ -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"));
}
}
+15 -9
View File
@@ -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}");
}
}
}
+1 -1
View File
@@ -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};
+27 -1
View File
@@ -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::*;
+339
View File
@@ -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)));
}
}
+19
View File
@@ -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
View File
@@ -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
+264
View File
@@ -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()))
}
+31 -8
View File
@@ -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::*;
+19
View File
@@ -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);
+17 -1
View File
@@ -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);
+157
View File
@@ -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}");
}
+21 -1
View File
@@ -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 [
+50
View File
@@ -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();
+3
View File
@@ -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),
+2
View File
@@ -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
View File
@@ -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"}}
]
+2 -1
View File
@@ -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.
+1
View File
@@ -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) |
+3
View File
@@ -0,0 +1,3 @@
# shellcheck config for the packaging scripts (CI: .github/workflows/packaging-lint.yml).
source-path=SCRIPTDIR
external-sources=true
+65
View File
@@ -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
}
+70
View File
@@ -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"
+73
View File
@@ -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>
+72
View File
@@ -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
+74
View File
@@ -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
+139
View File
@@ -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"
+13
View File
@@ -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
+64
View File
@@ -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>
+117
View File
@@ -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"
+36
View File
@@ -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"
+133
View File
@@ -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
+12
View File
@@ -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>
+122
View File
@@ -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="&quot;%1&quot;" />
</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="&quot;[#SolvecraftExe]&quot; &quot;%1&quot;" 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="&quot;[#SolvecraftExe]&quot; &quot;%1&quot;" 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>
+180
View File
@@ -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
View File
@@ -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");