Compare commits

..

1 Commits

277 changed files with 4134 additions and 23090 deletions
-75
View File
@@ -1,75 +0,0 @@
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.
## Fixed since v0.1.0
- **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
[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
-27
View File
@@ -1,27 +0,0 @@
# Every pull request must pass `cargo xtask ci`: fmt, clippy -D warnings, tests in release mode,
# the asset and layer checks, and the wasm build check.
name: CI
on:
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo xtask ci
-65
View File
@@ -1,65 +0,0 @@
# The documentation site on GitHub Pages: the book (docs/book) at /solvecraft/ and the web app
# at /solvecraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
# repository is private.
name: Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
MDBOOK_VERSION: v0.5.4
TRUNK_VERSION: v0.21.14
jobs:
build:
if: ${{ !github.event.repository.private }}
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- name: Install mdbook and trunk
run: |
mkdir -p "$HOME/.local/bin"
curl -sSL "https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
curl -sSL "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 the book
run: cargo xtask book
- name: Build the web app
working-directory: apps/solvecraft-web
run: trunk build --release --public-url /solvecraft/app/
- name: Assemble the site
run: |
mkdir -p site
cp -r target/book/. site/
cp -r dist/web site/app
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: site
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
-136
View File
@@ -1,136 +0,0 @@
# 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.
name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Tag to release (must exist), e.g. v0.1.0"
required: true
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
TAG: ${{ inputs.tag || github.ref_name }}
TRUNK_VERSION: v0.21.14
jobs:
linux:
runs-on: ubuntu-22.04 # an older glibc, so the binaries run on older distributions
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
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
run: |
$name = "solvecraft-$env:TAG-windows-x86_64"
New-Item -ItemType Directory -Force -Path "stage/$name", out | Out-Null
Copy-Item target/release/solvecraft.exe, target/release/solvecraft-cli.exe, README.md, LICENSE-MIT, LICENSE-APACHE, NOTICE, ATTRIBUTION.md "stage/$name/"
Compress-Archive -Path "stage/$name" -DestinationPath "out/$name.zip"
- uses: actions/upload-artifact@v4
with:
name: windows
path: out/*
macos:
# One universal build: arm64 natively, x86_64 cross-compiled on the same runner, joined by lipo.
runs-on: macos-14
timeout-minutes: 90
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"
- uses: actions/upload-artifact@v4
with:
name: macos
path: out/*
web:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.TAG }}
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- name: Install trunk
run: |
mkdir -p "$HOME/.local/bin"
curl -sSfL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Build (relative public URL, so it can be served from any path)
working-directory: apps/solvecraft-web
run: trunk build --release
- name: Package
run: |
name="solvecraft-$TAG-web"
mkdir -p out && cp -r dist/web "$name"
cp LICENSE-MIT LICENSE-APACHE NOTICE "$name/"
zip -qr "out/$name.zip" "$name"
- uses: actions/upload-artifact@v4
with:
name: web
path: out/*
release:
needs: [linux, windows, macos, web]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.TAG }}
- uses: actions/download-artifact@v4
with:
path: assets
merge-multiple: true
- name: Checksums
working-directory: assets
run: sha256sum * > SHA256SUMS && cat SHA256SUMS
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG }}
name: SolveCraft ${{ env.TAG }}
body_path: .github/release-notes.md
prerelease: ${{ startsWith(env.TAG, 'v0.') }}
files: assets/*
-1
View File
@@ -6,4 +6,3 @@
*.swp
/.claude/worktrees/
/target-snapshot
__pycache__/
+3 -4
View File
@@ -50,8 +50,7 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
`solid.extrude` or `sketch.rectangle.two_point`, never another product's command id; label;
(`CommandSpec`: id = Fusion's command id where Fusion has the command, else a dotted id; label;
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + tests. The toolbar,
dialogs, sketch tools, palette, scripts, CLI and control channel all reach the same commands.
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form; only toolbar
@@ -75,8 +74,8 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":1,"method":"engine.execute","params":{"command":"Extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"SketchCreate"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
`ui.render` or `solvecraft-cli snapshot`.
-6
View File
@@ -18,12 +18,6 @@ Generated-in-code assets are original and have no file to list:
| File | Author | Source | Licence |
|---|---|---|---|
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
| docs/screenshots/demo.gif | SolveCraft contributors | screen recording of SolveCraft itself driven by examples/demo (3x speed) | MIT OR Apache-2.0 |
| docs/book/src/generated/sample-plate.png | SolveCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/bracket.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/enclosure.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/revolve.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/sheet-metal.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
| crates/sketch/fonts/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/fonts/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
## Code adapted from sibling projects
+3 -4
View File
@@ -50,8 +50,7 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
`solid.extrude` or `sketch.rectangle.two_point`, never another product's command id; label;
(`CommandSpec`: id = Fusion's command id where Fusion has the command, else a dotted id; label;
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + tests. The toolbar,
dialogs, sketch tools, palette, scripts, CLI and control channel all reach the same commands.
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form; only toolbar
@@ -75,8 +74,8 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":1,"method":"engine.execute","params":{"command":"Extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"SketchCreate"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
`ui.render` or `solvecraft-cli snapshot`.
-153
View File
@@ -1,153 +0,0 @@
# Contributing to SolveCraft
SolveCraft is an open-source parametric 3D CAD app written in pure Rust, in the style of
Autodesk Fusion. You sketch on a plane, constrain the sketch, turn profiles into solids, and
change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, Linux), in
the browser (wasm + WebGPU/WebGL2), headless from a CLI, and as an MCP server that AI agents can
model with.
Help is welcome, whether that's a one-line fix or owning a whole area. This page covers how to
build it, how the code is laid out, the few rules that aren't negotiable, and where help is
needed most.
## Get it running (about 10 minutes on a fast machine)
You need stable Rust 1.90 or newer. For the web
build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://trunkrs.dev).
```sh
git clone https://github.com/bherbruck/solvecraft && cd solvecraft
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
cargo test --workspace --release # unit, engine and UI-scenario tests
cargo xtask ci # everything a PR must pass (see below)
```
Web: `cd apps/solvecraft-web && trunk serve --release`, then open the printed URL (`?sample`
opens the sample part, `?webgl` forces WebGL2). Saving and opening files in the browser needs
HTTPS or localhost, because it uses the Origin Private File System.
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
## How the code is laid out
The [README](README.md#architecture) has the layer table. The short version:
- `crates/geom`: vectors, planes, meshes, measures.
- `crates/sketch`: our own constraint solver, plus profile finding.
- `crates/kernel`: the B-rep kernel boundary. It wraps [truck](https://github.com/ricosjp/truck),
and it's the only crate allowed to touch truck. Fillets, chamfers, shell/offset, Delete Face,
Split Face, STEP/IGES read and write, and face provenance are our own code here. Patched
copies of three truck crates live in `vendor/` and are excluded from our fmt/clippy rules.
- `crates/doc`: the document. It holds parameters and expressions, the feature timeline and its
incremental rebuild, components and occurrences, joints, appearances, and persistent naming.
- `crates/io`: design files and mesh formats (3MF, STL, OBJ).
- `crates/engine`: the session and the command registry. **Everything the user can do is a
command**, and the toolbar, dialogs, scripts, CLI, control channel and MCP all run the same
commands.
- `crates/ui-egui`: the desktop/web front end (egui + wgpu). It's swappable; nothing below it
depends on egui.
- `crates/mcp`, `apps/solvecraft`, `apps/solvecraft-cli`, `apps/solvecraft-web`.
`cargo xtask layers` fails the build if a lower layer depends on a higher one.
## Rules that aren't negotiable
1. **Clean-room.** We observe Fusion as a black box: its UI, how parts it builds measure, and
what it does with a given input. We never copy Autodesk code, icons, artwork, help text,
tooltips, templates or materials, and we never commit files Fusion produced. Command ids are
our own (`solid.fillet`, not Fusion's internal names).
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.
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.
4. **Never crash.** People trust a CAD app with their work.
- Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data.
There's no `unsafe`, and clippy denies it.
- An unsupported case returns an error ("not supported yet: …"); it never panics.
- Every call into truck goes through `solvecraft_kernel::guard`.
- Every crash fix comes with a regression test.
5. **Everything is a command, with a test.** A new feature means a `CommandSpec` in
`crates/engine/src/cmd/`, engine tests that measure the result (volume, area,
face/edge/vertex counts against a value you can derive), and for UI work a scenario (below).
[AGENTS.md](AGENTS.md) has the long form of these rules. It's written for AI agents, but it's
the most complete description of the house style.
## Tests
| What | Where | Run |
|---|---|---|
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo test --workspace --release` |
| UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | part of `cargo test` |
| Fusion oracle | `cargo xtask oracle` | needs the local oracle data (see below) |
**UI scenarios** drive the real app headless: start a tool, click a plane or a sketch point,
press keys, then assert on the result.
```json
[
{"start": "sketch.create"},
{"click": {"plane": "XY"}},
{"start": "sketch.rectangle.two_point"},
{"click": {"sketch": [0, 0]}},
{"click": {"sketch": [40, 30]}},
{"expect": {"errors": 0}}
]
```
A new `.json` file in that folder is picked up automatically. To record a bug before it's fixed,
put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the bug.
**Release mode matters.** CI runs the tests with `--release`. truck has debug-only assertions
that can fail on valid geometry, so if a test fails only in debug, say so in the PR.
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
SolveCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's
measurements; [docs/oracle.md](docs/oracle.md) has the current results. The recipes and
measurements live outside the repo (`plan/`, gitignored), because they come from a Fusion
install. If you work on geometry and want to check against it, ask in an issue.
## Sending a change
1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solvecraft/issues), labelled
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). Before starting,
comment on the issue so it gets the `in progress` label and two people don't build the same
thing. For anything bigger than a bug fix that has no issue yet, open one first.
2. Branch from `main` and keep the PR to one topic.
3. Run `cargo xtask ci` before pushing. It runs fmt, clippy (`-D warnings`), the tests in
release mode, the asset and layer checks, and the wasm build check. A PR has to pass it.
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
the expected ones).
Much of SolveCraft so far was written by AI coding agents (Claude) working in parallel, each
owning one area, with a human setting direction and reporting bugs. Their working notes are in
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
long as the change meets the rules above.
## Where help is wanted most
See the [`help wanted`](https://github.com/bherbruck/solvecraft/labels/help%20wanted) and
[`good first issue`](https://github.com/bherbruck/solvecraft/labels/good%20first%20issue) labels.
In order of how much they'd help users:
1. **Fillet and blend robustness.** Fillets are our own code (truck has no fillet operation).
Constant, variable-radius and chord fillets work, and so do corners where fillets of
different radii meet, including three rounds meeting at a vertex. Next are setback corners, fillets along curved edges between curved
faces, and fillets that consume neighbouring faces.
2. **Boolean robustness.** A random-design fuzzer currently gets about half of its designs
through without a kernel error. Each failing seed is a reproducible bug.
3. **Y-up / Z-up.** SolveCraft is Z-up only, so most STEP files (from SolidWorks, Onshape, or
Fusion's default) open lying on their backs. Fusion solves this with a "default modeling
orientation" preference.
4. **Imported appearances.** Colours from STEP files are lost when a feature changes the
imported body. They should become design appearances on import.
5. **Drawings, CAM and mesh tools.** These are out of scope for now (see
[ROADMAP.md](ROADMAP.md)), but anyone who wants to own one is welcome.
## Licence
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
of SolveCraft. SolveCraft isn't affiliated with Autodesk. Fusion is a trademark of Autodesk, Inc.
Generated
+117 -21
View File
@@ -422,6 +422,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -518,6 +524,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -673,7 +685,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
dependencies = [
"bytes",
"memchr",
"memchr 2.8.3",
]
[[package]]
@@ -1750,6 +1762,23 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lz4_flex"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05304f8e67dfc93d1b4b990137fd1a7a4c6ad44b60a9c486c8c4486f9d2027ae"
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "matext4cgmath"
version = "0.1.0"
@@ -1761,6 +1790,15 @@ dependencies = [
"num-complex",
]
[[package]]
name = "memchr"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "148fab2e51b4f1cfc66da2a7c32981d1d3c083a803978268bb11fe4b86925e7a"
dependencies = [
"libc",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -1889,6 +1927,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05aec50c70fd288702bcd93284a8444607f3292dbdf2a30de5ea5dcdbe72287b"
dependencies = [
"memchr 1.0.2",
]
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -1898,6 +1945,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-derive"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2554,13 +2612,23 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8533f14c8382aaad0d592c812ac3b826162128b65662331e1127b45c3d18536b"
dependencies = [
"memchr 2.8.3",
"serde",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
dependencies = [
"memchr",
"memchr 2.8.3",
]
[[package]]
@@ -2569,7 +2637,7 @@ version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
"memchr 2.8.3",
]
[[package]]
@@ -2890,7 +2958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"memchr 2.8.3",
"serde",
"serde_core",
"zmij",
@@ -3059,7 +3127,7 @@ dependencies = [
[[package]]
name = "solvecraft"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"eframe",
"egui",
@@ -3072,7 +3140,7 @@ dependencies = [
[[package]]
name = "solvecraft-cli"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3081,7 +3149,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"log",
"serde",
@@ -3095,7 +3163,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"log",
"serde",
@@ -3111,7 +3179,7 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
@@ -3119,7 +3187,7 @@ dependencies = [
[[package]]
name = "solvecraft-io"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
@@ -3131,7 +3199,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"log",
"serde",
@@ -3146,7 +3214,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3154,7 +3222,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"image",
"serde",
@@ -3163,7 +3231,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
@@ -3174,7 +3242,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -3188,13 +3256,11 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"eframe",
"egui",
"js-sys",
"log",
"serde_json",
"solvecraft-engine",
"solvecraft-ui-egui",
"wasm-bindgen",
@@ -3236,6 +3302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
@@ -3471,6 +3538,7 @@ dependencies = [
"truck-geometry",
"truck-polymesh",
"truck-topology",
"vtkio",
]
[[package]]
@@ -3647,6 +3715,25 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vtkio"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abbe89e5b97b472d57abeb02755a06d75b28d2df7d1fe3df5baf032281a65c16"
dependencies = [
"base64",
"bytemuck",
"byteorder",
"flate2",
"lz4_flex",
"nom",
"num-derive",
"num-traits",
"quick-xml 0.22.0",
"serde",
"xz2",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -4413,7 +4500,7 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
"memchr 2.8.3",
]
[[package]]
@@ -4487,11 +4574,20 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "zbus"
version = "5.19.0"
@@ -4628,7 +4724,7 @@ dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"memchr 2.8.3",
"zopfli",
]
+2 -2
View File
@@ -5,12 +5,12 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.1.1"
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
authors = ["SolveCraft contributors"]
repository = "https://github.com/bherbruck/solvecraft"
repository = "https://github.com/storytold/solvecraft"
[workspace.dependencies]
# internal
+14 -31
View File
@@ -6,19 +6,11 @@ with extrude and revolve, round edges, combine bodies, and change a parameter to
timeline rebuild. It is a clean-room implementation (see [CLAUDE.md](CLAUDE.md)) and part of the
storytold "craft" family.
![An AI agent builds a parametric enclosure in SolveCraft over MCP, then the width is changed in the Parameters dialog and the model rebuilds (3x speed)](docs/screenshots/demo.gif)
![SolveCraft with the sample plate](docs/screenshots/sample-plate.png)
*An agent builds an electronics enclosure over MCP: sketches on faces, shell, a port cut, one screw boss patterned to four, then width 80 → 100 mm in the Parameters dialog and the timeline rebuilds. 3× speed; [full video](https://github.com/bherbruck/solvecraft/releases/download/v0.1.0/enclosure-demo-v3.mp4), script in [examples/demo](examples/demo).*
**Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide,
developer guide and command reference; sources in [docs/book](docs/book)), and the
[web app](https://bherbruck.github.io/solvecraft/app/) to try it in the browser.
**Status: early but usable for simple parts.** 76 of 76 reference parts built in Fusion
rebuild in SolveCraft with the same volume, area and topology ([docs/oracle.md](docs/oracle.md));
command parity with Fusion's Design workspace is tracked in [docs/parity.md](docs/parity.md).
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solvecraft/issues),
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
**Status: early (milestone M0 done, M1 in progress).** See [ROADMAP.md](ROADMAP.md),
[docs/parity.md](docs/parity.md) (command parity with Fusion's Design workspace) and
[docs/oracle.md](docs/oracle.md) (parts built in Fusion, rebuilt and measured in SolveCraft).
## What works
@@ -38,14 +30,12 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
deleted.
- **Parameters** with unit-aware expressions (`2 * width + 5 mm`, `angle / 2`).
- **Files**: designs as JSON (`.solvecraft`), export to STEP (AP242 with body names, colours and
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ;
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
need B-rep bodies);
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori), 3MF, STL (binary/ASCII) and OBJ;
3MF and STL import as mesh bodies (they render, measure, move and export; solid features need
B-rep bodies);
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) as a base feature
that later features build on — open a `.step`/`.stp` file, insert one into a design, or drop it
on the window ([docs/step-import.md](docs/step-import.md)).
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
view cube and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
control channel so agents and tests can drive it.
@@ -77,10 +67,10 @@ A script is a JSON list of commands, the same ones the UI runs:
```json
{"commands": [
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "solid.extrude", "params": {"distance": 20}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}}
{"command": "SketchCreate", "params": {"plane": "XY"}},
{"command": "ShapeRectangleTwoPoint", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "Extrude", "params": {"distance": 20}},
{"command": "FusionFilletEdgesCommand", "params": {"edges": [[0, 0, 10]], "radius": 3}}
]}
```
@@ -96,7 +86,6 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
## Architecture
<!-- ANCHOR: architecture -->
| Layer | Crates | Role |
|---|---|---|
| L0 | `geom` | vectors, planes, profiles, meshes and their measures |
@@ -106,12 +95,6 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
| L4 | `engine` | the session and the command registry (everything is a command) |
| L5 | `ui-egui`, `mcp` | the swappable desktop front end; the MCP server (headless or bridged to the app) |
| apps | `solvecraft`, `solvecraft-cli` | desktop app, headless CLI |
<!-- ANCHOR_END: architecture -->
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md): how to build and test, the code map, the clean-room
and licence rules, and where help is wanted most.
## Licence
+13 -18
View File
@@ -1,18 +1,19 @@
# SolveCraft roadmap
Status as of **2026-10-09**. SolveCraft targets Fusion-style parametric modelling (sketch →
Status as of **2026-10-07**. SolveCraft targets Fusion-style parametric modelling (sketch →
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
## At a glance
| Question | Answer |
|---|---|
| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
| Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) |
| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) |
| Tests | 596 (41 of them UI scenarios): solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command |
| Where are we? | **M0 done; M1/M2 in progress.** Sketch with solver, extrude (taper, two-sided, through all), revolve, fillet/chamfer, holes, patterns, mirror, shell, draft, loft, sweep, split, construction planes, parameters that rebuild the timeline, STEP/STL export, desktop and browser (wasm) app with Fusion-style selection (hover/selected highlights, origin planes, click-to-pick sketch planes, multi-select, window/crossing box selection, animated view cube), CLI, MCP server, oracle harness. |
| Command parity (in scope) | **139 / 541 (26%)** in-scope commands; SOLID + SKETCH **102 / 291 (35%)** — [docs/parity.md](docs/parity.md) |
| Fusion oracle | **72 / 76 parts match** (batch 1: 29/29; batch 2 in progress) — [docs/oracle.md](docs/oracle.md) |
| Tests | 85 (solver, profiles, kernel booleans/blends/measures, expressions, timeline, file formats, camera, engine end-to-end, hostile-input fuzz over every command) |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
| Weighted parity estimate | **≈ 5%** of Fusion's Design workspace by importance (sketch + basic solids are the core, but surfaces, assemblies, sheet metal, CAM, drawings are untouched) |
| Time to a useful alpha (M0–M6) | ≈ **150 agent hours** remain |
## Scope (owner decision, 2026-10-07)
@@ -43,22 +44,20 @@ headline counts in-scope tabs only and lists the deferred ones separately.
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 |
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. Left: sheet metal edges, kernel-reported face history (exact through coincident surfaces), more roles (sweep, loft, shell inner faces) | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, exact (unfaceted) modelled threads | in progress | 12 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, thicken (needs surfaces), split face, modelled threads | in progress | 12 |
| M8 | Surface workspace | deferred | — |
| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Bend (Fold) has no engine command yet | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: installers, signing, docs, performance | planned | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Left: configuration rules, per-row materials and appearances | in progress | 4 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (FusionStartDesignConfigModeCmd, FusionShowDesignConfigPanelCmd, config.column/row/activate). Left: configuration rules, per-row materials and appearances | in progress | 4 |
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/2"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
## Known limitations
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
- Fillets and chamfers: straight edges (convex or concave) between planar faces with perpendicular
planar end faces; whole smooth loops of a planar face, picked whole or by one edge (tangent
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
@@ -81,11 +80,7 @@ headline counts in-scope tabs only and lists the deferred ones separately.
product names, colours and assemblies; offset surfaces and pcurve-only edges are not read yet,
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
- Persistent naming (plan/adr/0002) names faces from their history and edges by their faces;
references resolve by name first and warn when the entity split or vanished. Kernel-reported
face history (exact through coincident surfaces) is not wired in yet.
- Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude
and Revolve To Object on a curved face cut the tool back to the target's surface.
- No persistent naming (fillet edges are re-found by position; projected sketch geometry too).
- Free-form curves cut at a tangent touch (no clean crossing) enter that profile as polylines.
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
- Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not:
+9 -9
View File
@@ -150,13 +150,13 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
_ => {}
}
for out in flags(args, "--out") {
s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?;
s.execute("ExportCommand", &json!({"path": out})).map_err(|e| e.to_string())?;
}
if let Some(p) = flag(args, "--save") {
s.execute("file.save_as", &json!({"path": p})).map_err(|e| e.to_string())?;
s.execute("SaveDocumentAsCommand", &json!({"path": p})).map_err(|e| e.to_string())?;
}
if !args.iter().any(|a| a == "--quiet") {
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let m = s.execute("MeasureCommand", &json!({})).map_err(|e| e.to_string())?;
println!("{}", pretty(&m));
}
Ok(())
@@ -165,7 +165,7 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
fn cmd_eval(args: &[String]) -> Result<(), String> {
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?;
let mut s = load(path)?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let m = s.execute("MeasureCommand", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
let out = json!({
"body_count": m["body_count"],
@@ -239,7 +239,7 @@ fn cmd_save_stress(args: &[String]) -> Result<(), String> {
s.doc_mut().params.extend(filler);
for r in 0..rounds {
s.doc_mut().set_param("save_round", &r.to_string(), None, None).map_err(|e| e.to_string())?;
s.execute("file.save", &json!({ "path": path })).map_err(|e| e.to_string())?;
s.execute("SaveDocumentCommand", &json!({ "path": path })).map_err(|e| e.to_string())?;
}
Ok(())
}
@@ -266,10 +266,10 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
Ok(())
};
let expr = expr.replace("{old}", &old);
time(&mut s, "edit", "parameters.change", json!({"name": param, "expression": expr}))?;
time(&mut s, "set back", "parameters.change", json!({"name": param, "expression": old}))?;
time(&mut s, "undo", "edit.undo", json!({}))?;
time(&mut s, "redo", "edit.redo", json!({}))?;
time(&mut s, "edit", "ChangeParameterCommand", json!({"name": param, "expression": expr}))?;
time(&mut s, "set back", "ChangeParameterCommand", json!({"name": param, "expression": old}))?;
time(&mut s, "undo", "UndoCommand", json!({}))?;
time(&mut s, "redo", "RedoCommand", json!({}))?;
println!("{}", pretty(&json!(out)));
Ok(())
}
+2 -2
View File
@@ -31,7 +31,7 @@ fn check_case(dir: &str) -> Value {
if let Err(e) = s.run_script(&script) {
return json!({"case": name, "pass": false, "error": e.to_string()});
}
let got = match s.execute("inspect.measure", &json!({})) {
let got = match s.execute("MeasureCommand", &json!({})) {
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
@@ -183,7 +183,7 @@ fn check_step_case(dir: &str) -> Value {
Ok(v) => v,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
let got = match s.execute("inspect.measure", &json!({})) {
let got = match s.execute("MeasureCommand", &json!({})) {
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
+52 -52
View File
@@ -44,23 +44,23 @@ fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
if let Some(n) = f.get("name") {
p["name"] = n.clone();
}
out.push(json!({"command": "sketch.create", "params": p}));
out.push(json!({"command": "SketchCreate", "params": p}));
for e in f.get("entities").and_then(Value::as_array).into_iter().flatten() {
let id = e.get("id").cloned().unwrap_or(Value::Null);
let construction = e.get("construction").cloned().unwrap_or(json!(false));
match e.get("type").and_then(Value::as_str) {
Some("line") => out.push(json!({"command": "sketch.line", "params": {"points": [parg(e.get("start")), parg(e.get("end"))], "ids": [id], "construction": construction}})),
Some("circle") => out.push(json!({"command": "sketch.circle.center", "params": {"center": parg(e.get("center")), "radius": e.get("radius"), "id": id, "construction": construction}})),
Some("arc_three_point") => out.push(json!({"command": "sketch.arc.three_point", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": e.get("mid").or(e.get("through")), "id": id}})),
Some("arc_center_start_sweep") => out.push(json!({"command": "sketch.arc.center_point", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "sweep": e.get("sweep_deg").or(e.get("sweep")), "id": id}})),
Some("line") => out.push(json!({"command": "DrawPolyline", "params": {"points": [parg(e.get("start")), parg(e.get("end"))], "ids": [id], "construction": construction}})),
Some("circle") => out.push(json!({"command": "CircleCenterRadius", "params": {"center": parg(e.get("center")), "radius": e.get("radius"), "id": id, "construction": construction}})),
Some("arc_three_point") => out.push(json!({"command": "ArcThreePoint", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": e.get("mid").or(e.get("through")), "id": id}})),
Some("arc_center_start_sweep") => out.push(json!({"command": "ArcCenterTwoPoint", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "sweep": e.get("sweep_deg").or(e.get("sweep")), "id": id}})),
Some("arc") => {
if e.get("center").is_some() {
out.push(json!({"command": "sketch.arc.center_point", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "end": parg(e.get("end")), "id": id}}))
out.push(json!({"command": "ArcCenterTwoPoint", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "end": parg(e.get("end")), "id": id}}))
} else {
out.push(json!({"command": "sketch.arc.three_point", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": parg(e.get("through").or(e.get("mid"))), "id": id}}))
out.push(json!({"command": "ArcThreePoint", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": parg(e.get("through").or(e.get("mid"))), "id": id}}))
}
}
Some("point") => out.push(json!({"command": "sketch.point", "params": {"point": parg(e.get("at").map(|a| json!({"at": a})).as_ref().or(Some(e))), "id": id}})),
Some("point") => out.push(json!({"command": "DrawPoint", "params": {"point": parg(e.get("at").map(|a| json!({"at": a})).as_ref().or(Some(e))), "id": id}})),
// A 3D helix fit: the sweep along it becomes a coil (see `helix_path`).
Some("fitted_spline") if e.get("fit_points").and_then(Value::as_array).is_some_and(|a| a.iter().any(|p| p.get(2).is_some())) => {}
Some(other) => return Err(format!("recipe: sketch entity type `{other}` is not supported yet")),
@@ -77,9 +77,9 @@ fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
p[k] = v.clone();
}
}
out.push(json!({"command": "sketch.dimension", "params": p}));
out.push(json!({"command": "SketchDimension", "params": p}));
}
out.push(json!({"command": "sketch.finish", "params": {}}));
out.push(json!({"command": "SketchStop", "params": {}}));
Ok(())
}
@@ -138,33 +138,33 @@ fn constraint_cmd(c: &Value) -> Result<Value, String> {
let at = |i: usize| r.get(i).cloned().unwrap_or(Value::Null);
let is_point = |v: &Value| v.as_str().is_some_and(|s| s.contains('.') || s == "origin" || s.starts_with('p'));
let (cmd, p) = match ty.as_str() {
"horizontal" | "vertical" if r.len() >= 2 => ("sketch.constraint.horizontal_vertical", json!({"points": [at(0), at(1)], "mode": ty})),
"horizontal" | "vertical" => ("sketch.constraint.horizontal_vertical", json!({"line": at(0), "mode": ty})),
"horizontal_points" => ("sketch.constraint.horizontal_vertical", json!({"points": [at(0), at(1)], "mode": "horizontal"})),
"vertical_points" => ("sketch.constraint.horizontal_vertical", json!({"points": [at(0), at(1)], "mode": "vertical"})),
"horizontal" | "vertical" if r.len() >= 2 => ("ConstraintHorizontalVertical", json!({"points": [at(0), at(1)], "mode": ty})),
"horizontal" | "vertical" => ("ConstraintHorizontalVertical", json!({"line": at(0), "mode": ty})),
"horizontal_points" => ("ConstraintHorizontalVertical", json!({"points": [at(0), at(1)], "mode": "horizontal"})),
"vertical_points" => ("ConstraintHorizontalVertical", json!({"points": [at(0), at(1)], "mode": "vertical"})),
"coincident" | "point_on_curve" => {
// The point goes first.
if is_point(&at(0)) {
("sketch.constraint.coincident", json!({"a": at(0), "b": at(1)}))
("ConstraintCoincident", json!({"a": at(0), "b": at(1)}))
} else {
("sketch.constraint.coincident", json!({"a": at(1), "b": at(0)}))
("ConstraintCoincident", json!({"a": at(1), "b": at(0)}))
}
}
"midpoint" => {
if is_point(&at(0)) {
("sketch.constraint.midpoint", json!({"point": at(0), "line": at(1)}))
("ConstraintMidPoint", json!({"point": at(0), "line": at(1)}))
} else {
("sketch.constraint.midpoint", json!({"point": at(1), "line": at(0)}))
("ConstraintMidPoint", json!({"point": at(1), "line": at(0)}))
}
}
"symmetric" | "symmetry" => ("sketch.constraint.symmetry", json!({"a": at(0), "b": at(1), "line": at(2)})),
"fix" => ("sketch.constraint.fix", json!({"entity": at(0), "fixed": true})),
"tangent" => ("sketch.constraint.tangent", json!({"a": at(0), "b": at(1)})),
"equal" => ("sketch.constraint.equal", json!({"a": at(0), "b": at(1)})),
"parallel" => ("sketch.constraint.parallel", json!({"a": at(0), "b": at(1)})),
"perpendicular" => ("sketch.constraint.perpendicular", json!({"a": at(0), "b": at(1)})),
"concentric" => ("sketch.constraint.concentric", json!({"a": at(0), "b": at(1)})),
"collinear" => ("sketch.constraint.collinear", json!({"a": at(0), "b": at(1)})),
"symmetric" | "symmetry" => ("ConstraintSymmetry", json!({"a": at(0), "b": at(1), "line": at(2)})),
"fix" => ("ConstraintFix", json!({"entity": at(0), "fixed": true})),
"tangent" => ("ConstraintTangent", json!({"a": at(0), "b": at(1)})),
"equal" => ("ConstraintEqual", json!({"a": at(0), "b": at(1)})),
"parallel" => ("ConstraintParallel", json!({"a": at(0), "b": at(1)})),
"perpendicular" => ("ConstraintPerpendicular", json!({"a": at(0), "b": at(1)})),
"concentric" => ("ConstraintConcentric", json!({"a": at(0), "b": at(1)})),
"collinear" => ("ConstraintCollinear", json!({"a": at(0), "b": at(1)})),
other => return Err(format!("recipe: constraint `{other}` is not supported yet")),
};
Ok(json!({"command": cmd, "params": p}))
@@ -294,7 +294,7 @@ fn circle_fit(pts: &[[f64; 3]]) -> Option<(f64, f64)> {
pub fn to_script(recipe: &Value) -> Result<Value, String> {
let mut out: Vec<Value> = Vec::new();
for p in recipe.get("parameters").and_then(Value::as_array).into_iter().flatten() {
out.push(json!({"command": "parameters.change", "params": {"name": p.get("name"), "expression": p.get("expression").or(p.get("value")), "unit": p.get("unit").cloned().unwrap_or(json!("mm"))}}));
out.push(json!({"command": "ChangeParameterCommand", "params": {"name": p.get("name"), "expression": p.get("expression").or(p.get("value")), "unit": p.get("unit").cloned().unwrap_or(json!("mm"))}}));
}
let features = recipe.get("features").and_then(Value::as_array).ok_or("recipe: missing `features`")?;
for f in features {
@@ -305,9 +305,9 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
Some("extrude") => {
let mut p = json!({"sketch": profile_sketch(f), "profiles": profiles_of(f), "operation": op(f), "name": name, "body_names": bodies});
extent(f, &mut p);
out.push(json!({"command": "solid.extrude", "params": p}));
out.push(json!({"command": "Extrude", "params": p}));
}
Some("combine") => out.push(json!({"command": "solid.combine", "params": {
Some("combine") => out.push(json!({"command": "FusionCombineCommand", "params": {
"target": f.get("target_body").or(f.get("target")), "tools": f.get("tool_bodies").or(f.get("tools")),
"operation": op(f), "keep_tools": f.get("keep_tools").cloned().unwrap_or(json!(false)), "name": name}})),
Some("revolve") => {
@@ -316,7 +316,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
Some(a) => a.get("line").or(a.get("ref")).cloned().unwrap_or_else(|| a.clone()),
None => Value::Null,
};
out.push(json!({"command": "solid.revolve", "params": {
out.push(json!({"command": "Revolve", "params": {
"sketch": profile_sketch(f), "profiles": profiles_of(f), "axis": axis,
"angle": f.get("angle").cloned().unwrap_or(json!("360 deg")), "operation": op(f), "name": name, "body_names": bodies}}));
}
@@ -329,11 +329,11 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
p["count2"] = d2.get("count").cloned().unwrap_or(Value::Null);
p["spacing2"] = d2.get("spacing").cloned().unwrap_or(Value::Null);
}
out.push(json!({"command": "solid.pattern.rectangular", "params": p}));
out.push(json!({"command": "PatternRectangular", "params": p}));
}
Some("circular_pattern") => {
let axis = f.get("axis").map(|a| json!({"origin": a.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "dir": a.get("dir")})).unwrap_or(Value::Null);
out.push(json!({"command": "solid.pattern.circular", "params": {"features": feature_refs(f.get("features")), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}}));
out.push(json!({"command": "PatternCircular", "params": {"features": feature_refs(f.get("features")), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}}));
}
Some("sm_flange") => match f.get("type").and_then(Value::as_str) {
Some("edge") => {
@@ -344,11 +344,11 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
if f.get("flip").and_then(Value::as_bool) == Some(true) {
p["flip"] = json!(true);
}
out.push(json!({"command": "sheet.flange", "params": p}));
out.push(json!({"command": "FusionSheetMetalFlangeCommand", "params": p}));
}
Some("contour") => out.push(json!({"command": "sheet.flange", "params": {
Some("contour") => out.push(json!({"command": "FusionSheetMetalFlangeCommand", "params": {
"type": "contour", "sketch": f.get("profile_sketch"), "curves": f.get("profile_curves"), "distance": f.get("distance"), "name": name}})),
_ => out.push(json!({"command": "sheet.flange", "params": {
_ => out.push(json!({"command": "FusionSheetMetalFlangeCommand", "params": {
"type": "base", "sketch": profile_sketch(f), "profiles": profiles_of(f), "name": name, "body_names": bodies}})),
},
Some("sheet_metal_rule_edit") => {
@@ -368,7 +368,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
p["thickness"] = json!(format!("{v} {u}"));
}
}
out.push(json!({"command": "sheet.manage_rules", "params": p}));
out.push(json!({"command": "FusionSheetMetalRulesCommand", "params": p}));
}
Some("sm_hem") => {
let e = f.get("edge").and_then(|e| e.get("point")).cloned().unwrap_or(Value::Null);
@@ -376,9 +376,9 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
if f.get("flipped").and_then(Value::as_bool) == Some(true) {
p["flip"] = json!(true);
}
out.push(json!({"command": "sheet.hem", "params": p}));
out.push(json!({"command": "FusionSheetMetalHemFlangeCommand", "params": p}));
}
Some("sm_unfold") => out.push(json!({"command": "sheet.unfold", "params": {"name": name}})),
Some("sm_unfold") => out.push(json!({"command": "FusionSheetmetalUnfoldCommand", "params": {"name": name}})),
Some("sm_refold") => out.push(json!({"command": "sheet.refold", "params": {"name": name}})),
Some("path_pattern") => {
let path = f.get("path").cloned().unwrap_or(Value::Null);
@@ -392,7 +392,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
} else {
p["features"] = feature_refs(f.get("features"));
}
out.push(json!({"command": "solid.pattern.path", "params": p}));
out.push(json!({"command": "PatternOnPath", "params": p}));
}
Some("loft") => {
let sections: Vec<Value> = f
@@ -405,24 +405,24 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
None => json!({"sketch": sec.get("sketch"), "profiles": [sec]}),
})
.collect();
out.push(json!({"command": "solid.loft", "params": {"sections": sections, "operation": op(f), "name": name, "body_names": bodies}}));
out.push(json!({"command": "SolidLoft", "params": {"sections": sections, "operation": op(f), "name": name, "body_names": bodies}}));
}
Some("sweep") if helix_path(features, f).is_some() => {
// A swept circle along a fitted 3D helix: a coil (the true helix).
let Some((base, diameter, pitch, turns, start, cw, size)) = helix_path(features, f) else { continue };
out.push(json!({"command": "solid.coil", "params": {
out.push(json!({"command": "PrimitiveCoil", "params": {
"base": base, "diameter": diameter, "pitch": pitch, "revolutions": turns, "section_size": size,
"start_angle": format!("{start} rad"), "clockwise": cw, "operation": op(f), "name": name, "body_names": bodies}}));
}
Some("sweep") => {
let path = f.get("path").cloned().unwrap_or(Value::Null);
out.push(json!({"command": "solid.sweep", "params": {
out.push(json!({"command": "Sweep", "params": {
"sketch": profile_sketch(f), "profiles": profiles_of(f), "path_sketch": path.get("sketch"), "path": path.get("curves"),
"operation": op(f), "name": name, "body_names": bodies}}));
}
Some("shell") => {
let faces: Vec<Value> = f.get("faces_removed").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
out.push(json!({"command": "solid.shell", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}}));
out.push(json!({"command": "FusionShellBodyCommand", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}}));
}
Some("draft") => {
let faces: Vec<Value> = f.get("faces").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
@@ -433,7 +433,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
if f.get("flipped").and_then(Value::as_bool).unwrap_or(false) {
angle = -angle;
}
out.push(json!({"command": "solid.draft", "params": {"faces": faces, "angle": angle, "neutral": neutral, "pull": n, "name": name}}));
out.push(json!({"command": "FusionDraftCommand", "params": {"faces": faces, "angle": angle, "neutral": neutral, "pull": n, "name": name}}));
}
Some("thread") => {
let face = f.get("face").and_then(|x| x.get("point")).cloned().unwrap_or(Value::Null);
@@ -443,7 +443,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
{
p["length"] = l.clone();
}
out.push(json!({"command": "solid.thread", "params": p}));
out.push(json!({"command": "FusionThreadCommand", "params": p}));
}
Some("hole") => {
let n = f.get("face").and_then(|fc| fc.get("plane_normal_outward").or(fc.get("normal_at_point_on_face"))).cloned();
@@ -495,20 +495,20 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
p[k] = v.clone();
}
}
out.push(json!({"command": "solid.hole", "params": p}));
out.push(json!({"command": "FusionHoleCommand", "params": p}));
}
Some("construction_plane") => match f.get("method").and_then(Value::as_str) {
Some("offset") => out.push(json!({"command": "construct.plane.offset", "params": {"base": f.get("base"), "offset": f.get("offset"), "name": name}})),
Some("offset") => out.push(json!({"command": "ConstructionPlaneOffsetFromPlaneCommand", "params": {"base": f.get("base"), "offset": f.get("offset"), "name": name}})),
_ => {
// Use the resulting plane as recorded.
let (o, n) = (f.get("result_origin").cloned().unwrap_or(json!([0, 0, 0])), f.get("result_normal").cloned().unwrap_or(Value::Null));
if n.is_null() {
return Err(format!("recipe: construction plane method {:?} without result_normal", f.get("method")));
}
out.push(json!({"command": "construct.plane.offset", "params": {"base": {"origin": o, "normal": n}, "offset": 0, "name": name}}));
out.push(json!({"command": "ConstructionPlaneOffsetFromPlaneCommand", "params": {"base": {"origin": o, "normal": n}, "offset": 0, "name": name}}));
}
},
Some("split_body") => out.push(json!({"command": "solid.split_body", "params": {"body": f.get("body"), "plane": f.get("tool"), "name": name}})),
Some("split_body") => out.push(json!({"command": "FusionSplitBodyCommand", "params": {"body": f.get("body"), "plane": f.get("tool"), "name": name}})),
Some("mirror") => {
let pl = f.get("plane").cloned().unwrap_or(Value::Null);
let plane = match pl.get("ref").and_then(Value::as_str) {
@@ -519,13 +519,13 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
if f.get("mirror_of").and_then(Value::as_str) == Some("bodies") {
p = json!({"bodies": f.get("bodies"), "combine": f.get("combine").cloned().unwrap_or(json!(false)), "plane": plane, "name": name});
}
out.push(json!({"command": "solid.mirror", "params": p}));
out.push(json!({"command": "MirrorCommand", "params": p}));
}
Some("fillet") => {
out.push(json!({"command": "solid.fillet", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}}))
out.push(json!({"command": "FusionFilletEdgesCommand", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}}))
}
Some("chamfer") => {
out.push(json!({"command": "solid.chamfer", "params": {"edges": edge_points(f), "distance": f.get("distance"), "name": name}}))
out.push(json!({"command": "FusionChamferCommand", "params": {"edges": edge_points(f), "distance": f.get("distance"), "name": name}}))
}
Some(other) => return Err(format!("recipe: feature op `{other}` is not supported yet")),
None => return Err("recipe: feature without `op`".into()),
@@ -1,15 +1,15 @@
{"commands": [
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}},
{"command": "component.create", "params": {"name": "Arm"}},
{"command": "solid.box", "params": {"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"}},
{"command": "solid.fillet", "params": {"edges": [[25, 4, 4]], "radius": 1}},
{"command": "PrimitiveBox", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}},
{"command": "FusionCreateNewComponentCommand", "params": {"name": "Arm"}},
{"command": "PrimitiveBox", "params": {"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"}},
{"command": "FusionFilletEdgesCommand", "params": {"edges": [[25, 4, 4]], "radius": 1}},
{"command": "component.activate", "params": {"component": 0}},
{"command": "occurrence.move", "params": {"occurrence": 3, "translate": [0, 50, 0]}},
{"command": "occurrence.copy", "params": {"component": "Arm", "translate": [0, 20, 0]}},
{"command": "component.paste_new", "params": {"component": "Arm", "translate": [0, -30, 0]}},
{"command": "occurrence.ground", "params": {"occurrence": 3}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "S1"}},
{"command": "sketch.circle.center", "params": {"center": [5, 5], "radius": 2}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 10, "operation": "cut"}}
{"command": "SketchCreate", "params": {"plane": "XY", "name": "S1"}},
{"command": "CircleCenterRadius", "params": {"center": [5, 5], "radius": 2}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": 10, "operation": "cut"}}
]}
@@ -1,14 +1,14 @@
{"commands": [
{"command": "parameters.change", "params": {"name": "open_angle", "expression": "90 deg"}},
{"command": "component.create", "params": {"name": "LeafA"}},
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
{"command": "solid.cylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
{"command": "ChangeParameterCommand", "params": {"name": "open_angle", "expression": "90 deg"}},
{"command": "FusionCreateNewComponentCommand", "params": {"name": "LeafA"}},
{"command": "PrimitiveBox", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
{"command": "PrimitiveCylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
{"command": "component.activate", "params": {"component": "root"}},
{"command": "component.create", "params": {"name": "LeafB"}},
{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
{"command": "solid.cylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
{"command": "FusionCreateNewComponentCommand", "params": {"name": "LeafB"}},
{"command": "PrimitiveBox", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}},
{"command": "PrimitiveCylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}},
{"command": "component.activate", "params": {"component": "root"}},
{"command": "occurrence.ground", "params": {"occurrence": 2, "grounded": true}},
{"command": "joint.create", "params": {"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "name": "Hinge"}},
{"command": "joint.drive", "params": {"joint": "Hinge", "value": "open_angle"}}
{"command": "JointAssembleCmdNew", "params": {"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "name": "Hinge"}},
{"command": "FusionMoveJointsCommand", "params": {"joint": "Hinge", "value": "open_angle"}}
]}
@@ -1,12 +1,12 @@
{"commands": [
{"command": "parameters.change", "params": {"name": "width", "expression": "40 mm"}},
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": "width / 2"}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}},
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.circle.center", "params": {"center": [20, 15], "radius": 5}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 20, "operation": "cut"}}
{"command": "ChangeParameterCommand", "params": {"name": "width", "expression": "40 mm"}},
{"command": "SketchCreate", "params": {"plane": "XY"}},
{"command": "ShapeRectangleTwoPoint", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": "width / 2"}},
{"command": "FusionFilletEdgesCommand", "params": {"edges": [[0, 0, 10]], "radius": 3}},
{"command": "SketchCreate", "params": {"plane": "XY"}},
{"command": "CircleCenterRadius", "params": {"center": [20, 15], "radius": 5}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": 20, "operation": "cut"}}
]}
@@ -1,11 +1,11 @@
{"commands": [
{"command": "sketch.create", "params": {"plane": "XZ"}},
{"command": "sketch.line", "params": {"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.revolve", "params": {"axis": "y"}},
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]}},
{"command": "solid.cylinder", "params": {"diameter": 4, "height": 20, "base": [35, 5, -5]}},
{"command": "solid.combine", "params": {"target": "Body2", "tools": ["Body3"], "operation": "cut"}},
{"command": "solid.chamfer", "params": {"edges": [[30, 5, 10]], "distance": 1}},
{"command": "SketchCreate", "params": {"plane": "XZ"}},
{"command": "DrawPolyline", "params": {"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}},
{"command": "SketchStop", "params": {}},
{"command": "Revolve", "params": {"axis": "y"}},
{"command": "PrimitiveBox", "params": {"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]}},
{"command": "PrimitiveCylinder", "params": {"diameter": 4, "height": 20, "base": [35, 5, -5]}},
{"command": "FusionCombineCommand", "params": {"target": "Body2", "tools": ["Body3"], "operation": "cut"}},
{"command": "FusionChamferCommand", "params": {"edges": [[30, 5, 10]], "distance": 1}},
{"command": "timeline.edit", "params": {"feature": "Revolve1", "set": {"angle": "180 deg"}}}
]}
@@ -1,14 +1,14 @@
{
"commands": [
{
"command": "sketch.create",
"command": "SketchCreate",
"params": {
"name": "Plate_sketch",
"plane": "XY"
}
},
{
"command": "sketch.line",
"command": "DrawPolyline",
"params": {
"construction": false,
"ids": [
@@ -27,7 +27,7 @@
}
},
{
"command": "sketch.line",
"command": "DrawPolyline",
"params": {
"construction": false,
"ids": [
@@ -43,7 +43,7 @@
}
},
{
"command": "sketch.line",
"command": "DrawPolyline",
"params": {
"construction": false,
"ids": [
@@ -59,7 +59,7 @@
}
},
{
"command": "sketch.line",
"command": "DrawPolyline",
"params": {
"construction": false,
"ids": [
@@ -72,11 +72,11 @@
}
},
{
"command": "sketch.finish",
"command": "SketchStop",
"params": {}
},
{
"command": "solid.extrude",
"command": "Extrude",
"params": {
"body_names": [
"Plate"
@@ -110,14 +110,14 @@
}
},
{
"command": "sketch.create",
"command": "SketchCreate",
"params": {
"name": "PathSketch",
"plane": "XY"
}
},
{
"command": "sketch.arc.three_point",
"command": "ArcThreePoint",
"params": {
"end": [
20,
@@ -135,7 +135,7 @@
}
},
{
"command": "sketch.circle.center",
"command": "CircleCenterRadius",
"params": {
"center": "a1.start",
"construction": false,
@@ -144,11 +144,11 @@
}
},
{
"command": "sketch.finish",
"command": "SketchStop",
"params": {}
},
{
"command": "solid.extrude",
"command": "Extrude",
"params": {
"body_names": [
"Plate"
@@ -179,7 +179,7 @@
}
},
{
"command": "solid.pattern.path",
"command": "PatternOnPath",
"params": {
"count": 7,
"features": [
@@ -1,10 +1,10 @@
{"commands": [
{"command": "parameters.change", "params": {"name": "wall", "expression": "2 mm"}},
{"command": "solid.box", "params": {"length": 80, "width": 50, "height": 30}},
{"command": "solid.shell", "params": {"faces": [[40, 25, 30]], "thickness": "wall"}},
{"command": "plastic.lip", "params": {"face": [1, 25, 30], "width": 1, "height": 2}},
{"command": "plastic.boss", "params": {"position": [20, 20, 2], "diameter": 6, "height": 10, "hole_diameter": 2.5, "draft": "1 deg"}},
{"command": "plastic.rest", "params": {"position": [60, 30, 2], "width": 8, "length": 12, "height": 3, "thickness": 1}},
{"command": "plastic.snap_fit", "params": {"position": [40, 12, 2], "direction": [0, 0, 1], "hook": [0, 1, 0], "length": 12, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 2}},
{"command": "plastic.manage_rules", "params": {"name": "ABS (1.5mm)", "active": true}}
{"command": "ChangeParameterCommand", "params": {"name": "wall", "expression": "2 mm"}},
{"command": "PrimitiveBox", "params": {"length": 80, "width": 50, "height": 30}},
{"command": "FusionShellBodyCommand", "params": {"faces": [[40, 25, 30]], "thickness": "wall"}},
{"command": "FusionLipCommand", "params": {"face": [1, 25, 30], "width": 1, "height": 2}},
{"command": "FusionBossCommand", "params": {"position": [20, 20, 2], "diameter": 6, "height": 10, "hole_diameter": 2.5, "draft": "1 deg"}},
{"command": "FusionRestCommand", "params": {"position": [60, 30, 2], "width": 8, "length": 12, "height": 3, "thickness": 1}},
{"command": "FusionSnapFitCommand", "params": {"position": [40, 12, 2], "direction": [0, 0, 1], "hook": [0, 1, 0], "length": 12, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 2}},
{"command": "FusionManagePlasticRuleCommand", "params": {"name": "ABS (1.5mm)", "active": true}}
]}
@@ -1,7 +1,7 @@
{
"commands": [
{
"command": "sketch.create",
"command": "SketchCreate",
"params": {
"name": "Contour",
"plane": {
@@ -24,7 +24,7 @@
}
},
{
"command": "sketch.line",
"command": "DrawPolyline",
"params": {
"construction": false,
"ids": [
@@ -43,7 +43,7 @@
}
},
{
"command": "sketch.line",
"command": "DrawPolyline",
"params": {
"construction": false,
"ids": [
@@ -59,7 +59,7 @@
}
},
{
"command": "sketch.line",
"command": "DrawPolyline",
"params": {
"construction": false,
"ids": [
@@ -75,11 +75,11 @@
}
},
{
"command": "sketch.finish",
"command": "SketchStop",
"params": {}
},
{
"command": "sheet.flange",
"command": "FusionSheetMetalFlangeCommand",
"params": {
"curves": [
"l1",
+2 -2
View File
@@ -57,7 +57,7 @@ fn old_designs_open_and_evaluate_as_saved() {
}
}
assert_eq!(s.doc.format, solvecraft_engine::doc::format::format_string(solvecraft_engine::doc::format::FORMAT), "{name}: upgraded");
let got = s.execute("inspect.measure", &json!({})).unwrap();
let got = s.execute("MeasureCommand", &json!({})).unwrap();
if got["body_count"] != want["body_count"] && !known(&name, "body_count") {
problems.push(format!("{name}: {} bodies, saved with {}", got["body_count"], want["body_count"]));
}
@@ -145,7 +145,7 @@ fn at_mut<'a>(v: &'a mut Value, path: &[Value]) -> Option<&'a mut Value> {
fn try_bytes(bytes: &[u8]) {
if let Ok(doc) = solvecraft_engine::io::read_design(bytes) {
let mut s = Session::new(doc);
let _ = s.execute("inspect.measure", &json!({}));
let _ = s.execute("MeasureCommand", &json!({}));
let _ = s.execute("document.inspect", &json!({}));
}
}
+1 -8
View File
@@ -23,11 +23,4 @@ log = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
serde_json = { workspace = true }
web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"BeforeUnloadEvent", "Event", "EventTarget",
] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location"] }
-476
View File
@@ -1,476 +0,0 @@
//! Files in the browser. Designs are kept in the origin private file system (OPFS): read into
//! the engine's in-memory files (`solvecraft_engine::io::vfs`) at start, written back whenever a
//! command writes one. Files come in from the computer through a file picker (the File System
//! Access API where the browser has it, an `<input type=file>` otherwise) or a drop on the page,
//! and go out as downloads (exports, and "Download design"). An autosave of unsaved work is kept
//! in OPFS too and offered again after a reload or a crash.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use js_sys::{Array, Function, Object, Reflect, Uint8Array};
use solvecraft_engine::io::vfs;
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetFileOptions, FileSystemWritableFileStream};
/// Where OPFS files appear to the engine.
pub const OPFS: &str = "/opfs/";
/// Paths written here are handed to the user as downloads.
pub const DOWNLOADS: &str = "/downloads/";
/// Files brought in from the computer.
pub const UPLOAD: &str = "/upload/";
/// The autosave of unsaved work (in OPFS: `recovery.solvecraft` and `recovery.json`).
const RECOVERY: &str = "recovery.solvecraft";
const RECOVERY_META: &str = "recovery.json";
/// What to do with a file brought in from the computer.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Intent {
/// Open it (a design, or STEP/mesh as a new design).
Open,
/// Add its bodies to the open design (STEP, 3MF, STL).
Insert,
}
/// A stored design, as the file panel lists it.
#[derive(Clone, Debug)]
pub struct Entry {
pub name: String,
pub size: u64,
/// Last written (ms since 1970; 0 when unknown).
pub modified: f64,
}
/// State shared between the async browser calls and the app's frames.
#[derive(Default)]
pub struct Files {
pub entries: RefCell<Vec<Entry>>,
/// Files read from the computer: (name, bytes, what to do).
pub incoming: RefCell<Vec<(String, Vec<u8>, Intent)>>,
/// Messages for the status line (errors and confirmations).
pub messages: RefCell<Vec<(String, bool)>>,
/// The stored designs are loaded (the app may open them).
pub ready: Cell<bool>,
/// The file panel is open.
pub panel: Cell<bool>,
/// An autosave left by an earlier session: its design's name and file.
pub recovery: RefCell<Option<(String, Option<String>)>>,
/// The app's context, to wake it when a browser call finishes.
pub ctx: RefCell<Option<egui::Context>>,
}
impl Files {
fn say(&self, text: impl Into<String>, error: bool) {
self.messages.borrow_mut().push((text.into(), error));
self.wake();
}
/// A file arrived from the computer.
fn arrive(&self, name: String, bytes: Vec<u8>, intent: Intent) {
self.incoming.borrow_mut().push((name, bytes, intent));
self.wake();
}
/// Run a frame (the app sleeps until something happens).
fn wake(&self) {
if let Some(c) = self.ctx.borrow().as_ref() {
c.request_repaint();
}
}
}
thread_local! {
/// The private file system isn't there (an insecure page: http on a network address, or an
/// old browser): designs are kept in local storage instead.
static LOCAL: Cell<bool> = const { Cell::new(false) };
}
/// Local storage keys of stored files.
const LOCAL_PREFIX: &str = "solvecraft.file.";
fn local() -> Result<web_sys::Storage, String> {
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).ok_or_else(|| "this browser keeps no local storage".to_string())
}
/// Text as is, anything else as hex (local storage holds strings).
fn encode(bytes: &[u8]) -> String {
match std::str::from_utf8(bytes) {
Ok(t) => format!("t{t}"),
Err(_) => {
let mut s = String::with_capacity(bytes.len() * 2 + 1);
s.push('x');
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
}
}
fn decode(s: &str) -> Vec<u8> {
if let Some(t) = s.strip_prefix('t') {
return t.as_bytes().to_vec();
}
let h = s.strip_prefix('x').unwrap_or("");
(0..h.len() / 2).filter_map(|i| h.get(2 * i..2 * i + 2).and_then(|x| u8::from_str_radix(x, 16).ok())).collect()
}
/// Is the browser's storage local storage (no OPFS here)?
pub fn local_only() -> bool {
LOCAL.with(Cell::get)
}
fn err(e: JsValue) -> String {
e.as_string().or_else(|| Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
}
/// The browser's storage manager: missing on insecure pages (http on a network address) and in
/// old browsers, where calling into it would throw.
fn storage_manager() -> Option<web_sys::StorageManager> {
let nav = web_sys::window()?.navigator();
let sm = Reflect::get(&nav, &"storage".into()).ok().filter(|v| v.is_object())?;
Some(sm.unchecked_into())
}
/// Does the storage manager have `method`?
fn has(sm: &web_sys::StorageManager, method: &str) -> bool {
Reflect::get(sm, &method.into()).is_ok_and(|f| f.is_function())
}
async fn root() -> Result<FileSystemDirectoryHandle, String> {
let sm = storage_manager().filter(|sm| has(sm, "getDirectory")).ok_or("this page has no private file storage (it needs https or localhost)")?;
let dir = JsFuture::from(sm.get_directory()).await.map_err(err)?;
dir.dyn_into::<FileSystemDirectoryHandle>().map_err(|_| "this browser has no private file storage (OPFS)".to_string())
}
async fn read_handle(h: &FileSystemFileHandle) -> Result<Vec<u8>, String> {
let file: web_sys::File = JsFuture::from(h.get_file()).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string())?;
blob_bytes(&file).await
}
async fn blob_bytes(b: &web_sys::Blob) -> Result<Vec<u8>, String> {
let buf = JsFuture::from(b.array_buffer()).await.map_err(err)?;
Ok(Uint8Array::new(&buf).to_vec())
}
async fn file_handle(dir: &FileSystemDirectoryHandle, name: &str, create: bool) -> Result<FileSystemFileHandle, String> {
let opts = FileSystemGetFileOptions::new();
opts.set_create(create);
JsFuture::from(dir.get_file_handle_with_options(name, &opts)).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string())
}
async fn write_file(name: &str, bytes: &[u8]) -> Result<(), String> {
if local_only() {
return local()?
.set_item(&format!("{LOCAL_PREFIX}{name}"), &encode(bytes))
.map_err(|e| format!("local storage is full or blocked: {}", err(e)));
}
let dir = root().await?;
let h = file_handle(&dir, name, true).await?;
let w: FileSystemWritableFileStream =
JsFuture::from(h.create_writable()).await.map_err(err)?.dyn_into().map_err(|_| "no writable stream".to_string())?;
JsFuture::from(w.write_with_u8_array(bytes).map_err(err)?).await.map_err(err)?;
JsFuture::from(w.close()).await.map_err(err)?;
Ok(())
}
async fn remove_file(name: &str) -> Result<(), String> {
if local_only() {
return local()?.remove_item(&format!("{LOCAL_PREFIX}{name}")).map_err(err);
}
let dir = root().await?;
JsFuture::from(dir.remove_entry(name)).await.map_err(err)?;
Ok(())
}
/// Every stored file in local storage.
fn read_local() -> Result<Vec<(String, Vec<u8>, f64)>, String> {
let st = local()?;
let mut out = Vec::new();
for i in 0..st.length().unwrap_or(0).min(10_000) {
let Some(k) = st.key(i).ok().flatten() else { continue };
let Some(name) = k.strip_prefix(LOCAL_PREFIX) else { continue };
if let Some(v) = st.get_item(&k).ok().flatten() {
out.push((name.to_string(), decode(&v), 0.0));
}
}
Ok(out)
}
/// Every stored file: (name, bytes). Without OPFS, local storage takes over from here on.
async fn read_all() -> Result<Vec<(String, Vec<u8>, f64)>, String> {
let dir = match root().await {
Ok(d) => d,
Err(e) => {
log::warn!("no OPFS ({e}): designs go to local storage");
LOCAL.with(|l| l.set(true));
return read_local();
}
};
let iter: JsValue = dir.values().into();
let next: Function = Reflect::get(&iter, &"next".into()).map_err(err)?.dyn_into().map_err(|_| "no iterator".to_string())?;
let mut out = Vec::new();
for _ in 0..10_000 {
let step = JsFuture::from(js_sys::Promise::resolve(&next.call0(&iter).map_err(err)?)).await.map_err(err)?;
if Reflect::get(&step, &"done".into()).ok().and_then(|d| d.as_bool()).unwrap_or(true) {
break;
}
let Ok(h) = Reflect::get(&step, &"value".into()).map_err(err)?.dyn_into::<FileSystemFileHandle>() else { continue };
let file =
match JsFuture::from(h.get_file()).await.map_err(err).and_then(|f| f.dyn_into::<web_sys::File>().map_err(|_| "not a file".to_string())) {
Ok(f) => f,
Err(e) => {
log::warn!("{}: {e}", h.name());
continue;
}
};
match blob_bytes(&file).await {
Ok(b) => out.push((h.name(), b, file.last_modified())),
Err(e) => log::warn!("{}: {e}", h.name()),
}
}
Ok(out)
}
/// Ask the browser to keep the storage (not evict it under pressure), then load every stored
/// file into the engine's files.
pub fn start(files: &Rc<Files>) {
if let Some(sm) = storage_manager().filter(|sm| has(sm, "persist"))
&& let Ok(p) = sm.persist()
{
spawn_local(async move {
let _ = JsFuture::from(p).await;
});
}
let files = files.clone();
spawn_local(async move {
match read_all().await {
Ok(all) => {
if local_only() {
files.say("This page can't use the browser's file storage (it needs https or localhost): designs are kept in local storage, a few MB at most.", false);
}
let mut entries = Vec::new();
for (name, bytes, modified) in all {
if name == RECOVERY_META {
let meta: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
let n = meta["name"].as_str().unwrap_or("Untitled").to_string();
*files.recovery.borrow_mut() = Some((n, meta["path"].as_str().map(str::to_string)));
continue;
}
if name != RECOVERY {
entries.push(Entry { name: name.clone(), size: bytes.len() as u64, modified });
}
vfs::insert(&format!("{OPFS}{name}"), bytes);
}
entries.sort_by_key(|a| a.name.to_lowercase());
*files.entries.borrow_mut() = entries;
// An autosave without its design file is nothing to recover.
if !vfs::exists(&format!("{OPFS}{RECOVERY}")) {
*files.recovery.borrow_mut() = None;
}
}
Err(e) => files.say(format!("browser storage: {e}"), true),
}
files.ready.set(true);
files.wake();
});
}
/// Store what commands wrote: OPFS paths into OPFS, download paths as downloads.
pub fn flush(files: &Rc<Files>) {
for (path, bytes) in vfs::take_writes() {
if let Some(name) = path.strip_prefix(OPFS) {
let name = name.to_string();
{
let mut e = files.entries.borrow_mut();
e.retain(|x| x.name != name);
if name != RECOVERY && name != RECOVERY_META {
e.push(Entry { name: name.clone(), size: bytes.len() as u64, modified: js_sys::Date::now() });
e.sort_by_key(|a| a.name.to_lowercase());
}
}
let files = files.clone();
spawn_local(async move {
if let Err(e) = write_file(&name, &bytes).await {
files.say(format!("{name}: {e}"), true);
}
});
} else if let Some(name) = path.strip_prefix(DOWNLOADS)
&& let Err(e) = download(name, &bytes)
{
files.say(format!("{name}: {e}"), true);
}
}
}
/// Hand `bytes` to the user as a download named `name`.
pub fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let parts = Array::new();
parts.push(&Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type("application/octet-stream");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(err)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(err)?;
let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(err)?.dyn_into().map_err(|_| "no anchor".to_string())?;
a.set_href(&url);
a.set_download(name);
a.click();
// Let the browser start the download before the URL goes.
let revoke = Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
if let Some(w) = web_sys::window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000);
}
Ok(())
}
/// Delete a stored design.
pub fn delete(files: &Rc<Files>, name: &str) {
let _ = vfs::remove(&format!("{OPFS}{name}"));
files.entries.borrow_mut().retain(|e| e.name != name);
let (files, name) = (files.clone(), name.to_string());
spawn_local(async move {
if let Err(e) = remove_file(&name).await {
files.say(format!("{name}: {e}"), true);
}
});
}
/// Rename a stored design (a copy under the new name, then the old one goes: not every browser
/// can move OPFS files).
pub fn rename(files: &Rc<Files>, from: &str, to: &str) -> Result<(), String> {
let to = to.trim();
if to.is_empty() || to.contains('/') || to.contains('\\') || to.len() > 200 {
return Err("give a file name without slashes".into());
}
if files.entries.borrow().iter().any(|e| e.name == to) {
return Err(format!("there is already a file named {to}"));
}
let bytes = vfs::read(&format!("{OPFS}{from}")).map_err(|e| e.to_string())?;
vfs::write(&format!("{OPFS}{to}"), &bytes).map_err(|e| e.to_string())?;
delete(files, from);
flush(files);
Ok(())
}
/// Keep (or drop) the autosave of the open design.
pub fn autosave(files: &Rc<Files>, doc: Option<(&solvecraft_engine::doc::Document, Option<&str>)>) {
let files = files.clone();
match doc {
Some((d, path)) => {
let bytes = solvecraft_engine::io::write_design(d);
let meta = serde_json::json!({"name": d.name, "path": path, "saved_at": js_sys::Date::now()}).to_string();
spawn_local(async move {
let r = async {
write_file(RECOVERY, &bytes).await?;
write_file(RECOVERY_META, meta.as_bytes()).await
};
if let Err(e) = r.await {
files.say(format!("autosave: {e}"), true);
}
});
}
None => spawn_local(async move {
let _ = remove_file(RECOVERY).await;
let _ = remove_file(RECOVERY_META).await;
}),
}
}
/// A stored path for `name` that no stored file has: `name`, else `name (2)`, `name (3)`…
pub fn free_name(name: &str) -> String {
let (stem, ext) = name.rsplit_once('.').map(|(s, e)| (s.to_string(), format!(".{e}"))).unwrap_or((name.to_string(), String::new()));
for i in 1..1000 {
let n = if i == 1 { name.to_string() } else { format!("{stem} ({i}){ext}") };
let p = format!("{OPFS}{n}");
if !vfs::exists(&p) {
return p;
}
}
format!("{OPFS}{stem} ({}){ext}", js_sys::Date::now() as u64)
}
/// The autosaved design's path (in the engine's files) for opening it.
pub fn recovery_path() -> String {
format!("{OPFS}{RECOVERY}")
}
/// Pick files on the computer: the File System Access picker where there is one, else an
/// `<input type=file>`. Read files arrive in `files.incoming`.
pub fn pick_from_disk(files: &Rc<Files>, intent: Intent) {
let Some(window) = web_sys::window() else { return };
let picker = Reflect::get(&window, &"showOpenFilePicker".into()).ok().and_then(|f| f.dyn_into::<Function>().ok());
match picker {
Some(f) => {
let opts = Object::new();
let _ = Reflect::set(&opts, &"multiple".into(), &JsValue::FALSE);
let promise = f.call1(&window, &opts);
let files = files.clone();
spawn_local(async move {
let r = async {
let p: js_sys::Promise = promise.map_err(err)?.dyn_into().map_err(|_| "no promise".to_string())?;
let handles: Array = JsFuture::from(p).await.map_err(err)?.dyn_into().map_err(|_| "no files".to_string())?;
for h in handles.iter() {
let h: FileSystemFileHandle = h.dyn_into().map_err(|_| "not a file".to_string())?;
let bytes = read_handle(&h).await?;
files.arrive(h.name(), bytes, intent);
}
Ok::<(), String>(())
};
match r.await {
Ok(()) => {}
// Closing the picker is no error.
Err(e) if e.contains("abort") || e.contains("Abort") => {}
Err(e) => files.say(format!("open: {e}"), true),
}
});
}
None => input_picker(files, intent),
}
}
fn input_picker(files: &Rc<Files>, intent: Intent) {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_accept(".solvecraft,.step,.stp,.3mf,.stl");
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
let _ = input.set_attribute("style", "display:none");
if let Some(body) = doc.body() {
let _ = body.append_child(&input);
}
let (files, el) = (files.clone(), input.clone());
let on_change = Closure::once_into_js(move || {
el.remove();
let Some(list) = el.files() else { return };
for i in 0..list.length() {
let Some(f) = list.get(i) else { continue };
let files = files.clone();
spawn_local(async move {
match blob_bytes(&f).await {
Ok(b) => files.arrive(f.name(), b, intent),
Err(e) => files.say(format!("{}: {e}", f.name()), true),
}
});
}
});
input.set_onchange(Some(on_change.unchecked_ref()));
input.click();
}
/// Read files dropped on the page.
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput) {
for f in std::mem::take(&mut raw.dropped_files) {
let files = files.clone();
spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(b) => files.arrive(name, b, Intent::Insert),
Err(e) => files.say(format!("{name}: {e}"), true),
}
});
}
}
+8 -332
View File
@@ -6,324 +6,30 @@
//! sample part.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod files;
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
use solvecraft_engine::Session;
use solvecraft_engine::io::vfs;
use solvecraft_ui_egui::{Services, SolveApp};
use wasm_bindgen::JsCast as _;
use crate::files::{self, Files, Intent};
const CANVAS_ID: &str = "solvecraft_canvas";
const LOADING_ID: &str = "solvecraft_loading";
const DEPTH_BITS: u8 = 24;
/// Seconds between autosaves of unsaved work.
const AUTOSAVE_S: f64 = 30.0;
thread_local! {
/// An open design has unsaved changes: leaving the page asks first.
static UNSAVED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
/// unsaved changes (the page can't show its own dialog when it is being closed).
fn ask_before_unload(window: &web_sys::Window) {
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
if UNSAVED.with(std::cell::Cell::get) {
e.prevent_default();
e.set_return_value("unsaved");
}
});
let _ = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref());
// The listener lives as long as the page.
on_unload.forget();
}
struct Shell {
app: SolveApp,
files: Rc<Files>,
/// When the last autosave was (seconds), and the revision it holds (none stored: `None`).
autosaved_at: f64,
autosaved: Option<u64>,
/// A stored design being renamed: (its name, the new name).
renaming: Option<(String, String)>,
/// The rename field takes the keyboard when it appears.
rename_focus: bool,
/// The start page's recent designs were filled from the stored ones.
recents_synced: bool,
}
impl Shell {
/// Files from the computer, writes to store, messages and the autosave.
fn files_tick(&mut self, ctx: &egui::Context) {
if self.files.ctx.borrow().is_none() {
*self.files.ctx.borrow_mut() = Some(ctx.clone());
}
files::flush(&self.files);
for (text, error) in self.files.messages.borrow_mut().drain(..) {
self.app.set_status(text, error);
}
let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
for (name, bytes, intent) in incoming {
let design = name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
let path = if design && intent == Intent::Open {
// A design from the computer is kept in the browser (a copy), so Save works.
let path = files::free_name(&name);
if let Err(e) = vfs::write(&path, &bytes) {
self.app.set_status(e.to_string(), true);
}
files::flush(&self.files);
path
} else {
let path = format!("{}{name}", files::UPLOAD);
vfs::insert(&path, bytes);
path
};
match intent {
Intent::Open => self.app.open_path(&path),
Intent::Insert => self.app.drop_path(&path),
}
self.files.panel.set(false);
}
if self.files.ready.get() && !self.recents_synced {
self.recents_synced = true;
self.sync_recents();
}
let now = ctx.input(|i| i.time);
// Unsaved work is kept (at most every AUTOSAVE_S); saved work needs no autosave.
if self.files.ready.get() && self.files.recovery.borrow().is_none() {
let s = &self.app.session;
let changed = s.is_dirty() && self.autosaved != Some(s.revision);
let wait = AUTOSAVE_S - (now - self.autosaved_at);
if changed && wait <= 0.0 {
files::autosave(&self.files, Some((&s.doc, s.path.as_deref())));
self.autosaved = Some(s.revision);
self.autosaved_at = now;
} else if changed {
// Come back for it even when nothing else happens.
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
} else if !s.is_dirty() && self.autosaved.is_some() {
files::autosave(&self.files, None);
self.autosaved = None;
}
}
if !self.files.ready.get() || !self.files.incoming.borrow().is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
/// The start page's recent designs are the stored ones: the remembered order first (those
/// still stored), then the others, newest first.
fn sync_recents(&mut self) {
let mut entries = self.files.entries.borrow().clone();
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
let recent = &mut self.app.home.recent;
recent.retain(|p| vfs::exists(p));
for e in entries.iter().filter(|e| e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT))) {
let p = format!("{}{}", files::OPFS, e.name);
if !recent.contains(&p) {
recent.push(p);
}
}
recent.truncate(solvecraft_ui_egui::home::MAX_RECENT);
}
/// The stored designs: open, insert, rename, delete, download; open from the computer.
fn file_panel(&mut self, ctx: &egui::Context) {
if !self.files.panel.get() {
return;
}
let mut open = true;
let mut act: Option<(&'static str, String)> = None;
egui::Window::new("Files in this browser")
.open(&mut open)
.collapsible(false)
.resizable(true)
.default_width(520.0)
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.show(ctx, |ui| {
ui.horizontal(|ui| {
if ui.button("Open from computer…").clicked() {
files::pick_from_disk(&self.files, Intent::Open);
}
if ui.button("Insert from computer…").on_hover_text("STEP, 3MF or STL into the open design").clicked() {
files::pick_from_disk(&self.files, Intent::Insert);
}
if ui.button("Download open design").clicked() {
act = Some(("download_open", String::new()));
}
});
ui.separator();
if !self.files.ready.get() {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Reading the browser's storage…");
});
return;
}
let entries = self.files.entries.borrow().clone();
if entries.is_empty() {
ui.label("Designs you save appear here. They stay in this browser.");
}
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
egui::Grid::new("opfs_files").num_columns(3).spacing([12.0, 6.0]).show(ui, |ui| {
for e in &entries {
match &mut self.renaming {
Some((from, to)) if *from == e.name => {
let r = ui.add(egui::TextEdit::singleline(to).desired_width(200.0));
if std::mem::take(&mut self.rename_focus) {
r.request_focus();
}
if r.lost_focus() {
act = Some(if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
("rename", to.clone())
} else {
("rename_cancel", String::new())
});
}
}
_ => {
ui.label(&e.name);
}
}
ui.label(format!("{:.1} KB", e.size as f64 / 1024.0));
ui.horizontal(|ui| {
if ui.small_button("Open").clicked() {
act = Some(("open", e.name.clone()));
}
let design = e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
if !design && ui.small_button("Insert").on_hover_text("Into the open design (STEP, 3MF, STL)").clicked() {
act = Some(("insert", e.name.clone()));
}
if ui.small_button("Rename").clicked() {
self.renaming = Some((e.name.clone(), e.name.clone()));
self.rename_focus = true;
}
if ui.small_button("Download").clicked() {
act = Some(("download", e.name.clone()));
}
if ui.small_button("Delete").clicked() {
act = Some(("delete", e.name.clone()));
}
});
ui.end_row();
}
});
});
});
if !open {
self.files.panel.set(false);
self.renaming = None;
}
let Some((what, name)) = act else { return };
let path = format!("{}{name}", files::OPFS);
match what {
"open" => {
self.app.open_path(&path);
self.files.panel.set(false);
}
"insert" => {
self.app.insert_path(&path);
self.files.panel.set(false);
}
"download" => match vfs::read(&path) {
Ok(b) => {
if let Err(e) = files::download(&name, &b) {
self.app.set_status(e, true);
}
}
Err(e) => self.app.set_status(e.to_string(), true),
},
"download_open" => {
let d = &self.app.session.doc;
let bytes = solvecraft_engine::io::write_design(d);
if let Err(e) = files::download(&format!("{}.{}", d.name, solvecraft_engine::io::DESIGN_EXT), &bytes) {
self.app.set_status(e, true);
}
}
"delete" => {
files::delete(&self.files, &name);
self.app.home.recent.retain(|r| *r != path);
}
"rename_cancel" => self.renaming = None,
"rename" => {
if let Some((from, _)) = self.renaming.take() {
match files::rename(&self.files, &from, &name) {
Ok(()) => {
// Recent designs follow the new name.
let (old, new) = (format!("{}{from}", files::OPFS), format!("{}{name}", files::OPFS));
for r in &mut self.app.home.recent {
if *r == old {
*r = new.clone();
}
}
}
Err(e) => self.app.set_status(e, true),
}
}
}
_ => {}
}
}
/// Offer the autosave an earlier session left.
fn recovery_prompt(&mut self, ctx: &egui::Context) {
let Some((name, path)) = self.files.recovery.borrow().clone() else { return };
let mut answer = None;
egui::Window::new("Recover unsaved work?").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(
ctx,
|ui| {
ui.label(format!("“{name}” had unsaved changes when this page last closed."));
ui.horizontal(|ui| {
if ui.button("Recover").clicked() {
answer = Some(true);
}
if ui.button("Discard").clicked() {
answer = Some(false);
}
});
},
);
let Some(recover) = answer else { return };
*self.files.recovery.borrow_mut() = None;
if recover {
self.app.open_path(&files::recovery_path());
// It belongs to its own file again (saving goes there); it is unsaved.
self.app.session.path = path;
self.app.session.doc_mut().name = name;
self.app.session.mark_unsaved();
self.app.home.open = false;
}
files::autosave(&self.files, None);
}
}
struct Shell(SolveApp);
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.app.logic(ctx);
self.files_tick(ctx);
UNSAVED.with(|u| u.set(solvecraft_ui_egui::documents::any_dirty(&self.app)));
self.0.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
files::take_drops(&self.files, raw);
self.app.raw_input_hook(raw);
self.0.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
let ctx = ui.ctx().clone();
self.file_panel(&ctx);
self.recovery_prompt(&ctx);
self.0.ui(ui);
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string(PREFS_KEY, self.app.prefs());
storage.set_string(PREFS_KEY, self.0.prefs());
}
}
@@ -337,9 +43,7 @@ mod web {
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
let Some(window) = web_sys::window() else { return };
ask_before_unload(&window);
let Some(document) = window.document() else { return };
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
@@ -355,23 +59,7 @@ mod web {
canvas,
options,
Box::new(move |cc| {
let files = Rc::new(Files::default());
files::start(&files);
let panel = files.clone();
let services = Services {
// Open shows the browser's files (and the computer's, from there).
pick_open: Some(Box::new(move || {
panel.panel.set(true);
None
})),
// Designs are kept in the browser; everything else is downloaded.
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
let design = exts.contains(&solvecraft_engine::io::DESIGN_EXT);
Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))
})),
..Default::default()
};
let mut app = SolveApp::new(Session::default(), services);
let mut app = SolveApp::new(Session::default(), Services::default());
if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) {
app.load_prefs(&p);
}
@@ -385,19 +73,7 @@ mod web {
app.session.doc_mut().name = "Sample Plate".into();
app.session.mark_saved();
}
if !query().contains("sample") {
let rev = app.session.revision;
app.home.show(rev);
}
Ok(Box::new(Shell {
app,
files,
autosaved_at: f64::NEG_INFINITY,
autosaved: None,
renaming: None,
rename_focus: false,
recents_synced: false,
}))
Ok(Box::new(Shell(app)))
}),
)
.await;
+2 -12
View File
@@ -6,7 +6,7 @@
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
//!
//! `--control <port>` (or `SOLVECRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
//! server: `{"id":1,"method":"engine.execute","params":{"command":"PrimitiveBox","params":{…}}}`.
//! See `solvecraft_ui_egui::control` and docs/control-protocol.md.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -21,12 +21,6 @@ struct App(SolveApp);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
// designs first; the window closes once they are answered (`quit_requested`).
if ctx.input(|i| i.viewport().close_requested()) && !self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
solvecraft_ui_egui::documents::request_quit(&mut self.0);
}
self.0.logic(ctx);
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
@@ -53,13 +47,9 @@ fn services() -> Services {
Services {
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter(
"Designs, STEP, IGES and meshes",
&["solvecraft", "step", "stp", "STEP", "STP", "igs", "iges", "IGS", "IGES", "3mf", "3MF", "stl", "STL"],
)
.add_filter("Designs, STEP and meshes", &["solvecraft", "step", "stp", "STEP", "STP", "3mf", "3MF", "stl", "STL"])
.add_filter("SolveCraft design", &["solvecraft"])
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
.add_filter("All files", &["*"])
.pick_file()
+8 -40
View File
@@ -47,33 +47,6 @@ pub struct FaceLook {
pub name: Option<String>,
}
/// The look a body was imported with, carried through later features (which build new kernel
/// bodies without it): its colour and opacity, and its faces' colours by persistent face name.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ImportedLook {
pub color: Option<[f32; 3]>,
pub opacity: Option<f32>,
pub faces: BTreeMap<String, ([f32; 3], f32)>,
}
impl ImportedLook {
/// A body's imported look: its own kernel paint, else what it carries.
pub fn of(b: &crate::ModelBody) -> Option<std::sync::Arc<ImportedLook>> {
if b.body.paint().is_none() && b.body.color().is_none() {
return b.imported.clone();
}
let names = crate::naming::face_names(b);
let faces = b
.body
.paint()
.map(|p| {
p.faces.iter().filter_map(|f| names.get(f.face).map(|n| (crate::naming::strip_piece(n).to_string(), (f.color, f.opacity)))).collect()
})
.unwrap_or_default();
Some(std::sync::Arc::new(ImportedLook { color: b.body.color(), opacity: b.body.paint().map(|p| p.opacity), faces }))
}
}
/// The design's appearances.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Appearances {
@@ -153,20 +126,15 @@ impl Document {
/// assignment to the same face wins.
pub fn face_colors(&self, b: &crate::ModelBody) -> Vec<(usize, Look)> {
// Face colours the body was imported with, under the design's own.
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
let mut out: Vec<(usize, Look)> = b
.body
.paint()
.map(|p| {
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
p.faces.iter().map(|f| (f.face, Look::custom(f.color.map(to8), f.opacity as f64))).collect()
})
.unwrap_or_default();
let names = crate::naming::face_names(b);
let mut out: Vec<(usize, Look)> = match b.body.paint() {
Some(p) => p.faces.iter().map(|f| (f.face, Look::custom(f.color.map(to8), f.opacity as f64))).collect(),
// Carried from an earlier version of the body: by face name.
None => match &b.imported {
Some(imp) => names
.iter()
.enumerate()
.filter_map(|(i, n)| imp.faces.get(crate::naming::strip_piece(n)).map(|(c, o)| (i, Look::custom(c.map(to8), *o as f64))))
.collect(),
None => Vec::new(),
},
};
for f in self.appearances.faces.iter().filter(|f| f.body == b.name) {
let (p, look) = (f.point, f.look.clone());
// By name (every piece of a split face), else by the point.
+6 -89
View File
@@ -144,60 +144,25 @@ fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
}
// Named origin planes and construction planes are the component's own.
PlaneRef::Origin { .. } | PlaneRef::Construction { .. } => {}
PlaneRef::Tangent { face, at } => {
*at = apply_point(m, *at);
map_geo_ref(m, face);
}
PlaneRef::Midplane { .. } | PlaneRef::TwoEdges { .. } | PlaneRef::ThreePoints { .. } | PlaneRef::AlongPath { .. } => {
for r in p.refs_mut() {
map_geo_ref(m, r);
}
}
}
}
fn map_geo_ref(m: &Mat, g: &mut crate::construct::GeoRef) {
g.map_points(&mut |p: &mut Vec3| *p = apply_point(m, *p), &mut |pl: &mut PlaneRef| map_plane_ref(m, pl));
}
impl FeatureKind {
/// Map the feature's world geometry (points, directions, explicit planes) by `m`.
pub fn transform_geometry(&mut self, m: &Mat) {
let pt = |p: &mut Vec3| *p = apply_point(m, *p);
let dir = |v: &mut Vec3| *v = apply_vector(m, *v);
match self {
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Mirror { plane, .. } => {
map_plane_ref(m, plane)
}
FeatureKind::ConstructionAxis { def } => def.refs_mut().into_iter().for_each(|r| map_geo_ref(m, r)),
FeatureKind::ConstructionPoint { def } => def.refs_mut().into_iter().for_each(|r| map_geo_ref(m, r)),
FeatureKind::Split { plane, tool, .. } => {
map_plane_ref(m, plane);
if let Some(t) = tool {
pt(&mut t.point);
}
}
FeatureKind::SplitFace { faces, plane, tool, .. } => {
faces.iter_mut().for_each(pt);
map_plane_ref(m, plane);
if let Some(t) = tool {
pt(&mut t.point);
}
}
FeatureKind::Sketch { plane, .. }
| FeatureKind::ConstructionPlane { plane }
| FeatureKind::Split { plane, .. }
| FeatureKind::Mirror { plane, .. } => map_plane_ref(m, plane),
FeatureKind::Revolve { axis: AxisRef::Line { origin, dir: d }, .. } => {
pt(origin);
dir(d);
}
FeatureKind::Fillet { edges, style, .. } => {
edges.iter_mut().for_each(pt);
if let crate::FilletStyle::Variable { start, .. } = style {
pt(start);
}
}
FeatureKind::Chamfer { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::Shell { faces, .. } | FeatureKind::OffsetFace { faces, .. } | FeatureKind::DeleteFace { faces, .. } => {
faces.iter_mut().for_each(pt)
}
FeatureKind::Fillet { edges, .. } | FeatureKind::Chamfer { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::Shell { faces, .. } | FeatureKind::OffsetFace { faces, .. } => faces.iter_mut().for_each(pt),
FeatureKind::Draft { faces, neutral, pull, .. } => {
faces.iter_mut().for_each(pt);
map_plane_ref(m, neutral);
@@ -240,7 +205,6 @@ impl FeatureKind {
dir(direction);
}
FeatureKind::Lip { face, .. } => pt(face),
FeatureKind::BoundaryFill { cells, .. } => cells.iter_mut().for_each(pt),
FeatureKind::Rest { position, direction, along, .. } => {
pt(position);
dir(direction);
@@ -288,18 +252,8 @@ impl FeatureKind {
| FeatureKind::Emboss { .. }
| FeatureKind::Rib { .. }
| FeatureKind::Remove { .. }
| FeatureKind::Stitch { .. }
| FeatureKind::Thicken { .. }
| FeatureKind::Import { .. }
| FeatureKind::MeshImport { .. } => {}
FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
map_plane_ref(m, plane);
if let Some(t) = tool {
pt(&mut t.point);
}
pt(keep);
}
}
}
}
@@ -344,43 +298,6 @@ impl Document {
out
}
/// Every body as the world shows it: its world name ("Body1", or "Body1 (Occ:2)" for a
/// further instance), its component, the occurrence path, and the transform from the frame
/// its geometry is in to the world.
pub fn body_frames(&self, st: &crate::ModelState) -> Vec<(String, u64, Vec<u64>, Mat)> {
let mut out = Vec::new();
for (comp, path, m) in self.placements() {
let primary = path == self.first_path(comp);
let label = || -> String {
path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::<Vec<_>>().join("/")
};
for b in st.bodies.iter().filter(|b| self.body_component(&b.name, b.feature) == comp) {
let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) };
let w = match self.body_offsets.get(&b.name) {
Some(o) => mat_mul(&m, o),
None => m,
};
out.push((name, comp, path.clone(), w));
}
}
out
}
/// The occurrence path of a component's first placement.
pub fn first_path(&self, component: u64) -> Vec<u64> {
let mut path = Vec::new();
let mut c = component;
for _ in 0..1000 {
if c == 0 {
break;
}
let Some(o) = self.occurrence_of(c) else { break };
path.insert(0, o.id);
c = o.parent;
}
path
}
/// Is `a` inside `b` (or `b` itself)?
pub fn component_within(&self, a: u64, b: u64) -> bool {
let mut c = a;
-6
View File
@@ -40,12 +40,6 @@ impl FeatureKind {
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Split { plane, .. } => {
plane_names(plane, names)
}
FeatureKind::SplitFace { plane, sketch, .. } => {
plane_names(plane, names);
if let Some(s) = sketch {
sk(s);
}
}
FeatureKind::Extrude { sketch, .. }
| FeatureKind::Revolve { sketch, .. }
| FeatureKind::Emboss { sketch, .. }
-643
View File
@@ -1,643 +0,0 @@
//! Construction geometry from the model: references to vertices, edges, faces, planes, axes,
//! points and sketch entities, resolved on each evaluation, and the axes, points and planes
//! built from them (Fusion's CONSTRUCT panel).
//!
//! Face and edge references carry the persistent name of what was picked (see `naming`), so
//! they follow the model when earlier features change; the picked point is the fallback.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use solvecraft_geom::{Plane, Vec2, Vec3};
use solvecraft_kernel::{self as kernel, FaceSurface};
use crate::eval::{ModelBody, ModelState};
use crate::expr::{Kind, Value};
use crate::{DocError, Document, PlaneRef, Result};
/// Something picked to build construction geometry from.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum GeoRef {
/// A fixed point.
Point { p: Vec3 },
/// The design origin.
Origin,
/// A body vertex (the one nearest `at`).
Vertex { at: Vec3 },
/// A body edge: the one named `name`, else the one nearest `at`.
Edge {
at: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// A body face: the one named `name`, else the one at `at`.
Face {
at: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// A plane (origin, construction, offset…).
Plane { plane: PlaneRef },
/// World X, Y or Z axis.
World { axis: String },
/// A construction axis feature, by name.
Axis { name: String },
/// A construction point feature, by name.
ConstructPoint { name: String },
/// A sketch point (by sketch feature id and point id).
SketchPoint { sketch: u64, point: String },
/// A sketch curve (by sketch feature id and curve id).
SketchCurve { sketch: u64, curve: String },
}
/// How a construction axis is defined.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AxisDef {
/// The axis of a cylinder, cone or torus face (or a circular edge).
ThroughFace { face: GeoRef },
/// Perpendicular to a face, through a point (projected onto the face).
NormalToFace { face: GeoRef, point: GeoRef },
/// Where two planes meet.
TwoPlanes { a: GeoRef, b: GeoRef },
/// Through two points.
TwoPoints { a: GeoRef, b: GeoRef },
/// Along a straight edge (or sketch line).
Edge { edge: GeoRef },
}
/// How a construction point is defined.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PointDef {
/// At a vertex, sketch point or fixed point.
At { point: GeoRef },
/// Where two edges (lines) meet, or come closest.
TwoEdges { a: GeoRef, b: GeoRef },
/// Where three planes meet.
ThreePlanes { a: GeoRef, b: GeoRef, c: GeoRef },
/// The centre of a circular edge, a sphere or a torus.
Center { of: GeoRef },
/// Where an edge (line) crosses a plane.
EdgeAndPlane { edge: GeoRef, plane: GeoRef },
/// At a fraction `t` (0…1) of the way along an edge or sketch curve.
AlongPath { path: GeoRef, t: String },
}
/// Resolved construction geometry, kept in the model state by feature.
#[derive(Clone, Debug, PartialEq)]
pub enum ConstructGeom {
Plane(Plane),
Axis { origin: Vec3, dir: Vec3 },
Point(Vec3),
}
#[derive(Clone, Debug, PartialEq)]
pub struct Construct {
pub feature: u64,
pub name: String,
pub geom: ConstructGeom,
}
/// Everything a reference needs to be resolved.
pub(crate) struct Ctx<'a> {
pub doc: &'a Document,
pub vals: &'a BTreeMap<String, Value>,
pub st: &'a ModelState,
}
fn bad(m: impl Into<String>) -> DocError {
DocError::Invalid(m.into())
}
fn bodies(st: &ModelState) -> impl Iterator<Item = &ModelBody> {
st.bodies.iter().filter(|b| !b.body.is_mesh())
}
fn construct<'a>(st: &'a ModelState, name: &str) -> Result<&'a ConstructGeom> {
st.construct.iter().find(|c| c.name == name).map(|c| &c.geom).ok_or_else(|| DocError::Unknown(format!("construction `{name}`")))
}
/// The face picked: (body, face index, point on it).
fn face_of<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<(&'a ModelBody, usize, Vec3)> {
let at = match name.as_deref().and_then(|n| crate::naming::point_on_face(st, n, at)) {
Some((q, _)) => q,
None => at,
};
for b in bodies(st) {
if let Some(fi) = crate::appearance::face_index_at(b, at) {
return Ok((b, fi, at));
}
}
Err(bad(format!("no face at {at:?}")))
}
/// The edge picked, as a polyline.
fn edge_points(st: &ModelState, at: Vec3, name: &Option<String>) -> Result<Vec<Vec3>> {
let mut at = at;
if let Some(n) = name {
for b in bodies(st) {
if let Ok((q, _)) = crate::naming::find_edge(b, n, at) {
at = q;
break;
}
}
}
let mut best: Option<(f64, Vec<Vec3>)> = None;
for b in bodies(st) {
let tol = (b.body.size() * 1e-3).max(1e-3);
let Ok(edges) = b.body.edges(tol) else { continue };
for e in edges {
let d = e.points.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::MAX, f64::min);
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
best = Some((d, e.points));
}
}
}
match best {
Some((d, pts)) if pts.len() >= 2 && d <= 1e-3 + 1e-2 * pts.iter().fold(0.0, |m: f64, p| m.max(p.len())).max(1.0) => Ok(pts),
_ => Err(bad(format!("no edge at {at:?}"))),
}
}
/// A polyline that is straight: its line.
fn as_line(pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
let (a, b) = (*pts.first()?, *pts.last()?);
let d = (b - a).normalized()?;
let len = (b - a).len();
pts.iter().all(|p| p.dist_to_segment(a, b) <= len * 1e-6 + 1e-9).then_some((a, d))
}
/// A polyline on a circle: (centre, unit normal, radius).
fn as_circle(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64)> {
let n = pts.len();
if n < 4 {
return None;
}
let (a, b, c) = (*pts.first()?, *pts.get(n / 3)?, *pts.get(2 * n / 3)?);
let (ab, ac) = (b - a, c - a);
let nrm = ab.cross(ac);
let l2 = nrm.len2();
if l2 < 1e-24 {
return None;
}
// Circumcentre of abc.
let centre = a + (nrm.cross(ab) * ac.len2() + ac.cross(nrm) * ab.len2()) * (1.0 / (2.0 * l2));
let r = (a - centre).len();
let unit = nrm.normalized()?;
pts.iter()
.all(|p| ((*p - centre).len() - r).abs() <= r * 1e-3 + 1e-6 && (*p - centre).dot(unit).abs() <= r * 1e-3 + 1e-6)
.then_some((centre, unit, r))
}
fn sketch_world(ctx: &Ctx, sketch: u64, q: Vec2) -> Result<Vec3> {
let ss = ctx.st.sketch(sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
Ok(ss.plane.to_world(q))
}
fn sketch_curve_points(ctx: &Ctx, sketch: u64, curve: &str) -> Result<Vec<Vec3>> {
let ss = ctx.st.sketch(sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("curve `{curve}`")))?;
let pl = ss.sketch.polyline(ci);
if pl.len() < 2 {
return Err(bad(format!("curve `{curve}` has no length")));
}
Ok(pl.into_iter().map(|q| ss.plane.to_world(q)).collect())
}
/// A reference as a point.
pub(crate) fn point(ctx: &Ctx, g: &GeoRef) -> Result<Vec3> {
match g {
GeoRef::Point { p } => Ok(*p),
GeoRef::Origin => Ok(Vec3::ZERO),
GeoRef::Vertex { at } => {
let mut best: Option<(f64, Vec3)> = None;
for b in bodies(ctx.st) {
let tol = (b.body.size() * 1e-3).max(1e-3);
let Ok(edges) = b.body.edges(tol) else { continue };
for v in edges.iter().flat_map(|e| [e.points.first().copied(), e.points.last().copied()]).flatten() {
let d = v.dist(*at);
if best.is_none_or(|(bd, _)| d < bd) {
best = Some((d, v));
}
}
}
best.map(|(_, v)| v).ok_or_else(|| bad("there is no vertex"))
}
GeoRef::ConstructPoint { name } => match construct(ctx.st, name)? {
ConstructGeom::Point(p) => Ok(*p),
_ => Err(bad(format!("`{name}` is not a point"))),
},
GeoRef::SketchPoint { sketch, point } => {
let ss = ctx.st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
let q =
ss.sketch.point_index(point).and_then(|i| ss.sketch.point(i)).ok_or_else(|| DocError::Unknown(format!("sketch point `{point}`")))?;
sketch_world(ctx, *sketch, q)
}
// A round edge or face: its centre.
GeoRef::Edge { .. } | GeoRef::Face { .. } | GeoRef::SketchCurve { .. } => centre(ctx, g),
_ => Err(bad("pick a point, vertex or sketch point")),
}
}
/// A reference as a line (origin, unit direction).
pub(crate) fn line(ctx: &Ctx, g: &GeoRef) -> Result<(Vec3, Vec3)> {
match g {
GeoRef::Edge { at, name } => {
let pts = edge_points(ctx.st, *at, name)?;
as_line(&pts).or_else(|| as_circle(&pts).map(|(c, n, _)| (c, n))).ok_or_else(|| bad("the edge is neither straight nor circular"))
}
GeoRef::SketchCurve { sketch, curve } => {
let pts = sketch_curve_points(ctx, *sketch, curve)?;
as_line(&pts).ok_or_else(|| bad(format!("sketch curve `{curve}` is not a line")))
}
GeoRef::World { axis } => {
let d = match axis.to_ascii_uppercase().as_str() {
"X" => Vec3::X,
"Y" => Vec3::Y,
"Z" => Vec3::Z,
_ => return Err(DocError::Unknown(format!("axis `{axis}`"))),
};
Ok((Vec3::ZERO, d))
}
GeoRef::Axis { name } => match construct(ctx.st, name)? {
ConstructGeom::Axis { origin, dir } => Ok((*origin, *dir)),
_ => Err(bad(format!("`{name}` is not an axis"))),
},
GeoRef::Face { at, name } => {
let (b, fi, _) = face_of(ctx.st, *at, name)?;
match kernel::face_surface(&b.body, fi) {
Some(FaceSurface::Cylinder { origin, axis, .. }) => Ok((origin, axis)),
Some(FaceSurface::Cone { apex, axis, .. }) => Ok((apex, axis)),
Some(FaceSurface::Torus { center, axis, .. }) => Ok((center, axis)),
_ => Err(bad("the face is not a cylinder, cone or torus")),
}
}
_ => Err(bad("pick an edge, line or axis")),
}
}
/// A reference as a plane.
pub(crate) fn plane(ctx: &Ctx, g: &GeoRef) -> Result<Plane> {
match g {
GeoRef::Plane { plane } => ctx.doc.resolve_plane_in(ctx.vals, plane, 0, Some(ctx.st)),
GeoRef::Face { at, name } => {
let (b, fi, q) = face_of(ctx.st, *at, name)?;
match kernel::face_surface(&b.body, fi) {
Some(FaceSurface::Plane { normal, point }) => {
let o = q - normal * (q - point).dot(normal);
Plane::from_normal(o, normal).ok_or_else(|| bad("degenerate face"))
}
_ => Err(bad("the face is not planar")),
}
}
_ => Err(bad("pick a plane or planar face")),
}
}
/// The centre of a round edge, sketch circle, sphere or torus.
fn centre(ctx: &Ctx, g: &GeoRef) -> Result<Vec3> {
match g {
GeoRef::Edge { at, name } => {
let pts = edge_points(ctx.st, *at, name)?;
as_circle(&pts).map(|(c, _, _)| c).ok_or_else(|| bad("the edge is not circular"))
}
GeoRef::SketchCurve { sketch, curve } => {
let pts = sketch_curve_points(ctx, *sketch, curve)?;
as_circle(&pts).map(|(c, _, _)| c).ok_or_else(|| bad(format!("sketch curve `{curve}` is not a circle or arc")))
}
GeoRef::Face { at, name } => {
let (b, fi, _) = face_of(ctx.st, *at, name)?;
match kernel::face_surface(&b.body, fi) {
Some(FaceSurface::Sphere { center, .. }) | Some(FaceSurface::Torus { center, .. }) => Ok(center),
_ => Err(bad("the face is not a sphere or torus")),
}
}
_ => Err(bad("pick a circular edge, a sphere or a torus")),
}
}
/// Point and unit tangent at fraction `t` along a polyline.
fn along(pts: &[Vec3], t: f64) -> Option<(Vec3, Vec3)> {
let total: f64 = pts.windows(2).map(|w| (w[1] - w[0]).len()).sum();
if !(total > 0.0) {
return None;
}
let mut left = t.clamp(0.0, 1.0) * total;
for w in pts.windows(2) {
let l = (w[1] - w[0]).len();
if l <= 0.0 {
continue;
}
if left <= l {
return Some((w[0] + (w[1] - w[0]) * (left / l), (w[1] - w[0]) * (1.0 / l)));
}
left -= l;
}
let (a, b) = (pts.get(pts.len() - 2)?, pts.last()?);
Some((*b, (*b - *a).normalized()?))
}
fn path_points(ctx: &Ctx, g: &GeoRef) -> Result<Vec<Vec3>> {
match g {
GeoRef::Edge { at, name } => edge_points(ctx.st, *at, name),
GeoRef::SketchCurve { sketch, curve } => sketch_curve_points(ctx, *sketch, curve),
_ => Err(bad("pick an edge or sketch curve as the path")),
}
}
/// The surface's unit normal at (the point of the surface nearest) `p`, and that point.
fn surface_normal(s: FaceSurface, p: Vec3) -> Option<(Vec3, Vec3)> {
match s {
FaceSurface::Plane { normal, point } => Some((p - normal * (p - point).dot(normal), normal)),
FaceSurface::Cylinder { origin, axis, radius } => {
let w = p - origin;
let r = (w - axis * w.dot(axis)).normalized()?;
Some((origin + axis * w.dot(axis) + r * radius, r))
}
FaceSurface::Sphere { center, radius } => {
let r = (p - center).normalized()?;
Some((center + r * radius, r))
}
FaceSurface::Cone { apex, axis, half_angle } => {
let w = p - apex;
let radial = (w - axis * w.dot(axis)).normalized()?;
// The generator through p's side, and the normal square to it in the axial plane.
let line = axis * half_angle.cos() + radial * half_angle.sin();
let n = (radial * half_angle.cos() - axis * half_angle.sin()).normalized()?;
Some((apex + line * w.dot(line), n))
}
FaceSurface::Torus { center, axis, major, minor } => {
let w = p - center;
let radial = (w - axis * w.dot(axis)).normalized()?;
let tube = center + radial * major;
let n = (p - tube).normalized()?;
Some((tube + n * minor, n))
}
}
}
fn face_surface_at(ctx: &Ctx, g: &GeoRef) -> Result<(FaceSurface, Vec3)> {
match g {
GeoRef::Face { at, name } => {
let (b, fi, q) = face_of(ctx.st, *at, name)?;
kernel::face_surface(&b.body, fi).map(|s| (s, q)).ok_or_else(|| bad("the face's surface is not a plane, cylinder, cone, sphere or torus"))
}
GeoRef::Plane { .. } => {
let pl = plane(ctx, g)?;
Ok((FaceSurface::Plane { normal: pl.normal(), point: pl.origin }, pl.origin))
}
_ => Err(bad("pick a face")),
}
}
/// Where two planes meet.
fn plane_plane(a: &Plane, b: &Plane) -> Option<(Vec3, Vec3)> {
let (n1, n2) = (a.normal(), b.normal());
let d = n1.cross(n2);
let l2 = d.len2();
if l2 < 1e-18 {
return None;
}
let (h1, h2) = (n1.dot(a.origin), n2.dot(b.origin));
let o = (n2.cross(d) * h1 + d.cross(n1) * h2) * (1.0 / l2);
Some((o, d.normalized()?))
}
/// Closest points of two lines: their midpoint (where they meet, if they do).
fn line_line(a: (Vec3, Vec3), b: (Vec3, Vec3)) -> Option<Vec3> {
let ((p, u), (q, v)) = (a, b);
let w = p - q;
let (uu, uv, vv, uw, vw) = (u.dot(u), u.dot(v), v.dot(v), u.dot(w), v.dot(w));
let den = uu * vv - uv * uv;
if den.abs() < 1e-12 {
return None;
}
let s = (uv * vw - vv * uw) / den;
let t = (uu * vw - uv * uw) / den;
Some(((p + u * s) + (q + v * t)) * 0.5)
}
pub(crate) fn resolve_axis(ctx: &Ctx, d: &AxisDef) -> Result<(Vec3, Vec3)> {
match d {
AxisDef::ThroughFace { face } => line(ctx, face),
AxisDef::Edge { edge } => {
let (o, dir) = line(ctx, edge)?;
if let GeoRef::Edge { at, name } = edge
&& as_line(&edge_points(ctx.st, *at, name)?).is_none()
{
return Err(bad("the edge is not straight"));
}
Ok((o, dir))
}
AxisDef::NormalToFace { face, point: p } => {
let (s, _) = face_surface_at(ctx, face)?;
let q = point(ctx, p)?;
let (on, n) = surface_normal(s, q).ok_or_else(|| bad("the point is on the face's axis"))?;
Ok((on, n))
}
AxisDef::TwoPlanes { a, b } => plane_plane(&plane(ctx, a)?, &plane(ctx, b)?).ok_or_else(|| bad("the planes are parallel")),
AxisDef::TwoPoints { a, b } => {
let (p, q) = (point(ctx, a)?, point(ctx, b)?);
Ok((p, (q - p).normalized().ok_or_else(|| bad("the two points coincide"))?))
}
}
}
pub(crate) fn resolve_point(ctx: &Ctx, d: &PointDef) -> Result<Vec3> {
match d {
PointDef::At { point: p } => point(ctx, p),
PointDef::Center { of } => centre(ctx, of),
PointDef::TwoEdges { a, b } => line_line(line(ctx, a)?, line(ctx, b)?).ok_or_else(|| bad("the edges are parallel")),
PointDef::ThreePlanes { a, b, c } => {
let (pa, pb, pc) = (plane(ctx, a)?, plane(ctx, b)?, plane(ctx, c)?);
let (o, dir) = plane_plane(&pa, &pb).ok_or_else(|| bad("two of the planes are parallel"))?;
line_plane(o, dir, &pc).ok_or_else(|| bad("the planes don't meet in a point"))
}
PointDef::EdgeAndPlane { edge, plane: p } => {
let (o, dir) = line(ctx, edge)?;
line_plane(o, dir, &plane(ctx, p)?).ok_or_else(|| bad("the edge is parallel to the plane"))
}
PointDef::AlongPath { path, t } => {
let t = Document::eval_in(ctx.vals, t, Kind::Unitless)?;
along(&path_points(ctx, path)?, t).map(|(p, _)| p).ok_or_else(|| bad("the path has no length"))
}
}
}
fn line_plane(o: Vec3, d: Vec3, p: &Plane) -> Option<Vec3> {
let n = p.normal();
let den = d.dot(n);
if den.abs() < 1e-12 {
return None;
}
Some(o + d * ((p.origin - o).dot(n) / den))
}
/// Planes built from model geometry (the `PlaneRef` kinds that need the model).
pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
let degenerate = || bad("degenerate plane");
match p {
PlaneRef::Midplane { a, b } => {
let (pa, pb) = (plane(ctx, a)?, plane(ctx, b)?);
let (na, nb) = (pa.normal(), pb.normal());
if na.cross(nb).len() < 1e-9 {
// Parallel: halfway between, facing as the first.
let half = nb * ((pb.origin - pa.origin).dot(nb) * 0.5);
return Plane::new(pa.origin + half, pa.x, pa.y).ok_or_else(degenerate);
}
// Meeting at an angle: the plane through their line that halves the angle.
let (o, dir) = plane_plane(&pa, &pb).ok_or_else(degenerate)?;
let n = (na - nb).normalized().ok_or_else(degenerate)?;
Plane::new(o, dir, n.cross(dir)).ok_or_else(degenerate)
}
PlaneRef::TwoEdges { a, b } => {
let ((o1, d1), (o2, d2)) = (line(ctx, a)?, line(ctx, b)?);
let n = match d1.cross(d2).normalized() {
Some(n) if (o2 - o1).dot(n).abs() <= 1e-6 * (1.0 + (o2 - o1).len()) => n,
Some(_) => return Err(bad("the edges are not in one plane")),
None => d1.cross(o2 - o1).normalized().ok_or_else(|| bad("the edges are on one line"))?,
};
Plane::new(o1, d1, n.cross(d1)).ok_or_else(degenerate)
}
PlaneRef::ThreePoints { a, b, c } => {
let (pa, pb, pc) = (point(ctx, a)?, point(ctx, b)?, point(ctx, c)?);
if (pb - pa).cross(pc - pa).len() < 1e-9 {
return Err(bad("the three points are on one line"));
}
Plane::new(pa, pb - pa, pc - pa).ok_or_else(degenerate)
}
PlaneRef::Tangent { face, at } => {
let (s, _) = face_surface_at(ctx, face)?;
let (on, n) = surface_normal(s, *at).ok_or_else(|| bad("the point is on the face's axis"))?;
Plane::from_normal(on, n).ok_or_else(degenerate)
}
PlaneRef::AlongPath { path, t } => {
let t = Document::eval_in(ctx.vals, t, Kind::Unitless)?;
let (p, tan) = along(&path_points(ctx, path)?, t).ok_or_else(|| bad("the path has no length"))?;
Plane::from_normal(p, tan).ok_or_else(degenerate)
}
_ => ctx.doc.resolve_plane_in(ctx.vals, p, 0, Some(ctx.st)),
}
}
impl GeoRef {
/// Map the reference's world points (component frames).
pub fn map_points(&mut self, pt: &mut dyn FnMut(&mut Vec3), plane: &mut dyn FnMut(&mut PlaneRef)) {
match self {
GeoRef::Point { p } => pt(p),
GeoRef::Vertex { at } | GeoRef::Edge { at, .. } | GeoRef::Face { at, .. } => pt(at),
GeoRef::Plane { plane: p } => plane(p),
_ => {}
}
}
/// Picked faces and edges get their persistent names (and only those still missing one).
pub fn name_picks(&mut self, st: &ModelState) {
match self {
GeoRef::Face { at, name: name @ None } => {
*name = crate::naming::face_names_at(st, &[*at]).into_iter().next().filter(|n| !n.is_empty());
}
GeoRef::Edge { at, name: name @ None } => {
for b in bodies(st) {
let tol = (b.body.size() * 1e-3).max(1e-3);
if b.body.nearest_edge(*at, tol).ok().flatten().is_some_and(|(_, d)| d <= tol * 10.0) {
*name = crate::naming::edge_names_at(b, &[*at]).into_iter().next().filter(|n| !n.is_empty());
break;
}
}
}
GeoRef::Plane { plane } => plane.name_picks(st),
_ => {}
}
}
}
impl GeoRef {
fn plane_ref(&self) -> Option<&PlaneRef> {
match self {
GeoRef::Plane { plane } => Some(plane),
_ => None,
}
}
}
impl AxisDef {
pub fn refs(&self) -> Vec<&GeoRef> {
match self {
AxisDef::ThroughFace { face } => vec![face],
AxisDef::Edge { edge } => vec![edge],
AxisDef::NormalToFace { face, point } => vec![face, point],
AxisDef::TwoPlanes { a, b } | AxisDef::TwoPoints { a, b } => vec![a, b],
}
}
/// Planes it refers to (whose expressions it depends on).
pub fn planes(&self) -> Vec<&PlaneRef> {
self.refs().into_iter().filter_map(GeoRef::plane_ref).collect()
}
pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> {
match self {
AxisDef::ThroughFace { face } => vec![face],
AxisDef::Edge { edge } => vec![edge],
AxisDef::NormalToFace { face, point } => vec![face, point],
AxisDef::TwoPlanes { a, b } | AxisDef::TwoPoints { a, b } => vec![a, b],
}
}
}
impl PointDef {
pub fn refs(&self) -> Vec<&GeoRef> {
match self {
PointDef::At { point } => vec![point],
PointDef::Center { of } => vec![of],
PointDef::TwoEdges { a, b } => vec![a, b],
PointDef::ThreePlanes { a, b, c } => vec![a, b, c],
PointDef::EdgeAndPlane { edge, plane } => vec![edge, plane],
PointDef::AlongPath { path, .. } => vec![path],
}
}
pub fn planes(&self) -> Vec<&PlaneRef> {
self.refs().into_iter().filter_map(GeoRef::plane_ref).collect()
}
pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> {
match self {
PointDef::At { point } => vec![point],
PointDef::Center { of } => vec![of],
PointDef::TwoEdges { a, b } => vec![a, b],
PointDef::ThreePlanes { a, b, c } => vec![a, b, c],
PointDef::EdgeAndPlane { edge, plane } => vec![edge, plane],
PointDef::AlongPath { path, .. } => vec![path],
}
}
}
impl PlaneRef {
/// The references of a plane built from model geometry.
pub fn refs_mut(&mut self) -> Vec<&mut GeoRef> {
match self {
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => vec![a.as_mut(), b.as_mut()],
PlaneRef::ThreePoints { a, b, c } => vec![a.as_mut(), b.as_mut(), c.as_mut()],
PlaneRef::Tangent { face, .. } => vec![face.as_mut()],
PlaneRef::AlongPath { path, .. } => vec![path.as_mut()],
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => base.refs_mut(),
_ => Vec::new(),
}
}
pub fn name_picks(&mut self, st: &ModelState) {
for r in self.refs_mut() {
r.name_picks(st);
}
}
/// Does resolving it need the model (edges, faces, construction axes and points)?
pub fn needs_model(&self) -> bool {
let mut k = self.clone();
!k.refs_mut().is_empty()
}
}
+18 -286
View File
@@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize};
use solvecraft_geom::{Plane, Vec2, Vec3};
use solvecraft_sketch::Sketch;
use crate::construct::{AxisDef, GeoRef, PointDef};
use crate::expr::{self, Kind};
use crate::{DocError, Result};
@@ -44,16 +43,6 @@ pub enum PlaneRef {
AtAngle { base: Box<PlaneRef>, axis_origin: Vec3, axis_dir: Vec3, angle: String },
/// A construction plane feature (by name).
Construction { name: String },
/// Halfway between two planes (or planar faces); bisecting them when they meet at an angle.
Midplane { a: Box<GeoRef>, b: Box<GeoRef> },
/// Through two straight edges (lines) that lie in one plane.
TwoEdges { a: Box<GeoRef>, b: Box<GeoRef> },
/// Through three points.
ThreePoints { a: Box<GeoRef>, b: Box<GeoRef>, c: Box<GeoRef> },
/// Tangent to a face (cylinder, cone, sphere, torus) where the point `at` lies over it.
Tangent { face: Box<GeoRef>, at: Vec3 },
/// Square to an edge or sketch curve, a fraction `t` (expression, 0…1) of the way along it.
AlongPath { path: Box<GeoRef>, t: String },
/// A planar body face: found again on each evaluation (the face with this plane's normal
/// nearest `at`), so sketches on it follow when earlier features change; `plane` is the
/// plane as picked (its frame, and the fallback when the face is gone). `name` is the face's
@@ -132,16 +121,6 @@ pub struct Extent {
/// Taper angle (expression; positive grows the profile).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub taper: Option<String>,
/// To Object: end at the face (or vertex/point) at this point, instead of `distance`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<Vec3>,
/// Offset past the To Object face (expression; negative stops short).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to_offset: Option<String>,
/// From Object: start at the face (or point) here instead of the sketch plane (plus
/// `start_offset`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<Vec3>,
}
/// Revolve axis.
@@ -186,15 +165,6 @@ pub enum FeatureKind {
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<String>,
/// Two Sides: the angle on the other side.
#[serde(default, skip_serializing_if = "Option::is_none")]
angle2: Option<String>,
/// Symmetric: `angle` split evenly either side of the profile.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
symmetric: bool,
/// To Object: turn until the face, vertex or point here.
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<Vec3>,
},
Fillet {
/// Reference points on the edges to round (each re-finds the nearest edge).
@@ -202,10 +172,6 @@ pub enum FeatureKind {
radius: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
/// Constant radius (the default), chord length (`radius` is the width across), or a
/// radius varying along each edge.
#[serde(default, skip_serializing_if = "FilletStyle::is_constant")]
style: FilletStyle,
},
Chamfer {
edges: Vec<Vec3>,
@@ -296,13 +262,9 @@ pub enum FeatureKind {
/// A cosmetic thread in the holes (e.g. "M6x1").
#[serde(default, skip_serializing_if = "Option::is_none")]
thread: Option<String>,
/// To Object: drill down to the face (or point) here instead of `depth`.
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<Vec3>,
},
/// A thread on a cylindrical face. Cosmetic by default (no geometry change; drawn and
/// exported as an annotation); `modeled` cuts the real helical ISO profile into the face.
/// `designation` is an ISO metric size such as "M8" or "M8x1".
/// A cosmetic thread on a cylindrical face (no geometry change; drawn and exported as an
/// annotation). `designation` is an ISO metric size such as "M8" or "M8x1".
Thread {
face: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -310,8 +272,6 @@ pub enum FeatureKind {
/// Thread length from the face's end nearest `face`; `None` = full length.
#[serde(default, skip_serializing_if = "Option::is_none")]
length: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
modeled: bool,
},
/// Ruled loft through profiles of several sketches, in order.
Loft {
@@ -338,12 +298,6 @@ pub enum FeatureKind {
thickness: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
/// inside (default) | outside | both (the wall straddles the faces).
#[serde(default, skip_serializing_if = "String::is_empty")]
direction: String,
/// Open the faces smoothly joined to the picked ones too.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
tangent_chain: bool,
},
/// Tilt faces (at the given points) about a neutral plane, leaning toward `pull`.
Draft {
@@ -358,34 +312,10 @@ pub enum FeatureKind {
ConstructionPlane {
plane: PlaneRef,
},
/// Split a body into the parts on either side of a plane, or of a face of a body (the face
/// through `point`, its surface extended) when `tool` is set.
/// Split a body with a plane into two bodies.
Split {
body: String,
plane: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
},
/// A construction axis (Fusion's CONSTRUCT axes).
ConstructionAxis {
def: AxisDef,
},
/// A construction point (Fusion's CONSTRUCT points).
ConstructionPoint {
def: PointDef,
},
/// Split faces (at the given points) where a tool meets them, the solid unchanged: a plane,
/// a face of a body (`tool`, its surface extended) or a sketch's curves (`sketch`, swept
/// along its normal). The pieces are named after the face (`#k`).
SplitFace {
faces: Vec<Vec3>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
plane: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
#[serde(default, skip_serializing_if = "Option::is_none")]
sketch: Option<u64>,
},
/// Scale bodies about a point (uniform, or per axis).
Scale {
@@ -404,14 +334,6 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// Remove faces (at the given points) and heal the gap from the neighbouring planes, as when
/// a fillet, chamfer, hole or boss is taken off. A picked face brings the rest of its surface
/// (both halves of a hole's wall).
DeleteFace {
faces: Vec<Vec3>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// A box around bodies, grown by a margin.
BoundingSolid {
bodies: Vec<String>,
@@ -676,65 +598,10 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// Boundary Fill: solids from the cells the tool bodies enclose (a cell: inside some
/// tools and outside the rest), each picked by a point inside it.
BoundaryFill {
tools: Vec<String>,
cells: Vec<Vec3>,
#[serde(default)]
operation: Operation,
/// Remove the tool bodies afterwards.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
remove_tools: bool,
},
/// Remove bodies from the model (from here on in the timeline).
Remove {
bodies: Vec<String>,
},
/// Surface bodies: planar patches filling sketch regions (`profiles` of `sketch`), or a
/// closed loop of a body's edges (`edges`: points on them, on `body`).
Patch {
#[serde(default, skip_serializing_if = "Option::is_none")]
sketch: Option<u64>,
#[serde(default = "all_profiles")]
profiles: ProfileSel,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
edges: Vec<Vec3>,
},
/// Surface bodies sewn along shared edges into the first one (a solid when they close).
Stitch {
bodies: Vec<String>,
#[serde(default = "zero_expr")]
tolerance: String,
},
/// Surface bodies thickened into solids along their normals (by half each way when
/// `symmetric`).
Thicken {
bodies: Vec<String>,
thickness: String,
#[serde(default)]
symmetric: bool,
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<String>,
},
/// A planar surface body extended outward by `distance` past the edges through `edges`.
SurfaceExtend {
body: String,
edges: Vec<Vec3>,
distance: String,
},
/// A surface body trimmed by a plane, or by a face of a body (`tool`), keeping the side of
/// `keep`.
SurfaceTrim {
body: String,
plane: PlaneRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
keep: Vec3,
},
Move {
bodies: Vec<String>,
translate: [String; 3],
@@ -763,31 +630,6 @@ pub enum FeatureKind {
},
}
/// How a fillet's size is given.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FilletStyle {
#[default]
Constant,
/// `radius` is the chord: the width across the blend.
Chord,
/// `radius` at the end of each edge nearest `start`, `radius2` at the other, linear between.
Variable { radius2: String, start: Vec3 },
}
impl FilletStyle {
pub fn is_constant(&self) -> bool {
*self == FilletStyle::Constant
}
}
/// A face picked by a point on it, on a named body.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FaceAt {
pub body: String,
pub point: Vec3,
}
/// Triangles of one imported mesh body (millimetres).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MeshData {
@@ -915,10 +757,6 @@ fn plane_exprs<'a>(p: &'a PlaneRef, v: &mut Vec<&'a str>) {
v.push(angle);
p = base;
}
PlaneRef::AlongPath { t, .. } => {
v.push(t);
break;
}
_ => break,
}
}
@@ -972,13 +810,9 @@ impl FeatureKind {
FeatureKind::Shell { .. } => "ShellFeature",
FeatureKind::Draft { .. } => "DraftFeature",
FeatureKind::Split { .. } => "SplitBodyFeature",
FeatureKind::SplitFace { .. } => "SplitFaceFeature",
FeatureKind::ConstructionAxis { .. } => "ConstructionAxis",
FeatureKind::ConstructionPoint { .. } => "ConstructionPoint",
FeatureKind::Move { .. } => "MoveFeature",
FeatureKind::Scale { .. } => "ScaleFeature",
FeatureKind::OffsetFace { .. } => "OffsetFacesFeature",
FeatureKind::DeleteFace { .. } => "DeleteFaceFeature",
FeatureKind::BoundingSolid { .. } => "BoundingSolidFeature",
FeatureKind::Pipe { .. } => "PipeFeature",
FeatureKind::Emboss { .. } => "EmbossFeature",
@@ -987,7 +821,6 @@ impl FeatureKind {
FeatureKind::Lip { .. } => "GrooveFeature",
FeatureKind::SnapFit { .. } => "SnapFitFeature",
FeatureKind::Rest { .. } => "RestFeature",
FeatureKind::BoundaryFill { .. } => "BoundaryFillFeature",
FeatureKind::SheetBase { .. } => "BaseFlangeFeature",
FeatureKind::SheetContour { .. } => "ContourFlangeFeature",
FeatureKind::SheetFlange { .. } => "EdgeFlangeFeature",
@@ -1002,11 +835,6 @@ impl FeatureKind {
FeatureKind::ReplaceFace { .. } => "ReplaceFaceFeature",
FeatureKind::Align { .. } => "AlignFeature",
FeatureKind::Remove { .. } => "RemoveFeature",
FeatureKind::Patch { .. } => "PatchFeature",
FeatureKind::Stitch { .. } => "StitchFeature",
FeatureKind::Thicken { .. } => "ThickenFeature",
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
FeatureKind::SurfaceExtend { .. } => "ExtendFeature",
FeatureKind::Import { .. } => "BaseFeature",
FeatureKind::MeshImport { .. } => "MeshFeature",
}
@@ -1036,13 +864,9 @@ impl FeatureKind {
FeatureKind::Shell { .. } => "Shell",
FeatureKind::Draft { .. } => "Draft",
FeatureKind::Split { .. } => "Split",
FeatureKind::SplitFace { .. } => "SplitFace",
FeatureKind::ConstructionAxis { .. } => "Axis",
FeatureKind::ConstructionPoint { .. } => "Point",
FeatureKind::Move { .. } => "Move",
FeatureKind::Scale { .. } => "Scale",
FeatureKind::OffsetFace { .. } => "OffsetFace",
FeatureKind::DeleteFace { .. } => "DeleteFace",
FeatureKind::BoundingSolid { .. } => "BoundingSolid",
FeatureKind::Pipe { .. } => "Pipe",
FeatureKind::Emboss { .. } => "Emboss",
@@ -1051,7 +875,6 @@ impl FeatureKind {
FeatureKind::Lip { .. } => "Groove",
FeatureKind::SnapFit { .. } => "SnapFit",
FeatureKind::Rest { .. } => "Rest",
FeatureKind::BoundaryFill { .. } => "BoundaryFill",
FeatureKind::SheetBase { .. } => "BaseFlange",
FeatureKind::SheetContour { .. } => "ContourFlange",
FeatureKind::SheetFlange { .. } => "EdgeFlange",
@@ -1066,11 +889,6 @@ impl FeatureKind {
FeatureKind::ReplaceFace { .. } => "ReplaceFace",
FeatureKind::Align { .. } => "Align",
FeatureKind::Remove { .. } => "Remove",
FeatureKind::Patch { .. } => "Patch",
FeatureKind::Stitch { .. } => "Stitch",
FeatureKind::Thicken { .. } => "Thicken",
FeatureKind::SurfaceTrim { .. } => "Trim",
FeatureKind::SurfaceExtend { .. } => "Extend",
FeatureKind::Import { .. } => "Import",
FeatureKind::MeshImport { .. } => "Mesh",
}
@@ -1083,14 +901,8 @@ impl FeatureKind {
FeatureKind::Shell { faces, .. }
| FeatureKind::Draft { faces, .. }
| FeatureKind::OffsetFace { faces, .. }
| FeatureKind::DeleteFace { faces, .. }
| FeatureKind::SplitFace { faces, .. }
| FeatureKind::ReplaceFace { faces, .. } => faces.iter_mut().collect(),
FeatureKind::Hole { position, points: None, to, .. } => std::iter::once(position).chain(to.iter_mut()).collect(),
FeatureKind::Hole { to: Some(t), .. } => vec![t],
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
FeatureKind::Thread { face, .. } => vec![face],
FeatureKind::Hole { position, points: None, .. } => vec![position],
_ => Vec::new(),
}
}
@@ -1103,17 +915,9 @@ impl FeatureKind {
pub fn expressions(&self) -> Vec<&str> {
let mut v: Vec<&str> = Vec::new();
match self {
FeatureKind::ConstructionAxis { def } => def.planes().into_iter().for_each(|p| plane_exprs(p, &mut v)),
FeatureKind::ConstructionPoint { def } => {
if let PointDef::AlongPath { t, .. } = def {
v.push(t);
}
def.planes().into_iter().for_each(|p| plane_exprs(p, &mut v));
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Split { plane, .. } => {
plane_exprs(plane, &mut v)
}
FeatureKind::Sketch { plane, .. }
| FeatureKind::ConstructionPlane { plane }
| FeatureKind::Split { plane, .. }
| FeatureKind::SplitFace { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::Extrude { extent, .. } => {
v.push(&extent.distance);
if let Some(d) = &extent.distance2 {
@@ -1126,16 +930,8 @@ impl FeatureKind {
v.push(d);
}
}
FeatureKind::Revolve { angle, angle2, .. } => {
v.push(angle);
v.extend(angle2.iter().map(String::as_str));
}
FeatureKind::Fillet { radius, style, .. } => {
v.push(radius);
if let FilletStyle::Variable { radius2, .. } = style {
v.push(radius2);
}
}
FeatureKind::Revolve { angle, .. } => v.push(angle),
FeatureKind::Fillet { radius, .. } => v.push(radius),
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
v.push(distance);
v.extend(distance2.iter().map(String::as_str));
@@ -1230,12 +1026,7 @@ impl FeatureKind {
v.extend(depth.iter().map(String::as_str));
}
FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v),
FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } | FeatureKind::DeleteFace { .. } => {}
FeatureKind::Stitch { tolerance, .. } => v.push(tolerance),
FeatureKind::Thicken { thickness, .. } => v.push(thickness),
FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::SurfaceExtend { distance, .. } => v.push(distance),
FeatureKind::Patch { .. } => {}
FeatureKind::Align { .. } | FeatureKind::Remove { .. } => {}
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {}
}
v
@@ -1267,10 +1058,6 @@ pub struct Feature {
/// Persistent names of the faces at the feature's face points (`FeatureKind::face_points`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub face_names: Vec<String>,
/// Bodies a join/cut/intersect may change (any component); empty = the feature's own
/// component's bodies.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub participants: Vec<String>,
#[serde(flatten)]
pub kind: FeatureKind,
}
@@ -1279,10 +1066,6 @@ fn inside_str() -> String {
"inside".into()
}
fn all_profiles() -> ProfileSel {
ProfileSel::All
}
fn zero_expr() -> String {
"0".into()
}
@@ -1291,15 +1074,6 @@ fn is_root(c: &u64) -> bool {
*c == 0
}
/// A standard part (fastener, pin, bearing) inserted as a component.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StandardPart {
pub family: String,
pub size: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub length: Option<f64>,
}
/// The document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
@@ -1330,9 +1104,6 @@ pub struct Document {
/// Plastic rules.
#[serde(default, skip_serializing_if = "crate::plastic::PlasticSettings::is_empty")]
pub plastic: crate::plastic::PlasticSettings,
/// Components that are standard parts (by component id): what to rebuild them from.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub parts: std::collections::BTreeMap<u64, StandardPart>,
/// Configurations (rows of parameter values and suppressions).
#[serde(default, skip_serializing_if = "crate::config::ConfigTable::is_empty")]
pub configs: crate::config::ConfigTable,
@@ -1354,10 +1125,6 @@ pub struct Document {
/// Bodies moved into another component than their feature's (by body name).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub body_components: std::collections::BTreeMap<String, u64>,
/// Bodies moved into another component keep their place in the world: the transform from
/// the new component's frame to the frame the body was made in (by body name).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub body_offsets: std::collections::BTreeMap<String, crate::Mat>,
/// Browser groups (folders of bodies, sketches or construction planes).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub browser_groups: Vec<BrowserGroup>,
@@ -1424,9 +1191,7 @@ impl Document {
sheet: Default::default(),
plastic: Default::default(),
configs: Default::default(),
parts: Default::default(),
body_components: Default::default(),
body_offsets: Default::default(),
materials: Default::default(),
appearances: Default::default(),
favorites: Default::default(),
@@ -1442,7 +1207,7 @@ impl Document {
/// else its component's, else its material's look, else the colour it was imported with.
/// (Faces may have their own: `face_looks`.)
pub fn body_color(&self, b: &crate::ModelBody) -> Option<[f32; 3]> {
self.body_look(&b.name, b.feature).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color))
self.body_look(&b.name, b.feature).map(|l| l.rgb()).or_else(|| b.body.color())
}
/// The model with each body carrying the colour, opacity and face colours it is shown with
@@ -1457,11 +1222,7 @@ impl Document {
.into_iter()
.map(|(face, l)| solvecraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32 })
.collect();
let opacity = look
.map(|l| l.opacity as f32)
.or_else(|| b.body.paint().map(|p| p.opacity))
.or_else(|| b.imported.as_ref().and_then(|i| i.opacity))
.unwrap_or(1.0);
let opacity = look.map(|l| l.opacity as f32).or_else(|| b.body.paint().map(|p| p.opacity)).unwrap_or(1.0);
let paint = solvecraft_kernel::Paint { opacity, faces };
if c != b.body.color() || b.body.paint() != Some(&paint) {
b.body = b.body.clone().with_color(c).with_paint(Some(paint));
@@ -1576,7 +1337,6 @@ impl Document {
edge_refs: Vec::new(),
edge_names: Vec::new(),
face_names: Vec::new(),
participants: Vec::new(),
kind,
};
match self.marker {
@@ -1602,7 +1362,7 @@ impl Document {
FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id),
FeatureKind::Emboss { sketch, .. } | FeatureKind::Rib { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::SheetFold { sketch: Some(s), .. } | FeatureKind::SplitFace { sketch: Some(s), .. } if *s == id => gone.push(f.id),
FeatureKind::SheetFold { sketch: Some(s), .. } if *s == id => gone.push(f.id),
_ => {}
}
}
@@ -1746,18 +1506,6 @@ impl Document {
/// Resolve a plane reference.
pub fn resolve_plane(&self, vals: &std::collections::BTreeMap<String, expr::Value>, p: &PlaneRef, depth: usize) -> Result<Plane> {
self.resolve_plane_in(vals, p, depth, None)
}
/// Resolve a plane; planes built from model geometry (edges, faces, construction axes and
/// points) need the model state `st`, and construction planes are read from it when given.
pub fn resolve_plane_in(
&self,
vals: &std::collections::BTreeMap<String, expr::Value>,
p: &PlaneRef,
depth: usize,
st: Option<&crate::ModelState>,
) -> Result<Plane> {
if depth > 32 {
return Err(DocError::Invalid("plane offsets nested too deeply".into()));
}
@@ -1767,37 +1515,21 @@ impl Document {
Plane::new(plane.origin, plane.x, plane.y).ok_or_else(|| DocError::Invalid("degenerate plane".into()))
}
PlaneRef::Offset { base, distance } => {
let b = self.resolve_plane_in(vals, base, depth + 1, st)?;
let b = self.resolve_plane(vals, base, depth + 1)?;
Ok(b.offset(Self::eval_in(vals, distance, Kind::Length)?))
}
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => {
let b = self.resolve_plane_in(vals, base, depth + 1, st)?;
let b = self.resolve_plane(vals, base, depth + 1)?;
let a = Self::eval_in(vals, angle, Kind::Angle)?;
let d = axis_dir.normalized().ok_or_else(|| DocError::Invalid("rotation axis".into()))?;
let rot = |v: Vec3| v * a.cos() + d.cross(v) * a.sin() + d * (d.dot(v) * (1.0 - a.cos()));
let o = *axis_origin + rot(b.origin - *axis_origin);
Plane::new(o, rot(b.x), rot(b.y)).ok_or_else(|| DocError::Invalid("degenerate plane".into()))
}
PlaneRef::Construction { name } => {
// Evaluated already: as it came out.
if let Some(c) = st.and_then(|st| st.construct.iter().find(|c| &c.name == name))
&& let crate::construct::ConstructGeom::Plane(pl) = &c.geom
{
return Ok(*pl);
}
match self.find_feature(name).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPlane { plane }) => self.resolve_plane_in(vals, plane, depth + 1, st),
_ => Err(DocError::Unknown(format!("construction plane `{name}`"))),
}
}
PlaneRef::Midplane { .. }
| PlaneRef::TwoEdges { .. }
| PlaneRef::ThreePoints { .. }
| PlaneRef::Tangent { .. }
| PlaneRef::AlongPath { .. } => {
let st = st.ok_or_else(|| DocError::Invalid("this plane is built from the model: evaluate first".into()))?;
crate::construct::resolve_geo_plane(&crate::construct::Ctx { doc: self, vals, st }, p)
}
PlaneRef::Construction { name } => match self.find_feature(name).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPlane { plane }) => self.resolve_plane(vals, plane, depth + 1),
_ => Err(DocError::Unknown(format!("construction plane `{name}`"))),
},
}
}
+38 -629
View File
@@ -25,15 +25,11 @@ pub struct ModelBody {
key: u64,
/// Persistent face names (worked out on first use).
pub(crate) names: crate::naming::NameCell,
/// The component the body belongs to (set after each feature).
pub component: u64,
/// The look it was imported with, carried through later features.
pub imported: Option<Arc<crate::appearance::ImportedLook>>,
}
impl ModelBody {
pub fn new(name: String, body: Body, feature: u64) -> Self {
ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0, names: Default::default(), component: 0, imported: None }
ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0, names: Default::default() }
}
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
pub fn mesh(&self) -> Arc<Mesh> {
@@ -186,49 +182,6 @@ fn thread_on(st: &ModelState, feature: u64, p: Vec3, designation: Option<&str>,
})
}
/// The helical space between the teeth of a modelled ISO metric thread (ISO 68-1 basic
/// profile, 60° flanks): cut from the shaft (external) or the hole wall (internal), running from
/// `start` to `end` along the axis. `radius` is the face's own radius; `open` says which ends
/// of the threaded length are free ends of the face.
fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<solvecraft_geom::Mesh> {
let (p, d) = (t.pitch, t.major_diameter);
let h = p * 3f64.sqrt() / 2.0;
let slope = (std::f64::consts::PI / 6.0).tan();
let len = t.end - t.start;
if !(p > 1e-3 && len > 1e-6) {
return Err(DocError::Invalid("thread too short".into()));
}
if len / p > 190.0 {
return Err(DocError::Invalid(format!("a modelled thread is limited to 190 turns ({:.0} mm at this pitch); use a cosmetic one", 190.0 * p)));
}
// (radius, half width of the groove) at its two ends: a trapezoid in the axial plane.
let (r0, w0, r1, w1) = if t.internal {
// From inside the hole wall out to the major diameter, where the bolt's crest sits
// (the root flat is P/8 wide).
let r_in = radius.min(d / 2.0 - 5.0 * h / 8.0) - 0.1 * p;
(r_in, (p / 8.0 + 2.0 * (d / 2.0 - r_in) * slope) / 2.0, d / 2.0, p / 16.0)
} else {
// From the root (5H/8 under the major diameter, flat P/4 wide) out past the surface.
let r_root = d / 2.0 - 5.0 * h / 8.0;
let r_out = radius.max(d / 2.0) + 0.05 * p;
(r_root, p / 8.0, r_out, (p / 4.0 + 2.0 * (r_out - r_root) * slope) / 2.0)
};
if w0.max(w1) * 2.0 >= p * 0.98 {
return Err(DocError::Invalid("the face is too far from the thread's diameter".into()));
}
let k = t.axis;
let u = k.cross(if k.x.abs() < 0.9 { Vec3::new(1.0, 0.0, 0.0) } else { Vec3::new(0.0, 1.0, 0.0) });
let u = u.normalized().ok_or_else(|| DocError::Invalid("thread axis".into()))?;
// Past a free end of the face the groove runs out into the air; elsewhere it stops square
// at the threaded length's end.
let (s0, s1) = (if open.0 { t.start - p } else { t.start }, if open.1 { t.end + p } else { t.end });
let base = t.axis_point + k * s0;
let plane = Plane { origin: base, x: u, y: k };
let section = [Vec2::new(r0, -w0), Vec2::new(r1, -w1), Vec2::new(r1, w1), Vec2::new(r0, w0)];
// Sections 7.5° apart: the chords sag 0.2% of the radius.
Ok(kernel::sweep_helix_mesh(&plane, &section, base, k, p, (s1 - s0) / p, 48)?)
}
/// Model after some prefix of the timeline.
#[derive(Clone, Debug, Default)]
pub struct ModelState {
@@ -239,8 +192,6 @@ pub struct ModelState {
pub body_counter: usize,
/// Sheet metal bodies (by body name) and their flat patterns.
pub sheets: Vec<crate::sheet::SheetBody>,
/// Construction planes, axes and points as evaluated (by feature).
pub construct: Vec<crate::construct::Construct>,
/// Evaluation-cache keys of the sketches (by sketch feature id).
sketch_keys: BTreeMap<u64, u64>,
}
@@ -283,13 +234,6 @@ impl ModelState {
(key, &b.name).hash(&mut h);
b.key = h.finish();
b.names = crate::naming::NameCell::new(Some(origin.clone()));
// An imported look outlives the operations on the body.
if b.body.paint().is_none()
&& b.body.color().is_none()
&& let Some(old) = before.bodies.iter().find(|x| x.name == b.name)
{
b.imported = crate::appearance::ImportedLook::of(old);
}
}
}
}
@@ -392,7 +336,6 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_ba
let mut h = std::collections::hash_map::DefaultHasher::new();
prev.hash(&mut h);
rolled_back.hash(&mut h);
vals.get(DESIGN_LEN).map(|v| v.v.to_bits()).hash(&mut h);
serde_json::to_string(f).unwrap_or_default().hash(&mut h);
for n in param_names(f) {
n.hash(&mut h);
@@ -412,12 +355,9 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_ba
/// metal and plastic rules (and the parameters they use), components and body placement.
fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for f in doc.features.iter().filter(|f| {
matches!(f.kind, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. })
}) {
for f in doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })) {
fingerprint(0, f, vals, false).hash(&mut h);
}
doc.units.hash(&mut h);
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h);
serde_json::to_string(&doc.components).unwrap_or_default().hash(&mut h);
@@ -487,12 +427,7 @@ impl Model {
/// Re-evaluate the document, reusing results up to the first changed feature.
pub fn evaluate(&mut self, doc: &Document) {
let (mut vals, perr) = doc.param_values();
if let Some((Kind::Length, scale)) = expr::unit_info(doc.units.trim())
&& scale != 1.0
{
vals.insert(DESIGN_LEN.into(), Value { v: scale, len: 0, ang: 0 });
}
let (vals, perr) = doc.param_values();
self.param_errors = perr;
let mut prev_fp = 0u64;
let mut state = self.empty.clone();
@@ -624,14 +559,11 @@ impl Model {
let mats = match &f.kind {
FeatureKind::Pattern { pattern, .. } => pattern_transforms(&vals, &state, pattern).unwrap_or_default(),
FeatureKind::Mirror { plane, .. } => {
doc.resolve_plane_in(&vals, plane, 0, Some(&state)).map(|pl| vec![mirror_matrix(&pl)]).unwrap_or_default()
doc.resolve_plane(&vals, plane, 0).map(|pl| vec![mirror_matrix(&pl)]).unwrap_or_default()
}
_ => Vec::new(),
};
next.stamp(&state, f, key, mats);
for b in &mut next.bodies {
b.component = doc.body_component(&b.name, b.feature);
}
state = Arc::new(next);
None
}
@@ -649,18 +581,7 @@ impl Model {
}
}
/// Key of the design's length unit (mm per unit) among the values (no parameter can be named
/// so).
pub(crate) const DESIGN_LEN: &str = "\u{1}design_len";
/// An expression's value. A length that comes out unit-less without being a bare number (a
/// unit-less parameter used as a length) is in the design's units.
fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
let scale = vals.get(DESIGN_LEN).map(|x| x.v).unwrap_or(1.0);
if k == Kind::Length && scale != 1.0 && !expr::is_literal(e) {
let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))))?;
return v.to_kind_in(k, expr::Defaults { len: scale, ..expr::Defaults::default() });
}
Document::eval_in(vals, e, k)
}
@@ -731,14 +652,8 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String {
/// Add tool bodies to the model according to the operation.
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
// Participants: the feature's own targets, else its explicit participants, else the bodies
// of its component (and what it made itself): other components are left alone.
let targets: &[String] = if targets.is_empty() { &f.participants } else { targets };
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
let target_idx: Vec<usize> = if all {
let target_idx: Vec<usize> = if targets.is_empty() {
(0..state.bodies.len()).collect()
} else if targets.is_empty() {
(0..state.bodies.len()).filter(|i| state.bodies.get(*i).is_some_and(|b| b.component == f.component || b.feature == f.id)).collect()
} else {
let mut v = Vec::new();
for t in targets {
@@ -1190,42 +1105,6 @@ fn hole_spots(st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
Ok((spots.into_iter().map(|p| hole_on_surface(st, p, dir)).collect(), dir))
}
/// A hole's depth: down to the To Object (where the hole's axis meets the face there, else the
/// point's depth along the axis), else as given.
fn hole_depth_to(st: &ModelState, at: &Vec3, dir: Vec3, to: &Option<Vec3>, depth: &Option<String>) -> Result<Option<String>> {
let Some(t) = to else { return Ok(depth.clone()) };
let mut d = (*t - *at).dot(dir);
// The face at the To point, met along the axis.
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
let Some(fi) = crate::appearance::face_index_at(b, *t) else { continue };
let m = b.mesh();
if let Some((h, tri)) = m.raycast(*at + dir * 1e-6, dir)
&& m.tri_face.get(tri).is_some_and(|x| *x as usize == fi)
{
d = h + 1e-6;
}
break;
}
if !(d > 1e-6) {
return Err(DocError::Invalid("the hole's To object is not ahead of the hole".into()));
}
Ok(Some(format!("{d} mm")))
}
/// A hole's spots as given (not yet moved onto the surface) and its direction.
fn hole_spots_raw(st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
let FeatureKind::Hole { position, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) };
match points {
Some(sp) => {
let ss = st.sketch(sp.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", sp.sketch)))?;
let v: Vec<Vec3> =
sp.ids.iter().filter_map(|id| ss.sketch.points.iter().find(|p| &p.id == id)).map(|p| ss.plane.to_world(p.pos)).take(10_000).collect();
Ok((v, -ss.plane.normal()))
}
None => Ok((vec![*position], direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?)),
}
}
/// The cutting tools of one hole at `position`, drilling along `dir`.
#[allow(clippy::too_many_arguments)]
fn hole_tools(
@@ -1267,13 +1146,7 @@ fn hole_tools(
HoleKind::Simple => tools.push(cyl(r, bottom, top)?),
HoleKind::Drilled { tip_angle } => {
let half = val(vals, tip_angle, Kind::Angle)? / 2.0;
// A hole exactly as deep as the material: the point is in the air past the exit
// face, which would lie on the drill point's shoulder (a through hole, as drilled).
let at = |h: f64| *position + dir * (-h);
let eps = size * 1e-6;
let solid_at = |h: f64| st.bodies.iter().any(|b| b.body.contains(at(h)));
let exits_at_shoulder = solid_at(bottom + eps) && !solid_at(bottom - eps);
if depth.is_some() && half > 1e-3 && half < std::f64::consts::FRAC_PI_2 && !exits_at_shoulder {
if depth.is_some() && half > 1e-3 && half < std::f64::consts::FRAC_PI_2 {
// One revolved tool (cylinder with a drill point).
let tip = r / half.tan();
let up = -dir;
@@ -1318,36 +1191,7 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
// From / To Object: where the face or point lies along the normal, measured over the
// middle of the profiles.
let n = ss.plane.normal();
let centre = {
let pts: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
let c = pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64);
ss.plane.to_world(c)
};
let from = match extent.from {
Some(p) => object_height(st, &ss.plane, centre, p),
None => 0.0,
};
let d = match extent.to {
Some(p) => {
let off = match &extent.to_offset {
Some(e) => val(vals, e, Kind::Length)?,
None => 0.0,
};
let h = object_height(st, &ss.plane, centre, p);
let start = from + extent.start_offset.as_deref().map(|e| val(vals, e, Kind::Length)).transpose()?.unwrap_or(0.0);
// Past the face by the offset, in the direction travelled.
let to = h + if h >= start { off } else { -off };
if (to - start).abs() < 1e-9 {
return Err(DocError::Invalid("the To object is where the extrude starts".into()));
}
to - start
}
None if extent.through_all => through_all_distance(st, &ss.plane, targets)?,
None => val(vals, &extent.distance, Kind::Length)?,
};
let d = if extent.through_all { through_all_distance(st, &ss.plane, targets)? } else { val(vals, &extent.distance, Kind::Length)? };
if let Some(tp) = &extent.taper {
let taper = val(vals, tp, Kind::Angle)?;
if extent.distance2.is_some() || extent.start_offset.is_some() || extent.direction == crate::Direction::Symmetric {
@@ -1360,82 +1204,33 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
.map(|r| kernel::extrude_tapered(&ss.plane, r, len, sign, taper))
.collect::<std::result::Result<Vec<_>, _>>()?);
}
let off = from
+ match &extent.start_offset {
Some(e) => val(vals, e, Kind::Length)?,
None => 0.0,
};
// To Object gives a signed distance from the start: one side only.
let direction = if extent.to.is_some() { crate::Direction::Positive } else { extent.direction };
let (a, b) = match (&extent.distance2, direction) {
let off = match &extent.start_offset {
Some(e) => val(vals, e, Kind::Length)?,
None => 0.0,
};
let (a, b) = match (&extent.distance2, extent.direction) {
(Some(d2), _) => (-val(vals, d2, Kind::Length)?, d),
(None, crate::Direction::Positive) => (0.0, d),
(None, crate::Direction::Negative) => (-d, 0.0),
(None, crate::Direction::Symmetric) => (-d, d),
};
// A negative distance flips the side.
let (mut lo, mut hi) = if a <= b { (a, b) } else { (b, a) };
// To a curved (or slanted) face: run right through the target body, then keep what
// lies before its surface (the tool less the body, the piece at the start).
let curved = extent.to.and_then(|p| curved_target(st, n, p));
if let Some(bi) = curved
&& let Some(tb) = st.bodies.get(bi)
{
let m = tb.mesh();
let hs: Vec<f64> = m.positions.iter().map(|q| (*q - ss.plane.origin).dot(n) - off).collect();
let margin = tb.body.size() * 0.05 + 1.0;
if hi > 0.0 {
hi = hs.iter().cloned().fold(hi, f64::max) + margin;
} else {
lo = hs.iter().cloned().fold(lo, f64::min) - margin;
}
}
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
let mut tools = Vec::new();
for r in &regions {
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets);
let n = ss.plane.normal();
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
tools.extend(kernel::extrude(&ss.plane, std::slice::from_ref(&r), l2, h2)?);
}
if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) {
let start = if hi > 0.0 { lo + off } else { hi + off };
tools = trim_to_body(tools, &tb.body, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?;
}
Ok(tools)
}
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, to, .. } => {
FeatureKind::Revolve { sketch, profiles, axis, angle, .. } => {
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let (o, d) = revolve_axis(&ss, axis)?;
// Where the turn starts (from the profile) and how far it goes.
let (start, ang) = match (to, angle2, symmetric) {
(Some(p), _, _) => {
let (ow, aw) = (ss.plane.to_world(o), ss.plane.dir_to_world(d));
let pts: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
let c = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, q| a + *q) * (1.0 / pts.len().max(1) as f64));
let flat = |q: Vec3| {
let v = q - ow;
v - aw * v.dot(aw)
};
let (u, v) = (flat(c), flat(*p));
// The revolve turns clockwise about its axis direction (the kernel's sense).
let a = (aw * -1.0).dot(u.cross(v)).atan2(u.dot(v));
let a = if a <= 1e-9 { a + std::f64::consts::TAU } else { a };
(0.0, a)
}
(None, Some(a2), _) => {
let (a, b) = (val(vals, angle, Kind::Angle)?, val(vals, a2, Kind::Angle)?);
(-b, a + b)
}
(None, None, true) => {
let a = val(vals, angle, Kind::Angle)?;
(-a / 2.0, a)
}
(None, None, false) => (0.0, val(vals, angle, Kind::Angle)?),
};
if !(ang.abs() > 1e-9 && ang.abs() <= std::f64::consts::TAU + 1e-9) {
return Err(DocError::Invalid("the revolve angle must be more than 0 and at most 360°".into()));
}
let ang = val(vals, angle, Kind::Angle)?;
let (operation, targets) = feature_op(f);
let axis_w = ss.plane.dir_to_world(d);
let origin_w = ss.plane.to_world(o);
@@ -1450,36 +1245,7 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
.collect::<Vec<_>>()
};
let regions: Vec<Region2> = regions.iter().map(|r| grow_profile_for_coplanar(st, r, operation, &targets, &probe)).collect();
// To a curved face: turn past the whole target body, then cut back to its surface
// (the piece that starts at the profile).
let curved = to.and_then(|p| curved_target_any(st, p));
if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) {
let pts: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
let c = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, q| a + *q) * (1.0 / pts.len().max(1) as f64));
let flat = |q: Vec3| {
let v = q - origin_w;
v - axis_w * v.dot(axis_w)
};
let u0 = flat(c);
let angle_of = |q: Vec3| {
let v = flat(q);
let a = (axis_w * -1.0).dot(u0.cross(v)).atan2(u0.dot(v));
if a < 0.0 { a + std::f64::consts::TAU } else { a }
};
let far = tb.mesh().positions.iter().map(|q| angle_of(*q)).fold(0.0, f64::max);
let sweep = (far + 0.05).min(std::f64::consts::TAU - 1e-3);
let bodies = kernel::revolve(&ss.plane, &regions, o, d, sweep)?;
let n = ss.plane.normal();
let tol_side = |q: Vec3| if flat(q).dot(u0) > 0.0 { ((q - ss.plane.origin).dot(n)).abs() } else { f64::MAX };
return trim_to_body(bodies, &tb.body, tol_side);
}
let bodies = kernel::revolve(&ss.plane, &regions, o, d, ang)?;
if start.abs() < 1e-12 {
return Ok(bodies);
}
// Turned back to start before the profile.
let m = crate::rigid(Vec3::ZERO, origin_w, axis_w, -start);
Ok(bodies.iter().map(|b| kernel::transform_matrix(b, m)).collect::<std::result::Result<Vec<_>, _>>()?)
Ok(kernel::revolve(&ss.plane, &regions, o, d, ang)?)
}
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.point.is_some()) => {
// A profile lofted to a sketch point (an apex).
@@ -1590,15 +1356,14 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
FeatureKind::Torus { center, major, minor, .. } => {
Ok(vec![kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?])
}
FeatureKind::Hole { diameter, depth, hole, to, .. } => {
FeatureKind::Hole { diameter, depth, hole, .. } => {
let (spots, dir) = hole_spots(st, f)?;
if spots.is_empty() || spots.len() > 10_000 {
return Err(DocError::Invalid("a hole needs 1…10000 points".into()));
}
let mut tools = Vec::new();
for at in spots {
let depth = hole_depth_to(st, &at, dir, to, depth)?;
tools.extend(hole_tools(vals, st, &at, dir, diameter, &depth, hole)?);
tools.extend(hole_tools(vals, st, &at, dir, diameter, depth, hole)?);
}
Ok(tools)
}
@@ -1971,28 +1736,12 @@ fn replay(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut
}
continue;
}
let tools = feature_tools(vals, src, st)?;
let (op, targets) = feature_op(src);
let mut copies = Vec::new();
if let FeatureKind::Hole { diameter, depth, hole, to, .. } = &src.kind {
// A hole is drilled again at each copy of its spot (found on the surface there), so
// a blind hole's depth counts from the face at the copy, not from the bottom of the
// original hole under the original spot.
let (spots, dir) = hole_spots_raw(st, src)?;
for m in mats {
let d = crate::apply_vector(m, dir).normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
for p in &spots {
let at = hole_on_surface(st, crate::apply_point(m, *p), d);
let to = to.map(|t| crate::apply_point(m, t));
let depth = hole_depth_to(st, &at, d, &to, depth)?;
copies.extend(hole_tools(vals, st, &at, d, diameter, &depth, hole)?);
}
}
} else {
let tools = feature_tools(vals, src, st)?;
for m in mats {
for t in &tools {
copies.push(kernel::transform_matrix(t, *m)?);
}
for m in mats {
for t in &tools {
copies.push(kernel::transform_matrix(t, *m)?);
}
}
// New bodies from a pattern join their source when they touch it (as one feature would).
@@ -2003,150 +1752,6 @@ fn replay(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut
#[path = "eval_more.rs"]
mod more;
#[path = "eval_surface.rs"]
mod surface;
/// Height above `plane` (along its normal) of the object at `p` as seen from `centre`: the face
/// there if it is planar and parallel (its plane), else where a ray from `centre` along the
/// normal meets the body there, else the point itself.
fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 {
let n = plane.normal();
let h = |q: Vec3| (q - plane.origin).dot(n);
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
let tol = (b.body.size() * 1e-3).max(1e-3);
let Ok(faces) = b.body.faces(tol) else { continue };
let m = b.mesh();
let on: Vec<usize> = faces.iter().filter(|f| crate::appearance::face_index_at(b, p) == Some(f.index)).map(|f| f.index).collect();
let Some(&fi) = on.first() else { continue };
if let Some(f) = faces.iter().find(|f| f.index == fi)
&& f.plane_normal.is_some_and(|fnrm| fnrm.cross(n).len() < 1e-6)
{
return h(f.centroid);
}
// A curved or slanted face: where the profile's middle meets it.
for dir in [n, n * -1.0] {
if let Some((t, tri)) = m.raycast(centre, dir)
&& m.tri_face.get(tri).is_some_and(|x| *x as usize == fi)
{
return h(centre + dir * t);
}
}
return h(p);
}
h(p)
}
/// The body at `p` when the face there is not a plane square to `n` (To Object must then
/// follow the face's shape).
fn curved_target(st: &ModelState, n: Vec3, p: Vec3) -> Option<usize> {
for (bi, b) in st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()) {
let Some(fi) = crate::appearance::face_index_at(b, p) else { continue };
let tol = (b.body.size() * 1e-3).max(1e-3);
let planar =
b.body.faces(tol).ok()?.into_iter().find(|f| f.index == fi).and_then(|f| f.plane_normal).is_some_and(|fnrm| fnrm.cross(n).len() < 1e-6);
return (!planar).then_some(bi);
}
None
}
/// The body at `p` when the face there is not planar.
fn curved_target_any(st: &ModelState, p: Vec3) -> Option<usize> {
for (bi, b) in st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()) {
let Some(fi) = crate::appearance::face_index_at(b, p) else { continue };
let tol = (b.body.size() * 1e-3).max(1e-3);
let planar = b.body.faces(tol).ok()?.into_iter().find(|f| f.index == fi).is_some_and(|f| f.plane_normal.is_some());
return (!planar).then_some(bi);
}
None
}
/// Tools run through `target`, cut back to its surface: each tool less the body, keeping the
/// piece(s) that touch the start (`dist_to_start` 0 there).
fn trim_to_body(tools: Vec<Body>, target: &Body, dist_to_start: impl Fn(Vec3) -> f64) -> Result<Vec<Body>> {
let mut out = Vec::new();
for t in tools {
let Some(cut) = kernel::boolean(&t, target, BoolOp::Cut)? else { continue };
for lump in cut.lumps()? {
let tol = (lump.size() * 1e-4).max(1e-6);
let touches =
lump.tessellate((lump.size() * 1e-2).max(1e-2)).map(|m| m.positions.iter().any(|q| dist_to_start(*q) < tol)).unwrap_or(false);
if touches {
out.push(lump);
}
}
}
if out.is_empty() {
return Err(DocError::Invalid("nothing lies between the profile and the To object".into()));
}
Ok(out)
}
/// One point on each face of a body (a triangle centre, so it lies on the face).
fn face_samples(b: &ModelBody) -> Vec<Vec3> {
let m = b.mesh();
let mut out: Vec<Option<Vec3>> = Vec::new();
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let f = *f as usize;
if out.len() <= f {
out.resize(f + 1, None);
}
if let (Some(slot @ None), Some([a, bb, c])) = (out.get_mut(f), m.tri(t)) {
*slot = Some((a + bb + c) * (1.0 / 3.0));
}
}
out.into_iter().flatten().collect()
}
/// The picked faces and every face joined to them smoothly (across tangent edges), as points.
fn smooth_chain(b: &ModelBody, picks: &[Vec3]) -> Vec<Vec3> {
let m = b.mesh();
let samples = face_samples(b);
let face_of = |p: Vec3| crate::appearance::face_index_at(b, p);
// The surface normal of face f at its mesh vertex nearest q (vertex normals are the
// surface's, so a tangent edge reads the same from both sides).
let normal_near = |f: u32, q: Vec3| -> Option<Vec3> {
m.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, tf)| **tf == f)
.flat_map(|(t, _)| t.iter().copied())
.filter_map(|i| Some((m.positions.get(i as usize)?.dist(q), *m.normals.get(i as usize)?)))
.min_by(|a, b| a.0.total_cmp(&b.0))
.and_then(|(_, n)| n.normalized())
};
let mut chosen: Vec<u32> = picks.iter().filter_map(|p| face_of(*p).map(|f| f as u32)).collect();
let mut k = 0;
while k < chosen.len() && chosen.len() < 10_000 {
let f = chosen[k];
k += 1;
for (e, fs) in m.edges.iter().zip(&m.edge_faces) {
let (Some(&a), Some(&bb)) = (fs.first(), fs.get(1)) else { continue };
let other = if a == f {
bb
} else if bb == f {
a
} else {
continue;
};
if chosen.contains(&other) {
continue;
}
let Some(mid) = e.get(e.len() / 2).copied() else { continue };
if let (Some(n1), Some(n2)) = (normal_near(f, mid), normal_near(other, mid))
&& n1.dot(n2) > 2f64.to_radians().cos()
{
chosen.push(other);
}
}
}
let mut out = picks.to_vec();
for f in chosen {
if let Some(p) = samples.iter().find(|p| face_of(**p) == Some(f as usize)) {
out.push(*p);
}
}
out
}
/// The plane of the body face a sketch sits on, found again: among planar faces with the
/// picked plane's normal, the one nearest `at`. The picked frame moves along its normal onto it.
@@ -2242,15 +1847,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::SheetHem { .. }
| FeatureKind::SheetFold { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::SheetConvert { .. } => more::sheet_eval(doc, vals, f, st, warning),
| FeatureKind::SheetConvert { .. } => more::sheet_eval(doc, vals, f, st),
FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. } => {
more::plastic_eval(doc, vals, f, st)
}
FeatureKind::Patch { .. }
| FeatureKind::Stitch { .. }
| FeatureKind::Thicken { .. }
| FeatureKind::SurfaceTrim { .. }
| FeatureKind::SurfaceExtend { .. } => surface::eval(doc, vals, f, st),
FeatureKind::Emboss { .. }
| FeatureKind::Rib { .. }
| FeatureKind::ReplaceFace { .. }
@@ -2259,7 +1859,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
FeatureKind::Sketch { plane, sketch } => {
let (plane, face_warning) = match plane {
PlaneRef::Face { plane: picked, at, name } => face_plane(st, picked, *at, name.as_deref()),
other => (doc.resolve_plane_in(vals, other, 0, Some(st))?, None),
other => (doc.resolve_plane(vals, other, 0)?, None),
};
let mut sk = sketch.clone();
let mut warns = crate::project::refresh_links(doc, vals, st, &plane, &mut sk);
@@ -2319,55 +1919,20 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
Ok(())
}
FeatureKind::Thread { face, designation, length, modeled } => {
FeatureKind::Thread { face, designation, length } => {
let l = match length {
Some(e) => Some(val(vals, e, Kind::Length)?),
None => None,
};
let t = thread_on(st, f.id, *face, designation.as_deref(), l)?;
if !*modeled {
st.threads.push(t);
return Ok(());
}
let i = st.bodies.iter().position(|b| b.name == t.body).ok_or_else(|| DocError::Invalid("threaded body".into()))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let cyl = kernel::cylinder_face_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("no cylindrical face there to thread".into()))?;
let tol = t.pitch * 1e-3;
let groove = thread_groove(&t, cyl.radius, ((t.start - cyl.start).abs() < tol, (t.end - cyl.end).abs() < tol))?;
// The B-rep boolean can't take helical walls in reasonable time: the body is cut
// faceted (curved faces within a two-hundredth of the pitch).
let nb = kernel::faceted_boolean(&mb.body, &groove, BoolOp::Cut, t.pitch * 0.005)?
.ok_or_else(|| DocError::Invalid("the thread removed everything".into()))?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
}
st.threads.push(t);
Ok(())
}
FeatureKind::Shell { faces, thickness, body, direction, tangent_chain } => {
FeatureKind::Shell { faces, thickness, body } => {
let t = val(vals, thickness, Kind::Length)?;
let i = body_at(st, body, faces)?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let open = if *tangent_chain { smooth_chain(&mb, faces) } else { faces.clone() };
let nb = match direction.as_str() {
"" | "inside" => kernel::shell(&mb.body, &open, t)?,
"outside" | "both" => {
// Walls grown out of the faces (or half out, half in): an offset body less a
// cavity, the open faces pushed out of the way.
if !(t > 1e-6) {
return Err(DocError::Invalid("shell thickness must be positive".into()));
}
let (out, inn) = if direction == "outside" { (t, 0.0) } else { (t / 2.0, t / 2.0) };
let samples = face_samples(&mb);
let opened =
|p: &Vec3| open.iter().any(|q| crate::appearance::face_index_at(&mb, *q) == crate::appearance::face_index_at(&mb, *p));
let walls: Vec<Vec3> = samples.iter().filter(|p| !opened(p)).copied().collect();
let outer = kernel::offset_faces(&mb.body, &walls, out)?;
let cavity = if inn > 0.0 { kernel::offset_faces(&mb.body, &walls, -inn)? } else { mb.body.clone() };
let cavity = kernel::offset_faces(&cavity, &open, 2.0 * t + 1.0)?;
kernel::boolean(&outer, &cavity, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("the shell removed everything".into()))?
}
o => return Err(DocError::Invalid(format!("unknown shell direction `{o}` (inside, outside or both)"))),
};
let nb = kernel::shell(&mb.body, faces, t)?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
}
@@ -2375,7 +1940,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
FeatureKind::Draft { faces, angle, neutral, pull, body } => {
let a = val(vals, angle, Kind::Angle)?;
let pl = doc.resolve_plane_in(vals, neutral, 0, Some(st))?;
let pl = doc.resolve_plane(vals, neutral, 0)?;
let i = body_at(st, body, faces)?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let nb = kernel::draft(&mb.body, faces, &pl, *pull, a)?;
@@ -2385,113 +1950,16 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
Ok(())
}
FeatureKind::ConstructionPlane { plane } => {
let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?;
st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Plane(pl) });
doc.resolve_plane(vals, plane, 0)?;
Ok(())
}
FeatureKind::ConstructionAxis { def } => {
let (origin, dir) = crate::construct::resolve_axis(&crate::construct::Ctx { doc, vals, st }, def)?;
st.construct.push(crate::construct::Construct {
feature: f.id,
name: f.name.clone(),
geom: crate::construct::ConstructGeom::Axis { origin, dir },
});
Ok(())
}
FeatureKind::ConstructionPoint { def } => {
let p = crate::construct::resolve_point(&crate::construct::Ctx { doc, vals, st }, def)?;
st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Point(p) });
Ok(())
}
FeatureKind::BoundaryFill { tools, cells, operation, remove_tools } => {
if tools.is_empty() || tools.len() > 64 || cells.is_empty() || cells.len() > 1000 {
return Err(DocError::Invalid("Boundary Fill needs 1…64 tool bodies and 1…1000 cells".into()));
}
let tb: Vec<ModelBody> =
tools.iter().map(|t| st.body(t).cloned().ok_or_else(|| DocError::Unknown(format!("body `{t}`")))).collect::<Result<_>>()?;
let mut made = Vec::new();
for p in cells {
// The cell: inside the tools that hold the point, outside the others.
let inside: Vec<bool> = tb.iter().map(|b| b.mesh().contains(*p)).collect();
let mut acc: Option<Body> = None;
for (b, _) in tb.iter().zip(&inside).filter(|(_, i)| **i) {
acc = match acc {
None => Some(b.body.clone()),
Some(a) => kernel::boolean(&a, &b.body, BoolOp::Intersect)?,
};
}
let Some(mut cell) = acc else {
return Err(DocError::Invalid(format!("the point {p:?} is outside every tool: no enclosed cell there")));
};
for (b, _) in tb.iter().zip(&inside).filter(|(_, i)| !**i) {
cell = kernel::boolean(&cell, &b.body, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("a cell came out empty".into()))?;
}
made.push(cell);
}
if *remove_tools {
st.bodies.retain(|b| !tools.contains(&b.name));
}
apply_op(st, f, made, *operation, &[])
}
FeatureKind::SplitFace { faces, body, plane, tool, sketch } => {
let i = body_at(st, body, faces)?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let mut picked: Vec<usize> = Vec::new();
for p in faces {
let fi = crate::appearance::face_index_at(&mb, *p)
.ok_or_else(|| DocError::Invalid(format!("Split Face: no face of {} at {p:?}", mb.name)))?;
if !picked.contains(&fi) {
picked.push(fi);
}
}
let nb = match (tool, sketch) {
(Some(t), _) => {
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
let face = crate::appearance::face_index_at(tb, t.point)
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?;
kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Face { body: &tb.body, face })?
}
(None, Some(sid)) => {
let ss = st.sketch(*sid).ok_or_else(|| DocError::Unknown(format!("sketch {sid}")))?;
// Each curve's exact segments (lines and arcs split by their own plane and cylinder).
let curves: Vec<Vec<solvecraft_geom::Seg2>> = (0..ss.sketch.curves.len())
.filter(|ci| ss.sketch.curves.get(*ci).is_some_and(|c| !c.construction))
.map(|ci| ss.sketch.segs(ci))
.filter(|segs| !segs.is_empty())
.collect();
if curves.is_empty() {
return Err(DocError::Invalid(format!("sketch `{}` has no curves to split with", ss.name)));
}
kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Curves { plane: ss.plane, curves })?
}
(None, None) => {
let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?;
kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Plane(pl))?
}
};
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
}
Ok(())
}
FeatureKind::Split { body, plane, tool } => {
FeatureKind::Split { body, plane } => {
let pl = doc.resolve_plane(vals, plane, 0)?;
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Unknown(format!("body `{body}`"))) };
let parts = match tool {
// A face of a body (its surface extended).
Some(t) => {
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
let face = crate::appearance::face_index_at(tb, t.point)
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?;
kernel::split_body(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face })?
}
None => {
let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?;
kernel::split_by_plane(&mb.body, &pl)?
}
};
let parts = kernel::split_by_plane(&mb.body, &pl)?;
if parts.len() < 2 {
return Err(DocError::Invalid("the tool does not split the body".into()));
return Err(DocError::Invalid("the plane does not split the body".into()));
}
let mut it = parts.into_iter();
if let (Some(first), Some(slot)) = (it.next(), st.bodies.get_mut(i)) {
@@ -2523,7 +1991,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
Ok(())
}
FeatureKind::Mirror { features, plane, bodies, combine } => {
let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?;
let pl = doc.resolve_plane(vals, plane, 0)?;
if bodies.is_empty() {
return replay(doc, vals, f, st, features, &[mirror_matrix(&pl)]);
}
@@ -2552,7 +2020,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
Ok(())
}
FeatureKind::Fillet { edges, radius, body, .. } | FeatureKind::Chamfer { edges, distance: radius, body, .. } => {
FeatureKind::Fillet { edges, radius, body } | FeatureKind::Chamfer { edges, distance: radius, body, .. } => {
let r = val(vals, radius, Kind::Length)?;
let ti = blend_target(st, body, edges)?;
let Some(mb) = st.bodies.get(ti) else { return Err(DocError::Invalid("body".into())) };
@@ -2595,15 +2063,6 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
resolve_edges(&mb.body, edges, &f.edge_refs, warning)?
};
let nb = match &f.kind {
FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => kernel::fillet_chord(&mb.body, &edges, r)?,
FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start }, .. } => {
let r2 = val(vals, radius2, Kind::Length)?;
let mut b = mb.body.clone();
for p in &edges {
b = kernel::fillet_variable(&b, *p, *start, r, r2)?;
}
b
}
FeatureKind::Fillet { .. } => kernel::fillet(&mb.body, &edges, r)?,
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &edges, r, distance2, angle, *flip)?,
_ => return Err(DocError::Invalid("blend".into())),
@@ -2687,24 +2146,6 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
Ok(())
}
FeatureKind::DeleteFace { faces, body } => {
let i = body_at(st, body, faces)?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let mut picked: Vec<usize> = Vec::new();
for p in faces {
let fi = crate::appearance::face_index_at(&mb, *p)
.ok_or_else(|| DocError::Invalid(format!("Delete Face: no face of {} at {p:?}", mb.name)))?;
if !picked.contains(&fi) {
picked.push(fi);
}
}
let all = kernel::with_same_surface(&mb.body, &picked);
let nb = kernel::delete_faces(&mb.body, &all)?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
}
Ok(())
}
FeatureKind::BoundingSolid { bodies, margin } => {
let m = val(vals, margin, Kind::Length)?;
let mut bb = solvecraft_geom::Aabb3::EMPTY;
@@ -2813,10 +2254,7 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
let ident = crate::is_identity(&m);
for b in st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == comp) {
let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) };
// A body moved in from another component keeps its place (its offset).
let bm = doc.body_offsets.get(&b.name).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
let body =
if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) };
let body = if ident { b.body.clone() } else { kernel::transform_matrix(&b.body, m).unwrap_or_else(|_| b.body.clone()) };
out.bodies.push(ModelBody::new(name, body, b.feature));
}
if primary {
@@ -2840,32 +2278,3 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
}
out
}
#[cfg(test)]
mod chain_tests {
use super::*;
/// The top face of a box with one rounded top edge chains to the round and on to the side
/// it rolls into; the other sides meet at sharp edges and stay out.
#[test]
fn tangent_chain_follows_smooth_edges() {
let mut doc = Document::new("T");
doc.add_feature(
FeatureKind::Box { corner: Vec3::ZERO, length: "40".into(), width: "30".into(), height: "20".into(), operation: Operation::NewBody },
None,
)
.unwrap();
doc.add_feature(
FeatureKind::Fillet { edges: vec![Vec3::new(20.0, 0.0, 20.0)], radius: "4".into(), body: None, style: crate::FilletStyle::Constant },
None,
)
.unwrap();
let mut m = Model::new();
m.evaluate(&doc);
let st = m.state();
let b = &st.bodies[0];
let pts = smooth_chain(b, &[Vec3::new(20.0, 15.0, 20.0)]);
let faces: std::collections::BTreeSet<usize> = pts.iter().filter_map(|p| crate::appearance::face_index_at(b, *p)).collect();
assert_eq!(faces.len(), 3, "{faces:?}");
}
}
+5 -62
View File
@@ -45,7 +45,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
apply_op(st, f, tools, Operation::Join, &[])
}
FeatureKind::ReplaceFace { faces, target, body } => {
let tp = doc.resolve_plane_in(vals, target, 0, Some(st))?;
let tp = doc.resolve_plane(vals, target, 0)?;
let tn = tp.normal();
let i = body_at(st, body, faces)?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
@@ -288,13 +288,7 @@ fn mirror_y(r: &Region2) -> Region2 {
Region2 { outer: m(&r.outer), holes: r.holes.iter().map(|h| m(h).reversed()).collect() }
}
pub(super) fn sheet_eval(
doc: &Document,
vals: &BTreeMap<String, Value>,
f: &Feature,
st: &mut ModelState,
warning: &mut Option<String>,
) -> Result<()> {
pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
use crate::sheet;
match &f.kind {
FeatureKind::SheetBase { sketch, profiles, rule, flip } => {
@@ -309,22 +303,7 @@ pub(super) fn sheet_eval(
(ss.plane, region)
};
let name = new_name(st, f, k);
let mut sh = sheet::base_flange(&name, &r.name, &rv, plane, region);
// The sketch's lines name the base panel's edges.
sh.base_curves = ss
.sketch
.curves
.iter()
.enumerate()
.filter(|(_, c)| !c.construction)
.flat_map(|(ci, c)| {
ss.sketch.segs(ci).into_iter().filter_map(move |sg| match sg {
Seg2::Line { a, b } => Some((c.id.clone(), a, b)),
_ => None,
})
})
.map(|(id, a, b)| if *flip { (id, Vec2::new(a.x, -a.y), Vec2::new(b.x, -b.y)) } else { (id, a, b) })
.collect();
let sh = sheet::base_flange(&name, &r.name, &rv, plane, region);
let solid = sh.solid()?;
st.bodies.push(ModelBody::new(name, solid, f.id));
st.sheets.push(sh);
@@ -370,10 +349,7 @@ pub(super) fn sheet_eval(
if sh.flat {
return Err(DocError::Invalid("refold the sheet before adding flanges".into()));
}
let picks = sheet_picks(sh, f, edges, warning);
let n0 = sh.flanges.len();
sh.add_flanges(&picks, h, a, rr, position, *flip)?;
tag_flanges(sh, n0, f.id);
sh.add_flanges(edges, h, a, rr, position, *flip)?;
rebuild_sheet(st, i)
}
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
@@ -387,12 +363,9 @@ pub(super) fn sheet_eval(
}
};
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
let picks = sheet_picks(sh, f, edges, warning);
let n0 = sh.flanges.len();
for e in &picks {
for e in edges {
sh.add_hem(*e, l, g, *flip)?;
}
tag_flanges(sh, n0, f.id);
rebuild_sheet(st, i)
}
FeatureKind::SheetFold { sketch, curve, a, b, angle, radius, position, flip, fixed, body } => {
@@ -421,9 +394,7 @@ pub(super) fn sheet_eval(
if sh.flat {
return Err(DocError::Invalid("refold the sheet before folding it".into()));
}
let n0 = sh.flanges.len();
sh.add_fold(a, b, ang, rr, position, *flip, *fixed)?;
tag_flanges(sh, n0, f.id);
rebuild_sheet(st, i)
}
FeatureKind::SheetUnfold { body, refold } => {
@@ -872,31 +843,3 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
_ => Err(DocError::Invalid(format!("{} is not a plastic feature", f.name))),
}
}
/// Edge picks of a sheet feature, moved onto their named edges (a name that is gone keeps the
/// point, with a warning).
fn sheet_picks(sh: &crate::sheet::SheetBody, f: &Feature, edges: &[Vec3], warning: &mut Option<String>) -> Vec<Vec3> {
let mut gone = Vec::new();
let out = edges
.iter()
.enumerate()
.map(|(i, p)| match f.edge_names.get(i).filter(|n| !n.is_empty()) {
Some(n) => sh.edge_by_name(n, *p).unwrap_or_else(|| {
gone.push(n.clone());
*p
}),
None => *p,
})
.collect();
if !gone.is_empty() {
*warning = Some(format!("sheet edge(s) {} no longer exist; using the edges at the picked points", gone.join(", ")));
}
out
}
/// Tag the flanges a feature just added (`F<feature>.<k>`).
fn tag_flanges(sh: &mut crate::sheet::SheetBody, from: usize, feature: u64) {
for (k, fl) in sh.flanges.iter_mut().enumerate().skip(from) {
fl.tag = format!("F{feature}.{}", k - from);
}
}
-89
View File
@@ -1,89 +0,0 @@
//! Evaluation of the surface features: Patch, Stitch, Thicken and Trim (kernel `surfaces`).
use super::*;
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
match &f.kind {
FeatureKind::Patch { sketch, profiles, body, edges } => {
let mut made = Vec::new();
if let Some(sk) = sketch {
let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
let regions = solvecraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
for r in &regions {
made.push(kernel::patch_region(&ss.plane, r)?);
}
}
if !edges.is_empty() {
let i = body_at(st, body, edges)?;
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("body".into()))?;
made.push(kernel::patch_edges(&b.body, edges)?);
}
if made.is_empty() {
return Err(DocError::Invalid("a patch needs a sketch region or a loop of edges".into()));
}
for (k, b) in made.into_iter().enumerate() {
let name = new_name(st, f, k);
st.bodies.push(ModelBody::new(name, b, f.id));
}
Ok(())
}
FeatureKind::Stitch { bodies, tolerance } => {
let tol = val(vals, tolerance, Kind::Length)?;
let idx: Vec<usize> = bodies
.iter()
.map(|n| st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`"))))
.collect::<Result<_>>()?;
let Some(&first) = idx.first() else { return Err(DocError::Invalid("pick the surfaces to stitch".into())) };
let refs: Vec<&kernel::Body> = idx.iter().filter_map(|i| st.bodies.get(*i).map(|b| &b.body)).collect();
let sewn = kernel::stitch(&refs, tol)?;
let Some(mb) = st.bodies.get(first).cloned() else { return Err(DocError::Invalid("body".into())) };
if let Some(slot) = st.bodies.get_mut(first) {
*slot = ModelBody::new(mb.name, sewn, mb.feature);
}
// The others are now part of the first.
let gone: Vec<String> = idx.iter().skip(1).filter_map(|i| st.bodies.get(*i).map(|b| b.name.clone())).collect();
st.bodies.retain(|b| !gone.contains(&b.name));
Ok(())
}
FeatureKind::Thicken { bodies, thickness, symmetric, operation, targets } => {
let t = val(vals, thickness, Kind::Length)?;
let mut tools = Vec::new();
for n in bodies {
let b = st.body(n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
tools.push(kernel::thicken(&b.body, t, *symmetric)?);
}
if tools.is_empty() {
return Err(DocError::Invalid("pick the surfaces to thicken".into()));
}
apply_op(st, f, tools, *operation, targets)
}
FeatureKind::SurfaceTrim { body, plane, tool, keep } => {
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let trimmed = match tool {
Some(t) => {
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
let face = crate::appearance::face_index_at(tb, t.point)
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?;
kernel::trim(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face }, *keep)?
}
None => kernel::trim(&mb.body, &kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?), *keep)?,
};
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, trimmed, mb.feature);
}
Ok(())
}
FeatureKind::SurfaceExtend { body, edges, distance } => {
let d = val(vals, distance, Kind::Length)?;
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let out = kernel::extend(&mb.body, edges, d)?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, out, mb.feature);
}
Ok(())
}
_ => Err(DocError::Invalid("not a surface feature".into())),
}
}
+2 -24
View File
@@ -13,9 +13,6 @@
//! - 3: appearances per body, face and component with names and opacity
//! (`"appearances": {"bodies": {name: {name, color, opacity}}, "faces": […], "components": {…}}`);
//! before, `"appearances"` mapped body names to bare `[r, g, b]` colours.
//! - 5: joins, cuts and intersects change only their own component's bodies unless bodies are
//! named as `participants`; older features in designs with components get `participants:
//! ["*"]` (every body), which is what they did, so those designs keep their shape.
//! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
@@ -24,7 +21,7 @@ use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 5;
pub const FORMAT: u32 = 4;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -37,26 +34,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 4] = [|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5];
/// Features in designs with components keep reaching every body.
fn v4_to_v5(v: &mut Value) -> Result<()> {
let has_components = v.get("components").and_then(Value::as_array).is_some_and(|c| !c.is_empty());
if !has_components {
return Ok(());
}
if let Some(Value::Array(fs)) = v.get_mut("features") {
for f in fs.iter_mut() {
if let Some(o) = f.as_object_mut()
&& o.get("type").and_then(Value::as_str) != Some("sketch")
&& o.get("participants").is_none()
{
o.insert("participants".into(), serde_json::json!(["*"]));
}
}
}
Ok(())
}
const JSON_STEPS: [JsonStep; 3] = [|_| Ok(()), v2_to_v3, v3_to_v4];
/// Joints placed with the old convention (aligned unless flipped) keep their placement.
fn v3_to_v4(v: &mut Value) -> Result<()> {
-1
View File
@@ -10,7 +10,6 @@ mod assembly;
pub mod canvas;
mod clipboard;
pub mod config;
pub mod construct;
mod document;
mod eval;
pub mod expr;
+1 -82
View File
@@ -226,14 +226,7 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
.enumerate()
.map(|(fi, n)| {
n.unwrap_or_else(|| {
let (ps, nn) = (pts.get(fi).map(Vec::as_slice).unwrap_or(&[]), ns.get(fi).unwrap_or(&empty));
// A shell's inner face: the face its wall runs back to.
if matches!(o.feature.kind, FeatureKind::Shell { .. })
&& let Some(n) = behind(ps, nn, &inputs)
{
return format!("F{fid}:inner:{}", strip_piece(&n));
}
role_name(&o.feature, &o.before, ps, nn, fi, fid)
role_name(&o.feature, &o.before, pts.get(fi).map(Vec::as_slice).unwrap_or(&[]), ns.get(fi).unwrap_or(&empty), fi, fid)
})
})
.collect();
@@ -241,59 +234,6 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
names
}
/// The input face a face looks back at through the material (along minus its normal): the
/// face a shell wall's inner side offsets.
fn behind(ps: &[Vec3], ns: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<String>>)]) -> Option<String> {
let n = ns.first().copied()?;
let d = n * -1.0;
let mut votes: Vec<(String, usize)> = Vec::new();
for p in ps {
let mut best: Option<(f64, String)> = None;
for (_, idx, nm) in inputs {
for (fi, (_, _, tris)) in idx.faces.iter().enumerate() {
for [a, b, c] in tris {
if let Some(t) = ray_hit(*p, d, *a, *b, *c)
&& t > 1e-9
&& best.as_ref().is_none_or(|(bt, _)| t < *bt)
&& let Some(name) = nm.get(fi)
{
best = Some((t, name.clone()));
}
}
}
}
if let Some((_, n)) = best {
match votes.iter_mut().find(|(v, _)| *v == n) {
Some(v) => v.1 += 1,
None => votes.push((n, 1)),
}
}
}
votes.sort_by_key(|v| std::cmp::Reverse(v.1));
votes.first().filter(|(_, k)| *k * 2 >= ps.len().max(1)).map(|(n, _)| n.clone())
}
fn ray_hit(o: Vec3, d: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Option<f64> {
let (e1, e2) = (b - a, c - a);
let h = d.cross(e2);
let det = e1.dot(h);
if det.abs() < 1e-14 {
return None;
}
let f = 1.0 / det;
let s = o - a;
let u = f * s.dot(h);
if !(0.0..=1.0).contains(&u) {
return None;
}
let q = s.cross(e1);
let v = f * d.dot(q);
if v < 0.0 || u + v > 1.0 {
return None;
}
Some(f * e2.dot(q))
}
/// The input face most of a face's sample points lie on (mapped by `map` first), if it holds
/// at least half of them.
fn vote(ps: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<String>>)], tol: f64, map: Option<&crate::Mat>) -> Option<String> {
@@ -442,27 +382,6 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
.min_by(|a, b| a.0.total_cmp(&b.0));
best.map(|(_, id)| format!("F{fid}:side:{id}")).unwrap_or_else(|| format!("F{fid}:face:{fi}"))
}
FeatureKind::Sweep { .. } | FeatureKind::Loft { .. } => {
let sk = match &f.kind {
FeatureKind::Sweep { sketch, .. } => Some(*sketch),
FeatureKind::Loft { sections, .. } => sections.first().map(|x| x.sketch),
_ => None,
};
let Some(ss) = sk.and_then(|k| before.sketch(k)) else { return format!("F{fid}:face:{fi}") };
let n = ss.plane.normal();
let h = |p: &Vec3| (*p - ss.plane.origin).dot(n);
let flat = !ns.is_empty() && ns.windows(2).all(|w| w[0].cross(w[1]).len() < 1e-6);
if flat && ps.iter().all(|p| h(p).abs() < 1e-6 * (1.0 + p.len())) {
return format!("F{fid}:start");
}
if flat && ns.iter().all(|m| m.cross(n).len() < 1e-6) {
return format!("F{fid}:end");
}
// A side: the profile curve under its sample nearest the start.
let Some(p0) = ps.iter().min_by(|a, b| h(a).abs().total_cmp(&h(b).abs())) else { return format!("F{fid}:face:{fi}") };
let q = ss.plane.to_local(*p0);
nearest_curve(ss, Vec2::new(q.x, q.y)).map(|id| format!("F{fid}:side:{id}")).unwrap_or_else(|| format!("F{fid}:face:{fi}"))
}
FeatureKind::Box { .. } => {
let n = ns.first().copied().unwrap_or(Vec3::Z);
let axis = |v: f64, p: &str, m: &str| {
+4 -82
View File
@@ -21,7 +21,6 @@ fn plane_inputs_mut<'a>(p: &'a mut PlaneRef, v: &mut Vec<(&'static str, &'a mut
v.push(("Angle", angle, Kind::Angle));
plane_inputs_mut(base, v);
}
PlaneRef::AlongPath { t, .. } => v.push(("Distance (0 to 1)", t, Kind::Unitless)),
_ => {}
}
}
@@ -35,25 +34,7 @@ impl FeatureKind {
FeatureKind::Sketch { plane, .. }
| FeatureKind::ConstructionPlane { plane }
| FeatureKind::Split { plane, .. }
| FeatureKind::SplitFace { plane, .. }
| FeatureKind::Mirror { plane, .. } => plane_inputs_mut(plane, &mut v),
FeatureKind::ConstructionAxis { def } => {
for r in def.refs_mut() {
if let crate::construct::GeoRef::Plane { plane } = r {
plane_inputs_mut(plane, &mut v);
}
}
}
FeatureKind::ConstructionPoint { def } => match def {
crate::construct::PointDef::AlongPath { t, .. } => v.push(("Distance (0 to 1)", t, U)),
other => {
for r in other.refs_mut() {
if let crate::construct::GeoRef::Plane { plane } = r {
plane_inputs_mut(plane, &mut v);
}
}
}
},
FeatureKind::Extrude { extent, .. } => {
v.push(("Distance", &mut extent.distance, L));
if let Some(d) = &mut extent.distance2 {
@@ -66,20 +47,8 @@ impl FeatureKind {
v.push(("Taper angle", d, A));
}
}
FeatureKind::Revolve { angle, angle2, .. } => {
v.push(("Angle", angle, A));
if let Some(a) = angle2 {
v.push(("Angle 2", a, A));
}
}
FeatureKind::Fillet { radius, style, .. } => match style {
crate::FilletStyle::Constant => v.push(("Radius", radius, L)),
crate::FilletStyle::Chord => v.push(("Chord length", radius, L)),
crate::FilletStyle::Variable { radius2, .. } => {
v.push(("Start radius", radius, L));
v.push(("End radius", radius2, L));
}
},
FeatureKind::Revolve { angle, .. } => v.push(("Angle", angle, A)),
FeatureKind::Fillet { radius, .. } => v.push(("Radius", radius, L)),
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
v.push(("Distance", distance, L));
if let Some(d) = distance2 {
@@ -254,19 +223,12 @@ impl FeatureKind {
}
}
FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v),
FeatureKind::Stitch { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
FeatureKind::Thicken { thickness, .. } => v.push(("Thickness", thickness, L)),
FeatureKind::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v),
FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)),
FeatureKind::Patch { .. } => {}
FeatureKind::Combine { .. }
| FeatureKind::SheetBase { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::SheetConvert { .. }
| FeatureKind::Align { .. }
| FeatureKind::Remove { .. }
| FeatureKind::DeleteFace { .. }
| FeatureKind::BoundaryFill { .. }
| FeatureKind::Loft { .. }
| FeatureKind::Sweep { .. }
| FeatureKind::Import { .. }
@@ -372,35 +334,6 @@ impl Document {
}
/// Change a parameter: a stored one, or a feature input by its name.
/// A typed length that is a bare number means the design's units: it gets that unit written
/// in (feature inputs are stored in mm otherwise). Anything else is returned unchanged.
pub fn with_design_unit(&self, e: &str, kind: Kind) -> String {
let u = self.units.trim();
let bare = expr::is_literal(e) && e.trim().trim_start_matches(['-', '+']).trim().parse::<f64>().is_ok();
if kind == Kind::Length && bare && !u.is_empty() && u != "mm" && expr::unit_info(u).is_some_and(|(k, _)| k == Kind::Length) {
format!("{} {u}", e.trim())
} else {
e.to_string()
}
}
/// Every length input of every feature as the geometry reads it (mm; a unit-less result is
/// in the design's units), for checking that an edit moves nothing.
pub fn length_inputs(&self) -> Vec<Option<f64>> {
let (vals, _) = self.param_values();
let scale = expr::unit_info(self.units.trim()).filter(|(k, _)| *k == Kind::Length).map(|(_, s)| s).unwrap_or(1.0);
let look = |n: &str| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")));
self.features
.iter()
.flat_map(|f| f.kind.inputs())
.filter(|(_, _, k)| *k == Kind::Length)
.map(|(_, e, _)| {
let d = if expr::is_literal(&e) { expr::Defaults::default() } else { expr::Defaults { len: scale, ..expr::Defaults::default() } };
expr::eval_with(&e, &look).and_then(|v| v.to_kind_in(Kind::Length, d)).ok()
})
.collect()
}
pub fn change_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> {
let hit = self.features.iter().enumerate().find_map(|(i, f)| f.param_names.iter().position(|n| n == name).map(|k| (i, k)));
let Some((i, k)) = hit else { return self.set_param(name, expr_s, unit, comment) };
@@ -408,12 +341,10 @@ impl Document {
return Err(DocError::Expr("expression too long".into()));
}
let mut next = self.clone();
let kind = self.features.get(i).and_then(|f| f.kind.inputs().get(k).map(|x| x.2)).unwrap_or(Kind::Unitless);
let expr_s = self.with_design_unit(expr_s, kind);
if let Some(f) = next.features.get_mut(i)
&& let Some((_, e, _)) = f.kind.inputs_mut().into_iter().nth(k)
{
*e = expr_s.clone();
*e = expr_s.to_string();
}
if let Some(c) = comment {
next.param_comments.insert(name.to_string(), c.to_string());
@@ -592,16 +523,7 @@ impl Document {
for f in &self.features {
for (name, (_, e, k)) in f.param_names.iter().zip(f.kind.inputs()) {
if !name.is_empty() {
// A bare number stored in an input is mm (the design's units are written in
// when it is typed); anything else that comes out unit-less (a unit-less
// parameter used as a length) is in the design's units.
let unit = match k {
Kind::Length if !expr::is_literal(&e) && expr::unit_info(self.units.trim()).is_some_and(|(k, _)| k == Kind::Length) => {
self.units.trim().to_string()
}
_ => unit_of(k).to_string(),
};
v.push(ParamDef { name: name.clone(), expr: e, unit });
v.push(ParamDef { name: name.clone(), expr: e, unit: unit_of(k).into() });
}
}
}
+1 -1
View File
@@ -257,7 +257,7 @@ fn resolve_uncached(
LinkSource::Plane { name } => {
let pr =
if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } };
let other = doc.resolve_plane_in(vals, &pr, 0, Some(st)).map_err(|_| lost(&format!("plane {name}")))?;
let other = doc.resolve_plane(vals, &pr, 0).map_err(|_| lost(&format!("plane {name}")))?;
plane_trace(plane, &other).into_iter().collect()
}
};
+2 -92
View File
@@ -160,9 +160,6 @@ pub struct SheetFlange {
/// instead of a rectangle `leg` long.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shape: Option<Vec<Vec2>>,
/// Which feature made it: `F<feature>.<k>` (persistent edge names).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub tag: String,
}
/// A sheet metal body (see the module docs).
@@ -182,9 +179,6 @@ pub struct SheetBody {
pub flat: bool,
/// Gap between flanges that meet at a corner.
pub gap: f64,
/// The base sketch's lines (id, ends in flat coordinates), naming the base panel's edges.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub base_curves: Vec<(String, Vec2, Vec2)>,
}
/// A piece of the sheet: a panel (None = the base) or a flange's bend zone.
@@ -845,7 +839,6 @@ impl SheetBody {
radius,
leg: leg.max(0.0),
shape: None,
tag: String::new(),
ext: (0.0, 0.0),
});
made.push(self.flanges.len() - 1);
@@ -912,7 +905,6 @@ impl SheetBody {
leg,
ext: (0.0, 0.0),
shape: None,
tag: String::new(),
});
Ok(self.flanges.len() - 1)
}
@@ -953,8 +945,7 @@ impl SheetBody {
let mut n = Vec2::new(-d.y, d.x);
let l0 = a2.dot(n);
// Which side moves. A flange keeps the side its own bend is on.
let on_line = n * l0;
let side_area = |m: Vec2| signed_area(&clip(&pts, on_line, m)).abs();
let side_area = |n: Vec2| signed_area(&clip(&pts, n * l0, n)).abs();
let moves_n = match (panel, fixed) {
(Some(j), _) => {
let f = self.flanges.get(j).ok_or_else(|| DocError::Invalid("flange".into()))?;
@@ -977,7 +968,7 @@ impl SheetBody {
n = n * -1.0;
}
let l = a2.dot(n);
let probe = SheetFlange { parent: panel, p: a2, d, len: 1.0, n, angle, radius, leg: 0.0, ext: (0.0, 0.0), shape: None, tag: String::new() };
let probe = SheetFlange { parent: panel, p: a2, d, len: 1.0, n, angle, radius, leg: 0.0, ext: (0.0, 0.0), shape: None };
let ba = self.allowance(&probe);
let s0 = match position {
"centerline" | "center" | "centre" => l - ba / 2.0,
@@ -1027,7 +1018,6 @@ impl SheetBody {
leg,
ext: (0.0, 0.0),
shape: Some(ccw(moving)),
tag: String::new(),
});
let i = self.flanges.len() - 1;
// Flanges on the moving part's edges now hang off the fold and move with it.
@@ -1050,69 +1040,6 @@ impl SheetBody {
Ok(i)
}
/// Name a panel edge (flat coordinates a→b on `panel`, top or bottom face): base edges by the
/// base sketch line they lie on, flange edges by the flange (its tag) and side.
fn edge_name_of(&self, panel: Option<usize>, a: Vec2, b: Vec2, top: bool) -> Option<String> {
let face = if top { "top" } else { "bottom" };
let d = (b - a).normalized()?;
match panel {
None => {
let on = |p: Vec2, s: Vec2, e: Vec2| {
let t = e - s;
t.len() > 1e-9 && ((p - s).cross(t) / t.len()).abs() < 1e-6
};
self.base_curves.iter().find(|(_, s, e)| on(a, *s, *e) && on(b, *s, *e)).map(|(id, _, _)| format!("base:{id}:{face}"))
}
Some(i) => {
let f = self.flanges.get(i)?;
if f.tag.is_empty() {
return None;
}
let side = if d.cross(f.d).abs() < 1e-6 {
// Along the bend: the far edge (or the bend side, never picked).
if ((a - f.p).dot(f.n)) > self.allowance(f) + 1e-6 { "far" } else { "bend" }
} else if ((a + b) * 0.5 - f.p).dot(f.d) < f.len / 2.0 {
"side0"
} else {
"side1"
};
Some(format!("{}:{side}:{face}", f.tag))
}
}
}
/// The persistent name of the sheet edge nearest a world point.
pub fn edge_name(&self, pick: Vec3) -> Option<String> {
let (panel, a, b, top, _) = self.edge_at(pick)?;
self.edge_name_of(panel, a, b, top)
}
/// A world point on the sheet edge with this name (the one nearest `near` if several).
pub fn edge_by_name(&self, name: &str, near: Vec3) -> Option<Vec3> {
let mut best: Option<(f64, Vec3)> = None;
for (pi, r) in self.panels() {
let pts = r.outer.ccw().polyline(1e-3);
let m = pts.len();
let w = self.world(pi);
for i in 0..m {
let (Some(&a), Some(&b)) = (pts.get(i), pts.get((i + 1) % m)) else { continue };
if a.dist(b) < 1e-9 {
continue;
}
for (z, top) in [(0.0, false), (self.t, true)] {
if self.edge_name_of(pi, a, b, top).as_deref() == Some(name) {
let mid = (a + b) * 0.5;
let q = apply_point(&w, Vec3::new(mid.x, mid.y, z));
if best.is_none_or(|(d, _)| q.dist(near) < d) {
best = Some((q.dist(near), q));
}
}
}
}
}
best.map(|(_, q)| q)
}
/// Map a region in a world plane to flat coordinates through a panel's placement.
pub fn region_to_flat(&self, panel: Option<usize>, plane: &Plane, r: &Region2) -> Option<Region2> {
let inv = mat_inverse(&self.world(panel))?;
@@ -1312,7 +1239,6 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
holes: Vec::new(),
flat: false,
gap: rv.gap,
base_curves: Vec::new(),
}
}
@@ -1363,7 +1289,6 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
holes: Vec::new(),
flat: false,
gap: rv.gap,
base_curves: Vec::new(),
};
let (y0, y1) = if ysign > 0.0 { (0.0, width) } else { (-width, 0.0) };
// First segment: the base panel.
@@ -1395,7 +1320,6 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
leg: leg.max(0.0),
ext: (0.0, 0.0),
shape: None,
tag: String::new(),
};
let ba = sheet.allowance(&f);
sheet.flanges.push(f);
@@ -1405,20 +1329,6 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
Ok(sheet)
}
/// Persistent names of the sheet edges nearest each world point (empty when none).
pub fn sheet_edge_names(st: &crate::ModelState, pts: &[Vec3]) -> Vec<String> {
pts.iter()
.map(|p| {
st.sheets
.iter()
.filter_map(|sh| sh.edge_at(*p).map(|e| (e.4, sh)))
.min_by(|a, b| a.0.total_cmp(&b.0))
.and_then(|(_, sh)| sh.edge_name(*p))
.unwrap_or_default()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
+2 -13
View File
@@ -38,9 +38,6 @@ fn plate_doc() -> (Document, u64) {
start_offset: None,
through_all: false,
taper: None,
to: None,
to_offset: None,
from: None,
},
operation: Operation::NewBody,
targets: vec![],
@@ -84,8 +81,7 @@ fn parametric_plate_reevaluates() {
#[test]
fn box_fillet_cut_timeline() {
let (mut doc, _) = plate_doc();
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 10.0)], radius: "3".into(), body: None, style: Default::default() }, None)
.unwrap();
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 10.0)], radius: "3".into(), body: None }, None).unwrap();
let mut sk = Sketch::new();
sk.add_circle(Vec2::new(20.0, 15.0), 5.0, None, None).unwrap();
let s2 = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
@@ -100,9 +96,6 @@ fn box_fillet_cut_timeline() {
start_offset: None,
through_all: false,
taper: None,
to: None,
to_offset: None,
from: None,
},
operation: Operation::Cut,
targets: vec![],
@@ -135,15 +128,11 @@ fn errors_are_reported_not_fatal() {
angle: "90 deg".into(),
operation: Operation::NewBody,
targets: vec![],
angle2: None,
symmetric: false,
to: None,
},
None,
)
.unwrap();
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None, style: Default::default() }, None)
.unwrap();
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None }, None).unwrap();
doc.add_feature(
FeatureKind::Box { corner: Vec3::ZERO, length: "nope".into(), width: "1".into(), height: "1".into(), operation: Operation::NewBody },
None,
+31 -36
View File
@@ -165,17 +165,14 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
if old == u {
return Ok(json!({ "units": u, "kept": [] }));
}
// What the geometry reads: every feature's length inputs.
let before = s.doc.length_inputs();
let (before, _) = s.doc.param_values();
s.doc_mut().units = u.to_string();
// Unit-less user parameters holding a bare number read it in the design's units where they
// stand for lengths. If any input moved, those numbers keep the old unit, written out.
let same = |a: &[Option<f64>]| {
a.iter().zip(&before).filter(|(x, y)| matches!((x, y), (Some(x), Some(y)) if (x - y).abs() <= 1e-9 * y.abs().max(1.0))).count()
};
let after = s.doc.length_inputs();
// stand for lengths. If any value moved, those numbers keep the old unit, written out.
let (after, _) = s.doc.param_values();
let moved = before.iter().any(|(k, b)| after.get(k).is_none_or(|a| (a.v - b.v).abs() > 1e-9 * b.v.abs().max(1.0)));
let mut kept = Vec::new();
if same(&after) < before.len() {
if moved {
let bare: Vec<(String, String)> = s
.doc
.params
@@ -184,15 +181,16 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
.map(|p| (p.name.clone(), p.expr.trim().to_string()))
.collect();
for (name, e) in bare {
let now = same(&s.doc.length_inputs());
let probe = {
let mut d = (*s.doc).clone();
let _ = d.change_param(&name, &format!("{e} {old}"), Some(&old), None);
same(&d.length_inputs())
let _ = d.change_param(&name, &format!("{e} {old}"), None, None);
d.param_values().0
};
// Only when writing the old unit puts more inputs back where they were.
if probe > now {
s.doc_mut().change_param(&name, &format!("{e} {old}"), Some(&old), None)?;
// Only when writing the old unit puts the values back where they were.
let fixes = before.iter().filter(|(k, b)| probe.get(*k).is_some_and(|a| (a.v - b.v).abs() <= 1e-9 * b.v.abs().max(1.0))).count()
> before.iter().filter(|(k, b)| after.get(*k).is_some_and(|a| (a.v - b.v).abs() <= 1e-9 * b.v.abs().max(1.0))).count();
if fixes {
s.doc_mut().change_param(&name, &format!("{e} {old}"), None, None)?;
kept.push(name);
}
}
@@ -332,9 +330,6 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
for f in d.appearances.faces.iter_mut().filter(|f| f.body == old) {
f.body = name.to_string();
}
if let Some(o) = d.body_offsets.remove(&old) {
d.body_offsets.insert(name.to_string(), o);
}
if let Some(c) = d.body_components.remove(&old) {
d.body_components.insert(name.to_string(), c);
}
@@ -358,7 +353,7 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
}
let plane = super::sketch::plane_ref(s, p, cmd)?;
let (vals, _) = s.doc.param_values();
s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
s.doc.resolve_plane(&vals, &plane, 0)?;
if let Some(FeatureKind::Sketch { plane: slot, .. }) = s.doc_mut().feature_mut(id).map(|f| &mut f.kind) {
*slot = plane;
}
@@ -385,10 +380,10 @@ mod tests {
#[test]
fn renamed_body_keeps_its_users() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
let [a, b] = [names(&s)[0].clone(), names(&s)[1].clone()];
run(&mut s, "solid.move", json!({"bodies": [b.clone()], "translate": [0, 0, 5]}));
run(&mut s, "FusionMoveCommand", json!({"bodies": [b.clone()], "translate": [0, 0, 5]}));
run(&mut s, "body.rename", json!({"body": b, "name": "Lid"}));
assert_eq!(names(&s), vec![a.clone(), "Lid".to_string()]);
assert!(s.model.results.iter().all(|r| r.error.is_none()), "the move still finds its body");
@@ -396,18 +391,18 @@ mod tests {
assert!(s.execute("body.rename", &json!({"body": "Lid", "name": a})).is_err());
assert!(s.execute("body.rename", &json!({"body": "Nope", "name": "X"})).is_err());
assert!(s.execute("body.rename", &json!({"body": "Lid", "name": " "})).is_err());
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert_eq!(names(&s)[1], b);
}
#[test]
fn redefined_sketch_moves_its_extrude() {
let mut s = Session::default();
let sk = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
let zmax = |s: &mut Session| run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap();
let sk = run(&mut s, "SketchCreate", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
let zmax = |s: &mut Session| run(s, "MeasureCommand", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap();
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
let z = zmax(&mut s);
@@ -419,8 +414,8 @@ mod tests {
#[test]
fn design_units_change_without_moving_geometry() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5}));
let vol = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
run(&mut s, "PrimitiveBox", json!({"length": 20, "width": 10, "height": 5}));
let vol = |s: &mut Session| run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let v0 = vol(&mut s);
run(&mut s, "document.units", json!({"units": "in"}));
assert_eq!(s.doc.units, "in");
@@ -430,14 +425,14 @@ mod tests {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "w", "expression": "5", "unit": ""}));
run(&mut s, "parameters.add", json!({"name": "n", "expression": "3", "unit": ""}));
run(&mut s, "solid.box", json!({"length": "w", "width": 10, "height": 5}));
run(&mut s, "PrimitiveBox", json!({"length": "w", "width": 10, "height": 5}));
let b = s.model.state().bodies[0].name.clone();
run(&mut s, "solid.pattern.rectangular", json!({"bodies": [b], "dir1": [1, 0, 0], "count1": "n", "spacing1": 20}));
run(&mut s, "PatternRectangular", json!({"bodies": [b], "dir1": [1, 0, 0], "count1": "n", "spacing1": 20}));
let v0 = vol(&mut s);
let r = run(&mut s, "document.units", json!({"units": "cm"}));
assert!((vol(&mut s) - v0).abs() < 1e-6, "{r}");
assert!(s.doc.param("n").is_some_and(|p| p.expr == "3"), "the count stays bare");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert_eq!(s.doc.units, "mm");
}
@@ -457,15 +452,15 @@ mod tests {
for p in [json!({}), json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": -1}}), json!({"camera": "x"})] {
assert!(s.execute("view.save", &p).is_err(), "{p}");
}
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert_eq!(s.doc.named_views.len(), 2);
}
#[test]
fn groups_hold_items_and_follow_renames() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
let [a, b] = [names(&s)[0].clone(), names(&s)[1].clone()];
let g = run(&mut s, "browser.group", json!({"folder": "bodies", "items": [a.clone()]}))["group"].as_u64().unwrap();
assert_eq!(s.doc.browser_groups[0].name, "Group1");
@@ -495,7 +490,7 @@ mod tests {
] {
assert!(s.execute(c, &p).is_err(), "{c} {p}");
}
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert_eq!(s.doc.browser_groups.len(), 1, "ungroup undoes");
}
}
+12 -12
View File
@@ -122,32 +122,32 @@ mod tests {
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
#[test]
fn copy_paste_sketch_and_extrude() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "w", "expression": "10 mm"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "w"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5}));
run(&mut s, "ChangeParameterCommand", json!({"name": "w", "expression": "10 mm"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "S"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "ConstraintCoincident", json!({"a": "p1", "b": "origin"}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": "w"}));
run(&mut s, "SketchDimension", json!({"entities": ["l2"], "value": 10}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "S", "distance": 5}));
assert!((volume(&mut s) - 500.0).abs() < 1e-6);
run(&mut s, "timeline.copy", json!({"features": ["S", "Extrude1"]}));
let r = run(&mut s, "timeline.paste", json!({"translate": [30, 0, 0]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
assert_eq!(run(&mut s, "inspect.measure", json!({}))["body_count"], 2);
assert_eq!(run(&mut s, "MeasureCommand", json!({}))["body_count"], 2);
assert!((volume(&mut s) - 1000.0).abs() < 1e-6);
// The copy's dimensions are its own, still driven by the user parameter.
run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "w", "expression": "20 mm"}));
assert!((volume(&mut s) - 2000.0).abs() < 1e-6);
let pasted_extrude = r["pasted"][1]["name"].as_str().unwrap_or_default().to_string();
let d = s.doc.find_feature(&pasted_extrude).and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "parameters.change", json!({"name": d, "expression": "10"}));
run(&mut s, "ChangeParameterCommand", json!({"name": d, "expression": "10"}));
assert!((volume(&mut s) - 3000.0).abs() < 1e-6, "only the copy got thicker");
assert!(s.execute("timeline.paste", &json!({"translate": "x"})).is_err());
let mut t = Session::default();
+9 -25
View File
@@ -12,11 +12,11 @@ use crate::params::{bad, bool_, num, str_, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.create", "New Component", new_component)
CommandSpec::new("FusionCreateNewComponentCommand", "New Component", new_component)
.at("SOLID", "ASSEMBLE")
.icon("component")
.params("name?, parent?: component id or name (default: the active one); activate?: bool (default true)"),
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
CommandSpec::new("FusionCreateComponentsFromBodiesCommand", "Create Components from Bodies", from_bodies)
.icon("component")
.params("bodies: [names] → one new component per body, named after it"),
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design)"),
@@ -31,8 +31,8 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence)
.params("occurrence; translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position)"),
CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"),
CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("drops pending occurrence moves"),
CommandSpec::new("SnapshotCmd", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
CommandSpec::new("AsBuiltPositionsCmd", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("drops pending occurrence moves"),
];
fn key(v: Option<&Value>) -> Option<String> {
@@ -58,7 +58,7 @@ fn translation(t: Vec3) -> Mat {
}
fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.create";
let cmd = "FusionCreateNewComponentCommand";
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component };
let id = s.doc_mut().add_component(str_(p, "name"), parent)?;
if p.get("activate").and_then(Value::as_bool).unwrap_or(true) {
@@ -70,7 +70,7 @@ fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
}
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.from_bodies";
let cmd = "FusionCreateComponentsFromBodiesCommand";
let bodies = string_list(p, "bodies");
if bodies.is_empty() || bodies.len() > 1000 {
return Err(bad(cmd, "`bodies` must list 1…1000 body names"));
@@ -156,9 +156,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
d.components.retain(|c| !gone.contains(&c.id));
d.occurrences.retain(|o| !gone.contains(&o.component) && !gone.contains(&o.parent));
let moved_out: Vec<String> = d.body_components.iter().filter(|(_, c)| gone.contains(c)).map(|(b, _)| b.clone()).collect();
d.body_components.retain(|_, c| !gone.contains(c));
d.body_offsets.retain(|b, _| !moved_out.contains(b));
if gone.contains(&s.active_component) {
s.active_component = 0;
}
@@ -176,23 +174,10 @@ fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
for b in &bodies {
let feature = st.body(b).map(|x| x.feature).unwrap_or(0);
let own = s.doc.feature(feature).map(|f| f.component).unwrap_or(0);
// The body keeps its place in the world: where it is shown now (its component's frame and
// any earlier offset), seen from the new component's frame.
let from = s.doc.body_component(b, feature);
let now = solvecraft_doc::mat_mul(&s.doc.component_transform(from), s.doc.body_offsets.get(b).unwrap_or(&solvecraft_doc::IDENTITY));
let offset = solvecraft_doc::mat_inverse(&s.doc.component_transform(to))
.map(|inv| solvecraft_doc::mat_mul(&inv, &now))
.unwrap_or(solvecraft_doc::IDENTITY);
let doc = s.doc_mut();
if own == to {
doc.body_components.remove(b);
s.doc_mut().body_components.remove(b);
} else {
doc.body_components.insert(b.clone(), to);
}
if solvecraft_doc::is_identity(&offset) {
doc.body_offsets.remove(b);
} else {
doc.body_offsets.insert(b.clone(), offset);
s.doc_mut().body_components.insert(b.clone(), to);
}
}
Ok(json!({"bodies": bodies, "component": to}))
@@ -370,8 +355,7 @@ fn revert(s: &mut Session, _p: &Value) -> Result<Value> {
/// The active component's frame from world coordinates (for new features' geometry).
pub fn to_active_frame(s: &Session, kind: &mut FeatureKind) {
// Commands whose picks `frames` maps are already in their frames.
if s.active_component == 0 || crate::frames::mapped() {
if s.active_component == 0 {
return;
}
let m = s.doc.component_transform(s.active_component);
+14 -14
View File
@@ -9,10 +9,10 @@ use crate::params::{bad, bool_, str_, string_list};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("config.configure", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params(
CommandSpec::new("FusionStartDesignConfigModeCmd", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params(
"params?: [parameter names]; features?: [feature names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
),
CommandSpec::new("config.table", "Display Configuration Table", show)
CommandSpec::new("FusionShowDesignConfigPanelCmd", "Display Configuration Table", show)
.at("SOLID", "CONFIGURE")
.icon("params")
.noundo()
@@ -54,7 +54,7 @@ fn add_column(s: &mut Session, c: Column, cmd: &str) -> Result<()> {
}
fn configure(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "config.configure";
let cmd = "FusionStartDesignConfigModeCmd";
for n in string_list(p, "params") {
let c = column_of(s, &json!({ "param": n }), cmd)?;
add_column(s, c, cmd)?;
@@ -196,17 +196,17 @@ mod tests {
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
#[test]
fn configurations_switch_sizes_and_suppressions() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "len", "expression": "40 mm"}));
run(&mut s, "solid.box", json!({"length": "len", "width": 20, "height": 10}));
run(&mut s, "solid.fillet", json!({"edges": [[0, 0, 5]], "radius": 3}));
run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "40 mm"}));
run(&mut s, "PrimitiveBox", json!({"length": "len", "width": 20, "height": 10}));
run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[0, 0, 5]], "radius": 3}));
let fillet = s.doc.features.last().map(|f| f.name.clone()).unwrap_or_default();
let t = run(&mut s, "config.configure", json!({"params": ["len"], "features": [fillet]}));
let t = run(&mut s, "FusionStartDesignConfigModeCmd", json!({"params": ["len"], "features": [fillet]}));
assert_eq!(t["rows"][0]["name"], "Default");
run(&mut s, "config.row", json!({"name": "Long", "values": {"len": "80 mm"}}));
run(&mut s, "config.row", json!({"name": "Sharp", "from": "Long", "values": {fillet.clone(): true}}));
@@ -216,17 +216,17 @@ mod tests {
assert!((volume(&mut s) - (16000.0 - (9.0 - std::f64::consts::PI * 9.0 / 4.0) * 10.0)).abs() < 0.5, "{}", volume(&mut s));
run(&mut s, "config.activate", json!({"row": "Sharp"}));
assert!((volume(&mut s) - 16000.0).abs() < 1e-6);
let t = run(&mut s, "config.table", json!({}));
let t = run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}));
assert_eq!((t["active"].as_str(), t["matches"].as_bool()), (Some("Sharp"), Some(true)), "{t}");
// Undo goes back to the previous configuration's values.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert!(volume(&mut s) > 15000.0 && volume(&mut s) < 16000.0);
// Renaming the parameter keeps the column; editing it by hand breaks the match.
run(&mut s, "parameters.rename", json!({"name": "len", "new_name": "length"}));
let t = run(&mut s, "config.table", json!({}));
let t = run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}));
assert_eq!(t["columns"][0], "param:length", "{t}");
run(&mut s, "parameters.change", json!({"name": "length", "expression": "50 mm"}));
assert_eq!(run(&mut s, "config.table", json!({}))["matches"], false);
run(&mut s, "ChangeParameterCommand", json!({"name": "length", "expression": "50 mm"}));
assert_eq!(run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}))["matches"], false);
// Bad input.
assert!(s.execute("config.row", &json!({"name": "X", "values": {"nope": 1}})).is_err());
assert!(s.execute("config.activate", &json!({"row": "Missing"})).is_err());
@@ -234,6 +234,6 @@ mod tests {
assert!(s.execute("config.activate", &json!({"row": "Broken"})).is_err());
run(&mut s, "config.row", json!({"name": "Broken", "delete": true}));
run(&mut s, "config.column", json!({"feature": fillet, "delete": true}));
assert_eq!(run(&mut s, "config.table", json!({}))["columns"].as_array().map(Vec::len), Some(1));
assert_eq!(run(&mut s, "FusionShowDesignConfigPanelCmd", json!({}))["columns"].as_array().map(Vec::len), Some(1));
}
}
-251
View File
@@ -1,251 +0,0 @@
//! SOLID ▸ CONSTRUCT: construction axes, points and the planes built from model geometry.
//!
//! References are given as `{face: [x,y,z]}`, `{edge: [x,y,z]}`, `{vertex: [x,y,z]}`,
//! `{point: [x,y,z]}`, `{plane: XY|name|{origin, normal}}`, `{axis: X|Y|Z|name}`,
//! `{construct_point: name}`, `{sketch, point}` or `{sketch, curve}`, or as a tagged
//! `GeoRef` (`{"type": "edge", "at": …}`). Picked faces and edges are stored with their
//! persistent names.
use serde_json::Value;
use solvecraft_doc::construct::{AxisDef, GeoRef, PointDef};
use solvecraft_doc::{FeatureKind, PlaneRef};
use super::CommandSpec;
use super::features::{add_feature, check_expr, plane_param, sketch_id};
use crate::params::{bad, req_expr, str_, vec3};
use crate::{Result, Session};
const REF: &str =
"{face|edge|vertex|point: [x,y,z]} | {plane: XY|name|{origin, normal}} | {axis: X|Y|Z|name} | {construct_point: name} | {sketch, point|curve}";
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("construct.axis.through_cylinder", "Axis Through Cylinder/Cone/Torus", axis_face)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.params("face: ref (a cylinder, cone or torus face, or a circular edge); name?"),
CommandSpec::new("construct.axis.normal_to_face", "Axis Perpendicular To Face", axis_normal)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.params("face: ref; point: ref (the axis goes through it, square to the face); name?"),
CommandSpec::new("construct.axis.two_planes", "Axis Through Two Planes", axis_planes)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.params("a, b: plane or planar face refs; name?"),
CommandSpec::new("construct.axis.two_points", "Axis Through Two Points", axis_points)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.params("a, b: point refs (vertices, sketch points, points); name?"),
CommandSpec::new("construct.axis.edge", "Axis Through Edge", axis_edge)
.at("SOLID", "CONSTRUCT")
.icon("axis")
.params("edge: ref (a straight edge or sketch line); name?"),
CommandSpec::new("construct.point.vertex", "Point At Vertex", point_at)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("point: ref (vertex, sketch point, point); name?"),
CommandSpec::new("construct.point.two_edges", "Point Through Two Edges", point_edges)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("a, b: edge or line refs (where they meet, or come closest); name?"),
CommandSpec::new("construct.point.three_planes", "Point Through Three Planes", point_planes)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("a, b, c: plane or planar face refs; name?"),
CommandSpec::new("construct.point.center", "Point At Center Of Circle/Sphere/Torus", point_center)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("of: ref (circular edge, sketch circle or arc, sphere or torus face); name?"),
CommandSpec::new("construct.point.edge_plane", "Point At Edge And Plane", point_edge_plane)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("edge: edge or line ref; plane: plane or planar face ref; name?"),
CommandSpec::new("construct.point.along_path", "Point Along Path", point_along)
.at("SOLID", "CONSTRUCT")
.icon("point")
.params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name?"),
CommandSpec::new("construct.plane.midplane", "Midplane", plane_mid)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("a, b: plane or planar face refs (halfway between; bisecting when they meet at an angle); name?"),
CommandSpec::new("construct.plane.two_edges", "Plane Through Two Edges", plane_edges)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("a, b: straight edge or line refs in one plane; name?"),
CommandSpec::new("construct.plane.three_points", "Plane Through Three Points", plane_points)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("a, b, c: point refs; name?"),
CommandSpec::new("construct.plane.tangent", "Tangent Plane", plane_tangent)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("face: ref (cylinder, cone, sphere or torus); at?: [x,y,z] where it touches (default: the face pick); name?"),
CommandSpec::new("construct.plane.along_path", "Plane Along Path", plane_along)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("path: edge or sketch curve ref; t: expr, 0…1 along it (default 0.5); name? The plane is square to the path there"),
];
/// A reference from its JSON form.
fn geo(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result<GeoRef> {
let v = v.ok_or_else(|| bad(cmd, format!("`{what}` is required: {REF}")))?;
let p3 = |k: &str| v.get(k).and_then(vec3);
let mut g = if v.get("type").is_some() {
serde_json::from_value::<GeoRef>(v.clone()).map_err(|e| bad(cmd, format!("`{what}`: {e}")))?
} else if let Some(at) = p3("face") {
GeoRef::Face { at, name: None }
} else if let Some(at) = p3("edge") {
GeoRef::Edge { at, name: None }
} else if let Some(at) = p3("vertex") {
GeoRef::Vertex { at }
} else if let Some(sk) = v.get("sketch") {
let sketch = sketch_id(s, Some(sk), cmd, what)?;
match (str_(v, "point"), str_(v, "curve")) {
(Some(point), _) => GeoRef::SketchPoint { sketch, point: point.to_string() },
(None, Some(curve)) => GeoRef::SketchCurve { sketch, curve: curve.to_string() },
_ => return Err(bad(cmd, format!("`{what}`: a sketch reference needs `point` or `curve`"))),
}
} else if let Some(p) = p3("point") {
GeoRef::Point { p }
} else if v.get("plane").is_some() {
GeoRef::Plane { plane: plane_param(s, v.get("plane"), cmd)? }
} else if let Some(a) = str_(v, "axis") {
if ["x", "y", "z"].contains(&a.to_ascii_lowercase().as_str()) {
GeoRef::World { axis: a.to_ascii_uppercase() }
} else {
match s.doc.find_feature(a).map(|f| &f.kind) {
Some(FeatureKind::ConstructionAxis { .. }) => GeoRef::Axis { name: a.to_string() },
_ => return Err(bad(cmd, format!("no axis `{a}`"))),
}
}
} else if let Some(n) = str_(v, "construct_point") {
match s.doc.find_feature(n).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.to_string() },
_ => return Err(bad(cmd, format!("no construction point `{n}`"))),
}
} else if v.as_str().is_some() {
// A bare string: a plane.
GeoRef::Plane { plane: plane_param(s, Some(v), cmd)? }
} else {
return Err(bad(cmd, format!("`{what}` must be {REF}")));
};
g.name_picks(&s.model.state());
Ok(g)
}
fn t_param(s: &Session, p: &Value, cmd: &str) -> Result<String> {
let t = if p.get("t").is_some() { req_expr(cmd, p, "t")? } else { "0.5".to_string() };
check_expr(s, &t, solvecraft_doc::expr::Kind::Unitless, cmd, "t")?;
Ok(t)
}
fn axis(s: &mut Session, p: &Value, def: AxisDef) -> Result<Value> {
add_feature(s, p, FeatureKind::ConstructionAxis { def })
}
fn point(s: &mut Session, p: &Value, def: PointDef) -> Result<Value> {
add_feature(s, p, FeatureKind::ConstructionPoint { def })
}
fn plane(s: &mut Session, p: &Value, plane: PlaneRef) -> Result<Value> {
add_feature(s, p, FeatureKind::ConstructionPlane { plane })
}
fn axis_face(s: &mut Session, p: &Value) -> Result<Value> {
let face = geo(s, p.get("face"), "construct.axis.through_cylinder", "face")?;
axis(s, p, AxisDef::ThroughFace { face })
}
fn axis_normal(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.axis.normal_to_face";
let face = geo(s, p.get("face"), cmd, "face")?;
let point = geo(s, p.get("point"), cmd, "point")?;
axis(s, p, AxisDef::NormalToFace { face, point })
}
fn axis_planes(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.axis.two_planes";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
axis(s, p, AxisDef::TwoPlanes { a, b })
}
fn axis_points(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.axis.two_points";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
axis(s, p, AxisDef::TwoPoints { a, b })
}
fn axis_edge(s: &mut Session, p: &Value) -> Result<Value> {
let edge = geo(s, p.get("edge"), "construct.axis.edge", "edge")?;
axis(s, p, AxisDef::Edge { edge })
}
fn point_at(s: &mut Session, p: &Value) -> Result<Value> {
let at = geo(s, p.get("point"), "construct.point.vertex", "point")?;
point(s, p, PointDef::At { point: at })
}
fn point_edges(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.point.two_edges";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
point(s, p, PointDef::TwoEdges { a, b })
}
fn point_planes(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.point.three_planes";
let (a, b, c) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?, geo(s, p.get("c"), cmd, "c")?);
point(s, p, PointDef::ThreePlanes { a, b, c })
}
fn point_center(s: &mut Session, p: &Value) -> Result<Value> {
let of = geo(s, p.get("of"), "construct.point.center", "of")?;
point(s, p, PointDef::Center { of })
}
fn point_edge_plane(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.point.edge_plane";
let (edge, pl) = (geo(s, p.get("edge"), cmd, "edge")?, geo(s, p.get("plane"), cmd, "plane")?);
point(s, p, PointDef::EdgeAndPlane { edge, plane: pl })
}
fn point_along(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.point.along_path";
let path = geo(s, p.get("path"), cmd, "path")?;
let t = t_param(s, p, cmd)?;
point(s, p, PointDef::AlongPath { path, t })
}
fn plane_mid(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.midplane";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
plane(s, p, PlaneRef::Midplane { a: Box::new(a), b: Box::new(b) })
}
fn plane_edges(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.two_edges";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
plane(s, p, PlaneRef::TwoEdges { a: Box::new(a), b: Box::new(b) })
}
fn plane_points(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.three_points";
let (a, b, c) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?, geo(s, p.get("c"), cmd, "c")?);
plane(s, p, PlaneRef::ThreePoints { a: Box::new(a), b: Box::new(b), c: Box::new(c) })
}
fn plane_tangent(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.tangent";
let face = geo(s, p.get("face"), cmd, "face")?;
let at = match (p.get("at").and_then(vec3), &face) {
(Some(a), _) => a,
(None, GeoRef::Face { at, .. }) => *at,
_ => return Err(bad(cmd, "`at` is required: where the plane touches the face")),
};
plane(s, p, PlaneRef::Tangent { face: Box::new(face), at })
}
fn plane_along(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.along_path";
let path = geo(s, p.get("path"), cmd, "path")?;
let t = t_param(s, p, cmd)?;
plane(s, p, PlaneRef::AlongPath { path: Box::new(path), t })
}
+15 -26
View File
@@ -6,7 +6,6 @@
use serde_json::{Value, json};
use solvecraft_doc::FeatureKind;
use solvecraft_geom::Vec3;
use super::CommandSpec;
use crate::params::{bad, bool_};
@@ -64,7 +63,6 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let mut entities = Vec::new();
let mut features: Vec<u64> = Vec::new();
let mut bodies: Vec<String> = Vec::new();
let mut faces: Vec<(String, Vec<Vec3>)> = Vec::new();
for x in &items {
match x {
Sel::SketchCurve { id } | Sel::SketchPoint { id } | Sel::SketchConstraint { id } => entities.push(id.clone()),
@@ -80,10 +78,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
},
Sel::Axis { .. } => skip("the origin axes can't be deleted".into()),
Sel::Body { name } => bodies.push(name.clone()),
Sel::Face { body, point, .. } => match faces.iter_mut().find(|(b, _)| b == body) {
Some((_, ps)) => ps.push(*point),
None => faces.push((body.clone(), vec![*point])),
},
Sel::Face { .. } => skip("Delete Face: not available yet".into()),
Sel::Edge { .. } | Sel::Vertex { .. } => skip("edges and vertices are deleted with their body or face".into()),
Sel::Profile { .. } => skip("a profile goes with its sketch".into()),
}
@@ -98,14 +93,9 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
done = true;
}
}
// Faces of a body that is going anyway are left to it; the rest become Delete Face features.
for (body, points) in faces.iter().filter(|(b, _)| !bodies.contains(b)) {
sub(s, "face.delete", json!({ "faces": points, "body": body }))?;
done = true;
}
if !features.is_empty() {
let ids: Vec<String> = features.iter().map(u64::to_string).collect();
sub(s, "timeline.delete", json!({ "features": ids }))?;
sub(s, "FusionDeleteCommand", json!({ "features": ids }))?;
done = true;
}
// Bodies still there (not made by a feature deleted above) get a Remove feature.
@@ -113,7 +103,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
bodies.retain(|b| st.body(b).is_some());
bodies.dedup();
if !bodies.is_empty() {
sub(s, "solid.remove", json!({ "bodies": bodies }))?;
sub(s, "SoftDeleteCommand", json!({ "bodies": bodies }))?;
done = true;
}
// An occurrence goes alone while its component has others; the last one takes the component.
@@ -184,12 +174,12 @@ mod tests {
/// A sketch with a rectangle extruded into a body, a second box, and an offset plane.
fn part() -> (Session, u64) {
let mut s = Session::default();
let sk = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [50, 0, 0]}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 10}));
let sk = run(&mut s, "SketchCreate", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 20]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
run(&mut s, "PrimitiveBox", json!({"length": 5, "width": 5, "height": 5, "corner": [50, 0, 0]}));
run(&mut s, "ConstructionPlaneOffsetFromPlaneCommand", json!({"base": "XY", "offset": 10}));
(s, sk)
}
@@ -202,7 +192,7 @@ mod tests {
assert!(dry["deleted"].as_array().unwrap().len() >= 2, "the extrude goes with its sketch: {dry}");
run(&mut s, "selection.delete", json!({"items": [{"type": "feature", "id": sk}]}));
assert_eq!(bodies(&s), 1);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert_eq!(s.doc.features.len(), n);
assert_eq!(bodies(&s), 2);
}
@@ -222,7 +212,7 @@ mod tests {
assert!(s.doc.find_feature("Plane1").is_none());
assert_eq!(r["skipped"][0], "the origin planes can't be deleted");
assert_eq!(s.undo.len(), depth + 1);
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert_eq!((bodies(&s), s.doc.features.len()), (2, n));
}
@@ -231,8 +221,7 @@ mod tests {
let (mut s, _) = part();
let b = s.model.state().bodies[0].name.clone();
let e = s.execute("selection.delete", &json!({"items": [{"type": "face", "body": b, "index": 0, "point": [0, 0, 0]}]})).unwrap_err();
// A side of the part its neighbours can't close: Delete Face refuses it.
assert!(e.to_string().contains("Delete Face"), "{e}");
assert!(e.to_string().contains("not available yet"), "{e}");
for p in [
json!({}),
json!({"items": "x"}),
@@ -248,7 +237,7 @@ mod tests {
fn occurrences_components_and_sketch_entities() {
let (mut s, _) = part();
let b = s.model.state().bodies[1].name.clone();
run(&mut s, "component.from_bodies", json!({"bodies": [b]}));
run(&mut s, "FusionCreateComponentsFromBodiesCommand", json!({"bodies": [b]}));
let c = s.doc.components[0].id;
let o1 = s.doc.occurrence_of(c).unwrap().id;
run(&mut s, "occurrence.copy", json!({"component": c, "translate": [0, 30, 0]}));
@@ -260,8 +249,8 @@ mod tests {
run(&mut s, "selection.delete", json!({"occurrences": [o2]}));
assert!(s.doc.components.is_empty());
// Sketch entities while sketching.
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]]}));
let sk = s.active_sketch.unwrap();
let id = s.model.state().sketch(sk).unwrap().sketch.curves[0].id.clone();
run(&mut s, "selection.delete", json!({"items": [{"type": "sketch_curve", "id": id}]}));
+10 -10
View File
@@ -8,17 +8,17 @@ use crate::params::{bad, bool_, num, str_, string_list};
use crate::{EngineError, Result, Sel, Session, Snapshot};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
CommandSpec::new("timeline.delete", "Delete", delete)
CommandSpec::new("UndoCommand", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
CommandSpec::new("RedoCommand", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
CommandSpec::new("FusionComputeAllCommand", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
CommandSpec::new("FusionDeleteCommand", "Delete", delete)
.at("SOLID", "MODIFY")
.icon("delete")
.key("Delete")
.params("features: [id or name] (a sketch takes its dependent features with it)"),
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name"),
CommandSpec::new("FusionRenameTimelineEntryCommand", "Rename", rename).params("feature: id|name, name"),
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
CommandSpec::new("timeline.rollTo", "Roll History Marker", rollback)
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder)
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
@@ -75,7 +75,7 @@ fn feature_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64> {
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.delete";
let cmd = "FusionDeleteCommand";
let mut keys = string_list(p, "features");
if let Some(Value::Array(a)) = p.get("features") {
keys.extend(a.iter().filter_map(Value::as_u64).map(|n| n.to_string()));
@@ -108,7 +108,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.rename";
let cmd = "FusionRenameTimelineEntryCommand";
let id = feature_id(s, p.get("feature"), cmd)?;
let name =
str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
@@ -125,7 +125,7 @@ fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
let n = s.doc.features.len();
let pos = match p.get("feature") {
Some(f) => {
let id = feature_id(s, Some(f), "timeline.roll_to")?;
let id = feature_id(s, Some(f), "timeline.rollTo")?;
s.doc.feature_index(id).map(|i| i + 1)
}
None => num(p, "position").map(|x| x.max(0.0) as usize),
@@ -278,7 +278,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
let id = feature_id(s, p.get("feature"), cmd)?;
let cid = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name the feature's command"))?;
let spec = super::find_command(cid).ok_or_else(|| bad(cmd, format!("no command `{cid}`")))?;
if spec.id.starts_with("timeline.") || spec.id.starts_with("select.") || spec.id.starts_with("sketch.") {
if cid.starts_with("timeline.") || cid.starts_with("select.") || cid.starts_with("Sketch") {
return Err(bad(cmd, format!("`{cid}` does not make a feature")));
}
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
+4 -4
View File
@@ -118,7 +118,7 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
let spec = crate::find_command(id).ok_or_else(|| bad(cmd, id))?;
(spec.run)(s, &p)
};
let r = run(s, "sketch.create", json!({"plane": {"face": [inside.x, inside.y, inside.z]}, "project_edges": true}))?;
let r = run(s, "SketchCreate", json!({"plane": {"face": [inside.x, inside.y, inside.z]}, "project_edges": true}))?;
let sid = r.get("sketch").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
s.refresh();
let plane = s.model.state().sketch(sid).map(|ss| ss.plane).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
@@ -142,10 +142,10 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
None
};
match circle {
Some((o, r)) if closed => run(s, "sketch.circle.center", json!({"center": xy(o), "radius": r}))?,
Some((o, r)) if closed => run(s, "CircleCenterRadius", json!({"center": xy(o), "radius": r}))?,
Some(_) => {
let mid = pts.get(pts.len() / 2).copied().unwrap_or(first);
run(s, "sketch.arc.three_point", json!({"start": xy(first), "end": xy(last), "through": xy(mid)}))?
run(s, "ArcThreePoint", json!({"start": xy(first), "end": xy(last), "through": xy(mid)}))?
}
None => {
let mut list: Vec<Value> = Vec::new();
@@ -159,7 +159,7 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
if closed_poly {
list.pop();
}
run(s, "sketch.line", json!({"points": list, "closed": closed_poly}))?
run(s, "DrawPolyline", json!({"points": list, "closed": closed_poly}))?
}
};
}
+77 -255
View File
@@ -9,105 +9,92 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec2, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.extrude", "Extrude", extrude)
CommandSpec::new("Extrude", "Extrude", extrude)
.at("SOLID", "CREATE")
.icon("extrude")
.key("E")
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"),
CommandSpec::new("Revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.sweep", "Sweep", sweep)
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
CommandSpec::new("Sweep", "Sweep", sweep)
.at("SOLID", "CREATE")
.icon("sweep")
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?"),
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"),
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z]; operation?"),
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z]; axis?: [x,y,z]; operation?"),
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z]; operation?"),
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?; operation?"),
CommandSpec::new("solid.fillet", "Fillet", fillet)
CommandSpec::new("SolidLoft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"),
CommandSpec::new("PrimitiveBox", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z]; operation?"),
CommandSpec::new("PrimitiveCylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z]; axis?: [x,y,z]; operation?"),
CommandSpec::new("PrimitiveSphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z]; operation?"),
CommandSpec::new("PrimitiveTorus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?; operation?"),
CommandSpec::new("FusionFilletEdgesCommand", "Fillet", fillet)
.at("SOLID", "MODIFY")
.icon("fillet")
.key("F")
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?"),
CommandSpec::new("FusionChamferCommand", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("FusionCombineCommand", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool"),
CommandSpec::new("PatternRectangular", "Rectangular Pattern", pattern_rect)
.at("SOLID", "CREATE")
.icon("pattern_rect")
.params("features: [names] | bodies: [names] | components: [names]; dir1: [x,y,z]; count1; spacing1; dir2?, count2?, spacing2?"),
CommandSpec::new("solid.pattern.circular", "Circular Pattern", pattern_circ)
CommandSpec::new("PatternCircular", "Circular Pattern", pattern_circ)
.at("SOLID", "CREATE")
.icon("pattern_circ")
.params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg)"),
CommandSpec::new("solid.pattern.path", "Pattern on Path", pattern_path)
CommandSpec::new("PatternOnPath", "Pattern on Path", pattern_path)
.at("SOLID", "CREATE")
.icon("pattern_rect")
.params("features: [names] | bodies: [names]; path_sketch: sketch; path: [curve ids in order]; count; spacing (between instances) | distance (first to last); orientation?: identical|path; flip?: bool"),
CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ | {origin, x_dir, y_dir}"),
CommandSpec::new("solid.hole", "Hole", hole)
CommandSpec::new("MirrorCommand", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ | {origin, x_dir, y_dir}"),
CommandSpec::new("FusionHoleCommand", "Hole", hole)
.at("SOLID", "CREATE")
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch) | sketch alone (every sketch point: standalone points and circle centres); direction?: [x,y,z] (default: into the face); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
CommandSpec::new("solid.pipe", "Pipe", pipe)
.params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch); direction?: [x,y,z] (default: into the face); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
CommandSpec::new("PrimitivePipe", "Pipe", pipe)
.at("SOLID", "CREATE")
.icon("pipe")
.params("path_sketch: sketch; path: [curve ids in order]; diameter: expr; wall?: expr (hollow); operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.bounding_solid", "Bounding Solid", bounding_solid)
CommandSpec::new("StockModelCommand", "Bounding Solid", bounding_solid)
.at("SOLID", "CREATE")
.icon("box")
.params("bodies?: [names] (default: all); margin?: expr; name?, body_name?"),
CommandSpec::new("solid.scale", "Scale", scale)
CommandSpec::new("ModifyScale", "Scale", scale)
.at("SOLID", "MODIFY")
.icon("scale")
.params("bodies: [names]; factor: expr | factors: [x, y, z] exprs; origin?: [x,y,z]"),
CommandSpec::new("face.delete", "Delete Face", delete_face)
.at("SOLID", "MODIFY")
.icon("delete")
.params("faces: [[x,y,z] points on the faces]; body?. The neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off"),
CommandSpec::new("solid.offset_face", "Offset Face", offset_face)
CommandSpec::new("FusionOffsetFacesCommand", "Offset Face", offset_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
.params("faces: [[x,y,z] points on planar faces]; distance: expr (positive: outward); body?"),
CommandSpec::new("solid.thread", "Thread", thread)
CommandSpec::new("FusionThreadCommand", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face). Cosmetic: the model is unchanged, model.threads lists it"),
CommandSpec::new("solid.shell", "Shell", shell)
CommandSpec::new("FusionShellBodyCommand", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
.params("faces: [[x,y,z] points on the faces to remove]; thickness: expr; direction?: inside|outside|both; tangent_chain?: bool (also open faces joined smoothly to the picked ones); body?"),
CommandSpec::new("solid.draft", "Draft", draft)
.params("faces: [[x,y,z] points on the faces to remove]; thickness: expr (inside); body?"),
CommandSpec::new("FusionDraftCommand", "Draft", draft)
.at("SOLID", "MODIFY")
.icon("draft")
.params("faces: [[x,y,z]]; angle: expr; neutral: XY|XZ|YZ|plane name|{origin, normal}; pull?: [x,y,z] (default: neutral plane normal); body?"),
CommandSpec::new("construct.plane.offset", "Offset Plane", plane_offset)
CommandSpec::new("ConstructionPlaneOffsetFromPlaneCommand", "Offset Plane", plane_offset)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("base: XY|XZ|YZ|plane name; offset: expr; name?"),
CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle)
CommandSpec::new("ConstructionPlaneAtAngleCommand", "Plane at Angle", plane_angle)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("base: XY|XZ|YZ|plane name; axis: X|Y|Z|{origin, dir}; angle: expr; name?"),
CommandSpec::new("solid.split_face", "Split Face", split_face)
.at("SOLID", "MODIFY")
.icon("split")
.params(
"faces: [[x,y,z] points on the faces to split]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
),
CommandSpec::new("solid.split_body", "Split Body", split_body)
.at("SOLID", "MODIFY")
.icon("split")
.params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended)"),
CommandSpec::new("solid.press_pull", "Press Pull", press_pull)
CommandSpec::new("FusionSplitBodyCommand", "Split Body", split_body).at("SOLID", "MODIFY").icon("split").params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal}"),
CommandSpec::new("FusionPressPullCommand", "Press Pull", press_pull)
.at("SOLID", "MODIFY")
.icon("presspull")
.key("Q")
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"),
CommandSpec::new("FusionMoveCommand", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"),
];
/// Where an extrude starts and which way is positive: a point inside the (first) profile or
@@ -152,14 +139,7 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
if st.bodies.is_empty() || !d.is_finite() {
return Some("new");
}
// Only the active component's bodies take part (or the ones named as participants).
let parts = string_list(p, "participants");
let inside = |q: Vec3| {
st.bodies
.iter()
.filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) })
.any(|b| b.mesh().contains(q))
};
let inside = |q: Vec3| st.bodies.iter().any(|b| b.mesh().contains(q));
let eps = (d.abs() * 1e-3).max(1e-3);
let sign = if d < 0.0 { -1.0 } else { 1.0 };
if symmetric {
@@ -263,36 +243,20 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
// Commands speak world coordinates; the feature is authored in the active component.
let mut kind = kind;
super::component::to_active_frame(s, &mut kind);
// Bare lengths typed in an inch (or cm…) design mean that unit.
if s.doc.units.trim() != "mm" {
let doc = s.doc.clone();
for (_, e, k) in kind.inputs_mut() {
*e = doc.with_design_unit(e, k);
}
}
// Blends remember where their edges sat, to find them again after upstream edits.
let (refs, names) = match &kind {
FeatureKind::Fillet { edges, body, .. } | FeatureKind::Chamfer { edges, body, .. } => {
let st = s.model.state();
(solvecraft_doc::edge_refs(&st, body, edges), solvecraft_doc::edge_names_for(&st, body, edges))
}
// Sheet metal edges are named by the sheet model.
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => {
(Vec::new(), solvecraft_doc::sheet::sheet_edge_names(&s.model.state(), edges))
}
_ => (Vec::new(), Vec::new()),
};
let face_names = {
let pts = kind.face_points();
if pts.is_empty() { Vec::new() } else { solvecraft_doc::naming::face_names_at(&s.model.state(), &pts) }
};
let participants = string_list(p, "participants");
if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) {
return Err(EngineError::Other("`participants` must list existing bodies".into()));
}
let id = s.doc_mut().add_feature(kind, name)?;
if let Some(f) = s.doc_mut().feature_mut(id) {
f.participants = participants;
f.edge_refs = refs;
f.edge_names = names;
f.face_names = face_names;
@@ -326,7 +290,7 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
}
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.extrude";
let cmd = "Extrude";
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
Some(fp) => {
@@ -336,14 +300,7 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
None => (feature_sketch(s, p, cmd)?, None),
};
let through_all = bool_(p, "through_all").unwrap_or(false) || str_(p, "extent").is_some_and(|e| e.eq_ignore_ascii_case("through_all"));
let to_object = p.get("to").is_some();
if to_object && p.get("to").and_then(vec3).is_none() {
return Err(bad(cmd, "`to` must be a point [x, y, z] on the face, vertex or point to extrude to"));
}
if p.get("from").is_some() && p.get("from").and_then(vec3).is_none() {
return Err(bad(cmd, "`from` must be a point [x, y, z] on the face or point to start from"));
}
let distance = if through_all || to_object { expr(p, "distance").unwrap_or_else(|| "0".into()) } else { req_expr(cmd, p, "distance")? };
let distance = if through_all { expr(p, "distance").unwrap_or_else(|| "0".into()) } else { req_expr(cmd, p, "distance")? };
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
let taper = expr(p, "taper");
if let Some(t) = &taper {
@@ -369,17 +326,7 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
Some(f) => f,
None => profiles(p, cmd)?,
},
extent: Extent {
distance,
direction,
distance2,
start_offset,
through_all,
taper,
to: p.get("to").and_then(vec3),
to_offset: expr(p, "to_offset"),
from: p.get("from").and_then(vec3),
},
extent: Extent { distance, direction, distance2, start_offset, through_all, taper },
operation: match str_(p, "operation") {
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
_ => operation(p, cmd)?,
@@ -390,7 +337,7 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
}
fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.revolve";
let cmd = "Revolve";
// A planar body face revolves like a profile (its edges projected into a sketch on it).
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
Some(fp) => {
@@ -418,27 +365,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
Some(f) => f,
None => profiles(p, cmd)?,
};
let angle2 = expr(p, "angle2");
if let Some(a) = &angle2 {
check_expr(s, a, Kind::Angle, cmd, "angle2")?;
}
let to = match p.get("to") {
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] to turn to"))?),
None => None,
};
let symmetric =
matches!(str_(p, "direction").map(str::to_ascii_lowercase).as_deref(), Some("symmetric")) || bool_(p, "symmetric").unwrap_or(false);
let kind = FeatureKind::Revolve {
sketch,
profiles,
axis,
angle,
operation: operation(p, cmd)?,
targets: string_list(p, "targets"),
angle2,
symmetric,
to,
};
let kind = FeatureKind::Revolve { sketch, profiles, axis, angle, operation: operation(p, cmd)?, targets: string_list(p, "targets") };
add_feature(s, p, kind)
}
@@ -451,7 +378,7 @@ fn radius_expr(p: &Value, cmd: &str) -> Result<String> {
}
fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.box";
let cmd = "PrimitiveBox";
let (l, w, h) = (req_expr(cmd, p, "length")?, req_expr(cmd, p, "width")?, req_expr(cmd, p, "height")?);
for (e, n) in [(&l, "length"), (&w, "width"), (&h, "height")] {
check_expr(s, e, Kind::Length, cmd, n)?;
@@ -468,7 +395,7 @@ fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
}
fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.cylinder";
let cmd = "PrimitiveCylinder";
let radius = radius_expr(p, cmd)?;
let height = req_expr(cmd, p, "height")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
@@ -479,7 +406,7 @@ fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
}
fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.sphere";
let cmd = "PrimitiveSphere";
let radius = radius_expr(p, cmd)?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
let center = p.get("center").and_then(vec3).unwrap_or(Vec3::ZERO);
@@ -487,7 +414,7 @@ fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
}
fn prim_torus(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.torus";
let cmd = "PrimitiveTorus";
let (major, minor) = (req_expr(cmd, p, "major")?, req_expr(cmd, p, "minor")?);
check_expr(s, &major, Kind::Length, cmd, "major")?;
check_expr(s, &minor, Kind::Length, cmd, "minor")?;
@@ -521,7 +448,7 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
/// Press Pull: a face moves along its normal (an extrude that joins outward or cuts inward); an
/// edge is rounded (a fillet).
fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.press_pull";
let cmd = "FusionPressPullCommand";
let distance = req_expr(cmd, p, "distance")?;
if p.get("edges").is_some() {
return fillet(s, &json!({"edges": p.get("edges"), "radius": distance}));
@@ -550,7 +477,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed")}));
}
// Another curved face moves along its normal.
let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?;
let r = super::find_command("FusionOffsetFacesCommand").ok_or_else(|| bad(cmd, "FusionOffsetFacesCommand"))?;
return (r.run)(s, &json!({"faces": [face], "distance": distance}));
}
let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (format!("-({distance})"), "negative", "cut") };
@@ -558,27 +485,15 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
}
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.fillet";
let cmd = "FusionFilletEdgesCommand";
let edges = edge_points(s, p, cmd)?;
let radius = req_expr(cmd, p, "radius")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
let style = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() {
None | Some("constant") | Some("rolling_ball") => solvecraft_doc::FilletStyle::Constant,
Some("chord") | Some("chord_length") => solvecraft_doc::FilletStyle::Chord,
Some("variable") => {
let radius2 = req_expr(cmd, p, "radius2")?;
check_expr(s, &radius2, Kind::Length, cmd, "radius2")?;
let start =
p.get("start").and_then(vec3).ok_or_else(|| bad(cmd, "a variable fillet needs `start`: [x,y,z] near the end that takes `radius`"))?;
solvecraft_doc::FilletStyle::Variable { radius2, start }
}
Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord or variable)"))),
};
add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string), style })
add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string) })
}
fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.chamfer";
let cmd = "FusionChamferCommand";
let edges = edge_points(s, p, cmd)?;
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
@@ -597,7 +512,7 @@ fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
}
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.combine";
let cmd = "FusionCombineCommand";
let target = str_(p, "target").ok_or_else(|| bad(cmd, "`target` must be a body name"))?.to_string();
let tools = string_list(p, "tools");
if tools.is_empty() {
@@ -607,23 +522,11 @@ fn combine(s: &mut Session, p: &Value) -> Result<Value> {
None => Operation::Join,
Some(o) => Operation::parse(o).filter(|o| *o != Operation::NewBody).ok_or_else(|| bad(cmd, "operation must be join, cut or intersect"))?,
};
let mut out = add_feature(
s,
p,
FeatureKind::Combine { target: target.clone(), tools, operation: op, keep_tools: bool_(p, "keep_tools").unwrap_or(false) },
)?;
// New Component: the result goes into a component of its own.
if bool_(p, "new_component").unwrap_or(false) {
let name = str_(p, "component_name").map(str::to_string).unwrap_or_else(|| format!("{target} Combined"));
let c = s.execute("component.create", &json!({"name": name, "activate": false}))?;
s.execute("component.move_bodies", &json!({"bodies": [target], "component": c["component"]}))?;
out["component"] = c["component"].clone();
}
Ok(out)
add_feature(s, p, FeatureKind::Combine { target, tools, operation: op, keep_tools: bool_(p, "keep_tools").unwrap_or(false) })
}
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.move";
let cmd = "FusionMoveCommand";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
@@ -717,7 +620,7 @@ fn pattern_sources(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<String>, Ve
}
fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.pattern.rectangular";
let cmd = "PatternRectangular";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let dir1 = p.get("dir1").and_then(vec3).ok_or_else(|| bad(cmd, "`dir1` must be [x, y, z]"))?;
let count1 = req_expr(cmd, p, "count1")?;
@@ -737,7 +640,7 @@ fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
}
fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.pattern.circular";
let cmd = "PatternCircular";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let (origin, axis) = axis_line(p, cmd)?;
let count = req_expr(cmd, p, "count")?;
@@ -749,7 +652,7 @@ fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
}
fn pattern_path(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.pattern.path";
let cmd = "PatternOnPath";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let path_sketch = sketch_id(s, p.get("path_sketch").or_else(|| p.get("sketch")), cmd, "path_sketch")?;
let path = string_list(p, "path");
@@ -775,7 +678,7 @@ fn pattern_path(s: &mut Session, p: &Value) -> Result<Value> {
}
fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.mirror";
let cmd = "MirrorCommand";
let bodies = string_list(p, "bodies");
let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() };
let plane = match p.get("plane") {
@@ -797,7 +700,7 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
/// A plane reference from a parameter: an origin plane, a construction plane name, or an
/// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`.
pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solvecraft_doc::PlaneRef> {
pub fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solvecraft_doc::PlaneRef> {
use solvecraft_doc::PlaneRef;
match v {
Some(Value::String(n)) if solvecraft_geom::Plane::named(n).is_some() => Ok(PlaneRef::Origin { name: n.to_ascii_uppercase() }),
@@ -819,7 +722,7 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
}
fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.offset";
let cmd = "ConstructionPlaneOffsetFromPlaneCommand";
let base = plane_param(s, p.get("base"), cmd)?;
let d = req_expr(cmd, p, "offset")?;
check_expr(s, &d, Kind::Length, cmd, "offset")?;
@@ -827,7 +730,7 @@ fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
}
fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.at_angle";
let cmd = "ConstructionPlaneAtAngleCommand";
let base = plane_param(s, p.get("base"), cmd)?;
let (axis_origin, axis_dir) = axis_line(p, cmd)?;
let angle = req_expr(cmd, p, "angle")?;
@@ -839,52 +742,14 @@ fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
)
}
fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.split_face";
let faces = face_points(p, cmd)?;
let body = str_(p, "body").map(str::to_string);
let xy = || solvecraft_doc::PlaneRef::Origin { name: "XY".into() };
let kind = if let Some(t) = p.get("tool").filter(|t| !t.is_null()) {
let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`tool.body` must name a body"))?.to_string();
let point = t.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "`tool.point` must be [x, y, z] on the tool face"))?;
FeatureKind::SplitFace { faces, body, plane: xy(), tool: Some(solvecraft_doc::FaceAt { body: tb, point }), sketch: None }
} else if p.get("sketch").is_some_and(|v| !v.is_null()) {
let sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?;
FeatureKind::SplitFace { faces, body, plane: xy(), tool: None, sketch: Some(sketch) }
} else {
let plane = plane_param(s, p.get("plane"), cmd)?;
FeatureKind::SplitFace { faces, body, plane, tool: None, sketch: None }
};
add_feature(s, p, kind)
}
fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.split_body";
let cmd = "FusionSplitBodyCommand";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
// A face as the tool: `tool: {body, point}` (its surface extended); else a plane.
if let Some(t) = p.get("tool").filter(|t| !t.is_null()) {
let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`tool.body` must name a body"))?.to_string();
let point = t.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "`tool.point` must be [x, y, z] on the tool face"))?;
let st = s.model.state();
let tbody = st.body(&tb).ok_or_else(|| bad(cmd, format!("no body `{tb}`")))?;
if solvecraft_doc::appearance::face_index_at(tbody, point).is_none() {
return Err(bad(cmd, "`tool.point` is not on a face of the tool body"));
}
return add_feature(
s,
p,
FeatureKind::Split {
body,
plane: solvecraft_doc::PlaneRef::Origin { name: "XY".into() },
tool: Some(solvecraft_doc::FaceAt { body: tb, point }),
},
);
}
let plane = plane_param(s, p.get("plane"), cmd)?;
add_feature(s, p, FeatureKind::Split { body, plane, tool: None })
add_feature(s, p, FeatureKind::Split { body, plane })
}
/// ISO 273 clearance hole diameters (close, normal, loose fit) for metric bolts.
@@ -924,14 +789,12 @@ fn clearance_diameter(size: &str, fit: &str) -> Option<f64> {
}
fn hole(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.hole";
let cmd = "FusionHoleCommand";
// At sketch points: the holes go perpendicular to the sketch and follow its points.
let points = match (p.get("sketch"), p.get("points"), p.get("position")) {
(Some(_), Some(_), _) | (Some(_), None, None) => {
let points = match (p.get("sketch"), p.get("points")) {
(Some(_), Some(_)) => {
let sketch = feature_sketch(s, p, cmd)?;
// No points named: every sketch point (standalone points and circle centres, not
// line or arc ends, not the origin).
let ids = if p.get("points").is_some() { string_list(p, "points") } else { sketch_hole_points(s, sketch) };
let ids = string_list(p, "points");
if ids.is_empty() || ids.len() > 10_000 {
return Err(bad(cmd, "`points` must list 1…10000 sketch point ids"));
}
@@ -1019,28 +882,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
},
other => return Err(bad(cmd, format!("unknown hole type `{other}`"))),
};
let to = match p.get("to") {
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] on the face to drill to"))?),
None => None,
};
add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread, to })
}
/// The points of a sketch a hole drills at when only the sketch is given.
fn sketch_hole_points(s: &Session, sketch: u64) -> Vec<String> {
let st = s.model.state();
let Some(ss) = st.sketch(sketch) else { return Vec::new() };
let mut ends: Vec<usize> = Vec::new();
for c in &ss.sketch.curves {
match &c.kind {
solvecraft_sketch::CurveKind::Circle { .. } => {}
solvecraft_sketch::CurveKind::Arc { a, b, .. } | solvecraft_sketch::CurveKind::Line { a, b } => ends.extend([*a, *b]),
solvecraft_sketch::CurveKind::Ellipse { m, .. } => ends.push(*m),
solvecraft_sketch::CurveKind::Spline { pts, .. } => ends.extend(pts.iter().copied()),
solvecraft_sketch::CurveKind::Conic { a, b, apex, .. } => ends.extend([*a, *b, *apex]),
}
}
ss.sketch.points.iter().enumerate().filter(|(i, p)| p.id != "origin" && !ends.contains(i)).map(|(_, p)| p.id.clone()).collect()
add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread })
}
fn face_points(p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
@@ -1052,35 +894,21 @@ fn face_points(p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
}
fn shell(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.shell";
let cmd = "FusionShellBodyCommand";
let faces = face_points(p, cmd)?;
let thickness = req_expr(cmd, p, "thickness")?;
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
let direction = str_(p, "direction").map(str::to_ascii_lowercase).unwrap_or_default();
if !matches!(direction.as_str(), "" | "inside" | "outside" | "both") {
return Err(bad(cmd, "`direction` must be inside, outside or both"));
}
add_feature(
s,
p,
FeatureKind::Shell {
faces,
thickness,
body: str_(p, "body").map(str::to_string),
direction: if direction == "inside" { String::new() } else { direction },
tangent_chain: bool_(p, "tangent_chain").unwrap_or(false),
},
)
add_feature(s, p, FeatureKind::Shell { faces, thickness, body: str_(p, "body").map(str::to_string) })
}
fn draft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.draft";
let cmd = "FusionDraftCommand";
let faces = face_points(p, cmd)?;
let angle = req_expr(cmd, p, "angle")?;
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
let neutral = plane_param(s, p.get("neutral"), cmd)?;
let (vals, _) = s.doc.param_values();
let pl = s.doc.resolve_plane_in(&vals, &neutral, 0, Some(&s.model.state()))?;
let pl = s.doc.resolve_plane(&vals, &neutral, 0)?;
let pull = p.get("pull").and_then(vec3).unwrap_or_else(|| pl.normal());
add_feature(s, p, FeatureKind::Draft { faces, angle, neutral, pull, body: str_(p, "body").map(str::to_string) })
}
@@ -1098,7 +926,7 @@ pub(super) fn sketch_id(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -
}
fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.sweep";
let cmd = "Sweep";
let sketch = feature_sketch(s, p, cmd)?;
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
let path = string_list(p, "path");
@@ -1120,7 +948,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
}
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.loft";
let cmd = "SolidLoft";
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
if list.len() < 2 || list.len() > 50 {
return Err(bad(cmd, "a loft needs 2…50 sections"));
@@ -1143,7 +971,7 @@ fn loft(s: &mut Session, p: &Value) -> Result<Value> {
}
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.thread";
let cmd = "FusionThreadCommand";
let face = p
.get("face")
.and_then(|v| vec3(v).or_else(|| v.get("point").and_then(vec3)))
@@ -1158,15 +986,14 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(l) = &length {
check_expr(s, l, Kind::Length, cmd, "length")?;
}
let modeled = p.get("modeled").and_then(Value::as_bool).unwrap_or(false);
let v = add_feature(s, p, FeatureKind::Thread { face, designation, length, modeled })?;
let v = add_feature(s, p, FeatureKind::Thread { face, designation, length })?;
let st = s.model.state();
let t = v.get("feature").and_then(Value::as_u64).and_then(|id| st.threads.iter().find(|t| t.feature == id).cloned());
Ok(json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t}))
}
fn pipe(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.pipe";
let cmd = "PrimitivePipe";
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
let path = string_list(p, "path");
if path.is_empty() {
@@ -1182,14 +1009,14 @@ fn pipe(s: &mut Session, p: &Value) -> Result<Value> {
}
fn bounding_solid(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.bounding_solid";
let cmd = "StockModelCommand";
let margin = expr(p, "margin").unwrap_or_else(|| "0".into());
check_expr(s, &margin, Kind::Length, cmd, "margin")?;
add_feature(s, p, FeatureKind::BoundingSolid { bodies: string_list(p, "bodies"), margin })
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.scale";
let cmd = "ModifyScale";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
@@ -1215,13 +1042,8 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::Scale { bodies, origin, factor, factors })
}
fn delete_face(s: &mut Session, p: &Value) -> Result<Value> {
let faces = face_points(p, "face.delete")?;
add_feature(s, p, FeatureKind::DeleteFace { faces, body: str_(p, "body").map(str::to_string) })
}
fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.offset_face";
let cmd = "FusionOffsetFacesCommand";
let faces = face_points(p, cmd)?;
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
+15 -74
View File
@@ -11,38 +11,35 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("SurfaceSculpt", "Boundary Fill", boundary_fill).at("SOLID", "CREATE").icon("boundary_fill").params(
"tools: [bodies]; cells: [[x,y,z] a point inside each cell to fill (a cell: inside some tools, outside the rest)]; operation?: new|join|cut|intersect; remove_tools?: bool",
),
CommandSpec::new("solid.coil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params(
CommandSpec::new("PrimitiveCoil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params(
"diameter; two of revolutions (or turns), height, pitch; section_size; section?: circular|square; section_position?: inside|center|outside; \
base?: [x,y,z]; axis?: X|Y|Z|[x,y,z] (default Z); start_angle?; clockwise?: bool; operation?, targets?, name?, body_name?",
),
CommandSpec::new("solid.rib", "Rib", rib)
CommandSpec::new("FusionRibCommand", "Rib", rib)
.at("SOLID", "CREATE")
.icon("rib")
.params("sketch, curves: [open curve ids, in order]; thickness (across the sketch plane); depth? (default: to the next face); flip?: bool (fill to the other side)"),
CommandSpec::new("solid.web", "Web", web)
CommandSpec::new("FusionWebCommand", "Web", web)
.at("SOLID", "CREATE")
.icon("rib")
.params("sketch, curves: [open curve ids] (one wall each); thickness; depth? (default: to the next face); flip?: bool"),
CommandSpec::new("solid.emboss", "Emboss", emboss)
CommandSpec::new("EmbossCmd", "Emboss", emboss)
.at("SOLID", "CREATE")
.icon("emboss")
.params("sketch (on a planar face), profiles?; depth; mode?: emboss|deboss (default emboss); targets?"),
CommandSpec::new("solid.replace_face", "Replace Face", replace_face)
CommandSpec::new("FusionReplaceFaceCommand", "Replace Face", replace_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
.params("faces: [[x,y,z] points on planar faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
CommandSpec::new("solid.align", "Align", align)
CommandSpec::new("AlignCmd", "Align", align)
.at("SOLID", "MODIFY")
.icon("move")
.params("bodies: [names]; from: [x,y,z] | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z]; to: (the same) | to_face: [x,y,z] (faces also turn to meet); flip?: bool"),
CommandSpec::new("solid.remove", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
.params("bodies: [names]; from: [x,y,z] | from_face: [x,y,z]; to: [x,y,z] | to_face: [x,y,z] (faces also turn to meet); flip?: bool"),
CommandSpec::new("SoftDeleteCommand", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
];
fn coil(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.coil";
let cmd = "PrimitiveCoil";
let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO);
let axis = match p.get("axis") {
None => Vec3::Z,
@@ -134,15 +131,15 @@ fn rib_like(s: &mut Session, p: &Value, cmd: &str, web: bool) -> Result<Value> {
}
fn rib(s: &mut Session, p: &Value) -> Result<Value> {
rib_like(s, p, "solid.rib", false)
rib_like(s, p, "FusionRibCommand", false)
}
fn web(s: &mut Session, p: &Value) -> Result<Value> {
rib_like(s, p, "solid.web", true)
rib_like(s, p, "FusionWebCommand", true)
}
fn emboss(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.emboss";
let cmd = "EmbossCmd";
let sketch = feature_sketch(s, p, cmd)?;
let profiles = profiles(p, cmd)?;
let depth = req_expr(cmd, p, "depth")?;
@@ -169,7 +166,7 @@ fn face_points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
}
fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.replace_face";
let cmd = "FusionReplaceFaceCommand";
let faces = face_points(p, "faces", cmd)?;
for f in &faces {
if super::face::face_normal(s, *f).is_none() {
@@ -189,7 +186,7 @@ fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.align";
let cmd = "AlignCmd";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list the bodies to move"));
@@ -205,12 +202,6 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
let n = super::face::face_normal(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
return Ok((fp, Some(n)));
}
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
if let Some(o) = p.get(pk).filter(|v| v.is_object()) {
let at = o.get("at").and_then(vec3).ok_or_else(|| bad(cmd, format!("`{pk}.at` must be [x, y, z]")))?;
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex");
return snap_point(s, kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")));
}
p.get(pk).and_then(vec3).map(|v| (v, None)).ok_or_else(|| bad(cmd, format!("give `{pk}` or `{fk}` as [x, y, z]")))
};
let (from, from_normal) = pick(s, "from", "from_face")?;
@@ -224,7 +215,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.remove";
let cmd = "SoftDeleteCommand";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
@@ -239,53 +230,3 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
#[path = "features_more_tests.rs"]
mod tests;
/// A snap point near `at` on the model: the nearest vertex, edge midpoint, planar face's centre
/// or circular edge's centre (the end of a cylindrical face).
pub(super) fn snap_point(s: &Session, kind: &str, at: Vec3) -> Option<Vec3> {
let st = s.world_state();
let near = |pts: Vec<Vec3>| pts.into_iter().min_by(|a, b| a.dist(at).total_cmp(&b.dist(at)));
match kind {
"vertex" => near(
st.bodies
.iter()
.flat_map(|b| b.mesh().edges.iter().flat_map(|e| [e.first().copied(), e.last().copied()]).flatten().collect::<Vec<_>>())
.collect(),
),
"edge_mid" => {
let d = |e: &solvecraft_doc::kernel::EdgeInfo| e.points.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::MAX, f64::min);
st.bodies
.iter()
.filter_map(|b| b.body.edges((b.body.size() * 2e-3).max(1e-3)).ok())
.flatten()
.min_by(|a, b| d(a).total_cmp(&d(b)))
.map(|e| e.mid)
}
"face_center" => st.bodies.iter().find_map(|b| {
let i = solvecraft_doc::appearance::face_index_at(b, at)?;
b.body.faces((b.body.size() * 2e-3).max(1e-3)).ok()?.into_iter().find(|f| f.index == i).map(|f| f.centroid)
}),
"circle_center" => st.bodies.iter().find_map(|b| {
let c = solvecraft_doc::kernel::cylinder_face_at(&b.body, at)?;
let along = (at - c.axis_point).dot(c.axis);
let end = if (along - c.start).abs() <= (c.end - along).abs() { c.start } else { c.end };
Some(c.axis_point + c.axis * end)
}),
_ => None,
}
}
fn boundary_fill(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SurfaceSculpt";
let tools = string_list(p, "tools");
let st = s.model.state();
if tools.is_empty() || tools.len() > 64 || tools.iter().any(|t| st.body(t).is_none()) {
return Err(bad(cmd, "`tools` must list 1…64 bodies"));
}
let cells: Vec<Vec3> = p.get("cells").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if cells.is_empty() || cells.len() > 1000 {
return Err(bad(cmd, "`cells` must list 1…1000 points [x, y, z], one inside each cell"));
}
let operation = super::features::operation(p, cmd)?;
add_feature(s, p, FeatureKind::BoundaryFill { tools, cells, operation, remove_tools: bool_(p, "remove_tools").unwrap_or(false) })
}
+43 -43
View File
@@ -10,7 +10,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
}
fn measure(s: &mut Session) -> Value {
run(s, "inspect.measure", json!({}))
run(s, "MeasureCommand", json!({}))
}
fn volume(s: &mut Session) -> f64 {
@@ -24,76 +24,76 @@ fn rel(a: f64, b: f64) -> f64 {
/// An L bracket: a 40 x 20 x 5 base and a 5 x 20 x 40 wall at x = 0..5.
fn bracket() -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 5, "corner": [0, 0, 0], "body_name": "L"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 20, "height": 40, "corner": [0, 0, 0], "operation": "join"}));
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 20, "height": 5, "corner": [0, 0, 0], "body_name": "L"}));
run(&mut s, "PrimitiveBox", json!({"length": 5, "width": 20, "height": 40, "corner": [0, 0, 0], "operation": "join"}));
assert!(rel(volume(&mut s), 4000.0 + 3500.0) < 1e-9);
// Mid-plane of the bracket (y = 10): sketch x = X, sketch y = Z.
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 10, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "RibSketch"}));
run(&mut s, "sketch.line", json!({"points": [[5, 25], [25, 5]], "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 10, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "RibSketch"}));
run(&mut s, "DrawPolyline", json!({"points": [[5, 25], [25, 5]], "ids": ["l1"]}));
run(&mut s, "SketchStop", json!({}));
s
}
#[test]
fn rib_to_the_next_face_fills_the_corner() {
let mut s = bracket();
run(&mut s, "solid.rib", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
run(&mut s, "FusionRibCommand", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
// The triangle (5,5)-(5,25)-(25,5) of area 200, 4 thick.
assert!(rel(volume(&mut s), 7500.0 + 800.0) < 1e-6, "{}", volume(&mut s));
assert_eq!(measure(&mut s)["body_count"], 1);
// The thickness is a parameter.
let t = s.doc.features.last().and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "parameters.change", json!({"name": t, "expression": "2"}));
run(&mut s, "ChangeParameterCommand", json!({"name": t, "expression": "2"}));
assert!(rel(volume(&mut s), 7500.0 + 400.0) < 1e-6);
}
#[test]
fn rib_with_a_depth_and_web() {
let mut s = bracket();
run(&mut s, "solid.rib", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4, "depth": 2}));
run(&mut s, "FusionRibCommand", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4, "depth": 2}));
let v = volume(&mut s);
assert!(v > 7500.0 + 100.0 && v < 7500.0 + 800.0, "{v}");
let mut s = bracket();
run(&mut s, "solid.web", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
run(&mut s, "FusionWebCommand", json!({"sketch": "RibSketch", "curves": ["l1"], "thickness": 4}));
assert!(rel(volume(&mut s), 8300.0) < 1e-6);
// Curves that face no body.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Far"}));
run(&mut s, "sketch.line", json!({"points": [[50, 50], [60, 60]], "ids": ["l1"]}));
run(&mut s, "sketch.finish", json!({}));
let e = s.execute("solid.rib", &json!({"sketch": "Far", "curves": ["l1"], "thickness": 1}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Far"}));
run(&mut s, "DrawPolyline", json!({"points": [[50, 50], [60, 60]], "ids": ["l1"]}));
run(&mut s, "SketchStop", json!({}));
let e = s.execute("FusionRibCommand", &json!({"sketch": "Far", "curves": ["l1"], "thickness": 1}));
assert!(e.is_err_and(|e| e.to_string().contains("do not face a body")));
}
#[test]
fn emboss_and_deboss_on_a_face() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 10]}, "name": "Logo"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.emboss", json!({"sketch": "Logo", "depth": 2}));
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 10]}, "name": "Logo"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [5, 5], "p1": [15, 15]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "EmbossCmd", json!({"sketch": "Logo", "depth": 2}));
assert!(rel(volume(&mut s), 12000.0 + 200.0) < 1e-6, "{}", volume(&mut s));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.emboss", json!({"sketch": "Logo", "depth": 3, "mode": "deboss"}));
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "EmbossCmd", json!({"sketch": "Logo", "depth": 3, "mode": "deboss"}));
assert!(rel(volume(&mut s), 12000.0 - 300.0) < 1e-6);
assert!(s.execute("solid.emboss", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err());
assert!(s.execute("EmbossCmd", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err());
}
#[test]
fn replace_face_align_remove() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.replace_face", json!({"faces": [[5, 5, 10]], "target": {"origin": [0, 0, 15], "normal": [0, 0, 1]}}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "FusionReplaceFaceCommand", json!({"faces": [[5, 5, 10]], "target": {"origin": [0, 0, 15], "normal": [0, 0, 1]}}));
assert!(rel(volume(&mut s), 1500.0) < 1e-6);
let e = s.execute("solid.replace_face", &json!({"faces": [[5, 5, 15]], "target": {"origin": [0, 0, 15], "normal": [1, 0, 1]}}));
let e = s.execute("FusionReplaceFaceCommand", &json!({"faces": [[5, 5, 15]], "target": {"origin": [0, 0, 15], "normal": [1, 0, 1]}}));
assert!(e.is_err() || s.model.results.last().is_some_and(|r| r.error.as_deref().unwrap_or("").contains("parallel")));
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 0], "body_name": "B"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 0], "body_name": "B"}));
// B's top face onto A's top face: B turns over and sits on A.
run(&mut s, "solid.align", json!({"bodies": ["B"], "from_face": [55, 5, 10], "to_face": [5, 5, 10]}));
run(&mut s, "AlignCmd", json!({"bodies": ["B"], "from_face": [55, 5, 10], "to_face": [5, 5, 10]}));
let m = measure(&mut s);
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == "B").unwrap().clone();
let (lo, hi) = (b["bbox"]["min"].clone(), b["bbox"]["max"].clone());
@@ -101,11 +101,11 @@ fn replace_face_align_remove() {
assert!((got.as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{b}");
}
// Points only: a plain move.
run(&mut s, "solid.align", json!({"bodies": ["B"], "from": [0, 0, 10], "to": [0, 0, 30]}));
run(&mut s, "AlignCmd", json!({"bodies": ["B"], "from": [0, 0, 10], "to": [0, 0, 30]}));
let m = measure(&mut s);
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == "B").unwrap().clone();
assert!((b["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 30.0).abs() < 1e-6, "{b}");
run(&mut s, "solid.remove", json!({"bodies": ["B"]}));
run(&mut s, "SoftDeleteCommand", json!({"bodies": ["B"]}));
assert_eq!(measure(&mut s)["body_count"], 1);
}
@@ -113,27 +113,27 @@ fn replace_face_align_remove() {
fn coil_and_loft_to_a_point() {
use std::f64::consts::{PI, TAU};
let mut s = Session::default();
run(&mut s, "solid.coil", json!({"diameter": 40, "revolutions": 2, "pitch": 8, "section_size": 4}));
run(&mut s, "PrimitiveCoil", json!({"diameter": 40, "revolutions": 2, "pitch": 8, "section_size": 4}));
assert!(rel(volume(&mut s), PI * 4.0 * TAU * 20.0 * 2.0) < 1e-3, "{}", volume(&mut s));
// Height and revolutions give the pitch; a square section outside the diameter.
let mut s = Session::default();
run(
&mut s,
"solid.coil",
"PrimitiveCoil",
json!({"diameter": 40, "revolutions": 1.5, "height": 15, "section": "square", "section_size": 2, "section_position": "outside", "axis": "X"}),
);
assert!(rel(volume(&mut s), 4.0 * TAU * 21.0 * 1.5) < 1e-3, "{}", volume(&mut s));
assert!(s.execute("solid.coil", &json!({"diameter": 40, "pitch": 1, "revolutions": 3, "section_size": 4})).is_err(), "runs into itself");
assert!(s.execute("solid.coil", &json!({"diameter": 40, "section_size": 4, "pitch": 8})).is_err(), "two of three");
assert!(s.execute("PrimitiveCoil", &json!({"diameter": 40, "pitch": 1, "revolutions": 3, "section_size": 4})).is_err(), "runs into itself");
assert!(s.execute("PrimitiveCoil", &json!({"diameter": 40, "section_size": 4, "pitch": 8})).is_err(), "two of three");
// Pyramid: a square lofted to a point.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-10, -10], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Top"}));
run(&mut s, "sketch.point", json!({"point": [0, 0], "id": "apex"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "apex"}]}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [-10, -10], "p1": [10, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Top"}));
run(&mut s, "DrawPoint", json!({"point": [0, 0], "id": "apex"}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "SolidLoft", json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "apex"}]}));
assert!(rel(volume(&mut s), 400.0 * 30.0 / 3.0) < 1e-6);
assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
assert!(s.execute("SolidLoft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
}
+23 -23
View File
@@ -9,22 +9,22 @@ use crate::params::{bad, bool_, str_, string_list};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("file.new", "New Design", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"),
CommandSpec::new("NewDocumentCommand", "New Design", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"),
CommandSpec::new("doc.open", "Open", open)
.icon("open")
.key("Ctrl+O")
.noundo()
.params("path: .solvecraft design, or a .step/.stp file (opens as a new design)"),
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
CommandSpec::new("FusionImportCommandFromToolbar", "Insert STEP", insert_step)
.at("SOLID", "INSERT")
.icon("import")
.params("path: .step/.stp file (its bodies join the design as an Import base feature); name?"),
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
CommandSpec::new("ParaMeshInsertAlignCommand", "Insert Mesh", insert_mesh)
.at("SOLID", "INSERT")
.icon("import")
.params("path: .3mf or .stl file (its meshes join the design as mesh bodies); name?"),
CommandSpec::new("file.save", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"),
CommandSpec::new("file.save_as", "Save As", save_as).icon("save").noundo().params("path"),
CommandSpec::new("SaveDocumentCommand", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"),
CommandSpec::new("SaveDocumentAsCommand", "Save As", save_as).icon("save").noundo().params("path"),
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list)
.noundo()
.params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"),
@@ -32,11 +32,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.params("id (from doc.recovery_list); dir?; discard?: bool (delete the entry once open) — opens it with its file path, unsaved"),
CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).noundo().params("id, or all: true; dir?"),
CommandSpec::new("file.export", "Export", export)
CommandSpec::new("ExportCommand", "Export", export)
.icon("export")
.noundo()
.params("path; format?: stl|stla|obj|step|3mf (default from extension); bodies?: [names]"),
CommandSpec::new("file.save_mesh", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"),
CommandSpec::new("FusionSaveAsSTLCommand", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"),
];
fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
@@ -51,11 +51,11 @@ fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
/// Read a STEP file as an Import feature.
fn read_step(path: &str) -> Result<solvecraft_io::StepFeature> {
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta > solvecraft_io::MAX_STEP_BYTES as u64 {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > solvecraft_io::MAX_STEP_BYTES as u64 {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
solvecraft_io::step_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
@@ -80,11 +80,11 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
/// Add a MeshImport feature for a 3MF or STL file.
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta > solvecraft_io::MAX_3MF_BYTES as u64 {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > solvecraft_io::MAX_3MF_BYTES as u64 {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mf = solvecraft_io::mesh_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string();
let id = s.doc_mut().add_feature(mf.kind, Some(&fname))?;
@@ -103,7 +103,7 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
}
fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.insert_mesh";
let cmd = "ParaMeshInsertAlignCommand";
let path = path_arg(p, cmd)?;
if !solvecraft_io::is_mesh_path(path) {
return Err(bad(cmd, "only 3MF and STL files (.3mf, .stl) can be inserted as meshes"));
@@ -112,7 +112,7 @@ fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
}
fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.insert_step";
let cmd = "FusionImportCommandFromToolbar";
let path = path_arg(p, cmd)?;
if !solvecraft_io::is_step_path(path) {
return Err(bad(cmd, "only STEP files (.step, .stp) can be inserted"));
@@ -132,11 +132,11 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
}
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta as usize > solvecraft_io::MAX_DESIGN_BYTES {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() as usize > solvecraft_io::MAX_DESIGN_BYTES {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let doc = solvecraft_io::read_design(&bytes)?;
*s = Session::new(doc);
s.path = Some(path.to_string());
@@ -156,13 +156,13 @@ fn write(s: &mut Session, path: &str) -> Result<Value> {
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let path = match str_(p, "path") {
Some(x) if !x.trim().is_empty() => x.to_string(),
_ => s.path.clone().ok_or_else(|| bad("file.save", "the design has no file yet: give a `path`"))?,
_ => s.path.clone().ok_or_else(|| bad("SaveDocumentCommand", "the design has no file yet: give a `path`"))?,
};
write(s, &path)
}
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "file.save_as")?.to_string();
let path = path_arg(p, "SaveDocumentAsCommand")?.to_string();
write(s, &path)
}
@@ -175,18 +175,18 @@ fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Resu
// Bodies carry the colour they are shown in (appearance, material or imported).
solvecraft_io::export(&s.doc.painted(&s.world_state()), bodies, format, &name)?
};
solvecraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")}))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "file.export")?;
let path = path_arg(p, "ExportCommand")?;
let format = Format::from_name(str_(p, "format").unwrap_or(path))?;
export_to(s, path, format, &string_list(p, "bodies"))
}
fn save_stl(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "file.save_mesh")?;
let path = path_arg(p, "FusionSaveAsSTLCommand")?;
let f = if bool_(p, "ascii").unwrap_or(false) { Format::StlAscii } else { Format::StlBinary };
export_to(s, path, f, &string_list(p, "bodies"))
}
+7 -10
View File
@@ -8,7 +8,7 @@ use crate::params::{bad, bool_, str_, string_list};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("inspect.measure", "Measure", measure)
CommandSpec::new("MeasureCommand", "Measure", measure)
.at("SOLID", "INSPECT")
.icon("measure")
.key("I")
@@ -19,11 +19,11 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("model.edges", "List Edges", model_edges).noundo().params("body: name"),
CommandSpec::new("model.faces", "List Faces", model_faces).noundo().params("body: name"),
CommandSpec::new("model.threads", "List Threads", model_threads).noundo(),
CommandSpec::new("material.assign", "Physical Material", physical_material)
CommandSpec::new("PhysicalMaterialCommand", "Physical Material", physical_material)
.at("SOLID", "MODIFY")
.icon("material")
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …; `material.list`)"),
CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
CommandSpec::new("AppearanceCommand", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
neither (or color: null): clear, so the face follows its body, the body its component, the component its material",
@@ -74,7 +74,7 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
}
}
if !want.is_empty() && bodies.len() != want.len() {
return Err(bad("inspect.measure", "unknown body name"));
return Err(bad("MeasureCommand", "unknown body name"));
}
// Sheet metal keeps the edges where bends meet flat faces (as Fusion does): count its
// B-rep faces as they are, without merging coplanar neighbours.
@@ -102,9 +102,6 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
fn sketch_json(s: &Session, id: u64) -> Option<Value> {
let state = s.model.state();
let ss = state.sketch(id)?;
// Where the sketch is in the world (its component's placement).
let world = s.world_state();
let plane = world.sketch(id).map(|w| w.plane).unwrap_or(ss.plane);
let sk = &ss.sketch;
let curves: Vec<Value> = sk
.curves
@@ -132,7 +129,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
.points
.iter()
.enumerate()
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": ss.plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
.collect();
let constraints: Vec<Value> = sk
.constraints
@@ -265,7 +262,7 @@ fn model_threads(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "material.assign";
let cmd = "PhysicalMaterialCommand";
let bodies = string_list(p, "bodies");
let material = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
let canonical = solvecraft_doc::MATERIALS
@@ -304,7 +301,7 @@ fn color_param(v: &Value) -> Option<[u8; 3]> {
fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
use solvecraft_doc::appearance::{FaceLook, Look, library};
let cmd = "appearance.assign";
let cmd = "AppearanceCommand";
let look: Option<Look> = match (str_(p, "appearance"), p.get("color")) {
// A colour with a name: that colour, called that (a name not in the library is fine).
(Some(n), Some(v)) if !v.is_null() => {
+17 -32
View File
@@ -13,21 +13,21 @@ use crate::params::{bad, bool_, num, str_, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.origin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
CommandSpec::new("JointOrigin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
"name?, occurrence?: id|name (default: from the pick), face: [x,y,z] (centre of a planar face) | circle: [x,y,z] (centre of a round end) | point: [x,y,z], z?: [x,y,z]",
),
CommandSpec::new("joint.create", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
CommandSpec::new("JointAssembleCmdNew", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball; a, b: joint origins ({occurrence, face|circle|point: [x,y,z] in world} | origin name); \
values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool; offset?; angle?; name?",
),
CommandSpec::new("joint.as_built", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params(
CommandSpec::new("JointAsBuiltCmd", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params(
"type, a: occurrence, b: occurrence (stay where they are); at?: joint origin (default: b's origin) — the joint's frame",
),
CommandSpec::new("joint.rigid_group", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
CommandSpec::new("joint.drive", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
CommandSpec::new("RigidGroupCmd", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
CommandSpec::new("FusionMoveJointsCommand", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
"joint: id|name; value | values: [angles in deg or expressions, distances] (clamped to the limits)",
),
CommandSpec::new("joint.motion_link", "Motion Link", motion_link)
CommandSpec::new("FusionMotionRelationshipCommand", "Motion Link", motion_link)
.at("SOLID", "ASSEMBLE")
.icon("motion_link")
.params("a, b: joints; ratio (b = ratio · a + offset); offset?; ia?, ib?: value indices (default 0)"),
@@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.delete", "Delete Joint", delete).params("joint: id|name (or origin: name, link: index)"),
CommandSpec::new("joint.list", "List Joints", list).noundo().params("→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts"),
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).params("re-place the occurrences from the joints → conflicts, errors, dof"),
CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
CommandSpec::new("InterferenceCheckCommand", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
"bodies?: [names] (default: all, in world placement) → pairs that overlap with the overlap volume, area, bounding box and centre",
),
];
@@ -81,7 +81,7 @@ pub(crate) fn occurrence_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result
}
/// The occurrence whose bodies are nearest a world point (0 = root bodies).
pub(crate) fn occurrence_at(s: &Session, p: Vec3) -> Option<u64> {
fn occurrence_at(s: &Session, p: Vec3) -> Option<u64> {
let st = s.model.state();
let mut best: Option<(f64, u64)> = None;
for b in &st.bodies {
@@ -133,13 +133,7 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<JointOrigin
let snap = match what {
"face" | "circle" => {
let pick = local(world);
// A circle's name is its cylindrical wall's (a rim pick also touches the end face).
let named_at = if what == "circle" {
s.model.state().bodies.iter().filter_map(|b| cylinder_wall_point(&b.body, pick)).next().unwrap_or(pick)
} else {
pick
};
let face = solvecraft_doc::naming::face_names_at(&s.model.state(), &[named_at]).into_iter().next().filter(|n| !n.is_empty());
let face = solvecraft_doc::naming::face_names_at(&s.model.state(), &[pick]).into_iter().next().filter(|n| !n.is_empty());
if what == "face" { Snap::FaceCenter { pick, face } } else { Snap::CircleCenter { pick, face } }
}
"point" => Snap::Point { point: local(world), z: v.get("z").and_then(vec3).map(local_dir) },
@@ -221,7 +215,7 @@ fn add_joint(s: &mut Session, j: Joint) -> Result<Value> {
return Err(EngineError::Other("too many joints".into()));
}
if j.a.occurrence == j.b.occurrence {
return Err(bad("joint.create", "a joint needs two different occurrences"));
return Err(bad("JointAssembleCmdNew", "a joint needs two different occurrences"));
}
let id = j.id;
let name = j.name.clone();
@@ -234,7 +228,7 @@ fn add_joint(s: &mut Session, j: Joint) -> Result<Value> {
}
fn joint_origin(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.origin";
let cmd = "JointOrigin";
let origin = origin_param(s, Some(p), cmd)?;
let name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => n.to_string(),
@@ -254,7 +248,7 @@ fn kind_param(p: &Value, cmd: &str) -> Result<JointKind> {
}
fn joint(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.create";
let cmd = "JointAssembleCmdNew";
let kind = kind_param(p, cmd)?;
let a = origin_param(s, p.get("a"), cmd)?;
let b = origin_param(s, p.get("b"), cmd)?;
@@ -285,7 +279,7 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
}
fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.as_built";
let cmd = "JointAsBuiltCmd";
let kind = kind_param(p, cmd)?;
let (oa, ob) = (occurrence_id(s, p.get("a"), cmd)?, occurrence_id(s, p.get("b"), cmd)?);
// The joint's frame in the world: the given origin, or B's component origin.
@@ -318,7 +312,7 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rigid_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.rigid_group";
let cmd = "RigidGroupCmd";
let list = p.get("occurrences").and_then(Value::as_array).cloned().unwrap_or_default();
if list.len() < 2 || list.len() > 1000 {
return Err(bad(cmd, "`occurrences` must list 2…1000 occurrences"));
@@ -334,7 +328,7 @@ fn rigid_group(s: &mut Session, p: &Value) -> Result<Value> {
}
fn drive(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.drive";
let cmd = "FusionMoveJointsCommand";
let id = joint_id(s, p.get("joint"), cmd)?;
let kind = s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.kind).ok_or_else(|| bad(cmd, "joint"))?;
if kind == JointKind::Rigid {
@@ -364,7 +358,7 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
}
fn motion_link(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.motion_link";
let cmd = "FusionMotionRelationshipCommand";
let (a, b) = (joint_id(s, p.get("a"), cmd)?, joint_id(s, p.get("b"), cmd)?);
if a == b {
return Err(bad(cmd, "link two different joints"));
@@ -523,7 +517,7 @@ fn solve_cmd(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn interference(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.interference";
let cmd = "InterferenceCheckCommand";
let want = string_list(p, "bodies");
let st = s.world_state();
let bodies: Vec<&solvecraft_doc::ModelBody> =
@@ -557,12 +551,3 @@ fn interference(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
#[path = "joints_tests.rs"]
mod tests;
/// A point in the middle of the cylindrical face at `pick` (on its wall, halfway along).
fn cylinder_wall_point(b: &solvecraft_doc::kernel::Body, pick: Vec3) -> Option<Vec3> {
let c = solvecraft_doc::kernel::cylinder_face_at(b, pick)?;
let v = pick - c.axis_point;
let along = v.dot(c.axis);
let radial = (v - c.axis * along).normalized()?;
Some(c.axis_point + c.axis * ((c.start + c.end) / 2.0) + radial * c.radius)
}
+32 -32
View File
@@ -29,9 +29,9 @@ fn near(a: Vec3, b: Vec3) -> bool {
/// at y = 33, z = 4.
fn leaf(s: &mut Session, name: &str) {
run(s, "component.activate", json!({"component": "root"}));
run(s, "component.create", json!({"name": name}));
run(s, "solid.box", json!({"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}));
run(s, "solid.cylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
run(s, "FusionCreateNewComponentCommand", json!({"name": name}));
run(s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}));
run(s, "PrimitiveCylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
}
#[test]
@@ -43,7 +43,7 @@ fn revolute_hinge_turns_about_the_knuckle() {
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
let r = run(
&mut s,
"joint.create",
"JointAssembleCmdNew",
json!({
"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "flip": true, "name": "Hinge"}),
);
@@ -54,7 +54,7 @@ fn revolute_hinge_turns_about_the_knuckle() {
let dof = l["dof"].as_array().unwrap().iter().find(|d| d["occurrence"] == occ(&s, "LeafB")).unwrap()["dof"].clone();
assert_eq!(dof, 1, "{l}");
// Drive 90°: B turns about the knuckle axis (through (0, 33, 4), along the frame's z = −X).
run(&mut s, "joint.drive", json!({"joint": "Hinge", "value": 90}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Hinge", "value": 90}));
let want = solvecraft_doc::rigid(Vec3::ZERO, Vec3::new(0.0, 33.0, 4.0), Vec3::new(-1.0, 0.0, 0.0), FRAC_PI_2);
let p = Vec3::new(20.0, 0.0, 0.0);
assert!(near(place(&s, "LeafB", p), solvecraft_doc::apply_point(&want, p)), "{:?}", place(&s, "LeafB", p));
@@ -63,9 +63,9 @@ fn revolute_hinge_turns_about_the_knuckle() {
assert!(near(place(&s, "LeafA", p), p));
assert!(near(place(&s, "LeafB", Vec3::new(10.0, 33.0, 4.0)), Vec3::new(10.0, 33.0, 4.0)));
// Expressions drive too, and undo puts it back.
run(&mut s, "joint.drive", json!({"joint": "Hinge", "value": "pi rad"}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Hinge", "value": "pi rad"}));
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 66.0, 8.0)));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 29.0, 37.0)));
}
@@ -74,8 +74,8 @@ fn two_cubes() -> Session {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": name}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
}
let o = occ(&s, "A");
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
@@ -88,19 +88,19 @@ fn slider_limits_clamp() {
// B's bottom face onto A's top face (faces meet), sliding along their common normal.
let r = run(
&mut s,
"joint.create",
"JointAssembleCmdNew",
json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "limits": [[0, 10]], "name": "Rail"}),
);
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
// B now sits on A.
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 0.0)));
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 25}));
let r = run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 25}));
assert_eq!(r["values"], json!([10.0]), "{r}");
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 20.0)));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)));
// Changing the limits clamps the current value.
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 8}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 8}));
run(&mut s, "joint.limits", json!({"joint": "Rail", "min": 0, "max": 4}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 14.0)));
// Joints follow edits: A gets taller, B rides on its new top.
@@ -111,7 +111,7 @@ fn slider_limits_clamp() {
.find(|f| matches!(f.kind, solvecraft_doc::FeatureKind::Box { .. }))
.and_then(|f| f.param_names.get(2).cloned())
.unwrap_or_default();
run(&mut s, "parameters.change", json!({"name": h, "expression": "15"}));
run(&mut s, "ChangeParameterCommand", json!({"name": h, "expression": "15"}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 19.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 0.0)));
}
@@ -119,12 +119,12 @@ fn slider_limits_clamp() {
fn rigid_cycles_solve_and_conflicts_report() {
let mut s = two_cubes();
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "C"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0]}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "C"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
// A rigid group and a closing as-built joint: a consistent loop.
run(&mut s, "joint.rigid_group", json!({"occurrences": ["A", "B", "C"]}));
let r = run(&mut s, "joint.as_built", json!({"type": "rigid", "a": "B", "b": "C"}));
run(&mut s, "RigidGroupCmd", json!({"occurrences": ["A", "B", "C"]}));
let r = run(&mut s, "JointAsBuiltCmd", json!({"type": "rigid", "a": "B", "b": "C"}));
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
assert!(near(place(&s, "C", Vec3::new(40.0, 0.0, 0.0)), Vec3::new(40.0, 0.0, 0.0)));
let l = run(&mut s, "joint.list", json!({}));
@@ -132,7 +132,7 @@ fn rigid_cycles_solve_and_conflicts_report() {
// A joint that wants C elsewhere conflicts and says so.
let r = run(
&mut s,
"joint.create",
"JointAssembleCmdNew",
json!({"type": "rigid", "a": {"occurrence": "A", "point": [0, 0, 0]}, "b": {"occurrence": "C", "point": [40, 0, 0]}, "name": "Wrong"}),
);
let c = r["conflicts"].as_array().cloned().unwrap_or_default();
@@ -143,37 +143,37 @@ fn rigid_cycles_solve_and_conflicts_report() {
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0));
assert!(
s.execute(
"joint.create",
"JointAssembleCmdNew",
&json!({"type": "rigid", "a": {"occurrence": "A", "point": [0, 0, 0]}, "b": {"occurrence": "A", "point": [1, 0, 0]}})
)
.is_err()
);
assert!(s.execute("joint.create", &json!({"type": "hinge"})).is_err());
assert!(s.execute("JointAssembleCmdNew", &json!({"type": "hinge"})).is_err());
}
#[test]
fn motion_links_and_interference() {
let mut s = two_cubes();
run(&mut s, "joint.create", json!({"type": "cylindrical", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Screw"}));
run(&mut s, "joint.drive", json!({"joint": "Screw", "values": [0, 0]}));
run(&mut s, "JointAssembleCmdNew", json!({"type": "cylindrical", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Screw"}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Screw", "values": [0, 0]}));
// No overlap while B sits on A; then push B 4 mm into A.
let i = run(&mut s, "inspect.interference", json!({}));
let i = run(&mut s, "InterferenceCheckCommand", json!({}));
assert_eq!(i["count"], 0, "{i}");
run(&mut s, "joint.drive", json!({"joint": "Screw", "values": [0, -4]}));
let i = run(&mut s, "inspect.interference", json!({}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Screw", "values": [0, -4]}));
let i = run(&mut s, "InterferenceCheckCommand", json!({}));
assert_eq!(i["count"], 1, "{i}");
assert!((i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0) - 400.0).abs() < 1e-3, "{i}");
// A motion link: a second joint's value follows.
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "D"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0]}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "D"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0]}));
run(
&mut s,
"joint.create",
"JointAssembleCmdNew",
json!({"type": "slider", "a": {"occurrence": "A", "face": [5, 5, 10]}, "b": {"occurrence": "D", "face": [65, 5, 0]}, "name": "Follower"}),
);
run(&mut s, "joint.motion_link", json!({"a": "Screw", "ia": 1, "b": "Follower", "ratio": 2}));
run(&mut s, "joint.drive", json!({"joint": "Screw", "values": [0, 3]}));
run(&mut s, "FusionMotionRelationshipCommand", json!({"a": "Screw", "ia": 1, "b": "Follower", "ratio": 2}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Screw", "values": [0, 3]}));
assert!(near(place(&s, "D", Vec3::new(65.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 16.0)), "{:?}", place(&s, "D", Vec3::new(65.0, 5.0, 0.0)));
// Saved and reopened, joints come back.
let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
@@ -188,7 +188,7 @@ fn motion_links_and_interference() {
#[test]
fn faces_mate_by_default() {
let mut s = two_cubes();
run(&mut s, "joint.create", json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Stack"}));
run(&mut s, "JointAssembleCmdNew", json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "name": "Stack"}));
// B's bottom centre (25, 5, 0) at A's top centre, B above it.
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 20.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0)));
@@ -1,65 +0,0 @@
//! Delete Face and shells/offsets on cones and spheres, through the engine.
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
}
/// A box with a drilled hole (cone tip) shells: the cavity is the box shrunk by the wall and the
/// hole grown by it (its cone slid down its axis), measured against that analytic volume.
#[test]
fn shell_a_box_with_a_drilled_hole() {
use std::f64::consts::PI;
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
run(&mut s, "solid.hole", json!({"position": [20, 15, 20], "diameter": 8, "depth": 10, "type": "drilled", "tip_angle": 118}));
let v_body = volume(&mut s);
let (r, t) = (4.0, 2.0);
let a = (59.0f64).to_radians();
// Cylinder depth from the measured body: 24000 − π r² h − π r² (r / tan α) / 3.
let h = (24000.0 - v_body - PI * r * r * (r / a.tan()) / 3.0) / (PI * r * r);
assert!(h > 9.0 && h < 11.0, "{h}");
run(&mut s, "solid.shell", json!({"faces": [[5, 5, 20]], "thickness": t}));
let v_shell = volume(&mut s);
let apex = 20.0 - h - r / a.tan() - t / a.sin();
let big_r = r + t;
let joint = apex + big_r / a.tan();
let grown = PI * big_r * big_r * (20.0 - joint) + PI * big_r * big_r * (joint - apex) / 3.0;
let cavity = 36.0 * 26.0 * 18.0 - grown;
let want = v_body - cavity;
assert!((v_shell - want).abs() < 2e-4 * want, "{v_shell} vs {want}");
}
/// Offset Face on a cone and a sphere move them exactly.
#[test]
fn offset_cone_and_sphere_faces() {
use std::f64::consts::PI;
let mut s = Session::default();
run(&mut s, "solid.sphere", json!({"center": [0, 0, 0], "radius": 10}));
let r = run(&mut s, "solid.offset_face", json!({"faces": [[10, 0, 0]], "distance": 2}));
let v = volume(&mut s);
assert!((v - 4.0 / 3.0 * PI * 1728.0).abs() < 1e-3 * v, "{v} {r}");
}
/// Offset Face on a torus changes its tube radius and keeps the major radius.
#[test]
fn offset_torus_face() {
use std::f64::consts::PI;
let mut s = Session::default();
run(&mut s, "solid.torus", json!({"major": 20, "minor": 5}));
let v0 = volume(&mut s);
assert!((v0 - 2.0 * PI * PI * 20.0 * 25.0).abs() < 1e-3 * v0, "{v0}");
run(&mut s, "solid.offset_face", json!({"faces": [[25, 0, 0]], "distance": 1}));
let v = volume(&mut s);
assert!((v - 2.0 * PI * PI * 20.0 * 36.0).abs() < 1e-3 * v, "{v}");
}
+3 -3
View File
@@ -1,4 +1,4 @@
//! Measuring picked geometry (`inspect.measure` with `items`): one item gives its length, area,
//! Measuring picked geometry (`MeasureCommand` with `items`): one item gives its length, area,
//! volume or position; two give the minimum distance between them (with the two closest
//! points) and, where both have a direction (planar faces, straight edges), the angle.
@@ -146,9 +146,9 @@ fn closest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> {
}
}
/// `inspect.measure` with `items` (one or two selections).
/// `MeasureCommand` with `items` (one or two selections).
pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
let cmd = "inspect.measure";
let cmd = "MeasureCommand";
let list = items.as_array().ok_or_else(|| bad(cmd, "`items` must be a list of selections"))?;
if list.is_empty() || list.len() > 2 {
return Err(bad(cmd, "measure one or two items"));
+3 -13
View File
@@ -1,11 +1,10 @@
//! The command registry. Ids are SolveCraft's own dotted ids (`solid.extrude`,
//! `sketch.rectangle.two_point`); the toolbar placement (`tab`, `panel`) follows the familiar
//! Design workspace layout. Ids used before the rename still resolve (`legacy_ids`).
//! The command registry. Ids follow Fusion's command ids where Fusion has the command; the
//! toolbar placement (`tab`, `panel`) follows Fusion's Design workspace so the registry doubles
//! as the parity metric (`cargo xtask parity`).
mod browser;
mod clipboard;
mod component;
mod construct;
mod delete;
mod edit;
mod face;
@@ -18,11 +17,8 @@ mod inspect;
pub(crate) mod joints;
mod measure_sel;
pub(crate) mod motion;
mod parts;
mod section;
pub(crate) use measure_sel::measure_items;
#[cfg(test)]
mod kernel_heal_tests;
mod parameters;
mod plastic;
mod sheet;
@@ -36,7 +32,6 @@ mod sketch_import;
mod sketch_inspect;
mod sketch_modify;
mod sketch_project;
mod surface;
mod surface_analysis;
use serde::Serialize;
@@ -141,19 +136,16 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(sketch_canvas::COMMANDS.iter());
v.extend(sketch_3d::COMMANDS.iter());
v.extend(surface_analysis::COMMANDS.iter());
v.extend(surface::COMMANDS.iter());
v.extend(sketch_modify::COMMANDS.iter());
v.extend(sketch_project::COMMANDS.iter());
v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter());
v.extend(construct::COMMANDS.iter());
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
v.extend(component::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(config::COMMANDS.iter());
v.extend(parts::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
@@ -167,7 +159,5 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
let all = command_specs();
// Ids from before the rename still find their command.
let id = crate::legacy_ids::current_id(id);
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
}
+8 -8
View File
@@ -13,22 +13,22 @@ use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
CommandSpec::new("EnableContactSetsCmd", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
"enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other",
),
CommandSpec::new("contact.enable_all", "Enable All Contact", enable_all)
CommandSpec::new("EnableAllContactCmd", "Enable All Contact", enable_all)
.at("SOLID", "ASSEMBLE")
.icon("joint")
.params("every occurrence takes part in contact (one set of all)"),
CommandSpec::new("contact.disable_all", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("joint").params("contact off"),
CommandSpec::new("contact.create", "New Contact Set", new_set)
CommandSpec::new("DisableAllContactCmd", "Disable Contact", disable_all).at("SOLID", "ASSEMBLE").icon("joint").params("contact off"),
CommandSpec::new("ContactSetCmd", "New Contact Set", new_set)
.at("SOLID", "ASSEMBLE")
.icon("joint")
.params("occurrences: [ids or names] (two or more); name?"),
CommandSpec::new("contact.edit", "Edit Contact Set", edit_set).params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"),
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().params("→ sets, enabled, all"),
CommandSpec::new("contact.check", "Check Contact", check).noundo().params("→ pairs in contact sets that pass through each other now"),
CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params(
CommandSpec::new("FusionMotionStudyCommand", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params(
"name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \
(values: degrees for angles, mm for distances, or expressions)",
),
@@ -285,7 +285,7 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)]) -> Result<Value
Ok(json!({"values": values, "stopped_by_contact": stopped, "conflicts": sol.conflicts.len()}))
}
/// Drive one joint (joint.drive), honouring contact sets.
/// Drive one joint (FusionMoveJointsCommand), honouring contact sets.
pub fn drive_joint(s: &mut Session, id: u64, q: Vec<f64>) -> Result<Value> {
drive_with_contact(s, &[(id, q)])
}
@@ -311,7 +311,7 @@ fn disable_all(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn new_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "contact.create";
let cmd = "ContactSetCmd";
let occ = occurrences(s, p, cmd)?;
if occ.len() < 2 {
return Err(bad(cmd, "a contact set needs two or more occurrences"));
@@ -387,7 +387,7 @@ fn study_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
}
fn study(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "motion.study";
let cmd = "FusionMotionStudyCommand";
let existing = if p.get("study").is_some() { Some(study_index(s, p, cmd)?) } else { None };
let steps = match p.get("steps") {
Some(v) => {
+24 -23
View File
@@ -26,14 +26,14 @@ fn rail() -> Session {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": name}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
}
run(&mut s, "component.activate", json!({"component": "root"}));
let a = occ(&s, "A");
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "JointAssembleCmdNew", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
assert!((b_x(&s) - 20.0).abs() < 1e-6, "{}", b_x(&s));
s
}
@@ -42,44 +42,45 @@ fn rail() -> Session {
fn contact_sets_stop_a_driven_joint_at_touching() {
let mut s = rail();
// No contact: B passes into A.
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 5.0).abs() < 1e-6);
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
run(&mut s, "ContactSetCmd", json!({"occurrences": ["A", "B"]}));
let r = run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
assert!(r["stopped_by_contact"].is_object(), "{r}");
// Stopped where the faces touch (to the search's resolution, 15 mm / 2^14).
assert!((b_x(&s) - 10.0).abs() < 2e-3, "{}", b_x(&s));
assert!(b_x(&s) >= 10.0 - 2e-3, "a touch, not a pass");
assert_eq!(run(&mut s, "contact.check", json!({}))["collisions"].as_array().map(Vec::len), Some(0));
// Away is free; contact off lets it through again.
let r = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 3}));
let r = run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 3}));
assert!(r["stopped_by_contact"].is_null() && (b_x(&s) - 13.0).abs() < 1e-6, "{r}");
run(&mut s, "contact.disable_all", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
run(&mut s, "DisableAllContactCmd", json!({}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 5.0).abs() < 1e-6);
// Suppressed sets don't count; Enable All Contact puts every occurrence in one.
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.enable_sets", json!({}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
run(&mut s, "EnableContactSetsCmd", json!({}));
run(&mut s, "contact.edit", json!({"set": "Contact Set1", "suppressed": true}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 5.0).abs() < 1e-6);
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "contact.enable_all", json!({}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": 10}));
run(&mut s, "EnableAllContactCmd", json!({}));
run(&mut s, "FusionMoveJointsCommand", json!({"joint": "Rail", "value": -5}));
assert!((b_x(&s) - 10.0).abs() < 2e-3);
assert!(s.execute("contact.create", &json!({"occurrences": ["A"]})).is_err());
assert!(s.execute("ContactSetCmd", &json!({"occurrences": ["A"]})).is_err());
}
#[test]
fn motion_study_plays_and_exports() {
let mut s = rail();
let r = run(&mut s, "motion.study", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]}));
let r =
run(&mut s, "FusionMotionStudyCommand", json!({"name": "Push", "steps": 100, "keys": [{"joint": "Rail", "points": [[0, 10], [100, -5]]}]}));
assert_eq!(r["keys"], 1);
run(&mut s, "motion.play", json!({"study": "Push", "step": 50}));
assert!((b_x(&s) - 12.5).abs() < 1e-6, "{}", b_x(&s));
// With contact, the end of the study stops at touching.
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
run(&mut s, "ContactSetCmd", json!({"occurrences": ["A", "B"]}));
let r = run(&mut s, "motion.play", json!({"study": "Push", "step": 100}));
assert!(r["stopped_by_contact"].is_object() && (b_x(&s) - 10.0).abs() < 2e-3, "{r}");
// Export: positions per sample; the design keeps its joint value.
@@ -103,7 +104,7 @@ fn motion_study_plays_and_exports() {
let text = std::fs::read_to_string(&csv).unwrap();
assert!(text.starts_with("step,occurrence,x,y,z") && text.lines().count() == 1 + 3 * 2, "{text}");
let _ = std::fs::remove_dir_all(&dir);
assert!(s.execute("motion.study", &json!({"keys": [{"joint": "Rail", "index": 3, "points": [[0, 1]]}]})).is_err());
assert!(s.execute("FusionMotionStudyCommand", &json!({"keys": [{"joint": "Rail", "index": 3, "points": [[0, 1]]}]})).is_err());
run(&mut s, "motion.delete", json!({"study": "Push"}));
assert_eq!(run(&mut s, "motion.list", json!({}))["studies"].as_array().map(Vec::len), Some(0));
}
@@ -116,7 +117,7 @@ fn exploded_views_show_without_changing_the_design() {
assert_eq!(r["moves"].as_array().map(Vec::len), Some(2), "{r}");
run(&mut s, "explode.show", json!({"view": "Apart"}));
// Centres at x 5 and 25 → middle 15: each moves 10 mm further out.
let bb = |s: &mut Session, b: &str| run(s, "inspect.measure", json!({"bodies": [b]}))["bodies"][0]["bbox"]["min"][0].as_f64().unwrap_or(f64::NAN);
let bb = |s: &mut Session, b: &str| run(s, "MeasureCommand", json!({"bodies": [b]}))["bodies"][0]["bbox"]["min"][0].as_f64().unwrap_or(f64::NAN);
let names: Vec<String> = s.world_state().bodies.iter().map(|b| b.name.clone()).collect();
assert!((bb(&mut s, &names[0]) + 10.0).abs() < 1e-6, "{names:?}");
assert!((bb(&mut s, &names[1]) - 30.0).abs() < 1e-6);
@@ -126,7 +127,7 @@ fn exploded_views_show_without_changing_the_design() {
run(&mut s, "explode.create", json!({"name": "Hand", "moves": [{"occurrence": "B", "translate": [0, 0, 50]}]}));
run(&mut s, "explode.show", json!({"view": "Hand"}));
assert!(
(run(&mut s, "inspect.measure", json!({"bodies": [names[1]]}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 50.0).abs() < 1e-6
(run(&mut s, "MeasureCommand", json!({"bodies": [names[1]]}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap_or(0.0) - 50.0).abs() < 1e-6
);
assert_eq!(run(&mut s, "explode.list", json!({}))["views"].as_array().map(Vec::len), Some(2));
}
+6 -6
View File
@@ -10,7 +10,7 @@ use crate::params::{bad, bool_, str_};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
CommandSpec::new("ChangeParameterCommand", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\" (new parameters default to their value's unit), comment?, \
favorite?: bool, new_name?: rename (references follow); or delete: name",
),
@@ -65,7 +65,7 @@ fn known(s: &Session, name: &str) -> bool {
}
fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parameters.change";
let cmd = "ChangeParameterCommand";
if let Some(n) = str_(p, "delete") {
s.doc_mut().remove_param(n.trim())?;
return Ok(json!({"deleted": n.trim()}));
@@ -219,7 +219,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
other => return Err(bad(cmd, format!("unknown format `{other}` (csv or json)"))),
};
if let Some(path) = path {
solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
}
Ok(json!({"format": format, "path": path, "text": text}))
}
@@ -229,11 +229,11 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let text = match (str_(p, "text"), str_(p, "path")) {
(Some(t), _) => t.to_string(),
(None, Some(path)) => {
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta > 16 << 20 {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > 16 << 20 {
return Err(bad(cmd, "file too large"));
}
solvecraft_io::vfs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
std::fs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
}
_ => return Err(bad(cmd, "give `text` or `path`")),
};
+21 -21
View File
@@ -17,7 +17,7 @@ fn err(s: &mut Session, id: &str, p: Value) -> String {
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -36,13 +36,13 @@ fn expr_of(s: &mut Session, name: &str) -> String {
/// A sketched block 40 x (len / 2) x 10 whose width is an expression of a user parameter.
fn block(s: &mut Session) {
run(s, "parameters.add", json!({"name": "len", "expression": "60 mm", "comment": "overall length", "favorite": true}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "sketch.constraint.coincident", json!({"a": "p1", "b": "origin"}));
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": 40}));
run(s, "sketch.dimension", json!({"entities": ["l2"], "value": "len/2"}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 10, "body_name": "Block"}));
run(s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "ConstraintCoincident", json!({"a": "p1", "b": "origin"}));
run(s, "SketchDimension", json!({"entities": ["l1"], "value": 40}));
run(s, "SketchDimension", json!({"entities": ["l2"], "value": "len/2"}));
run(s, "SketchStop", json!({}));
run(s, "Extrude", json!({"distance": 10, "body_name": "Block"}));
}
#[test]
@@ -50,20 +50,20 @@ fn width_follows_an_expression() {
let mut s = Session::default();
block(&mut s);
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
let r = run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "100 mm"}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
assert!(rel(volume(&mut s), 40.0 * 50.0 * 10.0) < 1e-9);
// Units convert: 4 in = 101.6 mm.
run(&mut s, "parameters.change", json!({"name": "len", "expression": "4 in"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "4 in"}));
assert!(rel(volume(&mut s), 40.0 * 50.8 * 10.0) < 1e-9);
// A parameter in inches reads bare numbers as inches.
run(&mut s, "parameters.change", json!({"name": "len", "expression": "5", "unit": "in"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "5", "unit": "in"}));
assert!(rel(volume(&mut s), 40.0 * 63.5 * 10.0) < 1e-9);
let l = run(&mut s, "parameters.list", json!({}));
assert_eq!(row(&l, "len")["value"], 5.0, "{l}");
assert_eq!(row(&l, "len")["unit"], "in");
// A failing parameter fails the features using it, with its error.
let e = err(&mut s, "parameters.change", json!({"name": "len", "expression": "5 deg"}));
let e = err(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "5 deg"}));
assert!(e.contains("expected a length, got an angle"), "{e}");
}
@@ -75,14 +75,14 @@ fn feature_inputs_are_named_and_editable() {
let dist = l["parameters"].as_array().unwrap().iter().find(|r| r["input"] == "Distance").unwrap().clone();
assert_eq!(dist["feature"], "Extrude1", "{l}");
let d = dist["name"].as_str().unwrap().to_string();
run(&mut s, "parameters.change", json!({"name": d, "expression": "len / 3"}));
run(&mut s, "ChangeParameterCommand", json!({"name": d, "expression": "len / 3"}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 20.0) < 1e-9);
// Editing the feature keeps its parameter name.
run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "12 mm"}}}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 12.0) < 1e-9);
assert_eq!(expr_of(&mut s, &d), "12 mm");
// Another feature can use it.
run(&mut s, "solid.box", json!({"length": d, "width": 5, "height": 5, "corner": [100, 0, 0]}));
run(&mut s, "PrimitiveBox", json!({"length": d, "width": 5, "height": 5, "corner": [100, 0, 0]}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 12.0 + 12.0 * 25.0) < 1e-9);
let u = run(&mut s, "parameters.users", json!({"name": d}));
assert_eq!(u["users"].as_array().map(Vec::len), Some(1), "{u}");
@@ -101,7 +101,7 @@ fn unit_errors_and_cycles_are_refused() {
assert!(e.contains("expected an angle, got a length"), "{e}");
assert!(err(&mut s, "parameters.add", json!({"name": "x", "expression": "1", "unit": "furlong"})).contains("unknown unit"));
run(&mut s, "parameters.add", json!({"name": "b", "expression": "a * 2"}));
let e = err(&mut s, "parameters.change", json!({"name": "a", "expression": "b + 1"}));
let e = err(&mut s, "ChangeParameterCommand", json!({"name": "a", "expression": "b + 1"}));
assert!(e.contains("circular reference: a → b → a"), "{e}");
assert!(err(&mut s, "parameters.add", json!({"name": "selfref", "expression": "selfref"})).contains("circular"));
assert!(err(&mut s, "parameters.add", json!({"name": "a", "expression": "1"})).contains("already"));
@@ -132,7 +132,7 @@ fn rename_updates_every_reference() {
let l = run(&mut s, "parameters.list", json!({"favorites": true}));
assert_eq!(l["count"], 1, "the favourite follows the rename");
assert_eq!(row(&l, "length")["comment"], "overall length");
run(&mut s, "parameters.change", json!({"name": "length", "expression": "80"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "length", "expression": "80"}));
assert!(rel(volume(&mut s), 40.0 * 40.0 * 10.0) < 1e-9);
// Model and feature parameters can be renamed too: dimensions and features follow.
let dim = s.doc.features.first().and_then(|f| match &f.kind {
@@ -140,17 +140,17 @@ fn rename_updates_every_reference() {
_ => None,
});
let dim = dim.unwrap_or_default();
run(&mut s, "parameters.change", json!({"name": dim, "new_name": "base_w", "expression": "50"}));
run(&mut s, "ChangeParameterCommand", json!({"name": dim, "new_name": "base_w", "expression": "50"}));
assert!(rel(volume(&mut s), 50.0 * 40.0 * 10.0) < 1e-9);
let ext = s.doc.features.get(1).and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "parameters.rename", json!({"name": ext, "new_name": "thick"}));
run(&mut s, "parameters.change", json!({"name": "thick", "expression": "base_w / 10"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "thick", "expression": "base_w / 10"}));
assert!(rel(volume(&mut s), 50.0 * 40.0 * 5.0) < 1e-9);
assert!(err(&mut s, "parameters.rename", json!({"name": "twice", "new_name": "length"})).contains("already"));
assert!(err(&mut s, "parameters.rename", json!({"name": "nope", "new_name": "x"})).contains("unknown"));
// Undo puts the old name back.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert!(s.doc.features.get(1).is_some_and(|f| f.param_names.first() == Some(&ext)));
}
@@ -165,7 +165,7 @@ fn deleting_a_used_parameter_lists_its_users() {
assert_eq!(u["users"].as_array().map(Vec::len), Some(2), "{u}");
assert_eq!(u["drives"][0]["name"], "Base", "{u}");
let dim = u["users"].as_array().unwrap().iter().find(|x| x["name"] != "spare").unwrap()["name"].as_str().unwrap().to_string();
let e = err(&mut s, "parameters.change", json!({"delete": dim}));
let e = err(&mut s, "ChangeParameterCommand", json!({"delete": dim}));
assert!(e.contains("Base dimension"), "{e}");
run(&mut s, "parameters.delete", json!({"name": "spare"}));
assert!(s.doc.param("spare").is_none());
-518
View File
@@ -1,518 +0,0 @@
//! Standard parts: ISO metric fasteners, dowel pins and ball bearings, generated in code from
//! the dimensions in the public standards tables (no vendor geometry). Each part is inserted as
//! its own component, built by ordinary features (so it stays parametric and editable), with a
//! cosmetic thread where it has one; at a hole it gets a rigid joint that puts its axis on the
//! hole's and seats it on the face.
//!
//! Part frame: the seating plane is z = 0 (the underside of a head, the bottom of a nut or
//! washer, the top of a set screw or pin); the shank runs down −z.
use serde_json::{Value, json};
use solvecraft_geom::Vec3;
use super::CommandSpec;
use crate::params::{bad, num, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("parts.library", "Standard Parts", library)
.noundo()
.params("family? → families (name, standard, sizes, lengths) and, with family, each size's dimensions (mm)"),
CommandSpec::new("parts.fastener", "Insert Fastener", insert).at("SOLID", "INSERT").icon("fastener").params(
"the Insert Part dialog's command (same as parts.insert): family, size, length?, at?|point?+direction?, name?",
),
CommandSpec::new("parts.insert", "Insert Part", insert).icon("fastener").params(
"family (socket_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing); size (\"M6\", or \"608\" for bearings); \
length? (mm; screws, bolts, set screws, pins; default a standard length near 2.5 d); \
at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint) | point?: [x,y,z] with direction?: [x,y,z] (no joint); name?",
),
CommandSpec::new("parts.resize", "Change Part Size", resize)
.params("component: id|name of an inserted part; size?; length? — rebuilds it (joints keep it seated)"),
];
/// A family: (id, name, standard).
const FAMILIES: [(&str, &str, &str); 7] = [
("socket_head_cap_screw", "Socket head cap screw", "ISO 4762"),
("hex_bolt", "Hex head bolt / screw", "ISO 4014 / ISO 4017"),
("hex_nut", "Hex nut", "ISO 4032"),
("washer", "Plain washer", "ISO 7089"),
("set_screw", "Hex socket set screw, flat point", "ISO 4026"),
("dowel_pin", "Parallel pin (dowel)", "ISO 8734"),
("bearing", "Deep groove ball bearing (envelope)", "ISO 15 (6000/6200/600 series)"),
];
/// ISO metric coarse: (size, d, pitch).
const METRIC: [(&str, f64, f64); 9] = [
("M3", 3.0, 0.5),
("M4", 4.0, 0.7),
("M5", 5.0, 0.8),
("M6", 6.0, 1.0),
("M8", 8.0, 1.25),
("M10", 10.0, 1.5),
("M12", 12.0, 1.75),
("M16", 16.0, 2.0),
("M20", 20.0, 2.5),
];
/// ISO 4762: (size, head diameter dk, head height k, socket s, socket depth t).
const SHCS: [(&str, f64, f64, f64, f64); 9] = [
("M3", 5.5, 3.0, 2.5, 1.3),
("M4", 7.0, 4.0, 3.0, 2.0),
("M5", 8.5, 5.0, 4.0, 2.5),
("M6", 10.0, 6.0, 5.0, 3.0),
("M8", 13.0, 8.0, 6.0, 4.0),
("M10", 16.0, 10.0, 8.0, 5.0),
("M12", 18.0, 12.0, 10.0, 6.0),
("M16", 24.0, 16.0, 14.0, 8.0),
("M20", 30.0, 20.0, 17.0, 10.0),
];
/// ISO 4014/4017 hex head: (size, across flats s, head height k).
const HEX_BOLT: [(&str, f64, f64); 9] = [
("M3", 5.5, 2.0),
("M4", 7.0, 2.8),
("M5", 8.0, 3.5),
("M6", 10.0, 4.0),
("M8", 13.0, 5.3),
("M10", 16.0, 6.4),
("M12", 18.0, 7.5),
("M16", 24.0, 10.0),
("M20", 30.0, 12.5),
];
/// ISO 4032: (size, across flats s, height m).
const HEX_NUT: [(&str, f64, f64); 9] = [
("M3", 5.5, 2.4),
("M4", 7.0, 3.2),
("M5", 8.0, 4.7),
("M6", 10.0, 5.2),
("M8", 13.0, 6.8),
("M10", 16.0, 8.4),
("M12", 18.0, 10.8),
("M16", 24.0, 14.8),
("M20", 30.0, 18.0),
];
/// ISO 7089: (size, inner d1, outer d2, thickness h).
const WASHER: [(&str, f64, f64, f64); 9] = [
("M3", 3.2, 7.0, 0.5),
("M4", 4.3, 9.0, 0.8),
("M5", 5.3, 10.0, 1.0),
("M6", 6.4, 12.0, 1.6),
("M8", 8.4, 16.0, 1.6),
("M10", 10.5, 20.0, 2.0),
("M12", 13.0, 24.0, 2.5),
("M16", 17.0, 30.0, 3.0),
("M20", 21.0, 37.0, 3.0),
];
/// ISO 4026: (size, socket s, socket depth t).
const SET_SCREW: [(&str, f64, f64); 7] =
[("M3", 1.5, 1.2), ("M4", 2.0, 1.5), ("M5", 2.5, 2.0), ("M6", 3.0, 2.0), ("M8", 4.0, 3.0), ("M10", 5.0, 4.0), ("M12", 6.0, 4.8)];
/// ISO 8734 diameters.
const DOWEL: [f64; 9] = [2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0, 12.0, 16.0];
/// Bearings: (designation, bore, outside diameter, width).
const BEARINGS: [(&str, f64, f64, f64); 13] = [
("625", 5.0, 16.0, 5.0),
("626", 6.0, 19.0, 6.0),
("608", 8.0, 22.0, 7.0),
("6000", 10.0, 26.0, 8.0),
("6001", 12.0, 28.0, 8.0),
("6002", 15.0, 32.0, 9.0),
("6003", 17.0, 35.0, 10.0),
("6004", 20.0, 42.0, 12.0),
("6200", 10.0, 30.0, 9.0),
("6201", 12.0, 32.0, 10.0),
("6202", 15.0, 35.0, 11.0),
("6203", 17.0, 40.0, 12.0),
("6204", 20.0, 47.0, 14.0),
];
/// Standard lengths for screws, bolts and pins (mm).
const LENGTHS: [f64; 22] =
[4.0, 5.0, 6.0, 8.0, 10.0, 12.0, 16.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 65.0, 70.0, 80.0, 90.0, 100.0, 120.0];
fn metric(size: &str) -> Option<(f64, f64)> {
METRIC.iter().find(|(s, _, _)| s.eq_ignore_ascii_case(size)).map(|(_, d, p)| (*d, *p))
}
fn default_length(d: f64) -> f64 {
LENGTHS.iter().copied().find(|l| *l >= 2.5 * d).unwrap_or(100.0)
}
/// One part's shape, in its own frame.
#[derive(Clone, Debug, PartialEq)]
enum Shape {
/// A head (round or hex), a shank down −z, an optional hex socket in the head's top.
Screw { d: f64, pitch: f64, len: f64, head: Head, socket: Option<(f64, f64)> },
/// A hex prism with a through hole (nut), z 0…m.
Nut { d: f64, pitch: f64, s: f64, m: f64 },
/// A ring (washer, bearing envelope), z 0…h.
Ring { d_in: f64, d_out: f64, h: f64 },
/// A cylinder down −z with a socket in its top (set screw), or plain (pin).
Rod { d: f64, pitch: Option<f64>, len: f64, socket: Option<(f64, f64)> },
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Head {
Round { dk: f64, k: f64 },
Hex { s: f64, k: f64 },
}
fn shape(family: &str, size: &str, length: Option<f64>) -> std::result::Result<Shape, String> {
let need = |what: &str| format!("no {what} size `{size}` (parts.library lists them)");
let len = |d: f64| length.unwrap_or_else(|| default_length(d));
Ok(match family {
"socket_head_cap_screw" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, dk, k, s, t) = SHCS.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4762"))?;
Shape::Screw { d, pitch, len: len(d), head: Head::Round { dk, k }, socket: Some((s, t)) }
}
"hex_bolt" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, s, k) = HEX_BOLT.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4014"))?;
Shape::Screw { d, pitch, len: len(d), head: Head::Hex { s, k }, socket: None }
}
"hex_nut" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, s, m) = HEX_NUT.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4032"))?;
Shape::Nut { d, pitch, s, m }
}
"washer" => {
let (_, d1, d2, h) = WASHER.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 7089"))?;
Shape::Ring { d_in: d1, d_out: d2, h }
}
"set_screw" => {
let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?;
let (_, s, t) = SET_SCREW.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4026"))?;
Shape::Rod {
d,
pitch: Some(pitch),
len: length.unwrap_or_else(|| LENGTHS.iter().copied().find(|l| *l >= d).unwrap_or(d)),
socket: Some((s, t)),
}
}
"dowel_pin" => {
let d: f64 = size.trim_start_matches(['d', 'D', 'Ø']).trim().parse().map_err(|_| need("dowel pin"))?;
if !DOWEL.contains(&d) {
return Err(need("ISO 8734"));
}
Shape::Rod { d, pitch: None, len: len(d * 1.6), socket: None }
}
"bearing" => {
let (_, bore, od, w) = BEARINGS.iter().find(|r| r.0 == size.trim()).copied().ok_or_else(|| need("bearing"))?;
Shape::Ring { d_in: bore, d_out: od, h: w }
}
o => return Err(format!("unknown part family `{o}` (parts.library lists them)")),
})
}
fn run(s: &mut Session, id: &str, p: Value) -> Result<Value> {
s.execute(id, &p)
}
/// A hex prism (across flats `af`) from z0 to z1, joined or cut.
fn hex(s: &mut Session, af: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
run(s, "sketch.create", json!({"plane": "XY", "offset": z0}))?;
run(s, "sketch.polygon.circumscribed", json!({"center": [0, 0], "radius": af / 2.0, "sides": 6}))?;
run(s, "sketch.finish", json!({}))?;
let (dist, dir) = if z1 >= z0 { (z1 - z0, "positive") } else { (z0 - z1, "negative") };
run(s, "solid.extrude", json!({"distance": dist, "direction": dir, "operation": op}))?;
Ok(())
}
fn cyl(s: &mut Session, d: f64, z0: f64, z1: f64, op: &str) -> Result<()> {
run(s, "solid.cylinder", json!({"diameter": d, "height": z1 - z0, "base": [0, 0, z0], "axis": [0, 0, 1], "operation": op}))?;
Ok(())
}
/// A solid of revolution about z from a closed (r, z) outline, as one body or joined.
fn revolve(s: &mut Session, pts: &[(f64, f64)], op: &str) -> Result<()> {
run(s, "sketch.create", json!({"plane": "XZ"}))?;
let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, *z]).collect();
run(s, "sketch.line", json!({"points": points, "closed": true}))?;
run(s, "sketch.finish", json!({}))?;
run(s, "solid.revolve", json!({"axis": "y", "operation": op}))?;
Ok(())
}
fn thread(s: &mut Session, d: f64, pitch: f64, at: Vec3) {
let _ = run(s, "solid.thread", json!({"face": at, "designation": format!("M{}x{}", d, pitch)}));
let _ = d;
}
/// Build a part in the active component.
fn build(s: &mut Session, sh: &Shape) -> Result<()> {
match *sh {
Shape::Screw { d, pitch, len, head, socket } => {
let k = match head {
// One revolved outline: shank and round head.
Head::Round { dk, k } => {
revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, 0.0), (dk / 2.0, 0.0), (dk / 2.0, k), (0.0, k)], "new")?;
k
}
// A hex head joined over a shank that runs up into it.
Head::Hex { s: af, k } => {
hex(s, af, 0.0, k, "new")?;
revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, k / 2.0), (0.0, k / 2.0)], "join")?;
k
}
};
if let Some((af, t)) = socket {
hex(s, af, k, k - t, "cut")?;
}
thread(s, d, pitch, Vec3::new(d / 2.0, 0.0, -len / 2.0));
}
Shape::Nut { d, pitch, s: af, m } => {
hex(s, af, 0.0, m, "new")?;
cyl(s, d, -1.0, m + 1.0, "cut")?;
thread(s, d, pitch, Vec3::new(d / 2.0, 0.0, m / 2.0));
}
Shape::Ring { d_in, d_out, h } => {
revolve(s, &[(d_in / 2.0, 0.0), (d_out / 2.0, 0.0), (d_out / 2.0, h), (d_in / 2.0, h)], "new")?;
}
Shape::Rod { d, pitch, len, socket } => {
revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, 0.0), (0.0, 0.0)], "new")?;
if let Some((af, t)) = socket {
hex(s, af, 0.0, -t, "cut")?;
}
if let Some(p) = pitch {
thread(s, d, p, Vec3::new(d / 2.0, 0.0, -len / 2.0));
}
}
}
Ok(())
}
fn library(_s: &mut Session, p: &Value) -> Result<Value> {
let fam = |id: &str| -> Value {
let sizes: Vec<Value> = match id {
"socket_head_cap_screw" => SHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(),
"hex_bolt" => HEX_BOLT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "head_height": r.2})).collect(),
"hex_nut" => HEX_NUT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "height": r.2})).collect(),
"washer" => WASHER.iter().map(|r| json!({"size": r.0, "inner": r.1, "outer": r.2, "thickness": r.3})).collect(),
"set_screw" => SET_SCREW.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "socket": r.1, "socket_depth": r.2})).collect(),
"dowel_pin" => DOWEL.iter().map(|d| json!({"size": format!("{d}"), "d": d})).collect(),
"bearing" => BEARINGS.iter().map(|r| json!({"size": r.0, "bore": r.1, "outer": r.2, "width": r.3})).collect(),
_ => Vec::new(),
};
let (_, name, standard) = FAMILIES.iter().find(|f| f.0 == id).copied().unwrap_or(("", "", ""));
let lengths = matches!(id, "socket_head_cap_screw" | "hex_bolt" | "set_screw" | "dowel_pin").then_some(LENGTHS.to_vec());
json!({"family": id, "name": name, "standard": standard, "sizes": sizes, "lengths": lengths})
};
match str_(p, "family") {
Some(f) if FAMILIES.iter().any(|x| x.0 == f) => Ok(fam(f)),
Some(f) => Err(bad("parts.library", format!("unknown part family `{f}`"))),
None => Ok(json!({"families": FAMILIES.iter().map(|f| fam(f.0)).collect::<Vec<_>>()})),
}
}
fn part_name(family: &str, size: &str, sh: &Shape) -> String {
match sh {
Shape::Screw { len, .. } | Shape::Rod { len, .. } => format!("{} {size}x{len}", label(family)),
_ => format!("{} {size}", label(family)),
}
}
fn label(family: &str) -> &'static str {
match family {
"socket_head_cap_screw" => "SHCS",
"hex_bolt" => "Hex Bolt",
"hex_nut" => "Hex Nut",
"washer" => "Washer",
"set_screw" => "Set Screw",
"dowel_pin" => "Dowel Pin",
_ => "Bearing",
}
}
fn length_param(p: &Value, cmd: &str) -> Result<Option<f64>> {
match p.get("length") {
None | Some(Value::Null) => Ok(None),
Some(_) => {
let l = num(p, "length").filter(|l| l.is_finite() && *l > 0.0 && *l <= 2000.0).ok_or_else(|| bad(cmd, "`length` must be 0…2000 mm"))?;
Ok(Some(l))
}
}
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parts.insert (Insert Fastener)";
let family = str_(p, "family").ok_or_else(|| bad(cmd, "`family` is required (parts.library)"))?.to_string();
let size = str_(p, "size").ok_or_else(|| bad(cmd, "`size` is required"))?.to_string();
let sh = shape(&family, &size, length_param(p, cmd)?).map_err(|e| bad(cmd, e))?;
// A pick on a hole's rim also touches its end face: step onto the wall just inside the rim.
let at = p.get("at").and_then(vec3).map(|a| hole_wall(s, a).unwrap_or(a));
// The occurrence the hole belongs to, found before the part exists.
let host = match at {
Some(a) => Some(super::joints::occurrence_at(s, a).ok_or_else(|| bad(cmd, "no body at `at` (pick a hole's rim or wall)"))?),
None => None,
};
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| part_name(&family, &size, &sh));
let active = s.active_component;
let c = run(s, "component.create", json!({"name": name}))?;
let (comp, occ) = (c["component"].as_u64().unwrap_or(0), c["occurrence"].as_u64().unwrap_or(0));
build(s, &sh)?;
s.doc_mut().parts.insert(comp, solvecraft_doc::StandardPart { family: family.clone(), size: size.clone(), length: shape_len(&sh) });
run(s, "component.activate", json!({"component": active}))?;
let mut out = json!({"component": comp, "occurrence": occ, "name": name});
if let (Some(a), Some(h)) = (at, host) {
// Seat it: its axis on the hole's, its seating plane on the hole's face, the shank going
// into the hole (the joint mates the part's −z with the face's outward z).
let j = run(
s,
"joint.create",
json!({"type": "rigid", "a": {"occurrence": h, "circle": a}, "b": {"occurrence": occ, "point": [0, 0, 0], "z": [0, 0, -1]}, "name": format!("{name} seat")}),
)?;
out["joint"] = j["joint"].clone();
} else if let Some(pt) = p.get("point").and_then(vec3) {
// Placed at a point, its +z along `direction`.
let dir = p.get("direction").and_then(vec3).and_then(|d| d.normalized()).unwrap_or(Vec3::Z);
let m = solvecraft_doc::rigid(
Vec3::ZERO,
Vec3::ZERO,
Vec3::Z.cross(dir).normalized().unwrap_or(Vec3::X),
Vec3::Z.dot(dir).clamp(-1.0, 1.0).acos(),
);
let mut m = m;
m[3] = [pt.x, pt.y, pt.z, 1.0];
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == occ) {
o.transform = m;
}
}
Ok(out)
}
/// A point on the cylindrical wall of the hole at `a`, a little in from the picked rim.
fn hole_wall(s: &Session, a: Vec3) -> Option<Vec3> {
let st = s.model.state();
let dirs = [Vec3::Z * -1.0, Vec3::Z, Vec3::X, Vec3::X * -1.0, Vec3::Y, Vec3::Y * -1.0];
for step in [0.2, 0.5, 1.0, 2.0] {
for b in &st.bodies {
for d in dirs {
let q = a + d * step;
if let Some(c) = solvecraft_doc::kernel::cylinder_face_at(&b.body, q) {
let v = q - c.axis_point;
let along = v.dot(c.axis);
let on_wall = ((v - c.axis * along).len() - c.radius).abs() < 1e-3 && along > c.start && along < c.end;
if on_wall {
return Some(q);
}
}
}
}
}
None
}
fn shape_len(sh: &Shape) -> Option<f64> {
match sh {
Shape::Screw { len, .. } | Shape::Rod { len, .. } => Some(*len),
_ => None,
}
}
fn resize(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parts.resize";
let key = match p.get("component") {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => return Err(bad(cmd, "`component` must be an id or a name")),
};
let comp = s.doc.find_component(&key).ok_or_else(|| bad(cmd, format!("no component `{key}`")))?;
let part = s.doc.parts.get(&comp).cloned().ok_or_else(|| bad(cmd, "that component is not a standard part"))?;
let size = str_(p, "size").map(str::to_string).unwrap_or(part.size.clone());
let length = length_param(p, cmd)?.or(if size == part.size { part.length } else { None });
let sh = shape(&part.family, &size, length).map_err(|e| bad(cmd, e))?;
// Rebuild: the component's features go, the new shape is built in their place.
let ids: Vec<u64> = s.doc.features.iter().filter(|f| f.component == comp).map(|f| f.id).collect();
for id in ids.iter().rev() {
let _ = s.doc_mut().delete_feature(*id);
}
let active = s.active_component;
run(s, "component.activate", json!({"component": comp}))?;
build(s, &sh)?;
let name = part_name(&part.family, &size, &sh);
s.doc_mut().parts.insert(comp, solvecraft_doc::StandardPart { family: part.family.clone(), size: size.clone(), length: shape_len(&sh) });
if let Some(c) = s.doc_mut().components.iter_mut().find(|c| c.id == comp) {
c.name = name.clone();
}
run(s, "component.activate", json!({"component": active}))?;
s.refresh();
if let Some(e) = s.model.results.iter().find_map(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
Ok(json!({"component": comp, "name": name, "size": size, "length": shape_len(&sh)}))
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn bbox(s: &Session, prefix: &str) -> (solvecraft_geom::Vec3, solvecraft_geom::Vec3) {
let st = s.world_state();
let comp = s.doc.components.iter().find(|c| c.name.starts_with(prefix)).map(|c| c.id).unwrap_or(0);
let (mut lo, mut hi) = (solvecraft_geom::Vec3::new(f64::MAX, f64::MAX, f64::MAX), solvecraft_geom::Vec3::new(f64::MIN, f64::MIN, f64::MIN));
for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp) {
let m = b.mesh().bounds();
lo = solvecraft_geom::Vec3::new(lo.x.min(m.min.x), lo.y.min(m.min.y), lo.z.min(m.min.z));
hi = solvecraft_geom::Vec3::new(hi.x.max(m.max.x), hi.y.max(m.max.y), hi.z.max(m.max.z));
}
(lo, hi)
}
#[test]
fn dimensions_follow_the_standard_tables() {
let l = run(&mut Session::default(), "parts.library", json!({"family": "socket_head_cap_screw"}));
let m6 = l["sizes"].as_array().and_then(|a| a.iter().find(|x| x["size"] == "M6")).cloned().unwrap_or_default();
assert_eq!((m6["head_diameter"].as_f64(), m6["head_height"].as_f64(), m6["socket"].as_f64()), (Some(10.0), Some(6.0), Some(5.0)));
// An M6 x 20 cap screw: head Ø10 x 6, shank 20 long.
let mut s = Session::default();
run(&mut s, "parts.insert", json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20}));
let (lo, hi) = bbox(&s, "SHCS M6x20");
assert!((hi.x - lo.x - 10.0).abs() < 1e-6 && (lo.z + 20.0).abs() < 1e-6 && (hi.z - 6.0).abs() < 1e-6, "{lo:?} {hi:?}");
assert!(s.model.state().threads.iter().any(|t| t.designation.starts_with("M6")), "{:?}", s.model.state().threads);
// An M8 nut: 13 across flats (14.43 across corners), 6.8 high.
let mut n = Session::default();
run(&mut n, "parts.insert", json!({"family": "hex_nut", "size": "M8"}));
let (lo, hi) = bbox(&n, "Hex Nut M8");
let (w, h) = ((hi.x - lo.x).max(hi.y - lo.y), hi.z - lo.z);
assert!((w - 13.0 / (std::f64::consts::PI / 6.0).cos()).abs() < 1e-6 && (h - 6.8).abs() < 1e-6, "{w} {h}");
// A 608 bearing: 8 x 22 x 7.
let mut b = Session::default();
run(&mut b, "parts.insert", json!({"family": "bearing", "size": "608"}));
let (lo, hi) = bbox(&b, "Bearing 608");
assert!((hi.x - lo.x - 22.0).abs() < 1e-6 && (hi.z - lo.z - 7.0).abs() < 1e-6);
assert!(b.execute("parts.insert", &json!({"family": "hex_nut", "size": "M7"})).is_err());
}
#[test]
fn a_screw_seats_in_a_hole_and_resizes() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
run(&mut s, "solid.hole", json!({"position": [20, 15, 10], "diameter": 6.4}));
// Pick the hole's rim at the top face.
let r = run(&mut s, "parts.insert", json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20, "at": [23.2, 15, 10]}));
assert!(r["joint"].is_number(), "{r}");
let (lo, hi) = bbox(&s, "SHCS M6x20");
// The head sits flush on the top face, the shank down the hole's axis.
assert!((hi.z - 16.0).abs() < 1e-6 && (lo.z + 10.0).abs() < 1e-6, "{lo:?} {hi:?}");
assert!(((lo.x + hi.x) / 2.0 - 20.0).abs() < 1e-6 && ((lo.y + hi.y) / 2.0 - 15.0).abs() < 1e-6);
// M8 x 25: rebuilt in place, still seated.
let comp = r["component"].as_u64().unwrap_or(0);
run(&mut s, "parts.resize", json!({"component": comp, "size": "M8", "length": 25}));
let (lo, hi) = bbox(&s, "SHCS M8x25");
assert!((hi.z - 18.0).abs() < 1e-6 && (lo.z + 15.0).abs() < 1e-6, "{lo:?} {hi:?}");
}
}
+28 -56
View File
@@ -12,7 +12,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -22,12 +22,12 @@ fn rel(a: f64, b: f64) -> f64 {
/// A 100 x 50 x 5 plate with a hole at the start of a straight path (10,10) → (90,10).
fn plate_with_hole() -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 100, "width": 50, "height": 5, "corner": [0, 0, 0]}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [90, 10]], "ids": ["l1"]}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 2, "id": "c1"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Path", "through_all": true, "operation": "cut", "name": "Hole1"}));
run(&mut s, "PrimitiveBox", json!({"length": 100, "width": 50, "height": 5, "corner": [0, 0, 0]}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [90, 10]], "ids": ["l1"]}));
run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 2, "id": "c1"}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "Path", "through_all": true, "operation": "cut", "name": "Hole1"}));
s
}
@@ -38,25 +38,25 @@ const HOLE: f64 = PI * 4.0 * 5.0;
fn pattern_on_path_spacing_extent_and_flip() {
let mut s = plate_with_hole();
assert!(rel(volume(&mut s), PLATE - HOLE) < 1e-4);
run(&mut s, "solid.pattern.path", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 5, "spacing": 20}));
run(&mut s, "PatternOnPath", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 5, "spacing": 20}));
assert!(rel(volume(&mut s), PLATE - 5.0 * HOLE) < 1e-4);
// The count is a parameter like any other input.
let n = s.doc.features.last().and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "parameters.change", json!({"name": n, "expression": "3"}));
run(&mut s, "ChangeParameterCommand", json!({"name": n, "expression": "3"}));
assert!(rel(volume(&mut s), PLATE - 3.0 * HOLE) < 1e-4);
// Instances past the end of the path are an error.
run(&mut s, "parameters.change", json!({"name": n, "expression": "6"}));
run(&mut s, "ChangeParameterCommand", json!({"name": n, "expression": "6"}));
let err = s.model.results.last().and_then(|r| r.error.clone()).unwrap_or_default();
assert!(err.contains("past the end of the path"), "{err}");
// Distance = first to last instance.
let mut s = plate_with_hole();
run(&mut s, "solid.pattern.path", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 3, "distance": 80}));
run(&mut s, "PatternOnPath", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 3, "distance": 80}));
assert!(rel(volume(&mut s), PLATE - 3.0 * HOLE) < 1e-4);
// Flipped, the path runs from (90,10) back toward (10,10).
let mut s = plate_with_hole();
run(&mut s, "solid.pattern.path", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 2, "spacing": 30, "flip": true}));
run(&mut s, "PatternOnPath", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 2, "spacing": 30, "flip": true}));
// The copy moves by path(30) − path(0) = (60,10) − (90,10) = −30: off the plate side → only 1 hole.
assert!(rel(volume(&mut s), PLATE - HOLE) < 1e-4);
}
@@ -64,47 +64,47 @@ fn pattern_on_path_spacing_extent_and_flip() {
#[test]
fn patterns_of_bodies() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cube"}));
run(&mut s, "solid.pattern.circular", json!({"bodies": ["Cube"], "axis": "Z", "count": 4}));
let m = run(&mut s, "inspect.measure", json!({}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cube"}));
run(&mut s, "PatternCircular", json!({"bodies": ["Cube"], "axis": "Z", "count": 4}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert_eq!(m["body_count"], 4, "{m}");
assert!(rel(volume(&mut s), 4000.0) < 1e-4);
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["Cube"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
run(&mut s, "PatternRectangular", json!({"bodies": ["Cube"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
assert!(rel(volume(&mut s), 6000.0) < 1e-4);
assert!(s.execute("solid.pattern.rectangular", &json!({"bodies": ["Nope"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 5})).is_err());
assert!(s.execute("PatternRectangular", &json!({"bodies": ["Nope"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 5})).is_err());
}
#[test]
fn patterns_of_components_place_occurrences() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Peg"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "Peg"}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let r = run(&mut s, "solid.pattern.rectangular", json!({"components": ["Peg"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
let r = run(&mut s, "PatternRectangular", json!({"components": ["Peg"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
assert_eq!(r["occurrences"].as_array().map(Vec::len), Some(2), "{r}");
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert_eq!(m["body_count"], 3, "{m}");
let zs: Vec<f64> = m["bodies"].as_array().unwrap().iter().filter_map(|b| b["bbox"]["min"][2].as_f64()).collect();
for z in [0.0, 20.0, 40.0] {
assert!(zs.iter().any(|x| (x - z).abs() < 1e-6), "{zs:?}");
}
assert!(s.execute("solid.pattern.circular", &json!({"components": ["Nope"], "axis": "Z", "count": 2})).is_err());
assert!(s.execute("PatternCircular", &json!({"components": ["Nope"], "axis": "Z", "count": 2})).is_err());
}
#[test]
fn path_pattern_orientation_follows_the_path() {
let width_of_copy = |orient: &str| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 2, "width": 4, "height": 2, "corner": [19, -2, 0], "body_name": "Tab"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Arc"}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [20, 0], "sweep": 90, "id": "a1"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "PrimitiveBox", json!({"length": 2, "width": 4, "height": 2, "corner": [19, -2, 0], "body_name": "Tab"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Arc"}));
run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 0], "start": [20, 0], "sweep": 90, "id": "a1"}));
run(&mut s, "SketchStop", json!({}));
run(
&mut s,
"solid.pattern.path",
"PatternOnPath",
json!({"bodies": ["Tab"], "path_sketch": "Arc", "path": ["a1"], "count": 2, "distance": "pi * 10", "orientation": orient}),
);
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] != "Tab").unwrap().clone();
let (lo, hi) = (b["bbox"]["min"].clone(), b["bbox"]["max"].clone());
// The copy sits at the arc's end (0, 20).
@@ -114,31 +114,3 @@ fn path_pattern_orientation_follows_the_path() {
assert!((width_of_copy("identical") - 2.0).abs() < 1e-6);
assert!((width_of_copy("path") - 4.0).abs() < 1e-6);
}
/// Patterns of a blind hole drill every copy to the full depth from the face at the copy
/// (QA s30): n copies remove n times one hole.
#[test]
fn patterns_of_a_blind_hole_drill_every_copy() {
let one = {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 25, "height": 15}));
let v0 = volume(&mut s);
run(&mut s, "solid.hole", json!({"position": [10, 12, 15], "diameter": "5 mm", "depth": "10 mm"}));
v0 - volume(&mut s)
};
let patterned = |p: (&str, Value)| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 25, "height": 15}));
let v0 = volume(&mut s);
run(&mut s, "solid.hole", json!({"position": [10, 12, 15], "diameter": "5 mm", "depth": "10 mm"}));
run(&mut s, p.0, p.1);
v0 - volume(&mut s)
};
let r = patterned(("solid.pattern.rectangular", json!({"features": ["Hole1"], "dir1": [1, 0, 0], "count1": 3, "spacing1": "15 mm"})));
assert!((r - 3.0 * one).abs() < 1e-3 * one, "{r} vs 3 × {one}");
let c = patterned((
"solid.pattern.circular",
json!({"features": ["Hole1"], "axis": {"origin": [25, 12, 0], "dir": [0, 0, 1]}, "count": 2, "angle": 360}),
));
assert!((c - 2.0 * one).abs() < 1e-3 * one, "{c} vs 2 × {one}");
}
+50 -50
View File
@@ -12,26 +12,26 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("plastic.boss", "Boss", boss).at("PLASTIC", "CREATE").icon("boss").params(
CommandSpec::new("FusionBossCommand", "Boss", boss).at("PLASTIC", "CREATE").icon("boss").params(
"position: [x,y,z] on a face; direction? (default: out of the face); diameter, height; hole_diameter?, hole_depth? (default: to the face); \
draft? (angle), fillet? (root radius); ribs? (count), rib_thickness?, rib_length? (out from the wall), rib_offset? (below the top); body?",
),
CommandSpec::new("plastic.lip", "Lip", lip).at("PLASTIC", "CREATE").icon("lip").params(
CommandSpec::new("FusionLipCommand", "Lip", lip).at("PLASTIC", "CREATE").icon("lip").params(
"face: [x,y,z] on the rim face of a shelled body; width, height; type?: lip|groove; gap? (groove clearance); side?: inside|outside (which rim edge); body?",
),
CommandSpec::new("plastic.snap_fit", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap").params(
CommandSpec::new("FusionSnapFitCommand", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap").params(
"position: [x,y,z] on a face; direction? (default: out of the face); hook: [x,y,z] (the catch's direction); length, thickness, width, catch_depth, catch_length; body?",
),
CommandSpec::new("plastic.rest", "Rest", rest).at("PLASTIC", "CREATE").icon("rest").params(
CommandSpec::new("FusionRestCommand", "Rest", rest).at("PLASTIC", "CREATE").icon("rest").params(
"position: [x,y,z] on a face (the rest's centre); direction? (default: out of the face); width (round: the diameter); length? (a rectangle, along `along`); along?: [x,y,z]; \
height; draft? (angle; default: the body's plastic rule); thickness? (wall of a hollow rest); body?",
),
CommandSpec::new("plastic.manage_rules", "Manage Plastic Rules", manage_rules).at("PLASTIC", "SETUP").icon("plastic_rule").params(
CommandSpec::new("FusionManagePlasticRuleCommand", "Manage Plastic Rules", manage_rules).at("PLASTIC", "SETUP").icon("plastic_rule").params(
"name (library or new rule; a new one starts from `from`, default ABS (1.5mm)); from?; material?; thickness?, nominal_radius?, clearance?, knife_edge?, \
reveal_height?, thickness_variation?, draft?, max_thickness?, min_thickness?, min_draft? (expressions; `Thickness` is the rule's); active?: bool (rule for bodies without one); \
delete?: bool (a design rule; a library rule goes back to the library values)",
),
CommandSpec::new("plastic.assign_rule", "Assign Plastic Rule", assign_rule).at("PLASTIC", "SETUP").icon("plastic_assign").params(
CommandSpec::new("FusionAssignPlasticRuleCommand", "Assign Plastic Rule", assign_rule).at("PLASTIC", "SETUP").icon("plastic_assign").params(
"bodies: [names]; rule: name (empty: no rule) — plastic features on these bodies take its draft and clearance as defaults",
),
CommandSpec::new("plastic.rules", "List Plastic Rules", list_rules).noundo().params("→ rules with their expressions and values, the active rule and the rules assigned to bodies"),
@@ -53,7 +53,7 @@ fn rule_json(s: &Session, r: &PlasticRule) -> Value {
}
fn manage_rules(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plastic.manage_rules";
let cmd = "FusionManagePlasticRuleCommand";
let name = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
if bool_(p, "delete").unwrap_or(false) {
let doc = s.doc_mut();
@@ -116,7 +116,7 @@ fn manage_rules(s: &mut Session, p: &Value) -> Result<Value> {
}
fn assign_rule(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plastic.assign_rule";
let cmd = "FusionAssignPlasticRuleCommand";
let bodies = string_list(p, "bodies");
if bodies.is_empty() || bodies.len() > 10_000 {
return Err(bad(cmd, "`bodies` must list bodies"));
@@ -147,7 +147,7 @@ fn list_rules(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn rest(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plastic.rest";
let cmd = "FusionRestCommand";
let (position, direction) = placed(s, p, cmd)?;
let along = match p.get("along") {
Some(v) => Some(
@@ -199,7 +199,7 @@ fn placed(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3)> {
}
fn boss(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plastic.boss";
let cmd = "FusionBossCommand";
let (position, direction) = placed(s, p, cmd)?;
let kind = FeatureKind::Boss {
position,
@@ -220,7 +220,7 @@ fn boss(s: &mut Session, p: &Value) -> Result<Value> {
}
fn lip(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plastic.lip";
let cmd = "FusionLipCommand";
let face = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on the rim face"))?;
let groove = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() {
None | Some("lip") => false,
@@ -245,7 +245,7 @@ fn lip(s: &mut Session, p: &Value) -> Result<Value> {
}
fn snap_fit(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "plastic.snap_fit";
let cmd = "FusionSnapFitCommand";
let (position, direction) = placed(s, p, cmd)?;
let hook = p.get("hook").and_then(vec3).and_then(|h| h.normalized()).ok_or_else(|| bad(cmd, "`hook` must be the catch's direction [x, y, z]"))?;
if hook.cross(direction).len() < 1e-6 {
@@ -281,7 +281,7 @@ mod tests {
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -291,17 +291,17 @@ mod tests {
#[test]
fn boss_with_hole_fillet_and_ribs() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 60, "width": 60, "height": 3, "body_name": "Plate"}));
run(&mut s, "plastic.boss", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 4}));
run(&mut s, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3, "body_name": "Plate"}));
run(&mut s, "FusionBossCommand", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 4}));
let want = 10800.0 + PI * 25.0 * 12.0 - PI * 4.0 * 12.0;
assert!(rel(volume(&mut s), want) < 1e-3, "{} {want}", volume(&mut s));
assert_eq!(run(&mut s, "inspect.measure", json!({}))["body_count"], 1);
assert_eq!(run(&mut s, "MeasureCommand", json!({}))["body_count"], 1);
// Ribs add t·l·h each.
let mut t = Session::default();
run(&mut t, "solid.box", json!({"length": 60, "width": 60, "height": 3}));
run(&mut t, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3}));
run(
&mut t,
"plastic.boss",
"FusionBossCommand",
json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 6, "rib_offset": 2}),
);
let ribs = 4.0 * 1.5 * 6.0 * 10.0;
@@ -309,52 +309,52 @@ mod tests {
let v = volume(&mut t) - 10800.0 - PI * 25.0 * 12.0;
assert!(v > ribs && v < ribs * 1.05, "{v} {ribs}");
let mut u = Session::default();
run(&mut u, "solid.box", json!({"length": 60, "width": 60, "height": 3}));
run(&mut u, "plastic.boss", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "fillet": 2, "draft": 1}));
run(&mut u, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3}));
run(&mut u, "FusionBossCommand", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "fillet": 2, "draft": 1}));
assert!(volume(&mut u) > 10800.0 + PI * 25.0 * 10.0);
assert!(u.execute("plastic.boss", &json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 20})).is_err());
assert!(u.execute("FusionBossCommand", &json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 20})).is_err());
}
#[test]
fn lip_and_groove_on_a_shelled_box() {
let shelled = || {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
run(&mut s, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
run(&mut s, "PrimitiveBox", json!({"length": 80, "width": 50, "height": 30}));
run(&mut s, "FusionShellBodyCommand", json!({"faces": [[40, 25, 30]], "thickness": 2}));
s
};
let mut s = shelled();
let v0 = volume(&mut s);
run(&mut s, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2}));
run(&mut s, "FusionLipCommand", json!({"face": [1, 25, 30], "width": 1, "height": 2}));
// Inside band 1 wide along the inner edge (76 x 46): (76+2)(46+2) − 76·46 = 248, 2 high.
assert!(rel(volume(&mut s) - v0, 248.0 * 2.0) < 1e-6, "{}", volume(&mut s) - v0);
let mut g = shelled();
run(&mut g, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
run(&mut g, "FusionLipCommand", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
// Outside band along the outer edge (80 x 50): 80·50 − 78·48 = 256, 2 deep.
assert!(rel(v0 - volume(&mut g), 256.0 * 2.0) < 1e-6, "{}", v0 - volume(&mut g));
assert!(g.execute("plastic.lip", &json!({"face": [40, 25, 0], "width": 1, "height": 2})).is_err(), "a face without an inner edge");
assert!(g.execute("FusionLipCommand", &json!({"face": [40, 25, 0], "width": 1, "height": 2})).is_err(), "a face without an inner edge");
}
#[test]
fn snap_fit_arm_and_catch() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 4}));
run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 40, "height": 4}));
let v0 = volume(&mut s);
run(
&mut s,
"plastic.snap_fit",
"FusionSnapFitCommand",
json!({"position": [20, 20, 4], "hook": [1, 0, 0], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3}),
);
let want = 1.5 * 15.0 * 6.0 + 0.5 * 1.0 * 3.0 * 6.0;
assert!(rel(volume(&mut s) - v0, want) < 1e-6, "{}", volume(&mut s) - v0);
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert!((m["bodies"][0]["bbox"]["max"][0].as_f64().unwrap_or(0.0) - 40.0).abs() < 1e-9);
assert!(s.execute("plastic.snap_fit", &json!({"position": [5, 5, 4], "direction": [0, 0, 1], "hook": [0, 0, 1], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3})).is_err());
assert!(s.execute("FusionSnapFitCommand", &json!({"position": [5, 5, 4], "direction": [0, 0, 1], "hook": [0, 0, 1], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3})).is_err());
}
fn plate() -> (Session, f64) {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 60, "width": 60, "height": 3}));
run(&mut s, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3}));
let v0 = volume(&mut s);
(s, v0)
}
@@ -363,27 +363,27 @@ mod tests {
fn rest_pads() {
// Rectangle 20 x 10, 4 high: 800.
let (mut s, v0) = plate();
run(&mut s, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "length": 20, "along": [1, 0, 0], "height": 4}));
run(&mut s, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "length": 20, "along": [1, 0, 0], "height": 4}));
assert!(rel(volume(&mut s) - v0, 800.0) < 1e-6, "{}", volume(&mut s) - v0);
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert!((m["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 7.0).abs() < 1e-9);
// Round, diameter 10: π·25·4.
let (mut r, v0) = plate();
run(&mut r, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "height": 4}));
run(&mut r, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "height": 4}));
assert!(rel(volume(&mut r) - v0, PI * 25.0 * 4.0) < 1e-3, "{}", volume(&mut r) - v0);
// Hollow, wall 1: (20·10 − 18·8)·4 = 224.
let (mut h, v0) = plate();
run(&mut h, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "thickness": 1}));
run(&mut h, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "thickness": 1}));
assert!(rel(volume(&mut h) - v0, 224.0) < 1e-6, "{}", volume(&mut h) - v0);
// Drafted 2°: the section shrinks by 2·s·tan 2° each way at height s.
let (mut d, v0) = plate();
run(&mut d, "plastic.rest", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "draft": "2 deg"}));
run(&mut d, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "draft": "2 deg"}));
let a = 2f64.to_radians().tan();
let want = 800.0 - a * 30.0 * 16.0 + 4.0 / 3.0 * a * a * 64.0;
assert!(rel(volume(&mut d) - v0, want) < 1e-6, "{} {want}", volume(&mut d) - v0);
// A wall thicker than half the width, or a draft that closes the top, is refused.
assert!(d.execute("plastic.rest", &json!({"position": [10, 10, 3], "width": 4, "height": 4, "thickness": 2})).is_err());
assert!(d.execute("plastic.rest", &json!({"position": [10, 10, 3], "width": 4, "height": 40, "draft": 10})).is_err());
assert!(d.execute("FusionRestCommand", &json!({"position": [10, 10, 3], "width": 4, "height": 4, "thickness": 2})).is_err());
assert!(d.execute("FusionRestCommand", &json!({"position": [10, 10, 3], "width": 4, "height": 40, "draft": 10})).is_err());
}
#[test]
@@ -395,31 +395,31 @@ mod tests {
assert_eq!(list["rules"][0]["values"]["nominal_radius"], 0.75);
assert_eq!(list["rules"][3]["values"]["thickness"].as_f64().map(|t| (t - 2.54).abs() < 1e-9), Some(true));
// A new rule from ABS: Thickness-based values follow its thickness.
let r = run(&mut s, "plastic.manage_rules", json!({"name": "Thick ABS", "thickness": 3, "draft": "1.5 deg"}));
let r = run(&mut s, "FusionManagePlasticRuleCommand", json!({"name": "Thick ABS", "thickness": 3, "draft": "1.5 deg"}));
assert_eq!(r["rule"]["values"]["nominal_radius"], 1.5);
assert!(s.execute("plastic.manage_rules", &json!({"name": "Bad", "draft": "3 mm"})).is_err());
assert!(s.execute("FusionManagePlasticRuleCommand", &json!({"name": "Bad", "draft": "3 mm"})).is_err());
// Assigned to the plate: a boss without a draft takes the rule's 1.5°.
let body = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["name"].as_str().unwrap_or_default().to_string();
run(&mut s, "plastic.assign_rule", json!({"bodies": [body], "rule": "Thick ABS"}));
run(&mut s, "plastic.boss", json!({"position": [30, 30, 3], "diameter": 10, "height": 12}));
let body = run(&mut s, "MeasureCommand", json!({}))["bodies"][0]["name"].as_str().unwrap_or_default().to_string();
run(&mut s, "FusionAssignPlasticRuleCommand", json!({"bodies": [body], "rule": "Thick ABS"}));
run(&mut s, "FusionBossCommand", json!({"position": [30, 30, 3], "diameter": 10, "height": 12}));
let rt = 5.0 - 12.0 * 1.5f64.to_radians().tan();
let want = PI * 12.0 / 3.0 * (25.0 + 5.0 * rt + rt * rt);
assert!(rel(volume(&mut s) - v0, want) < 1e-3, "{} {want}", volume(&mut s) - v0);
// Editing the rule rebuilds the boss.
run(&mut s, "plastic.manage_rules", json!({"name": "Thick ABS", "draft": "0 deg"}));
run(&mut s, "FusionManagePlasticRuleCommand", json!({"name": "Thick ABS", "draft": "0 deg"}));
assert!(rel(volume(&mut s) - v0, PI * 25.0 * 12.0) < 1e-3, "{}", volume(&mut s) - v0);
assert!(s.execute("plastic.assign_rule", &json!({"bodies": [body], "rule": "Nope"})).is_err());
assert!(s.execute("plastic.assign_rule", &json!({"bodies": ["Nope"], "rule": "Thick ABS"})).is_err());
assert!(s.execute("FusionAssignPlasticRuleCommand", &json!({"bodies": [body], "rule": "Nope"})).is_err());
assert!(s.execute("FusionAssignPlasticRuleCommand", &json!({"bodies": ["Nope"], "rule": "Thick ABS"})).is_err());
}
#[test]
fn groove_takes_the_rule_clearance() {
let mut g = Session::default();
run(&mut g, "solid.box", json!({"length": 80, "width": 50, "height": 30}));
run(&mut g, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}));
run(&mut g, "PrimitiveBox", json!({"length": 80, "width": 50, "height": 30}));
run(&mut g, "FusionShellBodyCommand", json!({"faces": [[40, 25, 30]], "thickness": 2}));
let v0 = volume(&mut g);
run(&mut g, "plastic.manage_rules", json!({"name": "ABS (1.5mm)", "active": true}));
run(&mut g, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
run(&mut g, "FusionManagePlasticRuleCommand", json!({"name": "ABS (1.5mm)", "active": true}));
run(&mut g, "FusionLipCommand", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"}));
// Width and depth grow by the 0.1 clearance: (80·50 − 77.8·47.8)·2.1.
let want = (4000.0 - 77.8 * 47.8) * 2.1;
assert!(rel(v0 - volume(&mut g), want) < 1e-6, "{} {want}", v0 - volume(&mut g));
+2 -2
View File
@@ -8,14 +8,14 @@ use super::CommandSpec;
use crate::params::{bad, bool_, expr, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.section", "Section Analysis", section)
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("FusionHalfSectionViewCommand", "Section Analysis", section)
.at("SOLID", "INSPECT")
.icon("section")
.noundo()
.params("plane: XY|XZ|YZ | {origin, normal}; offset?: expr along the normal; flip?: bool; or clear: true")];
fn section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.section";
let cmd = "FusionHalfSectionViewCommand";
if bool_(p, "clear").unwrap_or(false) {
s.section = None;
s.revision += 1;
+15 -15
View File
@@ -13,31 +13,31 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sheet.flange", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params(
CommandSpec::new("FusionSheetMetalFlangeCommand", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params(
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \
edge: edges: [[x,y,z] on top/bottom edges] + height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs)",
),
CommandSpec::new("sheet.hem", "Hem", hem)
CommandSpec::new("FusionSheetMetalHemFlangeCommand", "Hem", hem)
.at("SHEET METAL", "CREATE")
.icon("hem")
.params("edges: [[x,y,z] on sheet edges]; length (to the outer face); gap? (inner radius, default: rule); flip?; body?"),
CommandSpec::new("sheet.fold", "Fold", fold).at("SHEET METAL", "CREATE").icon("fold").params(
CommandSpec::new("SheetMetalFoldCmd", "Fold", fold).at("SHEET METAL", "CREATE").icon("fold").params(
"line: sketch + curve (a sketch line on the sheet's base face; followed when the sketch changes) or points: [[x,y,z], [x,y,z]]; \
angle? (default 90 deg); radius? (default: rule); position?: centerline|start|end|mould (where the line sits in the bend, default centerline); \
fixed?: [x,y,z] on the side that stays (default: the larger side); flip? (fold the other way); body?",
),
CommandSpec::new("sheet.unfold", "Unfold", unfold).at("SHEET METAL", "MODIFY").icon("unfold").params("body? (default: the last sheet body) — all bends"),
CommandSpec::new("FusionSheetmetalUnfoldCommand", "Unfold", unfold).at("SHEET METAL", "MODIFY").icon("unfold").params("body? (default: the last sheet body) — all bends"),
CommandSpec::new("sheet.refold", "Refold", refold).at("SHEET METAL", "MODIFY").icon("refold").params("body? (default: the last unfolded sheet)"),
CommandSpec::new("sheet.convert", "Convert to Sheet Metal", convert)
CommandSpec::new("ConvertToSheetMetalCmd", "Convert to Sheet Metal", convert)
.at("SHEET METAL", "CREATE")
.icon("convert_sheet")
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
CommandSpec::new("FusionSheetMetalRulesCommand", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's); active?: bool; delete?: bool",
),
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().params("→ rules with their expressions and values, and the active one"),
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
CommandSpec::new("FusionSheetMetalFlatPatternCmd", "Create Flat Pattern", flat_pattern)
.at("SHEET METAL", "CREATE")
.icon("flat")
.noundo()
@@ -60,13 +60,13 @@ fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
match str_(p, "rule") {
Some(r) if s.doc.sheet.rules.iter().any(|x| x.name == r) || r == SheetRule::steel().name => Ok(Some(r.to_string())),
Some(r) => Err(bad(cmd, format!("no sheet metal rule `{r}` (sheet.manage_rules makes one)"))),
Some(r) => Err(bad(cmd, format!("no sheet metal rule `{r}` (FusionSheetMetalRulesCommand makes one)"))),
None => Ok(None),
}
}
fn flange(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.flange";
let cmd = "FusionSheetMetalFlangeCommand";
let ty = match str_(p, "type") {
Some(t) => t.to_ascii_lowercase(),
None if p.get("edges").is_some() => "edge".into(),
@@ -123,7 +123,7 @@ fn flange(s: &mut Session, p: &Value) -> Result<Value> {
}
fn hem(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.hem";
let cmd = "FusionSheetMetalHemFlangeCommand";
let edges = points(p, "edges", cmd)?;
let length = req_expr(cmd, p, "length")?;
check_expr(s, &length, Kind::Length, cmd, "length")?;
@@ -139,7 +139,7 @@ fn hem(s: &mut Session, p: &Value) -> Result<Value> {
}
fn fold(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.fold";
let cmd = "SheetMetalFoldCmd";
let (sketch, curve, a, b) = match p.get("points") {
Some(v) => {
let pts = v.as_array().map(|a| a.iter().filter_map(vec3).collect::<Vec<Vec3>>()).unwrap_or_default();
@@ -189,7 +189,7 @@ fn refold(s: &mut Session, p: &Value) -> Result<Value> {
}
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.convert";
let cmd = "ConvertToSheetMetalCmd";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
@@ -200,7 +200,7 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rules(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.manage_rules";
let cmd = "FusionSheetMetalRulesCommand";
let name = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
if bool_(p, "delete").unwrap_or(false) {
let doc = s.doc_mut();
@@ -289,7 +289,7 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
}
fn flat_pattern(s: &mut Session, p: &Value) -> Result<Value> {
let sh = sheet_of(s, p, "sheet.flat_pattern")?;
let sh = sheet_of(s, p, "FusionSheetMetalFlatPatternCmd")?;
flat_json(&sh)
}
@@ -369,7 +369,7 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
let sh = sheet_of(s, p, cmd)?;
let text = flat_dxf(&sh)?;
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "bends": sh.bends().len()}));
}
Ok(json!({"dxf": text, "bends": sh.bends().len()}))
+61 -107
View File
@@ -12,7 +12,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
@@ -20,15 +20,15 @@ fn rel(a: f64, b: f64) -> f64 {
}
fn flat(s: &mut Session) -> Value {
run(s, "sheet.flat_pattern", json!({}))
run(s, "FusionSheetMetalFlatPatternCmd", json!({}))
}
/// A w x h base flange on XY.
fn base(s: &mut Session, w: f64, h: f64) {
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [w, h]}));
run(s, "sketch.finish", json!({}));
run(s, "sheet.flange", json!({"sketch": "Base"}));
run(s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [w, h]}));
run(s, "SketchStop", json!({}));
run(s, "FusionSheetMetalFlangeCommand", json!({"sketch": "Base"}));
}
#[test]
@@ -36,70 +36,70 @@ fn base_and_edge_flange_follow_the_k_factor() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
assert!(rel(volume(&mut s), 15000.0) < 1e-9);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20}));
// Oracle 68: flat = 60 − 5 + (π/2)(2.5 + 0.44·2.5) + 20 − 5.
let want = 60.0 - 5.0 + FRAC_PI_2 * (2.5 + 0.44 * 2.5) + 15.0;
let f = flat(&mut s);
assert!((f["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6, "{f}");
assert_eq!(f["bends"][0]["direction"], "up");
assert!(rel(volume(&mut s), 18972.622) < 1e-4, "{}", volume(&mut s));
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert_eq!(m["total"]["faces"], 14, "{m}");
// The angle is a parameter: 45° (oracle 70 uses height 15).
let names = s.doc.features.last().map(|f| f.param_names.clone()).unwrap_or_default();
run(&mut s, "parameters.change", json!({"name": names[0], "expression": "15"}));
run(&mut s, "parameters.change", json!({"name": names[1], "expression": "45 deg"}));
run(&mut s, "ChangeParameterCommand", json!({"name": names[0], "expression": "15"}));
run(&mut s, "ChangeParameterCommand", json!({"name": names[1], "expression": "45 deg"}));
assert!(rel(volume(&mut s), 18450.777) < 1e-4, "{}", volume(&mut s));
assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - 73.685298).abs() < 1e-5);
// DXF: outline and an up bend line.
let d = run(&mut s, "sheet.export_dxf", json!({}));
let text = d["dxf"].as_str().unwrap_or_default();
assert!(text.contains("BEND_UP") && text.contains("OUTLINE"), "{text}");
assert!(s.execute("sheet.flange", &json!({"edges": [[50, 30, 40]], "height": 20})).is_err(), "not on an edge");
assert!(s.execute("FusionSheetMetalFlangeCommand", &json!({"edges": [[50, 30, 40]], "height": 20})).is_err(), "not on an edge");
}
#[test]
fn rules_are_parameters() {
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "Thick", "expression": "1.5 mm"}));
run(&mut s, "sheet.manage_rules", json!({"name": "Custom", "thickness": "Thick", "k_factor": 0.33, "bend_radius": "3 mm"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "Thick", "expression": "1.5 mm"}));
run(&mut s, "FusionSheetMetalRulesCommand", json!({"name": "Custom", "thickness": "Thick", "k_factor": 0.33, "bend_radius": "3 mm"}));
base(&mut s, 80.0, 50.0);
run(&mut s, "sheet.flange", json!({"edges": [[40, 50, 1.5], [40, 0, 1.5]], "height": 20}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[40, 50, 1.5], [40, 0, 1.5]], "height": 20}));
// Oracle 71: 50 − 9 + 2·(π/2)(3 + 0.495) + 2·15.5 = 82.980.
let want = 50.0 - 2.0 * 4.5 + 2.0 * FRAC_PI_2 * (3.0 + 0.33 * 1.5) + 2.0 * 15.5;
assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6);
assert!(rel(volume(&mut s), 10053.717) < 1e-4, "{}", volume(&mut s));
// Thicker: everything follows.
run(&mut s, "parameters.change", json!({"name": "Thick", "expression": "2 mm"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "Thick", "expression": "2 mm"}));
let want = 50.0 - 2.0 * 5.0 + 2.0 * FRAC_PI_2 * (3.0 + 0.33 * 2.0) + 2.0 * 15.0;
assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6);
let l = run(&mut s, "sheet.rules", json!({}));
assert_eq!(l["active"], "Custom", "{l}");
assert!(s.execute("sheet.manage_rules", &json!({"name": "Bad", "k_factor": 3})).is_err());
assert!(s.execute("FusionSheetMetalRulesCommand", &json!({"name": "Bad", "k_factor": 3})).is_err());
}
#[test]
fn contour_flange_hem_and_tray() {
// Oracle 72.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1]}, "name": "Contour"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, -30], [70, -30]], "ids": ["l1", "l2", "l3"]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Contour", "curves": ["l1", "l2", "l3"], "distance": 50}));
run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1]}, "name": "Contour"}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [40, 0], [40, -30], [70, -30]], "ids": ["l1", "l2", "l3"]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"sketch": "Contour", "curves": ["l1", "l2", "l3"], "distance": 50}));
assert!(rel(volume(&mut s), 12097.622) < 1e-4, "{}", volume(&mut s));
assert!((flat(&mut s)["flat_size_mm"][0].as_f64().unwrap_or(0.0) - 96.309734).abs() < 1e-5);
// Oracle 73: flange, then a flat hem of 8.
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "sheet.hem", json!({"edges": [[50, 0, 20]], "length": 8}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "FusionSheetMetalHemFlangeCommand", json!({"edges": [[50, 0, 20]], "length": 8}));
assert!(rel(volume(&mut s), 21334.723) < 1e-4, "{}", volume(&mut s));
let b = flat(&mut s)["bends"].clone();
assert!(b.as_array().is_some_and(|a| a.iter().any(|x| (x["angle_deg"].as_f64().unwrap_or(0.0) - 180.0).abs() < 1e-9)), "{b}");
// A tray: four flanges at once stop at each other's bends.
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.flange", json!({"edges": [[50, 60, 2.5], [100, 30, 2.5], [50, 0, 2.5], [0, 30, 2.5]], "height": 25}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 60, 2.5], [100, 30, 2.5], [50, 0, 2.5], [0, 30, 2.5]], "height": 25}));
let f = flat(&mut s);
// Oracle 69: 100 − 10 + 2·5.655 + 2·20 = 141.310 across.
assert!((f["flat_size_mm"][0].as_f64().unwrap_or(0.0) - (90.0 + 2.0 * FRAC_PI_2 * 3.6 + 40.0)).abs() < 1e-6, "{f}");
@@ -117,15 +117,15 @@ fn unfold_cut_refold_and_convert() {
// Oracle 74: two flanges, unfold, a slot across the bend, refold.
let mut s = Session::default();
base(&mut s, 80.0, 50.0);
run(&mut s, "sheet.flange", json!({"edges": [[40, 50, 2.5], [40, 0, 2.5]], "height": 20}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[40, 50, 2.5], [40, 0, 2.5]], "height": 20}));
let folded = volume(&mut s);
run(&mut s, "sheet.unfold", json!({}));
let m = run(&mut s, "inspect.measure", json!({}));
run(&mut s, "FusionSheetmetalUnfoldCommand", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert!((m["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 2.5).abs() < 1e-6, "{m}");
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [35, -12], "p1": [45, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [35, -12], "p1": [45, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut s, "sheet.refold", json!({}));
let v = volume(&mut s);
assert!(v < folded - 300.0, "{v} {folded}");
@@ -133,24 +133,24 @@ fn unfold_cut_refold_and_convert() {
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1));
// Convert a plate.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2.5, "body_name": "Plate"}));
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": [50, 30, 2.5]}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
run(&mut s, "PrimitiveBox", json!({"length": 100, "width": 60, "height": 2.5, "body_name": "Plate"}));
run(&mut s, "ConvertToSheetMetalCmd", json!({"body": "Plate", "face": [50, 30, 2.5]}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20}));
assert!(rel(volume(&mut s), 18972.622) < 1e-4, "{}", volume(&mut s));
let mut t = Session::default();
run(&mut t, "solid.cylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
assert!(t.execute("sheet.convert", &json!({"body": "Rod", "face": [0, 0, 30]})).is_err());
run(&mut t, "PrimitiveCylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
assert!(t.execute("ConvertToSheetMetalCmd", &json!({"body": "Rod", "face": [0, 0, 30]})).is_err());
}
/// A sketch line across a 100 x 60 plate's top face at x = 40.
fn fold_line(s: &mut Session) {
run(s, "sketch.create", json!({"plane": "XY", "offset": 2.5, "name": "FoldLine"}));
run(s, "sketch.line", json!({"points": [[40, 0], [40, 60]], "ids": ["fl"]}));
run(s, "sketch.finish", json!({}));
run(s, "SketchCreate", json!({"plane": "XY", "offset": 2.5, "name": "FoldLine"}));
run(s, "DrawPolyline", json!({"points": [[40, 0], [40, 60]], "ids": ["fl"]}));
run(s, "SketchStop", json!({}));
}
fn bbox(s: &mut Session) -> Value {
run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"].clone()
run(s, "MeasureCommand", json!({}))["bodies"][0]["bbox"].clone()
}
#[test]
@@ -160,7 +160,7 @@ fn fold_takes_the_bend_out_of_the_flat_sheet() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
fold_line(&mut s);
run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"}));
run(&mut s, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl"}));
// The flat pattern keeps its size; the bend's centre line is the sketch line.
let f = flat(&mut s);
assert!((f["flat_size_mm"][0].as_f64().unwrap_or(0.0) - 100.0).abs() < 1e-6, "{f}");
@@ -189,7 +189,7 @@ fn fold_positions_fixed_side_and_flip() {
for (k, v) in p.as_object().into_iter().flatten() {
q[k] = v.clone();
}
run(&mut s, "sheet.fold", q);
run(&mut s, "SheetMetalFoldCmd", q);
bbox(&mut s)
};
let x0 = |b: &Value| b["min"][0].as_f64().unwrap_or(f64::NAN);
@@ -215,10 +215,10 @@ fn fold_carries_flanges_on_the_moving_side() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
// A flange on the left edge, then a fold between it and the rest.
run(&mut s, "sheet.flange", json!({"edges": [[0, 30, 2.5]], "height": 20}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[0, 30, 2.5]], "height": 20}));
let v0 = volume(&mut s);
fold_line(&mut s);
run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl", "fixed": [90, 30, 2.5]}));
run(&mut s, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl", "fixed": [90, 30, 2.5]}));
// The flange rode along: it is above the plate now, nothing left at z < 0.
let b = bbox(&mut s);
assert!(b["min"][2].as_f64().unwrap_or(-1.0) > -1e-6, "{b}");
@@ -229,9 +229,9 @@ fn fold_carries_flanges_on_the_moving_side() {
// Errors: off the sheet, through the flange's bend.
let mut e = Session::default();
base(&mut e, 100.0, 60.0);
assert!(e.execute("sheet.fold", &json!({"points": [[40, 0, 30], [40, 60, 30]]})).is_err());
assert!(e.execute("sheet.fold", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "position": "sideways"})).is_err());
assert!(e.execute("sheet.fold", &json!({"points": [[0.5, 0, 2.5], [0.5, 60, 2.5]]})).is_err(), "no material past the bend");
assert!(e.execute("SheetMetalFoldCmd", &json!({"points": [[40, 0, 30], [40, 60, 30]]})).is_err());
assert!(e.execute("SheetMetalFoldCmd", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "position": "sideways"})).is_err());
assert!(e.execute("SheetMetalFoldCmd", &json!({"points": [[0.5, 0, 2.5], [0.5, 60, 2.5]]})).is_err(), "no material past the bend");
}
#[test]
@@ -240,12 +240,12 @@ fn fold_on_a_flange_panel() {
let ba = FRAC_PI_2 * (r + k * t);
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 30}));
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 30}));
let flat0 = flat(&mut s)["flat_size_mm"].clone();
let v0 = volume(&mut s);
// The flange stands up at the plate's y = 0 edge: its outer face is the body's min y.
let y = bbox(&mut s)["min"][1].as_f64().unwrap_or(f64::NAN);
run(&mut s, "sheet.fold", json!({"points": [[10, y, 18], [90, y, 18]]}));
run(&mut s, "SheetMetalFoldCmd", json!({"points": [[10, y, 18], [90, y, 18]]}));
let f = flat(&mut s);
for i in 0..2 {
assert!(
@@ -262,7 +262,7 @@ fn fold_on_a_flange_panel() {
let b = bbox(&mut s);
assert!(b["max"][2].as_f64().unwrap_or(99.0) < 25.0, "{b}");
// Folding across the flange's own bend is refused.
assert!(s.execute("sheet.fold", &json!({"points": [[50, y, 2], [50, y, 25]]})).is_err());
assert!(s.execute("SheetMetalFoldCmd", &json!({"points": [[50, y, 2], [50, y, 25]]})).is_err());
}
#[test]
@@ -270,77 +270,31 @@ fn fold_through_cut_outs() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
// A slot across the fold line (edges along and across the bend).
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [30, 20], "p1": [50, 40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Slot"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [30, 20], "p1": [50, 40]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
let mut plain = Session::default();
base(&mut plain, 100.0, 60.0);
fold_line(&mut plain);
run(&mut plain, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"}));
run(&mut plain, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl"}));
fold_line(&mut s);
run(&mut s, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"}));
run(&mut s, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl"}));
let f = flat(&mut s);
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1), "{f}");
let (v, vp) = (volume(&mut s), volume(&mut plain));
// The slot takes 20 x 20 of the flat sheet; across the bend it removes the bend's share.
assert!(v < vp - 900.0 && v > vp - 1100.0, "{v} {vp}");
run(&mut s, "sheet.unfold", json!({}));
run(&mut s, "FusionSheetmetalUnfoldCommand", json!({}));
assert!((volume(&mut s) - (15000.0 - 1000.0)).abs() < 1e-3, "{}", volume(&mut s));
// A round hole across the bend is not supported yet, and says so.
let mut c = Session::default();
base(&mut c, 100.0, 60.0);
run(&mut c, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
run(&mut c, "sketch.circle.center", json!({"center": [40, 30], "radius": 5}));
run(&mut c, "sketch.finish", json!({}));
run(&mut c, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "operation": "cut", "direction": "symmetric"}));
run(&mut c, "SketchCreate", json!({"plane": "XY", "name": "Hole"}));
run(&mut c, "CircleCenterRadius", json!({"center": [40, 30], "radius": 5}));
run(&mut c, "SketchStop", json!({}));
run(&mut c, "Extrude", json!({"sketch": "Hole", "through_all": true, "operation": "cut", "direction": "symmetric"}));
fold_line(&mut c);
let e = c.execute("sheet.fold", &json!({"sketch": "FoldLine", "curve": "fl"})).map(|_| ()).unwrap_err().to_string();
let e = c.execute("SheetMetalFoldCmd", &json!({"sketch": "FoldLine", "curve": "fl"})).map(|_| ()).unwrap_err().to_string();
assert!(e.contains("not supported yet"), "{e}");
}
#[test]
fn flange_edges_are_named_and_follow_the_base_sketch() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 100}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
run(&mut s, "sheet.flange", json!({"edges": [[100, 30, 2.5]], "height": 20, "name": "Side"}));
let side = s.doc.find_feature("Side").cloned().unwrap();
assert_eq!(side.edge_names, vec!["base:l2:top".to_string()]);
let v0 = volume(&mut s);
// Narrower base: the flange stays on the right edge (now at x = 40).
let name = d["param"].as_str().or_else(|| d["name"].as_str()).map(str::to_string).unwrap_or_else(|| "d1".into());
run(&mut s, "parameters.change", json!({"name": name, "expression": "40"}));
let b = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"].clone();
let w = b["max"][0].as_f64().unwrap_or(0.0) - b["min"][0].as_f64().unwrap_or(0.0);
assert!((w - 40.0).abs() < 1e-3 && b["max"][2].as_f64().unwrap_or(0.0) > 19.0, "{b}");
assert!(volume(&mut s) < v0);
let id = side.id;
assert_eq!(s.model.result(id).and_then(|r| r.warning.clone()), None);
// A hem on the flange's far edge is named after the flange.
let top = b["max"][2].as_f64().unwrap_or(0.0);
let x = b["max"][0].as_f64().unwrap_or(0.0);
run(&mut s, "sheet.hem", json!({"edges": [[x, 30, top]], "length": 6, "name": "H"}));
let h = s.doc.find_feature("H").cloned().unwrap();
assert!(h.edge_names[0].starts_with(&format!("F{id}.0:far")), "{:?}", h.edge_names);
}
/// With no fixed side, the larger side stays whichever side of the line it is on (QA s22).
#[test]
fn fold_keeps_the_larger_side_either_way() {
for (w, line, moving_right) in [(60.0, 40.0, true), (100.0, 40.0, false)] {
let mut s = Session::default();
base(&mut s, w, 40.0);
run(&mut s, "sheet.fold", json!({"points": [[line, 0, 2.5], [line, 40, 2.5]]}));
let b = bbox(&mut s);
let (x0, x1) = (b["min"][0].as_f64().unwrap_or(0.0), b["max"][0].as_f64().unwrap_or(0.0));
if moving_right {
assert!(x0.abs() < 1e-6 && x1 < line + 5.0, "the left (larger) part stays: {b}");
} else {
assert!((x1 - w).abs() < 1e-6 && x0 > line - 5.0, "the right (larger) part stays: {b}");
}
}
}
+65 -79
View File
@@ -10,136 +10,136 @@ use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, ve
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
CommandSpec::new("SketchCreate", "Create Sketch", create_sketch)
.at("SOLID", "CREATE")
.icon("sketch")
.params("plane: XY|XZ|YZ | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"),
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"),
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("sketch.line", "Line", draw_line)
CommandSpec::new("SketchActivate", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"),
CommandSpec::new("SketchStop", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("DrawPolyline", "Line", draw_line)
.at("SKETCH", "CREATE")
.icon("line")
.key("L")
.enabled(in_sketch)
.params("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)"),
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point)
CommandSpec::new("ShapeRectangleTwoPoint", "2-Point Rectangle", rect_two_point)
.at("SKETCH", "CREATE")
.icon("rect")
.key("R")
.enabled(in_sketch)
.params("p0, p1: opposite corners [x,y]; construction?"),
CommandSpec::new("sketch.rectangle.three_point", "3-Point Rectangle", rect_three_point)
CommandSpec::new("ShapeRectangleThreePoint", "3-Point Rectangle", rect_three_point)
.at("SKETCH", "CREATE")
.icon("rect3")
.enabled(in_sketch)
.params("p0, p1: first edge; p2: point on the opposite edge"),
CommandSpec::new("sketch.rectangle.center", "Center Rectangle", rect_center)
CommandSpec::new("ShapeRectangleCenter", "Center Rectangle", rect_center)
.at("SKETCH", "CREATE")
.icon("rect_center")
.enabled(in_sketch)
.params("center, corner"),
CommandSpec::new("sketch.circle.center", "Center Diameter Circle", circle_center)
CommandSpec::new("CircleCenterRadius", "Center Diameter Circle", circle_center)
.at("SKETCH", "CREATE")
.icon("circle")
.key("C")
.enabled(in_sketch)
.params("center: [x,y] or point ref, radius | diameter: number"),
CommandSpec::new("sketch.circle.two_point", "2-Point Circle", circle_two)
CommandSpec::new("CircleTwoPoint", "2-Point Circle", circle_two)
.at("SKETCH", "CREATE")
.icon("circle2")
.enabled(in_sketch)
.params("p0, p1: diameter ends"),
CommandSpec::new("sketch.circle.three_point", "3-Point Circle", circle_three)
CommandSpec::new("CircleThreePoint", "3-Point Circle", circle_three)
.at("SKETCH", "CREATE")
.icon("circle3")
.enabled(in_sketch)
.params("p0, p1, p2"),
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params("start, end, through"),
CommandSpec::new("sketch.arc.center_point", "Center Point Arc", arc_center)
CommandSpec::new("ArcThreePoint", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params("start, end, through"),
CommandSpec::new("ArcCenterTwoPoint", "Center Point Arc", arc_center)
.at("SKETCH", "CREATE")
.icon("arc_center")
.enabled(in_sketch)
.params("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees"),
CommandSpec::new("sketch.polygon.inscribed", "Inscribed Polygon", polygon_inscribed)
CommandSpec::new("ShapePolygonInscribed", "Inscribed Polygon", polygon_inscribed)
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params("center, radius (to vertices), sides, angle?: deg"),
CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed)
CommandSpec::new("ShapePolygonCircumscribed", "Circumscribed Polygon", polygon_circumscribed)
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params("center, radius (to edge midpoints), sides, angle?: deg"),
CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge)
CommandSpec::new("ShapePolygonEdge", "Edge Polygon", polygon_edge)
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params("p0, p1: one edge, sides"),
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c)
CommandSpec::new("ShapeSlotCenterToCenter", "Center to Center Slot", slot_c2c)
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params("p0, p1: arc centres, width"),
CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall)
CommandSpec::new("ShapeSlotOverall", "Overall Slot", slot_overall)
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params("p0, p1: overall ends, width"),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params("point: [x,y], id?"),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
CommandSpec::new("DrawPoint", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params("point: [x,y], id?"),
CommandSpec::new("SketchDimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current)",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
CommandSpec::new("ConstraintHorizontalVertical", "Horizontal/Vertical", c_horizontal_vertical)
.at("SKETCH", "CONSTRAINTS")
.icon("c_hv")
.enabled(in_sketch)
.params("line: id | points: [a, b]; mode?: horizontal|vertical"),
CommandSpec::new("sketch.constraint.coincident", "Coincident", c_coincident)
CommandSpec::new("ConstraintCoincident", "Coincident", c_coincident)
.at("SKETCH", "CONSTRAINTS")
.icon("c_coincident")
.enabled(in_sketch)
.params("a: point, b: point or curve"),
CommandSpec::new("sketch.constraint.tangent", "Tangent", c_tangent)
CommandSpec::new("ConstraintTangent", "Tangent", c_tangent)
.at("SKETCH", "CONSTRAINTS")
.icon("c_tangent")
.enabled(in_sketch)
.params("a, b: curves"),
CommandSpec::new("sketch.constraint.equal", "Equal", c_equal)
CommandSpec::new("ConstraintEqual", "Equal", c_equal)
.at("SKETCH", "CONSTRAINTS")
.icon("c_equal")
.enabled(in_sketch)
.params("a, b: lines or circles/arcs"),
CommandSpec::new("sketch.constraint.parallel", "Parallel", c_parallel)
CommandSpec::new("ConstraintParallel", "Parallel", c_parallel)
.at("SKETCH", "CONSTRAINTS")
.icon("c_parallel")
.enabled(in_sketch)
.params("a, b: lines"),
CommandSpec::new("sketch.constraint.perpendicular", "Perpendicular", c_perpendicular)
CommandSpec::new("ConstraintPerpendicular", "Perpendicular", c_perpendicular)
.at("SKETCH", "CONSTRAINTS")
.icon("c_perpendicular")
.enabled(in_sketch)
.params("a, b: lines"),
CommandSpec::new("sketch.constraint.fix", "Fix/UnFix", c_fix)
CommandSpec::new("ConstraintFix", "Fix/UnFix", c_fix)
.at("SKETCH", "CONSTRAINTS")
.icon("c_fix")
.enabled(in_sketch)
.params("entity: point or curve | entities: [points and curves]; fixed?: bool (default: fix them all unless all are fixed, then unfix)"),
CommandSpec::new("sketch.constraint.midpoint", "MidPoint", c_midpoint)
CommandSpec::new("ConstraintMidPoint", "MidPoint", c_midpoint)
.at("SKETCH", "CONSTRAINTS")
.icon("c_midpoint")
.enabled(in_sketch)
.params("point, line"),
CommandSpec::new("sketch.constraint.concentric", "Concentric", c_concentric)
CommandSpec::new("ConstraintConcentric", "Concentric", c_concentric)
.at("SKETCH", "CONSTRAINTS")
.icon("c_concentric")
.enabled(in_sketch)
.params("a, b: circles/arcs"),
CommandSpec::new("sketch.constraint.collinear", "Collinear", c_collinear)
CommandSpec::new("ConstraintCollinear", "Collinear", c_collinear)
.at("SKETCH", "CONSTRAINTS")
.icon("c_collinear")
.enabled(in_sketch)
.params("a, b: lines"),
CommandSpec::new("sketch.constraint.symmetry", "Symmetry", c_symmetry)
CommandSpec::new("ConstraintSymmetry", "Symmetry", c_symmetry)
.at("SKETCH", "CONSTRAINTS")
.icon("c_symmetry")
.enabled(in_sketch)
@@ -363,11 +363,11 @@ fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> boo
}
fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.create";
let cmd = "SketchCreate";
let plane = plane_ref(s, p, cmd)?;
// Validate the plane resolves.
let (vals, _) = s.doc.param_values();
s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
s.doc.resolve_plane(&vals, &plane, 0)?;
let name = str_(p, "name");
let mut kind = FeatureKind::Sketch { plane, sketch: Sketch::new() };
super::component::to_active_frame(s, &mut kind);
@@ -393,11 +393,11 @@ fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
let key = match p.get("sketch") {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => return Err(bad("sketch.edit", "`sketch` must be an id or a name")),
_ => return Err(bad("SketchActivate", "`sketch` must be an id or a name")),
};
let f = s.doc.find_feature(&key).ok_or_else(|| bad("sketch.edit", format!("no sketch `{key}`")))?;
let f = s.doc.find_feature(&key).ok_or_else(|| bad("SketchActivate", format!("no sketch `{key}`")))?;
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
return Err(bad("sketch.edit", format!("`{key}` is not a sketch")));
return Err(bad("SketchActivate", format!("`{key}` is not a sketch")));
}
let (id, name) = (f.id, f.name.clone());
s.active_sketch = Some(id);
@@ -416,7 +416,7 @@ fn finish_sketch(s: &mut Session, _p: &Value) -> Result<Value> {
// Drawing
fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.line";
let cmd = "DrawPolyline";
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let list = p.get("points").and_then(Value::as_array).cloned().ok_or_else(|| bad(cmd, "`points` must be a list"))?;
if list.len() < 2 || list.len() > 10_000 {
@@ -527,7 +527,7 @@ fn rect_constraints(sk: &mut Sketch, l: &[usize]) -> Result<Vec<String>> {
}
fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.rectangle.two_point";
let cmd = "ShapeRectangleTwoPoint";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
if (a.x - b.x).abs() < 1e-9 || (a.y - b.y).abs() < 1e-9 {
return Err(bad(cmd, "the corners must differ in x and y"));
@@ -543,7 +543,7 @@ fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.rectangle.center";
let cmd = "ShapeRectangleCenter";
let (c, k) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "corner")?);
let h = Vec2::new((k.x - c.x).abs(), (k.y - c.y).abs());
if h.x < 1e-9 || h.y < 1e-9 {
@@ -552,16 +552,10 @@ fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (l, pts) = polygon_lines(sk, &[c - h, Vec2::new(c.x + h.x, c.y - h.y), c + h, Vec2::new(c.x - h.x, c.y + h.y)])?;
let mut cons = rect_constraints(sk, &l)?;
// Both diagonals (construction), the centre at their crossing. Only the first carries
// the midpoint constraint: diagonals of a parallelogram bisect each other, so a second
// one would be redundant (over-constrained).
if let (Some(p0), Some(p1), Some(p2), Some(p3)) = (pts.first(), pts.get(1), pts.get(2), pts.get(3)) {
if let (Some(p0), Some(p2)) = (pts.first(), pts.get(2)) {
let diag = sk.add_line_pts(*p0, *p2, None)?;
let diag2 = sk.add_line_pts(*p1, *p3, None)?;
for d in [diag, diag2] {
if let Some(cu) = sk.curves.get_mut(d) {
cu.construction = true;
}
if let Some(cu) = sk.curves.get_mut(diag) {
cu.construction = true;
}
let cp = sk.add_point(c, None)?;
cons.push(add_c(sk, ConstraintKind::Midpoint { p: cp, l: diag })?);
@@ -573,7 +567,7 @@ fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rect_three_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.rectangle.three_point";
let cmd = "ShapeRectangleThreePoint";
let (a, b, c) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?, req_vec2(cmd, p, "p2")?);
let d = (b - a).normalized().ok_or_else(|| bad(cmd, "p0 and p1 must differ"))?;
let n = d.perp();
@@ -605,7 +599,7 @@ pub(super) fn radius_arg(p: &Value, cmd: &str) -> Result<f64> {
}
fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.circle.center";
let cmd = "CircleCenterRadius";
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let r = radius_arg(p, cmd)?;
let id = str_(p, "id").map(str::to_string);
@@ -619,7 +613,7 @@ fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
}
fn circle_two(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.circle.two_point";
let cmd = "CircleTwoPoint";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let r = a.dist(b) / 2.0;
if r < 1e-9 {
@@ -647,7 +641,7 @@ pub(super) fn circumcircle(a: Vec2, b: Vec2, c: Vec2) -> Option<(Vec2, f64)> {
}
fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.circle.three_point";
let cmd = "CircleThreePoint";
let (a, b, c) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?, req_vec2(cmd, p, "p2")?);
let (o, r) = circumcircle(a, b, c).ok_or_else(|| bad(cmd, "the points are collinear"))?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
@@ -658,7 +652,7 @@ fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
}
fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.arc.three_point";
let cmd = "ArcThreePoint";
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let id = str_(p, "id").map(str::to_string);
let (out, info) = edit(s, p, cmd, false, |sk, _| {
@@ -680,7 +674,7 @@ fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
}
fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.arc.center_point";
let cmd = "ArcCenterTwoPoint";
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let id = str_(p, "id").map(str::to_string);
let sweep = num(p, "sweep");
@@ -759,7 +753,7 @@ fn sides(p: &Value, cmd: &str) -> Result<usize> {
}
fn polygon_inscribed(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.polygon.inscribed";
let cmd = "ShapePolygonInscribed";
let c = req_vec2(cmd, p, "center")?;
let r = radius_arg(p, cmd)?;
let n = sides(p, cmd)?;
@@ -768,7 +762,7 @@ fn polygon_inscribed(s: &mut Session, p: &Value) -> Result<Value> {
}
fn polygon_circumscribed(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.polygon.circumscribed";
let cmd = "ShapePolygonCircumscribed";
let c = req_vec2(cmd, p, "center")?;
let r = radius_arg(p, cmd)?;
let n = sides(p, cmd)?;
@@ -777,7 +771,7 @@ fn polygon_circumscribed(s: &mut Session, p: &Value) -> Result<Value> {
}
fn polygon_edge(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.polygon.edge";
let cmd = "ShapePolygonEdge";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let n = sides(p, cmd)?;
let e = a.dist(b);
@@ -816,14 +810,14 @@ pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w:
}
fn slot_c2c(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.slot.center_to_center";
let cmd = "ShapeSlotCenterToCenter";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let w = num(p, "width").filter(|w| *w > 1e-9).ok_or_else(|| bad(cmd, "`width` must be positive"))?;
slot(s, p, cmd, a, b, w)
}
fn slot_overall(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.slot.overall";
let cmd = "ShapeSlotOverall";
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
let w = num(p, "width").filter(|w| *w > 1e-9).ok_or_else(|| bad(cmd, "`width` must be positive"))?;
let d = (b - a).normalized().ok_or_else(|| bad(cmd, "the slot ends must differ"))?;
@@ -834,7 +828,7 @@ fn slot_overall(s: &mut Session, p: &Value) -> Result<Value> {
}
fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.point";
let cmd = "DrawPoint";
let at = req_vec2(cmd, p, "point")?;
let id = str_(p, "id").map(str::to_string);
let (pid, info) = edit(s, p, cmd, false, |sk, _| {
@@ -1018,7 +1012,7 @@ pub(super) fn reject_redundant(before: Option<usize>, info: &Value, cmd: &str) -
}
fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.horizontal_vertical";
let cmd = "ConstraintHorizontalVertical";
let mode = str_(p, "mode").map(str::to_ascii_lowercase);
constrain(s, p, cmd, |sk| {
if let Some(pts) = p.get("points").and_then(Value::as_array) {
@@ -1048,7 +1042,7 @@ fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
}
fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.coincident";
let cmd = "ConstraintCoincident";
constrain(s, p, cmd, |sk| {
let aref = p.get("a").and_then(Value::as_str).unwrap_or_default();
let bref = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?;
@@ -1070,39 +1064,31 @@ fn two_curves(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> {
}
fn c_tangent(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.tangent", |sk| two_curves(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b }))
constrain(s, p, "ConstraintTangent", |sk| two_curves(sk, p, "ConstraintTangent").map(|(a, b)| ConstraintKind::Tangent { a, b }))
}
fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.equal", |sk| two_curves(sk, p, "sketch.constraint.equal").map(|(a, b)| ConstraintKind::Equal { a, b }))
constrain(s, p, "ConstraintEqual", |sk| two_curves(sk, p, "ConstraintEqual").map(|(a, b)| ConstraintKind::Equal { a, b }))
}
fn c_parallel(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.parallel", |sk| {
two_curves(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b })
})
constrain(s, p, "ConstraintParallel", |sk| two_curves(sk, p, "ConstraintParallel").map(|(a, b)| ConstraintKind::Parallel { a, b }))
}
fn c_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.perpendicular", |sk| {
two_curves(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })
})
constrain(s, p, "ConstraintPerpendicular", |sk| two_curves(sk, p, "ConstraintPerpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b }))
}
fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.concentric", |sk| {
two_curves(sk, p, "sketch.constraint.concentric").map(|(a, b)| ConstraintKind::Concentric { a, b })
})
constrain(s, p, "ConstraintConcentric", |sk| two_curves(sk, p, "ConstraintConcentric").map(|(a, b)| ConstraintKind::Concentric { a, b }))
}
fn c_collinear(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.collinear", |sk| {
two_curves(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b })
})
constrain(s, p, "ConstraintCollinear", |sk| two_curves(sk, p, "ConstraintCollinear").map(|(a, b)| ConstraintKind::Collinear { a, b }))
}
fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.midpoint";
let cmd = "ConstraintMidPoint";
constrain(s, p, cmd, |sk| {
Ok(ConstraintKind::Midpoint { p: point_ref(sk, p.get("point"), cmd, "point")?, l: curve_ref(sk, p.get("line"), cmd, "line")? })
})
}
fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.symmetry";
let cmd = "ConstraintSymmetry";
let (ids, info) = edit(s, p, cmd, true, |sk, _| {
let l = curve_ref(sk, p.get("line"), cmd, "line")?;
let ra = p.get("a").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`a` must be a point or curve"))?;
@@ -1149,7 +1135,7 @@ fn round_center(sk: &Sketch, c: usize) -> Option<usize> {
}
fn c_fix(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.fix";
let cmd = "ConstraintFix";
let want = bool_(p, "fixed");
let mut refs = string_list(p, "entities");
if let Some(e) = str_(p, "entity") {
@@ -1215,7 +1201,7 @@ pub(super) fn line_pts(sk: &Sketch, l: usize) -> Option<(Vec2, Vec2)> {
}
fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.dimension";
let cmd = "SketchDimension";
let ents = string_list(p, "entities");
if ents.is_empty() || ents.len() > 2 {
return Err(bad(cmd, "`entities` must list one or two sketch entities"));
+13 -13
View File
@@ -9,11 +9,11 @@ use crate::params::{bad, bool_, num, str_, vec2};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("canvas.insert", "Canvas", add_canvas)
CommandSpec::new("FusionAddCanvasCommand", "Canvas", add_canvas)
.at("SKETCH", "INSERT")
.icon("canvas")
.params("path | data (base64 PNG or JPEG); plane?: XY|XZ|YZ|{face: [x,y,z]}|… (default XY); center?: [x,y] on the plane, or at?: [x,y,z]; width?: mm (default 100); angle?: deg; opacity?: 0…1 (default 0.5); flip?; name?"),
CommandSpec::new("canvas.decal", "Decal", add_decal)
CommandSpec::new("FusionAddEditDecalCommand", "Decal", add_decal)
.at("SKETCH", "INSERT")
.icon("decal")
.params("path | data; face: [x,y,z] on a planar face (the image is centred there); width?: mm; angle?: deg; opacity?: (default 1)"),
@@ -31,11 +31,11 @@ fn image_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
base64_decode(d).ok_or_else(|| bad(cmd, "`data` is not base64"))?
} else {
let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `data`"))?;
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta as usize > MAX_CANVAS_BYTES {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() as usize > MAX_CANVAS_BYTES {
return Err(bad(cmd, "the image is too large"));
}
solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
};
if b.len() > MAX_CANVAS_BYTES {
return Err(bad(cmd, "the image is too large"));
@@ -48,7 +48,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
let (format, pixels) = image_info(&bytes).ok_or_else(|| bad(cmd, "only PNG and JPEG images can be inserted"))?;
let plane = super::sketch::plane_ref(s, p, cmd)?;
let (vals, _) = s.doc.param_values();
let pl = s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
let pl = s.doc.resolve_plane(&vals, &plane, 0)?;
// A decal sits where the face was picked.
let at = p.get("at").or_else(|| p.get("plane").and_then(|v| v.get("face"))).and_then(crate::params::vec3).map(|q| pl.to_local(q));
let center = p.get("center").and_then(vec2).or(at).unwrap_or(Vec2::ZERO);
@@ -79,11 +79,11 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
}
fn add_canvas(s: &mut Session, p: &Value) -> Result<Value> {
make(s, p, "canvas.insert", false)
make(s, p, "FusionAddCanvasCommand", false)
}
fn add_decal(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "canvas.decal";
let cmd = "FusionAddEditDecalCommand";
let face = p.get("face").cloned().ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on a planar face"))?;
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
@@ -174,7 +174,7 @@ mod tests {
#[test]
fn canvas_insert_edit_calibrate_save() {
let mut s = Session::default();
let r = s.execute("canvas.insert", &json!({"data": png(400, 200), "plane": "XZ", "width": 80})).unwrap();
let r = s.execute("FusionAddCanvasCommand", &json!({"data": png(400, 200), "plane": "XZ", "width": 80})).unwrap();
let id = r["canvas"].as_u64().unwrap();
let c = s.doc.canvases[0].clone();
assert_eq!(c.pixels, [400, 200]);
@@ -188,12 +188,12 @@ mod tests {
assert_eq!(back.canvases, s.doc.canvases);
s.execute("canvas.delete", &json!({"canvas": id})).unwrap();
assert!(s.doc.canvases.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
s.execute("UndoCommand", &json!({})).unwrap();
assert_eq!(s.doc.canvases.len(), 1);
assert!(s.execute("canvas.insert", &json!({"data": "bm9wZQ=="})).is_err());
assert!(s.execute("FusionAddCanvasCommand", &json!({"data": "bm9wZQ=="})).is_err());
// A decal on the top face of a box.
s.execute("solid.box", &json!({"length": 30, "width": 30, "height": 10})).unwrap();
let r = s.execute("canvas.decal", &json!({"data": png(10, 10), "face": [15, 15, 10], "width": 20})).unwrap();
s.execute("PrimitiveBox", &json!({"length": 30, "width": 30, "height": 10})).unwrap();
let r = s.execute("FusionAddEditDecalCommand", &json!({"data": png(10, 10), "face": [15, 15, 10], "width": 20})).unwrap();
assert!(r["canvas"].is_u64());
}
}
+12 -12
View File
@@ -11,32 +11,32 @@ use crate::params::{bad, num, req_vec2, str_, string_list, vec2};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.constraint.curvature", "Curvature", c_smooth)
CommandSpec::new("ConstraintSmooth", "Curvature", c_smooth)
.at("SKETCH", "CONSTRAINTS")
.icon("c_smooth")
.enabled(in_sketch)
.params("a, b: two curves sharing an end point (curvature continuous, G2)"),
CommandSpec::new("sketch.constraint.polygon", "Polygon", c_polygon)
CommandSpec::new("SketchPolygonConstraintCmd", "Polygon", c_polygon)
.at("SKETCH", "CONSTRAINTS")
.icon("c_polygon")
.enabled(in_sketch)
.params("lines: [line ids] of a closed chain: equal sides with every corner on one circle (a regular polygon)"),
CommandSpec::new("sketch.auto_constrain", "AutoConstrain", auto_constrain)
CommandSpec::new("SketchAutoConstraintAndDimCmd", "AutoConstrain", auto_constrain)
.at("SKETCH", "CONSTRAINTS")
.icon("auto_constrain")
.enabled(in_sketch)
.params("tolerance?: mm (default 0.01), angle_tolerance?: deg (default 1), dimensions?: bool (default true): infer constraints, then dimension until fully constrained"),
CommandSpec::new("sketch.auto_constrain_from_datum", "AutoConstrain from datum", auto_constrain_datum)
CommandSpec::new("SketchAutoConstraintAndDimFromDatumCmd", "AutoConstrain from datum", auto_constrain_datum)
.at("SKETCH", "CONSTRAINTS")
.icon("auto_constrain")
.enabled(in_sketch)
.params("datum: point ref (default origin); like AutoConstrain with positions measured from the datum"),
CommandSpec::new("sketch.finish_auto_constrain", "Finish with AutoConstrain", auto_constrain_finish)
CommandSpec::new("SketchAutoConstrainAndFinish", "Finish with AutoConstrain", auto_constrain_finish)
.at("SKETCH", "FINISH SKETCH")
.icon("finish")
.enabled(in_sketch)
.params("like AutoConstrain, then Finish Sketch"),
CommandSpec::new("sketch.constrainer", "Sketch Constrainer", smart_constrain)
CommandSpec::new("SketchConstrainer", "SketchConstrainer", smart_constrain)
.at("SKETCH", "CREATE")
.icon("constrainer")
.enabled(in_sketch)
@@ -55,7 +55,7 @@ pub static COMMANDS: &[CommandSpec] = &[
];
fn c_smooth(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.curvature";
let cmd = "ConstraintSmooth";
let mut before = None;
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let a = curve(sk, p.get("a"), cmd)?;
@@ -73,7 +73,7 @@ fn curve(sk: &Sketch, v: Option<&Value>, cmd: &str) -> Result<usize> {
}
fn c_polygon(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.polygon";
let cmd = "SketchPolygonConstraintCmd";
let ids = string_list(p, "lines");
if ids.len() < 3 || ids.len() > 200 {
return Err(bad(cmd, "`lines` must list 3 to 200 lines of a closed chain"));
@@ -258,13 +258,13 @@ fn auto(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
}
fn auto_constrain(s: &mut Session, p: &Value) -> Result<Value> {
auto(s, p, "sketch.auto_constrain")
auto(s, p, "SketchAutoConstraintAndDimCmd")
}
fn auto_constrain_datum(s: &mut Session, p: &Value) -> Result<Value> {
auto(s, p, "sketch.auto_constrain_from_datum")
auto(s, p, "SketchAutoConstraintAndDimFromDatumCmd")
}
fn auto_constrain_finish(s: &mut Session, p: &Value) -> Result<Value> {
let r = auto(s, p, "sketch.finish_auto_constrain")?;
let r = auto(s, p, "SketchAutoConstrainAndFinish")?;
s.active_sketch = None;
s.revision += 1;
Ok(r)
@@ -343,7 +343,7 @@ fn guess(sk: &Sketch, ents: &[String], cmd: &str) -> Result<ConstraintKind> {
}
fn smart_constrain(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constrainer";
let cmd = "SketchConstrainer";
let ents = string_list(p, "entities");
let mut before = None;
let mut name = "";
@@ -16,7 +16,7 @@ fn ids(v: &Value) -> Vec<String> {
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
s
}
@@ -27,21 +27,21 @@ fn sketch(s: &Session) -> solvecraft_sketch::Sketch {
#[test]
fn redundant_constraints_and_dimensions_are_refused_driven_ones_measure() {
let mut s = new_sketch();
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"])[0].clone();
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"])[0].clone();
// Already horizontal (inferred).
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": l})).unwrap_err().to_string();
let e = s.execute("ConstraintHorizontalVertical", &json!({"line": l})).unwrap_err().to_string();
assert!(e.contains("over-constrain"), "{e}");
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{l}.start"), "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": 10}));
run(&mut s, "ConstraintCoincident", json!({"a": format!("{l}.start"), "b": "origin"}));
run(&mut s, "SketchDimension", json!({"entities": [l], "value": 10}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 0, "{si}");
// A second length on it over-constrains: refused, unless driven.
assert!(s.execute("sketch.dimension", &json!({"entities": [format!("{l}.start"), format!("{l}.end")]})).is_err());
let d = run(&mut s, "sketch.dimension", json!({"entities": [format!("{l}.start"), format!("{l}.end")], "driven": true}));
assert!(s.execute("SketchDimension", &json!({"entities": [format!("{l}.start"), format!("{l}.end")]})).is_err());
let d = run(&mut s, "SketchDimension", json!({"entities": [format!("{l}.start"), format!("{l}.end")], "driven": true}));
assert_eq!(d["driven"], true);
// The driven dimension follows the driving one.
let pname = si["constraints"].as_array().unwrap().iter().find_map(|c| c["param"].as_str().map(str::to_string)).unwrap();
run(&mut s, "parameters.change", json!({"name": pname, "expression": "25 mm"}));
run(&mut s, "ChangeParameterCommand", json!({"name": pname, "expression": "25 mm"}));
let g = run(&mut s, "sketch.glyphs", json!({}));
let driven = g["glyphs"].as_array().unwrap().iter().find(|x| x["driven"] == true).unwrap().clone();
assert!((driven["value"].as_f64().unwrap() - 25.0).abs() < 1e-7, "{driven}");
@@ -53,10 +53,9 @@ fn curvature_polygon_arc_length_linear_diameter() {
let mut s = new_sketch();
// A horizontal line ending where a control-point spline starts: G2 keeps the spline's
// start straight (zero curvature) and tangent.
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-10, 0], [0, 0]], "infer": true}))["curves"])[0].clone();
let sp =
ids(&run(&mut s, "sketch.spline.control_point", json!({"points": [format!("{l}.end"), [5, 2], [10, 6], [15, 15]]}))["curves"])[0].clone();
run(&mut s, "sketch.constraint.curvature", json!({"a": l, "b": sp}));
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-10, 0], [0, 0]], "infer": true}))["curves"])[0].clone();
let sp = ids(&run(&mut s, "DrawCVMSpline3D", json!({"points": [format!("{l}.end"), [5, 2], [10, 6], [15, 15]]}))["curves"])[0].clone();
run(&mut s, "ConstraintSmooth", json!({"a": l, "b": sp}));
let sk = sketch(&s);
let pts: Vec<Vec2> = sk.curves[sk.curve_index(&sp).unwrap()].kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect();
// Tangent: first leg along +x; zero curvature: the second leg too (collinear first three).
@@ -64,16 +63,16 @@ fn curvature_polygon_arc_length_linear_diameter() {
assert!((pts[2] - pts[1]).cross(pts[1] - pts[0]).abs() < 1e-3, "{pts:?}");
// Polygon constraint on a rough quadrilateral: becomes a square-ish regular polygon.
let q = run(&mut s, "sketch.line", json!({"points": [[100, 0], [111, 1], [110, 9], [99, 10]], "closed": true}));
let q = run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [111, 1], [110, 9], [99, 10]], "closed": true}));
let ql = ids(&q["curves"]);
run(&mut s, "sketch.constraint.polygon", json!({"lines": ql}));
run(&mut s, "SketchPolygonConstraintCmd", json!({"lines": ql}));
let sk = sketch(&s);
let lens: Vec<f64> = ql.iter().map(|i| sk.polyline(sk.curve_index(i).unwrap())).map(|p| p[0].dist(p[p.len() - 1])).collect();
assert!(lens.iter().all(|x| (x - lens[0]).abs() < 1e-6), "{lens:?}");
// Arc length.
let a = ids(&run(&mut s, "sketch.arc.center_point", json!({"center": [0, 50], "start": [10, 50], "end": [0, 60]}))["curves"])[0].clone();
run(&mut s, "sketch.dimension", json!({"entities": [a], "type": "arc_length", "value": 20}));
let a = ids(&run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 50], "start": [10, 50], "end": [0, 60]}))["curves"])[0].clone();
run(&mut s, "SketchDimension", json!({"entities": [a], "type": "arc_length", "value": 20}));
let sk = sketch(&s);
let ai = sk.curve_index(&a).unwrap();
let len = match sk.segs(ai)[0] {
@@ -83,10 +82,10 @@ fn curvature_polygon_arc_length_linear_diameter() {
assert!((len - 20.0).abs() < 1e-6, "{len}");
// Linear diameter about a centerline.
let cl = ids(&run(&mut s, "sketch.line", json!({"points": [[200, 0], [200, 50]]}))["curves"])[0].clone();
let cl = ids(&run(&mut s, "DrawPolyline", json!({"points": [[200, 0], [200, 50]]}))["curves"])[0].clone();
run(&mut s, "sketch.centerline", json!({"curves": [cl]}));
run(&mut s, "sketch.point", json!({"point": [207, 10], "id": "rim"}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["rim", cl], "value": 30}));
run(&mut s, "DrawPoint", json!({"point": [207, 10], "id": "rim"}));
let d = run(&mut s, "SketchDimension", json!({"entities": ["rim", cl], "value": 30}));
assert_eq!(d["type"], "LinearDiameterDimension", "{d}");
let sk = sketch(&s);
let rim = sk.point(sk.point_index("rim").unwrap()).unwrap();
@@ -99,7 +98,7 @@ fn curvature_polygon_arc_length_linear_diameter() {
#[test]
fn glyphs_and_snaps() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
let l = ids(&r["curves"]);
let g = run(&mut s, "sketch.glyphs", json!({}));
assert_eq!(g["glyphs"].as_array().unwrap().len(), 4);
@@ -117,9 +116,9 @@ fn glyphs_and_snaps() {
fn auto_constrain_fully_constrains_a_rough_sketch() {
let mut s = new_sketch();
// A slightly crooked closed rectangle and a circle inside.
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0.004], [40.003, 30], [0, 30.002]], "closed": true}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 15], "radius": 5}));
let r = run(&mut s, "sketch.auto_constrain", json!({}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [40, 0.004], [40.003, 30], [0, 30.002]], "closed": true}));
run(&mut s, "CircleCenterRadius", json!({"center": [20, 15], "radius": 5}));
let r = run(&mut s, "SketchAutoConstraintAndDimCmd", json!({}));
assert_eq!(r["dof"], 0, "{r}");
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["fully_constrained"], true, "{si}");
@@ -128,9 +127,9 @@ fn auto_constrain_fully_constrains_a_rough_sketch() {
assert!(names.iter().any(|n| n == "DiameterDimension"), "{names:?}");
// From a datum, and finishing.
let mut s = new_sketch();
run(&mut s, "sketch.point", json!({"point": [5, 5], "id": "d"}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [20, 10]]}));
let r = run(&mut s, "sketch.finish_auto_constrain", json!({"datum": "d"}));
run(&mut s, "DrawPoint", json!({"point": [5, 5], "id": "d"}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [20, 10]]}));
let r = run(&mut s, "SketchAutoConstrainAndFinish", json!({"datum": "d"}));
assert!(s.active_sketch.is_none());
assert!(r["added"].as_array().unwrap().len() >= 3, "{r}");
}
@@ -138,11 +137,11 @@ fn auto_constrain_fully_constrains_a_rough_sketch() {
#[test]
fn drawing_infers_tangent_and_perpendicular() {
let mut s = new_sketch();
let l1 = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 10]], "infer": true}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("{l1}.end"), [20, 0]], "infer": true}));
let l1 = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 10]], "infer": true}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("{l1}.end"), [20, 0]], "infer": true}));
assert_eq!(r["constraints"].as_array().unwrap().len(), 1, "{r}");
let a = ids(&run(&mut s, "sketch.arc.center_point", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("{a}.end"), [30, 10]], "infer": true}));
let a = ids(&run(&mut s, "ArcCenterTwoPoint", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("{a}.end"), [30, 10]], "infer": true}));
let si = run(&mut s, "sketch.inspect", json!({}));
let names: Vec<&str> = si["constraints"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect();
assert!(names.contains(&"Perpendicular") && names.contains(&"Tangent"), "{names:?} {r}");
@@ -151,18 +150,18 @@ fn drawing_infers_tangent_and_perpendicular() {
#[test]
fn smart_constrain_and_driven_toggle() {
let mut s = new_sketch();
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0.5]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 5], [10, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.constrainer", json!({"entities": [a]}));
let a = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0.5]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 5], [10, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "SketchConstrainer", json!({"entities": [a]}));
assert_eq!(r["type"], "Horizontal");
let r = run(&mut s, "sketch.constrainer", json!({"entities": [a, b]}));
let r = run(&mut s, "SketchConstrainer", json!({"entities": [a, b]}));
assert_eq!(r["type"], "Parallel");
let c1 = ids(&run(&mut s, "sketch.circle.center", json!({"center": [50, 0], "radius": 5}))["curves"])[0].clone();
let c2 = ids(&run(&mut s, "sketch.circle.center", json!({"center": [50.3, 0.2], "radius": 3}))["curves"])[0].clone();
let r = run(&mut s, "sketch.constrainer", json!({"entities": [c1, c2]}));
let c1 = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [50, 0], "radius": 5}))["curves"])[0].clone();
let c2 = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [50.3, 0.2], "radius": 3}))["curves"])[0].clone();
let r = run(&mut s, "SketchConstrainer", json!({"entities": [c1, c2]}));
assert_eq!(r["type"], "Concentric");
// Driven toggle.
let d = run(&mut s, "sketch.dimension", json!({"entities": [c1], "value": 10}));
let d = run(&mut s, "SketchDimension", json!({"entities": [c1], "value": 10}));
let g = run(&mut s, "sketch.glyphs", json!({}));
let did = g["glyphs"].as_array().unwrap().iter().find(|x| x["param"] == d["param"]).unwrap()["id"].as_str().unwrap().to_string();
let r = run(&mut s, "sketch.toggle_driven", json!({"constraint": did}));
@@ -175,8 +174,8 @@ fn smart_constrain_and_driven_toggle() {
#[test]
fn drawing_from_a_line_midpoint_keeps_it_there() {
let mut s = new_sketch();
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("mid:{l}"), [10, 15]]}));
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("mid:{l}"), [10, 15]]}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert!(si["constraints"].as_array().unwrap().iter().any(|c| c["name"] == "MidPoint"), "{si}");
// Lengthen the first line: the second one's start follows the middle.
@@ -192,10 +191,10 @@ fn drawing_from_a_line_midpoint_keeps_it_there() {
#[test]
fn drawing_onto_a_curve_keeps_the_point_on_it() {
let mut s = new_sketch();
let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("on:{c}:7.2,7.1"), [30, 30]]}));
let c = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
let r = run(&mut s, "DrawPolyline", json!({"points": [format!("on:{c}:7.2,7.1"), [30, 30]]}));
let n = ids(&r["curves"])[0].clone();
run(&mut s, "sketch.dimension", json!({"entities": [c], "value": 16}));
run(&mut s, "SketchDimension", json!({"entities": [c], "value": 16}));
let sk = sketch(&s);
let st = sk.resolve_point(&format!("{n}.start")).and_then(|i| sk.point(i)).unwrap();
let cc = sk.center(sk.curve_index(&c).unwrap()).unwrap();
+28 -28
View File
@@ -12,65 +12,65 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] =
&[
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
CommandSpec::new("SketchMidpointLine", "Midpoint Line", midpoint_line)
.at("SKETCH", "CREATE")
.icon("line_mid")
.enabled(in_sketch)
.params("mid: [x,y] or point ref, end: [x,y]; construction?"),
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(
CommandSpec::new("ArcTangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(
"start: end point of a line or arc (\"l1.end\"), end: [x,y]; curve?: the curve to be tangent to (default: the one ending at start)",
),
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan)
CommandSpec::new("CircleTanTanRadius", "2-Tangent Circle", circle_tan_tan)
.at("SKETCH", "CREATE")
.icon("circle_tt")
.enabled(in_sketch)
.params("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)"),
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", circle_three_tan)
CommandSpec::new("CircleThreeTangent", "3-Tangent Circle", circle_three_tan)
.at("SKETCH", "CREATE")
.icon("circle_ttt")
.enabled(in_sketch)
.params("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)"),
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point)
CommandSpec::new("ShapeSlotCenterPoint", "Center Point Slot", slot_center_point)
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params("center: slot centre, end: one arc centre, width"),
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three)
CommandSpec::new("ShapeArcSlotThreePoint", "Three Point Arc Slot", arc_slot_three)
.at("SKETCH", "CREATE")
.icon("arc_slot")
.enabled(in_sketch)
.params("start, through, end: points on the centre arc, width"),
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center)
CommandSpec::new("ShapeArcSlotCenterTwoPoint", "Center Point Arc Slot", arc_slot_center)
.at("SKETCH", "CREATE")
.icon("arc_slot")
.enabled(in_sketch)
.params("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width"),
CommandSpec::new("sketch.ellipse", "Ellipse", ellipse)
CommandSpec::new("CircleElipse", "Ellipse", ellipse)
.at("SKETCH", "CREATE")
.icon("ellipse")
.enabled(in_sketch)
.params("center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius"),
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit)
CommandSpec::new("DrawSpline", "Fit Point Spline", spline_fit)
.at("SKETCH", "CREATE")
.icon("spline")
.enabled(in_sketch)
.params("points: [[x,y] or point refs…] (2…500): the spline passes through them"),
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3)
CommandSpec::new("DrawCVMSpline3D", "Control Point Spline", spline_cv3)
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.params("points: control points (2…500), cubic"),
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", spline_cv5)
CommandSpec::new("DrawCVMSpline5D", "Control Point Spline (Degree 5)", spline_cv5)
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.params("points: control points (2…500), degree 5"),
CommandSpec::new("sketch.conic", "Conic Curve", conic)
CommandSpec::new("ConicCurveCmd", "Conic Curve", conic)
.at("SKETCH", "CREATE")
.icon("conic")
.enabled(in_sketch)
.params("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)"),
CommandSpec::new("sketch.blend_curve", "Blend Curve", blend)
CommandSpec::new("BlendG1CurveSketchCmd", "Blend Curve", blend)
.at("SKETCH", "MODIFY")
.icon("blend")
.enabled(in_sketch)
@@ -78,7 +78,7 @@ pub static COMMANDS: &[CommandSpec] =
CommandSpec::new("sketch.centerline", "Centerline", centerline)
.enabled(in_sketch)
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
CommandSpec::new("sketch.text", "Text", text)
CommandSpec::new("MTextCmd", "Text", text)
.at("SKETCH", "CREATE")
.icon("text")
.enabled(in_sketch)
@@ -107,7 +107,7 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> R
}
fn text(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.text";
let cmd = "MTextCmd";
if p.get("at").is_none() {
return Err(bad(cmd, "`at` must be the baseline start [x, y]"));
}
@@ -153,7 +153,7 @@ fn end_of(sk: &Sketch, pi: usize) -> Option<(usize, Vec2)> {
}
fn blend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.blend_curve";
let cmd = "BlendG1CurveSketchCmd";
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let pa = req_parg(sk, p, "a", cmd)?.idx().ok_or_else(|| bad(cmd, "`a` must be the end point of a curve"))?;
let pb = req_parg(sk, p, "b", cmd)?.idx().ok_or_else(|| bad(cmd, "`b` must be the end point of a curve"))?;
@@ -200,7 +200,7 @@ fn centerline(s: &mut Session, p: &Value) -> Result<Value> {
}
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.ellipse";
let cmd = "CircleElipse";
let (c, m) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "major")?);
let u = (m - c).normalized().ok_or_else(|| bad(cmd, "the major axis end must differ from the centre"))?;
let a = c.dist(m);
@@ -263,17 +263,17 @@ fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> R
}
fn spline_fit(s: &mut Session, p: &Value) -> Result<Value> {
spline(s, p, "sketch.spline.fit_point", false, 3)
spline(s, p, "DrawSpline", false, 3)
}
fn spline_cv3(s: &mut Session, p: &Value) -> Result<Value> {
spline(s, p, "sketch.spline.control_point", true, 3)
spline(s, p, "DrawCVMSpline3D", true, 3)
}
fn spline_cv5(s: &mut Session, p: &Value) -> Result<Value> {
spline(s, p, "sketch.spline.control_point_5", true, 5)
spline(s, p, "DrawCVMSpline5D", true, 5)
}
fn conic(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.conic";
let cmd = "ConicCurveCmd";
let rho = num(p, "rho").unwrap_or(0.5);
if !(rho > 1e-6 && rho < 1.0 - 1e-6) {
return Err(bad(cmd, "`rho` must be between 0 and 1"));
@@ -300,7 +300,7 @@ fn conic(s: &mut Session, p: &Value) -> Result<Value> {
}
fn midpoint_line(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.line.midpoint";
let cmd = "SketchMidpointLine";
let end = req_vec2(cmd, p, "end")?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let m = req_parg(sk, p, "mid", cmd)?;
@@ -369,7 +369,7 @@ fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option<Vec2> {
}
fn arc_tangent(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.arc.tangent";
let cmd = "ArcTangent";
let end = req_vec2(cmd, p, "end")?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let st = req_parg(sk, p, "start", cmd)?;
@@ -458,7 +458,7 @@ fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Optio
}
fn circle_tan_tan(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.circle.two_tangent";
let cmd = "CircleTanTanRadius";
let r = match (crate::params::expr(p, "radius"), num(p, "diameter")) {
(Some(r), _) => r,
(None, Some(d)) => format!("{}", d / 2.0),
@@ -468,7 +468,7 @@ fn circle_tan_tan(s: &mut Session, p: &Value) -> Result<Value> {
}
fn circle_three_tan(s: &mut Session, p: &Value) -> Result<Value> {
tangent_circle(s, p, "sketch.circle.three_tangent", 3, None)
tangent_circle(s, p, "CircleThreeTangent", 3, None)
}
fn width(p: &Value, cmd: &str) -> Result<f64> {
@@ -476,7 +476,7 @@ fn width(p: &Value, cmd: &str) -> Result<f64> {
}
fn slot_center_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.slot.center_point";
let cmd = "ShapeSlotCenterPoint";
let (c, e) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "end")?);
let w = width(p, cmd)?;
slot(s, p, cmd, c * 2.0 - e, e, w)
@@ -518,7 +518,7 @@ fn arc_slot(s: &mut Session, p: &Value, cmd: &str, o: Vec2, r: f64, t0: f64, swe
}
fn arc_slot_three(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.slot.arc_three_point";
let cmd = "ShapeArcSlotThreePoint";
let (a, t, b) = (req_vec2(cmd, p, "start")?, req_vec2(cmd, p, "through")?, req_vec2(cmd, p, "end")?);
let w = width(p, cmd)?;
let (o, r) = circumcircle(a, t, b).ok_or_else(|| bad(cmd, "the points are collinear"))?;
@@ -530,7 +530,7 @@ fn arc_slot_three(s: &mut Session, p: &Value) -> Result<Value> {
}
fn arc_slot_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.slot.arc_center";
let cmd = "ShapeArcSlotCenterTwoPoint";
let (o, a, b) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "start")?, req_vec2(cmd, p, "end")?);
let w = width(p, cmd)?;
let r = o.dist(a);
+33 -33
View File
@@ -44,53 +44,53 @@ fn random_sketch_editing_never_breaks() {
for seed in 1..=2u64 {
let mut r = Rng(0x9E37_79B9_7F4A_7C15 ^ seed);
let mut s = Session::default();
let _ = s.execute("solid.box", &json!({"length": 30, "width": 20, "height": 10}));
let _ = s.execute("sketch.create", &json!({"plane": {"face": [15, 10, 10]}}));
let _ = s.execute("PrimitiveBox", &json!({"length": 30, "width": 20, "height": 10}));
let _ = s.execute("SketchCreate", &json!({"plane": {"face": [15, 10, 10]}}));
for step in 0..120 {
let (curves, points, cons) = entities(&s);
let c = |r: &mut Rng| r.pick(&curves).cloned().unwrap_or_default();
let p = |r: &mut Rng| r.pick(&points).cloned().unwrap_or_default();
let (cmd, params) = match r.next() % 34 {
0 => ("sketch.line", json!({"points": [r.pt(), r.pt(), r.pt()], "infer": true})),
1 => ("sketch.circle.center", json!({"center": r.pt(), "radius": r.f(0.5, 20.0)})),
2 => ("sketch.arc.center_point", json!({"center": r.pt(), "start": r.pt(), "end": r.pt()})),
3 => ("sketch.rectangle.two_point", json!({"p0": r.pt(), "p1": r.pt()})),
4 => ("sketch.spline.fit_point", json!({"points": [r.pt(), r.pt(), r.pt(), r.pt()]})),
5 => ("sketch.ellipse", json!({"center": r.pt(), "major": r.pt(), "minor_radius": r.f(0.5, 10.0)})),
6 => ("sketch.conic", json!({"start": r.pt(), "end": r.pt(), "apex": r.pt(), "rho": r.f(0.1, 0.9)})),
7 => ("sketch.trim", json!({"curve": c(&mut r), "at": r.pt()})),
8 => ("sketch.extend", json!({"curve": c(&mut r), "at": r.pt()})),
9 => ("sketch.break", json!({"curve": c(&mut r), "at": r.pt()})),
10 => ("sketch.fillet", json!({"point": p(&mut r), "radius": r.f(0.1, 5.0)})),
11 => ("sketch.chamfer.equal_distance", json!({"point": p(&mut r), "distance": r.f(0.1, 5.0)})),
12 => ("sketch.offset", json!({"curves": [c(&mut r)], "distance": r.f(-5.0, 5.0), "side": r.pt()})),
13 => ("sketch.mirror", json!({"entities": [c(&mut r)], "line": c(&mut r)})),
14 => ("sketch.pattern.circular", json!({"entities": [c(&mut r)], "center": r.pt(), "count": 3})),
15 => ("sketch.constraint.coincident", json!({"a": p(&mut r), "b": p(&mut r)})),
16 => ("sketch.constraint.tangent", json!({"a": c(&mut r), "b": c(&mut r)})),
17 => ("sketch.constraint.parallel", json!({"a": c(&mut r), "b": c(&mut r)})),
18 => ("sketch.constraint.curvature", json!({"a": c(&mut r), "b": c(&mut r)})),
19 => ("sketch.dimension", json!({"entities": [c(&mut r)]})),
20 => ("sketch.dimension", json!({"entities": [p(&mut r), p(&mut r)], "driven": r.next().is_multiple_of(2)})),
0 => ("DrawPolyline", json!({"points": [r.pt(), r.pt(), r.pt()], "infer": true})),
1 => ("CircleCenterRadius", json!({"center": r.pt(), "radius": r.f(0.5, 20.0)})),
2 => ("ArcCenterTwoPoint", json!({"center": r.pt(), "start": r.pt(), "end": r.pt()})),
3 => ("ShapeRectangleTwoPoint", json!({"p0": r.pt(), "p1": r.pt()})),
4 => ("DrawSpline", json!({"points": [r.pt(), r.pt(), r.pt(), r.pt()]})),
5 => ("CircleElipse", json!({"center": r.pt(), "major": r.pt(), "minor_radius": r.f(0.5, 10.0)})),
6 => ("ConicCurveCmd", json!({"start": r.pt(), "end": r.pt(), "apex": r.pt(), "rho": r.f(0.1, 0.9)})),
7 => ("TrimSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})),
8 => ("ExtendSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})),
9 => ("BreakSketchCmd", json!({"curve": c(&mut r), "at": r.pt()})),
10 => ("FilletSketchCmd", json!({"point": p(&mut r), "radius": r.f(0.1, 5.0)})),
11 => ("ChamferSketchEqualDistance", json!({"point": p(&mut r), "distance": r.f(0.1, 5.0)})),
12 => ("Offset", json!({"curves": [c(&mut r)], "distance": r.f(-5.0, 5.0), "side": r.pt()})),
13 => ("MirrorSketchCommand", json!({"entities": [c(&mut r)], "line": c(&mut r)})),
14 => ("CircularSketchPatternCommand", json!({"entities": [c(&mut r)], "center": r.pt(), "count": 3})),
15 => ("ConstraintCoincident", json!({"a": p(&mut r), "b": p(&mut r)})),
16 => ("ConstraintTangent", json!({"a": c(&mut r), "b": c(&mut r)})),
17 => ("ConstraintParallel", json!({"a": c(&mut r), "b": c(&mut r)})),
18 => ("ConstraintSmooth", json!({"a": c(&mut r), "b": c(&mut r)})),
19 => ("SketchDimension", json!({"entities": [c(&mut r)]})),
20 => ("SketchDimension", json!({"entities": [p(&mut r), p(&mut r)], "driven": r.next().is_multiple_of(2)})),
21 => ("sketch.delete", json!({"entities": [r.pick(&cons).cloned().unwrap_or_else(|| c(&mut r))]})),
22 => ("sketch.move_point", json!({"point": p(&mut r), "to": r.pt()})),
23 => ("sketch.project", json!({"refs": [{"edge": [r.f(0.0, 30.0), 0.0, 10.0]}]})),
23 => ("ProjectNewCmd", json!({"refs": [{"edge": [r.f(0.0, 30.0), 0.0, 10.0]}]})),
24 => ("sketch.break_link", json!({"entities": [c(&mut r)]})),
25 => ("sketch.constrainer", json!({"entities": [c(&mut r), c(&mut r)]})),
26 => ("sketch.auto_constrain", json!({})),
27 => ("sketch.text", json!({"text": "Ab", "at": r.pt(), "height": r.f(1.0, 8.0)})),
25 => ("SketchConstrainer", json!({"entities": [c(&mut r), c(&mut r)]})),
26 => ("SketchAutoConstraintAndDimCmd", json!({})),
27 => ("MTextCmd", json!({"text": "Ab", "at": r.pt(), "height": r.f(1.0, 8.0)})),
28 => ("sketch.toggle_driven", json!({"constraint": r.pick(&cons).cloned().unwrap_or_default()})),
29 => ("sketch.arc.tangent", json!({"start": format!("{}.end", c(&mut r)), "end": r.pt()})),
30 => ("sketch.scale", json!({"entities": [c(&mut r)], "base": r.pt(), "factor": r.f(0.5, 2.0)})),
31 => ("edit.undo", json!({})),
32 => ("sketch.slot.arc_three_point", json!({"start": r.pt(), "through": r.pt(), "end": r.pt(), "width": r.f(0.5, 4.0)})),
_ => ("edit.redo", json!({})),
29 => ("ArcTangent", json!({"start": format!("{}.end", c(&mut r)), "end": r.pt()})),
30 => ("SketchScaleCmd", json!({"entities": [c(&mut r)], "base": r.pt(), "factor": r.f(0.5, 2.0)})),
31 => ("UndoCommand", json!({})),
32 => ("ShapeArcSlotThreePoint", json!({"start": r.pt(), "through": r.pt(), "end": r.pt(), "width": r.f(0.5, 4.0)})),
_ => ("RedoCommand", json!({})),
};
if let Err(EngineError::Internal(id, msg)) = s.execute(cmd, &params) {
panic!("seed {seed} step {step}: {id} {params}: {msg}");
}
if s.active_sketch.is_none() {
let _ = s.execute("sketch.create", &json!({"plane": "XY"}));
let _ = s.execute("SketchCreate", &json!({"plane": "XY"}));
}
}
// The design round-trips.
+20 -20
View File
@@ -13,11 +13,11 @@ use crate::params::{bad, num, str_, vec2};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.insert_dxf", "Insert DXF", insert_dxf)
CommandSpec::new("ImportDxfFileCommand", "Insert DXF", insert_dxf)
.at("SKETCH", "INSERT")
.icon("dxf")
.params("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)"),
CommandSpec::new("sketch.insert_svg", "Insert SVG", insert_svg)
CommandSpec::new("SketchImportSVG", "Insert SVG", insert_svg)
.at("SKETCH", "INSERT")
.icon("svg")
.params("path | text: SVG; at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)"),
@@ -34,11 +34,11 @@ fn source_text(p: &Value, cmd: &str) -> Result<String> {
return Ok(t.to_string());
}
let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `text`"))?;
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta as usize > MAX_DRAWING_BYTES {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() as usize > MAX_DRAWING_BYTES {
return Err(EngineError::Other(format!("{path}: file too large")));
}
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(String::from_utf8_lossy(&bytes).to_string())
}
@@ -123,7 +123,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
if let Some(n) = str_(p, "name") {
cp["name"] = json!(n);
}
let r = (super::find_command("sketch.create").ok_or_else(|| bad(cmd, "sketch.create"))?.run)(s, &cp)?;
let r = (super::find_command("SketchCreate").ok_or_else(|| bad(cmd, "SketchCreate"))?.run)(s, &cp)?;
created = r.get("sketch").and_then(Value::as_u64);
}
let tol = num(p, "tolerance").filter(|t| t.is_finite() && *t > 0.0).unwrap_or(1e-4).clamp(1e-9, 10.0);
@@ -150,11 +150,11 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
}
fn insert_dxf(s: &mut Session, p: &Value) -> Result<Value> {
insert(s, p, "sketch.insert_dxf", false)
insert(s, p, "ImportDxfFileCommand", false)
}
fn insert_svg(s: &mut Session, p: &Value) -> Result<Value> {
insert(s, p, "sketch.insert_svg", true)
insert(s, p, "SketchImportSVG", true)
}
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
@@ -186,7 +186,7 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
}
let text = solvecraft_io::write_dxf(&geom);
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "entities": geom.len()}));
}
Ok(json!({"dxf": text, "entities": geom.len()}))
@@ -201,14 +201,14 @@ mod tests {
#[test]
fn dxf_and_svg_round_trip_into_sketches() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [30, 20]})).unwrap();
s.execute("sketch.circle.center", &json!({"center": [10, 10], "radius": 4})).unwrap();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
s.execute("ShapeRectangleTwoPoint", &json!({"p0": [0, 0], "p1": [30, 20]})).unwrap();
s.execute("CircleCenterRadius", &json!({"center": [10, 10], "radius": 4})).unwrap();
let out = s.execute("sketch.export_dxf", &json!({})).unwrap();
let dxf = out["dxf"].as_str().unwrap().to_string();
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("SketchStop", &json!({})).unwrap();
// Insert it again as a new sketch, offset: same profiles.
let r = s.execute("sketch.insert_dxf", &json!({"text": dxf, "at": [100, 0]})).unwrap();
let r = s.execute("ImportDxfFileCommand", &json!({"text": dxf, "at": [100, 0]})).unwrap();
assert_eq!(r["curves"], 5, "{r}");
let id = r["sketch"].as_u64().unwrap();
let ss = s.model.state().sketch(id).unwrap().clone();
@@ -219,19 +219,19 @@ mod tests {
// SVG with a path and text, into the active sketch.
let svg =
r#"<svg width="40mm" height="40mm" viewBox="0 0 40 40"><path d="M0 0 H40 V40 H0 Z"/><text x="5" y="20" font-size="10">Hi</text></svg>"#;
let r = s.execute("sketch.insert_svg", &json!({"text": svg})).unwrap();
let r = s.execute("SketchImportSVG", &json!({"text": svg})).unwrap();
assert_eq!(r["curves"], 4, "{r}");
assert_eq!(r["texts"], 1, "{r}");
assert!(s.execute("sketch.insert_svg", &json!({"text": "<svg></svg>"})).is_err());
assert!(s.execute("sketch.insert_dxf", &json!({"path": "/nonexistent.dxf"})).is_err());
s.execute("sketch.finish", &json!({})).unwrap();
assert!(s.execute("SketchImportSVG", &json!({"text": "<svg></svg>"})).is_err());
assert!(s.execute("ImportDxfFileCommand", &json!({"path": "/nonexistent.dxf"})).is_err());
s.execute("SketchStop", &json!({})).unwrap();
// Ends that miss by 0.00005 still close the square; with a tighter tolerance they don't.
let gappy = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n10\n21\n0\n0\nLINE\n10\n10.00005\n20\n0\n11\n10\n21\n10\n0\nLINE\n10\n10\n20\n10\n11\n0\n21\n10\n0\nLINE\n10\n0\n20\n10.00003\n11\n0\n21\n0.00004\n0\nENDSEC\n0\nEOF\n";
for (tol, n) in [(1e-4, 1), (1e-6, 0)] {
let r = s.execute("sketch.insert_dxf", &json!({"text": gappy, "tolerance": tol})).unwrap();
let r = s.execute("ImportDxfFileCommand", &json!({"text": gappy, "tolerance": tol})).unwrap();
let ss = s.model.state().sketch(r["sketch"].as_u64().unwrap()).unwrap().clone();
assert_eq!(ss.profiles.len(), n, "tolerance {tol}");
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("SketchStop", &json!({})).unwrap();
}
}
}
+28 -28
View File
@@ -9,22 +9,22 @@ use crate::params::{bad, num, string_list, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("inspect.curvature_comb", "Curvature Comb Analysis", curvature_comb)
CommandSpec::new("FusionCurvatureCombAnalysisCommand", "Curvature Comb Analysis", curvature_comb)
.at("SKETCH", "INSPECT")
.icon("curvature_comb")
.noundo()
.params("curves?: [sketch curve ids] (sketch?: id|name, default active) | edges?: [[x,y,z] points on body edges]; density?: teeth per curve (default 40), scale?: comb length per unit curvature (default auto)"),
CommandSpec::new("inspect.minimum_radius", "Minimum Radius Analysis", minimum_radius)
CommandSpec::new("FusionMinimumRadiusAnalysisCommand", "Minimum Radius Analysis", minimum_radius)
.at("SKETCH", "INSPECT")
.icon("min_radius")
.noundo()
.params("curves?: [sketch curve ids] (sketch?) | edges?: [[x,y,z]…] | faces?: [[x,y,z]…] (default: every face of every body); the smallest radius of curvature of each and where it is"),
CommandSpec::new("inspect.isocurve", "Isocurve Analysis", isocurves)
CommandSpec::new("FusionIsoCurveAnalysisCommand", "Isocurve Analysis", isocurves)
.at("SKETCH", "INSPECT")
.icon("iso_analysis")
.noundo()
.params("faces: [[x,y,z]…] points on faces; count?: curves each way (default 8): the faces' isoparametric curves"),
CommandSpec::new("inspect.center_of_mass", "Center of Mass", center_of_mass)
CommandSpec::new("FusionCenterOfMassCommand", "Center of Mass", center_of_mass)
.at("SKETCH", "INSPECT")
.icon("center_of_mass")
.noundo()
@@ -73,7 +73,7 @@ fn teeth_3d(pts: &[Vec3]) -> Vec<(Vec3, f64, Vec3)> {
}
fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.curvature_comb";
let cmd = "FusionCurvatureCombAnalysisCommand";
let density = num(p, "density").map(|d| d.clamp(2.0, 2000.0) as usize).unwrap_or(40);
let st = s.model.state();
let mut combs: Vec<(String, Vec<(Vec3, f64, Vec3)>)> = Vec::new();
@@ -210,7 +210,7 @@ fn face_min_radius(m: &solvecraft_geom::Mesh, face: u32) -> Option<(f64, Vec3)>
}
fn minimum_radius(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.minimum_radius";
let cmd = "FusionMinimumRadiusAnalysisCommand";
let st = s.model.state();
let mut items: Vec<(String, Option<(f64, Vec3)>)> = Vec::new();
let curves = string_list(p, "curves");
@@ -282,7 +282,7 @@ fn minimum_radius(s: &mut Session, p: &Value) -> Result<Value> {
}
fn isocurves(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.isocurve";
let cmd = "FusionIsoCurveAnalysisCommand";
let count = num(p, "count").map(|c| c.clamp(1.0, 100.0) as usize).unwrap_or(8);
let faces = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?;
if faces.is_empty() {
@@ -302,7 +302,7 @@ fn isocurves(s: &mut Session, p: &Value) -> Result<Value> {
}
fn center_of_mass(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.center_of_mass";
let cmd = "FusionCenterOfMassCommand";
let names = string_list(p, "bodies");
let st = s.model.state();
let mut out = Vec::new();
@@ -332,10 +332,10 @@ mod tests {
#[test]
fn curvature_combs() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let c = s.execute("sketch.circle.center", &json!({"center": [0, 0], "radius": 5})).unwrap()["curves"][0].clone();
let l = s.execute("sketch.line", &json!({"points": [[0, 20], [10, 20]]})).unwrap()["curves"][0].clone();
let r = s.execute("inspect.curvature_comb", &json!({"curves": [c, l], "density": 20})).unwrap();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
let c = s.execute("CircleCenterRadius", &json!({"center": [0, 0], "radius": 5})).unwrap()["curves"][0].clone();
let l = s.execute("DrawPolyline", &json!({"points": [[0, 20], [10, 20]]})).unwrap()["curves"][0].clone();
let r = s.execute("FusionCurvatureCombAnalysisCommand", &json!({"curves": [c, l], "density": 20})).unwrap();
let circle = &r["combs"][0];
assert!((circle["min_radius"].as_f64().unwrap() - 5.0).abs() < 1e-3, "{circle}");
assert!(r["combs"][1]["min_radius"].is_null(), "a line is straight");
@@ -343,39 +343,39 @@ mod tests {
let t = &circle["teeth"][3];
let (a, b) = ((t[0][0].as_f64().unwrap(), t[0][1].as_f64().unwrap()), (t[1][0].as_f64().unwrap(), t[1][1].as_f64().unwrap()));
assert!(b.0 * b.0 + b.1 * b.1 > a.0 * a.0 + a.1 * a.1);
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.cylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
let r = s.execute("inspect.curvature_comb", &json!({"edges": [[58, 0, 10]]})).unwrap();
s.execute("SketchStop", &json!({})).unwrap();
s.execute("PrimitiveCylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
let r = s.execute("FusionCurvatureCombAnalysisCommand", &json!({"edges": [[58, 0, 10]]})).unwrap();
assert!((r["combs"][0]["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.5, "{r}");
assert!(s.execute("inspect.curvature_comb", &json!({})).is_err());
assert!(s.execute("FusionCurvatureCombAnalysisCommand", &json!({})).is_err());
}
#[test]
fn minimum_radius_and_center_of_mass() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let e = s.execute("sketch.ellipse", &json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4})).unwrap()["curves"][0].clone();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
let e = s.execute("CircleElipse", &json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4})).unwrap()["curves"][0].clone();
// An ellipse's tightest bend is at the major axis ends: b²/a.
let r = s.execute("inspect.minimum_radius", &json!({"curves": [e]})).unwrap();
let r = s.execute("FusionMinimumRadiusAnalysisCommand", &json!({"curves": [e]})).unwrap();
assert!((r["min_radius"].as_f64().unwrap() - 1.6).abs() < 1e-3, "{r}");
assert!((r["at"][0].as_f64().unwrap().abs() - 10.0).abs() < 0.1, "{r}");
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.cylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
s.execute("SketchStop", &json!({})).unwrap();
s.execute("PrimitiveCylinder", &json!({"base": [50, 0, 0], "radius": 8, "height": 10})).unwrap();
// The cylinder's side face: radius 8 (the mesh estimate is close).
let r = s.execute("inspect.minimum_radius", &json!({"faces": [[58, 0, 5]]})).unwrap();
let r = s.execute("FusionMinimumRadiusAnalysisCommand", &json!({"faces": [[58, 0, 5]]})).unwrap();
assert!((r["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.4, "{r}");
let r = s.execute("inspect.minimum_radius", &json!({})).unwrap();
let r = s.execute("FusionMinimumRadiusAnalysisCommand", &json!({})).unwrap();
assert!((r["min_radius"].as_f64().unwrap() - 8.0).abs() < 0.4, "{r}");
// Centre of mass: a box beside the cylinder.
s.execute("solid.box", &json!({"corner": [0, 0, 0], "length": 10, "width": 10, "height": 10})).unwrap();
let r = s.execute("inspect.center_of_mass", &json!({})).unwrap();
s.execute("PrimitiveBox", &json!({"corner": [0, 0, 0], "length": 10, "width": 10, "height": 10})).unwrap();
let r = s.execute("FusionCenterOfMassCommand", &json!({})).unwrap();
let (vb, vc) = (1000.0, std::f64::consts::PI * 640.0);
let want = (5.0 * vb + 50.0 * vc) / (vb + vc);
assert!((r["center"][0].as_f64().unwrap() - want).abs() < 0.1, "{r}");
assert!((r["center"][2].as_f64().unwrap() - 5.0).abs() < 0.05, "{r}");
assert!(s.execute("inspect.center_of_mass", &json!({"bodies": ["nope"]})).is_err());
assert!(s.execute("FusionCenterOfMassCommand", &json!({"bodies": ["nope"]})).is_err());
// Isocurves on the cylinder's side: rings around it and lines along it.
let r = s.execute("inspect.isocurve", &json!({"faces": [[58, 0, 5]], "count": 4})).unwrap();
let r = s.execute("FusionIsoCurveAnalysisCommand", &json!({"faces": [[58, 0, 5]], "count": 4})).unwrap();
let curves = r["curves"].as_array().unwrap();
let ring =
|c: &Value| c.as_array().unwrap().iter().all(|q| ((q[0].as_f64().unwrap() - 50.0).hypot(q[1].as_f64().unwrap()) - 8.0).abs() < 0.2);
@@ -386,6 +386,6 @@ mod tests {
z.iter().cloned().fold(f64::NEG_INFINITY, f64::max) - z.iter().cloned().fold(f64::INFINITY, f64::min) < 1e-6
};
assert!(curves.iter().filter(|c| flat(c)).count() >= 4, "rings at constant height");
assert!(s.execute("inspect.isocurve", &json!({"faces": []})).is_err());
assert!(s.execute("FusionIsoCurveAnalysisCommand", &json!({"faces": []})).is_err());
}
}
+24 -24
View File
@@ -13,49 +13,49 @@ use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.fillet", "Fillet", fillet)
CommandSpec::new("FilletSketchCmd", "Fillet", fillet)
.at("SKETCH", "MODIFY")
.icon("sketch_fillet")
.enabled(in_sketch)
.params("point: corner shared by two curves (\"l1.end\") | a, b: two lines or arcs meeting at a corner; radius: expr"),
CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal)
CommandSpec::new("ChamferSketchEqualDistance", "Equal Distance Chamfer", chamfer_equal)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.enabled(in_sketch)
.params("point | a, b: a corner of two lines; distance"),
CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", chamfer_dist_angle)
CommandSpec::new("ChamferSketchDistanceAngle", "Distance and Angle Chamfer", chamfer_dist_angle)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.enabled(in_sketch)
.params("point | a, b: a corner of two lines; distance: along a, angle: deg from a"),
CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", chamfer_two)
CommandSpec::new("ChamferSketchDistanceDistance", "Two Distance Chamfer", chamfer_two)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.enabled(in_sketch)
.params("point | a, b: a corner of two lines; distance: along a, distance2: along b"),
CommandSpec::new("sketch.offset", "Offset", offset)
CommandSpec::new("Offset", "Offset", offset)
.at("SKETCH", "MODIFY")
.icon("offset")
.key("O")
.enabled(in_sketch)
.params("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)"),
CommandSpec::new("sketch.trim", "Trim", trim)
CommandSpec::new("TrimSketchCmd", "Trim", trim)
.at("SKETCH", "MODIFY")
.icon("trim")
.key("T")
.enabled(in_sketch)
.params("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it)"),
CommandSpec::new("sketch.extend", "Extend", extend)
CommandSpec::new("ExtendSketchCmd", "Extend", extend)
.at("SKETCH", "MODIFY")
.icon("extend")
.enabled(in_sketch)
.params("curve: id, at: [x,y] near the end to extend (to the nearest curve)"),
CommandSpec::new("sketch.break", "Break", break_cmd)
CommandSpec::new("BreakSketchCmd", "Break", break_cmd)
.at("SKETCH", "MODIFY")
.icon("break")
.enabled(in_sketch)
.params("curve: id, at: [x,y] (split at the nearest intersections around it) | params: split at points: [[x,y]…]"),
CommandSpec::new("sketch.scale", "Sketch Scale", scale)
CommandSpec::new("SketchScaleCmd", "Sketch Scale", scale)
.at("SKETCH", "MODIFY")
.icon("scale")
.enabled(in_sketch)
@@ -63,17 +63,17 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy)
.enabled(in_sketch)
.params("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool"),
CommandSpec::new("sketch.mirror", "Mirror", mirror)
CommandSpec::new("MirrorSketchCommand", "Mirror", mirror)
.at("SKETCH", "CREATE")
.icon("sketch_mirror")
.enabled(in_sketch)
.params("entities: [curve/point ids], line: mirror line id (symmetry constraints are added)"),
CommandSpec::new("sketch.pattern.circular", "Circular Pattern", pattern_circular)
CommandSpec::new("CircularSketchPatternCommand", "Circular Pattern", pattern_circular)
.at("SKETCH", "CREATE")
.icon("sketch_circ_pattern")
.enabled(in_sketch)
.params("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360)"),
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
CommandSpec::new("RectangularSketchPatternCommand", "Rectangular Pattern", pattern_rect)
.at("SKETCH", "CREATE")
.icon("sketch_rect_pattern")
.enabled(in_sketch)
@@ -253,7 +253,7 @@ fn bracket(sk: &Sketch, ci: usize, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) {
}
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.trim";
let cmd = "TrimSketchCmd";
let at = req_vec2(cmd, p, "at")?;
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let ci = curve_arg(sk, p, cmd)?;
@@ -313,7 +313,7 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
}
fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.break";
let cmd = "BreakSketchCmd";
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let ci = curve_arg(sk, p, cmd)?;
if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) {
@@ -349,7 +349,7 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
}
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.extend";
let cmd = "ExtendSketchCmd";
let at = req_vec2(cmd, p, "at")?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let ci = curve_arg(sk, p, cmd)?;
@@ -476,7 +476,7 @@ fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: us
}
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.fillet";
let cmd = "FilletSketchCmd";
let r_expr = expr(p, "radius").ok_or_else(|| bad(cmd, "`radius` must be a number or an expression"))?;
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let r = doc.eval(&r_expr, solvecraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("radius: {e}")))?;
@@ -610,20 +610,20 @@ fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Res
}
fn chamfer_equal(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.chamfer.equal_distance";
let cmd = "ChamferSketchEqualDistance";
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
chamfer(s, p, cmd, |_| Ok((d, d)))
}
fn chamfer_two(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.chamfer.two_distance";
let cmd = "ChamferSketchDistanceDistance";
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
let d2 = num(p, "distance2").ok_or_else(|| bad(cmd, "`distance2` must be a number"))?;
chamfer(s, p, cmd, |_| Ok((d, d2)))
}
fn chamfer_dist_angle(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.chamfer.distance_angle";
let cmd = "ChamferSketchDistanceAngle";
let d = num(p, "distance").ok_or_else(|| bad(cmd, "`distance` must be a number"))?;
let a = num(p, "angle").ok_or_else(|| bad(cmd, "`angle` must be a number (degrees)"))?.to_radians();
chamfer(s, p, cmd, |phi| {
@@ -766,7 +766,7 @@ fn piece_dist(p: &Piece, q: Vec2) -> f64 {
}
fn offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.offset";
let cmd = "Offset";
let ids = string_list(p, "curves");
if ids.is_empty() {
return Err(bad(cmd, "`curves` must list curves"));
@@ -988,7 +988,7 @@ fn copy_through(
}
fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.mirror";
let cmd = "MirrorSketchCommand";
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (cs, ps) = entities(sk, p, cmd)?;
let lid = str_(p, "line").ok_or_else(|| bad(cmd, "`line` must be the mirror line"))?;
@@ -1043,7 +1043,7 @@ fn rotate(c: Vec2, ang: f64) -> impl Fn(Vec2) -> Vec2 {
}
fn pattern_circular(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.pattern.circular";
let cmd = "CircularSketchPatternCommand";
let n = count(p, "count", cmd)?;
let total = num(p, "angle").unwrap_or(360.0);
if !(total.abs() > 1e-9 && total.abs() <= 360.0) {
@@ -1066,7 +1066,7 @@ fn pattern_circular(s: &mut Session, p: &Value) -> Result<Value> {
}
fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.pattern.rectangular";
let cmd = "RectangularSketchPatternCommand";
let n1 = count(p, "count", cmd)?;
let s1 = num(p, "spacing").ok_or_else(|| bad(cmd, "`spacing` must be a number"))?;
let n2 = if p.get("count2").is_some() { count(p, "count2", cmd)? } else { 1 };
@@ -1134,7 +1134,7 @@ fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.scale";
let cmd = "SketchScaleCmd";
let k = num(p, "factor").filter(|k| *k > 1e-9 && *k < 1e6).ok_or_else(|| bad(cmd, "`factor` must be a positive number"))?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (cs, ps) = entities(sk, p, cmd)?;
+105 -108
View File
@@ -22,7 +22,7 @@ fn sketch(s: &Session) -> solvecraft_sketch::Sketch {
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
s
}
@@ -41,10 +41,10 @@ fn close(a: f64, b: f64) -> bool {
fn trim_extend_break() {
let mut s = new_sketch();
// A cross: horizontal line crossed by a vertical one.
let h = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let v = ids(&run(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 5]]}))["curves"])[0].clone();
let h = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let v = ids(&run(&mut s, "DrawPolyline", json!({"points": [[10, -5], [10, 5]]}))["curves"])[0].clone();
// Trim the right half of the horizontal line.
run(&mut s, "sketch.trim", json!({"curve": h, "at": [15, 0]}));
run(&mut s, "TrimSketchCmd", json!({"curve": h, "at": [15, 0]}));
let sk = sketch(&s);
let sh = sk.shape(sk.curve_index(&h).unwrap()).unwrap();
assert!(
@@ -52,32 +52,32 @@ fn trim_extend_break() {
"{sh:?}"
);
// Trim the lower half of the vertical line: the original id survives on the upper piece.
run(&mut s, "sketch.trim", json!({"curve": v, "at": [10, -3]}));
run(&mut s, "TrimSketchCmd", json!({"curve": v, "at": [10, -3]}));
let sk = sketch(&s);
assert_eq!(sk.curves.len(), 2);
// Extend: a short line reaching toward the vertical one.
let e = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 3], [4, 3]]}))["curves"])[0].clone();
run(&mut s, "sketch.extend", json!({"curve": e, "at": [4, 3]}));
let e = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 3], [4, 3]]}))["curves"])[0].clone();
run(&mut s, "ExtendSketchCmd", json!({"curve": e, "at": [4, 3]}));
let sk = sketch(&s);
let end = sk.resolve_point(&format!("{e}.end")).and_then(|i| sk.point(i)).unwrap();
assert!(end.dist(Vec2::new(10.0, 3.0)) < 1e-7, "{end:?}");
// Break the horizontal line where... nothing crosses it inside: error.
assert!(s.execute("sketch.break", &json!({"curve": h, "at": [5, 0]})).is_err());
assert!(s.execute("BreakSketchCmd", &json!({"curve": h, "at": [5, 0]})).is_err());
// Break the vertical at the extended line: two pieces.
let r = run(&mut s, "sketch.break", json!({"curve": v, "at": [10, 1]}));
let r = run(&mut s, "BreakSketchCmd", json!({"curve": v, "at": [10, 1]}));
assert_eq!(ids(&r["curves"]).len(), 2, "{r}");
// A lone curve trims away entirely.
let lone = ids(&run(&mut s, "sketch.line", json!({"points": [[50, 50], [60, 50]]}))["curves"])[0].clone();
run(&mut s, "sketch.trim", json!({"curve": lone, "at": [55, 50]}));
let lone = ids(&run(&mut s, "DrawPolyline", json!({"points": [[50, 50], [60, 50]]}))["curves"])[0].clone();
run(&mut s, "TrimSketchCmd", json!({"curve": lone, "at": [55, 50]}));
assert!(sketch(&s).curve_index(&lone).is_none());
}
#[test]
fn trim_circle_makes_an_arc_and_a_profile() {
let mut s = new_sketch();
let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]]}));
run(&mut s, "sketch.trim", json!({"curve": c, "at": [0, -10]}));
let c = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [[-20, 0], [20, 0]]}));
run(&mut s, "TrimSketchCmd", json!({"curve": c, "at": [0, -10]}));
let sk = sketch(&s);
let ci = sk.curve_index(&c).unwrap();
assert!(matches!(sk.curves[ci].kind, CurveKind::Arc { .. }));
@@ -90,65 +90,65 @@ fn trim_circle_makes_an_arc_and_a_profile() {
#[test]
fn fillet_and_chamfers() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
let l = ids(&r["curves"]);
// Pin the rectangle so a dimension change only moves the fillet.
run(&mut s, "sketch.constraint.fix", json!({"entity": l[2]}));
run(&mut s, "sketch.constraint.fix", json!({"entity": l[3]}));
let f = run(&mut s, "sketch.fillet", json!({"a": l[0], "b": l[1], "radius": 5}));
run(&mut s, "ConstraintFix", json!({"entity": l[2]}));
run(&mut s, "ConstraintFix", json!({"entity": l[3]}));
let f = run(&mut s, "FilletSketchCmd", json!({"a": l[0], "b": l[1], "radius": 5}));
assert!(f["param"].is_string());
let a = profiles(&s);
assert!(close(a[0], 1200.0 - (25.0 - std::f64::consts::PI * 25.0 / 4.0)), "{a:?}");
// The fillet radius is a dimension: change it.
let pname = f["param"].as_str().unwrap().to_string();
run(&mut s, "parameters.change", json!({"name": pname, "expression": "8 mm"}));
run(&mut s, "ChangeParameterCommand", json!({"name": pname, "expression": "8 mm"}));
let a = profiles(&s);
assert!(close(a[0], 1200.0 - (64.0 - std::f64::consts::PI * 64.0 / 4.0)), "{a:?}");
run(&mut s, "sketch.chamfer.equal_distance", json!({"a": l[2], "b": l[3], "distance": 4}));
run(&mut s, "ChamferSketchEqualDistance", json!({"a": l[2], "b": l[3], "distance": 4}));
let a2 = profiles(&s)[0];
assert!(close(a[0] - a2, 8.0), "{a2}");
run(&mut s, "sketch.chamfer.two_distance", json!({"a": l[1], "b": l[2], "distance": 2, "distance2": 6}));
run(&mut s, "ChamferSketchDistanceDistance", json!({"a": l[1], "b": l[2], "distance": 2, "distance2": 6}));
let a3 = profiles(&s)[0];
assert!(close(a2 - a3, 6.0), "{a3}");
// Distance-angle at the last corner (origin, 90 degrees): 45 deg gives an isosceles cut.
run(&mut s, "sketch.chamfer.distance_angle", json!({"a": l[3], "b": l[0], "distance": 3, "angle": 45}));
run(&mut s, "ChamferSketchDistanceAngle", json!({"a": l[3], "b": l[0], "distance": 3, "angle": 45}));
let a4 = profiles(&s)[0];
assert!(close(a3 - a4, 4.5), "{a4}");
assert!(s.execute("sketch.fillet", &json!({"a": l[0], "b": l[2], "radius": 1})).is_err(), "no shared corner");
assert!(s.execute("FilletSketchCmd", &json!({"a": l[0], "b": l[2], "radius": 1})).is_err(), "no shared corner");
}
#[test]
fn offset_chain_mirror_patterns_move_scale() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
let l = ids(&r["curves"]);
// Outward offset by 2 (toward a point outside).
let o = run(&mut s, "sketch.offset", json!({"curves": [l[0]], "distance": 2, "side": [20, -10]}));
let o = run(&mut s, "Offset", json!({"curves": [l[0]], "distance": 2, "side": [20, -10]}));
assert_eq!(ids(&o["curves"]).len(), 4, "{o}");
let a = profiles(&s);
assert!(close(a[1], 44.0 * 34.0 - 1200.0) || close(a.iter().sum::<f64>(), 44.0 * 34.0), "{a:?}");
// Circle offset inward.
let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": [100, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "sketch.offset", json!({"curves": [c], "distance": 3, "side": [100, 0]}));
let c = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [100, 0], "radius": 10}))["curves"])[0].clone();
run(&mut s, "Offset", json!({"curves": [c], "distance": 3, "side": [100, 0]}));
// Mirror a circle about a vertical line.
let m = ids(&run(&mut s, "sketch.line", json!({"points": [[120, -50], [120, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.mirror", json!({"entities": [c], "line": m}));
let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[120, -50], [120, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "MirrorSketchCommand", json!({"entities": [c], "line": m}));
let mc = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
assert!(sk.center(sk.curve_index(&mc).unwrap()).unwrap().dist(Vec2::new(140.0, 0.0)) < 1e-7);
// Circular pattern of the mirrored circle: 4 around (120, 0).
let r = run(&mut s, "sketch.pattern.circular", json!({"entities": [mc], "center": [120, 0], "count": 4}));
let r = run(&mut s, "CircularSketchPatternCommand", json!({"entities": [mc], "center": [120, 0], "count": 4}));
assert_eq!(ids(&r["curves"]).len(), 3);
let sk = sketch(&s);
let c2 = sk.center(sk.curve_index(&ids(&r["curves"])[0]).unwrap()).unwrap();
assert!(c2.dist(Vec2::new(120.0, 20.0)) < 1e-7, "{c2:?}");
// Rectangular pattern 3 x 2.
let small = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0, 100], "radius": 1}))["curves"])[0].clone();
let r = run(&mut s, "sketch.pattern.rectangular", json!({"entities": [small], "count": 3, "spacing": 5, "count2": 2, "spacing2": 4}));
let small = ids(&run(&mut s, "CircleCenterRadius", json!({"center": [0, 100], "radius": 1}))["curves"])[0].clone();
let r = run(&mut s, "RectangularSketchPatternCommand", json!({"entities": [small], "count": 3, "spacing": 5, "count2": 2, "spacing2": 4}));
assert_eq!(ids(&r["curves"]).len(), 5);
// Move and scale.
run(&mut s, "sketch.move", json!({"entities": [small], "translate": [0, 10]}));
run(&mut s, "sketch.scale", json!({"entities": [small], "base": [0, 110], "factor": 3}));
run(&mut s, "SketchScaleCmd", json!({"entities": [small], "base": [0, 110], "factor": 3}));
let sk = sketch(&s);
let ci = sk.curve_index(&small).unwrap();
assert!(sk.center(ci).unwrap().dist(Vec2::new(0.0, 110.0)) < 1e-7);
@@ -162,31 +162,31 @@ fn offset_chain_mirror_patterns_move_scale() {
#[test]
fn new_creation_tools() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.line.midpoint", json!({"mid": [0, 0], "end": [10, 5]}));
let r = run(&mut s, "SketchMidpointLine", json!({"mid": [0, 0], "end": [10, 5]}));
let l = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let start = sk.resolve_point(&format!("{l}.start")).and_then(|i| sk.point(i)).unwrap();
assert!(start.dist(Vec2::new(-10.0, -5.0)) < 1e-9);
// Tangent arc off the end of a horizontal line.
let h = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 20], [10, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.arc.tangent", json!({"start": format!("{h}.end"), "end": [20, 30]}));
let h = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 20], [10, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "ArcTangent", json!({"start": format!("{h}.end"), "end": [20, 30]}));
let a = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let ai = sk.curve_index(&a).unwrap();
assert!(sk.center(ai).unwrap().dist(Vec2::new(10.0, 30.0)) < 1e-7, "{:?}", sk.center(ai));
assert!(close(sk.radius(ai).unwrap(), 10.0));
// 2-tangent circle in the corner of two lines.
let x = ids(&run(&mut s, "sketch.line", json!({"points": [[100, 0], [150, 0]]}))["curves"])[0].clone();
let y = ids(&run(&mut s, "sketch.line", json!({"points": [[100, 0], [100, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.circle.two_tangent", json!({"curves": [x, y], "radius": 5, "near": [106, 104 - 100]}));
let x = ids(&run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [150, 0]]}))["curves"])[0].clone();
let y = ids(&run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [100, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "CircleTanTanRadius", json!({"curves": [x, y], "radius": 5, "near": [106, 104 - 100]}));
let c = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let ci = sk.curve_index(&c).unwrap();
assert!(sk.center(ci).unwrap().dist(Vec2::new(105.0, 5.0)) < 1e-6, "{:?}", sk.center(ci));
// 3-tangent circle inside a triangle: the incircle of the 3-4-5 triangle (r = 1).
let t = run(&mut s, "sketch.line", json!({"points": [[200, 0], [203, 0], [200, 4]], "closed": true}));
let t = run(&mut s, "DrawPolyline", json!({"points": [[200, 0], [203, 0], [200, 4]], "closed": true}));
let tl = ids(&t["curves"]);
let r = run(&mut s, "sketch.circle.three_tangent", json!({"curves": tl, "near": [201, 1.2]}));
let r = run(&mut s, "CircleThreeTangent", json!({"curves": tl, "near": [201, 1.2]}));
let c = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let ci = sk.curve_index(&c).unwrap();
@@ -194,9 +194,9 @@ fn new_creation_tools() {
assert!(sk.center(ci).unwrap().dist(Vec2::new(201.0, 1.0)) < 1e-6);
// Slots.
let before = profiles(&s).len();
run(&mut s, "sketch.slot.center_point", json!({"center": [0, -100], "end": [10, -100], "width": 4}));
run(&mut s, "sketch.slot.arc_three_point", json!({"start": [100, -100], "through": [110, -90], "end": [120, -100], "width": 2}));
run(&mut s, "sketch.slot.arc_center", json!({"center": [200, -100], "start": [210, -100], "end": [200, -90], "width": 2}));
run(&mut s, "ShapeSlotCenterPoint", json!({"center": [0, -100], "end": [10, -100], "width": 4}));
run(&mut s, "ShapeArcSlotThreePoint", json!({"start": [100, -100], "through": [110, -90], "end": [120, -100], "width": 2}));
run(&mut s, "ShapeArcSlotCenterTwoPoint", json!({"center": [200, -100], "start": [210, -100], "end": [200, -90], "width": 2}));
let a = profiles(&s);
assert_eq!(a.len(), before + 3, "{a:?}");
let pi = std::f64::consts::PI;
@@ -210,13 +210,13 @@ fn new_creation_tools() {
fn ellipse_splines_conic() {
let pi = std::f64::consts::PI;
let mut s = new_sketch();
let e = run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor": [0, 4]}));
let e = run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor": [0, 4]}));
let eid = ids(&e["curves"])[0].clone();
let a = profiles(&s);
assert!((a[0] - pi * 40.0).abs() / (pi * 40.0) < 2e-3, "{a:?}");
// A point constrained onto the ellipse lands on it.
run(&mut s, "sketch.point", json!({"point": [3, 5], "id": "q"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "q", "b": eid}));
run(&mut s, "DrawPoint", json!({"point": [3, 5], "id": "q"}));
run(&mut s, "ConstraintCoincident", json!({"a": "q", "b": eid}));
let sk = sketch(&s);
let q = sk.point(sk.point_index("q").unwrap()).unwrap();
let poly = sk.polyline(sk.curve_index(&eid).unwrap());
@@ -227,9 +227,9 @@ fn ellipse_splines_conic() {
});
assert!(d.fold(f64::INFINITY, f64::min) < 1e-6, "{q:?}");
// Fit spline closed by a line: one profile.
let sp = run(&mut s, "sketch.spline.fit_point", json!({"points": [[100, 0], [110, 8], [120, 3], [130, 0]]}));
let sp = run(&mut s, "DrawSpline", json!({"points": [[100, 0], [110, 8], [120, 3], [130, 0]]}));
let sid = ids(&sp["curves"])[0].clone();
run(&mut s, "sketch.line", json!({"points": [format!("{sid}.end"), format!("{sid}.start")]}));
run(&mut s, "DrawPolyline", json!({"points": [format!("{sid}.end"), format!("{sid}.start")]}));
assert_eq!(profiles(&s).len(), 2);
// Moving a fit point reshapes the spline (it is a sketch point).
let sk = sketch(&s);
@@ -240,31 +240,31 @@ fn ellipse_splines_conic() {
let sk = sketch(&s);
assert!(sk.polyline(si).iter().any(|q| q.dist(Vec2::new(110.0, 12.0)) < 1e-9));
// Control point splines (degree 3 and 5) and a conic.
run(&mut s, "sketch.spline.control_point", json!({"points": [[0, 50], [0, 60], [10, 60], [10, 50]]}));
run(&mut s, "sketch.spline.control_point_5", json!({"points": [[20, 50], [20, 60], [25, 65], [30, 60], [35, 62], [40, 50]]}));
run(&mut s, "DrawCVMSpline3D", json!({"points": [[0, 50], [0, 60], [10, 60], [10, 50]]}));
run(&mut s, "DrawCVMSpline5D", json!({"points": [[20, 50], [20, 60], [25, 65], [30, 60], [35, 62], [40, 50]]}));
let w = std::f64::consts::FRAC_1_SQRT_2;
let k = run(&mut s, "sketch.conic", json!({"start": [210, 0], "end": [200, 10], "apex": [210, 10], "rho": w / (1.0 + w)}));
let k = run(&mut s, "ConicCurveCmd", json!({"start": [210, 0], "end": [200, 10], "apex": [210, 10], "rho": w / (1.0 + w)}));
let kid = ids(&k["curves"])[0].clone();
// A line tangent to the conic at its end: the tangent arc tool continues it.
let r = run(&mut s, "sketch.arc.tangent", json!({"start": format!("{kid}.end"), "end": [190, 0]}));
let r = run(&mut s, "ArcTangent", json!({"start": format!("{kid}.end"), "end": [190, 0]}));
let aid = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
// The conic is a quarter circle about (200, 0) leaving (200, 10) heading -x: the tangent
// arc from there to (190, 0) continues the same circle.
let ai = sk.curve_index(&aid).unwrap();
assert!(sk.center(ai).unwrap().dist(Vec2::new(200.0, 0.0)) < 1e-6, "{:?}", sk.center(ai));
assert!(s.execute("sketch.spline.fit_point", &json!({"points": [[0, 0]]})).is_err());
assert!(s.execute("sketch.conic", &json!({"start": [0, 0], "end": [1, 0], "apex": [0, 1], "rho": 1.5})).is_err());
assert!(s.execute("DrawSpline", &json!({"points": [[0, 0]]})).is_err());
assert!(s.execute("ConicCurveCmd", &json!({"start": [0, 0], "end": [1, 0], "apex": [0, 1], "rho": 1.5})).is_err());
// Mirror and pattern copy free-form curves too.
let m = ids(&run(&mut s, "sketch.line", json!({"points": [[-50, -50], [-50, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.mirror", json!({"entities": [eid, sid], "line": m}));
let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-50, -50], [-50, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "MirrorSketchCommand", json!({"entities": [eid, sid], "line": m}));
assert_eq!(ids(&r["curves"]).len(), 2);
}
#[test]
fn text_makes_profiles_that_extrude_and_edit() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.text", json!({"text": "SO", "at": [0, 0], "height": 10}));
let r = run(&mut s, "MTextCmd", json!({"text": "SO", "at": [0, 0], "height": 10}));
let link = r["link"].as_str().unwrap().to_string();
let a = profiles(&s);
// S (one region) + O (ring = outer with a hole, plus the counter as its own region).
@@ -276,21 +276,21 @@ fn text_makes_profiles_that_extrude_and_edit() {
assert_eq!(r2["link"], json!(link));
let a = profiles(&s);
assert_eq!(a.len(), 1, "{a:?}");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2}));
let m = run(&mut s, "inspect.measure", json!({}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 2}));
let m = run(&mut s, "MeasureCommand", json!({}));
let v = m["total"]["volume_mm3"].as_f64().unwrap();
// An "I" 20 mm tall is a bar a couple of mm wide.
assert!(v > 40.0 && v < 200.0, "{v}");
assert!(s.execute("sketch.text", &json!({"text": "", "at": [0, 0]})).is_err());
assert!(s.execute("MTextCmd", &json!({"text": "", "at": [0, 0]})).is_err());
}
#[test]
fn blend_curve_joins_tangentially() {
let mut s = new_sketch();
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 10], [20, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.blend_curve", json!({"a": format!("{a}.end"), "b": format!("{b}.start")}));
let a = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "DrawPolyline", json!({"points": [[20, 10], [20, 20]]}))["curves"])[0].clone();
let r = run(&mut s, "BlendG1CurveSketchCmd", json!({"a": format!("{a}.end"), "b": format!("{b}.start")}));
assert_eq!(r["sketch"]["solved"], true, "{r}");
let sp = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
@@ -305,9 +305,9 @@ fn blend_curve_joins_tangentially() {
#[test]
fn centerline_is_left_out_of_profiles() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 10]}));
let l = ids(&r["curves"]);
let m = ids(&run(&mut s, "sketch.line", json!({"points": [[5, -5], [5, 15]]}))["curves"])[0].clone();
let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[5, -5], [5, 15]]}))["curves"])[0].clone();
assert_eq!(profiles(&s).len(), 2);
run(&mut s, "sketch.centerline", json!({"curves": [m]}));
assert_eq!(profiles(&s).len(), 1);
@@ -318,59 +318,57 @@ fn centerline_is_left_out_of_profiles() {
#[test]
fn free_form_profiles_extrude_exactly() {
let pi = std::f64::consts::PI;
let vol = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let vol = |s: &mut Session| run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
// Ellipse a = 10, b = 4: π a b h.
let mut s = new_sketch();
run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}));
run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}));
let a = profiles(&s)[0];
assert!((a - pi * 40.0).abs() < 1e-9, "{a}");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
let v = vol(&mut s);
assert!((v - pi * 40.0 * 5.0).abs() / (pi * 200.0) < 2e-4, "{v}");
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert_eq!(m["bodies"][0]["faces"], 3, "an exact ellipse: top, bottom and one side ({m})");
// A region closed by a fit spline and a line.
let mut s = new_sketch();
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 8], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.line", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 8], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let a = profiles(&s)[0];
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 2}));
let v = vol(&mut s);
assert!((v - a * 2.0).abs() / (a * 2.0) < 2e-4, "{v} vs {}", a * 2.0);
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert!(m["bodies"][0]["faces"].as_u64().unwrap() <= 5, "{m}");
}
#[test]
fn degree_five_and_cut_free_form_profiles_extrude_exactly() {
let vol = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let vol = |s: &mut Session| run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
// A degree-5 control spline closed by a line: one B-spline side face.
let mut s = new_sketch();
let sp =
ids(&run(&mut s, "sketch.spline.control_point_5", json!({"points": [[0, 0], [4, 10], [10, 12], [16, 12], [22, 10], [26, 0]]}))["curves"])[0]
.clone();
run(&mut s, "sketch.line", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let sp = ids(&run(&mut s, "DrawCVMSpline5D", json!({"points": [[0, 0], [4, 10], [10, 12], [16, 12], [22, 10], [26, 0]]}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
let a = profiles(&s)[0];
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 3}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 3}));
let v = vol(&mut s);
assert!((v - a * 3.0).abs() / (a * 3.0) < 2e-4, "{v} vs {}", a * 3.0);
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert_eq!(m["bodies"][0]["faces"], 4, "{m}");
// A fit spline arch cut by a line: both sides extrude with exact curved faces.
let mut s = new_sketch();
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [6, 9], [14, 9], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.line", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
run(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 20]]}));
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [6, 9], [14, 9], [20, 0]]}))["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [format!("{sp}.end"), format!("{sp}.start")]}));
run(&mut s, "DrawPolyline", json!({"points": [[10, -5], [10, 20]]}));
let areas = profiles(&s);
assert_eq!(areas.len(), 2, "{areas:?}");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 2, "profiles": [0]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 2, "profiles": [0]}));
let v = vol(&mut s);
assert!((v - areas[0] * 2.0).abs() / (areas[0] * 2.0) < 2e-4, "{v} vs {}", areas[0] * 2.0);
let m = run(&mut s, "inspect.measure", json!({}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert!(m["bodies"][0]["faces"].as_u64().unwrap() <= 7, "no polyline facets: {m}");
}
@@ -380,9 +378,9 @@ fn trim_and_break_free_form_curves_exactly() {
let mut s = new_sketch();
// An ellipse cut by a line through its centre: trimming the lower half leaves the upper
// half, which closes with the line into half the ellipse.
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-15, 0], [15, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.trim", json!({"curve": e, "at": [0, -4]}));
let e = ids(&run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-15, 0], [15, 0]]}))["curves"])[0].clone();
let r = run(&mut s, "TrimSketchCmd", json!({"curve": e, "at": [0, -4]}));
assert!(!r["result"]["pieces"].as_array().unwrap().is_empty(), "{r}");
let a = profiles(&s);
assert_eq!(a.len(), 1, "{a:?}");
@@ -390,10 +388,10 @@ fn trim_and_break_free_form_curves_exactly() {
let _ = l;
// A fit spline broken where a line crosses it: two pieces that still trace the curve.
let mut s = new_sketch();
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 10], [20, 0], [30, 5]]}))["curves"])[0].clone();
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 10], [20, 0], [30, 5]]}))["curves"])[0].clone();
let before = sketch(&s).polyline(sketch(&s).curve_index(&sp).unwrap());
run(&mut s, "sketch.line", json!({"points": [[15, -10], [15, 20]]}));
let r = run(&mut s, "sketch.break", json!({"curve": sp, "at": [15, 5]}));
run(&mut s, "DrawPolyline", json!({"points": [[15, -10], [15, 20]]}));
let r = run(&mut s, "BreakSketchCmd", json!({"curve": sp, "at": [15, 5]}));
let pieces = ids(&r["curves"]);
assert_eq!(pieces.len(), 4, "three Béziers, the middle one split: {r}");
let sk = sketch(&s);
@@ -417,9 +415,9 @@ fn trim_and_break_free_form_curves_exactly() {
#[test]
fn ellipse_radii_dimensions() {
let mut s = new_sketch();
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
run(&mut s, "sketch.dimension", json!({"entities": [e], "type": "major", "value": 12}));
run(&mut s, "sketch.dimension", json!({"entities": [e], "value": 5}));
let e = ids(&run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor_radius": 4}))["curves"])[0].clone();
run(&mut s, "SketchDimension", json!({"entities": [e], "type": "major", "value": 12}));
run(&mut s, "SketchDimension", json!({"entities": [e], "value": 5}));
let a = profiles(&s)[0];
assert!((a - std::f64::consts::PI * 60.0).abs() < 1e-6, "{a}");
}
@@ -428,11 +426,10 @@ fn ellipse_radii_dimensions() {
fn fillet_a_line_and_an_arc() {
let mut s = new_sketch();
// A line into a quarter arc meeting at a sharp corner.
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let a =
ids(&run(&mut s, "sketch.arc.center_point", json!({"center": [30, 0], "start": format!("{l}.end"), "end": [30, -10]}))["curves"])[0].clone();
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone();
let a = ids(&run(&mut s, "ArcCenterTwoPoint", json!({"center": [30, 0], "start": format!("{l}.end"), "end": [30, -10]}))["curves"])[0].clone();
let _ = a;
let f = run(&mut s, "sketch.fillet", json!({"point": format!("{l}.end"), "radius": 2}));
let f = run(&mut s, "FilletSketchCmd", json!({"point": format!("{l}.end"), "radius": 2}));
let arc = ids(&f["curves"])[0].clone();
let sk = sketch(&s);
let ai = sk.curve_index(&arc).unwrap();
@@ -447,8 +444,8 @@ fn fillet_a_line_and_an_arc() {
#[test]
fn offset_free_form_curves() {
let mut s = new_sketch();
let sp = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 8], [20, 0], [30, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.offset", json!({"curves": [sp], "distance": 2, "side": [10, 20]}));
let sp = ids(&run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 8], [20, 0], [30, 6]]}))["curves"])[0].clone();
let r = run(&mut s, "Offset", json!({"curves": [sp], "distance": 2, "side": [10, 20]}));
let made = ids(&r["curves"]);
assert_eq!(made.len(), 1, "{r}");
let sk = sketch(&s);
@@ -467,7 +464,7 @@ fn offset_free_form_curves() {
assert!((dist(*q) - 2.0).abs() < 0.08, "{q:?} {}", dist(*q));
}
// An ellipse grows by the offset all round.
let e = ids(&run(&mut s, "sketch.ellipse", json!({"center": [100, 0], "major": [110, 0], "minor_radius": 5}))["curves"])[0].clone();
let r = run(&mut s, "sketch.offset", json!({"curves": [e], "distance": 1, "side": [100, 20]}));
let e = ids(&run(&mut s, "CircleElipse", json!({"center": [100, 0], "major": [110, 0], "minor_radius": 5}))["curves"])[0].clone();
let r = run(&mut s, "Offset", json!({"curves": [e], "distance": 1, "side": [100, 20]}));
assert_eq!(ids(&r["curves"]).len(), 1, "{r}");
}
+2 -2
View File
@@ -15,7 +15,7 @@ fn ms(t: Instant) -> f64 {
/// them, built straight into the document.
fn big(n: usize) -> Session {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap();
let id = s.active_sketch.unwrap();
let mut doc = (*s.doc).clone();
let sk = doc.sketch_mut(id).unwrap();
@@ -67,7 +67,7 @@ fn sketch_perf() {
s.execute("sketch.snap", &json!({"at": [10.0, 0.2], "from": [0, 0], "radius": 1})).unwrap();
let snap = ms(t);
let t = Instant::now();
s.execute("sketch.line", &json!({"points": [[-50, -50], [-40, -45]]})).unwrap();
s.execute("DrawPolyline", &json!({"points": [[-50, -50], [-40, -45]]})).unwrap();
let add = ms(t);
let t = Instant::now();
s.execute("sketch.move_point", &json!({"point": "p1", "to": [1, 1]})).unwrap();
+16 -16
View File
@@ -14,36 +14,36 @@ use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(REFS_DOC),
CommandSpec::new("sketch.intersect", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
CommandSpec::new("ProjectNewCmd", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(REFS_DOC),
CommandSpec::new("IntersectCmd", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
"refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane, linked",
),
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", include_cmd)
CommandSpec::new("Include3DGeometry", "Include 3D Geometry", include_cmd)
.at("SKETCH", "CREATE")
.icon("include")
.enabled(in_sketch)
.params("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it"),
CommandSpec::new("sketch.project_to_surface", "Project To Surface", project_to_surface)
CommandSpec::new("ProjectToSurface", "Project To Surface", project_to_surface)
.at("SKETCH", "CREATE")
.icon("project_surface")
.enabled(in_sketch)
.params("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves"),
CommandSpec::new("sketch.intersection_curve", "Intersection Curve", intersection_curve)
CommandSpec::new("IntersectionCurve", "Intersection Curve", intersection_curve)
.at("SKETCH", "CREATE")
.icon("intersection_curve")
.enabled(in_sketch)
.params("a, b: {body} | {face: [x,y,z], body?}: where they meet, as linked 3D curves"),
CommandSpec::new("sketch.spun_profile", "Spun Profile", spun_profile)
CommandSpec::new("SpunProfileCmd", "Spun Profile", spun_profile)
.at("SKETCH", "CREATE")
.icon("spun")
.enabled(in_sketch)
.params("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane"),
CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", iso_curve)
CommandSpec::new("SketchIsoparametricCurve", "Isoparametric Curve", iso_curve)
.at("SKETCH", "CREATE")
.icon("iso_curve")
.enabled(in_sketch)
.params("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve"),
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", fit_section)
CommandSpec::new("FitCurvesToSectionCommand", "Fit Curves to Mesh Section", fit_section)
.at("SKETCH", "CREATE")
.icon("fit_section")
.enabled(in_sketch)
@@ -176,15 +176,15 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind) -> Result<Va
}
fn project_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "sketch.project", LinkKind::Project)
run_links(s, p, "ProjectNewCmd", LinkKind::Project)
}
fn intersect_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "sketch.intersect", LinkKind::Intersect)
run_links(s, p, "IntersectCmd", LinkKind::Intersect)
}
fn include_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "sketch.include_3d", LinkKind::Include)
run_links(s, p, "Include3DGeometry", LinkKind::Include)
}
/// Add one link from `src` to the active sketch and report it.
@@ -204,7 +204,7 @@ fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Resu
}
fn project_to_surface(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.project_to_surface";
let cmd = "ProjectToSurface";
let at = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on the target face"))?;
let body = str_(p, "body").unwrap_or("").to_string();
let list = p.get("curves").and_then(Value::as_array).cloned().ok_or_else(|| bad(cmd, "`curves` must list {sketch, curve}"))?;
@@ -222,7 +222,7 @@ fn project_to_surface(s: &mut Session, p: &Value) -> Result<Value> {
}
fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.intersection_curve";
let cmd = "IntersectionCurve";
let side = |k: &str| -> Result<LinkSource> {
let v = p.get(k).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?;
match source(s, v, cmd)? {
@@ -235,7 +235,7 @@ fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
}
fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.spun_profile";
let cmd = "SpunProfileCmd";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must name a body"))?.to_string();
let axis = str_(p, "axis").ok_or_else(|| bad(cmd, "`axis` must be a sketch line or X|Y|Z"))?;
let (origin, dir) = match axis.to_ascii_uppercase().as_str() {
@@ -256,7 +256,7 @@ fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
}
fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.isoparametric_curve";
let cmd = "SketchIsoparametricCurve";
let at = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x,y,z] on the face"))?;
let body = str_(p, "body").unwrap_or("").to_string();
let dir = match p.get("direction") {
@@ -271,7 +271,7 @@ fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
}
fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.fit_curves_to_section";
let cmd = "FitCurvesToSectionCommand";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must name a body"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
+88 -88
View File
@@ -15,20 +15,20 @@ fn ids(v: &Value) -> Vec<String> {
/// A 40 x 30 x 20 box from a sketch whose width is the user parameter `width`.
fn box_part(s: &mut Session) {
run(s, "parameters.change", json!({"name": "width", "expression": "40 mm"}));
run(s, "sketch.create", json!({"plane": "XY"}));
let r = run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
run(s, "ChangeParameterCommand", json!({"name": "width", "expression": "40 mm"}));
run(s, "SketchCreate", json!({"plane": "XY"}));
let r = run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
let l = ids(&r["curves"]);
let sk = s.doc.sketch(s.active_sketch.unwrap()).unwrap();
let start = match sk.curves[0].kind {
solvecraft_sketch::CurveKind::Line { a, .. } => sk.points[a].id.clone(),
_ => panic!(),
};
run(s, "sketch.constraint.coincident", json!({"a": start, "b": "origin"}));
run(s, "sketch.dimension", json!({"entities": [l[0]], "value": "width"}));
run(s, "sketch.dimension", json!({"entities": [l[1]], "value": 30}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": 20}));
run(s, "ConstraintCoincident", json!({"a": start, "b": "origin"}));
run(s, "SketchDimension", json!({"entities": [l[0]], "value": "width"}));
run(s, "SketchDimension", json!({"entities": [l[1]], "value": 30}));
run(s, "SketchStop", json!({}));
run(s, "Extrude", json!({"distance": 20}));
}
fn inspect(s: &mut Session) -> Value {
@@ -43,7 +43,7 @@ fn linked_curves(si: &Value) -> Vec<Value> {
fn sketch_on_face_projects_its_edges_and_follows_edits() {
let mut s = Session::default();
box_part(&mut s);
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
assert!(r["projected"].is_string(), "{r}");
let si = inspect(&mut s);
let lc = linked_curves(&si);
@@ -55,16 +55,16 @@ fn sketch_on_face_projects_its_edges_and_follows_edits() {
// Projected geometry is fixed: the sketch has no degrees of freedom.
assert_eq!(si["dof"], 0, "{si}");
// A circle on the face, dimensioned to a projected edge.
let c = run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 3}));
let c = run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 3}));
let cid = ids(&c["curves"])[0].clone();
let e = lc.iter().find(|c| c["start_at"][1].as_f64().unwrap().abs() < 1e-9 && c["end_at"][1].as_f64().unwrap().abs() < 1e-9).unwrap();
run(&mut s, "sketch.dimension", json!({"entities": [format!("{cid}.center"), e["id"]], "value": 8}));
run(&mut s, "SketchDimension", json!({"entities": [format!("{cid}.center"), e["id"]], "value": 8}));
let si = inspect(&mut s);
let center = si["points"].as_array().unwrap().iter().find(|p| p["id"] == format!("{cid}.center")).unwrap();
assert!((center["at"][1].as_f64().unwrap() - 8.0).abs() < 1e-7, "{center}");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
// Widen the box: the projected loop follows.
run(&mut s, "parameters.change", json!({"name": "width", "expression": "50 mm"}));
run(&mut s, "ChangeParameterCommand", json!({"name": "width", "expression": "50 mm"}));
let st = s.model.state();
let ss = st.sketches.last().unwrap();
assert_eq!(ss.profiles.len(), 2);
@@ -78,11 +78,11 @@ fn sketch_on_face_projects_its_edges_and_follows_edits() {
fn auto_project_can_be_turned_off() {
let mut s = Session::default();
box_part(&mut s);
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}, "project_edges": false}));
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}, "project_edges": false}));
assert!(r["projected"].is_null());
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "sketch.auto_project", json!({"value": false}));
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
assert!(r["projected"].is_null());
}
@@ -90,10 +90,10 @@ fn auto_project_can_be_turned_off() {
fn project_edges_bodies_sketches_and_origin() {
let mut s = Session::default();
box_part(&mut s);
run(&mut s, "solid.cylinder", json!({"base": [80, 0, 5], "radius": 6, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "PrimitiveCylinder", json!({"base": [80, 0, 5], "radius": 6, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
// An edge on the top face projects to a line on XY.
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 30, 20]}]}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [20, 30, 20]}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "line");
@@ -101,7 +101,7 @@ fn project_edges_bodies_sketches_and_origin() {
assert!(ys.iter().all(|y| (y - 30.0).abs() < 1e-9), "{c}");
// The cylinder's silhouette seen from above is its circle, exactly.
let name = s.model.state().bodies[1].name.clone();
let r = run(&mut s, "sketch.project", json!({"refs": [{"body": name}]}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"body": name}]}));
let cid = r["links"][0]["curves"][0].clone();
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == cid).unwrap().clone();
@@ -109,34 +109,34 @@ fn project_edges_bodies_sketches_and_origin() {
assert!((c["radius"].as_f64().unwrap() - 6.0).abs() < 1e-6, "{c}");
assert!((c["center_at"][0].as_f64().unwrap() - 80.0).abs() < 1e-6, "{c}");
// Origin, the Y axis and the YZ plane.
run(&mut s, "sketch.project", json!({"refs": ["origin", {"axis": "Y"}, {"plane": "YZ"}]}));
run(&mut s, "ProjectNewCmd", json!({"refs": ["origin", {"axis": "Y"}, {"plane": "YZ"}]}));
// The box silhouette from above is its 40 x 30 outline.
let b0 = s.model.state().bodies[0].name.clone();
let r = run(&mut s, "sketch.project", json!({"refs": [{"body": b0}]}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"body": b0}]}));
assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
let first = s.active_sketch;
assert!(first.is_none());
let sk1 = s.doc.features.last().unwrap().id;
// Another sketch projects a curve of the first one.
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 50}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": sk1, "curve": cid}]}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"sketch": sk1, "curve": cid}]}));
let c2 = r["links"][0]["curves"][0].clone();
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == c2).unwrap().clone();
assert_eq!(c["type"], "circle");
// Bad references fail cleanly.
assert!(s.execute("sketch.project", &json!({"refs": [{"edge": [1000, 1000, 1000]}]})).is_err());
assert!(s.execute("sketch.project", &json!({"refs": [{"plane": "XY"}]})).is_err(), "parallel plane projects to nothing");
assert!(s.execute("ProjectNewCmd", &json!({"refs": [{"edge": [1000, 1000, 1000]}]})).is_err());
assert!(s.execute("ProjectNewCmd", &json!({"refs": [{"plane": "XY"}]})).is_err(), "parallel plane projects to nothing");
}
#[test]
fn intersect_cuts_a_body_with_the_sketch_plane() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 12}));
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 12}));
let name = s.model.state().bodies[0].name.clone();
let r = run(&mut s, "sketch.intersect", json!({"refs": [{"body": name}]}));
let r = run(&mut s, "IntersectCmd", json!({"refs": [{"body": name}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "circle", "{c}");
@@ -148,7 +148,7 @@ fn intersect_cuts_a_body_with_the_sketch_plane() {
fn break_link_makes_normal_geometry() {
let mut s = Session::default();
box_part(&mut s);
let r = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
let r = run(&mut s, "SketchCreate", json!({"plane": {"face": [20, 15, 20]}}));
let link = r["projected"].as_str().unwrap().to_string();
run(&mut s, "sketch.break_link", json!({"link": link}));
let si = inspect(&mut s);
@@ -162,11 +162,11 @@ fn break_link_makes_normal_geometry() {
#[test]
fn lost_reference_keeps_geometry_with_a_warning() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
let boxf = s.doc.features.last().unwrap().id;
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [5, 0, 10]}]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [5, 0, 10]}]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "timeline.suppress", json!({"feature": boxf, "suppressed": true}));
let st = s.model.state();
let ss = st.sketches.last().unwrap();
@@ -180,8 +180,8 @@ fn lost_reference_keeps_geometry_with_a_warning() {
fn drawing_on_a_model_vertex_projects_it() {
let mut s = Session::default();
box_part(&mut s);
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.line", json!({"points": ["vertex:40,30,20", [60, 45]]}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "DrawPolyline", json!({"points": ["vertex:40,30,20", [60, 45]]}));
let l = ids(&r["curves"])[0].clone();
let si = inspect(&mut s);
let start = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == l).unwrap()["start"].clone();
@@ -190,7 +190,7 @@ fn drawing_on_a_model_vertex_projects_it() {
assert_eq!(p["fixed"], true);
assert!((p["at"][0].as_f64().unwrap() - 40.0).abs() < 1e-9);
// Snapping onto an edge projects it and keeps the point on it.
let r = run(&mut s, "sketch.line", json!({"points": [{"on_edge": [20, 0, 20]}, [20, -15]]}));
let r = run(&mut s, "DrawPolyline", json!({"points": [{"on_edge": [20, 0, 20]}, [20, -15]]}));
assert_eq!(ids(&r["curves"]).len(), 1);
let si = inspect(&mut s);
assert!(si["constraints"].as_array().unwrap().iter().any(|c| c["type"] == "point_on_curve"), "{si}");
@@ -199,41 +199,41 @@ fn drawing_on_a_model_vertex_projects_it() {
#[test]
fn fit_curves_to_a_mesh_section() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 10}));
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 7, "height": 30}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 10}));
let name = s.model.state().bodies[0].name.clone();
let r = run(&mut s, "sketch.fit_curves_to_section", json!({"body": name}));
let r = run(&mut s, "FitCurvesToSectionCommand", json!({"body": name}));
assert_eq!(r["curves"].as_array().unwrap().len(), 1, "{r}");
let si = inspect(&mut s);
assert!(si["links"].as_array().unwrap().is_empty());
let c = &si["curves"][0];
assert_eq!(c["type"], "circle");
assert!(c.get("link").is_none());
assert!(s.execute("sketch.fit_curves_to_section", &json!({"body": "nope"})).is_err());
assert!(s.execute("FitCurvesToSectionCommand", &json!({"body": "nope"})).is_err());
}
#[test]
fn three_d_sketch_curves() {
let mut s = Session::default();
// Include keeps an out-of-plane edge 3D.
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.include_3d", json!({"refs": [{"edge": [10, 10, 5]}]}));
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "Include3DGeometry", json!({"refs": [{"edge": [10, 10, 5]}]}));
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
assert_eq!(sk.wires.len(), 1, "{r}");
assert!((sk.wires[0].pts.iter().map(|p| p.z).fold(0.0, f64::max) - 10.0).abs() < 1e-9);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
// Project a line onto a sphere along the sketch normal.
let mut s = Session::default();
run(&mut s, "solid.sphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-10, 0], [10, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "PrimitiveSphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-10, 0], [10, 0]]}))["curves"])[0].clone();
run(&mut s, "SketchStop", json!({}));
let src = s.doc.features.last().unwrap().id;
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project_to_surface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectToSurface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
let wid = r["links"][0]["wires"][0].as_str().unwrap().to_string();
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
@@ -246,16 +246,16 @@ fn three_d_sketch_curves() {
// Delete the wire.
run(&mut s, "sketch.delete", json!({"entities": [wid]}));
assert!(s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.wires.is_empty());
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
// Two crossing cylinders meet in 3D curves.
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, -20], "radius": 5, "height": 40}));
run(&mut s, "solid.cylinder", json!({"base": [-20, 0, 0], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, -20], "radius": 5, "height": 40}));
run(&mut s, "PrimitiveCylinder", json!({"base": [-20, 0, 0], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names.len(), 2, "{names:?}");
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.intersection_curve", json!({"a": {"body": names[0]}, "b": {"body": names[1]}}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "IntersectionCurve", json!({"a": {"body": names[0]}, "b": {"body": names[1]}}));
assert!(!r["wires"].as_array().unwrap().is_empty(), "{r}");
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
@@ -265,14 +265,14 @@ fn three_d_sketch_curves() {
assert!(((p.y * p.y + p.z * p.z).sqrt() - 4.0).abs() < 0.1, "{p:?}");
}
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
// Spun profile of a cylinder about Z, on the XZ plane: a straight outline 6 from the axis.
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
let name = s.model.state().bodies[0].name.clone();
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
let r = run(&mut s, "sketch.spun_profile", json!({"body": name, "axis": "Z"}));
run(&mut s, "SketchCreate", json!({"plane": "XZ"}));
let r = run(&mut s, "SpunProfileCmd", json!({"body": name, "axis": "Z"}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "line", "{c}");
@@ -282,15 +282,15 @@ fn three_d_sketch_curves() {
#[test]
fn isoparametric_curves_of_a_cylinder() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "radius": 6, "height": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
// u: around (a circle at z = 10); v: along (a line from z 0 to 20).
let r = run(&mut s, "sketch.isoparametric_curve", json!({"face": [6, 0, 10]}));
let r = run(&mut s, "SketchIsoparametricCurve", json!({"face": [6, 0, 10]}));
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
let w = &sk.wires[sk.wire_index(r["wires"][0].as_str().unwrap()).unwrap()];
assert!(w.pts.iter().all(|p| (p.z - 10.0).abs() < 1e-6 && ((p.x * p.x + p.y * p.y).sqrt() - 6.0).abs() < 1e-3), "{:?}", &w.pts[..3]);
let r = run(&mut s, "sketch.isoparametric_curve", json!({"face": [6, 0, 10], "direction": "v"}));
let r = run(&mut s, "SketchIsoparametricCurve", json!({"face": [6, 0, 10], "direction": "v"}));
let st = s.model.state();
let sk = &st.sketch(s.active_sketch.unwrap()).unwrap().sketch;
let w = &sk.wires[sk.wire_index(r["wires"][0].as_str().unwrap()).unwrap()];
@@ -302,10 +302,10 @@ fn isoparametric_curves_of_a_cylinder() {
#[test]
fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "axis": [1, 0, 1], "radius": 5, "height": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": -10}));
run(&mut s, "PrimitiveCylinder", json!({"base": [0, 0, 0], "axis": [1, 0, 1], "radius": 5, "height": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": -10}));
// The base circle (centred at the origin) seen from above.
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [0, 5, 0]}]}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [0, 5, 0]}]}));
// One seam half: exact conics (rational quadratic arcs).
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
@@ -320,7 +320,7 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
}
}
// The whole face (both halves of its rim, plus its seam) gives the full ellipse.
let r = run(&mut s, "sketch.project", json!({"refs": [{"face": [0, 0, 0]}]}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"face": [0, 0, 0]}]}));
let si = inspect(&mut s);
let types: Vec<String> = r["links"][0]["curves"]
.as_array()
@@ -329,7 +329,7 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
.map(|id| si["curves"].as_array().unwrap().iter().find(|c| c["id"] == *id).unwrap()["type"].as_str().unwrap().to_string())
.collect();
assert!(types.iter().all(|t| t == "conic" || t == "ellipse"), "{types:?}");
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [0, -5, 0]}]}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [0, -5, 0]}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
if c["type"] == "ellipse" {
@@ -337,14 +337,14 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
let major = &c["major_at"];
assert!(((major[0].as_f64().unwrap().powi(2) + major[1].as_f64().unwrap().powi(2)).sqrt() - 5.0).abs() < 1e-6, "{c}");
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
// A spline of a parallel sketch projects as the same spline.
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 30}));
let sp = run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 5], [20, 0]]}))["curves"][0].clone();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 30}));
let sp = run(&mut s, "DrawSpline", json!({"points": [[0, 0], [10, 5], [20, 0]]}))["curves"][0].clone();
run(&mut s, "SketchStop", json!({}));
let sk1 = s.doc.features.last().unwrap().id;
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": sk1, "curve": sp}]}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"sketch": sk1, "curve": sp}]}));
let si = inspect(&mut s);
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == r["links"][0]["curves"][0]).unwrap().clone();
assert_eq!(c["type"], "spline", "{c}");
@@ -355,13 +355,13 @@ fn tilted_circles_project_to_exact_ellipses_and_splines_stay_splines() {
fn sweeps_and_pipes_follow_3d_sketch_curves() {
// A line projected onto a sphere: a 3D arc on its top.
let mut s = Session::default();
run(&mut s, "solid.sphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[-12, 0], [12, 0]]}))["curves"])[0].clone();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "PrimitiveSphere", json!({"center": [0, 0, 0], "radius": 20}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "offset": 50}));
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-12, 0], [12, 0]]}))["curves"])[0].clone();
run(&mut s, "SketchStop", json!({}));
let src = s.doc.features.last().unwrap().id;
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project_to_surface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectToSurface", json!({"curves": [{"sketch": src, "curve": l}], "face": [0, 0, 20]}));
let wid = r["links"][0]["wires"][0].as_str().unwrap().to_string();
let path_sketch = s.active_sketch.unwrap();
let pts = {
@@ -369,11 +369,11 @@ fn sweeps_and_pipes_follow_3d_sketch_curves() {
let sk = &st.sketch(path_sketch).unwrap().sketch;
sk.wires[sk.wire_index(&wid).unwrap()].pts.clone()
};
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "SketchStop", json!({}));
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
// A pipe along it: volume ≈ π r² × length.
let before = s.model.state().bodies.len();
run(&mut s, "solid.pipe", json!({"path_sketch": path_sketch, "path": [wid.clone()], "diameter": "2 mm", "operation": "new"}));
run(&mut s, "PrimitivePipe", json!({"path_sketch": path_sketch, "path": [wid.clone()], "diameter": "2 mm", "operation": "new"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), before + 1);
let v = st.bodies.last().unwrap().mesh().measure().volume;
@@ -385,11 +385,11 @@ fn sweeps_and_pipes_follow_3d_sketch_curves() {
let t = (b - a).normalized().unwrap();
let x = t.cross(solvecraft_geom::Vec3::Z).normalized().unwrap();
let y = t.cross(x);
run(&mut s, "sketch.create", json!({"plane": {"origin": [a.x, a.y, a.z], "x_dir": [x.x, x.y, x.z], "y_dir": [y.x, y.y, y.z]}}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-1, -1], "p1": [1, 1]}));
run(&mut s, "SketchCreate", json!({"plane": {"origin": [a.x, a.y, a.z], "x_dir": [x.x, x.y, x.z], "y_dir": [y.x, y.y, y.z]}}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [-1, -1], "p1": [1, 1]}));
let prof = s.active_sketch.unwrap();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.sweep", json!({"sketch": prof, "path_sketch": path_sketch, "path": [wid], "operation": "new"}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Sweep", json!({"sketch": prof, "path_sketch": path_sketch, "path": [wid], "operation": "new"}));
let st = s.model.state();
assert_eq!(st.bodies.len(), before + 2);
let v = st.bodies.last().unwrap().mesh().measure().volume;
+9 -25
View File
@@ -19,11 +19,11 @@ fn ids(v: &Value) -> Vec<String> {
/// A new sketch with one rectangle of the given kind; returns its four side ids.
fn rect(kind: &str) -> (Session, Vec<String>) {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = match kind {
"center" => run(&mut s, "sketch.rectangle.center", json!({"center": [3, 2], "corner": [13, 9]})),
"two" => run(&mut s, "sketch.rectangle.two_point", json!({"p0": [1, 1], "p1": [21, 15]})),
_ => run(&mut s, "sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15]})),
"center" => run(&mut s, "ShapeRectangleCenter", json!({"center": [3, 2], "corner": [13, 9]})),
"two" => run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [1, 1], "p1": [21, 15]})),
_ => run(&mut s, "ShapeRectangleThreePoint", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15]})),
};
let sides = ids(&r["curves"]);
assert_eq!(sides.len(), 4, "{r}");
@@ -54,9 +54,9 @@ fn fresh_rectangles_have_the_expected_freedom() {
fn rectangles_take_dimensions_without_conflict() {
for k in KINDS {
let (mut s, l) = rect(k);
run(&mut s, "sketch.dimension", json!({"entities": [l[0]], "value": 30}));
run(&mut s, "SketchDimension", json!({"entities": [l[0]], "value": 30}));
healthy(&mut s, &format!("{k} width"));
run(&mut s, "sketch.dimension", json!({"entities": [l[1]], "value": 12}));
run(&mut s, "SketchDimension", json!({"entities": [l[1]], "value": 12}));
let dof = healthy(&mut s, &format!("{k} width+height"));
assert_eq!(dof, if k == "three" { 3 } else { 2 }, "{k}");
}
@@ -72,12 +72,12 @@ fn rectangles_can_be_dragged_pinned_and_made_square() {
run(&mut s, "sketch.move_point", json!({"point": format!("{}.end", l[2]), "to": [25, 22]}));
healthy(&mut s, &format!("{k} drag other corner"));
// Pin a corner to the origin.
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", l[0]), "b": "origin"}));
run(&mut s, "ConstraintCoincident", json!({"a": format!("{}.start", l[0]), "b": "origin"}));
healthy(&mut s, &format!("{k} corner on origin"));
// Square: equal sides.
run(&mut s, "sketch.constraint.equal", json!({"a": l[0], "b": l[1]}));
run(&mut s, "ConstraintEqual", json!({"a": l[0], "b": l[1]}));
healthy(&mut s, &format!("{k} square"));
run(&mut s, "sketch.dimension", json!({"entities": [l[0]], "value": 10}));
run(&mut s, "SketchDimension", json!({"entities": [l[0]], "value": 10}));
let dof = healthy(&mut s, &format!("{k} square sized"));
assert_eq!(dof, if k == "three" { 1 } else { 0 }, "{k}");
}
@@ -101,19 +101,3 @@ fn dragging_corners_never_collapses_a_side() {
}
}
}
#[test]
fn center_rectangle_draws_both_diagonals_like_fusion() {
let (mut s, _) = rect("center");
let si = run(&mut s, "sketch.inspect", json!({}));
let curves = si["curves"].as_array().cloned().unwrap_or_default();
// Four sides plus two construction diagonals; the other rectangles have no diagonals.
assert_eq!(curves.len(), 6, "{si}");
assert_eq!(curves.iter().filter(|c| c["construction"] == true).count(), 2, "{si}");
assert_eq!(healthy(&mut s, "center diagonals"), 4);
for k in ["two", "three"] {
let (mut s, _) = rect(k);
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["curves"].as_array().map(Vec::len), Some(4), "{k}: {si}");
}
}
+102 -102
View File
@@ -20,7 +20,7 @@ fn sketch(s: &Session) -> Sketch {
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
s
}
@@ -30,11 +30,11 @@ fn new_sketch() -> Session {
#[test]
fn every_dimension_kind_has_a_drawable_frame() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [60, 40]}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 20], "radius": 8}));
run(&mut s, "sketch.arc.center_point", json!({"center": [100, 0], "start": [110, 0], "end": [100, 10]}));
run(&mut s, "sketch.line", json!({"points": [[-40, 0], [-10, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[-40, 0], [-20, 25]]}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [60, 40]}));
run(&mut s, "CircleCenterRadius", json!({"center": [30, 20], "radius": 8}));
run(&mut s, "ArcCenterTwoPoint", json!({"center": [100, 0], "start": [110, 0], "end": [100, 10]}));
run(&mut s, "DrawPolyline", json!({"points": [[-40, 0], [-10, 0]]}));
run(&mut s, "DrawPolyline", json!({"points": [[-40, 0], [-20, 25]]}));
for (ents, ty) in [
(json!(["l1"]), "auto"),
(json!(["l2"]), "vertical"),
@@ -44,9 +44,9 @@ fn every_dimension_kind_has_a_drawable_frame() {
(json!(["a1"]), "arc_length"),
(json!(["l5", "l6"]), "angle"),
] {
run(&mut s, "sketch.dimension", json!({"entities": ents, "type": ty}));
run(&mut s, "SketchDimension", json!({"entities": ents, "type": ty}));
}
run(&mut s, "sketch.dimension", json!({"entities": ["c1.center", "l2"], "driven": true}));
run(&mut s, "SketchDimension", json!({"entities": ["c1.center", "l2"], "driven": true}));
let sk = sketch(&s);
let dims: Vec<_> = sk.constraints.iter().filter(|c| c.kind.is_dimension()).collect();
assert_eq!(dims.len(), 8);
@@ -71,8 +71,8 @@ fn every_dimension_kind_has_a_drawable_frame() {
#[test]
fn dimension_text_is_stored_relative_to_the_dimension_and_follows_it() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]]}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "text_at": [20, -8]}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [40, 0]]}));
let d = run(&mut s, "SketchDimension", json!({"entities": ["l1"], "text_at": [20, -8]}));
let param = d["param"].as_str().unwrap_or_default().to_string();
let text = |s: &Session| {
let sk = sketch(s);
@@ -102,8 +102,8 @@ fn dimension_text_is_stored_relative_to_the_dimension_and_follows_it() {
#[test]
fn deleting_a_dimension_frees_its_parameter() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
let d = run(&mut s, "sketch.dimension", json!({"entities": ["c1"]}));
run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 5}));
let d = run(&mut s, "SketchDimension", json!({"entities": ["c1"]}));
let param = d["param"].as_str().unwrap_or_default().to_string();
let id = sketch(&s).constraints.iter().find(|c| c.param.as_deref() == Some(param.as_str())).map(|c| c.id.clone()).expect("dim");
run(&mut s, "sketch.delete", json!({"entities": [id]}));
@@ -126,22 +126,22 @@ fn dof(s: &mut Session) -> i64 {
#[test]
fn fixed_points_survive_drags_and_solves_and_unfix_frees_them() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0], [30, 20]]}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 0], [30, 20]]}));
let free = dof(&mut s);
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"}));
let r = run(&mut s, "ConstraintFix", json!({"entity": "l1.end"}));
assert_eq!(r["result"]["fixed"], true);
assert_eq!(dof(&mut s), free - 2);
let corner = pt(&s, "l1.end");
// Dragging the fixed point does nothing; dragging its neighbours never moves it.
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [50, 50]}));
assert_eq!(pt(&s, "l1.end"), corner);
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "vertical"}));
run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l2", "mode": "vertical"}));
run(&mut s, "sketch.move_point", json!({"point": "l2.end", "to": [40, 25]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 12}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 12}));
assert!(pt(&s, "l1.end").dist(corner) < 1e-12, "{:?}", pt(&s, "l1.end"));
assert!((pt(&s, "l1.start").dist(corner) - 12.0).abs() < 1e-6);
// Unfix: the point moves again.
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"}));
let r = run(&mut s, "ConstraintFix", json!({"entity": "l1.end"}));
assert_eq!(r["result"]["fixed"], false);
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [30, -5]}));
assert!(pt(&s, "l1.end").dist(corner) > 1.0);
@@ -150,13 +150,13 @@ fn fixed_points_survive_drags_and_solves_and_unfix_frees_them() {
#[test]
fn fixing_curves_holds_their_points_and_radius() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 5}));
run(&mut s, "sketch.line", json!({"points": [[30, 0], [50, 0]]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 40], "start": [10, 40], "end": [0, 50]}));
run(&mut s, "sketch.ellipse", json!({"center": [60, 40], "major": [70, 40], "minor_radius": 4}));
run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 5}));
run(&mut s, "DrawPolyline", json!({"points": [[30, 0], [50, 0]]}));
run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 40], "start": [10, 40], "end": [0, 50]}));
run(&mut s, "CircleElipse", json!({"center": [60, 40], "major": [70, 40], "minor_radius": 4}));
let free = dof(&mut s);
// Multi-select fixes them all (circle 3, line 4, arc 5, ellipse 5 degrees of freedom).
let r = run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
let r = run(&mut s, "ConstraintFix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
assert_eq!(r["result"]["changed"], 4);
assert_eq!(dof(&mut s), free - 17);
let si = run(&mut s, "sketch.inspect", json!({}));
@@ -164,18 +164,18 @@ fn fixing_curves_holds_their_points_and_radius() {
// A fixed circle keeps centre and radius; a dimension on it conflicts.
run(&mut s, "sketch.move_point", json!({"point": "c1.center", "to": [0, 0]}));
assert_eq!(pt(&s, "c1.center"), solvecraft_geom::Vec2::new(10.0, 10.0));
assert!(s.execute("sketch.dimension", &json!({"entities": ["c1"], "value": 20})).is_err());
assert!(s.execute("SketchDimension", &json!({"entities": ["c1"], "value": 20})).is_err());
let r0 = sketch(&s).radius(0);
assert_eq!(r0, Some(5.0));
// Toggle without `fixed`: all fixed, so all are unfixed.
let r = run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
let r = run(&mut s, "ConstraintFix", json!({"entities": ["c1", "l1", "a1", "e1"]}));
assert_eq!(r["result"]["fixed"], false);
assert_eq!(dof(&mut s), free);
run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "type": "radius", "value": 7}));
run(&mut s, "SketchDimension", json!({"entities": ["c1"], "type": "radius", "value": 7}));
assert!((sketch(&s).radius(0).unwrap_or(0.0) - 7.0).abs() < 1e-9);
// Mixed selection: one fixed, one free → both end up fixed.
run(&mut s, "sketch.constraint.fix", json!({"entity": "l1"}));
let r = run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1", "l1.start", "a1"]}));
run(&mut s, "ConstraintFix", json!({"entity": "l1"}));
let r = run(&mut s, "ConstraintFix", json!({"entities": ["l1", "l1.start", "a1"]}));
assert_eq!(r["result"]["fixed"], true);
assert!(sketch(&s).curves.iter().filter(|c| c.id == "l1" || c.id == "a1").all(|c| c.fixed));
}
@@ -183,17 +183,17 @@ fn fixing_curves_holds_their_points_and_radius() {
#[test]
fn projected_geometry_counts_as_fixed() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [10, 0, 5]}]}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
let r = run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [10, 0, 5]}]}));
let sk = sketch(&s);
let ci = sk.curves.iter().position(|c| c.link.is_some()).unwrap_or_else(|| panic!("projected: {r}"));
let id = sk.curves[ci].id.clone();
assert!(sk.curve_locked(ci));
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": id}));
let r = run(&mut s, "ConstraintFix", json!({"entity": id}));
// Already fixed: the toggle unfixes, which projected geometry ignores.
assert_eq!(r["result"]["changed"], 0);
assert!(sketch(&s).curve_locked(ci));
@@ -213,11 +213,11 @@ fn curve_ids(v: &Value) -> Vec<String> {
#[test]
fn construction_toggle_takes_shapes_out_of_profiles_and_back() {
let mut s = new_sketch();
let rect = curve_ids(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})));
let slot = curve_ids(&run(&mut s, "sketch.slot.center_to_center", json!({"p0": [40, 5], "p1": [60, 5], "width": 6})));
let poly = curve_ids(&run(&mut s, "sketch.polygon.inscribed", json!({"center": [0, 40], "radius": 8, "sides": 6})));
let circ = curve_ids(&run(&mut s, "sketch.circle.center", json!({"center": [40, 40], "radius": 5})));
let ell = curve_ids(&run(&mut s, "sketch.ellipse", json!({"center": [70, 40], "major": [80, 40], "minor_radius": 4})));
let rect = curve_ids(&run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]})));
let slot = curve_ids(&run(&mut s, "ShapeSlotCenterToCenter", json!({"p0": [40, 5], "p1": [60, 5], "width": 6})));
let poly = curve_ids(&run(&mut s, "ShapePolygonInscribed", json!({"center": [0, 40], "radius": 8, "sides": 6})));
let circ = curve_ids(&run(&mut s, "CircleCenterRadius", json!({"center": [40, 40], "radius": 5})));
let ell = curve_ids(&run(&mut s, "CircleElipse", json!({"center": [70, 40], "major": [80, 40], "minor_radius": 4})));
assert_eq!(profiles(&mut s), 5);
for (shape, left) in [(&rect, 4), (&slot, 3), (&poly, 2), (&circ, 1), (&ell, 0)] {
let r = run(&mut s, "sketch.construction", json!({ "curves": shape }));
@@ -240,12 +240,12 @@ fn construction_toggle_takes_shapes_out_of_profiles_and_back() {
#[test]
fn projected_geometry_can_become_construction() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [10, 0, 5]}]}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"distance": 5}));
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [10, 0, 5]}]}));
let id = sketch(&s).curves.iter().find(|c| c.link.is_some()).map(|c| c.id.clone()).expect("projected");
let r = run(&mut s, "sketch.construction", json!({ "curves": [id.clone()] }));
assert_eq!(r["changed"], 1);
@@ -273,8 +273,8 @@ fn same(a: &(Vec<solvecraft_geom::Vec2>, Option<f64>), b: &(Vec<solvecraft_geom:
/// A free line l1 (5,3)–(45,3) and a circle c1 at (20,18) with radius 5.
fn line_and_circle() -> Session {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[5, 3], [45, 3]]}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 18], "radius": 5}));
run(&mut s, "DrawPolyline", json!({"points": [[5, 3], [45, 3]]}));
run(&mut s, "CircleCenterRadius", json!({"center": [20, 18], "radius": 5}));
s
}
@@ -291,40 +291,40 @@ fn tangent_moves_only_the_second_pick() {
// Line then circle: only the circle moves (and keeps its size).
let mut s = line_and_circle();
let l0 = snap(&s, "l1");
run(&mut s, "sketch.constraint.tangent", json!({"a": "l1", "b": "c1"}));
run(&mut s, "ConstraintTangent", json!({"a": "l1", "b": "c1"}));
assert!(same(&snap(&s, "l1"), &l0), "line moved: {:?}", snap(&s, "l1"));
let c = snap(&s, "c1");
assert!((c.1.unwrap_or(0.0) - 5.0).abs() < 1e-9 && (c.0[0].y - 8.0).abs() < 1e-6, "{c:?}");
// Circle then line: only the line moves.
check_order(line_and_circle(), "sketch.constraint.tangent", "c1", "l1");
check_order(line_and_circle(), "ConstraintTangent", "c1", "l1");
}
#[test]
fn a_fixed_second_pick_makes_the_first_move() {
let mut s = line_and_circle();
run(&mut s, "sketch.constraint.fix", json!({"entity": "l1"}));
run(&mut s, "ConstraintFix", json!({"entity": "l1"}));
let l0 = snap(&s, "l1");
let c0 = snap(&s, "c1");
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "l1"}));
run(&mut s, "ConstraintTangent", json!({"a": "c1", "b": "l1"}));
assert!(same(&snap(&s, "l1"), &l0));
assert!(!same(&snap(&s, "c1"), &c0));
// Fixed first: the second moves as usual.
let mut s = line_and_circle();
run(&mut s, "sketch.constraint.fix", json!({"entity": "c1"}));
run(&mut s, "ConstraintFix", json!({"entity": "c1"}));
let c0 = snap(&s, "c1");
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "l1"}));
run(&mut s, "ConstraintTangent", json!({"a": "c1", "b": "l1"}));
assert!(same(&snap(&s, "c1"), &c0));
}
#[test]
fn two_fully_constrained_entities_conflict_and_nothing_moves() {
let mut s = line_and_circle();
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l1.end", "c1.center"]}));
run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "type": "radius", "value": 5}));
run(&mut s, "ConstraintFix", json!({"entities": ["l1.start", "l1.end", "c1.center"]}));
run(&mut s, "SketchDimension", json!({"entities": ["c1"], "type": "radius", "value": 5}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert!(si["curves"].as_array().is_some_and(|c| c.iter().all(|c| c["fully_constrained"] == true)), "{si}");
let before = sketch(&s);
let e = s.execute("sketch.constraint.tangent", &json!({"a": "l1", "b": "c1"}));
let e = s.execute("ConstraintTangent", &json!({"a": "l1", "b": "c1"}));
assert!(e.is_err(), "{e:?}");
assert_eq!(sketch(&s), before);
}
@@ -333,22 +333,22 @@ fn two_fully_constrained_entities_conflict_and_nothing_moves() {
fn line_pairs_keep_the_first_line() {
let lines = || {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 2]]}));
run(&mut s, "sketch.line", json!({"points": [[5, 20], [25, 35]]}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 2]]}));
run(&mut s, "DrawPolyline", json!({"points": [[5, 20], [25, 35]]}));
s
};
let len = |s: &Session, l: &str| {
let p = snap(s, l).0;
p[0].dist(p[1])
};
for cmd in ["sketch.constraint.parallel", "sketch.constraint.perpendicular", "sketch.constraint.equal", "sketch.constraint.collinear"] {
for cmd in ["ConstraintParallel", "ConstraintPerpendicular", "ConstraintEqual", "ConstraintCollinear"] {
check_order(lines(), cmd, "l1", "l2");
check_order(lines(), cmd, "l2", "l1");
// The second line turns and slides; only Equal changes its length.
let mut s = lines();
let l0 = len(&s, "l2");
run(&mut s, cmd, json!({"a": "l1", "b": "l2"}));
if cmd != "sketch.constraint.equal" {
if cmd != "ConstraintEqual" {
assert!((len(&s, "l2") - l0).abs() < 1e-6, "{cmd}: {l0} -> {}", len(&s, "l2"));
} else {
assert!((len(&s, "l2") - len(&s, "l1")).abs() < 1e-6);
@@ -360,17 +360,17 @@ fn line_pairs_keep_the_first_line() {
fn circle_pairs_keep_the_first_circle() {
let circles = || {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 10], "radius": 8}));
run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 5}));
run(&mut s, "CircleCenterRadius", json!({"center": [30, 10], "radius": 8}));
s
};
for cmd in ["sketch.constraint.concentric", "sketch.constraint.equal", "sketch.constraint.tangent"] {
for cmd in ["ConstraintConcentric", "ConstraintEqual", "ConstraintTangent"] {
check_order(circles(), cmd, "c1", "c2");
check_order(circles(), cmd, "c2", "c1");
}
// Equal resizes the second to the first.
let mut s = circles();
run(&mut s, "sketch.constraint.equal", json!({"a": "c2", "b": "c1"}));
run(&mut s, "ConstraintEqual", json!({"a": "c2", "b": "c1"}));
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
}
@@ -378,25 +378,25 @@ fn circle_pairs_keep_the_first_circle() {
fn point_constraints_keep_the_first_pick() {
let pts = || {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[10, 10], [20, 25]]}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [20, 25]]}));
s
};
check_order(pts(), "sketch.constraint.coincident", "l2.start", "l1.end");
check_order(pts(), "sketch.constraint.coincident", "l1.end", "l2.start");
check_order(pts(), "ConstraintCoincident", "l2.start", "l1.end");
check_order(pts(), "ConstraintCoincident", "l1.end", "l2.start");
// Curve picked first, point second: the point goes onto the line.
check_order(pts(), "sketch.constraint.coincident", "l1", "l2.start");
check_order(pts(), "ConstraintCoincident", "l1", "l2.start");
// Midpoint: the point stays, the line moves.
let mut s = pts();
let p0 = snap(&s, "l2.end");
run(&mut s, "sketch.constraint.midpoint", json!({"point": "l2.end", "line": "l1"}));
run(&mut s, "ConstraintMidPoint", json!({"point": "l2.end", "line": "l1"}));
assert!(same(&snap(&s, "l2.end"), &p0));
// Symmetry: the first point and the line stay, the second mirrors.
let mut s = pts();
run(&mut s, "sketch.point", json!({"point": [3, 7]}));
run(&mut s, "sketch.point", json!({"point": [4, -9]}));
run(&mut s, "DrawPoint", json!({"point": [3, 7]}));
run(&mut s, "DrawPoint", json!({"point": [4, -9]}));
let (a0, l0) = (snap(&s, "p1"), snap(&s, "l1"));
run(&mut s, "sketch.constraint.symmetry", json!({"a": "p1", "b": "p2", "line": "l1"}));
run(&mut s, "ConstraintSymmetry", json!({"a": "p1", "b": "p2", "line": "l1"}));
assert!(same(&snap(&s, "p1"), &a0) && same(&snap(&s, "l1"), &l0));
assert!(pt(&s, "p2").dist(solvecraft_geom::Vec2::new(3.0, -7.0)) < 1e-6, "{:?}", pt(&s, "p2"));
}
@@ -418,8 +418,8 @@ fn palette_options_are_kept_with_each_sketch() {
assert!(v.hide_dimensions && v.no_snap && v.slice && v.three_d && !v.hide_points);
// Another sketch starts with the defaults; the first keeps its options after finishing.
let first = s.active_sketch.expect("sketch");
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "SketchCreate", json!({"plane": "XZ"}));
assert_eq!(sketch(&s).view, solvecraft_sketch::SketchView::default());
let o = run(&mut s, "sketch.options", json!({"sketch": first}));
assert_eq!(o["options"]["show_dimensions"], false);
@@ -430,7 +430,7 @@ fn palette_options_are_kept_with_each_sketch() {
assert!(s.execute("sketch.options", &json!({"bogus": true})).is_err());
// Undo restores the option.
run(&mut s, "sketch.options", json!({"show_points": false}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "UndoCommand", json!({}));
assert!(!sketch(&s).view.hide_points);
}
@@ -448,7 +448,7 @@ fn v(x: f64, y: f64) -> solvecraft_geom::Vec2 {
#[test]
fn dragging_a_line_slides_it_and_an_end_keeps_the_other_end() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 20]]}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 20]]}));
drag(&mut s, "l1", [25.0, 15.0], [30.0, 25.0]);
assert!(pt(&s, "l1.start").dist(v(15.0, 20.0)) < 1e-9 && pt(&s, "l1.end").dist(v(45.0, 30.0)) < 1e-9);
drag(&mut s, "l1.end", [45.0, 30.0], [50.0, 0.0]);
@@ -459,7 +459,7 @@ fn dragging_a_line_slides_it_and_an_end_keeps_the_other_end() {
#[test]
fn dragging_a_rectangle_side_moves_that_side_only() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [40, 30]}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [10, 10], "p1": [40, 30]}));
// l2 is the right side (x = 40): dragged right, it stays vertical, the left side stays.
let left = snap(&s, "l4");
drag(&mut s, "l2", [40.0, 20.0], [50.0, 20.0]);
@@ -471,7 +471,7 @@ fn dragging_a_rectangle_side_moves_that_side_only() {
#[test]
fn dragging_circles_and_arcs() {
let mut s = new_sketch();
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 5}));
// The edge changes the radius about a fixed centre; the centre moves the circle.
drag(&mut s, "c1", [5.0, 0.0], [0.0, 8.0]);
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
@@ -479,7 +479,7 @@ fn dragging_circles_and_arcs() {
drag(&mut s, "c1.center", [0.0, 0.0], [20.0, 5.0]);
assert!(pt(&s, "c1.center").dist(v(20.0, 5.0)) < 1e-9);
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
run(&mut s, "sketch.arc.center_point", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}));
run(&mut s, "ArcCenterTwoPoint", json!({"center": [50, 0], "start": [60, 0], "end": [50, 10]}));
drag(&mut s, "a1", [57.07, 7.07], [50.0 + 200f64.sqrt(), 200f64.sqrt()]);
assert!(pt(&s, "a1.center").dist(v(50.0, 0.0)) < 1e-9);
assert!((snap(&s, "a1").1.unwrap_or(0.0) - 20.0).abs() < 1e-9, "{:?}", snap(&s, "a1"));
@@ -489,16 +489,16 @@ fn dragging_circles_and_arcs() {
#[test]
fn constrained_geometry_resists_drags() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l1.end"]}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "ConstraintFix", json!({"entities": ["l1.start", "l1.end"]}));
let l0 = snap(&s, "l1");
drag(&mut s, "l1", [25.0, 10.0], [25.0, 30.0]);
assert!(same(&snap(&s, "l1"), &l0));
// A horizontal line dragged at its end stays horizontal; its length dimension holds.
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [40, 10]]}));
run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l1"}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 30}));
drag(&mut s, "l1.end", [40.0, 10.0], [45.0, 20.0]);
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
assert!((a.y - b.y).abs() < 1e-6 && (a.dist(b) - 30.0).abs() < 1e-6, "{a:?} {b:?}");
@@ -507,12 +507,12 @@ fn constrained_geometry_resists_drags() {
#[test]
fn fully_constrained_feedback_is_per_entity() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [50, 35]}));
run(&mut s, "sketch.circle.center", json!({"center": [80, 20], "radius": 8}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 40}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 25}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 10}));
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 10}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [10, 10], "p1": [50, 35]}));
run(&mut s, "CircleCenterRadius", json!({"center": [80, 20], "radius": 8}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 40}));
run(&mut s, "SketchDimension", json!({"entities": ["l2"], "value": 25}));
run(&mut s, "SketchDimension", json!({"entities": ["origin", "l1.start"], "type": "horizontal", "value": 10}));
run(&mut s, "SketchDimension", json!({"entities": ["origin", "l1.start"], "type": "vertical", "value": 10}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 3);
for c in si["curves"].as_array().expect("curves") {
@@ -533,10 +533,10 @@ fn fully_constrained_feedback_is_per_entity() {
#[test]
fn chain_follows_shared_and_coincident_ends() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "sketch.line", json!({"points": [[40, 0], [50, 0]]}));
run(&mut s, "sketch.line", json!({"points": [[50.5, 0], [60, 5]]}));
run(&mut s, "sketch.circle.center", json!({"center": [80, 0], "radius": 3}));
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
run(&mut s, "DrawPolyline", json!({"points": [[40, 0], [50, 0]]}));
run(&mut s, "DrawPolyline", json!({"points": [[50.5, 0], [60, 5]]}));
run(&mut s, "CircleCenterRadius", json!({"center": [80, 0], "radius": 3}));
let chain = |s: &mut Session, c: &str| -> Vec<String> {
let v = run(s, "sketch.chain", json!({"curve": c}));
v["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
@@ -545,7 +545,7 @@ fn chain_follows_shared_and_coincident_ends() {
assert_eq!(chain(&mut s, "l5"), ["l5"]);
assert_eq!(chain(&mut s, "c1"), ["c1"]);
// Joined by a coincident constraint, the two lines are one chain.
run(&mut s, "sketch.constraint.coincident", json!({"a": "l5.end", "b": "l6.start"}));
run(&mut s, "ConstraintCoincident", json!({"a": "l5.end", "b": "l6.start"}));
assert_eq!(chain(&mut s, "l6"), ["l6", "l5"]);
assert!(s.execute("sketch.chain", &json!({"curve": "nope"})).is_err());
}
@@ -553,9 +553,9 @@ fn chain_follows_shared_and_coincident_ends() {
#[test]
fn constraints_and_dimensions_can_be_selected_and_deleted_together() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1"}));
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "ConstraintHorizontalVertical", json!({"line": "l1"}));
run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 30}));
let ids: Vec<String> = sketch(&s).constraints.iter().map(|c| c.id.clone()).collect();
assert_eq!(ids.len(), 2);
let items: Vec<Value> = ids.iter().map(|id| json!({"type": "sketch_constraint", "id": id})).collect();
@@ -603,14 +603,14 @@ fn three_d_curves_need_the_option_and_move_joined() {
#[test]
fn pipe_and_sweep_follow_3d_sketch_curves() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "sketch.options", json!({"sketch_3d": true}));
let r = run(&mut s, "sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 30], [20, 0, 30]]}));
let mut path = curve_ids(&r);
let sp = run(&mut s, "sketch.spline3d", json!({"points": [[20, 0, 30], [35, 10, 35], [50, 0, 50]]}));
path.extend(curve_ids(&sp));
run(&mut s, "sketch.finish", json!({}));
let p = run(&mut s, "solid.pipe", json!({"path_sketch": "Path", "path": path, "diameter": 4}));
run(&mut s, "SketchStop", json!({}));
let p = run(&mut s, "PrimitivePipe", json!({"path_sketch": "Path", "path": path, "diameter": 4}));
let si = run(&mut s, "document.inspect", json!({"measure": true}));
let body = si["bodies"].as_array().and_then(|b| b.first()).cloned().unwrap_or_default();
// The tube runs from the first line's start to the spline's end (radius 2 around them; the
-154
View File
@@ -1,154 +0,0 @@
//! Surface commands: Patch, Stitch, Thicken and Trim (surface bodies and turning them into
//! solids).
use serde_json::Value;
use solvecraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, expr::Kind};
use super::CommandSpec;
use super::features::{add_feature, check_expr, feature_sketch, operation, plane_param, profiles};
use crate::params::{bad, bool_, expr, str_, string_list, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", patch)
.at("SURFACE", "CREATE")
.icon("patch")
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges) and body?; name?, body_name?"),
CommandSpec::new("surface.stitch", "Stitch", stitch)
.at("SURFACE", "MODIFY")
.icon("stitch")
.params("bodies: [names] (surfaces sewn into the first; a solid when they close); tolerance?: expr (default: tight)"),
CommandSpec::new("surface.thicken", "Thicken", thicken)
.at("SURFACE", "CREATE")
.icon("thicken")
.params("bodies: [surface names]; thickness: expr (negative: against the normal); symmetric?: bool; operation?: new|join|cut|intersect; targets?"),
CommandSpec::new("surface.extend", "Extend", extend)
.at("SURFACE", "MODIFY")
.icon("extend")
.params("body: planar surface name; edges: [[x,y,z]] (points on straight edges to push out); distance: expr"),
CommandSpec::new("surface.trim", "Trim", trim)
.at("SURFACE", "MODIFY")
.icon("trim")
.params("body: surface name; plane: XY|XZ|YZ|plane name|{origin, normal} or tool: {body, point} (a face, its surface extended); keep: [x,y,z] (a point on the side to keep)"),
];
fn patch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.patch";
let edges: Vec<solvecraft_geom::Vec3> = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if !edges.is_empty() {
if edges.len() > 10_000 {
return Err(bad(cmd, "too many edges"));
}
let body = str_(p, "body").map(str::to_string);
return add_feature(s, p, FeatureKind::Patch { sketch: None, profiles: ProfileSel::All, body, edges });
}
let sketch = feature_sketch(s, p, cmd)?;
let profiles = profiles(p, cmd)?;
add_feature(s, p, FeatureKind::Patch { sketch: Some(sketch), profiles, body: None, edges: Vec::new() })
}
fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
}
let st = s.model.state();
if let Some(b) = bodies.iter().find(|b| st.body(b).is_none()) {
return Err(bad(cmd, format!("no body `{b}`")));
}
Ok(bodies)
}
fn stitch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.stitch";
let bodies = bodies_param(s, p, cmd)?;
let tolerance = expr(p, "tolerance").unwrap_or_else(|| "0".into());
check_expr(s, &tolerance, Kind::Length, cmd, "tolerance")?;
add_feature(s, p, FeatureKind::Stitch { bodies, tolerance })
}
fn thicken(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.thicken";
let bodies = bodies_param(s, p, cmd)?;
let thickness = expr(p, "thickness").ok_or_else(|| bad(cmd, "`thickness` is required"))?;
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
let kind = FeatureKind::Thicken {
bodies,
thickness,
symmetric: bool_(p, "symmetric").unwrap_or(false),
operation: operation(p, cmd)?,
targets: string_list(p, "targets"),
};
add_feature(s, p, kind)
}
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.extend";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
let edges: Vec<solvecraft_geom::Vec3> = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
if edges.is_empty() || edges.len() > 10_000 {
return Err(bad(cmd, "`edges` must list points on the edges to extend"));
}
let distance = expr(p, "distance").ok_or_else(|| bad(cmd, "`distance` is required"))?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
add_feature(s, p, FeatureKind::SurfaceExtend { body, edges, distance })
}
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.trim";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
let keep = p.get("keep").and_then(vec3).ok_or_else(|| bad(cmd, "`keep` must be [x, y, z] on the side to keep"))?;
let (plane, tool) = match p.get("tool").filter(|t| !t.is_null()) {
Some(t) => {
let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`tool.body` must name a body"))?.to_string();
let point = t.get("point").and_then(vec3).ok_or_else(|| bad(cmd, "`tool.point` must be [x, y, z] on the tool face"))?;
(PlaneRef::Origin { name: "XY".into() }, Some(FaceAt { body: tb, point }))
}
None => (plane_param(s, p.get("plane"), cmd)?, None),
};
add_feature(s, p, FeatureKind::SurfaceTrim { body, plane, tool, keep })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
use serde_json::json;
fn run(s: &mut Session, c: &str, p: Value) -> Value {
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
}
fn volume(s: &Session, name: &str) -> f64 {
let st = s.world_state();
solvecraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume
}
/// A rectangle patched, trimmed by a plane and thickened; a box's faces stitched back.
#[test]
fn patch_trim_thicken_and_stitch() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "surface.patch", json!({"sketch": "S", "body_name": "Sheet"}));
let st = s.world_state();
assert!(st.body("Sheet").unwrap().body.is_surface());
run(&mut s, "surface.trim", json!({"body": "Sheet", "plane": {"origin": [3, 0, 0], "normal": [1, 0, 0]}, "keep": [8, 5, 0]}));
run(&mut s, "surface.extend", json!({"body": "Sheet", "edges": [[10, 5, 0]], "distance": 5}));
run(&mut s, "surface.thicken", json!({"bodies": ["Sheet"], "thickness": "2 mm", "body_name": "Slab"}));
let v = volume(&s, "Slab");
assert!((v - 240.0).abs() < 1e-9, "{v}");
// Stitch the patch of a box's open top back on.
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cup"}));
run(&mut s, "surface.patch", json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup", "body_name": "Lid"}));
assert!(s.world_state().body("Lid").unwrap().body.is_surface());
assert!(s.execute("surface.thicken", &json!({"bodies": ["Nope"], "thickness": 1})).is_err());
assert!(s.execute("surface.trim", &json!({"body": "Sheet"})).is_err());
}
}
+20 -18
View File
@@ -9,27 +9,27 @@ use crate::params::{bad, bool_, num, vec3};
use crate::{Result, Session, SurfaceAnalysis};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("inspect.zebra", "Zebra Analysis", zebra)
CommandSpec::new("FusionZebraAnalysisCommand", "Zebra Analysis", zebra)
.at("SKETCH", "INSPECT")
.icon("zebra")
.noundo()
.params("stripes?: number across the view (default 12); clear?: true turns the analysis off"),
CommandSpec::new("inspect.draft", "Draft Analysis", draft)
CommandSpec::new("FusionDraftAnalysisCommand", "Draft Analysis", draft)
.at("SKETCH", "INSPECT")
.icon("draft_analysis")
.noundo()
.params("pull?: [x,y,z] direction (default Z); angle?: degrees (default 1); clear?: true turns the analysis off"),
CommandSpec::new("inspect.environment_map", "Environment Map Analysis", |s, p| set(s, p, SurfaceAnalysis::Environment))
CommandSpec::new("FusionEnvironmentMapAnalysisCommand", "Environment Map Analysis", |s, p| set(s, p, SurfaceAnalysis::Environment))
.at("SKETCH", "INSPECT")
.icon("environment_map")
.noundo()
.params("clear?: true turns the analysis off"),
CommandSpec::new("inspect.accessibility", "Accessibility Analysis", accessibility)
CommandSpec::new("FusionAccessibilityAnalysisCommand", "Accessibility Analysis", accessibility)
.at("SKETCH", "INSPECT")
.icon("accessibility")
.noundo()
.params("direction?: [x,y,z] the tool comes from (default Z, from above); clear?: true turns the analysis off"),
CommandSpec::new("inspect.curvature_map", "Curvature Map Analysis", curvature_map)
CommandSpec::new("FusionCurvatureMapAnalysisCommand", "Curvature Map Analysis", curvature_map)
.at("SKETCH", "INSPECT")
.icon("curvature_map")
.noundo()
@@ -49,13 +49,13 @@ fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result<Value> {
fn zebra(s: &mut Session, p: &Value) -> Result<Value> {
let stripes = num(p, "stripes").unwrap_or(12.0);
if !(stripes.is_finite() && (1.0..=200.0).contains(&stripes)) {
return Err(bad("inspect.zebra", "`stripes` must be 1…200"));
return Err(bad("FusionZebraAnalysisCommand", "`stripes` must be 1…200"));
}
set(s, p, SurfaceAnalysis::Zebra { stripes })
}
fn draft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.draft";
let cmd = "FusionDraftAnalysisCommand";
let pull = match p.get("pull") {
Some(v) => vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad(cmd, "`pull` must be a non-zero [x,y,z]"))?,
None => Vec3::Z,
@@ -69,14 +69,16 @@ fn draft(s: &mut Session, p: &Value) -> Result<Value> {
fn accessibility(s: &mut Session, p: &Value) -> Result<Value> {
let dir = match p.get("direction") {
Some(v) => vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad("inspect.accessibility", "`direction` must be a non-zero [x,y,z]"))?,
Some(v) => {
vec3(v).and_then(Vec3::normalized).ok_or_else(|| bad("FusionAccessibilityAnalysisCommand", "`direction` must be a non-zero [x,y,z]"))?
}
None => Vec3::Z,
};
set(s, p, SurfaceAnalysis::Access { dir })
}
fn curvature_map(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.curvature_map";
let cmd = "FusionCurvatureMapAnalysisCommand";
let radius = match num(p, "radius") {
Some(r) if r.is_finite() && r > 1e-6 && r < 1e9 => r,
Some(_) => return Err(bad(cmd, "`radius` must be positive")),
@@ -98,22 +100,22 @@ mod tests {
#[test]
fn surface_analyses_are_view_state() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
s.execute("PrimitiveBox", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
let undo = s.undo.len();
s.execute("inspect.zebra", &json!({"stripes": 20})).unwrap();
s.execute("FusionZebraAnalysisCommand", &json!({"stripes": 20})).unwrap();
assert_eq!(s.analysis, Some(SurfaceAnalysis::Zebra { stripes: 20.0 }));
s.execute("inspect.draft", &json!({"pull": [0, 0, 2], "angle": 3})).unwrap();
s.execute("FusionDraftAnalysisCommand", &json!({"pull": [0, 0, 2], "angle": 3})).unwrap();
assert!(matches!(s.analysis, Some(SurfaceAnalysis::Draft { angle, .. }) if angle == 3.0));
s.execute("inspect.curvature_map", &json!({})).unwrap();
s.execute("FusionCurvatureMapAnalysisCommand", &json!({})).unwrap();
assert!(matches!(s.analysis, Some(SurfaceAnalysis::Curvature { radius }) if (radius - 300f64.sqrt() / 10.0).abs() < 1e-6));
s.execute("inspect.environment_map", &json!({})).unwrap();
s.execute("FusionEnvironmentMapAnalysisCommand", &json!({})).unwrap();
assert_eq!(s.analysis, Some(SurfaceAnalysis::Environment));
s.execute("inspect.accessibility", &json!({"direction": [0, 0, -3]})).unwrap();
s.execute("FusionAccessibilityAnalysisCommand", &json!({"direction": [0, 0, -3]})).unwrap();
assert_eq!(s.analysis, Some(SurfaceAnalysis::Access { dir: solvecraft_geom::Vec3::new(0.0, 0.0, -1.0) }));
s.execute("inspect.curvature_map", &json!({"clear": true})).unwrap();
s.execute("FusionCurvatureMapAnalysisCommand", &json!({"clear": true})).unwrap();
assert_eq!(s.analysis, None);
assert_eq!(s.undo.len(), undo, "no undo steps");
assert!(s.execute("inspect.zebra", &json!({"stripes": 0})).is_err());
assert!(s.execute("inspect.draft", &json!({"pull": [0, 0, 0]})).is_err());
assert!(s.execute("FusionZebraAnalysisCommand", &json!({"stripes": 0})).is_err());
assert!(s.execute("FusionDraftAnalysisCommand", &json!({"pull": [0, 0, 0]})).is_err());
}
}
-259
View File
@@ -1,259 +0,0 @@
//! Construction axes, points and planes (SOLID ▸ CONSTRUCT), one test per type, against a
//! 40 × 30 × 20 box at the origin, a cylinder, a sphere and a torus.
use serde_json::{Value, json};
use solvecraft_geom::{Plane, Vec3};
use crate::Session;
use crate::view::{construction_axes, construction_planes, construction_points};
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn part() -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Box"}));
run(&mut s, "solid.cylinder", json!({"base": [60, 0, 0], "radius": 5, "height": 10, "body_name": "Cyl"}));
run(&mut s, "solid.sphere", json!({"center": [0, 0, 60], "radius": 5, "body_name": "Ball"}));
run(&mut s, "solid.torus", json!({"center": [0, 60, 0], "major": 10, "minor": 2, "body_name": "Ring"}));
s
}
fn near(a: Vec3, b: Vec3) -> bool {
a.dist(b) < 1e-3
}
fn parallel(a: Vec3, b: Vec3) -> bool {
a.normalized().zip(b.normalized()).is_some_and(|(a, b)| a.cross(b).len() < 1e-6)
}
fn axis(s: &Session, name: &str) -> (Vec3, Vec3) {
construction_axes(s).into_iter().find(|a| a.1 == name).map(|a| (a.2, a.3)).unwrap_or_else(|| panic!("no axis {name}"))
}
fn point(s: &Session, name: &str) -> Vec3 {
construction_points(s).into_iter().find(|p| p.1 == name).map(|p| p.2).unwrap_or_else(|| panic!("no point {name}"))
}
fn plane(s: &Session, name: &str) -> Plane {
construction_planes(s).into_iter().find(|p| p.1 == name).map(|p| p.2).unwrap_or_else(|| panic!("no plane {name}"))
}
/// The line through `o` along `d` contains `p`.
fn on_line(o: Vec3, d: Vec3, p: Vec3) -> bool {
let d = d.normalized().unwrap_or(Vec3::Z);
(p - o - d * (p - o).dot(d)).len() < 1e-3
}
fn on_plane(pl: &Plane, p: Vec3) -> bool {
(p - pl.origin).dot(pl.normal()).abs() < 1e-3
}
#[test]
fn axis_through_cylinder_cone_or_torus() {
let mut s = part();
run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [65, 0, 5]}, "name": "A"}));
let (o, d) = axis(&s, "A");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(60.0, 0.0, 0.0)), "{o:?} {d:?}");
run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [12, 60, 0]}, "name": "T"}));
let (o, d) = axis(&s, "T");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(0.0, 60.0, 0.0)), "{o:?} {d:?}");
// A flat face has no axis.
assert!(s.execute("construct.axis.through_cylinder", &json!({"face": {"face": [20, 15, 20]}})).is_err());
}
#[test]
fn axis_perpendicular_to_a_face_at_a_point() {
let mut s = part();
run(&mut s, "construct.axis.normal_to_face", json!({"face": {"face": [10, 10, 20]}, "point": {"point": [10, 10, 25]}, "name": "N"}));
let (o, d) = axis(&s, "N");
assert!(near(o, Vec3::new(10.0, 10.0, 20.0)) && parallel(d, Vec3::Z), "{o:?} {d:?}");
// On the cylinder: radial through the point.
run(&mut s, "construct.axis.normal_to_face", json!({"face": {"face": [65, 0, 5]}, "point": {"point": [70, 0, 5]}, "name": "R"}));
let (o, d) = axis(&s, "R");
assert!(near(o, Vec3::new(65.0, 0.0, 5.0)) && parallel(d, Vec3::X), "{o:?} {d:?}");
}
#[test]
fn axis_through_two_planes() {
let mut s = part();
run(&mut s, "construct.axis.two_planes", json!({"a": {"plane": "XZ"}, "b": {"face": [40, 15, 10]}, "name": "L"}));
let (o, d) = axis(&s, "L");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(40.0, 0.0, 0.0)), "{o:?} {d:?}");
assert!(s.execute("construct.axis.two_planes", &json!({"a": "XY", "b": {"face": [20, 15, 20]}})).is_err(), "parallel");
}
#[test]
fn axis_through_two_points() {
let mut s = part();
run(&mut s, "construct.axis.two_points", json!({"a": {"vertex": [0.5, 0.5, 0.5]}, "b": {"vertex": [39, 29, 19]}, "name": "D"}));
let (o, d) = axis(&s, "D");
assert!(on_line(o, d, Vec3::ZERO) && parallel(d, Vec3::new(40.0, 30.0, 20.0)), "{o:?} {d:?}");
}
#[test]
fn axis_through_an_edge() {
let mut s = part();
run(&mut s, "construct.axis.edge", json!({"edge": {"edge": [20, 0, 0]}, "name": "E"}));
let (o, d) = axis(&s, "E");
assert!(parallel(d, Vec3::X) && on_line(o, d, Vec3::ZERO), "{o:?} {d:?}");
// A circular edge is not straight.
assert!(s.execute("construct.axis.edge", &json!({"edge": {"edge": [65, 0, 10]}})).is_err());
}
#[test]
fn point_at_a_vertex() {
let mut s = part();
run(&mut s, "construct.point.vertex", json!({"point": {"vertex": [39, 29, 19]}, "name": "P"}));
assert!(near(point(&s, "P"), Vec3::new(40.0, 30.0, 20.0)));
}
#[test]
fn point_through_two_edges() {
let mut s = part();
run(&mut s, "construct.point.two_edges", json!({"a": {"edge": [20, 0, 0]}, "b": {"edge": [40, 15, 0]}, "name": "P"}));
assert!(near(point(&s, "P"), Vec3::new(40.0, 0.0, 0.0)), "{:?}", point(&s, "P"));
}
#[test]
fn point_through_three_planes() {
let mut s = part();
run(
&mut s,
"construct.point.three_planes",
json!({"a": {"face": [40, 15, 10]}, "b": {"face": [20, 30, 10]}, "c": {"face": [20, 15, 20]}, "name": "P"}),
);
assert!(near(point(&s, "P"), Vec3::new(40.0, 30.0, 20.0)), "{:?}", point(&s, "P"));
}
#[test]
fn point_at_the_centre_of_a_circle_sphere_or_torus() {
let mut s = part();
run(&mut s, "construct.point.center", json!({"of": {"edge": [65, 0, 10]}, "name": "C"}));
assert!(near(point(&s, "C"), Vec3::new(60.0, 0.0, 10.0)), "{:?}", point(&s, "C"));
run(&mut s, "construct.point.center", json!({"of": {"face": [5, 0, 60]}, "name": "S"}));
assert!(near(point(&s, "S"), Vec3::new(0.0, 0.0, 60.0)), "{:?}", point(&s, "S"));
run(&mut s, "construct.point.center", json!({"of": {"face": [12, 60, 0]}, "name": "T"}));
assert!(near(point(&s, "T"), Vec3::new(0.0, 60.0, 0.0)), "{:?}", point(&s, "T"));
}
#[test]
fn point_at_edge_and_plane() {
let mut s = part();
run(
&mut s,
"construct.point.edge_plane",
json!({"edge": {"edge": [20, 0, 0]}, "plane": {"plane": {"origin": [12, 0, 0], "normal": [1, 0, 0]}}, "name": "P"}),
);
assert!(near(point(&s, "P"), Vec3::new(12.0, 0.0, 0.0)), "{:?}", point(&s, "P"));
}
#[test]
fn point_along_a_path() {
let mut s = part();
run(&mut s, "construct.point.along_path", json!({"path": {"edge": [20, 0, 0]}, "t": 0.25, "name": "P"}));
let p = point(&s, "P");
assert!(near(p, Vec3::new(10.0, 0.0, 0.0)) || near(p, Vec3::new(30.0, 0.0, 0.0)), "{p:?}");
}
#[test]
fn midplane_between_parallel_and_meeting_faces() {
let mut s = part();
run(&mut s, "construct.plane.midplane", json!({"a": "XY", "b": {"face": [20, 15, 20]}, "name": "M"}));
let m = plane(&s, "M");
assert!(parallel(m.normal(), Vec3::Z) && on_plane(&m, Vec3::new(5.0, 5.0, 10.0)), "{m:?}");
// Two sides meeting at the corner (40, 30): the diagonal plane through it.
run(&mut s, "construct.plane.midplane", json!({"a": {"face": [40, 15, 10]}, "b": {"face": [20, 30, 10]}, "name": "Q"}));
let q = plane(&s, "Q");
assert!(on_plane(&q, Vec3::new(40.0, 30.0, 0.0)) && on_plane(&q, Vec3::new(41.0, 31.0, 5.0)), "{q:?}");
}
#[test]
fn plane_through_two_edges() {
let mut s = part();
run(&mut s, "construct.plane.two_edges", json!({"a": {"edge": [20, 0, 0]}, "b": {"edge": [20, 30, 20]}, "name": "E"}));
let e = plane(&s, "E");
// The front-bottom and back-top edges: the slanted plane through both.
assert!(on_plane(&e, Vec3::new(5.0, 0.0, 0.0)) && on_plane(&e, Vec3::new(5.0, 30.0, 20.0)), "{e:?}");
assert!(s.execute("construct.plane.two_edges", &json!({"a": {"edge": [20, 0, 0]}, "b": {"edge": [40, 15, 20]}})).is_err(), "skew");
}
#[test]
fn plane_through_three_points() {
let mut s = part();
run(
&mut s,
"construct.plane.three_points",
json!({"a": {"vertex": [0, 0, 20]}, "b": {"vertex": [40, 0, 20]}, "c": {"vertex": [40, 30, 0]}, "name": "T"}),
);
let t = plane(&s, "T");
assert!(on_plane(&t, Vec3::new(0.0, 0.0, 20.0)) && on_plane(&t, Vec3::new(40.0, 30.0, 0.0)) && on_plane(&t, Vec3::new(0.0, 30.0, 0.0)), "{t:?}");
}
#[test]
fn plane_tangent_to_a_cylinder() {
let mut s = part();
run(&mut s, "construct.plane.tangent", json!({"face": {"face": [65, 0, 5]}, "name": "T"}));
let t = plane(&s, "T");
assert!(parallel(t.normal(), Vec3::X) && on_plane(&t, Vec3::new(65.0, 3.0, 9.0)), "{t:?}");
// And to the sphere, where the pick lies over it.
run(&mut s, "construct.plane.tangent", json!({"face": {"face": [0, 0, 65]}, "name": "U"}));
let u = plane(&s, "U");
assert!(parallel(u.normal(), Vec3::Z) && on_plane(&u, Vec3::new(3.0, 3.0, 65.0)), "{u:?}");
}
#[test]
fn plane_along_a_path() {
let mut s = part();
run(&mut s, "construct.plane.along_path", json!({"path": {"edge": [20, 0, 0]}, "t": 0.5, "name": "P"}));
let p = plane(&s, "P");
assert!(parallel(p.normal(), Vec3::X) && on_plane(&p, Vec3::new(20.0, 7.0, 3.0)), "{p:?}");
// A sketch can be placed on it.
run(&mut s, "sketch.create", json!({"plane": "P", "name": "OnPath"}));
}
/// Built from model geometry, they follow when the model changes: the box gets taller.
#[test]
fn construction_follows_the_model() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "h", "expression": "20 mm"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": "h", "body_name": "Box"}));
run(
&mut s,
"construct.point.three_planes",
json!({"a": {"face": [40, 15, 10]}, "b": {"face": [20, 30, 10]}, "c": {"face": [20, 15, 20]}, "name": "Corner"}),
);
run(&mut s, "construct.axis.edge", json!({"edge": {"edge": [20, 30, 20]}, "name": "TopBack"}));
run(&mut s, "construct.plane.midplane", json!({"a": "XY", "b": {"face": [20, 15, 20]}, "name": "Mid"}));
run(&mut s, "parameters.change", json!({"name": "h", "expression": "50 mm"}));
assert!(near(point(&s, "Corner"), Vec3::new(40.0, 30.0, 50.0)), "{:?}", point(&s, "Corner"));
let (o, d) = axis(&s, "TopBack");
assert!(parallel(d, Vec3::X) && on_line(o, d, Vec3::new(0.0, 30.0, 50.0)), "{o:?} {d:?}");
assert!(on_plane(&plane(&s, "Mid"), Vec3::new(1.0, 1.0, 25.0)));
// Construction from construction: an axis through two construction points.
run(&mut s, "construct.point.vertex", json!({"point": {"vertex": [0, 0, 0]}, "name": "Origin"}));
run(&mut s, "construct.axis.two_points", json!({"a": {"construct_point": "Origin"}, "b": {"construct_point": "Corner"}, "name": "Diag"}));
let (o, d) = axis(&s, "Diag");
assert!(on_line(o, d, Vec3::new(40.0, 30.0, 50.0)) && on_line(o, d, Vec3::ZERO));
}
/// In a component moved 50 mm along X, picks given in the world land on its geometry, and the
/// axis and point move with the occurrence.
#[test]
fn construction_lives_in_the_component_frame() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Part"}));
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "body_name": "Pin"}));
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [50, 0, 0]}));
run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [55, 0, 5]}, "name": "PinAxis"}));
run(&mut s, "construct.point.center", json!({"of": {"edge": [55, 0, 10]}, "name": "PinTop"}));
let (o, d) = axis(&s, "PinAxis");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(50.0, 0.0, 0.0)), "{o:?} {d:?}");
assert!(near(point(&s, "PinTop"), Vec3::new(50.0, 0.0, 10.0)), "{:?}", point(&s, "PinTop"));
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 20, 0]}));
assert!(near(point(&s, "PinTop"), Vec3::new(50.0, 20.0, 10.0)), "{:?}", point(&s, "PinTop"));
}
-57
View File
@@ -1,57 +0,0 @@
//! Delete Face: a fillet, a hole and a chamfer taken off, healed from the neighbouring planes.
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn volume(s: &mut Session, b: &str) -> f64 {
run(s, "inspect.measure", json!({ "bodies": [b] }))["bodies"][0]["volume_mm3"].as_f64().unwrap_or(0.0)
}
#[test]
fn delete_a_fillet_face() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "B"}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 20]], "radius": 5}));
assert!(volume(&mut s, "B") < 24000.0 - 1.0);
// A point on the fillet's surface: 45° round from its axis (35, ·, 15).
let h = 5.0 * std::f64::consts::FRAC_1_SQRT_2;
let r = run(&mut s, "face.delete", json!({"faces": [[35.0 + h, 15, 15.0 + h]]}));
assert!(r["feature"].is_u64(), "{r}");
assert!((volume(&mut s, "B") - 24000.0).abs() < 1e-3 * 24000.0, "{}", volume(&mut s, "B"));
// Undo-able as a timeline feature, and named after its kind.
let f = s.doc.features.last().unwrap();
assert_eq!(f.kind.type_name(), "DeleteFaceFeature");
}
/// A through hole picked in the Delete selection: both halves of its wall go, and the plate
/// closes up.
#[test]
fn delete_a_hole_through_the_selection() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 50, "width": 30, "height": 10, "body_name": "Plate"}));
run(&mut s, "solid.cylinder", json!({"base": [25, 15, -1], "radius": 4, "height": 12, "body_name": "Drill"}));
run(&mut s, "solid.combine", json!({"target": "Plate", "tools": ["Drill"], "operation": "cut"}));
let v = volume(&mut s, "Plate");
assert!(v < 15000.0 - 100.0, "{v}");
run(&mut s, "selection.delete", json!({"items": [{"type": "face", "body": "Plate", "index": 0, "point": [29, 15, 5]}]}));
assert!((volume(&mut s, "Plate") - 15000.0).abs() < 1e-3 * 15000.0, "{}", volume(&mut s, "Plate"));
}
/// A face whose neighbours can't close the gap is refused, and the design is unchanged.
#[test]
fn a_box_face_cannot_be_deleted() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"}));
let n = s.doc.features.len();
let e = s.execute("face.delete", &json!({"faces": [[5, 5, 10]]})).unwrap_err().to_string();
assert!(e.contains("Delete Face"), "{e}");
assert_eq!(s.doc.features.len(), n);
}
-106
View File
@@ -1,106 +0,0 @@
//! To Object extents on curved targets, and shells outside/both on curved bodies.
use std::f64::consts::PI;
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn body(s: &mut Session, b: &str) -> Value {
run(s, "inspect.measure", json!({ "bodies": [b] }))["bodies"][0].clone()
}
/// A square post extruded up to a horizontal cylinder (axis along x, r 10, centre z 30): its
/// top takes the cylinder's shape, so the post is shortest in the middle.
#[test]
fn extrude_to_a_cylinder_follows_it() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [-20, 0, 30], "axis": [1, 0, 0], "radius": 10, "height": 40, "body_name": "Bar"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "P"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-4, -4], "p1": [4, 4]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "P", "to": [0, 0, 20], "body_name": "Post"}));
let b = body(&mut s, "Post");
// Highest at the post's sides (y = ±4): z = 30 − √(100 − 16); the volume follows the arc.
let top = b["bbox"]["max"][2].as_f64().unwrap_or(0.0);
assert!((top - (30.0 - (100.0f64 - 16.0).sqrt())).abs() < 1e-3, "{b}");
// ∫ over y of (30 − √(100 − y²)) × 8 (x width), y from −4 to 4.
let n = 2000;
let want: f64 = (0..n).map(|i| -4.0 + 8.0 * (i as f64 + 0.5) / n as f64).map(|y| (30.0 - (100.0 - y * y).sqrt()) * 8.0 * 8.0 / n as f64).sum();
assert!((b["volume_mm3"].as_f64().unwrap_or(0.0) - want).abs() < want * 2e-3, "{} vs {want}", b["volume_mm3"]);
}
/// A round post extruded up to a sphere (r 15 at z 40): it stops on the sphere's underside.
#[test]
fn extrude_to_a_sphere_follows_it() {
let mut s = Session::default();
run(&mut s, "solid.sphere", json!({"center": [0, 0, 40], "radius": 15, "body_name": "Ball"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "P"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "P", "to": [0, 0, 25], "body_name": "Post"}));
let b = body(&mut s, "Post");
let top = b["bbox"]["max"][2].as_f64().unwrap_or(0.0);
assert!((top - (40.0 - (225.0f64 - 25.0).sqrt())).abs() < 1e-2, "{b}");
// Volume: cylinder to z 25 plus the ring between the sphere cap and the plane z = 25.
let cap = |r: f64| 40.0 - (225.0 - r * r).sqrt();
let n = 2000;
let want: f64 = (0..n).map(|i| 5.0 * (i as f64 + 0.5) / n as f64).map(|r| cap(r) * 2.0 * PI * r * 5.0 / n as f64).sum();
assert!((b["volume_mm3"].as_f64().unwrap_or(0.0) - want).abs() < want * 2e-3, "{} vs {want}", b["volume_mm3"]);
}
/// Shell Outside and Both on a cylinder (open top): exact offsets of the curved wall.
#[test]
fn shell_outside_and_both_on_a_cylinder() {
let shell = |dir: &str| {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 20, "body_name": "C"}));
run(&mut s, "solid.shell", json!({"faces": [[0, 0, 20]], "thickness": 2, "direction": dir}));
body(&mut s, "C")["volume_mm3"].as_f64().unwrap_or(0.0)
};
let out = PI * (144.0 * 22.0 - 100.0 * 20.0);
assert!((shell("outside") - out).abs() < out * 1e-3, "{} vs {out}", shell("outside"));
let both = PI * (121.0 * 21.0 - 81.0 * 19.0);
assert!((shell("both") - both).abs() < both * 1e-3, "{} vs {both}", shell("both"));
}
/// A ring section revolved To Object up to a post standing in its path: it ends on the post's
/// round side (touching, not overlapping), between first contact and the post's centre.
#[test]
fn revolve_to_a_cylinder_follows_it() {
for side in [1.0, -1.0] {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"base": [0, 14.0 * side, -5], "radius": 4, "height": 15, "body_name": "Post"}));
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "S"}));
run(&mut s, "sketch.line", json!({"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true}));
run(&mut s, "sketch.finish", json!({}));
let r = s.execute("solid.revolve", &json!({"sketch": "S", "axis": "y", "to": [4, 14.0 * side, 2], "body_name": "Arc"}));
if r.is_err() {
continue; // the post on the side the revolve turns away from is past the turn
}
let st = s.model.state();
let (arc, post) = (st.body("Arc").unwrap(), st.body("Post").unwrap());
let overlap = solvecraft_kernel::boolean(&arc.body, &post.body, solvecraft_kernel::BoolOp::Intersect)
.ok()
.flatten()
.map(|b| solvecraft_kernel::measure(&b).map(|m| m.volume).unwrap_or(0.0))
.unwrap_or(0.0);
assert!(overlap < 1e-3, "no overlap: {overlap}");
let v = solvecraft_kernel::measure(&arc.body).unwrap().volume;
let full = PI * (225.0 - 100.0) * 5.0;
// The post is at a quarter turn one way (three quarters the other): the ring stops
// short of the post's centre line, within its half-width of it.
let turned = v / full * 360.0;
let to_centre = if turned < 180.0 { 90.0 } else { 270.0 };
assert!(turned < to_centre && turned > to_centre - 30.0, "{turned}°");
return;
}
panic!("neither post was reached");
}
-357
View File
@@ -1,357 +0,0 @@
//! Component frames for picks. The viewport shows the placed model (`Session::world_state`):
//! a component's bodies and sketches moved by its occurrence (and pasted instances moved by
//! theirs). Features are made and kept in their component's own frame. So a command's picked
//! points and directions, given in the world, are mapped into the frame of the body they lie on
//! (through the inverse of that body's placement), and points that create geometry into the
//! active component's frame, before the command runs. Instance body names ("Body1 (Occ:2)")
//! become the component's body name.
//!
//! Nothing changes while every placement is the identity (the common case, and every design
//! without moved components).
use serde_json::{Map, Value};
use solvecraft_doc::{Mat, apply_point, apply_vector, is_identity, mat_inverse};
use solvecraft_geom::Vec3;
use crate::Session;
/// How a parameter is mapped.
#[derive(Clone, Copy, PartialEq)]
enum K {
/// A point on a body (a pick): into that body's frame.
Pick,
/// A list of picks: `[[x,y,z]…]` or `[{point|face|edge: [x,y,z], body?}…]`.
Picks,
/// A point that places new geometry: into the active component's frame.
Make,
/// A direction: turned with the command's frame.
Dir,
/// A plane object (`{face}`, `{origin, normal}`, `{origin, x_dir, y_dir}`) or a plane name.
Plane,
/// A body name, or a list of them.
Body,
}
use K::*;
/// The parameters each command takes in world coordinates.
fn table(id: &str) -> &'static [(&'static str, K)] {
match id {
"solid.extrude" | "solid.revolve" => &[("face", Pick), ("targets", Body), ("participants", Body)],
"solid.box" | "solid.sphere" => &[("corner", Make), ("center", Make)],
"solid.cylinder" | "solid.coil" | "solid.torus" => &[("base", Make), ("center", Make), ("axis", Dir)],
"solid.fillet" | "solid.chamfer" => &[("edges", Picks), ("body", Body)],
"solid.hole" => &[("position", Pick), ("direction", Dir), ("body", Body)],
"solid.scale" => &[("origin", Make), ("bodies", Body)],
"solid.offset_face" | "solid.shell" => &[("faces", Picks), ("body", Body)],
"solid.thread" => &[("face", Pick)],
"solid.draft" => &[("faces", Picks), ("neutral", Plane), ("pull", Dir), ("body", Body)],
"solid.press_pull" => &[("face", Pick), ("edges", Picks)],
"solid.replace_face" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)],
"solid.align" => &[("from", Pick), ("from_face", Pick), ("to", Pick), ("to_face", Pick), ("bodies", Body)],
"solid.mirror" | "solid.split_body" => &[("plane", Plane), ("bodies", Body), ("body", Body)],
"plastic.boss" | "plastic.snap_fit" | "plastic.rest" => {
&[("position", Pick), ("direction", Dir), ("hook", Dir), ("along", Dir), ("body", Body)]
}
"plastic.lip" | "sheet.convert" => &[("face", Pick), ("body", Body)],
"sheet.flange" | "sheet.hem" => &[("edges", Picks), ("body", Body)],
"sheet.fold" => &[("points", Picks), ("fixed", Pick), ("body", Body)],
"sketch.create" | "sketch.redefine" => &[("plane", Plane)],
"canvas.decal" => &[("face", Pick)],
"sketch.intersect" => &[("refs", Picks)],
"sketch.project_to_surface" | "sketch.isoparametric_curve" => &[("face", Pick), ("body", Body)],
"sketch.intersection_curve" => &[("a", Picks), ("b", Picks)],
"inspect.curvature_comb" | "inspect.minimum_radius" | "inspect.isocurve" => &[("edges", Picks), ("faces", Picks)],
"parts.insert" | "parts.fastener" => &[("at", Pick), ("point", Make), ("direction", Dir)],
"appearance.assign" => &[("faces", Picks), ("bodies", Body), ("body", Body)],
"solid.move" => &[("bodies", Body), ("translate", Dir), ("axis", Dir), ("origin", Make)],
"solid.combine" => &[("target", Body), ("tools", Body)],
"solid.remove" | "solid.bounding_solid" | "material.assign" => &[("bodies", Body)],
_ => &[],
}
}
/// The world placement of bodies (name → frame), with moves not captured yet.
struct Frames {
bodies: Vec<(String, String, Mat)>,
active: Mat,
}
fn frames(s: &Session) -> Frames {
let st = s.model.state();
let doc: std::borrow::Cow<solvecraft_doc::Document> = if s.pending_moves.is_empty() {
std::borrow::Cow::Borrowed(&*s.doc)
} else {
let mut d = (*s.doc).clone();
for o in &mut d.occurrences {
if let Some(m) = s.pending_moves.get(&o.id) {
o.transform = *m;
}
}
std::borrow::Cow::Owned(d)
};
// World name, the component's own body name, and its frame.
let bodies = doc
.body_frames(&st)
.into_iter()
.map(|(world, _, _, m)| {
let own = st
.bodies
.iter()
.find(|b| world == b.name || world.starts_with(&format!("{} (", b.name)))
.map(|b| b.name.clone())
.unwrap_or_else(|| world.clone());
(world, own, m)
})
.collect();
Frames { bodies, active: doc.component_transform(s.active_component) }
}
/// Does any placement move anything (so the world differs from the components' frames)?
pub fn placed(s: &Session) -> bool {
!s.pending_moves.is_empty() || !s.doc.body_offsets.is_empty() || s.doc.placements().iter().any(|(_, _, m)| !is_identity(m))
}
fn vec3(v: &Value) -> Option<Vec3> {
let a = v.as_array()?;
if a.len() != 3 {
return None;
}
let c = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite());
Some(Vec3::new(c(0)?, c(1)?, c(2)?))
}
fn pt(p: Vec3) -> Value {
serde_json::json!([p.x, p.y, p.z])
}
/// The frame of the body a world point lies on (the nearest), or of the named body.
fn frame_at(s: &Session, f: &Frames, p: Vec3, body: Option<&str>) -> Option<Mat> {
if let Some(b) = body
&& let Some((_, _, m)) = f.bodies.iter().find(|(w, own, _)| w == b || (own == b && !w.contains(" (")))
{
return Some(*m);
}
let world = s.world_state();
let mut best: Option<(f64, Mat)> = None;
for wb in &world.bodies {
let Some((_, _, m)) = f.bodies.iter().find(|(w, _, _)| *w == wb.name) else { continue };
let mesh = wb.mesh();
let bb = mesh.bounds();
let tol = (bb.diagonal() * 1e-3).max(1e-3);
if p.x < bb.min.x - tol
|| p.y < bb.min.y - tol
|| p.z < bb.min.z - tol
|| p.x > bb.max.x + tol
|| p.y > bb.max.y + tol
|| p.z > bb.max.z + tol
{
continue;
}
let d = mesh.triangles.iter().filter_map(|t| mesh.tri(t)).map(|[a, b, c]| tri_dist(p, a, b, c)).fold(f64::INFINITY, f64::min);
if d <= tol && best.as_ref().is_none_or(|(bd, _)| d < *bd) {
best = Some((d, *m));
}
}
best.map(|(_, m)| m)
}
fn tri_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
let n = (b - a).cross(c - a);
let Some(nn) = n.normalized() else { return p.dist_to_segment(a, b).min(p.dist_to_segment(b, c)) };
let h = (p - a).dot(nn);
let q = p - nn * h;
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-12);
if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) }
}
struct Mapper<'a> {
s: &'a Session,
f: Frames,
/// The frame of the command's first pick (its directions turn with it).
first: Option<Mat>,
changed: bool,
}
impl Mapper<'_> {
fn pick(&mut self, v: &Value, body: Option<&str>) -> Option<Value> {
let p = vec3(v)?;
let m = frame_at(self.s, &self.f, p, body).unwrap_or(self.f.active);
self.first.get_or_insert(m);
self.local_point(&m, p)
}
fn local_point(&mut self, m: &Mat, p: Vec3) -> Option<Value> {
if is_identity(m) {
return None;
}
let inv = mat_inverse(m)?;
self.changed = true;
Some(pt(apply_point(&inv, p)))
}
fn make(&mut self, v: &Value) -> Option<Value> {
let p = vec3(v)?;
let m = self.f.active;
self.local_point(&m, p)
}
fn dir(&mut self, v: &Value) -> Option<Value> {
let d = vec3(v)?;
let m = self.first.unwrap_or(self.f.active);
if is_identity(&m) {
return None;
}
let inv = mat_inverse(&m)?;
self.changed = true;
Some(pt(apply_vector(&inv, d)))
}
/// A list of picks (points, or objects holding one).
fn picks(&mut self, v: &Value, body: Option<&str>) -> Option<Value> {
match v {
Value::Array(a) if vec3(v).is_some() && a.iter().all(Value::is_number) => self.pick(v, body),
Value::Array(a) => {
let out: Vec<Value> = a.iter().map(|x| self.picks(x, body).unwrap_or_else(|| x.clone())).collect();
Some(Value::Array(out))
}
Value::Object(o) => {
let b = o.get("body").and_then(Value::as_str).map(str::to_string);
let mut m = o.clone();
for k in ["point", "face", "edge", "vertex", "at"] {
if let Some(x) = o.get(k)
&& let Some(y) = self.pick(x, b.as_deref().or(body))
{
m.insert(k.into(), y);
}
}
if let Some(bn) = &b
&& let Some(own) = self.own_name(bn)
{
m.insert("body".into(), Value::String(own));
}
Some(Value::Object(m))
}
_ => None,
}
}
fn plane(&mut self, v: &Value) -> Option<Value> {
let o = v.as_object()?;
let mut m = o.clone();
if let Some(x) = o.get("face").and_then(|x| self.pick(x, None)) {
m.insert("face".into(), x);
}
if let Some(x) = o.get("origin").and_then(|x| self.pick(x, None)) {
m.insert("origin".into(), x);
}
for k in ["normal", "x_dir", "y_dir"] {
if let Some(x) = o.get(k).and_then(|x| self.dir(x)) {
m.insert(k.into(), x);
}
}
Some(Value::Object(m))
}
/// The component's body name for a world (instance) body name.
fn own_name(&mut self, world: &str) -> Option<String> {
let own = self.f.bodies.iter().find(|(w, _, _)| w == world).map(|(_, own, _)| own.clone())?;
(own != world).then(|| {
self.changed = true;
own
})
}
fn body(&mut self, v: &Value) -> Option<Value> {
match v {
Value::String(n) => self.own_name(n).map(Value::String),
Value::Array(a) => {
let out: Vec<Value> = a.iter().map(|x| self.body(x).unwrap_or_else(|| x.clone())).collect();
Some(Value::Array(out))
}
_ => None,
}
}
}
thread_local! {
static DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
/// The outermost command's coordinates were mapped here (its table lists them).
static MAPPED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Did this module map the running command's coordinates? (Otherwise a new feature's geometry
/// goes into the active component's frame as a whole.)
pub fn mapped() -> bool {
MAPPED.with(std::cell::Cell::get)
}
/// A command running inside another (its parameters are already in component frames).
pub struct Nested;
impl Nested {
/// Enter command `id`; true when it is the outermost one.
pub fn enter(id: &str) -> (Nested, bool) {
let outer = DEPTH.with(|d| {
let n = d.get();
d.set(n + 1);
n == 0
});
if outer {
let inner = if id == "timeline.redefine" { None } else { Some(id) };
MAPPED.with(|m| m.set(inner.is_none_or(|i| !table(i).is_empty())));
}
(Nested, outer)
}
}
impl Drop for Nested {
fn drop(&mut self) {
DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
}
}
/// `params` with world picks mapped into component frames (`None`: nothing to change).
pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
if id == "timeline.redefine" {
let cmd = params.get("command")?.as_str()?;
let inner = to_local(s, cmd, params.get("params")?)?;
let mut out = params.clone();
out["params"] = inner;
return Some(out);
}
let keys = table(id);
if keys.is_empty() || !placed(s) {
return None;
}
let obj = params.as_object()?;
let mut m = Mapper { s, f: frames(s), first: None, changed: false };
let body = obj.get("body").and_then(Value::as_str).map(str::to_string);
let mut out: Map<String, Value> = obj.clone();
// Picks first: their frame turns the directions.
for pass in [true, false] {
for (k, kind) in keys {
let Some(v) = obj.get(*k) else { continue };
let is_pick = matches!(kind, Pick | Picks | Plane);
if is_pick != pass {
continue;
}
let new = match kind {
Pick => m.pick(v, body.as_deref()),
Picks => m.picks(v, body.as_deref()),
Make => m.make(v),
Dir => m.dir(v),
Plane => m.plane(v),
Body => m.body(v),
};
if let Some(n) = new {
out.insert((*k).to_string(), n);
}
}
}
m.changed.then_some(Value::Object(out))
}
#[cfg(test)]
#[path = "frames_tests.rs"]
mod tests;
-146
View File
@@ -1,146 +0,0 @@
//! Components as frames: what a component holds moves with its occurrence, and picks in the
//! world land in the component's own frame (user bug #4).
use serde_json::{Value, json};
use solvecraft_geom::Vec3;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn near(a: Vec3, b: Vec3) -> bool {
a.dist(b) < 1e-6
}
fn world_bbox(s: &Session, body: &str) -> (Vec3, Vec3) {
let w = s.world_state();
let b = w.body(body).unwrap_or_else(|| panic!("no body {body}")).mesh().bounds();
(b.min, b.max)
}
fn sketch_point_world(s: &mut Session, sketch: &str, point: &str) -> Vec3 {
let si = run(s, "sketch.inspect", json!({ "sketch": sketch }));
let p = si["points"].as_array().unwrap().iter().find(|p| p["id"] == point).unwrap()["world"].clone();
Vec3::new(p[0].as_f64().unwrap(), p[1].as_f64().unwrap(), p[2].as_f64().unwrap())
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
}
/// A component "Part" with a 30 x 20 x 10 block (sketch "Base" on XY, "Top" on its top face)
/// and a construction plane, moved 50 mm along X.
fn moved_part() -> Session {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Part"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 10}));
run(&mut s, "sketch.create", json!({"plane": {"face": [15, 10, 10]}, "name": "Top"}));
run(&mut s, "sketch.circle.center", json!({"center": [15, 10], "radius": 3}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 25, "name": "Shelf"}));
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [50, 0, 0]}));
s
}
/// (a) Sketches and construction planes move with their component's occurrence.
#[test]
fn sketches_and_planes_move_with_the_component() {
let mut s = moved_part();
let (lo, hi) = world_bbox(&s, "Body1");
assert!(near(lo, Vec3::new(50.0, 0.0, 0.0)) && near(hi, Vec3::new(80.0, 20.0, 10.0)), "{lo:?} {hi:?}");
assert!(near(sketch_point_world(&mut s, "Base", "p1"), Vec3::new(50.0, 0.0, 0.0)));
let c = sketch_point_world(&mut s, "Top", "p1");
assert!((c.z - 10.0).abs() < 1e-6 && c.x >= 50.0 - 1e-6, "{c:?}");
let shelf = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Shelf").unwrap().2;
assert!(near(shelf.origin, Vec3::new(50.0, 0.0, 25.0)), "{:?}", shelf.origin);
// A turn about Z carries them too.
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "axis": [0, 0, 1], "angle": "90 deg", "origin": [50, 0, 0]}));
assert!(near(sketch_point_world(&mut s, "Base", "p3"), Vec3::new(30.0, 30.0, 0.0)), "{:?}", sketch_point_world(&mut s, "Base", "p3"));
}
/// (b) Picks on a moved component, given in the world, land on its geometry: fillet edges, a
/// sketch on a face, a hole, a shell; new geometry of the active component goes where asked.
#[test]
fn world_picks_map_into_the_component() {
let mut s = moved_part();
let v0 = volume(&mut s);
// The edge along X at the top front: world (65, 0, 10).
run(&mut s, "solid.fillet", json!({"edges": [[65, 0, 10]], "radius": 2}));
assert!(volume(&mut s) < v0 - 1.0, "the fillet cut material");
// A sketch on the moved top face sits at world z = 10 over the moved block.
run(&mut s, "sketch.create", json!({"plane": {"face": [70, 10, 10]}, "name": "Pad"}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 10], "radius": 2}));
run(&mut s, "sketch.finish", json!({}));
let pad = sketch_point_world(&mut s, "Pad", "p1");
assert!((pad.z - 10.0).abs() < 1e-6 && pad.x > 50.0, "{pad:?}");
let v1 = volume(&mut s);
let h = run(&mut s, "solid.hole", json!({"position": [60, 10, 10], "diameter": 4, "depth": 5}));
assert!(volume(&mut s) < v1 - 1.0, "the hole cut material: {h}");
// A box made at world (100, 0, 0) in the active (moved) component shows there.
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [100, 0, 0], "operation": "new"}));
let last = s.model.state().bodies.last().map(|b| b.name.clone()).unwrap();
let (lo, _) = world_bbox(&s, &last);
assert!(near(lo, Vec3::new(100.0, 0.0, 0.0)), "{lo:?}");
}
/// (b) A pasted instance's pick fillets the component (every instance).
#[test]
fn picks_on_an_instance_map_through_its_placement() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Pin"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.copy", json!({"component": "Pin", "translate": [0, 40, 0]}));
let v0 = volume(&mut s);
// An edge of the second instance only (world y = 40).
run(&mut s, "solid.fillet", json!({"edges": [[5, 40, 10]], "radius": 2}));
let v1 = volume(&mut s);
assert!(v1 < v0 - 1.0, "{v0} {v1}");
}
/// (c) Moving a body into a moved component keeps it where it is in the world.
#[test]
fn moving_bodies_between_components_keeps_their_place() {
let mut s = moved_part();
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [0, 40, 0], "body_name": "Loose"}));
let before = world_bbox(&s, "Loose");
run(&mut s, "component.move_bodies", json!({"bodies": ["Loose"], "component": "Part"}));
let after = world_bbox(&s, "Loose");
assert!(near(before.0, after.0) && near(before.1, after.1), "{before:?} {after:?}");
// It moves with the component from now on, and a pick on it still lands.
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 0, 10]}));
let moved = world_bbox(&s, "Loose");
assert!(near(moved.0, before.0 + Vec3::new(0.0, 0.0, 10.0)), "{moved:?}");
let v0 = volume(&mut s);
run(&mut s, "solid.fillet", json!({"edges": [[2, 40, 14]], "radius": 1}));
assert!(volume(&mut s) < v0 - 0.1);
// Back to the root: still in place.
run(&mut s, "component.move_bodies", json!({"bodies": ["Loose"], "component": "root"}));
let back = world_bbox(&s, "Loose");
assert!(near(back.0, moved.0) && near(back.1, moved.1), "{back:?} {moved:?}");
}
/// (d) A sketch on a body's face follows the body when a Move feature moves it.
#[test]
fn sketch_on_a_face_follows_a_moved_body() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10}));
run(&mut s, "sketch.create", json!({"plane": {"face": [10, 10, 10]}, "name": "OnTop"}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 2}));
run(&mut s, "sketch.finish", json!({}));
// The move goes before the sketch in the timeline: the face the sketch is on moves up.
run(&mut s, "timeline.roll_to", json!({"position": 1}));
run(&mut s, "solid.move", json!({"bodies": ["Body1"], "translate": [0, 0, 15]}));
run(&mut s, "timeline.roll_to", json!({}));
let c = sketch_point_world(&mut s, "OnTop", "p1");
assert!((c.z - 25.0).abs() < 1e-6, "{c:?}");
}
-236
View File
@@ -1,236 +0,0 @@
//! Legacy migration: the command ids SolveCraft used before 2026-10-09, when many commands
//! carried the id of the matching command in another CAD program, and the dotted ids that
//! replaced them. This is the one place the old ids live. Old scripts, recipes, control-channel
//! calls, saved shortcuts and toolbar layouts keep working: `find_command` and the preference
//! loaders map an old id through `current_id`. `solvecraft-tools/rename_ids.sh` converts a
//! worktree with this same table.
/// (old id, current id).
pub const LEGACY_IDS: &[(&str, &str)] = &[
("AsBuiltPositionsCmd", "component.revert_position"),
("FusionCreateComponentsFromBodiesCommand", "component.from_bodies"),
("FusionCreateNewComponentCommand", "component.create"),
("SnapshotCmd", "component.capture_position"),
("FusionShowDesignConfigPanelCmd", "config.table"),
("FusionStartDesignConfigModeCmd", "config.configure"),
("FusionComputeAllCommand", "timeline.compute_all"),
("FusionDeleteCommand", "timeline.delete"),
("FusionRenameTimelineEntryCommand", "timeline.rename"),
("RedoCommand", "edit.redo"),
("UndoCommand", "edit.undo"),
("timeline.rollTo", "timeline.roll_to"),
("AlignCmd", "solid.align"),
("EmbossCmd", "solid.emboss"),
("FusionReplaceFaceCommand", "solid.replace_face"),
("FusionRibCommand", "solid.rib"),
("FusionWebCommand", "solid.web"),
("PrimitiveCoil", "solid.coil"),
("SoftDeleteCommand", "solid.remove"),
("ConstructionPlaneAtAngleCommand", "construct.plane.at_angle"),
("ConstructionPlaneOffsetFromPlaneCommand", "construct.plane.offset"),
("Extrude", "solid.extrude"),
("FusionChamferCommand", "solid.chamfer"),
("FusionCombineCommand", "solid.combine"),
("FusionDraftCommand", "solid.draft"),
("FusionFilletEdgesCommand", "solid.fillet"),
("FusionHoleCommand", "solid.hole"),
("FusionMoveCommand", "solid.move"),
("FusionOffsetFacesCommand", "solid.offset_face"),
("FusionPressPullCommand", "solid.press_pull"),
("FusionShellBodyCommand", "solid.shell"),
("FusionSplitBodyCommand", "solid.split_body"),
("FusionThreadCommand", "solid.thread"),
("MirrorCommand", "solid.mirror"),
("ModifyScale", "solid.scale"),
("PatternCircular", "solid.pattern.circular"),
("PatternOnPath", "solid.pattern.path"),
("PatternRectangular", "solid.pattern.rectangular"),
("PrimitiveBox", "solid.box"),
("PrimitiveCylinder", "solid.cylinder"),
("PrimitivePipe", "solid.pipe"),
("PrimitiveSphere", "solid.sphere"),
("PrimitiveTorus", "solid.torus"),
("Revolve", "solid.revolve"),
("SolidLoft", "solid.loft"),
("StockModelCommand", "solid.bounding_solid"),
("Sweep", "solid.sweep"),
("ExportCommand", "file.export"),
("FusionImportCommandFromToolbar", "file.insert_step"),
("FusionSaveAsSTLCommand", "file.save_mesh"),
("NewDocumentCommand", "file.new"),
("ParaMeshInsertAlignCommand", "file.insert_mesh"),
("SaveDocumentAsCommand", "file.save_as"),
("SaveDocumentCommand", "file.save"),
("AppearanceCommand", "appearance.assign"),
("MeasureCommand", "inspect.measure"),
("PhysicalMaterialCommand", "material.assign"),
("FusionMotionRelationshipCommand", "joint.motion_link"),
("FusionMoveJointsCommand", "joint.drive"),
("InterferenceCheckCommand", "inspect.interference"),
("JointAsBuiltCmd", "joint.as_built"),
("JointAssembleCmdNew", "joint.create"),
("JointOrigin", "joint.origin"),
("RigidGroupCmd", "joint.rigid_group"),
("ContactSetCmd", "contact.create"),
("DisableAllContactCmd", "contact.disable_all"),
("EnableAllContactCmd", "contact.enable_all"),
("EnableContactSetsCmd", "contact.enable_sets"),
("FusionMotionStudyCommand", "motion.study"),
("ChangeParameterCommand", "parameters.change"),
("FusionFastenersCommand", "parts.fastener"),
("FusionAssignPlasticRuleCommand", "plastic.assign_rule"),
("FusionBossCommand", "plastic.boss"),
("FusionLipCommand", "plastic.lip"),
("FusionManagePlasticRuleCommand", "plastic.manage_rules"),
("FusionRestCommand", "plastic.rest"),
("FusionSnapFitCommand", "plastic.snap_fit"),
("FusionHalfSectionViewCommand", "inspect.section"),
("ConvertToSheetMetalCmd", "sheet.convert"),
("FusionSheetMetalFlangeCommand", "sheet.flange"),
("FusionSheetMetalFlatPatternCmd", "sheet.flat_pattern"),
("FusionSheetMetalHemFlangeCommand", "sheet.hem"),
("FusionSheetMetalRulesCommand", "sheet.manage_rules"),
("FusionSheetmetalUnfoldCommand", "sheet.unfold"),
("SheetMetalFoldCmd", "sheet.fold"),
("FusionAddCanvasCommand", "canvas.insert"),
("FusionAddEditDecalCommand", "canvas.decal"),
("ConstraintSmooth", "sketch.constraint.curvature"),
("SketchAutoConstrainAndFinish", "sketch.finish_auto_constrain"),
("SketchAutoConstraintAndDimCmd", "sketch.auto_constrain"),
("SketchAutoConstraintAndDimFromDatumCmd", "sketch.auto_constrain_from_datum"),
("SketchConstrainer", "sketch.constrainer"),
("SketchPolygonConstraintCmd", "sketch.constraint.polygon"),
("ArcTangent", "sketch.arc.tangent"),
("BlendG1CurveSketchCmd", "sketch.blend_curve"),
("CircleElipse", "sketch.ellipse"),
("CircleTanTanRadius", "sketch.circle.two_tangent"),
("CircleThreeTangent", "sketch.circle.three_tangent"),
("ConicCurveCmd", "sketch.conic"),
("DrawCVMSpline3D", "sketch.spline.control_point"),
("DrawCVMSpline5D", "sketch.spline.control_point_5"),
("DrawSpline", "sketch.spline.fit_point"),
("MTextCmd", "sketch.text"),
("ShapeArcSlotCenterTwoPoint", "sketch.slot.arc_center"),
("ShapeArcSlotThreePoint", "sketch.slot.arc_three_point"),
("ShapeSlotCenterPoint", "sketch.slot.center_point"),
("SketchMidpointLine", "sketch.line.midpoint"),
("ImportDxfFileCommand", "sketch.insert_dxf"),
("SketchImportSVG", "sketch.insert_svg"),
("FusionCenterOfMassCommand", "inspect.center_of_mass"),
("FusionCurvatureCombAnalysisCommand", "inspect.curvature_comb"),
("FusionIsoCurveAnalysisCommand", "inspect.isocurve"),
("FusionMinimumRadiusAnalysisCommand", "inspect.minimum_radius"),
("BreakSketchCmd", "sketch.break"),
("ChamferSketchDistanceAngle", "sketch.chamfer.distance_angle"),
("ChamferSketchDistanceDistance", "sketch.chamfer.two_distance"),
("ChamferSketchEqualDistance", "sketch.chamfer.equal_distance"),
("CircularSketchPatternCommand", "sketch.pattern.circular"),
("ExtendSketchCmd", "sketch.extend"),
("FilletSketchCmd", "sketch.fillet"),
("MirrorSketchCommand", "sketch.mirror"),
("Offset", "sketch.offset"),
("RectangularSketchPatternCommand", "sketch.pattern.rectangular"),
("SketchScaleCmd", "sketch.scale"),
("TrimSketchCmd", "sketch.trim"),
("FitCurvesToSectionCommand", "sketch.fit_curves_to_section"),
("Include3DGeometry", "sketch.include_3d"),
("IntersectCmd", "sketch.intersect"),
("IntersectionCurve", "sketch.intersection_curve"),
("ProjectNewCmd", "sketch.project"),
("ProjectToSurface", "sketch.project_to_surface"),
("SketchIsoparametricCurve", "sketch.isoparametric_curve"),
("SpunProfileCmd", "sketch.spun_profile"),
("ArcCenterTwoPoint", "sketch.arc.center_point"),
("ArcThreePoint", "sketch.arc.three_point"),
("CircleCenterRadius", "sketch.circle.center"),
("CircleThreePoint", "sketch.circle.three_point"),
("CircleTwoPoint", "sketch.circle.two_point"),
("ConstraintCoincident", "sketch.constraint.coincident"),
("ConstraintCollinear", "sketch.constraint.collinear"),
("ConstraintConcentric", "sketch.constraint.concentric"),
("ConstraintEqual", "sketch.constraint.equal"),
("ConstraintFix", "sketch.constraint.fix"),
("ConstraintHorizontalVertical", "sketch.constraint.horizontal_vertical"),
("ConstraintMidPoint", "sketch.constraint.midpoint"),
("ConstraintParallel", "sketch.constraint.parallel"),
("ConstraintPerpendicular", "sketch.constraint.perpendicular"),
("ConstraintSymmetry", "sketch.constraint.symmetry"),
("ConstraintTangent", "sketch.constraint.tangent"),
("DrawPoint", "sketch.point"),
("DrawPolyline", "sketch.line"),
("ShapePolygonCircumscribed", "sketch.polygon.circumscribed"),
("ShapePolygonEdge", "sketch.polygon.edge"),
("ShapePolygonInscribed", "sketch.polygon.inscribed"),
("ShapeRectangleCenter", "sketch.rectangle.center"),
("ShapeRectangleThreePoint", "sketch.rectangle.three_point"),
("ShapeRectangleTwoPoint", "sketch.rectangle.two_point"),
("ShapeSlotCenterToCenter", "sketch.slot.center_to_center"),
("ShapeSlotOverall", "sketch.slot.overall"),
("SketchActivate", "sketch.edit"),
("SketchCreate", "sketch.create"),
("SketchDimension", "sketch.dimension"),
("SketchStop", "sketch.finish"),
("FusionAccessibilityAnalysisCommand", "inspect.accessibility"),
("FusionCurvatureMapAnalysisCommand", "inspect.curvature_map"),
("FusionDraftAnalysisCommand", "inspect.draft"),
("FusionEnvironmentMapAnalysisCommand", "inspect.environment_map"),
("FusionZebraAnalysisCommand", "inspect.zebra"),
("FusionExtendCommand", "surface.extend"),
("FusionSurfacePatchCommand", "surface.patch"),
("FusionSurfaceStitchCommand", "surface.stitch"),
("FusionSurfaceThickenCommand", "surface.thicken"),
("FusionSurfaceTrimCommand", "surface.trim"),
("FusionSplitFaceCommand", "solid.split_face"),
("ConstructionAxisThroughCylinderCommand", "construct.axis.through_cylinder"),
("ConstructionAxisNormalToFaceCommand", "construct.axis.normal_to_face"),
("ConstructionAxisFromTwoPlanesCommand", "construct.axis.two_planes"),
("ConstructionAxisThroughTwoPointsCommand", "construct.axis.two_points"),
("ConstructionAxisThroughEdgeCommand", "construct.axis.edge"),
("ConstructionPointFromPointCommand", "construct.point.vertex"),
("ConstructionPointThroughTwoEdgesCommand", "construct.point.two_edges"),
("ConstructionPointFromThreePlanesCommand", "construct.point.three_planes"),
("ConstructionPointFromCircleOrSphereCommand", "construct.point.center"),
("ConstructionPointAtEdgeAndPlaneCommand", "construct.point.edge_plane"),
("ConstructionPointAlongPathCommand", "construct.point.along_path"),
("ConstructionMidPlaneCommand", "construct.plane.midplane"),
("ConstructionPlaneFromTwoEdgesCommand", "construct.plane.two_edges"),
("ConstructionPlaneFromThreePointsCommand", "construct.plane.three_points"),
("ConstructionTangentPlaneCommand", "construct.plane.tangent"),
("ConstructionPlaneAlongPathCommand", "construct.plane.along_path"),
];
/// The current id for a command id that may be a legacy one (unchanged otherwise).
pub fn current_id(id: &str) -> &str {
LEGACY_IDS.iter().find(|(old, _)| old.eq_ignore_ascii_case(id)).map(|(_, new)| *new).unwrap_or(id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_legacy_id_names_a_command() {
let unknown: Vec<&str> =
LEGACY_IDS.iter().filter(|(_, new)| crate::find_command(new).is_none_or(|c| c.id != *new)).map(|(_, new)| *new).collect();
// The construction set and Split Face are on a branch not yet merged when the table was made.
let pending = ["solid.split_face"];
let unknown: Vec<&str> = unknown
.into_iter()
.filter(|n| {
!pending.contains(n)
&& !n.starts_with("construct.axis")
&& !n.starts_with("construct.point")
&& ![
"construct.plane.midplane",
"construct.plane.two_edges",
"construct.plane.three_points",
"construct.plane.tangent",
"construct.plane.along_path",
]
.contains(n)
})
.collect();
assert!(unknown.is_empty(), "{unknown:?}");
assert_eq!(crate::find_command("SketchCreate").map(|c| c.id), Some("sketch.create"));
}
}
+4 -20
View File
@@ -1,15 +1,14 @@
//! The SolveCraft engine.
//!
//! Every user-visible action is a command with a stable, dotted, lower-case id
//! (`solid.extrude`, `sketch.create`, `solid.fillet`, `timeline.rollback`). Commands take JSON
//! Every user-visible action is a command with a stable id. Ids are Fusion's command ids where
//! Fusion has the command (`Extrude`, `SketchCreate`, `FusionFilletEdgesCommand`…) and dotted
//! lower-case ids otherwise (`timeline.rollback`, `document.inspect`). Commands take JSON
//! parameters and never open dialogs; the UI, the command palette, scripts, the CLI and the
//! control channel all run the same commands through [`Session::execute`].
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod cmd;
pub mod frames;
pub mod legacy_ids;
pub mod params;
pub mod recovery;
pub mod sample;
@@ -298,11 +297,6 @@ impl Session {
let before = Snapshot { label: spec.label.to_string(), doc: self.doc.clone(), active_sketch: self.active_sketch };
let null = Value::Object(Default::default());
let params = if params.is_null() { &null } else { params };
// Picks in the world go into their component's frame (once: commands run by commands
// already have them there).
let (_nested, outermost) = frames::Nested::enter(spec.id);
let mapped = if outermost { frames::to_local(self, spec.id, params) } else { None };
let params = mapped.as_ref().unwrap_or(params);
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
let r = match r {
Ok(r) => r,
@@ -390,7 +384,7 @@ impl Session {
Ok(self.model.state())
}
/// Measure one or two selections (see `inspect.measure` with `items`); errors come back as
/// Measure one or two selections (see `MeasureCommand` with `items`); errors come back as
/// `{"error": …}`.
pub fn measure_items(&self, items: &[Sel]) -> Value {
let v = serde_json::to_value(items).unwrap_or(Value::Null);
@@ -425,15 +419,5 @@ impl Session {
}
}
#[cfg(test)]
mod construct_tests;
#[cfg(test)]
mod delete_face_tests;
#[cfg(test)]
mod extent_tests;
#[cfg(test)]
mod split_face_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod thread_tests;
+3 -3
View File
@@ -207,7 +207,7 @@ mod tests {
let mut s = Session::default();
let mut a = Autosaver::new(&d, Duration::from_secs(3600)).unwrap();
assert!(!a.save(&s).unwrap(), "nothing unsaved");
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
s.execute("PrimitiveBox", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
assert!(!a.tick(&s, false).unwrap(), "not due yet");
assert!(a.tick(&s, true).unwrap(), "after a big operation");
assert!(!a.save(&s).unwrap(), "unchanged since the last autosave");
@@ -230,7 +230,7 @@ mod tests {
let d = dir("clean");
let mut s = Session::default();
let mut a = Autosaver::new(&d, Duration::ZERO).unwrap();
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
s.execute("PrimitiveBox", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
assert!(a.tick(&s, false).unwrap());
s.mark_saved();
a.tick(&s, false).unwrap();
@@ -239,7 +239,7 @@ mod tests {
assert_eq!(fs::read_dir(&d).unwrap().count(), 0);
// Closing with unsaved changes keeps them for the next launch.
let b = Autosaver::new(&d, Duration::ZERO).unwrap();
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5})).unwrap();
s.execute("PrimitiveBox", &json!({"length": 5, "width": 5, "height": 5})).unwrap();
b.close(&s);
assert_eq!(orphans(&d).len(), 1);
assert!(load(&d, "../etc").is_err());
+73 -73
View File
@@ -5,30 +5,30 @@ use serde_json::{Value, json};
pub fn script() -> Value {
json!({"commands": [
{"command": "parameters.change", "params": {"name": "width", "expression": "80 mm", "comment": "plate width"}},
{"command": "parameters.change", "params": {"name": "depth", "expression": "50 mm"}},
{"command": "parameters.change", "params": {"name": "thickness", "expression": "8 mm"}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [80, 50]}},
{"command": "sketch.constraint.coincident", "params": {"a": "p1", "b": "origin"}},
{"command": "sketch.dimension", "params": {"entities": ["l1"], "value": "width"}},
{"command": "sketch.dimension", "params": {"entities": ["l2"], "value": "depth"}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": "thickness", "body_name": "Plate"}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 4], [80, 0, 4], [80, 50, 4], [0, 50, 4]], "radius": "6 mm"}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "Holes"}},
{"command": "sketch.circle.center", "params": {"center": [12, 25], "diameter": 8}},
{"command": "sketch.circle.center", "params": {"center": [68, 25], "diameter": 8}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": "thickness", "operation": "cut", "name": "Holes"}},
{"command": "sketch.create", "params": {"plane": {"face": [40, 25, 8]}, "name": "Boss"}},
{"command": "sketch.circle.center", "params": {"center": [40, 25], "diameter": 24}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 14, "operation": "join", "name": "Boss"}},
{"command": "sketch.create", "params": {"plane": {"face": [40, 37, 22]}, "name": "Bore"}},
{"command": "sketch.circle.center", "params": {"center": [40, 25], "diameter": 12}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 22, "direction": "negative", "operation": "cut", "name": "Bore"}},
{"command": "ChangeParameterCommand", "params": {"name": "width", "expression": "80 mm", "comment": "plate width"}},
{"command": "ChangeParameterCommand", "params": {"name": "depth", "expression": "50 mm"}},
{"command": "ChangeParameterCommand", "params": {"name": "thickness", "expression": "8 mm"}},
{"command": "SketchCreate", "params": {"plane": "XY", "name": "Base"}},
{"command": "ShapeRectangleTwoPoint", "params": {"p0": [0, 0], "p1": [80, 50]}},
{"command": "ConstraintCoincident", "params": {"a": "p1", "b": "origin"}},
{"command": "SketchDimension", "params": {"entities": ["l1"], "value": "width"}},
{"command": "SketchDimension", "params": {"entities": ["l2"], "value": "depth"}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": "thickness", "body_name": "Plate"}},
{"command": "FusionFilletEdgesCommand", "params": {"edges": [[0, 0, 4], [80, 0, 4], [80, 50, 4], [0, 50, 4]], "radius": "6 mm"}},
{"command": "SketchCreate", "params": {"plane": "XY", "name": "Holes"}},
{"command": "CircleCenterRadius", "params": {"center": [12, 25], "diameter": 8}},
{"command": "CircleCenterRadius", "params": {"center": [68, 25], "diameter": 8}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": "thickness", "operation": "cut", "name": "Holes"}},
{"command": "SketchCreate", "params": {"plane": {"face": [40, 25, 8]}, "name": "Boss"}},
{"command": "CircleCenterRadius", "params": {"center": [40, 25], "diameter": 24}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": 14, "operation": "join", "name": "Boss"}},
{"command": "SketchCreate", "params": {"plane": {"face": [40, 37, 22]}, "name": "Bore"}},
{"command": "CircleCenterRadius", "params": {"center": [40, 25], "diameter": 12}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": 22, "direction": "negative", "operation": "cut", "name": "Bore"}},
]})
}
@@ -53,14 +53,14 @@ pub fn samples() -> Vec<Sample> {
fn bracket() -> Value {
json!({"commands": [
{"command": "sheet.manage_rules", "params": {"name": "Steel 2mm", "thickness": "2 mm", "bend_radius": "Thickness", "active": true}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [120, 60]}},
{"command": "sketch.finish", "params": {}},
{"command": "sheet.flange", "params": {"sketch": "Base"}},
{"command": "sheet.flange", "params": {"edges": [[60, 0, 2], [60, 60, 2]], "height": 30}},
{"command": "sheet.flange", "params": {"edges": [[120, 30, 2]], "height": 20, "angle": "60 deg"}},
{"command": "sheet.hem", "params": {"edges": [[0, 30, 2]], "length": 8}},
{"command": "FusionSheetMetalRulesCommand", "params": {"name": "Steel 2mm", "thickness": "2 mm", "bend_radius": "Thickness", "active": true}},
{"command": "SketchCreate", "params": {"plane": "XY", "name": "Base"}},
{"command": "ShapeRectangleTwoPoint", "params": {"p0": [0, 0], "p1": [120, 60]}},
{"command": "SketchStop", "params": {}},
{"command": "FusionSheetMetalFlangeCommand", "params": {"sketch": "Base"}},
{"command": "FusionSheetMetalFlangeCommand", "params": {"edges": [[60, 0, 2], [60, 60, 2]], "height": 30}},
{"command": "FusionSheetMetalFlangeCommand", "params": {"edges": [[120, 30, 2]], "height": 20, "angle": "60 deg"}},
{"command": "FusionSheetMetalHemFlangeCommand", "params": {"edges": [[0, 30, 2]], "length": 8}},
]})
}
@@ -69,70 +69,70 @@ fn hinge() -> Value {
for name in ["Leaf A", "Leaf B"] {
cmds.extend([
json!({"command": "component.activate", "params": {"component": "root"}}),
json!({"command": "component.create", "params": {"name": name}}),
json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}}),
json!({"command": "solid.cylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}}),
json!({"command": "FusionCreateNewComponentCommand", "params": {"name": name}}),
json!({"command": "PrimitiveBox", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}}),
json!({"command": "PrimitiveCylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}}),
]);
}
cmds.extend([
json!({"command": "component.activate", "params": {"component": "root"}}),
json!({"command": "occurrence.ground", "params": {"occurrence": "Leaf A:1", "grounded": true}}),
json!({"command": "joint.create", "params": {"type": "revolute", "name": "Hinge",
json!({"command": "JointAssembleCmdNew", "params": {"type": "revolute", "name": "Hinge",
"a": {"occurrence": "Leaf A:1", "circle": [1, 37, 4]}, "b": {"occurrence": "Leaf B:1", "circle": [1, 37, 4]},
"limits": [["0 deg", "180 deg"]]}}),
json!({"command": "joint.drive", "params": {"joint": "Hinge", "value": 60}}),
json!({"command": "FusionMoveJointsCommand", "params": {"joint": "Hinge", "value": 60}}),
]);
json!({ "commands": cmds })
}
fn enclosure() -> Value {
json!({"commands": [
{"command": "solid.box", "params": {"length": 80, "width": 50, "height": 30, "body_name": "Enclosure"}},
{"command": "solid.shell", "params": {"faces": [[40, 25, 30]], "thickness": 2}},
{"command": "plastic.lip", "params": {"face": [1, 25, 30], "width": 1, "height": 2}},
{"command": "plastic.snap_fit", "params": {"position": [40, 44, 2], "hook": [0, -1, 0], "length": 14, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 2}},
{"command": "plastic.boss", "params": {"position": [10, 10, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}},
{"command": "plastic.boss", "params": {"position": [70, 40, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}},
{"command": "plastic.rest", "params": {"position": [40, 25, 2], "width": 16, "length": 24, "height": 3}},
{"command": "PrimitiveBox", "params": {"length": 80, "width": 50, "height": 30, "body_name": "Enclosure"}},
{"command": "FusionShellBodyCommand", "params": {"faces": [[40, 25, 30]], "thickness": 2}},
{"command": "FusionLipCommand", "params": {"face": [1, 25, 30], "width": 1, "height": 2}},
{"command": "FusionSnapFitCommand", "params": {"position": [40, 44, 2], "hook": [0, -1, 0], "length": 14, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 2}},
{"command": "FusionBossCommand", "params": {"position": [10, 10, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}},
{"command": "FusionBossCommand", "params": {"position": [70, 40, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}},
{"command": "FusionRestCommand", "params": {"position": [40, 25, 2], "width": 16, "length": 24, "height": 3}},
]})
}
fn flange() -> Value {
json!({"commands": [
{"command": "parameters.change", "params": {"name": "bolts", "expression": "6", "unit": "", "comment": "number of bolt holes"}},
{"command": "parameters.change", "params": {"name": "pcd", "expression": "70 mm", "comment": "bolt circle diameter"}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "Disc"}},
{"command": "sketch.circle.center", "params": {"center": [0, 0], "diameter": 100}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 10, "body_name": "Flange", "name": "Disc"}},
{"command": "sketch.create", "params": {"plane": {"face": [0, 0, 10]}, "name": "Hub"}},
{"command": "sketch.circle.center", "params": {"center": [0, 0], "diameter": 44}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 16, "operation": "join", "name": "Hub"}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "Bore"}},
{"command": "sketch.circle.center", "params": {"center": [0, 0], "diameter": 24}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 26, "operation": "cut", "name": "Bore"}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "Bolt"}},
{"command": "sketch.circle.center", "params": {"center": [35, 0], "diameter": 9}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 10, "operation": "cut", "name": "BoltHole"}},
{"command": "solid.pattern.circular", "params": {"features": ["BoltHole"], "axis": "Z", "count": "bolts"}},
{"command": "ChangeParameterCommand", "params": {"name": "bolts", "expression": "6", "unit": "", "comment": "number of bolt holes"}},
{"command": "ChangeParameterCommand", "params": {"name": "pcd", "expression": "70 mm", "comment": "bolt circle diameter"}},
{"command": "SketchCreate", "params": {"plane": "XY", "name": "Disc"}},
{"command": "CircleCenterRadius", "params": {"center": [0, 0], "diameter": 100}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": 10, "body_name": "Flange", "name": "Disc"}},
{"command": "SketchCreate", "params": {"plane": {"face": [0, 0, 10]}, "name": "Hub"}},
{"command": "CircleCenterRadius", "params": {"center": [0, 0], "diameter": 44}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": 16, "operation": "join", "name": "Hub"}},
{"command": "SketchCreate", "params": {"plane": "XY", "name": "Bore"}},
{"command": "CircleCenterRadius", "params": {"center": [0, 0], "diameter": 24}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": 26, "operation": "cut", "name": "Bore"}},
{"command": "SketchCreate", "params": {"plane": "XY", "name": "Bolt"}},
{"command": "CircleCenterRadius", "params": {"center": [35, 0], "diameter": 9}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": 10, "operation": "cut", "name": "BoltHole"}},
{"command": "PatternCircular", "params": {"features": ["BoltHole"], "axis": "Z", "count": "bolts"}},
]})
}
fn vents() -> Value {
json!({"commands": [
{"command": "parameters.change", "params": {"name": "cols", "expression": "6", "unit": ""}},
{"command": "parameters.change", "params": {"name": "rows", "expression": "3", "unit": ""}},
{"command": "parameters.change", "params": {"name": "pitch", "expression": "16 mm", "comment": "slot spacing"}},
{"command": "solid.box", "params": {"length": 120, "width": 80, "height": 4, "body_name": "Plate"}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 2], [120, 0, 2], [120, 80, 2], [0, 80, 2]], "radius": "8 mm"}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "Slot"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [18, 12], "p1": [24, 26]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 4, "operation": "cut", "name": "Vent"}},
{"command": "solid.pattern.rectangular", "params": {"features": ["Vent"], "dir1": [1, 0, 0], "count1": "cols", "spacing1": "pitch",
{"command": "ChangeParameterCommand", "params": {"name": "cols", "expression": "6", "unit": ""}},
{"command": "ChangeParameterCommand", "params": {"name": "rows", "expression": "3", "unit": ""}},
{"command": "ChangeParameterCommand", "params": {"name": "pitch", "expression": "16 mm", "comment": "slot spacing"}},
{"command": "PrimitiveBox", "params": {"length": 120, "width": 80, "height": 4, "body_name": "Plate"}},
{"command": "FusionFilletEdgesCommand", "params": {"edges": [[0, 0, 2], [120, 0, 2], [120, 80, 2], [0, 80, 2]], "radius": "8 mm"}},
{"command": "SketchCreate", "params": {"plane": "XY", "name": "Slot"}},
{"command": "ShapeRectangleTwoPoint", "params": {"p0": [18, 12], "p1": [24, 26]}},
{"command": "SketchStop", "params": {}},
{"command": "Extrude", "params": {"distance": 4, "operation": "cut", "name": "Vent"}},
{"command": "PatternRectangular", "params": {"features": ["Vent"], "dir1": [1, 0, 0], "count1": "cols", "spacing1": "pitch",
"dir2": [0, 1, 0], "count2": "rows", "spacing2": "22 mm"}},
]})
}
-64
View File
@@ -1,64 +0,0 @@
//! Split Face: the top of a box divided by a plane, by another body's face and by sketch curves.
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
/// (kernel faces, volume) of a body: the merged count joins pieces of one surface back up.
fn faces_volume(s: &mut Session, b: &str) -> (u64, f64) {
let m = run(s, "inspect.measure", json!({ "bodies": [b] }))["bodies"][0].clone();
(m["kernel"]["faces"].as_u64().unwrap_or(0), m["volume_mm3"].as_f64().unwrap_or(0.0))
}
fn boxed() -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "B"}));
s
}
#[test]
fn split_a_face_by_a_plane() {
let mut s = boxed();
run(&mut s, "solid.split_face", json!({"faces": [[10, 15, 20]], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}}));
let (f, v) = faces_volume(&mut s, "B");
assert_eq!(f, 7);
assert!((v - 24000.0).abs() < 1e-6 * 24000.0, "{v}");
assert_eq!(s.doc.features.last().unwrap().kind.type_name(), "SplitFaceFeature");
}
#[test]
fn split_a_face_by_another_bodys_face() {
let mut s = boxed();
run(&mut s, "solid.box", json!({"corner": [-5, 10, 25], "length": 50, "width": 5, "height": 5, "body_name": "Tool"}));
// The tool's face at y = 10, extended down through the box's top.
run(&mut s, "solid.split_face", json!({"faces": [[20, 5, 20]], "body": "B", "tool": {"body": "Tool", "point": [20, 10, 27]}}));
assert_eq!(faces_volume(&mut s, "B").0, 7);
}
#[test]
fn split_a_face_by_sketch_curves() {
let mut s = boxed();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cuts"}));
run(&mut s, "sketch.line", json!({"points": [[10, -5], [10, 35]]}));
run(&mut s, "sketch.line", json!({"points": [[30, -5], [30, 35]]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.split_face", json!({"faces": [[20, 15, 20]], "body": "B", "sketch": "Cuts"}));
let (f, v) = faces_volume(&mut s, "B");
assert_eq!(f, 8, "two lines make three pieces of the top");
assert!((v - 24000.0).abs() < 1e-6 * 24000.0, "{v}");
}
#[test]
fn a_tool_that_misses_is_refused() {
let mut s = boxed();
let n = s.doc.features.len();
assert!(s.execute("solid.split_face", &json!({"faces": [[10, 15, 20]], "plane": {"origin": [100, 0, 0], "normal": [1, 0, 0]}})).is_err());
assert_eq!(s.doc.features.len(), n);
}

Some files were not shown because too many files have changed in this diff Show More