mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 11:05:56 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 551d068ae2 |
@@ -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.
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Package the Linux release build: a tar.gz with both binaries, and an AppImage of the desktop app.
|
||||
# Usage: .github/release/package-linux.sh <tag> (from the repository root, after
|
||||
# `cargo build --release -p solvecraft -p solvecraft-cli`). Writes out/.
|
||||
set -euo pipefail
|
||||
tag=${1:?usage: package-linux.sh <tag>}
|
||||
bin=${CARGO_TARGET_DIR:-target}/release
|
||||
name="solvecraft-$tag-linux-x86_64"
|
||||
rm -rf stage out && mkdir -p "stage/$name" out
|
||||
cp "$bin/solvecraft" "$bin/solvecraft-cli" README.md LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md "stage/$name/"
|
||||
tar -C stage -czf "out/$name.tar.gz" "$name"
|
||||
|
||||
# AppImage. The icon is rendered by SolveCraft itself from the bracket example, so no image file
|
||||
# is committed.
|
||||
app=stage/SolveCraft.AppDir
|
||||
mkdir -p "$app/usr/bin"
|
||||
cp "$bin/solvecraft" "$bin/solvecraft-cli" "$app/usr/bin/"
|
||||
"$bin/solvecraft-cli" snapshot examples/bracket.json --out "$app/solvecraft.png" --width 256 --height 256 >/dev/null
|
||||
cat > "$app/solvecraft.desktop" <<'DESKTOP'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=SolveCraft
|
||||
Comment=Parametric 3D CAD
|
||||
Exec=solvecraft %f
|
||||
Icon=solvecraft
|
||||
Categories=Graphics;Engineering;
|
||||
MimeType=model/step;
|
||||
DESKTOP
|
||||
cat > "$app/AppRun" <<'APPRUN'
|
||||
#!/bin/sh
|
||||
here="$(dirname "$(readlink -f "$0")")"
|
||||
exec "$here/usr/bin/solvecraft" "$@"
|
||||
APPRUN
|
||||
chmod +x "$app/AppRun"
|
||||
tool=stage/appimagetool
|
||||
curl -sSfL -o "$tool" https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
chmod +x "$tool"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 "$tool" --no-appstream "$app" "out/SolveCraft-$tag-x86_64.AppImage"
|
||||
ls -l out
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Package the macOS release build: a universal (arm64 + x86_64) SolveCraft.app and CLI in one zip.
|
||||
# Usage: .github/release/package-macos.sh <tag> (from the repository root, after building both
|
||||
# aarch64-apple-darwin and x86_64-apple-darwin). Writes out/. The app is not signed.
|
||||
set -euo pipefail
|
||||
tag=${1:?usage: package-macos.sh <tag>}
|
||||
version=${tag#v}
|
||||
t=${CARGO_TARGET_DIR:-target}
|
||||
name="solvecraft-$tag-macos-universal"
|
||||
rm -rf stage out && mkdir -p "stage/$name" out
|
||||
app="stage/$name/SolveCraft.app"
|
||||
mkdir -p "$app/Contents/MacOS"
|
||||
lipo -create -output "$app/Contents/MacOS/solvecraft" "$t/aarch64-apple-darwin/release/solvecraft" "$t/x86_64-apple-darwin/release/solvecraft"
|
||||
lipo -create -output "stage/$name/solvecraft-cli" "$t/aarch64-apple-darwin/release/solvecraft-cli" "$t/x86_64-apple-darwin/release/solvecraft-cli"
|
||||
cat > "$app/Contents/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key><string>SolveCraft</string>
|
||||
<key>CFBundleDisplayName</key><string>SolveCraft</string>
|
||||
<key>CFBundleIdentifier</key><string>io.github.bherbruck.solvecraft</string>
|
||||
<key>CFBundleExecutable</key><string>solvecraft</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleVersion</key><string>$version</string>
|
||||
<key>CFBundleShortVersionString</key><string>$version</string>
|
||||
<key>LSMinimumSystemVersion</key><string>11.0</string>
|
||||
<key>NSHighResolutionCapable</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
cp README.md LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md "stage/$name/"
|
||||
lipo -info "$app/Contents/MacOS/solvecraft"
|
||||
(cd stage && ditto -c -k --keepParent "$name" "../out/$name.zip")
|
||||
ls -l out
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/*
|
||||
@@ -6,4 +6,3 @@
|
||||
*.swp
|
||||
/.claude/worktrees/
|
||||
/target-snapshot
|
||||
__pycache__/
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 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
@@ -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:
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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()}),
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, .. }
|
||||
|
||||
@@ -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
@@ -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
@@ -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, §ion, 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 ®ions {
|
||||
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, ®ions, 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, ®ions, 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, ®ions, 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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ®ions {
|
||||
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())),
|
||||
}
|
||||
}
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -10,7 +10,6 @@ mod assembly;
|
||||
pub mod canvas;
|
||||
mod clipboard;
|
||||
pub mod config;
|
||||
pub mod construct;
|
||||
mod document;
|
||||
mod eval;
|
||||
pub mod expr;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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}]}));
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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}))?
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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() => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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`")),
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()}))
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, ¶ms) {
|
||||
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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`")));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"}},
|
||||
]})
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user