mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 02:55:54 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a2c5a0036 | |||
| 29c594e1f6 |
@@ -1,2 +0,0 @@
|
||||
[alias]
|
||||
xtask = "run --package xtask --"
|
||||
@@ -1,62 +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 the first public build, a **pre-release**: it is
|
||||
early, and we'd like your bug reports.
|
||||
|
||||
- **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.0-windows-x86_64.zip` | Windows 10/11: `solvecraft.exe` (the app) and `solvecraft-cli.exe` |
|
||||
| `solvecraft-v0.1.0-linux-x86_64.tar.gz` | Linux x86_64 (glibc 2.35 or newer): `solvecraft` and `solvecraft-cli` |
|
||||
| `SolveCraft-v0.1.0-x86_64.AppImage` | Linux, the app as one file: `chmod +x` and run it |
|
||||
| `solvecraft-v0.1.0-macos-universal.zip` | macOS 11 or newer, Apple silicon and Intel: `SolveCraft.app` and `solvecraft-cli` |
|
||||
| `solvecraft-v0.1.0-web.zip` | the web app, to host yourself (any static server; HTTPS or localhost for saving files) |
|
||||
| `SHA256SUMS` | checksums: `sha256sum -c SHA256SUMS` |
|
||||
|
||||
### The binaries are not signed
|
||||
|
||||
- **macOS:** the first time, right-click (or Control-click) `SolveCraft.app` and choose **Open**,
|
||||
then **Open** again in the dialog. If macOS says the app is damaged, run
|
||||
`xattr -dr com.apple.quarantine SolveCraft.app`. Do the same for `solvecraft-cli`.
|
||||
- **Windows:** SmartScreen may say "Windows protected your PC". Click **More info**, then
|
||||
**Run anyway**.
|
||||
|
||||
## Known limitations
|
||||
|
||||
SolveCraft is early. Some fillet, chamfer and boolean configurations still fail (with an error,
|
||||
never a crash); surfaces, drawings, CAM, mesh tools and simulation are out of scope for now; the
|
||||
model is Z-up only, so STEP files from Y-up tools open lying on their backs. The full list is in
|
||||
[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/*
|
||||
@@ -1,9 +0,0 @@
|
||||
/target*/
|
||||
/plan/
|
||||
/dist/
|
||||
*~
|
||||
.DS_Store
|
||||
*.swp
|
||||
/.claude/worktrees/
|
||||
/target-snapshot
|
||||
__pycache__/
|
||||
@@ -1,92 +0,0 @@
|
||||
# SolveCraft — instructions for agents
|
||||
|
||||
SolveCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application in the style of
|
||||
Autodesk Fusion: sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
|
||||
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
|
||||
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
|
||||
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
|
||||
and the relevant `plan/architecture.md` section. Behaviour reference: `plan/fusion/`
|
||||
(`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their
|
||||
recipes and measurements).
|
||||
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
|
||||
is rejected for licence reasons).
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
|
||||
unless it lists the decision as the owner's.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust SolveCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
|
||||
returns an error ("not supported yet: …"); it never panics.
|
||||
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
|
||||
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
|
||||
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
|
||||
`solvecraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
|
||||
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
|
||||
panic restores the design and reports `EngineError::Internal`.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`).
|
||||
- Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Fusion may be *observed* black-box (its UI, its toolbar dump, parts built in it
|
||||
and their measurements, kept under `plan/fusion/`, never committed). Never copy Autodesk icons,
|
||||
artwork, help text, tooltips, templates or materials, and never commit files produced by
|
||||
Fusion. Command ids and command names are used as identifiers only (`xtask/data/fusion-catalog.tsv`
|
||||
holds ids and names, nothing else). File formats come from public specifications. **Never read,
|
||||
copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs), SolveSpace,
|
||||
CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. The sketch solver and the blend
|
||||
operations are our own.
|
||||
- **Assets — absolutely essential.** SolveCraft contains **no Autodesk iconography, images,
|
||||
fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any
|
||||
file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
|
||||
(`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;
|
||||
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
|
||||
clicks (`ui.start`) start interactive tools and dialogs, which then run commands.
|
||||
- **Layering** is enforced by `cargo xtask layers`: L0 `geom` → L1 `sketch`, `kernel`, `render`
|
||||
→ L2 `doc` → L3 `io` → L4 `engine` → L5 `ui-egui` → apps. Nothing below L5 depends on
|
||||
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.start(id)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
|
||||
assets, layers). Commit after every feature arc that builds.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solvecraft (private). After each commit that
|
||||
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
|
||||
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
|
||||
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
|
||||
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
|
||||
- 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":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`.
|
||||
- Headless: `solvecraft-cli run script.json --out part.step`, `solvecraft-cli eval design.solvecraft`,
|
||||
`solvecraft-cli snapshot script.json --out shot.png`, `solvecraft-cli commands`.
|
||||
- Fusion oracle: `cargo xtask oracle` replays `plan/fusion/oracle/*/recipe.json` and compares
|
||||
with Fusion's `measure.json` (writes `docs/oracle.md`).
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a
|
||||
milestone task lands. `cargo xtask parity` recomputes command parity in `docs/parity.md`;
|
||||
`cargo xtask oracle` recomputes `docs/oracle.md`.
|
||||
@@ -1,49 +0,0 @@
|
||||
# Attribution
|
||||
|
||||
Every non-code asset in this repository (images, icons, fonts, example parts) is listed here with
|
||||
its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset
|
||||
file is missing from this table.
|
||||
|
||||
**Policy (mandatory):** SolveCraft contains **no Autodesk iconography, images, artwork, fonts,
|
||||
templates, materials or help text.** Every asset is original work by SolveCraft contributors or
|
||||
third-party material under an open licence. Screenshots of Autodesk software are never committed.
|
||||
|
||||
Generated-in-code assets are original and have no file to list:
|
||||
- the UI icon set (`crates/ui-egui/src/icons.rs`);
|
||||
- the view cube, navigation bar and timeline glyphs (`crates/ui-egui/src/viewport.rs`,
|
||||
`crates/ui-egui/src/timeline.rs`).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | 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
|
||||
|
||||
| SolveCraft file | Adapted from | Licence |
|
||||
|---|---|---|
|
||||
| xtask/src/layers.rs | CADCraft `xtask/src/layers.rs` (dependency layering rules and tests) | MIT OR Apache-2.0 |
|
||||
| apps/solvecraft/src/control_server.rs | CADCraft `apps/cadcraft/src/control_server.rs` (loopback JSON-lines server) | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/lib.rs (screenshot queue, synthetic input) | CADCraft `crates/ui-egui/src/lib.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/control.rs (method dispatch and screenshot saving) | CADCraft `crates/ui-egui/src/control.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/gpu.rs (paint-callback structure) | CADCraft `crates/ui-egui/src/gpu.rs` | MIT OR Apache-2.0 |
|
||||
| crates/mcp/src/server.rs, crates/mcp/src/backend.rs (JSON-RPC framing, MCP lifecycle, control-port bridge) | CADCraft `crates/mcp/src/{server,backend}.rs` | MIT OR Apache-2.0 |
|
||||
| crates/mcp/src/tools.rs (tool schema helpers and argument checking) | CADCraft `crates/mcp/src/tools.rs`, GridCraft `crates/mcp/src/tools.rs` | MIT OR Apache-2.0 |
|
||||
| crates/io/src/threemf.rs (bounded zip part reading and writing) | GridCraft `crates/xlsx/src/package.rs` | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solvecraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
|
||||
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
|
||||
|
||||
## Vendored third-party code
|
||||
|
||||
| Directory | Source | Licence | Changes |
|
||||
|---|---|---|---|
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) |
|
||||
@@ -1,92 +0,0 @@
|
||||
# SolveCraft — instructions for agents
|
||||
|
||||
SolveCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application in the style of
|
||||
Autodesk Fusion: sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
|
||||
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
|
||||
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
|
||||
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
|
||||
and the relevant `plan/architecture.md` section. Behaviour reference: `plan/fusion/`
|
||||
(`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their
|
||||
recipes and measurements).
|
||||
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
|
||||
is rejected for licence reasons).
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
|
||||
unless it lists the decision as the owner's.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust SolveCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
|
||||
returns an error ("not supported yet: …"); it never panics.
|
||||
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
|
||||
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
|
||||
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
|
||||
`solvecraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
|
||||
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
|
||||
panic restores the design and reports `EngineError::Internal`.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`).
|
||||
- Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Fusion may be *observed* black-box (its UI, its toolbar dump, parts built in it
|
||||
and their measurements, kept under `plan/fusion/`, never committed). Never copy Autodesk icons,
|
||||
artwork, help text, tooltips, templates or materials, and never commit files produced by
|
||||
Fusion. Command ids and command names are used as identifiers only (`xtask/data/fusion-catalog.tsv`
|
||||
holds ids and names, nothing else). File formats come from public specifications. **Never read,
|
||||
copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs), SolveSpace,
|
||||
CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. The sketch solver and the blend
|
||||
operations are our own.
|
||||
- **Assets — absolutely essential.** SolveCraft contains **no Autodesk iconography, images,
|
||||
fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any
|
||||
file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
|
||||
(`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;
|
||||
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
|
||||
clicks (`ui.start`) start interactive tools and dialogs, which then run commands.
|
||||
- **Layering** is enforced by `cargo xtask layers`: L0 `geom` → L1 `sketch`, `kernel`, `render`
|
||||
→ L2 `doc` → L3 `io` → L4 `engine` → L5 `ui-egui` → apps. Nothing below L5 depends on
|
||||
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.start(id)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
|
||||
assets, layers). Commit after every feature arc that builds.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solvecraft (private). After each commit that
|
||||
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
|
||||
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
|
||||
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
|
||||
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
|
||||
- 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":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`.
|
||||
- Headless: `solvecraft-cli run script.json --out part.step`, `solvecraft-cli eval design.solvecraft`,
|
||||
`solvecraft-cli snapshot script.json --out shot.png`, `solvecraft-cli commands`.
|
||||
- Fusion oracle: `cargo xtask oracle` replays `plan/fusion/oracle/*/recipe.json` and compares
|
||||
with Fusion's `measure.json` (writes `docs/oracle.md`).
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a
|
||||
milestone task lands. `cargo xtask parity` recomputes command parity in `docs/parity.md`;
|
||||
`cargo xtask oracle` recomputes `docs/oracle.md`.
|
||||
-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
-4713
File diff suppressed because it is too large
Load Diff
-85
@@ -1,85 +0,0 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
# Third-party code kept with local fixes (see vendor/README.md); not linted as ours.
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
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"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
solvecraft-geom = { path = "crates/geom" }
|
||||
solvecraft-sketch = { path = "crates/sketch" }
|
||||
solvecraft-kernel = { path = "crates/kernel" }
|
||||
solvecraft-render = { path = "crates/render" }
|
||||
solvecraft-doc = { path = "crates/doc" }
|
||||
solvecraft-io = { path = "crates/io" }
|
||||
solvecraft-engine = { path = "crates/engine" }
|
||||
solvecraft-ui-egui = { path = "crates/ui-egui" }
|
||||
solvecraft-mcp = { path = "crates/mcp" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
|
||||
# geometry kernel (Apache-2.0; see plan/adr/0001-geometry-kernel.md). Only crates/kernel uses it.
|
||||
truck-modeling = "0.6"
|
||||
truck-shapeops = { version = "0.4", default-features = false }
|
||||
truck-meshalgo = { version = "0.4", default-features = false, features = ["tessellation", "analyzers"] }
|
||||
truck-stepio = { version = "0.3", default-features = false }
|
||||
truck-topology = "0.6"
|
||||
|
||||
# packages (3MF)
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.38"
|
||||
|
||||
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
|
||||
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
|
||||
|
||||
# images
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false, features = ["persistence"] }
|
||||
# Instant/SystemTime that also work in the browser (std panics on wasm32-unknown-unknown).
|
||||
web-time = "1"
|
||||
egui-wgpu = "0.36"
|
||||
rfd = "0.17"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
# `!(x > tol)` is our idiom for "not a usable number" (NaN-safe).
|
||||
neg_cmp_op_on_partial_ord = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
# truck-shapeops with SolveCraft fixes (Apache-2.0, see vendor/truck-shapeops and ATTRIBUTION.md).
|
||||
[patch.crates-io]
|
||||
truck-shapeops = { path = "vendor/truck-shapeops" }
|
||||
truck-stepio = { path = "vendor/truck-stepio" }
|
||||
truck-meshalgo = { path = "vendor/truck-meshalgo" }
|
||||
-177
@@ -1,177 +0,0 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 SolveCraft contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,23 +0,0 @@
|
||||
SolveCraft
|
||||
Copyright (c) 2026 SolveCraft contributors.
|
||||
Licensed under the MIT License or the Apache License, Version 2.0, at your option
|
||||
(LICENSE-MIT, LICENSE-APACHE).
|
||||
|
||||
Third-party material
|
||||
|
||||
Software dependencies are listed in Cargo.lock. The geometry kernel builds on the truck crates
|
||||
(truck-modeling, truck-shapeops, truck-meshalgo, truck-stepio, truck-topology and their
|
||||
dependencies, https://github.com/ricosjp/truck), licensed under the Apache License 2.0 and used
|
||||
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolveCraft
|
||||
modifications (listed in vendor/README.md and marked in the source); its licence is in
|
||||
vendor/truck-shapeops/LICENSE. Copyright (c) RICOS Co. Ltd.
|
||||
|
||||
Some code is adapted from sibling projects in the storytold family (CADCraft, GridCraft), which are
|
||||
licensed MIT OR Apache-2.0; ATTRIBUTION.md lists the files.
|
||||
|
||||
None of the bundled assets come from third parties: every icon is drawn in code. The per-file
|
||||
list is in ATTRIBUTION.md.
|
||||
|
||||
SolveCraft is an independent project and is not affiliated with, sponsored by or endorsed by
|
||||
Autodesk, Inc. Autodesk and Fusion are trademarks of Autodesk, Inc.; they are used here only to
|
||||
describe compatibility goals.
|
||||
@@ -1,117 +0,0 @@
|
||||
# SolveCraft
|
||||
|
||||
SolveCraft is an open-source parametric 3D CAD application written in Rust, in the style of
|
||||
Autodesk Fusion: sketch on a plane, constrain and dimension the sketch, turn profiles into solids
|
||||
with extrude and revolve, round edges, combine bodies, and change a parameter to watch the whole
|
||||
timeline rebuild. It is a clean-room implementation (see [CLAUDE.md](CLAUDE.md)) and part of the
|
||||
storytold "craft" family.
|
||||
|
||||

|
||||
|
||||
**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).
|
||||
|
||||
## What works
|
||||
|
||||
- **Sketches** on the origin planes, offset planes or planar faces: lines, rectangles (2-point,
|
||||
3-point, centre), circles (centre, 2-point, 3-point), arcs (3-point, centre), polygons, slots
|
||||
and points; construction geometry.
|
||||
- **Constraints and dimensions** solved by our own solver (damped Gauss–Newton with
|
||||
degree-of-freedom analysis): coincident, horizontal/vertical, parallel, perpendicular, tangent,
|
||||
equal, concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
|
||||
radius, diameter and angle dimensions driven by parameters. Over-constraining edits are
|
||||
rejected; fully constrained geometry turns black.
|
||||
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
|
||||
- **Features** on a parametric timeline: extrude (one side, flipped, symmetric, two-sided, start
|
||||
offset; new body, join, cut, intersect), revolve, fillet and chamfer (straight convex edges),
|
||||
box, cylinder, sphere, torus, combine, move. The timeline rebuilds incrementally from the first
|
||||
changed feature; it can be rolled back, and features can be suppressed, renamed, edited and
|
||||
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)).
|
||||
- **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.
|
||||
- **Headless CLI** `solvecraft-cli`: run command scripts, measure (volume, area, centre of mass,
|
||||
face/edge/vertex counts), export, render PNG snapshots, replay Fusion oracle recipes.
|
||||
- **MCP server** `solvecraft-cli mcp`: AI agents model, measure and look at parts headless or
|
||||
in the running app (see below).
|
||||
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solvecraft-cli -- commands # the command registry
|
||||
cargo run --release -p solvecraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo run --release -p solvecraft-cli -- snapshot examples/bracket.json --out bracket.png
|
||||
cargo run --release -p solvecraft -- part.step # open a STEP file as a new design
|
||||
cargo run --release -p solvecraft-cli -- eval part.step # measure an imported STEP file
|
||||
cargo xtask ci # fmt, clippy, tests, assets, layers, wasm
|
||||
```
|
||||
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
|
||||
In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
||||
|
||||
## Scripts
|
||||
|
||||
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}}
|
||||
]}
|
||||
```
|
||||
|
||||
## AI agents (MCP)
|
||||
|
||||
`solvecraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on
|
||||
stdio. Headless it runs its own design session; with `--connect 127.0.0.1:PORT` it drives the app
|
||||
started with `solvecraft --control PORT`, so you can watch the agent model. Tools:
|
||||
`list_commands`, `execute`, `batch`, `inspect_design`, `measure`, `body_topology`,
|
||||
`set_parameter`, `screenshot` (returns the image), `export`, `undo`, `redo`, `new_design`, `open`,
|
||||
`save`. Claude Code: `claude mcp add solvecraft -- solvecraft-cli mcp`. Details, client
|
||||
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 |
|
||||
| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and CPU rasterizer |
|
||||
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
|
||||
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL import features |
|
||||
| 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
|
||||
|
||||
MIT OR Apache-2.0, at your option. SolveCraft is not affiliated with Autodesk; Fusion is a
|
||||
trademark of Autodesk, Inc.
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
# SolveCraft roadmap
|
||||
|
||||
Status as of **2026-10-09**. 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 |
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
||||
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
|
||||
|
||||
## Scope (owner decision, 2026-10-07)
|
||||
|
||||
In scope, in priority order:
|
||||
1. SOLID and SKETCH (now).
|
||||
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
|
||||
cycles reported with their path, rename follows references, deletes list their users):
|
||||
user parameters with units and expressions (+-*/, ^,
|
||||
parentheses, sin/cos/sqrt/min/max/floor/ceil/round, unit conversion), model parameters
|
||||
d1, d2… for every dimension and feature input, references between parameters with a
|
||||
dependency graph and cycle detection, re-evaluation on change, comments, favourites,
|
||||
CSV/JSON import and export.
|
||||
3. Components and assemblies with joints (M9).
|
||||
4. SHEET METAL: flange, contour flange, bend, unfold/refold, flat pattern with DXF export, sheet
|
||||
metal rules (thickness, K-factor, bend radius) (M10).
|
||||
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
|
||||
|
||||
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need, PCB / 3D
|
||||
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. The parity report's
|
||||
headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | Milestone | Status | Hours left |
|
||||
|---|---|---|---|
|
||||
| M0 | Vertical slice | done | — |
|
||||
| M1 | Sketch depth: done — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs | done | 4 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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
|
||||
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
|
||||
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
|
||||
with the edge between them too, the corner becomes a sphere octant),
|
||||
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
|
||||
convex planar body or of an extruded part; fillets inside patterns; closed chains of curved
|
||||
edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the
|
||||
blend a rational B-spline through exact arcs), closed or ending at planar faces square to the
|
||||
edges.
|
||||
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
|
||||
sections stacked end to end) are combined exactly in 2D; bodies touching along one whole face
|
||||
(a part and its mirror image) are stitched; coincident planar faces are pushed apart when
|
||||
their neighbours are planes or cylinders. Other coincident curved configurations can still
|
||||
fail. Fully internal voids are not supported. Spheres are built from six pole-free patches.
|
||||
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles
|
||||
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
|
||||
- STEP import (our own reader, crates/kernel/src/step_in; all 29 Fusion STEP files pass
|
||||
`cargo xtask step-corpus`) reads solids (B-rep with analytic and B-spline geometry), units,
|
||||
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.
|
||||
- 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:
|
||||
filleted boxes, bosses, domes, countersinks); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
|
||||
sections ruled.
|
||||
|
||||
## Kernel
|
||||
|
||||
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
|
||||
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
|
||||
@@ -1,18 +0,0 @@
|
||||
[package]
|
||||
name = "solvecraft-cli"
|
||||
description = "SolveCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "solvecraft-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
solvecraft-engine = { workspace = true }
|
||||
solvecraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -1,275 +0,0 @@
|
||||
//! SolveCraft headless command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
|
||||
//! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
|
||||
//! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! solvecraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solvecraft-cli commands the command registry as JSON
|
||||
//! solvecraft-cli recipe <recipe.json> translate an oracle recipe to a script
|
||||
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with measure.json
|
||||
//! solvecraft-cli step-corpus <case-dir>... import part.step and compare with measure.json
|
||||
//! solvecraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod oracle;
|
||||
mod recipe;
|
||||
mod seams;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_engine::render::{StandardView, render_png};
|
||||
|
||||
const USAGE: &str = "usage:
|
||||
solvecraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
|
||||
solvecraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
|
||||
solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
|
||||
solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solvecraft-cli exec <command> [json-params]
|
||||
solvecraft-cli commands
|
||||
solvecraft-cli recipe <recipe.json>
|
||||
solvecraft-cli oracle <case-dir>... [--json]
|
||||
solvecraft-cli step-corpus <case-dir>... [--json]
|
||||
solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let r = match args.first().map(String::as_str) {
|
||||
Some("run") => cmd_run(&args[1..]),
|
||||
Some("eval" | "inspect") => cmd_eval(&args[1..]),
|
||||
Some("snapshot") => cmd_snapshot(&args[1..]),
|
||||
Some("exec") => cmd_exec(&args[1..]),
|
||||
Some("commands") => {
|
||||
let mut s = Session::default();
|
||||
s.execute("engine.commands", &json!({})).map(|v| println!("{}", pretty(&v))).map_err(|e| e.to_string())
|
||||
}
|
||||
Some("recipe") => args.get(1).ok_or_else(|| USAGE.to_string()).and_then(|p| {
|
||||
let v = read_json(p)?;
|
||||
recipe::to_script(&v).map(|s| println!("{}", pretty(&s)))
|
||||
}),
|
||||
Some("oracle") => oracle::run(&args[1..]),
|
||||
Some("step-corpus") => oracle::step_corpus(&args[1..]),
|
||||
Some("mcp") => cmd_mcp(&args[1..]),
|
||||
// Hidden, for the kill-mid-save test: save a large design over and over.
|
||||
Some("save-stress") => cmd_save_stress(&args[1..]),
|
||||
// Hidden: time recomputes after parameter edits (docs/perf.md).
|
||||
Some("bench-edit") => cmd_bench_edit(&args[1..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.to_string()),
|
||||
};
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pretty(v: &Value) -> String {
|
||||
serde_json::to_string_pretty(v).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn read_json(path: &str) -> Result<Value, String> {
|
||||
let s = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
serde_json::from_str(&s).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// A session from a design file, a STEP/3MF/STL file, a command script, or an oracle recipe.
|
||||
pub fn load(path: &str) -> Result<Session, String> {
|
||||
let mut s = Session::default();
|
||||
if solvecraft_engine::io::is_step_path(path) || solvecraft_engine::io::is_mesh_path(path) {
|
||||
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
return Ok(s);
|
||||
}
|
||||
let v = read_json(path)?;
|
||||
if v.get("format").and_then(Value::as_str).is_some_and(|f| f.starts_with("solvecraft")) {
|
||||
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
} else if v.get("features").is_some() && v.get("commands").is_none() {
|
||||
let script = recipe::to_script(&v)?;
|
||||
s.run_script(&script).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
|
||||
}
|
||||
|
||||
fn flags<'a>(args: &'a [String], name: &str) -> Vec<&'a str> {
|
||||
args.windows(2).filter(|w| w[0] == name).map(|w| w[1].as_str()).collect()
|
||||
}
|
||||
|
||||
/// The first argument that is not an option or an option's value.
|
||||
fn positional(args: &[String]) -> Option<&String> {
|
||||
let mut i = 0;
|
||||
while let Some(a) = args.get(i) {
|
||||
if a.starts_with("--") {
|
||||
i += if matches!(a.as_str(), "--quiet" | "--results") { 1 } else { 2 };
|
||||
continue;
|
||||
}
|
||||
return Some(a);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
let base = flag(args, "--in");
|
||||
let path = positional(args);
|
||||
if path.is_none() && base.is_none() {
|
||||
return Err(USAGE.to_string());
|
||||
}
|
||||
// `--in` starts from a design or STEP file; the positional script then runs on top of it.
|
||||
let mut s = match base {
|
||||
Some(b) => load(b)?,
|
||||
None => Session::default(),
|
||||
};
|
||||
if args.iter().any(|a| a == "--results") {
|
||||
// Print every command's result (scripts only).
|
||||
let v = read_json(path.ok_or("--results needs a script")?)?;
|
||||
let r = s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
println!("{}", pretty(&Value::Array(r)));
|
||||
return Ok(());
|
||||
}
|
||||
match (base, path) {
|
||||
(Some(_), Some(p)) => {
|
||||
let v = read_json(p)?;
|
||||
s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
}
|
||||
(None, Some(p)) => s = load(p)?,
|
||||
_ => {}
|
||||
}
|
||||
for out in flags(args, "--out") {
|
||||
s.execute("file.export", &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())?;
|
||||
}
|
||||
if !args.iter().any(|a| a == "--quiet") {
|
||||
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
|
||||
println!("{}", pretty(&m));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
|
||||
let out = json!({
|
||||
"body_count": m["body_count"],
|
||||
"bodies": m["bodies"],
|
||||
"total": m["total"],
|
||||
"timeline": d["timeline"],
|
||||
"params": d["params"],
|
||||
"sketches": d["sketches"],
|
||||
});
|
||||
println!("{}", pretty(&out));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
let path = args.first().ok_or_else(|| USAGE.to_string())?;
|
||||
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
|
||||
let w = flag(args, "--width").and_then(|x| x.parse().ok()).unwrap_or(1280usize);
|
||||
let h = flag(args, "--height").and_then(|x| x.parse().ok()).unwrap_or(800usize);
|
||||
let s = load(path)?;
|
||||
let mut cam = solvecraft_engine::view::home_camera(&s);
|
||||
if let Some(v) = flag(args, "--view") {
|
||||
cam.set_view(StandardView::parse(v).ok_or_else(|| format!("unknown view `{v}`"))?);
|
||||
}
|
||||
let scene = solvecraft_engine::view::scene(&s, &cam);
|
||||
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
|
||||
std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?;
|
||||
println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()})));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
let id = args.first().ok_or_else(|| USAGE.to_string())?;
|
||||
let p: Value = match args.get(1) {
|
||||
Some(t) => serde_json::from_str(t).map_err(|e| format!("params: {e}"))?,
|
||||
None => json!({}),
|
||||
};
|
||||
let mut s = Session::default();
|
||||
let v = s.execute(id, &p).map_err(|e| e.to_string())?;
|
||||
println!("{}", pretty(&v));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// MCP server on stdio: headless (optionally starting from a design or script) or bridged to
|
||||
/// a running app's control port. stdout carries only protocol messages.
|
||||
fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
let backend: Box<dyn solvecraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
|
||||
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
|
||||
(Some(addr), None) => Box::new(solvecraft_mcp::Remote::connect(addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?),
|
||||
(None, Some(path)) => Box::new(solvecraft_mcp::Headless::new(load(path)?)),
|
||||
(None, None) => Box::new(solvecraft_mcp::Headless::default()),
|
||||
};
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
solvecraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `save-stress <path> <rounds>`: save a design of a few hundred kilobytes `rounds` times, its
|
||||
/// parameter `save_round` set to the round number each time.
|
||||
fn cmd_save_stress(args: &[String]) -> Result<(), String> {
|
||||
let (Some(path), Some(rounds)) = (args.first(), args.get(1).and_then(|n| n.parse::<u64>().ok())) else {
|
||||
return Err("usage: solvecraft-cli save-stress <path> <rounds>".into());
|
||||
};
|
||||
let mut s = Session::default();
|
||||
let filler = (0..3000).map(|i| solvecraft_engine::doc::Param {
|
||||
name: format!("p{i}"),
|
||||
expr: format!("{i} mm + 0.5 mm"),
|
||||
unit: "mm".into(),
|
||||
comment: "filler to make the file large".into(),
|
||||
model: false,
|
||||
});
|
||||
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())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `bench-edit <script.json|recipe.json> <param> <expr>`: build the design, then time a
|
||||
/// parameter edit, setting it back, undo and redo (ms and features recomputed each).
|
||||
fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
|
||||
let (Some(path), Some(param), Some(expr)) = (args.first(), args.get(1), args.get(2)) else {
|
||||
return Err("usage: solvecraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
|
||||
};
|
||||
let v = read_json(path)?;
|
||||
let script = if v.get("features").is_some() { recipe::to_script(&v)? } else { v };
|
||||
let mut s = Session::default();
|
||||
let t = std::time::Instant::now();
|
||||
s.run_script(&script).map_err(|e| e.to_string())?;
|
||||
let build = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let old = s.doc.all_param_exprs().into_iter().find(|(n, _, _)| n == param).map(|(_, e, _)| e).ok_or(format!("no parameter `{param}`"))?;
|
||||
let mut out = vec![json!({"step": "build", "ms": build, "features": s.doc.features.len()})];
|
||||
let mut time = |s: &mut Session, step: &str, id: &str, p: Value| -> Result<(), String> {
|
||||
let t = std::time::Instant::now();
|
||||
let r = s.execute(id, &p).map_err(|e| format!("{step}: {e}"))?;
|
||||
let n = r.get("recomputed").cloned().unwrap_or_else(|| json!(s.model.last_recomputed));
|
||||
out.push(json!({"step": step, "ms": t.elapsed().as_secs_f64() * 1000.0, "recomputed": n}));
|
||||
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!({}))?;
|
||||
println!("{}", pretty(&json!(out)));
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
//! Replay oracle recipes and compare with Fusion's measurements; import oracle STEP files and
|
||||
//! compare them with the same measurements.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{read_json, recipe, seams};
|
||||
|
||||
/// Relative tolerance for volume and area (our curved-face tessellation is within ~2e-4).
|
||||
pub const REL_TOL: f64 = 1e-3;
|
||||
|
||||
/// Cases compared loosely (relative tolerance on volume and area, topology not compared), and why.
|
||||
const APPROXIMATE: [(&str, f64, &str); 1] = [(
|
||||
"49-helical-sweep",
|
||||
5e-3,
|
||||
"Fusion sweeps along a spline fitted through helix points; SolveCraft builds the true helix (a coil), so the size is compared within 0.5% and the topology not at all",
|
||||
)];
|
||||
|
||||
fn check_case(dir: &str) -> Value {
|
||||
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let rp = format!("{dir}/recipe.json");
|
||||
let mp = format!("{dir}/measure.json");
|
||||
let (recipe, want) = match (read_json(&rp), read_json(&mp)) {
|
||||
(Ok(r), Ok(m)) => (r, m),
|
||||
(Err(e), _) | (_, Err(e)) => return json!({"case": name, "pass": false, "error": e}),
|
||||
};
|
||||
let script = match recipe::to_script(&recipe) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return json!({"case": name, "pass": false, "skipped": true, "error": e}),
|
||||
};
|
||||
let mut s = solvecraft_engine::Session::default();
|
||||
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!({})) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
|
||||
};
|
||||
let mut checks = Vec::new();
|
||||
let mut pass = true;
|
||||
let mut num = |what: &str, g: Option<f64>, w: Option<f64>, rel: f64| {
|
||||
let ok = match (g, w) {
|
||||
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
|
||||
_ => false,
|
||||
};
|
||||
pass &= ok;
|
||||
checks.push(json!({"check": what, "got": g, "want": w, "ok": ok}));
|
||||
};
|
||||
// Cases where Fusion's part is itself an approximation: compare size loosely, not topology.
|
||||
let approx = APPROXIMATE.iter().find(|(c, _, _)| *c == name);
|
||||
let tol = approx.map(|(_, t, _)| *t).unwrap_or(REL_TOL);
|
||||
let within = |k: &str, rel: f64| match (got["total"][k].as_f64(), want["total"][k].as_f64()) {
|
||||
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
|
||||
_ => false,
|
||||
};
|
||||
let size_ok =
|
||||
got["body_count"] == want["body_count"] && within("volume_mm3", tol) && (FUSION_AREA_OFF.contains(&name.as_str()) || within("area_mm2", tol));
|
||||
num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0);
|
||||
num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), tol);
|
||||
let area_off = FUSION_AREA_OFF.contains(&name.as_str());
|
||||
if !area_off {
|
||||
num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), tol);
|
||||
}
|
||||
let mut seam_note = None;
|
||||
if approx.is_none() {
|
||||
let exact = ["faces", "edges", "vertices"].iter().all(|k| got["total"][k].as_f64().is_some() && got["total"][k] == want["total"][k]);
|
||||
let fusion_seams = want["bodies"]
|
||||
.as_array()
|
||||
.and_then(|bs| bs.iter().map(seams::normalised).try_fold((0, 0, 0), |a, n| n.map(|n| (a.0 + n.0, a.1 + n.1, a.2 + n.2))));
|
||||
match fusion_seams {
|
||||
// Size matches and only the topology differs: compare with Fusion's seam splits merged.
|
||||
Some((f, e, v)) if size_ok && !exact => {
|
||||
let raw: Vec<String> = ["faces", "edges", "vertices"]
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let ours: i64 = got["bodies"].as_array().into_iter().flatten().filter_map(|b| b["kernel"][k].as_i64()).sum();
|
||||
format!("{k} {ours}/{}", want["total"][k])
|
||||
})
|
||||
.collect();
|
||||
seam_note = Some(format!(
|
||||
"topology compared seam-normalised: Fusion's {}/{}/{} faces/edges/vertices become {f}/{e}/{v} with closed-surface patches counted once and seam edges and vertices dropped (raw SolveCraft/Fusion: {})",
|
||||
want["total"]["faces"],
|
||||
want["total"]["edges"],
|
||||
want["total"]["vertices"],
|
||||
raw.join(", ")
|
||||
));
|
||||
num("faces (seam-normalised)", got["total"]["faces"].as_f64(), Some(f as f64), 0.0);
|
||||
num("edges (seam-normalised)", got["total"]["edges"].as_f64(), Some(e as f64), 0.0);
|
||||
num("vertices (seam-normalised)", got["total"]["vertices"].as_f64(), Some(v as f64), 0.0);
|
||||
}
|
||||
_ => {
|
||||
num("faces", got["total"]["faces"].as_f64(), want["total"]["faces"].as_f64(), 0.0);
|
||||
num("edges", got["total"]["edges"].as_f64(), want["total"]["edges"].as_f64(), 0.0);
|
||||
num("vertices", got["total"]["vertices"].as_f64(), want["total"]["vertices"].as_f64(), 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Our STEP export of the rebuilt part: well formed, and read back with no warnings and the
|
||||
// same volume.
|
||||
let (ok, note) = step_export_check(&s, got["total"]["volume_mm3"].as_f64().unwrap_or(0.0));
|
||||
pass &= ok;
|
||||
checks.push(json!({"check": "step_export", "got": note, "want": "valid, re-imports cleanly", "ok": ok}));
|
||||
let mut out = json!({"case": name, "pass": pass, "checks": checks});
|
||||
if let Some((_, _, why)) = approx {
|
||||
out["note"] = json!(why);
|
||||
}
|
||||
if let Some(n) = seam_note {
|
||||
out["note"] = json!(n);
|
||||
}
|
||||
if area_off {
|
||||
out["note"] = json!(
|
||||
"area not compared: Fusion reports this tight sweep's horn torus face at three times its area (π²·R·r quarter-turn value; 740 mm² against 246.7 by Pappus); volume and topology are compared"
|
||||
);
|
||||
}
|
||||
let effective: Vec<String> = recipe["features"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|f| f.get("fusion_effective_extent").is_some())
|
||||
.map(|f| f["name"].as_str().unwrap_or("?").to_string())
|
||||
.collect();
|
||||
if !effective.is_empty() {
|
||||
out["note"] = json!(format!(
|
||||
"known Fusion difference: {} replayed with the extent Fusion actually used (its through-all is sized as 1.1 × the farthest vertex, so curved tops stop it short; SolveCraft's through-all goes truly through)",
|
||||
effective.join(", ")
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Export the session's bodies as STEP, validate the file and import it again.
|
||||
fn step_export_check(s: &solvecraft_engine::Session, volume: f64) -> (bool, String) {
|
||||
let st = s.model.state();
|
||||
let bytes = match solvecraft_engine::io::export(&st, &[], solvecraft_engine::io::Format::Step, "oracle") {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (false, format!("export: {e}")),
|
||||
};
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
if let Err(e) = solvecraft_engine::kernel::step_validate(&text) {
|
||||
return (false, format!("invalid: {e}"));
|
||||
}
|
||||
match solvecraft_engine::kernel::step_orientation_errors(&text) {
|
||||
Ok(errs) if !errs.is_empty() => return (false, format!("faces inside out: {}", errs.join("; "))),
|
||||
Err(e) => return (false, format!("orientation check: {e}")),
|
||||
Ok(_) => {}
|
||||
}
|
||||
match solvecraft_engine::kernel::step_import(&text) {
|
||||
Ok(imp) if !imp.warnings.is_empty() => (false, format!("warnings: {}", imp.warnings.join("; "))),
|
||||
Ok(imp) if imp.bodies.len() != st.bodies.len() => (false, format!("{} bodies back of {}", imp.bodies.len(), st.bodies.len())),
|
||||
Ok(imp) => {
|
||||
let m: Vec<_> = imp.bodies.iter().filter_map(|b| solvecraft_engine::kernel::measure(&b.body).ok()).collect();
|
||||
let m0: Vec<_> = st.bodies.iter().filter_map(|b| solvecraft_engine::kernel::measure(&b.body).ok()).collect();
|
||||
let v: f64 = m.iter().map(|x| x.volume).sum();
|
||||
let (a, a0): (f64, f64) = (m.iter().map(|x| x.area).sum(), m0.iter().map(|x| x.area).sum());
|
||||
let faces: usize = imp.bodies.iter().map(|b| b.file_faces).sum();
|
||||
let faces0: usize = m0.iter().map(|x| x.merged.faces).sum();
|
||||
let close = |x: f64, y: f64| (x - y).abs() <= REL_TOL * y.abs().max(1.0);
|
||||
let faces_raw: usize = st.bodies.iter().map(|b| b.body.face_count()).sum();
|
||||
// (Fewer is fine: pieces of one plane, cylinder, cone, sphere or torus are written as one face.)
|
||||
let ok = close(v, volume) && close(a, a0) && faces <= faces_raw;
|
||||
(ok, format!("re-imported volume {v:.3} (was {volume:.3}), area {a:.3} (was {a0:.3}), faces {faces} (was {faces0})"))
|
||||
}
|
||||
Err(e) => (false, format!("import: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cases whose Fusion area is not the geometric one: Fusion reports the horn torus face of a
|
||||
/// tight sweep (tube touching its axis) at three times π²·R·r, its quarter-turn area; the
|
||||
/// volumes agree. Their area is not compared.
|
||||
const FUSION_AREA_OFF: &[&str] = &["50-sweep-tight-bend"];
|
||||
|
||||
/// Import a case's `part.step` (as File → Open does) and compare with Fusion's measurements:
|
||||
/// body count, volume and area (relative 1e-3) and the faces read from the file.
|
||||
fn check_step_case(dir: &str) -> Value {
|
||||
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let sp = format!("{dir}/part.step");
|
||||
let want = match read_json(&format!("{dir}/measure.json")) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e}),
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = solvecraft_engine::Session::default();
|
||||
let opened = match s.execute("doc.open", &json!({"path": sp})) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
|
||||
};
|
||||
let got = match s.execute("inspect.measure", &json!({})) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
|
||||
};
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
// Faces read from the file (the kernel may split a face that wraps around a closed surface).
|
||||
let file_faces = std::fs::read(&sp)
|
||||
.ok()
|
||||
.and_then(|b| solvecraft_engine::kernel::step_import_shared(&String::from_utf8_lossy(&b)).ok())
|
||||
.map(|imp| imp.bodies.iter().map(|b| b.file_faces).sum::<usize>() as f64);
|
||||
let mut checks = Vec::new();
|
||||
let mut pass = true;
|
||||
let mut num = |what: &str, g: Option<f64>, w: Option<f64>, rel: f64| {
|
||||
let ok = match (g, w) {
|
||||
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
|
||||
_ => false,
|
||||
};
|
||||
pass &= ok;
|
||||
checks.push(json!({"check": what, "got": g, "want": w, "ok": ok}));
|
||||
};
|
||||
num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0);
|
||||
num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), REL_TOL);
|
||||
if !FUSION_AREA_OFF.contains(&name.as_str()) {
|
||||
num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), REL_TOL);
|
||||
}
|
||||
num("faces", file_faces, want["total"]["faces"].as_f64(), 0.0);
|
||||
json!({
|
||||
"case": name, "pass": pass, "checks": checks, "ms": ms,
|
||||
"bodies": opened["bodies"], "warnings": opened["warnings"],
|
||||
"info": {"kernel_faces_merged": got["total"]["faces"], "edges": got["total"]["edges"], "want_edges": want["total"]["edges"], "vertices": got["total"]["vertices"], "want_vertices": want["total"]["vertices"]},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn step_corpus(args: &[String]) -> Result<(), String> {
|
||||
let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
|
||||
if dirs.is_empty() {
|
||||
return Err("step-corpus: give one or more case directories (each with part.step and measure.json)".into());
|
||||
}
|
||||
let results: Vec<Value> = dirs.iter().map(|d| check_step_case(d)).collect();
|
||||
report(args, &results);
|
||||
let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false)).count();
|
||||
if failed > 0 { Err(format!("{failed} STEP case(s) failed")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn report(args: &[String], results: &[Value]) {
|
||||
if args.iter().any(|a| a == "--json") {
|
||||
println!("{}", serde_json::to_string_pretty(&Value::Array(results.to_vec())).unwrap_or_default());
|
||||
return;
|
||||
}
|
||||
for r in results {
|
||||
let status = if r["pass"].as_bool().unwrap_or(false) {
|
||||
"PASS"
|
||||
} else if r["skipped"].as_bool().unwrap_or(false) {
|
||||
"SKIP"
|
||||
} else {
|
||||
"FAIL"
|
||||
};
|
||||
let detail: Vec<String> = r["checks"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|c| !c["ok"].as_bool().unwrap_or(false))
|
||||
.map(|c| format!("{} got {} want {}", c["check"].as_str().unwrap_or(""), c["got"], c["want"]))
|
||||
.collect();
|
||||
let err = r["error"].as_str().map(|e| format!(" ({e})")).unwrap_or_default();
|
||||
println!(
|
||||
"{status} {}{err}{}",
|
||||
r["case"].as_str().unwrap_or(""),
|
||||
if detail.is_empty() { String::new() } else { format!(": {}", detail.join("; ")) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
|
||||
if dirs.is_empty() {
|
||||
return Err("oracle: give one or more case directories".into());
|
||||
}
|
||||
let results: Vec<Value> = dirs.iter().map(|d| check_case(d)).collect();
|
||||
report(args, &results);
|
||||
let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false) && !r["skipped"].as_bool().unwrap_or(false)).count();
|
||||
if failed > 0 { Err(format!("{failed} oracle case(s) failed")) } else { Ok(()) }
|
||||
}
|
||||
@@ -1,568 +0,0 @@
|
||||
//! Oracle recipes (`plan/fusion/oracle/*/recipe.json`) → SolveCraft command scripts.
|
||||
//!
|
||||
//! A recipe lists features with sketch entities in sketch-local coordinates; point references
|
||||
//! (`l1.end`) share points. Each feature becomes one or more commands.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn plane_param(pl: &Value) -> Value {
|
||||
let r = pl.get("ref").and_then(Value::as_str).unwrap_or("");
|
||||
let origin_zero = pl.get("origin").and_then(Value::as_array).is_none_or(|o| o.iter().all(|x| x.as_f64().unwrap_or(0.0).abs() < 1e-12));
|
||||
let std = match r.to_ascii_uppercase().as_str() {
|
||||
"XY" => Some(([1.0, 0.0, 0.0], [0.0, 1.0, 0.0])),
|
||||
"XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, 1.0])),
|
||||
"YZ" => Some(([0.0, 1.0, 0.0], [0.0, 0.0, 1.0])),
|
||||
_ => None,
|
||||
};
|
||||
let axes_match = |k: &str, want: [f64; 3]| {
|
||||
pl.get(k).and_then(Value::as_array).is_some_and(|a| a.iter().zip(want).all(|(x, w)| (x.as_f64().unwrap_or(f64::NAN) - w).abs() < 1e-9))
|
||||
};
|
||||
match std {
|
||||
Some((x, y)) if origin_zero && axes_match("x_dir", x) && axes_match("y_dir", y) => json!(r.to_ascii_uppercase()),
|
||||
_ => json!({"origin": pl.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "x_dir": pl.get("x_dir"), "y_dir": pl.get("y_dir")}),
|
||||
}
|
||||
}
|
||||
|
||||
/// A point argument: the shared reference if any, else the coordinates.
|
||||
fn parg(p: Option<&Value>) -> Value {
|
||||
match p {
|
||||
Some(v) => match (v.get("ref"), v.get("at")) {
|
||||
(Some(Value::String(r)), _) => json!(r),
|
||||
(_, Some(at)) => at.clone(),
|
||||
_ => v.clone(),
|
||||
},
|
||||
None => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn op(f: &Value) -> Value {
|
||||
f.get("operation").cloned().unwrap_or(json!("new"))
|
||||
}
|
||||
|
||||
fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
|
||||
let mut p = json!({"plane": plane_param(f.get("plane").unwrap_or(&Value::Null))});
|
||||
if let Some(n) = f.get("name") {
|
||||
p["name"] = n.clone();
|
||||
}
|
||||
out.push(json!({"command": "sketch.create", "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("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}}))
|
||||
} 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}}))
|
||||
}
|
||||
}
|
||||
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}})),
|
||||
// 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")),
|
||||
None => return Err("recipe: sketch entity without type".into()),
|
||||
}
|
||||
}
|
||||
for c in f.get("constraints").and_then(Value::as_array).into_iter().flatten() {
|
||||
out.push(constraint_cmd(c)?);
|
||||
}
|
||||
for d in f.get("dimensions").and_then(Value::as_array).into_iter().flatten() {
|
||||
let mut p = json!({"entities": d.get("refs").or(d.get("entities")), "type": d.get("type"), "value": d.get("value")});
|
||||
for k in ["orientation", "text_at"] {
|
||||
if let Some(v) = d.get(k) {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
}
|
||||
out.push(json!({"command": "sketch.dimension", "params": p}));
|
||||
}
|
||||
out.push(json!({"command": "sketch.finish", "params": {}}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extent parameters for the Extrude command.
|
||||
fn extent(f: &Value, p: &mut Value) {
|
||||
// Where Fusion's own result differs from the geometric meaning (its through-all is sized
|
||||
// from vertices), the recipe records the extent Fusion actually used: replay that.
|
||||
if let Some(fx) = f.get("fusion_effective_extent").filter(|x| x.get("type").and_then(Value::as_str) == Some("distance")) {
|
||||
let dir = fx.get("direction").and_then(Value::as_str).unwrap_or("positive");
|
||||
p["distance"] = fx.get("distance").cloned().unwrap_or(Value::Null);
|
||||
p["direction"] = json!(if dir.starts_with("negative") { "negative" } else { "positive" });
|
||||
return;
|
||||
}
|
||||
let e = f.get("extent").cloned().unwrap_or(Value::Null);
|
||||
let ty = e.get("type").and_then(Value::as_str).unwrap_or("distance");
|
||||
let dist = e.get("distance").cloned().or_else(|| f.get("distance").cloned()).unwrap_or(Value::Null);
|
||||
match ty {
|
||||
"symmetric" => {
|
||||
let full = e.get("is_full_length").and_then(Value::as_bool).unwrap_or(false);
|
||||
p["direction"] = json!("symmetric");
|
||||
p["distance"] = if full { json!(format!("({}) / 2", expr_text(&dist))) } else { dist };
|
||||
}
|
||||
"two_sides" => {
|
||||
p["distance"] = e.get("side1").cloned().unwrap_or(Value::Null);
|
||||
p["distance2"] = e.get("side2_opposite").or(e.get("side2")).cloned().unwrap_or(Value::Null);
|
||||
}
|
||||
"through_all" => {
|
||||
p["through_all"] = json!(true);
|
||||
p["direction"] = e.get("direction").cloned().unwrap_or(json!("positive"));
|
||||
}
|
||||
_ => {
|
||||
p["distance"] = dist;
|
||||
p["direction"] = e.get("direction").cloned().unwrap_or(json!("positive"));
|
||||
}
|
||||
}
|
||||
if let Some(t) = e.get("taper_deg") {
|
||||
p["taper"] = t.clone();
|
||||
}
|
||||
}
|
||||
|
||||
fn expr_text(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn refs(c: &Value) -> Vec<Value> {
|
||||
c.get("refs").and_then(Value::as_array).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A recipe constraint `{type, refs}` → a constraint command.
|
||||
fn constraint_cmd(c: &Value) -> Result<Value, String> {
|
||||
let ty = c.get("type").and_then(Value::as_str).unwrap_or("").to_ascii_lowercase();
|
||||
let r = refs(c);
|
||||
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"})),
|
||||
"coincident" | "point_on_curve" => {
|
||||
// The point goes first.
|
||||
if is_point(&at(0)) {
|
||||
("sketch.constraint.coincident", json!({"a": at(0), "b": at(1)}))
|
||||
} else {
|
||||
("sketch.constraint.coincident", json!({"a": at(1), "b": at(0)}))
|
||||
}
|
||||
}
|
||||
"midpoint" => {
|
||||
if is_point(&at(0)) {
|
||||
("sketch.constraint.midpoint", json!({"point": at(0), "line": at(1)}))
|
||||
} else {
|
||||
("sketch.constraint.midpoint", 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)})),
|
||||
other => return Err(format!("recipe: constraint `{other}` is not supported yet")),
|
||||
};
|
||||
Ok(json!({"command": cmd, "params": p}))
|
||||
}
|
||||
|
||||
/// The profiles a feature uses (`profiles` list or a single `profile`).
|
||||
fn profiles_of(f: &Value) -> Value {
|
||||
match (f.get("profiles"), f.get("profile")) {
|
||||
(Some(p), _) => p.clone(),
|
||||
(None, Some(p)) => json!([p]),
|
||||
_ => json!("all"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sketch a feature uses (name), from its profiles.
|
||||
fn profile_sketch(f: &Value) -> Value {
|
||||
profiles_of(f)
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|p| p.get("sketch"))
|
||||
.cloned()
|
||||
.or_else(|| f.get("sketch").cloned())
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn sketch_entity<'a>(features: &'a [Value], sketch: &Value, id: &Value) -> Option<&'a Value> {
|
||||
features
|
||||
.iter()
|
||||
.find(|x| x.get("op").and_then(Value::as_str) == Some("sketch") && x.get("name") == Some(sketch))?
|
||||
.get("entities")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|e| e.get("id") == Some(id))
|
||||
}
|
||||
|
||||
/// A sweep of a circle along a fitted 3D spline that is a helix about a Z-parallel axis:
|
||||
/// (base, diameter, pitch, turns, start angle, clockwise, section size).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn helix_path(features: &[Value], f: &Value) -> Option<([f64; 3], f64, f64, f64, f64, bool, f64)> {
|
||||
let path = f.get("path")?;
|
||||
let curve = sketch_entity(features, path.get("sketch")?, path.get("curves")?.as_array()?.first()?)?;
|
||||
if curve.get("type")?.as_str()? != "fitted_spline" {
|
||||
return None;
|
||||
}
|
||||
let pts: Vec<[f64; 3]> = curve
|
||||
.get("fit_points")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let a = p.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
|
||||
})
|
||||
.collect();
|
||||
if pts.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let n = pts.len() as f64;
|
||||
let (cx, cy) = circle_fit(&pts)?;
|
||||
let r: Vec<f64> = pts.iter().map(|p| (p[0] - cx).hypot(p[1] - cy)).collect();
|
||||
let rm = r.iter().sum::<f64>() / n;
|
||||
if rm <= 0.0 || r.iter().any(|x| (x - rm).abs() > 1e-3 * rm) {
|
||||
return None;
|
||||
}
|
||||
// Unwrapped angle and height: both linear along a helix.
|
||||
let mut total = 0.0;
|
||||
let mut prev = (pts[0][1] - cy).atan2(pts[0][0] - cx);
|
||||
let start = prev;
|
||||
for p in pts.iter().skip(1) {
|
||||
let a = (p[1] - cy).atan2(p[0] - cx);
|
||||
let mut d = a - prev;
|
||||
while d > std::f64::consts::PI {
|
||||
d -= std::f64::consts::TAU;
|
||||
}
|
||||
while d < -std::f64::consts::PI {
|
||||
d += std::f64::consts::TAU;
|
||||
}
|
||||
total += d;
|
||||
prev = a;
|
||||
}
|
||||
let turns = total.abs() / std::f64::consts::TAU;
|
||||
let rise = pts.last()?[2] - pts[0][2];
|
||||
if turns < 1e-6 || rise.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
// The section: a circle in the profile sketch.
|
||||
let prof = f.get("profile").or_else(|| f.get("profiles").and_then(|x| x.get(0)))?;
|
||||
let loop0 = prof.get("loops")?.get(0)?.get("curves")?.get(0)?;
|
||||
let circle = sketch_entity(features, prof.get("sketch")?, loop0)?;
|
||||
let size = 2.0 * circle.get("radius")?.as_f64()?;
|
||||
Some(([cx, cy, pts[0][2]], 2.0 * rm, rise.abs() / turns, turns, start, (total < 0.0) != (rise < 0.0), size))
|
||||
}
|
||||
|
||||
/// Least-squares circle through the points' (x, y): x² + y² + D x + E y + F = 0 (Kåsa).
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
fn circle_fit(pts: &[[f64; 3]]) -> Option<(f64, f64)> {
|
||||
let mut m = [[0.0f64; 4]; 3];
|
||||
for p in pts {
|
||||
let row = [p[0], p[1], 1.0];
|
||||
let rhs = -(p[0] * p[0] + p[1] * p[1]);
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
m[i][j] += row[i] * row[j];
|
||||
}
|
||||
m[i][3] += row[i] * rhs;
|
||||
}
|
||||
}
|
||||
// Gauss-Jordan on the 3x4 normal equations.
|
||||
for c in 0..3 {
|
||||
let piv = (c..3).max_by(|a, b| m[*a][c].abs().total_cmp(&m[*b][c].abs()))?;
|
||||
m.swap(c, piv);
|
||||
if m[c][c].abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
for r in 0..3 {
|
||||
if r != c {
|
||||
let f = m[r][c] / m[c][c];
|
||||
for k in c..4 {
|
||||
m[r][k] -= f * m[c][k];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (d, e) = (m[0][3] / m[0][0], m[1][3] / m[1][1]);
|
||||
Some((-d / 2.0, -e / 2.0))
|
||||
}
|
||||
|
||||
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"))}}));
|
||||
}
|
||||
let features = recipe.get("features").and_then(Value::as_array).ok_or("recipe: missing `features`")?;
|
||||
for f in features {
|
||||
let name = f.get("name").cloned().unwrap_or(Value::Null);
|
||||
let bodies = f.get("result_bodies").cloned().unwrap_or(json!([]));
|
||||
match f.get("op").and_then(Value::as_str) {
|
||||
Some("sketch") => sketch_cmds(f, &mut out)?,
|
||||
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}));
|
||||
}
|
||||
Some("combine") => out.push(json!({"command": "solid.combine", "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") => {
|
||||
let axis = match f.get("axis") {
|
||||
Some(a) if a.get("dir").is_some() => json!({"origin": a.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "dir": a.get("dir")}),
|
||||
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": {
|
||||
"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}}));
|
||||
}
|
||||
Some("rectangular_pattern") => {
|
||||
let d1 = f.get("direction1").cloned().unwrap_or(Value::Null);
|
||||
let d2 = f.get("direction2").cloned().unwrap_or(Value::Null);
|
||||
let mut p = json!({"features": feature_refs(f.get("features")), "dir1": d1.get("axis"), "count1": d1.get("count"), "spacing1": d1.get("spacing"), "name": name});
|
||||
if !d2.is_null() {
|
||||
p["dir2"] = d2.get("axis").cloned().unwrap_or(Value::Null);
|
||||
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}));
|
||||
}
|
||||
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}}));
|
||||
}
|
||||
Some("sm_flange") => match f.get("type").and_then(Value::as_str) {
|
||||
Some("edge") => {
|
||||
let mut p = json!({"type": "edge", "edges": edge_points(f), "height": f.get("height"), "name": name});
|
||||
if let Some(a) = f.get("angle") {
|
||||
p["angle"] = a.clone();
|
||||
}
|
||||
if f.get("flip").and_then(Value::as_bool) == Some(true) {
|
||||
p["flip"] = json!(true);
|
||||
}
|
||||
out.push(json!({"command": "sheet.flange", "params": p}));
|
||||
}
|
||||
Some("contour") => out.push(json!({"command": "sheet.flange", "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": {
|
||||
"type": "base", "sketch": profile_sketch(f), "profiles": profiles_of(f), "name": name, "body_names": bodies}})),
|
||||
},
|
||||
Some("sheet_metal_rule_edit") => {
|
||||
let rule = f.get("rule").cloned().unwrap_or(json!("Steel (mm)"));
|
||||
let mut p = json!({"name": rule});
|
||||
if let Some(set) = f.get("set") {
|
||||
for (from, to) in [("thickness", "thickness"), ("kFactor", "k_factor"), ("bendRadius", "bend_radius")] {
|
||||
if let Some(v) = set.get(from) {
|
||||
p[to] = v.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
// "thickness 3 mm (final state…)": the last step of a sequence.
|
||||
if let Some(last) = f.get("sequence").and_then(Value::as_array).and_then(|a| a.last()).and_then(Value::as_str) {
|
||||
let w: Vec<&str> = last.split_whitespace().collect();
|
||||
if let (Some(&"thickness"), Some(v), Some(u)) = (w.first(), w.get(1), w.get(2)) {
|
||||
p["thickness"] = json!(format!("{v} {u}"));
|
||||
}
|
||||
}
|
||||
out.push(json!({"command": "sheet.manage_rules", "params": p}));
|
||||
}
|
||||
Some("sm_hem") => {
|
||||
let e = f.get("edge").and_then(|e| e.get("point")).cloned().unwrap_or(Value::Null);
|
||||
let mut p = json!({"edges": [e], "length": f.get("length"), "name": name});
|
||||
if f.get("flipped").and_then(Value::as_bool) == Some(true) {
|
||||
p["flip"] = json!(true);
|
||||
}
|
||||
out.push(json!({"command": "sheet.hem", "params": p}));
|
||||
}
|
||||
Some("sm_unfold") => out.push(json!({"command": "sheet.unfold", "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);
|
||||
let mut p = json!({"path_sketch": path.get("sketch"), "path": path.get("curves"), "count": f.get("count"), "name": name});
|
||||
match f.get("spacing") {
|
||||
Some(sp) => p["spacing"] = sp.clone(),
|
||||
None => p["distance"] = f.get("distance").cloned().unwrap_or(Value::Null),
|
||||
}
|
||||
if f.get("pattern_of").and_then(Value::as_str) == Some("bodies") {
|
||||
p["bodies"] = f.get("bodies").cloned().unwrap_or(Value::Null);
|
||||
} else {
|
||||
p["features"] = feature_refs(f.get("features"));
|
||||
}
|
||||
out.push(json!({"command": "solid.pattern.path", "params": p}));
|
||||
}
|
||||
Some("loft") => {
|
||||
let sections: Vec<Value> = f
|
||||
.get("sections")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|sec| match sec.get("point") {
|
||||
Some(pt) => json!({"sketch": sec.get("sketch"), "point": pt}),
|
||||
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}}));
|
||||
}
|
||||
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": {
|
||||
"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": {
|
||||
"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}}));
|
||||
}
|
||||
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();
|
||||
let np = f.get("neutral_plane").cloned().unwrap_or(Value::Null);
|
||||
let n = np.get("plane_normal_outward").or(np.get("normal_at_point_on_face")).cloned().unwrap_or(json!([0, 0, 1]));
|
||||
let neutral = json!({"origin": np.get("point").or(np.get("centroid")).cloned().unwrap_or(json!([0, 0, 0])), "normal": n.clone()});
|
||||
let mut angle = f.get("angle").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
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}}));
|
||||
}
|
||||
Some("thread") => {
|
||||
let face = f.get("face").and_then(|x| x.get("point")).cloned().unwrap_or(Value::Null);
|
||||
let mut p = json!({"face": face, "designation": f.get("designation"), "name": name});
|
||||
if f.get("full_length").and_then(Value::as_bool) == Some(false)
|
||||
&& let Some(l) = f.get("length")
|
||||
{
|
||||
p["length"] = l.clone();
|
||||
}
|
||||
out.push(json!({"command": "solid.thread", "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();
|
||||
let mut dir = n.and_then(|n| n.as_array().map(|a| a.iter().map(|x| -x.as_f64().unwrap_or(0.0)).collect::<Vec<f64>>()));
|
||||
// On a cylinder the normal given is at the face's sample point: drill toward the axis.
|
||||
let v3 = |v: Option<&Value>| -> Option<[f64; 3]> {
|
||||
let a = v?.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
|
||||
};
|
||||
if let Some(fc) = f.get("face").filter(|fc| fc.get("type").and_then(Value::as_str) == Some("cylinder"))
|
||||
&& let (Some(o), Some(ax), Some(p)) = (v3(fc.get("axis_origin")), v3(fc.get("axis")), v3(f.get("position")))
|
||||
{
|
||||
let d = [p[0] - o[0], p[1] - o[1], p[2] - o[2]];
|
||||
let t = d[0] * ax[0] + d[1] * ax[1] + d[2] * ax[2];
|
||||
let r = [d[0] - t * ax[0], d[1] - t * ax[1], d[2] - t * ax[2]];
|
||||
let l = (r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt();
|
||||
if l > 1e-9 {
|
||||
dir = Some(vec![-r[0] / l, -r[1] / l, -r[2] / l]);
|
||||
}
|
||||
}
|
||||
let ty = f.get("hole_type").and_then(Value::as_str).unwrap_or("simple");
|
||||
let mut p = json!({"position": f.get("position"), "diameter": f.get("diameter"), "type": ty, "name": name});
|
||||
// "sketch HolePoints points p1..p6"
|
||||
if let Some(src) = f.get("positions_from").and_then(Value::as_str) {
|
||||
let w: Vec<&str> = src.split_whitespace().collect();
|
||||
if let (Some(sk), Some(range)) = (w.iter().position(|x| *x == "sketch").and_then(|i| w.get(i + 1)), w.iter().position(|x| *x == "points").and_then(|i| w.get(i + 1))) {
|
||||
let ids: Vec<String> = match range.split_once("..") {
|
||||
Some((a, b)) => {
|
||||
let pre: String = a.chars().take_while(|c| !c.is_ascii_digit()).collect();
|
||||
let (lo, hi) = (a[pre.len()..].parse::<u32>().unwrap_or(1), b.trim_start_matches(pre.as_str()).parse::<u32>().unwrap_or(0));
|
||||
(lo..=hi).map(|k| format!("{pre}{k}")).collect()
|
||||
}
|
||||
None => range.split(',').map(str::to_string).collect(),
|
||||
};
|
||||
p = json!({"sketch": sk, "points": ids, "diameter": f.get("diameter"), "type": ty, "name": name});
|
||||
}
|
||||
}
|
||||
if let Some(d) = dir {
|
||||
p["direction"] = json!(d);
|
||||
}
|
||||
if f.get("extent").and_then(Value::as_str) != Some("through_all") {
|
||||
p["depth"] = f.get("depth").cloned().unwrap_or(Value::Null);
|
||||
if let Some(t) = f.get("tip_angle") {
|
||||
p["tip_angle"] = t.clone();
|
||||
}
|
||||
}
|
||||
for (k, src) in [("cb_diameter", "counterbore_diameter"), ("cb_depth", "counterbore_depth"), ("cs_diameter", "countersink_diameter"), ("cs_angle", "countersink_angle")] {
|
||||
if let Some(v) = f.get(src) {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
}
|
||||
out.push(json!({"command": "solid.hole", "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}})),
|
||||
_ => {
|
||||
// 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}}));
|
||||
}
|
||||
},
|
||||
Some("split_body") => out.push(json!({"command": "solid.split_body", "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) {
|
||||
Some(r @ ("XY" | "XZ" | "YZ")) => json!(r),
|
||||
_ => json!({"origin": pl.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "normal": pl.get("normal")}),
|
||||
};
|
||||
let mut p = json!({"features": feature_refs(f.get("features")), "plane": plane, "name": name});
|
||||
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}));
|
||||
}
|
||||
Some("fillet") => {
|
||||
out.push(json!({"command": "solid.fillet", "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}}))
|
||||
}
|
||||
Some(other) => return Err(format!("recipe: feature op `{other}` is not supported yet")),
|
||||
None => return Err("recipe: feature without `op`".into()),
|
||||
}
|
||||
}
|
||||
Ok(json!({"commands": out}))
|
||||
}
|
||||
|
||||
fn edge_points(f: &Value) -> Value {
|
||||
Value::Array(
|
||||
f.get("edges")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|e| e.get("point").or(e.get("mid")).cloned().unwrap_or_else(|| e.clone()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Fusion names a pattern's source features with an occurrence suffix ("Hole1 (1)"); the
|
||||
/// features are the recipe's own names.
|
||||
fn feature_refs(v: Option<&Value>) -> Value {
|
||||
let list: Vec<Value> = v
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|x| match x.as_str() {
|
||||
Some(s) => {
|
||||
let t = s.trim();
|
||||
let base = match t.rfind(" (") {
|
||||
Some(i) if t.ends_with(')') && t[i + 2..t.len() - 1].chars().all(|c| c.is_ascii_digit()) => &t[..i],
|
||||
_ => t,
|
||||
};
|
||||
json!(base)
|
||||
}
|
||||
None => x.clone(),
|
||||
})
|
||||
.collect();
|
||||
json!(list)
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
//! Seam-normalised topology of Fusion's measurements.
|
||||
//!
|
||||
//! Fusion splits a closed surface along seams: a full cylinder can be two half faces, and a
|
||||
//! closed curve crossing a seam becomes two edges with a vertex on the seam. SolveCraft's merged
|
||||
//! counts already count each closed surface patch once and ignore seam edges and vertices, so
|
||||
//! the oracle normalises Fusion's side the same way from its face, edge and vertex lists:
|
||||
//! - faces cut from one periodic surface (cylinder, cone, sphere, torus) and separated only by
|
||||
//! seam edges count once;
|
||||
//! - a seam edge lies on that surface and on no other face's surface: it is dropped;
|
||||
//! - a vertex left with exactly two edges is no corner (a solid's corners have three or more):
|
||||
//! the two edges join, and a curve that closes on itself keeps one vertex.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
type V3 = [f64; 3];
|
||||
|
||||
const TOL: f64 = 1e-4;
|
||||
|
||||
fn v3(v: &Value) -> Option<V3> {
|
||||
let a = v.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
|
||||
}
|
||||
fn sub(a: V3, b: V3) -> V3 {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
fn add(a: V3, b: V3) -> V3 {
|
||||
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
||||
}
|
||||
fn mul(a: V3, s: f64) -> V3 {
|
||||
[a[0] * s, a[1] * s, a[2] * s]
|
||||
}
|
||||
fn dot(a: V3, b: V3) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
fn cross(a: V3, b: V3) -> V3 {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
fn len(a: V3) -> f64 {
|
||||
dot(a, a).sqrt()
|
||||
}
|
||||
fn unit(a: V3) -> Option<V3> {
|
||||
let l = len(a);
|
||||
(l > 1e-12).then(|| mul(a, 1.0 / l))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Surf {
|
||||
Plane { p: V3, n: V3 },
|
||||
Cylinder { o: V3, a: V3, r: f64 },
|
||||
Cone { o: V3, a: V3, r: f64, tan: f64 },
|
||||
Sphere { c: V3, r: f64 },
|
||||
Torus { c: V3, a: V3, big: f64, small: f64 },
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Surf {
|
||||
fn of(f: &Value) -> Surf {
|
||||
let g = |k: &str| v3(&f[k]);
|
||||
let n = |k: &str| f[k].as_f64();
|
||||
let s = match f["type"].as_str().unwrap_or_default() {
|
||||
"plane" => g("centroid").zip(g("plane_normal_outward").and_then(unit)).map(|(p, n)| Surf::Plane { p, n }),
|
||||
"cylinder" => (|| Some(Surf::Cylinder { o: g("axis_origin")?, a: unit(g("axis")?)?, r: n("radius_mm")? }))(),
|
||||
"cone" => {
|
||||
(|| Some(Surf::Cone { o: g("axis_origin")?, a: unit(g("axis")?)?, r: n("radius_mm")?, tan: n("half_angle_deg")?.to_radians().tan() }))(
|
||||
)
|
||||
}
|
||||
"sphere" => (|| Some(Surf::Sphere { c: g("center")?, r: n("radius_mm")? }))(),
|
||||
"torus" => (|| Some(Surf::Torus { c: g("center")?, a: unit(g("axis")?)?, big: n("major_radius_mm")?, small: n("minor_radius_mm")? }))(),
|
||||
_ => None,
|
||||
};
|
||||
s.unwrap_or(Surf::Other)
|
||||
}
|
||||
|
||||
fn periodic(&self) -> bool {
|
||||
matches!(self, Surf::Cylinder { .. } | Surf::Cone { .. } | Surf::Sphere { .. } | Surf::Torus { .. })
|
||||
}
|
||||
|
||||
/// Whether the point lies on the surface; `None` when that can't be told (free-form faces).
|
||||
fn contains(&self, p: V3) -> Option<bool> {
|
||||
let radial = |o: V3, a: V3| {
|
||||
let d = sub(p, o);
|
||||
let t = dot(d, a);
|
||||
(t, len(sub(d, mul(a, t))))
|
||||
};
|
||||
Some(match *self {
|
||||
Surf::Plane { p: q, n } => dot(sub(p, q), n).abs() < TOL,
|
||||
Surf::Cylinder { o, a, r } => (radial(o, a).1 - r).abs() < TOL,
|
||||
Surf::Cone { o, a, r, tan } => {
|
||||
let (t, rho) = radial(o, a);
|
||||
(rho - (r + t * tan)).abs() < TOL || (rho - (r - t * tan)).abs() < TOL
|
||||
}
|
||||
Surf::Sphere { c, r } => (len(sub(p, c)) - r).abs() < TOL,
|
||||
Surf::Torus { c, a, big, small } => {
|
||||
let (t, rho) = radial(c, a);
|
||||
((rho - big).hypot(t) - small).abs() < TOL
|
||||
}
|
||||
Surf::Other => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn same(&self, o: &Surf) -> bool {
|
||||
let close = |a: f64, b: f64| (a - b).abs() < TOL;
|
||||
let pt = |a: V3, b: V3| len(sub(a, b)) < TOL;
|
||||
let par = |a: V3, b: V3| len(cross(a, b)) < TOL;
|
||||
// Same axis line: parallel, and either origin on the other's axis.
|
||||
let axis = |o1: V3, a1: V3, o2: V3, a2: V3| par(a1, a2) && len(cross(sub(o2, o1), a1)) < TOL;
|
||||
match (*self, *o) {
|
||||
(Surf::Cylinder { o: o1, a: a1, r: r1 }, Surf::Cylinder { o: o2, a: a2, r: r2 }) => axis(o1, a1, o2, a2) && close(r1, r2),
|
||||
(Surf::Cone { o: o1, a: a1, r: r1, tan: t1 }, Surf::Cone { .. }) => {
|
||||
// Same cone: the other's frame circle lies on this one, same opening angle.
|
||||
let Surf::Cone { o: o2, a: a2, r: r2, tan: t2 } = *o else { return false };
|
||||
axis(o1, a1, o2, a2) && close(t1, t2) && {
|
||||
let t = dot(sub(o2, o1), a1) * dot(a1, a2).signum();
|
||||
close(r2, r1 + t * t1) || close(r2, r1 - t * t1)
|
||||
}
|
||||
}
|
||||
(Surf::Sphere { c: c1, r: r1 }, Surf::Sphere { c: c2, r: r2 }) => pt(c1, c2) && close(r1, r2),
|
||||
(Surf::Torus { c: c1, a: a1, big: b1, small: s1 }, Surf::Torus { c: c2, a: a2, big: b2, small: s2 }) => {
|
||||
pt(c1, c2) && par(a1, a2) && close(b1, b2) && close(s1, s2)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Points along an edge from Fusion's edge list.
|
||||
fn edge_points(e: &Value) -> Vec<V3> {
|
||||
let (s, t) = (v3(&e["start"]), v3(&e["end"]));
|
||||
let mut pts: Vec<V3> = s.into_iter().chain(t).collect();
|
||||
let (c, r, a) = (v3(&e["center"]), e["radius_mm"].as_f64(), v3(&e["axis"]).and_then(unit));
|
||||
match (e["type"].as_str().unwrap_or_default(), s, t) {
|
||||
("line", Some(s), Some(t)) => pts.push(mul(add(s, t), 0.5)),
|
||||
("arc", Some(s), Some(t)) => {
|
||||
if let (Some(c), Some(r)) = (c, r)
|
||||
&& let Some(m) = unit(add(sub(s, c), sub(t, c)))
|
||||
{
|
||||
let long = e["length_mm"].as_f64().is_some_and(|l| l > std::f64::consts::PI * r);
|
||||
pts.push(add(c, mul(m, if long { -r } else { r })));
|
||||
}
|
||||
}
|
||||
("circle", Some(s), _) => {
|
||||
if let (Some(c), Some(a)) = (c, a) {
|
||||
let d = sub(s, c);
|
||||
for th in [2.0 * std::f64::consts::FRAC_PI_3, 4.0 * std::f64::consts::FRAC_PI_3] {
|
||||
pts.push(add(c, add(mul(d, th.cos()), mul(cross(a, d), th.sin()))));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
fn key(p: V3) -> [i64; 3] {
|
||||
p.map(|x| (x * 1e6).round() as i64)
|
||||
}
|
||||
|
||||
fn find(p: &mut [usize], mut i: usize) -> usize {
|
||||
while let Some(&q) = p.get(i) {
|
||||
if q == i {
|
||||
break;
|
||||
}
|
||||
i = q;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Faces, edges and vertices of one Fusion body after merging seam splits; `None` when the
|
||||
/// lists are missing.
|
||||
pub fn normalised(body: &Value) -> Option<(usize, usize, usize)> {
|
||||
let faces = body["face_list"].as_array()?;
|
||||
let edges = body["edge_list"].as_array()?;
|
||||
let surfs: Vec<Surf> = faces.iter().map(Surf::of).collect();
|
||||
// Group faces cut from one periodic surface.
|
||||
let mut group: Vec<usize> = (0..surfs.len()).collect();
|
||||
for i in 0..surfs.len() {
|
||||
for j in 0..i {
|
||||
if surfs[i].periodic() && surfs[i].same(&surfs[j]) {
|
||||
group[i] = group[j];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let size = |g: usize| group.iter().filter(|x| **x == g).count();
|
||||
// Seam edges: on a split group's surface and on no other face's surface.
|
||||
let mut seams_in: HashMap<usize, usize> = HashMap::new();
|
||||
let mut seam = vec![false; edges.len()];
|
||||
for (ei, e) in edges.iter().enumerate() {
|
||||
let pts = edge_points(e);
|
||||
if pts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let on = |s: &Surf| pts.iter().map(|p| s.contains(*p)).try_fold(true, |acc, x| Some(acc && x?));
|
||||
let carriers: Vec<Option<bool>> = surfs.iter().map(on).collect();
|
||||
if carriers.contains(&None) {
|
||||
continue;
|
||||
}
|
||||
let groups: Vec<usize> = (0..surfs.len()).filter(|f| carriers[*f] == Some(true)).map(|f| group[f]).collect();
|
||||
if let Some(&g) = groups.first()
|
||||
&& groups.iter().all(|x| *x == g)
|
||||
&& surfs[g].periodic()
|
||||
&& size(g) > 1
|
||||
{
|
||||
seam[ei] = true;
|
||||
*seams_in.entry(g).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
let mut face_count = 0;
|
||||
for g in 0..surfs.len() {
|
||||
let k = size(g);
|
||||
if k > 0 {
|
||||
face_count += k.saturating_sub(seams_in.get(&g).copied().unwrap_or(0)).max(1);
|
||||
}
|
||||
}
|
||||
// Vertex degrees over the remaining edges; a closed edge counts once at its vertex.
|
||||
let mut ends: Vec<(usize, Option<[i64; 3]>, Option<[i64; 3]>)> = Vec::new();
|
||||
for (ei, e) in edges.iter().enumerate() {
|
||||
if seam[ei] {
|
||||
continue;
|
||||
}
|
||||
let (s, t) = (v3(&e["start"]).map(key), v3(&e["end"]).map(key));
|
||||
let t = if t == s { None } else { t };
|
||||
ends.push((ends.len(), s, t));
|
||||
}
|
||||
// A seam between two closed curves (a closed face's seam whose surface we can't evaluate):
|
||||
// an open edge whose ends each meet only a closed edge. At a real vertex three edges meet.
|
||||
let closed_at = |v: &[i64; 3], ends: &[(usize, Option<[i64; 3]>, Option<[i64; 3]>)]| {
|
||||
let closed = ends.iter().filter(|(_, s, t)| t.is_none() && s.as_ref() == Some(v)).count();
|
||||
let open = ends.iter().filter(|(_, s, t)| t.is_some() && (s.as_ref() == Some(v) || t.as_ref() == Some(v))).count();
|
||||
closed == 1 && open == 1
|
||||
};
|
||||
let loop_seams: Vec<usize> =
|
||||
ends.iter().filter(|(_, s, t)| matches!((s, t), (Some(s), Some(t)) if closed_at(s, &ends) && closed_at(t, &ends))).map(|e| e.0).collect();
|
||||
ends.retain(|e| !loop_seams.contains(&e.0));
|
||||
for (k, e) in ends.iter_mut().enumerate() {
|
||||
e.0 = k;
|
||||
}
|
||||
let mut deg: HashMap<[i64; 3], Vec<usize>> = HashMap::new();
|
||||
for (i, s, t) in &ends {
|
||||
for v in [s, t].into_iter().flatten() {
|
||||
deg.entry(*v).or_default().push(*i);
|
||||
}
|
||||
}
|
||||
let mut ep: Vec<usize> = (0..ends.len()).collect();
|
||||
let mut merged: Vec<[i64; 3]> = Vec::new();
|
||||
for (v, es) in ° {
|
||||
// Two open edges meeting: one curve split at a seam.
|
||||
if let [i, j] = es[..]
|
||||
&& i != j
|
||||
&& ends.get(i).is_some_and(|e| e.2.is_some())
|
||||
&& ends.get(j).is_some_and(|e| e.2.is_some())
|
||||
{
|
||||
let (ri, rj) = (find(&mut ep, i), find(&mut ep, j));
|
||||
if let Some(x) = ep.get_mut(ri) {
|
||||
*x = rj;
|
||||
}
|
||||
merged.push(*v);
|
||||
}
|
||||
}
|
||||
let mut real: HashMap<usize, bool> = HashMap::new();
|
||||
for (i, s, t) in &ends {
|
||||
let r = find(&mut ep, *i);
|
||||
let has_real = [s, t].into_iter().flatten().any(|v| !merged.contains(v)) || (s.is_none() && t.is_none());
|
||||
*real.entry(r).or_insert(false) |= has_real;
|
||||
}
|
||||
let closed = real.values().filter(|r| !**r).count();
|
||||
let vertices = deg.keys().filter(|v| !merged.contains(v)).count() + closed;
|
||||
Some((face_count, real.len(), vertices))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A cylinder (r 5, height 10) split by Fusion into two halves along two seams.
|
||||
#[test]
|
||||
fn split_cylinder_counts_once() {
|
||||
let cyl = json!({"type": "cylinder", "axis_origin": [0, 0, 0], "axis": [0, 0, 1], "radius_mm": 5.0});
|
||||
let plane = |z: f64, n: f64| json!({"type": "plane", "centroid": [0, 0, z], "plane_normal_outward": [0, 0, n]});
|
||||
let arc = |z: f64, s: [f64; 3], t: [f64; 3]| json!({"type": "arc", "start": s, "end": t, "center": [0, 0, z], "radius_mm": 5.0, "axis": [0, 0, 1], "length_mm": 15.707963});
|
||||
let line = |x: f64| json!({"type": "line", "start": [x, 0, 0], "end": [x, 0, 10]});
|
||||
let body = json!({
|
||||
"face_list": [cyl.clone(), cyl, plane(0.0, -1.0), plane(10.0, 1.0)],
|
||||
"edge_list": [line(5.0), line(-5.0), arc(0.0, [5.0, 0.0, 0.0], [-5.0, 0.0, 0.0]), arc(0.0, [-5.0, 0.0, 0.0], [5.0, 0.0, 0.0]), arc(10.0, [5.0, 0.0, 10.0], [-5.0, 0.0, 10.0]), arc(10.0, [-5.0, 0.0, 10.0], [5.0, 0.0, 10.0])],
|
||||
});
|
||||
assert_eq!(normalised(&body), Some((3, 2, 2)));
|
||||
}
|
||||
|
||||
/// Oracle 35: a fillet (free-form face, surface unknown) between two cylinders is bounded by
|
||||
/// two closed contact curves joined by its seam arc. The arc goes; the curves and circles stay.
|
||||
#[test]
|
||||
fn seam_between_closed_curves_on_an_unknown_surface() {
|
||||
let circle =
|
||||
|s: [f64; 3], c: [f64; 3], a: [f64; 3]| json!({"type": "circle", "start": s, "end": s, "center": c, "radius_mm": 1.0, "axis": a});
|
||||
let lp = |p: [f64; 3]| json!({"type": "nurbs", "start": p, "end": p});
|
||||
let (p, q) = ([0.0, -10.833333333, 10.374916332], [0.0, -10.0, 12.449899598]);
|
||||
let body = json!({
|
||||
"face_list": [{"type": "nurbs"}],
|
||||
"edge_list": [
|
||||
{"type": "arc", "start": p, "end": q, "center": [0.0, -13.0, 12.449899598], "radius_mm": 3.0, "axis": [1, 0, 0]},
|
||||
circle([-40.0, 0.0, 15.0], [-40.0, 0.0, 0.0], [-1.0, 0.0, 0.0]),
|
||||
circle([-10.0, 0.0, 35.0], [0.0, 0.0, 35.0], [0.0, 0.0, 1.0]),
|
||||
circle([40.0, 0.0, 15.0], [40.0, 0.0, 0.0], [1.0, 0.0, 0.0]),
|
||||
lp(p),
|
||||
lp(q),
|
||||
],
|
||||
});
|
||||
assert_eq!(normalised(&body), Some((1, 5, 5)));
|
||||
}
|
||||
|
||||
/// A closed curve split into two edges at a seam vertex joins again (Fusion part 61's keyway).
|
||||
#[test]
|
||||
fn degree_two_vertices_join() {
|
||||
let n = |s: [f64; 3], t: [f64; 3]| json!({"type": "nurbs", "start": s, "end": t});
|
||||
let body = json!({"face_list": [], "edge_list": [n([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), n([1.0, 0.0, 0.0], [0.0, 0.0, 0.0])]});
|
||||
assert_eq!(normalised(&body), Some((0, 1, 1)));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
# Design file corpus
|
||||
|
||||
Designs saved by older SolveCraft builds. `tests/design_corpus.rs` opens each one with the
|
||||
current build and checks that it evaluates as it did when saved (the `.expected.json` next to
|
||||
it is `solvecraft-cli eval` of that file by the build that saved it: bodies, volumes, areas,
|
||||
world bounding boxes), that it is upgraded to the current file format, and that damaged and
|
||||
hostile variants of it never panic.
|
||||
|
||||
| File | Saved by | Era |
|
||||
|---|---|---|
|
||||
| `m0-box-fillet-hole` | 3714342 | M0: sketch, parameters, extrude, fillet, cut |
|
||||
| `m0-revolve-primitives` | 3714342 | M0: revolve, primitives, combine, chamfer, timeline edit |
|
||||
| `components-arm` | 97d03e3 | components and occurrences: move, copy, Paste New, ground |
|
||||
| `joints-hinge` | c2309eb | joints: revolute hinge driven by a parameter |
|
||||
| `modern-sheet-metal` | bd98035 | sheet metal contour flange (oracle 72) |
|
||||
| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) |
|
||||
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
|
||||
|
||||
The scripts that made them are in `scripts/`. Never regenerate a file with a newer build: the
|
||||
point is that it was written by the old one. Add new files for new eras instead.
|
||||
@@ -1,134 +0,0 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 700.5299915783324,
|
||||
"bbox": {
|
||||
"max": [
|
||||
10.0,
|
||||
10.0,
|
||||
10.000000000000007
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
-6.080836915950756e-16
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
4.999999998722992,
|
||||
4.999999999366269,
|
||||
4.999999996470206
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 14,
|
||||
"face_types": {
|
||||
"cylinder": 1,
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 7,
|
||||
"kernel": {
|
||||
"edges": 18,
|
||||
"faces": 8,
|
||||
"shells": 1,
|
||||
"vertices": 12
|
||||
},
|
||||
"mass_g": 1.1279207478827844,
|
||||
"material": "Default",
|
||||
"name": "Base",
|
||||
"vertices": 10,
|
||||
"volume_mm3": 874.3571689013833
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 1,
|
||||
"dof": 3,
|
||||
"id": 11,
|
||||
"name": "S1",
|
||||
"profiles": 1,
|
||||
"status": "solved"
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 13.568090999999999,
|
||||
"name": "Box1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 1.004123,
|
||||
"name": "Box2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 6.818065,
|
||||
"name": "Fillet1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "FilletFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 9,
|
||||
"ms": 0.223829,
|
||||
"name": "Box2 (copy)",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 10,
|
||||
"ms": 6.139636,
|
||||
"name": "Fillet1 (copy)",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "FilletFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 11,
|
||||
"ms": 0.045728,
|
||||
"name": "S1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 12,
|
||||
"ms": 1537.323948,
|
||||
"name": "Extrude1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 700.5299915783324,
|
||||
"edges": 14,
|
||||
"faces": 7,
|
||||
"mass_g": 1.1279207478827844,
|
||||
"vertices": 10,
|
||||
"volume_mm3": 874.3571689013833
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Box1",
|
||||
"suppressed": false,
|
||||
"body_names": [
|
||||
"Base"
|
||||
],
|
||||
"type": "box",
|
||||
"corner": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"length": "10",
|
||||
"width": "10",
|
||||
"height": "10",
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Box2",
|
||||
"suppressed": false,
|
||||
"body_names": [
|
||||
"ArmBody"
|
||||
],
|
||||
"component": 2,
|
||||
"type": "box",
|
||||
"corner": [
|
||||
20.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"length": "10",
|
||||
"width": "4",
|
||||
"height": "4",
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Fillet1",
|
||||
"suppressed": false,
|
||||
"component": 2,
|
||||
"type": "fillet",
|
||||
"edges": [
|
||||
[
|
||||
25.0,
|
||||
4.0,
|
||||
4.0
|
||||
]
|
||||
],
|
||||
"radius": "1"
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": "Box2 (copy)",
|
||||
"suppressed": false,
|
||||
"component": 7,
|
||||
"type": "box",
|
||||
"corner": [
|
||||
20.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"length": "10",
|
||||
"width": "4",
|
||||
"height": "4",
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"name": "Fillet1 (copy)",
|
||||
"suppressed": false,
|
||||
"component": 7,
|
||||
"type": "fillet",
|
||||
"edges": [
|
||||
[
|
||||
25.0,
|
||||
4.0,
|
||||
4.0
|
||||
]
|
||||
],
|
||||
"radius": "1"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"name": "S1",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XY"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "c1.center",
|
||||
"pos": [
|
||||
5.0,
|
||||
5.0
|
||||
],
|
||||
"fixed": false
|
||||
}
|
||||
],
|
||||
"curves": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "circle",
|
||||
"c": 1,
|
||||
"r": 2.0,
|
||||
"construction": false
|
||||
}
|
||||
],
|
||||
"constraints": [],
|
||||
"counters": {
|
||||
"c": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"name": "Extrude1",
|
||||
"suppressed": false,
|
||||
"type": "extrude",
|
||||
"sketch": 11,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"extent": {
|
||||
"distance": "10",
|
||||
"direction": "positive"
|
||||
},
|
||||
"operation": "cut"
|
||||
}
|
||||
],
|
||||
"next_id": 13,
|
||||
"components": [
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Arm",
|
||||
"parent": 0
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "Arm (copy)",
|
||||
"parent": 0
|
||||
}
|
||||
],
|
||||
"occurrences": [
|
||||
{
|
||||
"id": 3,
|
||||
"component": 2,
|
||||
"parent": 0,
|
||||
"name": "Arm:1",
|
||||
"transform": [
|
||||
[
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
50.0,
|
||||
0.0,
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"grounded": true
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"component": 2,
|
||||
"parent": 0,
|
||||
"name": "Arm:2",
|
||||
"transform": [
|
||||
[
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
70.0,
|
||||
0.0,
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"grounded": false
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"component": 7,
|
||||
"parent": 0,
|
||||
"name": "Arm (copy):1",
|
||||
"transform": [
|
||||
[
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
20.0,
|
||||
0.0,
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"grounded": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 2820.0,
|
||||
"bbox": {
|
||||
"max": [
|
||||
40.0,
|
||||
30.0,
|
||||
3.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
20.0,
|
||||
15.0,
|
||||
1.5
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 12,
|
||||
"face_types": {
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 6,
|
||||
"kernel": {
|
||||
"edges": 12,
|
||||
"faces": 6,
|
||||
"shells": 1,
|
||||
"vertices": 8
|
||||
},
|
||||
"mass_g": 4.644,
|
||||
"material": "Default",
|
||||
"name": "Body1",
|
||||
"vertices": 8,
|
||||
"volume_mm3": 3600.0
|
||||
},
|
||||
{
|
||||
"area_mm2": 1105.7821673177034,
|
||||
"bbox": {
|
||||
"max": [
|
||||
40.00000000000002,
|
||||
37.0,
|
||||
8.000000000000002
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
29.0,
|
||||
-6.661338147750943e-16
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
20.00000000000423,
|
||||
32.99999999999611,
|
||||
3.9999999999995404
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 2,
|
||||
"face_types": {
|
||||
"cylinder": 1,
|
||||
"plane": 2
|
||||
},
|
||||
"faces": 3,
|
||||
"kernel": {
|
||||
"edges": 6,
|
||||
"faces": 4,
|
||||
"shells": 1,
|
||||
"vertices": 4
|
||||
},
|
||||
"mass_g": 2.593268071855663,
|
||||
"material": "Default",
|
||||
"name": "Body2",
|
||||
"vertices": 2,
|
||||
"volume_mm3": 2010.2853270198937
|
||||
},
|
||||
{
|
||||
"area_mm2": 2820.0,
|
||||
"bbox": {
|
||||
"max": [
|
||||
40.0,
|
||||
32.00000000000008,
|
||||
37.000000000000064
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
29.000000000000075,
|
||||
7.000000000000064
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
20.0,
|
||||
30.500000000000075,
|
||||
22.00000000000007
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 12,
|
||||
"face_types": {
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 6,
|
||||
"kernel": {
|
||||
"edges": 12,
|
||||
"faces": 6,
|
||||
"shells": 1,
|
||||
"vertices": 8
|
||||
},
|
||||
"mass_g": 4.643999999999996,
|
||||
"material": "Default",
|
||||
"name": "Body3",
|
||||
"vertices": 8,
|
||||
"volume_mm3": 3599.9999999999964
|
||||
},
|
||||
{
|
||||
"area_mm2": 1105.782167317711,
|
||||
"bbox": {
|
||||
"max": [
|
||||
40.00000000000002,
|
||||
37.000000000000085,
|
||||
8.000000000000064
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
29.000000000000075,
|
||||
6.394884621840902e-14
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
20.000000000004935,
|
||||
32.99999999999709,
|
||||
3.999999999999749
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 2,
|
||||
"face_types": {
|
||||
"cylinder": 1,
|
||||
"plane": 2
|
||||
},
|
||||
"faces": 3,
|
||||
"kernel": {
|
||||
"edges": 6,
|
||||
"faces": 4,
|
||||
"shells": 1,
|
||||
"vertices": 4
|
||||
},
|
||||
"mass_g": 2.59326807185558,
|
||||
"material": "Default",
|
||||
"name": "Body4",
|
||||
"vertices": 2,
|
||||
"volume_mm3": 2010.2853270198293
|
||||
}
|
||||
],
|
||||
"body_count": 4,
|
||||
"params": [
|
||||
{
|
||||
"comment": "",
|
||||
"error": null,
|
||||
"expression": "90 deg",
|
||||
"model": false,
|
||||
"name": "open_angle",
|
||||
"unit": "deg",
|
||||
"value": 1.5707963267948966
|
||||
}
|
||||
],
|
||||
"sketches": [],
|
||||
"timeline": [
|
||||
{
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 1.1335799999999998,
|
||||
"name": "Box1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 0.580462,
|
||||
"name": "Cylinder1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "CylinderFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 7,
|
||||
"ms": 0.257673,
|
||||
"name": "Box2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 8,
|
||||
"ms": 0.5234019999999999,
|
||||
"name": "Cylinder2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "CylinderFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 7851.564334635414,
|
||||
"edges": 28,
|
||||
"faces": 18,
|
||||
"mass_g": 14.474536143711237,
|
||||
"vertices": 20,
|
||||
"volume_mm3": 11220.570654039719
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [
|
||||
{
|
||||
"name": "open_angle",
|
||||
"expr": "90 deg",
|
||||
"unit": "deg",
|
||||
"model": false
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Box1",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d1",
|
||||
"d2",
|
||||
"d3"
|
||||
],
|
||||
"component": 1,
|
||||
"type": "box",
|
||||
"corner": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"length": "40",
|
||||
"width": "30",
|
||||
"height": "3",
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Cylinder1",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d4",
|
||||
"d5"
|
||||
],
|
||||
"component": 1,
|
||||
"type": "cylinder",
|
||||
"base": [
|
||||
0.0,
|
||||
33.0,
|
||||
4.0
|
||||
],
|
||||
"axis": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"radius": "4",
|
||||
"height": "40",
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "Box2",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d6",
|
||||
"d7",
|
||||
"d8"
|
||||
],
|
||||
"component": 5,
|
||||
"type": "box",
|
||||
"corner": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"length": "40",
|
||||
"width": "30",
|
||||
"height": "3",
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": "Cylinder2",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d9",
|
||||
"d10"
|
||||
],
|
||||
"component": 5,
|
||||
"type": "cylinder",
|
||||
"base": [
|
||||
0.0,
|
||||
33.0,
|
||||
4.0
|
||||
],
|
||||
"axis": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"radius": "4",
|
||||
"height": "40",
|
||||
"operation": "new_body"
|
||||
}
|
||||
],
|
||||
"next_id": 9,
|
||||
"components": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "LeafA",
|
||||
"parent": 0
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "LeafB",
|
||||
"parent": 0
|
||||
}
|
||||
],
|
||||
"occurrences": [
|
||||
{
|
||||
"id": 2,
|
||||
"component": 1,
|
||||
"parent": 0,
|
||||
"name": "LeafA:1",
|
||||
"transform": [
|
||||
[
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"grounded": true
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"component": 5,
|
||||
"parent": 0,
|
||||
"name": "LeafB:1",
|
||||
"transform": [
|
||||
[
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
6.123233995736766e-17,
|
||||
-1.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
1.0,
|
||||
6.123233995736766e-17,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
29.000000000000075,
|
||||
37.000000000000064,
|
||||
1.0
|
||||
]
|
||||
],
|
||||
"grounded": false
|
||||
}
|
||||
],
|
||||
"assembly": {
|
||||
"joints": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Hinge",
|
||||
"kind": "revolute",
|
||||
"a": {
|
||||
"occurrence": 2,
|
||||
"snap": {
|
||||
"type": "circle_center",
|
||||
"pick": [
|
||||
1.0,
|
||||
37.0,
|
||||
4.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"b": {
|
||||
"occurrence": 6,
|
||||
"snap": {
|
||||
"type": "circle_center",
|
||||
"pick": [
|
||||
1.0,
|
||||
37.0,
|
||||
4.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"values": [
|
||||
1.5707963267948966
|
||||
],
|
||||
"limits": [
|
||||
null
|
||||
],
|
||||
"offset": 0.0,
|
||||
"angle": 0.0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 5641.5699696719,
|
||||
"bbox": {
|
||||
"max": [
|
||||
40.0,
|
||||
30.0,
|
||||
20.00000000000001
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
-2.331468351865795e-15
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
20.0333953680467,
|
||||
15.02475683626249,
|
||||
9.999999936169297
|
||||
],
|
||||
"edges": 17,
|
||||
"face_types": {
|
||||
"cylinder": 2,
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 8,
|
||||
"kernel": {
|
||||
"edges": 21,
|
||||
"faces": 9,
|
||||
"shells": 1,
|
||||
"vertices": 14
|
||||
},
|
||||
"name": "Body1",
|
||||
"vertices": 12,
|
||||
"volume_mm3": 22391.21832759239
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [
|
||||
{
|
||||
"comment": "",
|
||||
"error": null,
|
||||
"expression": "40 mm",
|
||||
"model": false,
|
||||
"name": "width",
|
||||
"unit": "mm",
|
||||
"value": 40.0
|
||||
}
|
||||
],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 4,
|
||||
"id": 1,
|
||||
"name": "Sketch1",
|
||||
"profiles": 1,
|
||||
"status": "solved"
|
||||
},
|
||||
{
|
||||
"curves": 1,
|
||||
"dof": 3,
|
||||
"id": 4,
|
||||
"name": "Sketch2",
|
||||
"profiles": 1,
|
||||
"status": "solved"
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.043782999999999996,
|
||||
"name": "Sketch1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 1.609402,
|
||||
"name": "Extrude1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 11.196359,
|
||||
"name": "Fillet1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "FilletFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 0.031249,
|
||||
"name": "Sketch2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 1378.231746,
|
||||
"name": "Extrude2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 5641.5699696719,
|
||||
"edges": 17,
|
||||
"faces": 8,
|
||||
"vertices": 12,
|
||||
"volume_mm3": 22391.21832759239
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [
|
||||
{
|
||||
"name": "width",
|
||||
"expr": "40 mm",
|
||||
"unit": "mm",
|
||||
"model": false
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Sketch1",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XY"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
40.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
40.0,
|
||||
30.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
0.0,
|
||||
30.0
|
||||
],
|
||||
"fixed": false
|
||||
}
|
||||
],
|
||||
"curves": [
|
||||
{
|
||||
"id": "l5",
|
||||
"type": "line",
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l6",
|
||||
"type": "line",
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l7",
|
||||
"type": "line",
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l8",
|
||||
"type": "line",
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k9",
|
||||
"type": "horizontal",
|
||||
"l": 0
|
||||
},
|
||||
{
|
||||
"id": "k10",
|
||||
"type": "horizontal",
|
||||
"l": 2
|
||||
},
|
||||
{
|
||||
"id": "k11",
|
||||
"type": "vertical",
|
||||
"l": 1
|
||||
},
|
||||
{
|
||||
"id": "k12",
|
||||
"type": "vertical",
|
||||
"l": 3
|
||||
}
|
||||
],
|
||||
"next_id": 13
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Extrude1",
|
||||
"suppressed": false,
|
||||
"type": "extrude",
|
||||
"sketch": 1,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"extent": {
|
||||
"distance": "width / 2",
|
||||
"direction": "positive"
|
||||
},
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Fillet1",
|
||||
"suppressed": false,
|
||||
"type": "fillet",
|
||||
"edges": [
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"radius": "3"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Sketch2",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XY"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "c1.center",
|
||||
"pos": [
|
||||
20.0,
|
||||
15.0
|
||||
],
|
||||
"fixed": false
|
||||
}
|
||||
],
|
||||
"curves": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "circle",
|
||||
"c": 1,
|
||||
"r": 5.0,
|
||||
"construction": false
|
||||
}
|
||||
],
|
||||
"constraints": [],
|
||||
"next_id": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Extrude2",
|
||||
"suppressed": false,
|
||||
"type": "extrude",
|
||||
"sketch": 4,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"extent": {
|
||||
"distance": "20",
|
||||
"direction": "positive"
|
||||
},
|
||||
"operation": "cut"
|
||||
}
|
||||
],
|
||||
"next_id": 6
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 835.4130709318358,
|
||||
"bbox": {
|
||||
"max": [
|
||||
15.0,
|
||||
15.0,
|
||||
5.0
|
||||
],
|
||||
"min": [
|
||||
-15.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
-5.1971813422033816e-8,
|
||||
8.063488509334698,
|
||||
2.500000003353729
|
||||
],
|
||||
"edges": 12,
|
||||
"face_types": {
|
||||
"cylinder": 2,
|
||||
"plane": 4
|
||||
},
|
||||
"faces": 6,
|
||||
"kernel": {
|
||||
"edges": 28,
|
||||
"faces": 14,
|
||||
"shells": 1,
|
||||
"vertices": 16
|
||||
},
|
||||
"name": "Body1",
|
||||
"vertices": 8,
|
||||
"volume_mm3": 981.8090960455798
|
||||
},
|
||||
{
|
||||
"area_mm2": 693.6702600465544,
|
||||
"bbox": {
|
||||
"max": [
|
||||
40.0,
|
||||
10.0,
|
||||
10.000000000000007
|
||||
],
|
||||
"min": [
|
||||
30.0,
|
||||
0.0,
|
||||
-2.3775115272352145e-15
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
35.02683928802334,
|
||||
4.999999999290639,
|
||||
4.973160725428021
|
||||
],
|
||||
"edges": 17,
|
||||
"face_types": {
|
||||
"cylinder": 1,
|
||||
"plane": 7
|
||||
},
|
||||
"faces": 8,
|
||||
"kernel": {
|
||||
"edges": 21,
|
||||
"faces": 9,
|
||||
"shells": 1,
|
||||
"vertices": 14
|
||||
},
|
||||
"name": "Body2",
|
||||
"vertices": 12,
|
||||
"volume_mm3": 869.3724925404262
|
||||
}
|
||||
],
|
||||
"body_count": 2,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 8,
|
||||
"id": 1,
|
||||
"name": "Sketch1",
|
||||
"profiles": 1,
|
||||
"status": "solved"
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.034476,
|
||||
"name": "Sketch1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 7.732595999999999,
|
||||
"name": "Revolve1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "RevolveFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 2.5985959999999997,
|
||||
"name": "Box1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 1.6926910000000002,
|
||||
"name": "Cylinder1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "CylinderFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 384.534019,
|
||||
"name": "Combine1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "CombineFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 150.763588,
|
||||
"name": "Chamfer1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ChamferFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 1529.0833309783902,
|
||||
"edges": 29,
|
||||
"faces": 14,
|
||||
"vertices": 20,
|
||||
"volume_mm3": 1851.181588586006
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Sketch1",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XZ"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "l1.start",
|
||||
"pos": [
|
||||
10.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "l1.end",
|
||||
"pos": [
|
||||
15.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "l2.end",
|
||||
"pos": [
|
||||
15.0,
|
||||
5.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "l3.end",
|
||||
"pos": [
|
||||
10.0,
|
||||
5.0
|
||||
],
|
||||
"fixed": false
|
||||
}
|
||||
],
|
||||
"curves": [
|
||||
{
|
||||
"id": "l1",
|
||||
"type": "line",
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"type": "line",
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"type": "line",
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l4",
|
||||
"type": "line",
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false
|
||||
}
|
||||
],
|
||||
"constraints": [],
|
||||
"next_id": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Revolve1",
|
||||
"suppressed": false,
|
||||
"type": "revolve",
|
||||
"sketch": 1,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"axis": {
|
||||
"type": "sketch_axis",
|
||||
"axis": "y"
|
||||
},
|
||||
"angle": "180 deg",
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Box1",
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"corner": [
|
||||
30.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"length": "10",
|
||||
"width": "10",
|
||||
"height": "10",
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Cylinder1",
|
||||
"suppressed": false,
|
||||
"type": "cylinder",
|
||||
"base": [
|
||||
35.0,
|
||||
5.0,
|
||||
-5.0
|
||||
],
|
||||
"axis": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"radius": "(4) / 2",
|
||||
"height": "20",
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Combine1",
|
||||
"suppressed": false,
|
||||
"type": "combine",
|
||||
"target": "Body2",
|
||||
"tools": [
|
||||
"Body3"
|
||||
],
|
||||
"operation": "cut",
|
||||
"keep_tools": false
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Chamfer1",
|
||||
"suppressed": false,
|
||||
"type": "chamfer",
|
||||
"edges": [
|
||||
[
|
||||
30.0,
|
||||
5.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"distance": "1"
|
||||
}
|
||||
],
|
||||
"next_id": 7
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 16463.932136252846,
|
||||
"bbox": {
|
||||
"max": [
|
||||
120.0,
|
||||
60.0,
|
||||
5.0000000000000036
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
60.000000010005735,
|
||||
29.965234604788655,
|
||||
2.4999999999999867
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 26,
|
||||
"face_types": {
|
||||
"cylinder": 7,
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 13,
|
||||
"kernel": {
|
||||
"edges": 54,
|
||||
"faces": 20,
|
||||
"shells": 1,
|
||||
"vertices": 36
|
||||
},
|
||||
"mass_g": 45.163625870882676,
|
||||
"material": "Default",
|
||||
"name": "Plate",
|
||||
"vertices": 22,
|
||||
"volume_mm3": 35010.56269060673
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 8,
|
||||
"id": 1,
|
||||
"name": "Plate_sketch",
|
||||
"profiles": 1,
|
||||
"status": "solved"
|
||||
},
|
||||
{
|
||||
"curves": 2,
|
||||
"dof": 6,
|
||||
"id": 3,
|
||||
"name": "PathSketch",
|
||||
"profiles": 1,
|
||||
"status": "solved"
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.093199,
|
||||
"name": "Plate_sketch",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 9.166392,
|
||||
"name": "Plate",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.057569999999999996,
|
||||
"name": "PathSketch",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 84.237259,
|
||||
"name": "Hole1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 1093.817516,
|
||||
"name": "PathPattern1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "PathPatternFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 16463.932136252846,
|
||||
"edges": 26,
|
||||
"faces": 13,
|
||||
"mass_g": 45.163625870882676,
|
||||
"vertices": 22,
|
||||
"volume_mm3": 35010.56269060673
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Plate_sketch",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XY"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "l1.start",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "l1.end",
|
||||
"pos": [
|
||||
120.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "l2.end",
|
||||
"pos": [
|
||||
120.0,
|
||||
60.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "l3.end",
|
||||
"pos": [
|
||||
0.0,
|
||||
60.0
|
||||
],
|
||||
"fixed": false
|
||||
}
|
||||
],
|
||||
"curves": [
|
||||
{
|
||||
"id": "l1",
|
||||
"type": "line",
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"type": "line",
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"type": "line",
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l4",
|
||||
"type": "line",
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false
|
||||
}
|
||||
],
|
||||
"constraints": [],
|
||||
"counters": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Plate",
|
||||
"suppressed": false,
|
||||
"body_names": [
|
||||
"Plate"
|
||||
],
|
||||
"param_names": [
|
||||
"d1"
|
||||
],
|
||||
"type": "extrude",
|
||||
"sketch": 1,
|
||||
"profiles": {
|
||||
"type": "curves",
|
||||
"loops": [
|
||||
[
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
]
|
||||
},
|
||||
"extent": {
|
||||
"distance": "5",
|
||||
"direction": "positive"
|
||||
},
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "PathSketch",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XY"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "a1.center",
|
||||
"pos": [
|
||||
60.0,
|
||||
3.3333333333333335
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "a1.start",
|
||||
"pos": [
|
||||
100.0,
|
||||
15.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "a1.end",
|
||||
"pos": [
|
||||
20.0,
|
||||
15.000000000000002
|
||||
],
|
||||
"fixed": false
|
||||
}
|
||||
],
|
||||
"curves": [
|
||||
{
|
||||
"id": "a1",
|
||||
"type": "arc",
|
||||
"c": 1,
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "circle",
|
||||
"c": 2,
|
||||
"r": 3.0,
|
||||
"construction": false
|
||||
}
|
||||
],
|
||||
"constraints": [],
|
||||
"counters": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Hole1",
|
||||
"suppressed": false,
|
||||
"body_names": [
|
||||
"Plate"
|
||||
],
|
||||
"param_names": [
|
||||
"d2"
|
||||
],
|
||||
"type": "extrude",
|
||||
"sketch": 3,
|
||||
"profiles": {
|
||||
"type": "curves",
|
||||
"loops": [
|
||||
[
|
||||
"c1"
|
||||
]
|
||||
]
|
||||
},
|
||||
"extent": {
|
||||
"distance": "0",
|
||||
"direction": "positive",
|
||||
"through_all": true
|
||||
},
|
||||
"operation": "cut"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "PathPattern1",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d3",
|
||||
"d4"
|
||||
],
|
||||
"type": "pattern",
|
||||
"features": [
|
||||
"Hole1"
|
||||
],
|
||||
"pattern": {
|
||||
"type": "path",
|
||||
"path_sketch": 3,
|
||||
"path": [
|
||||
"a1"
|
||||
],
|
||||
"count": "7",
|
||||
"spacing": "17.875031"
|
||||
}
|
||||
}
|
||||
],
|
||||
"next_id": 6
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 24280.380636378435,
|
||||
"bbox": {
|
||||
"max": [
|
||||
80.0,
|
||||
50.0,
|
||||
32.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
39.89510389356416,
|
||||
24.906013036240918,
|
||||
10.936558895792075
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 82,
|
||||
"face_types": {
|
||||
"cone": 1,
|
||||
"cylinder": 1,
|
||||
"plane": 35
|
||||
},
|
||||
"faces": 37,
|
||||
"kernel": {
|
||||
"edges": 90,
|
||||
"faces": 39,
|
||||
"shells": 1,
|
||||
"vertices": 60
|
||||
},
|
||||
"mass_g": 29.716687268283682,
|
||||
"material": "Default",
|
||||
"name": "Body1",
|
||||
"vertices": 56,
|
||||
"volume_mm3": 23036.19168084006
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [
|
||||
{
|
||||
"comment": "",
|
||||
"error": null,
|
||||
"expression": "2 mm",
|
||||
"model": false,
|
||||
"name": "wall",
|
||||
"unit": "mm",
|
||||
"value": 2.0
|
||||
}
|
||||
],
|
||||
"sketches": [],
|
||||
"timeline": [
|
||||
{
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 13.671479,
|
||||
"name": "Box1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 15.729548999999999,
|
||||
"name": "Shell1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ShellFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 136.407014,
|
||||
"name": "Lip1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "LipFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 530.869306,
|
||||
"name": "Boss1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BossFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 176.568115,
|
||||
"name": "Rest1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "RestFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 260.395779,
|
||||
"name": "SnapFit1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "SnapFitFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 24280.380636378435,
|
||||
"edges": 82,
|
||||
"faces": 37,
|
||||
"mass_g": 29.716687268283682,
|
||||
"vertices": 56,
|
||||
"volume_mm3": 23036.19168084006
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [
|
||||
{
|
||||
"name": "wall",
|
||||
"expr": "2 mm",
|
||||
"unit": "mm",
|
||||
"model": false
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Box1",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d1",
|
||||
"d2",
|
||||
"d3"
|
||||
],
|
||||
"type": "box",
|
||||
"corner": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"length": "80",
|
||||
"width": "50",
|
||||
"height": "30",
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Shell1",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d4"
|
||||
],
|
||||
"type": "shell",
|
||||
"faces": [
|
||||
[
|
||||
40.0,
|
||||
25.0,
|
||||
30.0
|
||||
]
|
||||
],
|
||||
"thickness": "wall"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Lip1",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d5",
|
||||
"d6"
|
||||
],
|
||||
"type": "lip",
|
||||
"face": [
|
||||
1.0,
|
||||
25.0,
|
||||
30.0
|
||||
],
|
||||
"width": "1",
|
||||
"height": "2"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Boss1",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d7",
|
||||
"d8",
|
||||
"d9",
|
||||
"d10"
|
||||
],
|
||||
"type": "boss",
|
||||
"position": [
|
||||
20.0,
|
||||
20.0,
|
||||
2.0
|
||||
],
|
||||
"direction": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"diameter": "6",
|
||||
"height": "10",
|
||||
"hole_diameter": "2.5",
|
||||
"draft": "1 deg"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Rest1",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d11",
|
||||
"d12",
|
||||
"d13",
|
||||
"d14"
|
||||
],
|
||||
"type": "rest",
|
||||
"position": [
|
||||
60.0,
|
||||
30.0,
|
||||
2.0
|
||||
],
|
||||
"direction": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"width": "8",
|
||||
"length": "12",
|
||||
"height": "3",
|
||||
"thickness": "1"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "SnapFit1",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d15",
|
||||
"d16",
|
||||
"d17",
|
||||
"d18",
|
||||
"d19"
|
||||
],
|
||||
"type": "snap_fit",
|
||||
"position": [
|
||||
40.0,
|
||||
12.0,
|
||||
2.0
|
||||
],
|
||||
"direction": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"hook": [
|
||||
0.0,
|
||||
1.0,
|
||||
0.0
|
||||
],
|
||||
"length": "12",
|
||||
"thickness": "1.5",
|
||||
"width": "6",
|
||||
"catch_depth": "1",
|
||||
"catch_length": "2"
|
||||
}
|
||||
],
|
||||
"next_id": 7,
|
||||
"plastic": {
|
||||
"active": "ABS (1.5mm)"
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 10411.967792188118,
|
||||
"bbox": {
|
||||
"max": [
|
||||
70.0,
|
||||
50.00000000000002,
|
||||
32.500000000000014
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
-4.440892098500626e-14,
|
||||
-5.329070518200751e-15
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
36.03765771659564,
|
||||
24.999999999999982,
|
||||
15.08756759282672
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 44,
|
||||
"face_types": {
|
||||
"cylinder": 4,
|
||||
"plane": 10
|
||||
},
|
||||
"faces": 22,
|
||||
"kernel": {
|
||||
"edges": 44,
|
||||
"faces": 22,
|
||||
"shells": 1,
|
||||
"vertices": 24
|
||||
},
|
||||
"mass_g": 15.605746095907175,
|
||||
"material": "Default",
|
||||
"name": "Body1",
|
||||
"sheet_metal": true,
|
||||
"vertices": 24,
|
||||
"volume_mm3": 12097.477593726493
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 3,
|
||||
"dof": 8,
|
||||
"id": 1,
|
||||
"name": "Contour",
|
||||
"profiles": 0,
|
||||
"status": "solved"
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.074984,
|
||||
"name": "Contour",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 37.187197,
|
||||
"name": "ContourFlange",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ContourFlangeFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 10411.967792188118,
|
||||
"edges": 44,
|
||||
"faces": 22,
|
||||
"mass_g": 15.605746095907175,
|
||||
"vertices": 24,
|
||||
"volume_mm3": 12097.477593726493
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Contour",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "custom",
|
||||
"plane": {
|
||||
"origin": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"x_dir": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"y_dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
-1.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "l1.start",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "l1.end",
|
||||
"pos": [
|
||||
40.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "l2.end",
|
||||
"pos": [
|
||||
40.0,
|
||||
-30.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "l3.end",
|
||||
"pos": [
|
||||
70.0,
|
||||
-30.0
|
||||
],
|
||||
"fixed": false
|
||||
}
|
||||
],
|
||||
"curves": [
|
||||
{
|
||||
"id": "l1",
|
||||
"type": "line",
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"type": "line",
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"type": "line",
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false
|
||||
}
|
||||
],
|
||||
"constraints": [],
|
||||
"counters": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "ContourFlange",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d1"
|
||||
],
|
||||
"type": "sheet_contour",
|
||||
"sketch": 1,
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3"
|
||||
],
|
||||
"distance": "50"
|
||||
}
|
||||
],
|
||||
"next_id": 3
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{"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": "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"}}
|
||||
]}
|
||||
@@ -1,14 +0,0 @@
|
||||
{"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": "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": "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"}}
|
||||
]}
|
||||
@@ -1,12 +0,0 @@
|
||||
{"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"}}
|
||||
]}
|
||||
@@ -1,11 +0,0 @@
|
||||
{"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": "timeline.edit", "params": {"feature": "Revolve1", "set": {"angle": "180 deg"}}}
|
||||
]}
|
||||
@@ -1,197 +0,0 @@
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"name": "Plate_sketch",
|
||||
"plane": "XY"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.line",
|
||||
"params": {
|
||||
"construction": false,
|
||||
"ids": [
|
||||
"l1"
|
||||
],
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
120,
|
||||
0
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.line",
|
||||
"params": {
|
||||
"construction": false,
|
||||
"ids": [
|
||||
"l2"
|
||||
],
|
||||
"points": [
|
||||
"l1.end",
|
||||
[
|
||||
120,
|
||||
60
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.line",
|
||||
"params": {
|
||||
"construction": false,
|
||||
"ids": [
|
||||
"l3"
|
||||
],
|
||||
"points": [
|
||||
"l2.end",
|
||||
[
|
||||
0,
|
||||
60
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.line",
|
||||
"params": {
|
||||
"construction": false,
|
||||
"ids": [
|
||||
"l4"
|
||||
],
|
||||
"points": [
|
||||
"l3.end",
|
||||
"l1.start"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"body_names": [
|
||||
"Plate"
|
||||
],
|
||||
"direction": "positive",
|
||||
"distance": 5,
|
||||
"name": "Plate",
|
||||
"operation": "new",
|
||||
"profiles": [
|
||||
{
|
||||
"area_mm2": 7200.0,
|
||||
"centroid_sketch": [
|
||||
60.0,
|
||||
30.0
|
||||
],
|
||||
"loops": [
|
||||
{
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
],
|
||||
"outer": true
|
||||
}
|
||||
],
|
||||
"sketch": "Plate_sketch"
|
||||
}
|
||||
],
|
||||
"sketch": "Plate_sketch"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"name": "PathSketch",
|
||||
"plane": "XY"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.arc.three_point",
|
||||
"params": {
|
||||
"end": [
|
||||
20,
|
||||
15
|
||||
],
|
||||
"id": "a1",
|
||||
"start": [
|
||||
100,
|
||||
15
|
||||
],
|
||||
"through": [
|
||||
60,
|
||||
45
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.circle.center",
|
||||
"params": {
|
||||
"center": "a1.start",
|
||||
"construction": false,
|
||||
"id": "c1",
|
||||
"radius": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"body_names": [
|
||||
"Plate"
|
||||
],
|
||||
"direction": "positive",
|
||||
"name": "Hole1",
|
||||
"operation": "cut",
|
||||
"profiles": [
|
||||
{
|
||||
"area_mm2": 28.274334,
|
||||
"centroid_sketch": [
|
||||
100.0,
|
||||
15.0
|
||||
],
|
||||
"loops": [
|
||||
{
|
||||
"curves": [
|
||||
"c1"
|
||||
],
|
||||
"outer": true
|
||||
}
|
||||
],
|
||||
"sketch": "PathSketch"
|
||||
}
|
||||
],
|
||||
"sketch": "PathSketch",
|
||||
"through_all": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.pattern.path",
|
||||
"params": {
|
||||
"count": 7,
|
||||
"features": [
|
||||
"Hole1"
|
||||
],
|
||||
"name": "PathPattern1",
|
||||
"path": [
|
||||
"a1"
|
||||
],
|
||||
"path_sketch": "PathSketch",
|
||||
"spacing": 17.875031
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{"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}}
|
||||
]}
|
||||
@@ -1,96 +0,0 @@
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"name": "Contour",
|
||||
"plane": {
|
||||
"origin": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"x_dir": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"y_dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
-1.0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.line",
|
||||
"params": {
|
||||
"construction": false,
|
||||
"ids": [
|
||||
"l1"
|
||||
],
|
||||
"points": [
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
40.0,
|
||||
0.0
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.line",
|
||||
"params": {
|
||||
"construction": false,
|
||||
"ids": [
|
||||
"l2"
|
||||
],
|
||||
"points": [
|
||||
"l1.end",
|
||||
[
|
||||
40.0,
|
||||
-30.0
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.line",
|
||||
"params": {
|
||||
"construction": false,
|
||||
"ids": [
|
||||
"l3"
|
||||
],
|
||||
"points": [
|
||||
"l2.end",
|
||||
[
|
||||
70.0,
|
||||
-30.0
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"command": "sheet.flange",
|
||||
"params": {
|
||||
"curves": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3"
|
||||
],
|
||||
"distance": 50,
|
||||
"name": "ContourFlange",
|
||||
"sketch": "Contour",
|
||||
"type": "contour"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
//! Designs saved by older SolveCraft builds (tests/corpus, see its README) open and evaluate as
|
||||
//! they did when they were saved; damaged and hostile design files never panic.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::Session;
|
||||
|
||||
fn corpus() -> Vec<PathBuf> {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
|
||||
let mut v: Vec<PathBuf> =
|
||||
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solvecraft")).collect();
|
||||
v.sort();
|
||||
assert!(v.len() >= 7, "corpus files in {}", dir.display());
|
||||
v
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
s
|
||||
}
|
||||
|
||||
/// Differences that are deliberate behaviour changes made before the file format had versions
|
||||
/// (no migration can tell those files apart): (file, check, why).
|
||||
const KNOWN: [(&str, &str, &str); 2] = [
|
||||
(
|
||||
"m0-revolve-primitives",
|
||||
"Body1 bbox",
|
||||
"partial revolves turn toward the sketch normal since ac43365 (as Fusion's do); M0 turned them the other way",
|
||||
),
|
||||
("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"),
|
||||
];
|
||||
|
||||
fn known(file: &str, check: &str) -> bool {
|
||||
KNOWN.iter().any(|(f, c, _)| *f == file && *c == check)
|
||||
}
|
||||
|
||||
/// Volumes and areas then came from coarser tessellation (and curved booleans and fillets have
|
||||
/// become exact since): the same part within this relative tolerance (the oracle's).
|
||||
const REL: f64 = 1e-3;
|
||||
|
||||
fn close(a: f64, b: f64, rel: f64) -> bool {
|
||||
(a - b).abs() <= rel * b.abs().max(1.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_designs_open_and_evaluate_as_saved() {
|
||||
let mut problems = Vec::new();
|
||||
for p in corpus() {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let want: Value = serde_json::from_slice(&std::fs::read(p.with_extension("expected.json")).unwrap()).unwrap();
|
||||
let mut s = open(&p);
|
||||
for r in &s.model.results {
|
||||
if let Some(e) = &r.error {
|
||||
problems.push(format!("{name}: feature {}: {e}", r.id));
|
||||
}
|
||||
}
|
||||
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();
|
||||
if got["body_count"] != want["body_count"] && !known(&name, "body_count") {
|
||||
problems.push(format!("{name}: {} bodies, saved with {}", got["body_count"], want["body_count"]));
|
||||
}
|
||||
for w in want["bodies"].as_array().into_iter().flatten() {
|
||||
let bname = w["name"].as_str().unwrap_or_default();
|
||||
let Some(g) = got["bodies"].as_array().into_iter().flatten().find(|g| g["name"] == w["name"]) else {
|
||||
problems.push(format!("{name}: body {bname} missing"));
|
||||
continue;
|
||||
};
|
||||
for k in ["volume_mm3", "area_mm2"] {
|
||||
let (a, b) = (g[k].as_f64().unwrap_or(f64::NAN), w[k].as_f64().unwrap_or(f64::NAN));
|
||||
if !close(a, b, REL) {
|
||||
problems.push(format!("{name}: {bname} {k} {a} (saved {b})"));
|
||||
}
|
||||
}
|
||||
if known(&name, &format!("{bname} bbox")) {
|
||||
continue;
|
||||
}
|
||||
for (end, i) in [("min", 0), ("min", 1), ("min", 2), ("max", 0), ("max", 1), ("max", 2)] {
|
||||
let (a, b) = (g["bbox"][end][i].as_f64().unwrap_or(f64::NAN), w["bbox"][end][i].as_f64().unwrap_or(f64::NAN));
|
||||
if (a - b).abs() > 1e-3 * b.abs().max(1.0) {
|
||||
problems.push(format!("{name}: {bname} bbox {end}[{i}] {a} (saved {b})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Saved again, it reads back the same.
|
||||
let again = solvecraft_engine::doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(again, *s.doc, "{name}: round trip");
|
||||
}
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
/// A small deterministic generator (xorshift).
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
const HOSTILE: [&str; 14] =
|
||||
["0", "-1", "1e308", "-1e308", "1e9", "4294967297", "\"\"", "\"1/0\"", "\"((((1\"", "null", "true", "[]", "{}", "\"nope\""];
|
||||
|
||||
/// Every JSON value in a document, as a path of keys and indices.
|
||||
fn paths(v: &Value, at: &mut Vec<Value>, out: &mut Vec<Vec<Value>>) {
|
||||
out.push(at.clone());
|
||||
match v {
|
||||
Value::Object(m) => {
|
||||
for (k, x) in m {
|
||||
at.push(json!(k));
|
||||
paths(x, at, out);
|
||||
at.pop();
|
||||
}
|
||||
}
|
||||
Value::Array(a) => {
|
||||
for (i, x) in a.iter().enumerate() {
|
||||
at.push(json!(i));
|
||||
paths(x, at, out);
|
||||
at.pop();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn at_mut<'a>(v: &'a mut Value, path: &[Value]) -> Option<&'a mut Value> {
|
||||
let mut cur = v;
|
||||
for k in path {
|
||||
cur = match k {
|
||||
Value::String(s) => cur.get_mut(s.as_str())?,
|
||||
Value::Number(n) => cur.get_mut(n.as_u64()? as usize)?,
|
||||
_ => return None,
|
||||
};
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
/// Open a file's bytes as File → Open does and evaluate them; any panic is a failure.
|
||||
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("document.inspect", &json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_and_hostile_design_files_never_panic() {
|
||||
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
|
||||
let mut failures = Vec::new();
|
||||
for p in corpus() {
|
||||
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let bytes = std::fs::read(&p).unwrap();
|
||||
let doc: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
let mut all = Vec::new();
|
||||
paths(&doc, &mut Vec::new(), &mut all);
|
||||
let mut cases: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
// Truncated and bit-flipped files.
|
||||
for _ in 0..8 {
|
||||
let cut = rng.below(bytes.len());
|
||||
cases.push((format!("truncated at {cut}"), bytes[..cut].to_vec()));
|
||||
let mut b = bytes.clone();
|
||||
let i = rng.below(b.len());
|
||||
b[i] ^= 1 << rng.below(8);
|
||||
cases.push((format!("bit flip at {i}"), b));
|
||||
}
|
||||
// Values replaced by hostile ones, and keys removed.
|
||||
for _ in 0..25 {
|
||||
let path = &all[rng.below(all.len())];
|
||||
let mut v = doc.clone();
|
||||
let h = HOSTILE[rng.below(HOSTILE.len())];
|
||||
if let Some(slot) = at_mut(&mut v, path) {
|
||||
*slot = serde_json::from_str(h).unwrap();
|
||||
}
|
||||
cases.push((format!("{path:?} = {h}"), v.to_string().into_bytes()));
|
||||
let mut v = doc.clone();
|
||||
if let Some((last, parent)) = path.split_last()
|
||||
&& let (Some(Value::Object(m)), Value::String(k)) = (at_mut(&mut v, parent), last)
|
||||
{
|
||||
m.remove(k);
|
||||
}
|
||||
cases.push((format!("{path:?} removed"), v.to_string().into_bytes()));
|
||||
}
|
||||
// Features repeated many times; deep nesting; not JSON at all.
|
||||
let mut v = doc.clone();
|
||||
if let Some(Value::Array(fs)) = v.get_mut("features") {
|
||||
let copy = fs.clone();
|
||||
for _ in 0..2 {
|
||||
fs.extend(copy.iter().cloned());
|
||||
}
|
||||
}
|
||||
cases.push(("features repeated".into(), v.to_string().into_bytes()));
|
||||
cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes()));
|
||||
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
|
||||
for (what, b) in cases {
|
||||
if std::env::var_os("CORPUS_TRACE").is_some() {
|
||||
eprintln!("{name}: {what}");
|
||||
}
|
||||
if std::panic::catch_unwind(|| try_bytes(&b)).is_err() {
|
||||
failures.push(format!("{name}: {what}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "panics:\n{}", failures.join("\n"));
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//! Kill the process while it saves: the design file is always the old version or the new one,
|
||||
//! whole, and opens.
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
fn round_of(path: &std::path::Path) -> Option<String> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let doc = solvecraft_engine::io::read_design(&bytes).ok()?;
|
||||
doc.params.iter().find(|p| p.name == "save_round").map(|p| p.expr.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn killed_mid_save_the_old_file_survives() {
|
||||
let exe = env!("CARGO_BIN_EXE_solvecraft-cli");
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-kill-mid-save-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("design.solvecraft");
|
||||
let ok = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "1"]).status().unwrap();
|
||||
assert!(ok.success());
|
||||
assert_eq!(round_of(&path).as_deref(), Some("0"));
|
||||
let mut kills = 0;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for i in 0..25u64 {
|
||||
let mut child = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "100000"]).spawn().unwrap();
|
||||
// Let it get into its save loop (building the design takes a moment), then kill it.
|
||||
std::thread::sleep(Duration::from_millis(150 + (i * 37) % 200));
|
||||
if child.try_wait().unwrap().is_none() {
|
||||
child.kill().unwrap();
|
||||
kills += 1;
|
||||
}
|
||||
let _ = child.wait();
|
||||
let round = round_of(&path);
|
||||
assert!(round.is_some(), "after kill {i} the design file is torn or missing");
|
||||
seen.insert(round);
|
||||
let bak = solvecraft_engine::io::backup_path(&path);
|
||||
if bak.exists() {
|
||||
assert!(round_of(&bak).is_some(), "after kill {i} the backup is torn");
|
||||
}
|
||||
}
|
||||
assert!(kills > 20, "the saver was killed while running ({kills})");
|
||||
assert!(seen.len() > 5, "the saver got to saving before it was killed ({seen:?})");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
[package]
|
||||
name = "solvecraft-web"
|
||||
description = "SolveCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "solvecraft-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
solvecraft-engine = { workspace = true }
|
||||
solvecraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
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",
|
||||
] }
|
||||
@@ -1,10 +0,0 @@
|
||||
# Build with: trunk build --release (from apps/solvecraft-web)
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "../../dist/web"
|
||||
public_url = "./"
|
||||
filehash = true
|
||||
|
||||
[serve]
|
||||
addresses = ["127.0.0.1"]
|
||||
port = 8781
|
||||
@@ -1,19 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>SolveCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="solvecraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e8ebf0; }
|
||||
#solvecraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#solvecraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: #4a5060; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="solvecraft_canvas"></canvas>
|
||||
<div id="solvecraft_loading">Loading SolveCraft…</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
//! SolveCraft in the browser.
|
||||
//!
|
||||
//! Runs the same [`solvecraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the
|
||||
//! 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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.app.logic(ctx);
|
||||
self.files_tick(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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(PREFS_KEY, self.app.prefs());
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the preferences (theme) are kept (the browser's local storage).
|
||||
const PREFS_KEY: &str = "solvecraft.prefs";
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(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;
|
||||
};
|
||||
let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() };
|
||||
if query().contains("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
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);
|
||||
if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs, DEPTH_BITS, 1);
|
||||
}
|
||||
if query().contains("sample") {
|
||||
if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) {
|
||||
app.set_status(format!("sample: {e}"), true);
|
||||
}
|
||||
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,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
Err(e) => {
|
||||
el.set_inner_html(&format!("<p>SolveCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
web::start();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("solvecraft-web only runs in the browser: build it with `trunk build --release` in apps/solvecraft-web");
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "solvecraft"
|
||||
description = "SolveCraft desktop app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
default-run = "solvecraft"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solvecraft-engine = { workspace = true }
|
||||
solvecraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
@@ -1,62 +0,0 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! Agents and tests drive the app through it (adapted from CADCraft, MIT OR Apache-2.0).
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_ui_egui::ControlRequest;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("solvecraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("solvecraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
//! SolveCraft desktop app.
|
||||
//!
|
||||
//! Usage: `solvecraft [--control <port>] [--sample] [design.solvecraft | part.step]`
|
||||
//!
|
||||
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
|
||||
//! `.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":{…}}}`.
|
||||
//! 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)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod control_server;
|
||||
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_ui_egui::{Services, SolveApp};
|
||||
|
||||
struct App(SolveApp);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.0.logic(ctx);
|
||||
if self.0.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
}
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(PREFS_KEY, self.0.prefs());
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
solvecraft_ui_egui::recovery_ui::close(&mut self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the preferences (theme) are kept in eframe's storage.
|
||||
const PREFS_KEY: &str = "solvecraft.prefs";
|
||||
|
||||
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("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()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
|
||||
rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The anti-aliasing preference, kept next to eframe's storage (it is needed before the
|
||||
/// window opens).
|
||||
fn graphics_path() -> Option<std::path::PathBuf> {
|
||||
eframe::storage_dir("SolveCraft").map(|d| d.join("graphics.json"))
|
||||
}
|
||||
|
||||
/// Anti-aliasing samples from Preferences (4 when unset).
|
||||
fn msaa() -> u16 {
|
||||
graphics_path()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.and_then(|v| v.get("msaa").and_then(serde_json::Value::as_u64))
|
||||
.and_then(|m| u16::try_from(m).ok())
|
||||
.filter(|m| [1, 2, 4, 8].contains(m))
|
||||
.unwrap_or(MSAA)
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the application bar is the title bar
|
||||
/// (`solvecraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated bar.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
const MSAA: u16 = 4;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let samples = msaa();
|
||||
let mut control_port: Option<u16> = std::env::var("SOLVECRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--sample" => sample = true,
|
||||
"--version" | "-V" => {
|
||||
println!("solvecraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("usage: solvecraft [--control PORT] [--sample] [design.solvecraft | part.step]");
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("SolveCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!CUSTOM_TITLEBAR)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id("ai.storyteller.solvecraft"),
|
||||
depth_buffer: DEPTH_BITS,
|
||||
multisampling: samples,
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
"SolveCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = SolveApp::new(Session::default(), services());
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs, DEPTH_BITS, u32::from(samples));
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
if sample {
|
||||
if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) {
|
||||
app.set_status(format!("sample: {e}"), true);
|
||||
}
|
||||
app.session.doc_mut().name = "Sample Plate".into();
|
||||
app.session.mark_saved();
|
||||
app.session.undo.clear();
|
||||
}
|
||||
for f in &files {
|
||||
app.open_path(f);
|
||||
}
|
||||
// No design given: the start page (not when driven over the control channel, whose
|
||||
// clients expect the design view; `ui.home` shows it there).
|
||||
if files.is_empty() && !sample && control_port.is_none() {
|
||||
let rev = app.session.revision;
|
||||
app.home.show(rev);
|
||||
}
|
||||
// Autosave, and offer what crashed sessions left behind.
|
||||
if let Some(dir) = solvecraft_engine::recovery::default_dir() {
|
||||
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
|
||||
solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
||||
}
|
||||
Ok(Box::new(App(app)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
@@ -1,19 +0,0 @@
|
||||
[package]
|
||||
name = "solvecraft-doc"
|
||||
description = "SolveCraft document model: parameters and expressions, sketches and features on a parametric timeline, incremental evaluation"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solvecraft-geom = { workspace = true }
|
||||
solvecraft-sketch = { workspace = true }
|
||||
solvecraft-kernel = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
@@ -1,258 +0,0 @@
|
||||
//! Appearances: how bodies, faces and components look (colour and opacity, with a name), kept
|
||||
//! in the design. A face's appearance wins over its body's, a body's over its component's, and
|
||||
//! a component's over the physical material's look; a body with none of these keeps the colour
|
||||
//! it was imported with.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::Document;
|
||||
|
||||
/// A look: a name (a library entry's, or "Custom"), sRGB colour and opacity (1 = opaque).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Look {
|
||||
pub name: String,
|
||||
pub color: [u8; 3],
|
||||
#[serde(default = "opaque")]
|
||||
pub opacity: f64,
|
||||
}
|
||||
|
||||
fn opaque() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl Look {
|
||||
pub fn custom(color: [u8; 3], opacity: f64) -> Look {
|
||||
Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0) }
|
||||
}
|
||||
/// Colour as 0–1 fractions.
|
||||
pub fn rgb(&self) -> [f32; 3] {
|
||||
self.color.map(|x| x as f32 / 255.0)
|
||||
}
|
||||
pub fn hex(&self) -> String {
|
||||
format!("#{:02x}{:02x}{:02x}", self.color[0], self.color[1], self.color[2])
|
||||
}
|
||||
}
|
||||
|
||||
/// A face's appearance: the face of `body` through `point` (world, on the face).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FaceLook {
|
||||
pub body: String,
|
||||
pub point: Vec3,
|
||||
pub look: Look,
|
||||
/// The face's persistent name (found first).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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 {
|
||||
/// By body name.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub bodies: BTreeMap<String, Look>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub faces: Vec<FaceLook>,
|
||||
/// By component id.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub components: BTreeMap<u64, Look>,
|
||||
}
|
||||
|
||||
impl Appearances {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.bodies.is_empty() && self.faces.is_empty() && self.components.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in appearance library: (name, colour, opacity).
|
||||
pub const LIBRARY: [(&str, [u8; 3], f64); 20] = [
|
||||
("Aluminum - Satin", [204, 208, 214], 1.0),
|
||||
("Aluminum - Anodized Black", [44, 46, 50], 1.0),
|
||||
("Aluminum - Anodized Blue", [52, 92, 160], 1.0),
|
||||
("Steel - Satin", [158, 164, 172], 1.0),
|
||||
("Stainless Steel - Brushed", [186, 191, 198], 1.0),
|
||||
("Cast Iron", [104, 104, 110], 1.0),
|
||||
("Brass - Polished", [208, 172, 88], 1.0),
|
||||
("Copper", [204, 122, 80], 1.0),
|
||||
("Titanium", [150, 148, 158], 1.0),
|
||||
("ABS - White", [236, 236, 230], 1.0),
|
||||
("ABS - Black", [36, 36, 38], 1.0),
|
||||
("ABS - Red", [196, 40, 40], 1.0),
|
||||
("ABS - Blue", [40, 90, 186], 1.0),
|
||||
("ABS - Yellow", [236, 196, 40], 1.0),
|
||||
("ABS - Green", [52, 150, 80], 1.0),
|
||||
("Rubber - Black", [28, 28, 28], 1.0),
|
||||
("Glass - Clear", [190, 220, 228], 0.25),
|
||||
("Glass - Smoked", [90, 96, 104], 0.45),
|
||||
("Wood - Oak", [190, 146, 92], 1.0),
|
||||
("Paint - Gloss White", [246, 246, 244], 1.0),
|
||||
];
|
||||
|
||||
/// A library appearance by name (case-insensitive).
|
||||
pub fn library(name: &str) -> Option<Look> {
|
||||
LIBRARY.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())).map(|(n, c, o)| Look { name: n.to_string(), color: *c, opacity: *o })
|
||||
}
|
||||
|
||||
/// The look a physical material gives a body that has no appearance of its own.
|
||||
pub fn material_look(material: &str) -> Option<Look> {
|
||||
let color = crate::material_color(material)?;
|
||||
let opacity = if material == "Glass" { 0.25 } else { 1.0 };
|
||||
Some(Look { name: material.to_string(), color, opacity })
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// A body's look (without face appearances): its own, else its component's, else its
|
||||
/// material's.
|
||||
pub fn body_look(&self, name: &str, feature: u64) -> Option<Look> {
|
||||
self.appearances
|
||||
.bodies
|
||||
.get(name)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
let c = self.body_component(name, feature);
|
||||
(c != 0).then(|| self.appearances.components.get(&c).cloned()).flatten()
|
||||
})
|
||||
.or_else(|| self.materials.get(name).and_then(|m| material_look(m)))
|
||||
}
|
||||
|
||||
/// Face appearances on a body: (point on the face, look).
|
||||
pub fn face_looks(&self, body: &str) -> Vec<(Vec3, Look)> {
|
||||
self.appearances.faces.iter().filter(|f| f.body == body).map(|f| (f.point, f.look.clone())).collect()
|
||||
}
|
||||
|
||||
/// The faces of a body with appearances of their own: (B-rep face index, look). A later
|
||||
/// 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 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.
|
||||
if let Some(n) = &f.name {
|
||||
let stem = crate::naming::strip_piece(n);
|
||||
let hits: Vec<usize> = (0..names.len()).filter(|i| names.get(*i).is_some_and(|x| crate::naming::strip_piece(x) == stem)).collect();
|
||||
if !hits.is_empty() {
|
||||
for i in hits {
|
||||
out.retain(|(j, _)| *j != i);
|
||||
out.push((i, look.clone()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(i) = face_index_at(b, p) {
|
||||
out.retain(|(j, _)| *j != i);
|
||||
out.push((i, look));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The B-rep face of a body nearest a point (within 5% of the body's size).
|
||||
pub fn face_index_at(b: &crate::ModelBody, p: Vec3) -> Option<usize> {
|
||||
let m = b.mesh();
|
||||
let mut best: Option<(f64, u32)> = None;
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let Some([a, bb, c]) = m.tri(t) else { continue };
|
||||
let d = closest_on_triangle(p, a, bb, c).dist(p);
|
||||
if best.is_none_or(|(x, _)| d < x) {
|
||||
best = Some((d, *f));
|
||||
}
|
||||
}
|
||||
let (d, f) = best?;
|
||||
(d <= b.body.size() * 0.05 + 1e-6).then_some(f as usize)
|
||||
}
|
||||
|
||||
/// The point of triangle abc nearest p.
|
||||
fn closest_on_triangle(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
let (ab, ac, ap) = (b - a, c - a, p - a);
|
||||
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
|
||||
if d1 <= 0.0 && d2 <= 0.0 {
|
||||
return a;
|
||||
}
|
||||
let bp = p - b;
|
||||
let (d3, d4) = (ab.dot(bp), ac.dot(bp));
|
||||
if d3 >= 0.0 && d4 <= d3 {
|
||||
return b;
|
||||
}
|
||||
let vc = d1 * d4 - d3 * d2;
|
||||
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
|
||||
return a + ab * (d1 / (d1 - d3));
|
||||
}
|
||||
let cp = p - c;
|
||||
let (d5, d6) = (ab.dot(cp), ac.dot(cp));
|
||||
if d6 >= 0.0 && d5 <= d6 {
|
||||
return c;
|
||||
}
|
||||
let vb = d5 * d2 - d1 * d6;
|
||||
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
|
||||
return a + ac * (d2 / (d2 - d6));
|
||||
}
|
||||
let va = d3 * d6 - d5 * d4;
|
||||
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
|
||||
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
|
||||
}
|
||||
let den = va + vb + vc;
|
||||
if den.abs() < 1e-300 {
|
||||
return a;
|
||||
}
|
||||
a + ab * (vb / den) + ac * (vc / den)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn library_and_materials() {
|
||||
assert_eq!(library("abs - red").map(|l| l.color), Some([196, 40, 40]));
|
||||
assert!(library("Glass - Clear").is_some_and(|l| l.opacity < 0.5));
|
||||
assert!(library("nope").is_none());
|
||||
assert_eq!(material_look("Brass").map(|l| l.color), crate::material_color("Brass"));
|
||||
assert!(material_look("Glass").is_some_and(|l| l.opacity < 1.0));
|
||||
assert_eq!(Look::custom([1, 2, 3], 7.0).opacity, 1.0);
|
||||
assert_eq!(Look::custom([255, 0, 16], 1.0).hex(), "#ff0010");
|
||||
}
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
//! Components and occurrences: the design as a tree.
|
||||
//!
|
||||
//! The root component (id 0) is the design. Every other component lives in a parent component
|
||||
//! through one or more occurrences, each with a rigid transform (instances share the component's
|
||||
//! contents; a "Paste New" copy is a separate component). A component's sketches, construction
|
||||
//! geometry and features are authored in its own frame (its origin); the model is evaluated in
|
||||
//! those frames and placed in the world through the occurrence transforms when bodies are
|
||||
//! gathered for display, measuring and export (`world_bodies`). Commands take world coordinates
|
||||
//! and the engine maps a new feature's geometry into the active component's frame.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::{Plane, Vec3};
|
||||
|
||||
use crate::{AxisRef, DocError, Document, FeatureKind, PatternKind, PlaneRef, Result};
|
||||
|
||||
/// Column-major 4×4 affine transform (`m[col][row]`).
|
||||
pub type Mat = [[f64; 4]; 4];
|
||||
|
||||
pub const IDENTITY: Mat = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
|
||||
|
||||
pub fn mat_mul(a: &Mat, b: &Mat) -> Mat {
|
||||
let mut r = [[0.0; 4]; 4];
|
||||
for (c, col) in r.iter_mut().enumerate() {
|
||||
for (row, cell) in col.iter_mut().enumerate() {
|
||||
*cell = (0..4).map(|k| a[k][row] * b[c][k]).sum();
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
pub fn apply_point(m: &Mat, p: Vec3) -> Vec3 {
|
||||
Vec3::new(
|
||||
m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z + m[3][0],
|
||||
m[0][1] * p.x + m[1][1] * p.y + m[2][1] * p.z + m[3][1],
|
||||
m[0][2] * p.x + m[1][2] * p.y + m[2][2] * p.z + m[3][2],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn apply_vector(m: &Mat, v: Vec3) -> Vec3 {
|
||||
Vec3::new(
|
||||
m[0][0] * v.x + m[1][0] * v.y + m[2][0] * v.z,
|
||||
m[0][1] * v.x + m[1][1] * v.y + m[2][1] * v.z,
|
||||
m[0][2] * v.x + m[1][2] * v.y + m[2][2] * v.z,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_identity(m: &Mat) -> bool {
|
||||
m.iter().flatten().zip(IDENTITY.iter().flatten()).all(|(a, b)| (a - b).abs() < 1e-12)
|
||||
}
|
||||
|
||||
/// Inverse of an affine transform.
|
||||
pub fn mat_inverse(m: &Mat) -> Option<Mat> {
|
||||
let a = |r: usize, c: usize| m[c][r];
|
||||
let det = a(0, 0) * (a(1, 1) * a(2, 2) - a(1, 2) * a(2, 1)) - a(0, 1) * (a(1, 0) * a(2, 2) - a(1, 2) * a(2, 0))
|
||||
+ a(0, 2) * (a(1, 0) * a(2, 1) - a(1, 1) * a(2, 0));
|
||||
if det.abs() < 1e-14 || !det.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let inv = |r: usize, c: usize| -> f64 {
|
||||
// Cofactor of (c, r) / det.
|
||||
let (r0, r1) = match c {
|
||||
0 => (1, 2),
|
||||
1 => (0, 2),
|
||||
_ => (0, 1),
|
||||
};
|
||||
let (c0, c1) = match r {
|
||||
0 => (1, 2),
|
||||
1 => (0, 2),
|
||||
_ => (0, 1),
|
||||
};
|
||||
let minor = a(r0, c0) * a(r1, c1) - a(r0, c1) * a(r1, c0);
|
||||
let sign = if (r + c).is_multiple_of(2) { 1.0 } else { -1.0 };
|
||||
sign * minor / det
|
||||
};
|
||||
let mut out = IDENTITY;
|
||||
for (c, col) in out.iter_mut().enumerate().take(3) {
|
||||
for (r, cell) in col.iter_mut().enumerate().take(3) {
|
||||
*cell = inv(r, c);
|
||||
}
|
||||
}
|
||||
let t = Vec3::new(m[3][0], m[3][1], m[3][2]);
|
||||
let it = apply_vector(&out, t);
|
||||
out[3] = [-it.x, -it.y, -it.z, 1.0];
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A rigid transform: rotation by `angle` about `axis` through `origin`, then a translation.
|
||||
pub fn rigid(translate: Vec3, origin: Vec3, axis: Vec3, angle: f64) -> Mat {
|
||||
let k = axis.normalized().unwrap_or(Vec3::Z);
|
||||
let (s, c) = angle.sin_cos();
|
||||
let t = 1.0 - c;
|
||||
let r = [
|
||||
[t * k.x * k.x + c, t * k.x * k.y + s * k.z, t * k.x * k.z - s * k.y],
|
||||
[t * k.x * k.y - s * k.z, t * k.y * k.y + c, t * k.y * k.z + s * k.x],
|
||||
[t * k.x * k.z + s * k.y, t * k.y * k.z - s * k.x, t * k.z * k.z + c],
|
||||
];
|
||||
let mut m = IDENTITY;
|
||||
for (mc, rc) in m.iter_mut().zip(r) {
|
||||
mc[..3].copy_from_slice(&rc);
|
||||
}
|
||||
let ro = apply_vector(&m, origin);
|
||||
let tr = origin - ro + translate;
|
||||
m[3] = [tr.x, tr.y, tr.z, 1.0];
|
||||
m
|
||||
}
|
||||
|
||||
/// A placement of a component inside a parent component.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Occurrence {
|
||||
pub id: u64,
|
||||
pub component: u64,
|
||||
/// The component it sits in (0 = the root).
|
||||
#[serde(default)]
|
||||
pub parent: u64,
|
||||
pub name: String,
|
||||
#[serde(default = "identity")]
|
||||
pub transform: Mat,
|
||||
/// Grounded occurrences don't move.
|
||||
#[serde(default)]
|
||||
pub grounded: bool,
|
||||
}
|
||||
|
||||
fn identity() -> Mat {
|
||||
IDENTITY
|
||||
}
|
||||
|
||||
fn map_plane(m: &Mat, p: &Plane) -> Plane {
|
||||
let (o, x, y) = (apply_point(m, p.origin), apply_vector(m, p.x), apply_vector(m, p.y));
|
||||
Plane::new(o, x, y).unwrap_or(*p)
|
||||
}
|
||||
|
||||
fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
|
||||
match p {
|
||||
PlaneRef::Custom { plane } => *plane = map_plane(m, plane),
|
||||
PlaneRef::Face { plane, at, .. } => {
|
||||
*plane = map_plane(m, plane);
|
||||
*at = apply_point(m, *at);
|
||||
}
|
||||
PlaneRef::Offset { base, .. } => map_plane_ref(m, base),
|
||||
PlaneRef::AtAngle { base, axis_origin, axis_dir, .. } => {
|
||||
map_plane_ref(m, base);
|
||||
*axis_origin = apply_point(m, *axis_origin);
|
||||
*axis_dir = apply_vector(m, *axis_dir);
|
||||
}
|
||||
// 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::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::Draft { faces, neutral, pull, .. } => {
|
||||
faces.iter_mut().for_each(pt);
|
||||
map_plane_ref(m, neutral);
|
||||
dir(pull);
|
||||
}
|
||||
FeatureKind::Box { corner, .. } => pt(corner),
|
||||
FeatureKind::Cylinder { base, axis, .. } => {
|
||||
pt(base);
|
||||
dir(axis);
|
||||
}
|
||||
FeatureKind::Sphere { center, .. } | FeatureKind::Torus { center, .. } => pt(center),
|
||||
FeatureKind::Pattern { pattern: PatternKind::Rectangular { dir1, dir2, .. }, .. } => {
|
||||
dir(dir1);
|
||||
if let Some(d) = dir2 {
|
||||
dir(d);
|
||||
}
|
||||
}
|
||||
FeatureKind::Pattern { pattern: PatternKind::Circular { origin, axis, .. }, .. } => {
|
||||
pt(origin);
|
||||
dir(axis);
|
||||
}
|
||||
// Follows its sketch.
|
||||
FeatureKind::Pattern { pattern: PatternKind::Path { .. }, .. } => {}
|
||||
FeatureKind::Hole { position, direction, .. } => {
|
||||
pt(position);
|
||||
dir(direction);
|
||||
}
|
||||
FeatureKind::Thread { face, .. } => pt(face),
|
||||
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SheetConvert { face, .. } => pt(face),
|
||||
FeatureKind::SheetFold { a, b, fixed, .. } => {
|
||||
pt(a);
|
||||
pt(b);
|
||||
if let Some(f) = fixed {
|
||||
pt(f);
|
||||
}
|
||||
}
|
||||
FeatureKind::Boss { position, direction, .. } => {
|
||||
pt(position);
|
||||
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);
|
||||
if let Some(a) = along {
|
||||
dir(a);
|
||||
}
|
||||
}
|
||||
FeatureKind::SnapFit { position, direction, hook, .. } => {
|
||||
pt(position);
|
||||
dir(direction);
|
||||
dir(hook);
|
||||
}
|
||||
FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetUnfold { .. } => {}
|
||||
FeatureKind::Coil { base, axis, .. } => {
|
||||
pt(base);
|
||||
dir(axis);
|
||||
}
|
||||
FeatureKind::ReplaceFace { faces, target, .. } => {
|
||||
faces.iter_mut().for_each(pt);
|
||||
map_plane_ref(m, target);
|
||||
}
|
||||
FeatureKind::Align { from, to, from_normal, to_normal, .. } => {
|
||||
pt(from);
|
||||
pt(to);
|
||||
if let Some(n) = from_normal {
|
||||
dir(n);
|
||||
}
|
||||
if let Some(n) = to_normal {
|
||||
dir(n);
|
||||
}
|
||||
}
|
||||
FeatureKind::Scale { origin, .. } => pt(origin),
|
||||
FeatureKind::Move { rotate_axis, .. } => {
|
||||
if let Some(a) = rotate_axis {
|
||||
dir(a);
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { .. }
|
||||
| FeatureKind::Extrude { .. }
|
||||
| FeatureKind::Combine { .. }
|
||||
| FeatureKind::Loft { .. }
|
||||
| FeatureKind::Sweep { .. }
|
||||
| FeatureKind::BoundingSolid { .. }
|
||||
| FeatureKind::Pipe { .. }
|
||||
| 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The first occurrence of a component (its placement for authoring).
|
||||
pub fn occurrence_of(&self, component: u64) -> Option<&crate::Occurrence> {
|
||||
self.occurrences.iter().find(|o| o.component == component)
|
||||
}
|
||||
|
||||
/// World transform of a component's frame (through its first occurrence and its parents').
|
||||
pub fn component_transform(&self, component: u64) -> Mat {
|
||||
let mut m = IDENTITY;
|
||||
let mut c = component;
|
||||
for _ in 0..1000 {
|
||||
if c == 0 {
|
||||
break;
|
||||
}
|
||||
let Some(o) = self.occurrence_of(c) else { break };
|
||||
m = mat_mul(&o.transform, &m);
|
||||
c = o.parent;
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Every placement of each component: (component, occurrence path ids, world transform).
|
||||
pub fn placements(&self) -> Vec<(u64, Vec<u64>, Mat)> {
|
||||
let mut out = vec![(0, Vec::new(), IDENTITY)];
|
||||
let mut stack: Vec<(u64, Vec<u64>, Mat)> = vec![(0, Vec::new(), IDENTITY)];
|
||||
while let Some((comp, path, m)) = stack.pop() {
|
||||
if path.len() > 64 || out.len() > 100_000 {
|
||||
break;
|
||||
}
|
||||
for o in self.occurrences.iter().filter(|o| o.parent == comp) {
|
||||
let mut p = path.clone();
|
||||
p.push(o.id);
|
||||
let w = mat_mul(&m, &o.transform);
|
||||
out.push((o.component, p.clone(), w));
|
||||
stack.push((o.component, p, w));
|
||||
}
|
||||
}
|
||||
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;
|
||||
for _ in 0..1000 {
|
||||
if c == b {
|
||||
return true;
|
||||
}
|
||||
if c == 0 {
|
||||
return false;
|
||||
}
|
||||
match self.components.iter().find(|x| x.id == c) {
|
||||
Some(x) => c = x.parent,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Add an occurrence of an existing component into `parent`.
|
||||
pub fn add_occurrence(&mut self, component: u64, parent: u64, transform: Mat) -> Result<u64> {
|
||||
if component == 0 || !self.components.iter().any(|c| c.id == component) {
|
||||
return Err(DocError::Unknown(format!("component {component}")));
|
||||
}
|
||||
if self.component_within(parent, component) {
|
||||
return Err(DocError::Invalid("a component can't contain itself".into()));
|
||||
}
|
||||
if self.occurrences.len() >= 100_000 {
|
||||
return Err(DocError::Invalid("too many occurrences".into()));
|
||||
}
|
||||
let id = self.next_id.max(1);
|
||||
self.next_id = id + 1;
|
||||
let cname = self.components.iter().find(|c| c.id == component).map(|c| c.name.clone()).unwrap_or_default();
|
||||
let k = self.occurrences.iter().filter(|o| o.component == component).count() + 1;
|
||||
self.occurrences.push(crate::Occurrence { id, component, parent, name: format!("{cname}:{k}"), transform, grounded: false });
|
||||
Ok(id)
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
//! Canvases: reference images placed on a plane (Fusion's Insert Canvas), kept in the design.
|
||||
//! The image bytes travel inside the design file (base64), so it reopens without the original.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::{Plane, Vec2, Vec3};
|
||||
|
||||
use crate::PlaneRef;
|
||||
|
||||
/// Largest image a canvas may hold (bytes).
|
||||
pub const MAX_CANVAS_BYTES: usize = 16 << 20;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Canvas {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// `png` or `jpeg`.
|
||||
pub format: String,
|
||||
/// The image file, base64.
|
||||
pub data: String,
|
||||
/// Pixel size of the image.
|
||||
pub pixels: [u32; 2],
|
||||
pub plane: PlaneRef,
|
||||
/// Centre of the image on the plane (plane coordinates, mm).
|
||||
pub center: Vec2,
|
||||
/// Width on the plane (mm); the height follows the image's aspect.
|
||||
pub width: f64,
|
||||
/// Rotation on the plane (radians).
|
||||
#[serde(default)]
|
||||
pub angle: f64,
|
||||
#[serde(default = "half")]
|
||||
pub opacity: f64,
|
||||
/// Mirrored left to right.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub flip: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
fn half() -> f64 {
|
||||
0.5
|
||||
}
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Canvas {
|
||||
pub fn height(&self) -> f64 {
|
||||
let [w, h] = self.pixels;
|
||||
if w == 0 { self.width } else { self.width * f64::from(h) / f64::from(w) }
|
||||
}
|
||||
/// The four corners on the plane (plane coordinates): bottom-left, bottom-right, top-right,
|
||||
/// top-left of the image.
|
||||
pub fn corners(&self) -> [Vec2; 4] {
|
||||
let (hw, hh) = (self.width / 2.0, self.height() / 2.0);
|
||||
let (s, c) = self.angle.sin_cos();
|
||||
let rot = |x: f64, y: f64| self.center + Vec2::new(x * c - y * s, x * s + y * c);
|
||||
let f = if self.flip { -1.0 } else { 1.0 };
|
||||
[rot(-hw * f, -hh), rot(hw * f, -hh), rot(hw * f, hh), rot(-hw * f, hh)]
|
||||
}
|
||||
/// Corners in world coordinates.
|
||||
pub fn world_corners(&self, plane: &Plane) -> [Vec3; 4] {
|
||||
self.corners().map(|p| plane.to_world(p))
|
||||
}
|
||||
pub fn bytes(&self) -> Option<Vec<u8>> {
|
||||
base64_decode(&self.data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Image kind and pixel size from the file header (PNG or JPEG).
|
||||
pub fn image_info(b: &[u8]) -> Option<(&'static str, [u32; 2])> {
|
||||
if b.len() > 24 && b.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
let w = u32::from_be_bytes([*b.get(16)?, *b.get(17)?, *b.get(18)?, *b.get(19)?]);
|
||||
let h = u32::from_be_bytes([*b.get(20)?, *b.get(21)?, *b.get(22)?, *b.get(23)?]);
|
||||
return (w > 0 && h > 0).then_some(("png", [w, h]));
|
||||
}
|
||||
if b.starts_with(&[0xFF, 0xD8]) {
|
||||
// Walk the markers to a start-of-frame.
|
||||
let mut i = 2;
|
||||
while i + 9 < b.len() {
|
||||
if *b.get(i)? != 0xFF {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let m = *b.get(i + 1)?;
|
||||
if m == 0xD8 || m == 0x01 || (0xD0..=0xD7).contains(&m) || m == 0xFF {
|
||||
i += if m == 0xFF { 1 } else { 2 };
|
||||
continue;
|
||||
}
|
||||
let len = usize::from(u16::from_be_bytes([*b.get(i + 2)?, *b.get(i + 3)?]));
|
||||
if matches!(m, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF) {
|
||||
let h = u32::from(u16::from_be_bytes([*b.get(i + 5)?, *b.get(i + 6)?]));
|
||||
let w = u32::from(u16::from_be_bytes([*b.get(i + 7)?, *b.get(i + 8)?]));
|
||||
return (w > 0 && h > 0).then_some(("jpeg", [w, h]));
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
pub fn base64_encode(b: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(b.len().div_ceil(3) * 4);
|
||||
for c in b.chunks(3) {
|
||||
let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0));
|
||||
for k in 0..4 {
|
||||
if k <= c.len() {
|
||||
out.push(char::from(B64[((n >> (18 - 6 * k)) & 63) as usize]));
|
||||
} else {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(s.len() / 4 * 3);
|
||||
let (mut acc, mut bits) = (0u32, 0u32);
|
||||
for ch in s.bytes() {
|
||||
let v = match ch {
|
||||
b'A'..=b'Z' => ch - b'A',
|
||||
b'a'..=b'z' => ch - b'a' + 26,
|
||||
b'0'..=b'9' => ch - b'0' + 52,
|
||||
b'+' => 62,
|
||||
b'/' => 63,
|
||||
b'=' | b'\n' | b'\r' | b' ' => continue,
|
||||
_ => return None,
|
||||
};
|
||||
acc = (acc << 6) | u32::from(v);
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push(((acc >> bits) & 0xFF) as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_round_trip_and_headers() {
|
||||
for n in 0..10 {
|
||||
let b: Vec<u8> = (0..n).map(|i| (i * 37) as u8).collect();
|
||||
assert_eq!(base64_decode(&base64_encode(&b)).unwrap(), b);
|
||||
}
|
||||
assert_eq!(base64_encode(b"Man"), "TWFu");
|
||||
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
|
||||
png.extend(640u32.to_be_bytes());
|
||||
png.extend(480u32.to_be_bytes());
|
||||
png.extend([8, 6, 0, 0, 0]);
|
||||
assert_eq!(image_info(&png), Some(("png", [640, 480])));
|
||||
let jpg = [0xFF, 0xD8, 0xFF, 0xE0, 0, 4, 0, 0, 0xFF, 0xC0, 0, 11, 8, 0, 100, 0, 200, 3, 0, 0, 0];
|
||||
assert_eq!(image_info(&jpg), Some(("jpeg", [200, 100])));
|
||||
assert_eq!(image_info(b"nope"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//! Copies of features (timeline Copy / Paste): references to sketches and features inside the
|
||||
//! copied set are moved to the copies.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{FeatureKind, PatternKind, PlaneRef};
|
||||
|
||||
fn plane_names(p: &mut PlaneRef, names: &BTreeMap<String, String>) {
|
||||
let mut p = p;
|
||||
for _ in 0..64 {
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
|
||||
PlaneRef::Construction { name } => {
|
||||
if let Some(n) = names.get(name) {
|
||||
*name = n.clone();
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
/// Point references to sketches (by id) and features (by name) at their copies.
|
||||
pub fn remap_refs(&mut self, sketches: &BTreeMap<u64, u64>, names: &BTreeMap<String, String>) {
|
||||
let sk = |id: &mut u64| {
|
||||
if let Some(n) = sketches.get(id) {
|
||||
*id = *n;
|
||||
}
|
||||
};
|
||||
let feats = |list: &mut Vec<String>| {
|
||||
for x in list.iter_mut() {
|
||||
if let Some(n) = names.get(x) {
|
||||
*x = n.clone();
|
||||
}
|
||||
}
|
||||
};
|
||||
match self {
|
||||
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, .. }
|
||||
| FeatureKind::Rib { sketch, .. } => sk(sketch),
|
||||
FeatureKind::Sweep { sketch, path_sketch, .. } => {
|
||||
sk(sketch);
|
||||
sk(path_sketch);
|
||||
}
|
||||
FeatureKind::Pipe { path_sketch, .. } => sk(path_sketch),
|
||||
FeatureKind::SheetFold { sketch: Some(s), .. } => sk(s),
|
||||
FeatureKind::Loft { sections, .. } => sections.iter_mut().for_each(|s| sk(&mut s.sketch)),
|
||||
FeatureKind::Hole { points: Some(p), .. } => sk(&mut p.sketch),
|
||||
FeatureKind::Pattern { features, pattern, .. } => {
|
||||
feats(features);
|
||||
if let PatternKind::Path { path_sketch, .. } = pattern {
|
||||
sk(path_sketch);
|
||||
}
|
||||
}
|
||||
FeatureKind::Mirror { features, plane, .. } => {
|
||||
feats(features);
|
||||
plane_names(plane, names);
|
||||
}
|
||||
FeatureKind::Draft { neutral, .. } => plane_names(neutral, names),
|
||||
FeatureKind::ReplaceFace { target, .. } => plane_names(target, names),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
//! Configurations: a table whose rows are variants of the design. Each column is a parameter
|
||||
//! (its expression per row) or a feature (suppressed or not per row); activating a row sets
|
||||
//! those values in the design, which then rebuilds as usual.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Column {
|
||||
/// A parameter's expression.
|
||||
Param(String),
|
||||
/// A feature's suppression (by feature id).
|
||||
Suppress(u64),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConfigRow {
|
||||
pub name: String,
|
||||
/// One cell per column: an expression, or "true"/"false" for suppression.
|
||||
pub cells: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConfigTable {
|
||||
pub columns: Vec<Column>,
|
||||
pub rows: Vec<ConfigRow>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active: Option<String>,
|
||||
}
|
||||
|
||||
pub const MAX_COLUMNS: usize = 500;
|
||||
pub const MAX_ROWS: usize = 2000;
|
||||
|
||||
impl ConfigTable {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.columns.is_empty() && self.rows.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// A column's current value in the design.
|
||||
pub fn config_current(&self, c: &Column) -> Option<String> {
|
||||
match c {
|
||||
Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, _)| e),
|
||||
Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A column's display key: `param:<name>` or `suppress:<feature name>`.
|
||||
pub fn config_key(&self, c: &Column) -> String {
|
||||
match c {
|
||||
Column::Param(n) => format!("param:{n}"),
|
||||
Column::Suppress(id) => format!("suppress:{}", self.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| id.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the design to a row's values (all or nothing).
|
||||
pub fn apply_configuration(&mut self, row: &str) -> Result<()> {
|
||||
let r = self.configs.rows.iter().find(|r| r.name == row).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{row}`")))?;
|
||||
let mut next = self.clone();
|
||||
for (c, v) in self.configs.columns.iter().zip(&r.cells) {
|
||||
match c {
|
||||
Column::Param(n) => {
|
||||
if next.config_current(c).as_deref() != Some(v.as_str()) {
|
||||
next.change_param(n, v, None, None).map_err(|e| DocError::Invalid(format!("configuration `{row}`, parameter `{n}`: {e}")))?;
|
||||
}
|
||||
}
|
||||
Column::Suppress(id) => {
|
||||
let on = match v.trim() {
|
||||
"true" | "1" | "yes" => true,
|
||||
"false" | "0" | "no" => false,
|
||||
o => return Err(DocError::Invalid(format!("configuration `{row}`: suppression must be true or false, not `{o}`"))),
|
||||
};
|
||||
let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?;
|
||||
f.suppressed = on;
|
||||
}
|
||||
}
|
||||
}
|
||||
next.configs.active = Some(row.to_string());
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The active row's values all hold in the design.
|
||||
pub fn configuration_matches(&self) -> bool {
|
||||
let Some(r) = self.configs.active.as_ref().and_then(|a| self.configs.rows.iter().find(|r| r.name == *a)) else { return false };
|
||||
self.configs.columns.iter().zip(&r.cells).all(|(c, v)| match c {
|
||||
Column::Suppress(_) => self.config_current(c).as_deref() == Some(v.trim()),
|
||||
Column::Param(_) => self.config_current(c).is_some_and(|x| x.replace(' ', "") == v.replace(' ', "")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rows_switch_parameters() {
|
||||
let mut d = Document::new("A");
|
||||
d.set_param("w", "10 mm", None, None).unwrap();
|
||||
d.configs.columns.push(Column::Param("w".into()));
|
||||
d.configs.rows.push(ConfigRow { name: "Small".into(), cells: vec!["10 mm".into()] });
|
||||
d.configs.rows.push(ConfigRow { name: "Large".into(), cells: vec!["40 mm".into()] });
|
||||
d.apply_configuration("Large").unwrap();
|
||||
assert_eq!(d.config_current(&Column::Param("w".into())).as_deref(), Some("40 mm"));
|
||||
assert!(d.configuration_matches());
|
||||
d.set_param("w", "12 mm", None, None).unwrap();
|
||||
assert!(!d.configuration_matches());
|
||||
d.configs.rows.push(ConfigRow { name: "Bad".into(), cells: vec!["1/".into()] });
|
||||
let before = d.clone();
|
||||
assert!(d.apply_configuration("Bad").is_err());
|
||||
assert_eq!(d, before, "all or nothing");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,902 +0,0 @@
|
||||
//! Evaluation of Emboss, Rib/Web, Replace Face, Align and Remove (features built from the
|
||||
//! kernel's extrude, boolean, offset and transform operations).
|
||||
|
||||
use super::*;
|
||||
use crate::apply_point;
|
||||
use solvecraft_geom::{Loop2, Seg2};
|
||||
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
match &f.kind {
|
||||
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
|
||||
let regions = select_profiles(ss, profiles)?;
|
||||
let d = val(vals, depth, Kind::Length)?;
|
||||
if !(d > 0.0) {
|
||||
return Err(DocError::Invalid("the emboss depth must be positive".into()));
|
||||
}
|
||||
let (a, b) = if *deboss { (-d, 0.0) } else { (0.0, d) };
|
||||
let tools = kernel::extrude(&ss.plane, ®ions, a, b)?;
|
||||
if st.bodies.is_empty() {
|
||||
return Err(DocError::Invalid("there is no body to emboss".into()));
|
||||
}
|
||||
apply_op(st, f, tools, if *deboss { Operation::Cut } else { Operation::Join }, targets)
|
||||
}
|
||||
FeatureKind::Rib { sketch, curves, thickness, depth, flip, web } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
let t = val(vals, thickness, Kind::Length)?;
|
||||
if !(t > 0.0) {
|
||||
return Err(DocError::Invalid("the rib thickness must be positive".into()));
|
||||
}
|
||||
let depth = match depth {
|
||||
Some(e) => Some(val(vals, e, Kind::Length)?)
|
||||
.filter(|d| *d > 0.0)
|
||||
.ok_or_else(|| DocError::Invalid("the rib depth must be positive".into()))
|
||||
.map(Some)?,
|
||||
None => None,
|
||||
};
|
||||
let groups: Vec<Vec<String>> = if *web { curves.iter().map(|c| vec![c.clone()]).collect() } else { vec![curves.clone()] };
|
||||
if groups.is_empty() || curves.is_empty() || curves.len() > 1000 {
|
||||
return Err(DocError::Invalid("a rib needs 1…1000 sketch curves".into()));
|
||||
}
|
||||
let mut tools = Vec::new();
|
||||
for g in &groups {
|
||||
tools.push(rib_tool(st, &ss, g, t, depth, *flip)?);
|
||||
}
|
||||
apply_op(st, f, tools, Operation::Join, &[])
|
||||
}
|
||||
FeatureKind::ReplaceFace { faces, target, body } => {
|
||||
let tp = doc.resolve_plane_in(vals, target, 0, Some(st))?;
|
||||
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())) };
|
||||
let mut b = mb.body.clone();
|
||||
for p in faces {
|
||||
let n = planar_normal_at(&b, *p).ok_or_else(|| DocError::Invalid("not supported yet: replacing a face that is not planar".into()))?;
|
||||
if n.dot(tn).abs() < 1.0 - 1e-9 {
|
||||
return Err(DocError::Invalid("not supported yet: a target that is not parallel to the face".into()));
|
||||
}
|
||||
let d = (tp.origin - *p).dot(n);
|
||||
if d.abs() > 1e-9 {
|
||||
b = kernel::offset_faces(&b, &[*p], d)?;
|
||||
}
|
||||
}
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, b, mb.feature);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip } => {
|
||||
let mut m = translation(Vec3::ZERO);
|
||||
if let (Some(a), Some(b)) = (from_normal.and_then(Vec3::normalized), to_normal.and_then(Vec3::normalized)) {
|
||||
// Turn `a` onto −b (faces meet) or b (flipped), about the `from` point.
|
||||
let want = if *flip { b } else { b * -1.0 };
|
||||
let axis = a.cross(want);
|
||||
let ang = axis.len().atan2(a.dot(want));
|
||||
let axis = axis.normalized().unwrap_or_else(|| a.any_perp());
|
||||
if ang.abs() > 1e-12 {
|
||||
m = rotation(*from, axis, ang);
|
||||
}
|
||||
}
|
||||
let d = *to - *from;
|
||||
m[3][0] += d.x;
|
||||
m[3][1] += d.y;
|
||||
m[3][2] += d.z;
|
||||
if bodies.is_empty() {
|
||||
return Err(DocError::Invalid("no bodies to align".into()));
|
||||
}
|
||||
for n in bodies {
|
||||
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
|
||||
if let Some(mb) = st.bodies.get_mut(i) {
|
||||
let nb = kernel::transform_matrix(&mb.body, m)?;
|
||||
*mb = ModelBody::new(mb.name.clone(), nb, mb.feature);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Remove { bodies } => {
|
||||
if bodies.is_empty() {
|
||||
return Err(DocError::Invalid("no bodies to remove".into()));
|
||||
}
|
||||
for n in bodies {
|
||||
if st.body(n).is_none() {
|
||||
return Err(DocError::Unknown(format!("body `{n}`")));
|
||||
}
|
||||
}
|
||||
st.bodies.retain(|b| !bodies.contains(&b.name));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(DocError::Invalid(format!("{} is not evaluated here", f.name))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit outward normal of the planar face through `p`.
|
||||
fn planar_normal_at(b: &Body, p: Vec3) -> Option<Vec3> {
|
||||
let tol = (b.size() * 2e-3).max(1e-3);
|
||||
let faces = b.faces(tol).ok()?;
|
||||
faces
|
||||
.iter()
|
||||
.filter_map(|f| f.plane_normal.map(|n| (n, ((p - f.centroid).dot(n)).abs())))
|
||||
.filter(|(_, d)| *d < tol * 5.0)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(n, _)| n)
|
||||
}
|
||||
|
||||
/// The curves of a chain as one polyline in sketch coordinates, in order.
|
||||
fn chain_points(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec2>> {
|
||||
let mut segs: Vec<Seg2> = Vec::new();
|
||||
for id in ids {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?;
|
||||
segs.extend(ss.sketch.segs(ci));
|
||||
}
|
||||
let Some(first) = segs.first().copied() else { return Err(DocError::Invalid("no curves".into())) };
|
||||
// Orient the first segment toward the second, then chain.
|
||||
let mut chain = vec![first];
|
||||
if let Some(n) = segs.get(1)
|
||||
&& (first.start().dist(n.start()) < 1e-6 || first.start().dist(n.end()) < 1e-6)
|
||||
{
|
||||
chain[0] = first.reversed();
|
||||
}
|
||||
for s in segs.iter().skip(1) {
|
||||
let prev = chain.last().map(Seg2::end).unwrap_or_default();
|
||||
let s = if s.end().dist(prev) < s.start().dist(prev) { s.reversed() } else { *s };
|
||||
if s.start().dist(prev) > 1e-5 {
|
||||
return Err(DocError::Invalid("the rib curves are not connected end to end".into()));
|
||||
}
|
||||
chain.push(s);
|
||||
}
|
||||
let mut pts = vec![chain[0].start()];
|
||||
for s in &chain {
|
||||
let n = if matches!(s, Seg2::Line { .. }) { 1 } else { 24 };
|
||||
for k in 1..=n {
|
||||
pts.push(s.point_at(k as f64 / n as f64));
|
||||
}
|
||||
}
|
||||
Ok(pts)
|
||||
}
|
||||
|
||||
/// A rib wall: the band swept from the curves across the plane by `depth` (or to the body),
|
||||
/// `t` thick about the sketch plane.
|
||||
fn rib_tool(st: &ModelState, ss: &SolvedSketch, ids: &[String], t: f64, depth: Option<f64>, flip: bool) -> Result<Body> {
|
||||
let pts = chain_points(ss, ids)?;
|
||||
let (Some(a), Some(b)) = (pts.first().copied(), pts.last().copied()) else { return Err(DocError::Invalid("empty rib curve".into())) };
|
||||
let chord = (b - a).normalized().ok_or_else(|| DocError::Invalid("a closed or zero-length rib curve".into()))?;
|
||||
let mid = pts.get(pts.len() / 2).copied().unwrap_or(a);
|
||||
// Toward the material: the side of the curve where the bodies are (unless flipped).
|
||||
let mut n = chord.perp();
|
||||
let reach = st.bodies.iter().map(|b| b.mesh().bounds()).fold(0.0f64, |acc, bb| acc.max((bb.max - bb.min).len()));
|
||||
let probe = |side: Vec2| {
|
||||
let mut hits = 0;
|
||||
for k in 1..=20 {
|
||||
let q = ss.plane.to_world(mid + side * (reach * k as f64 / 20.0));
|
||||
if st.bodies.iter().any(|b| b.mesh().contains(q)) {
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
hits
|
||||
};
|
||||
if probe(n * -1.0) > probe(n) {
|
||||
n = n * -1.0;
|
||||
}
|
||||
if flip {
|
||||
n = n * -1.0;
|
||||
}
|
||||
// From each point of the curves, walk across the plane until the body begins (or `depth`);
|
||||
// walls that reach the body go a little into it so they merge with it.
|
||||
let inside = |q: Vec2| {
|
||||
let w = ss.plane.to_world(q);
|
||||
st.bodies.iter().any(|b| b.mesh().contains(w))
|
||||
};
|
||||
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let samples = ((len / (t.min(len) * 0.25).max(len / 400.0)).ceil() as usize).clamp(2, 400);
|
||||
let along: Vec<Vec2> = (0..=samples).map(|k| point_on_polyline(&pts, len * k as f64 / samples as f64)).collect();
|
||||
let limit = depth.unwrap_or(reach * 2.0 + 1.0);
|
||||
let step = (limit.min(reach * 2.0 + 1.0) / 400.0).max(1e-3);
|
||||
let overlap = (len / samples as f64 * 2.0).max(step * 2.0).min(t);
|
||||
let mut far = Vec::with_capacity(along.len());
|
||||
for q in &along {
|
||||
let mut hit = None;
|
||||
let mut k = 1;
|
||||
while (k as f64) * step <= limit + step && k < 100_000 {
|
||||
if inside(*q + n * (k as f64 * step)) {
|
||||
// Refine between the last outside and the first inside sample.
|
||||
let (mut lo, mut hi) = ((k - 1) as f64 * step, k as f64 * step);
|
||||
for _ in 0..30 {
|
||||
let m = 0.5 * (lo + hi);
|
||||
if inside(*q + n * m) { hi = m } else { lo = m }
|
||||
}
|
||||
hit = Some(hi);
|
||||
break;
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
let h = match (hit, depth) {
|
||||
(Some(h), Some(d)) if h >= d => d,
|
||||
(Some(h), _) => h + overlap,
|
||||
(None, Some(d)) => d,
|
||||
(None, None) => return Err(DocError::Invalid("the rib curves do not face a body (give a depth)".into())),
|
||||
};
|
||||
far.push(*q + n * h);
|
||||
}
|
||||
let mut poly = along.clone();
|
||||
poly.extend(far.into_iter().rev());
|
||||
let mut lp = Loop2::polygon(&poly);
|
||||
if lp.signed_area() < 0.0 {
|
||||
lp = lp.reversed();
|
||||
}
|
||||
let region = Region2 { outer: lp, holes: Vec::new() };
|
||||
let tools = kernel::extrude(&ss.plane, &[region], -t / 2.0, t / 2.0)?;
|
||||
tools.into_iter().next().ok_or_else(|| DocError::Invalid("the rib made no solid".into()))
|
||||
}
|
||||
|
||||
/// The point `d` along a polyline.
|
||||
fn point_on_polyline(pts: &[Vec2], d: f64) -> Vec2 {
|
||||
let mut left = d.max(0.0);
|
||||
for w in pts.windows(2) {
|
||||
let l = w[0].dist(w[1]);
|
||||
if left <= l && l > 0.0 {
|
||||
return w[0].lerp(w[1], left / l);
|
||||
}
|
||||
left -= l;
|
||||
}
|
||||
pts.last().copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Rebuild a sheet body's solid from its model.
|
||||
fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> {
|
||||
let sheet = st.sheets.get(idx).cloned().ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
let solid = sheet.solid()?;
|
||||
let i = st.bodies.iter().position(|b| b.name == sheet.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", sheet.body)))?;
|
||||
if let Some(mb) = st.bodies.get_mut(i) {
|
||||
*mb = ModelBody::new(mb.name.clone(), solid, mb.feature);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The sheet a feature works on: by body name, else the one with an edge nearest the pick.
|
||||
fn sheet_for(st: &ModelState, body: &Option<String>, pick: Option<Vec3>) -> Result<usize> {
|
||||
if let Some(n) = body {
|
||||
return st.sheets.iter().position(|s| &s.body == n).ok_or_else(|| DocError::Invalid(format!("`{n}` is not a sheet metal body")));
|
||||
}
|
||||
let p = pick.ok_or_else(|| DocError::Invalid("no sheet metal body".into()))?;
|
||||
st.sheets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, s)| s.edge_at(p).map(|e| (i, e.4)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(i, _)| i)
|
||||
.ok_or_else(|| DocError::Invalid("there is no sheet metal body".into()))
|
||||
}
|
||||
|
||||
/// A region mirrored in the sketch x axis (y → −y); curves other than lines and arcs as polylines.
|
||||
fn mirror_y(r: &Region2) -> Region2 {
|
||||
let f = |p: Vec2| Vec2::new(p.x, -p.y);
|
||||
let m = |l: &Loop2| {
|
||||
let segs: Vec<Seg2> = l
|
||||
.segs
|
||||
.iter()
|
||||
.flat_map(|s| match *s {
|
||||
Seg2::Line { a, b } => vec![Seg2::Line { a: f(a), b: f(b) }],
|
||||
Seg2::Arc { center, radius, start, sweep } => vec![Seg2::Arc { center: f(center), radius, start: -start, sweep: -sweep }],
|
||||
other => {
|
||||
let pts: Vec<Vec2> = (0..=16).map(|k| f(other.point_at(k as f64 / 16.0))).collect();
|
||||
pts.windows(2).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Loop2 { segs }.ccw()
|
||||
};
|
||||
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<()> {
|
||||
use crate::sheet;
|
||||
match &f.kind {
|
||||
FeatureKind::SheetBase { sketch, profiles, rule, flip } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let r = doc.sheet_rule(rule.as_deref());
|
||||
let rv = doc.rule_values(vals, &r)?;
|
||||
for (k, region) in regions.into_iter().enumerate() {
|
||||
let (plane, region) = if *flip {
|
||||
(Plane { origin: ss.plane.origin, x: ss.plane.x, y: ss.plane.y * -1.0 }, mirror_y(®ion))
|
||||
} else {
|
||||
(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 solid = sh.solid()?;
|
||||
st.bodies.push(ModelBody::new(name, solid, f.id));
|
||||
st.sheets.push(sh);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SheetContour { sketch, curves, distance, rule, flip, reverse } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
for id in curves {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?;
|
||||
if !ss.sketch.segs(ci).iter().all(|s| matches!(s, Seg2::Line { .. })) {
|
||||
return Err(DocError::Invalid("not supported yet: arcs in a contour flange (use lines; the bends are made for you)".into()));
|
||||
}
|
||||
}
|
||||
let pts = chain_points(&ss, curves)?;
|
||||
let w = val(vals, distance, Kind::Length)?;
|
||||
if !(w > 0.0) {
|
||||
return Err(DocError::Invalid("the contour flange distance must be positive".into()));
|
||||
}
|
||||
let r = doc.sheet_rule(rule.as_deref());
|
||||
let rv = doc.rule_values(vals, &r)?;
|
||||
let plane = if *reverse { Plane { origin: ss.plane.origin, x: ss.plane.x, y: ss.plane.y * -1.0 } } else { ss.plane };
|
||||
let pts: Vec<Vec2> = if *reverse { pts.iter().map(|p| Vec2::new(p.x, -p.y)).collect() } else { pts };
|
||||
// Mirroring the sketch swaps the material side too.
|
||||
let name = new_name(st, f, 0);
|
||||
let sh = sheet::contour_flange(&name, &r.name, &rv, &plane, &pts, w, *flip != *reverse)?;
|
||||
let solid = sh.solid()?;
|
||||
st.bodies.push(ModelBody::new(name, solid, f.id));
|
||||
st.sheets.push(sh);
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body } => {
|
||||
let i = sheet_for(st, body, edges.first().copied())?;
|
||||
let (h, a) = (val(vals, height, Kind::Length)?, val(vals, angle, Kind::Angle)?);
|
||||
let rr = match radius {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => {
|
||||
let rule = st.sheets.get(i).map(|s| s.rule.clone()).unwrap_or_default();
|
||||
doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius
|
||||
}
|
||||
};
|
||||
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
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);
|
||||
rebuild_sheet(st, i)
|
||||
}
|
||||
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
|
||||
let i = sheet_for(st, body, edges.first().copied())?;
|
||||
let l = val(vals, length, Kind::Length)?;
|
||||
let g = match gap {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => {
|
||||
let rule = st.sheets.get(i).map(|s| s.rule.clone()).unwrap_or_default();
|
||||
doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.hem_gap
|
||||
}
|
||||
};
|
||||
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 {
|
||||
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 } => {
|
||||
// The sketch line, where it is now.
|
||||
let (a, b) = match (sketch, curve) {
|
||||
(Some(sk), Some(c)) => {
|
||||
let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk}")))?;
|
||||
let ci = ss.sketch.curve_index(c).ok_or_else(|| DocError::Unknown(format!("curve `{c}`")))?;
|
||||
match ss.sketch.segs(ci).as_slice() {
|
||||
[Seg2::Line { a, b }] => (ss.plane.to_world(*a), ss.plane.to_world(*b)),
|
||||
_ => return Err(DocError::Invalid("the fold line must be a sketch line".into())),
|
||||
}
|
||||
}
|
||||
_ => (*a, *b),
|
||||
};
|
||||
let i = sheet_for(st, body, Some((a + b) * 0.5))?;
|
||||
let ang = val(vals, angle, Kind::Angle)?;
|
||||
let rr = match radius {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => {
|
||||
let rule = st.sheets.get(i).map(|s| s.rule.clone()).unwrap_or_default();
|
||||
doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius
|
||||
}
|
||||
};
|
||||
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
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 } => {
|
||||
let i = match body {
|
||||
Some(_) => sheet_for(st, body, None)?,
|
||||
None => st.sheets.iter().rposition(|s| s.flat == *refold).ok_or_else(|| {
|
||||
DocError::Invalid(if *refold { "no unfolded sheet to refold".into() } else { "no sheet metal body to unfold".into() })
|
||||
})?,
|
||||
};
|
||||
if let Some(sh) = st.sheets.get_mut(i) {
|
||||
sh.flat = !*refold;
|
||||
}
|
||||
rebuild_sheet(st, i)
|
||||
}
|
||||
FeatureKind::SheetConvert { body, face, rule } => {
|
||||
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick a planar face of the plate".into()))?;
|
||||
let tol = (mb.body.size() * 1e-3).max(1e-3);
|
||||
let mesh = mb.body.tessellate(tol)?;
|
||||
// Thickness: the body's extent along the face normal.
|
||||
let ds: Vec<f64> = mesh.positions.iter().map(|p| (*p - *face).dot(n)).collect();
|
||||
let (lo, hi) = (ds.iter().cloned().fold(f64::MAX, f64::min), ds.iter().cloned().fold(f64::MIN, f64::max));
|
||||
let t = hi - lo;
|
||||
// The face outline: boundary edges of the triangles on that face's plane.
|
||||
let plane = Plane::from_normal(*face + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?;
|
||||
let mut loops = face_loops(&mesh, &plane, *face, n, lo, tol);
|
||||
loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs()));
|
||||
let mut it = loops.into_iter();
|
||||
let outer = Loop2::polygon(&it.next().ok_or_else(|| DocError::Invalid("no outline on that face".into()))?).ccw();
|
||||
let region = Region2 { outer, holes: it.map(|l| Loop2::polygon(&l).ccw().reversed()).collect() };
|
||||
let area = region.area().abs();
|
||||
let vol = kernel::measure(&mb.body)?.volume;
|
||||
if (area * t - vol).abs() > 1e-3 * vol.max(1.0) {
|
||||
return Err(DocError::Invalid("not supported yet: converting bodies that are not a plate of even thickness".into()));
|
||||
}
|
||||
let r = doc.sheet_rule(rule.as_deref());
|
||||
let mut rv = doc.rule_values(vals, &r)?;
|
||||
rv.thickness = t;
|
||||
let mut sh = sheet::base_flange(body, &r.name, &rv, plane, Region2 { outer: region.outer.clone(), holes: Vec::new() });
|
||||
sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect();
|
||||
let solid = sh.solid()?;
|
||||
if let Some(b) = st.bodies.iter_mut().find(|b| &b.name == body) {
|
||||
*b = ModelBody::new(b.name.clone(), solid, b.feature);
|
||||
}
|
||||
st.sheets.retain(|s| &s.body != body);
|
||||
st.sheets.push(sh);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(DocError::Invalid(format!("{} is not a sheet metal feature", f.name))),
|
||||
}
|
||||
}
|
||||
|
||||
fn poly_area(p: &[Vec2]) -> f64 {
|
||||
let m = p.len();
|
||||
(0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
/// An extrude cut through sheet metal: the profiles become cut-outs of the flat pattern
|
||||
/// (parallel to a panel, through its thickness). Returns the sheet bodies handled.
|
||||
pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], targets: &[String]) -> Result<Vec<String>> {
|
||||
let FeatureKind::Extrude { sketch, profiles, .. } = &f.kind else { return Ok(Vec::new()) };
|
||||
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let n = ss.plane.normal();
|
||||
// How far the tools reach along the sketch normal.
|
||||
let mut span = (f64::MAX, f64::MIN);
|
||||
for t in tools {
|
||||
for p in t.tessellate((t.size() * 1e-3).max(1e-3))?.positions {
|
||||
let d = (p - ss.plane.origin).dot(n);
|
||||
span = (span.0.min(d), span.1.max(d));
|
||||
}
|
||||
}
|
||||
let mut handled = Vec::new();
|
||||
for i in 0..st.sheets.len() {
|
||||
let Some(sh) = st.sheets.get(i).cloned() else { continue };
|
||||
if !targets.is_empty() && !targets.contains(&sh.body) {
|
||||
continue;
|
||||
}
|
||||
let candidates: Vec<(Option<usize>, Region2)> = if sh.flat {
|
||||
if sh.frame.normal().cross(n).len() > 1e-6 {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![(None, Region2 { outer: Loop2::polygon(&sh.outline()?.into_iter().next().unwrap_or_default()), holes: Vec::new() })]
|
||||
}
|
||||
} else {
|
||||
sh.parallel_panels(&ss.plane)
|
||||
};
|
||||
let mut added = Vec::new();
|
||||
for (pi, panel) in candidates {
|
||||
// The panel's faces along the sketch normal must both be inside the cut.
|
||||
let w = sh.world(pi);
|
||||
let z0 = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n);
|
||||
let z1 = (apply_point(&w, Vec3::Z * sh.t) - ss.plane.origin).dot(n);
|
||||
if z0.min(z1) < span.0 - 1e-6 || z0.max(z1) > span.1 + 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let outline = if sh.flat { sh.outline()?.into_iter().flatten().collect::<Vec<_>>() } else { panel.outer.polyline(1e-3) };
|
||||
for r in ®ions {
|
||||
let Some(flat) = sh.region_to_flat(pi, &ss.plane, r) else { continue };
|
||||
let fp = flat.outer.polyline(1e-3);
|
||||
let overlaps = fp.iter().any(|p| point_in_poly(&outline, *p)) || outline.iter().any(|p| point_in_poly(&fp, *p));
|
||||
if overlaps && !added.iter().any(|x: &Region2| x == &flat) {
|
||||
added.push(flat);
|
||||
}
|
||||
}
|
||||
}
|
||||
if added.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = st.sheets.get_mut(i) {
|
||||
s.holes.extend(added);
|
||||
}
|
||||
rebuild_sheet(st, i)?;
|
||||
handled.push(sh.body.clone());
|
||||
}
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
fn point_in_poly(poly: &[Vec2], p: Vec2) -> bool {
|
||||
let mut inside = false;
|
||||
let m = poly.len();
|
||||
for i in 0..m {
|
||||
let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + m - 1) % m)) else { continue };
|
||||
if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
|
||||
/// `level` from `at` along `n`: the edges used by one of them only, chained.
|
||||
fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
let on: Vec<[u32; 3]> = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - at).dot(n) - level).abs() < tol)))
|
||||
.collect();
|
||||
// Boundary edges by position (meshes may repeat vertices per triangle).
|
||||
let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default();
|
||||
let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64);
|
||||
let mut count: std::collections::HashMap<((i64, i64, i64), (i64, i64, i64)), i32> = Default::default();
|
||||
let edges: Vec<(Vec3, Vec3)> = on.iter().flat_map(|t| [(pos(t[0]), pos(t[1])), (pos(t[1]), pos(t[2])), (pos(t[2]), pos(t[0]))]).collect();
|
||||
for (a, b) in &edges {
|
||||
let (ka, kb) = (q(*a), q(*b));
|
||||
*count.entry((ka.min(kb), ka.max(kb))).or_insert(0) += 1;
|
||||
}
|
||||
let mut boundary: Vec<(Vec3, Vec3)> = edges
|
||||
.into_iter()
|
||||
.filter(|(a, b)| {
|
||||
let (ka, kb) = (q(*a), q(*b));
|
||||
count.get(&(ka.min(kb), ka.max(kb))) == Some(&1)
|
||||
})
|
||||
.collect();
|
||||
// Chain into loops (plane coordinates).
|
||||
let mut loops: Vec<Vec<Vec2>> = Vec::new();
|
||||
for _ in 0..10_000 {
|
||||
let Some((s0, mut cur)) = boundary.pop() else { break };
|
||||
let mut lp = vec![s0];
|
||||
for _ in 0..boundary.len() + 1 {
|
||||
if q(cur) == q(s0) {
|
||||
break;
|
||||
}
|
||||
lp.push(cur);
|
||||
match boundary.iter().position(|(a, _)| q(*a) == q(cur)) {
|
||||
Some(k) => cur = boundary.remove(k).1,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let raw: Vec<Vec2> = lp.iter().map(|p| plane.to_local(*p)).collect();
|
||||
// Drop points in the middle of straight runs.
|
||||
let m = raw.len();
|
||||
let pts: Vec<Vec2> = (0..m)
|
||||
.filter_map(|i| {
|
||||
let (p, c, n) = (*raw.get((i + m - 1) % m)?, *raw.get(i)?, *raw.get((i + 1) % m)?);
|
||||
((c - p).cross(n - c).abs() > 1e-9 * (c - p).len().max(1e-9) * (n - c).len().max(1e-9)).then_some(c)
|
||||
})
|
||||
.collect();
|
||||
if pts.len() >= 3 {
|
||||
loops.push(pts);
|
||||
}
|
||||
}
|
||||
loops
|
||||
}
|
||||
|
||||
/// The body a placed plastic feature joins: by name, else the one under the point.
|
||||
fn body_under(st: &ModelState, body: &Option<String>, p: Vec3) -> Result<String> {
|
||||
let i = body_at(st, body, &[p])?;
|
||||
st.bodies.get(i).map(|b| b.name.clone()).ok_or_else(|| DocError::Invalid("there is no body".into()))
|
||||
}
|
||||
|
||||
/// Axes with z along `z` (and x along `x` when given).
|
||||
fn local_frame(z: Vec3, x: Option<Vec3>) -> Result<(Vec3, Vec3, Vec3)> {
|
||||
let z = z.normalized().ok_or_else(|| DocError::Invalid("direction".into()))?;
|
||||
let x = x.and_then(|x| (x - z * x.dot(z)).normalized()).unwrap_or_else(|| z.any_perp());
|
||||
let y = z.cross(x);
|
||||
Ok((x, y, z))
|
||||
}
|
||||
|
||||
/// Offset a closed polygon to its left by `d` (mitred corners).
|
||||
fn offset_left(p: &[Vec2], d: f64) -> Vec<Vec2> {
|
||||
let m = p.len();
|
||||
let line = |i: usize| -> Option<(Vec2, Vec2)> {
|
||||
let (a, b) = (*p.get(i)?, *p.get((i + 1) % m)?);
|
||||
let t = (b - a).normalized()?;
|
||||
Some((a + Vec2::new(-t.y, t.x) * d, t))
|
||||
};
|
||||
(0..m)
|
||||
.filter_map(|i| {
|
||||
let (p0, d0) = line((i + m - 1) % m)?;
|
||||
let (p1, d1) = line(i)?;
|
||||
let den = d0.cross(d1);
|
||||
if den.abs() < 1e-12 {
|
||||
return Some(p1);
|
||||
}
|
||||
Some(p0 + d0 * ((p1 - p0).cross(d1) / den))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
let len = |e: &str| val(vals, e, Kind::Length);
|
||||
// The plastic rule of the body a feature goes on gives its defaults (draft, clearance).
|
||||
let rule = |target: &str| -> Result<Option<crate::plastic::PlasticValues>> {
|
||||
doc.plastic_rule_for(target).map(|r| doc.plastic_values(vals, &r)).transpose()
|
||||
};
|
||||
// Tools start a little inside the material so they overlap it instead of touching a face.
|
||||
match &f.kind {
|
||||
FeatureKind::Boss {
|
||||
position,
|
||||
direction,
|
||||
diameter,
|
||||
height,
|
||||
hole_diameter,
|
||||
hole_depth,
|
||||
draft,
|
||||
fillet,
|
||||
ribs,
|
||||
rib_thickness,
|
||||
rib_length,
|
||||
rib_offset,
|
||||
body,
|
||||
} => {
|
||||
let target = body_under(st, body, *position)?;
|
||||
let (r0, h) = (len(diameter)? / 2.0, len(height)?);
|
||||
if !(r0 > 0.0 && h > 0.0) {
|
||||
return Err(DocError::Invalid("the boss diameter and height must be positive".into()));
|
||||
}
|
||||
let dr = match draft {
|
||||
Some(e) => val(vals, e, Kind::Angle)?,
|
||||
None => rule(&target)?.map_or(0.0, |r| r.draft),
|
||||
};
|
||||
let rf = match fillet {
|
||||
Some(e) => len(e)?.max(0.0),
|
||||
None => 0.0,
|
||||
};
|
||||
let r_top = r0 - h * dr.tan();
|
||||
if r_top <= 0.0 {
|
||||
return Err(DocError::Invalid("the draft closes the boss before its top".into()));
|
||||
}
|
||||
let delta = (h * 0.05).clamp(1e-3, 0.5);
|
||||
let (x, _, z) = local_frame(*direction, None)?;
|
||||
// The post: a (tapered) cylinder from a little inside the face.
|
||||
let base = Plane::new(*position - z * delta, x, z.cross(x)).ok_or_else(|| DocError::Invalid("boss frame".into()))?;
|
||||
let r_in = r0 + delta * dr.tan();
|
||||
let disc = Region2 { outer: Loop2::circle(Vec2::ZERO, r_in), holes: Vec::new() };
|
||||
let mut tools = if dr.abs() > 1e-12 {
|
||||
vec![kernel::extrude_tapered(&base, &disc, h + delta, 1.0, -dr)?]
|
||||
} else {
|
||||
kernel::extrude(&base, &[disc], 0.0, h + delta)?
|
||||
};
|
||||
if let Some(n) = ribs {
|
||||
let n = val(vals, n, Kind::Unitless)?.round();
|
||||
if !(0.0..=64.0).contains(&n) {
|
||||
return Err(DocError::Invalid("0…64 ribs".into()));
|
||||
}
|
||||
let t = match rib_thickness {
|
||||
Some(e) => len(e)?,
|
||||
None => r0 * 0.3,
|
||||
};
|
||||
let l = match rib_length {
|
||||
Some(e) => len(e)?,
|
||||
None => r0,
|
||||
};
|
||||
let off = match rib_offset {
|
||||
Some(e) => len(e)?,
|
||||
None => 0.0,
|
||||
};
|
||||
let hr = h - off;
|
||||
if n > 0.0 && !(t > 0.0 && l > 0.0 && hr > 0.0) {
|
||||
return Err(DocError::Invalid("ribs need a positive thickness, length and height".into()));
|
||||
}
|
||||
for k in 0..n as usize {
|
||||
let a = std::f64::consts::TAU * k as f64 / n;
|
||||
let (u, v) = (x * a.cos() + z.cross(x) * a.sin(), z.cross(x) * a.cos() - x * a.sin());
|
||||
let rp = Plane::new(*position, u, v).ok_or_else(|| DocError::Invalid("rib frame".into()))?;
|
||||
let rect = Region2 {
|
||||
outer: Loop2::polygon(&[
|
||||
Vec2::new(r0 * 0.5, -t / 2.0),
|
||||
Vec2::new(r0 + l, -t / 2.0),
|
||||
Vec2::new(r0 + l, t / 2.0),
|
||||
Vec2::new(r0 * 0.5, t / 2.0),
|
||||
]),
|
||||
holes: Vec::new(),
|
||||
};
|
||||
tools.extend(kernel::extrude(&rp, &[rect], -delta, hr)?);
|
||||
}
|
||||
}
|
||||
apply_op(st, f, tools, Operation::Join, std::slice::from_ref(&target))?;
|
||||
// The root fillet: rounds the circle where the post meets the face.
|
||||
if rf > 1e-9 {
|
||||
let i = st.bodies.iter().position(|b| b.name == target).ok_or_else(|| DocError::Invalid("boss body".into()))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("boss body".into())) };
|
||||
let mut warn = None;
|
||||
let edges = resolve_edges(&mb.body, &[*position + x * r0], &[], &mut warn)?;
|
||||
let nb = kernel::fillet(&mb.body, &edges, rf)?;
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, nb, mb.feature);
|
||||
}
|
||||
}
|
||||
if let Some(hd) = hole_diameter {
|
||||
let rh = len(hd)? / 2.0;
|
||||
let depth = match hole_depth {
|
||||
Some(e) => len(e)?,
|
||||
None => h,
|
||||
};
|
||||
if !(rh > 0.0 && rh < r_top && depth > 0.0) {
|
||||
return Err(DocError::Invalid("the boss hole must fit inside the boss".into()));
|
||||
}
|
||||
let hp = Plane::new(*position, x, z.cross(x)).ok_or_else(|| DocError::Invalid("hole frame".into()))?;
|
||||
let tool = kernel::extrude(&hp, &[Region2 { outer: Loop2::circle(Vec2::ZERO, rh), holes: Vec::new() }], h - depth, h + delta)?;
|
||||
apply_op(st, f, tool, Operation::Cut, std::slice::from_ref(&target))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Lip { face, width, height, groove, gap, outside, body } => {
|
||||
let target = body_under(st, body, *face)?;
|
||||
let mb = st.body(&target).cloned().ok_or_else(|| DocError::Invalid("body".into()))?;
|
||||
let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick the planar rim face".into()))?;
|
||||
let (w, h) = (len(width)?, len(height)?);
|
||||
let g = match gap {
|
||||
Some(e) => len(e)?,
|
||||
None if *groove => rule(&target)?.map_or(0.0, |r| r.clearance),
|
||||
None => 0.0,
|
||||
};
|
||||
if !(w > 0.0 && h > 0.0 && g >= 0.0) {
|
||||
return Err(DocError::Invalid("the lip width and height must be positive".into()));
|
||||
}
|
||||
let plane = Plane::from_normal(*face, n).ok_or_else(|| DocError::Invalid("rim plane".into()))?;
|
||||
let tol = (mb.body.size() * 1e-3).max(1e-3);
|
||||
let mesh = mb.body.tessellate(tol)?;
|
||||
let mut loops = face_loops(&mesh, &plane, *face, n, 0.0, tol);
|
||||
loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs()));
|
||||
let ccw = |l: &[Vec2]| if poly_area(l) < 0.0 { l.iter().rev().copied().collect::<Vec<_>>() } else { l.to_vec() };
|
||||
let (Some(outer), Some(inner)) = (loops.first().map(|l| ccw(l)), loops.get(1).map(|l| ccw(l))) else {
|
||||
return Err(DocError::Invalid("pick the rim face of a shelled body (a face with an inner and an outer edge)".into()));
|
||||
};
|
||||
let bw = if *groove { w + g } else { w };
|
||||
// The band along the chosen edge, inside the rim face.
|
||||
let band = if *outside {
|
||||
Region2 { outer: Loop2::polygon(&outer).ccw(), holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()] }
|
||||
} else {
|
||||
Region2 { outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(), holes: vec![Loop2::polygon(&inner).ccw().reversed()] }
|
||||
};
|
||||
let delta = (h * 0.05).clamp(1e-3, 0.5);
|
||||
if *groove {
|
||||
let tool = kernel::extrude(&plane, &[band], -(h + g), delta)?;
|
||||
apply_op(st, f, tool, Operation::Cut, std::slice::from_ref(&target))
|
||||
} else {
|
||||
let tool = kernel::extrude(&plane, &[band], -delta, h)?;
|
||||
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
}
|
||||
}
|
||||
FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, body } => {
|
||||
let target = body_under(st, body, *position)?;
|
||||
let (l, t, w, cd, cl) = (len(length)?, len(thickness)?, len(width)?, len(catch_depth)?, len(catch_length)?);
|
||||
if !(l > 0.0 && t > 0.0 && w > 0.0 && cd > 0.0 && cl > 0.0 && cl < l) {
|
||||
return Err(DocError::Invalid("the snap fit's sizes must be positive and the catch shorter than the arm".into()));
|
||||
}
|
||||
let (x, y, z) = local_frame(*direction, Some(*hook))?;
|
||||
let delta = (l * 0.05).clamp(1e-3, 0.5);
|
||||
// Side profile (x = toward the catch, y = up the arm), extruded across the width.
|
||||
let pts =
|
||||
[Vec2::new(-t, -delta), Vec2::new(0.0, -delta), Vec2::new(0.0, l - cl), Vec2::new(cd, l - cl), Vec2::new(0.0, l), Vec2::new(-t, l)];
|
||||
let o = *position - y * (w / 2.0);
|
||||
let pl = Plane::new(o, x, z).ok_or_else(|| DocError::Invalid("snap fit frame".into()))?;
|
||||
let mut lp = Loop2::polygon(&pts);
|
||||
if lp.signed_area() < 0.0 {
|
||||
lp = lp.reversed();
|
||||
}
|
||||
let sgn = if pl.normal().dot(y) >= 0.0 { 1.0 } else { -1.0 };
|
||||
let (lo, hi) = if sgn > 0.0 { (0.0, w) } else { (-w, 0.0) };
|
||||
let tool = kernel::extrude(&pl, &[Region2 { outer: lp, holes: Vec::new() }], lo, hi)?;
|
||||
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
}
|
||||
FeatureKind::Rest { position, direction, along, width, length, height, draft, thickness, body } => {
|
||||
let target = body_under(st, body, *position)?;
|
||||
let (w, h) = (len(width)?, len(height)?);
|
||||
let l = length.as_deref().map(len).transpose()?;
|
||||
let dr = match draft {
|
||||
Some(e) => val(vals, e, Kind::Angle)?,
|
||||
None => rule(&target)?.map_or(0.0, |r| r.draft),
|
||||
};
|
||||
let t = thickness.as_deref().map(len).transpose()?;
|
||||
if !(w > 0.0 && h > 0.0 && l.is_none_or(|l| l > 0.0) && t.is_none_or(|t| t > 0.0 && 2.0 * t < w.min(l.unwrap_or(w)))) {
|
||||
return Err(DocError::Invalid("the rest's sizes must be positive and its wall thinner than half its width".into()));
|
||||
}
|
||||
if !(dr.abs() < 1.0) {
|
||||
return Err(DocError::Invalid("the rest's draft must be under 57°".into()));
|
||||
}
|
||||
let shrink = h * dr.tan();
|
||||
let closes = match t {
|
||||
Some(t) => 2.0 * shrink >= t,
|
||||
None => 2.0 * shrink >= w.min(l.unwrap_or(w)),
|
||||
};
|
||||
if closes {
|
||||
return Err(DocError::Invalid("the draft closes the rest before its top".into()));
|
||||
}
|
||||
let delta = (h * 0.05).clamp(1e-3, 0.5);
|
||||
let (x, y, z) = local_frame(*direction, *along)?;
|
||||
// Sized at the face: the tool starts a little inside, grown by the draft over that.
|
||||
let e = delta * dr.tan();
|
||||
let shape = |grow: f64| match l {
|
||||
Some(l) => Loop2::polygon(&[
|
||||
Vec2::new(-l / 2.0 - grow, -w / 2.0 - grow),
|
||||
Vec2::new(l / 2.0 + grow, -w / 2.0 - grow),
|
||||
Vec2::new(l / 2.0 + grow, w / 2.0 + grow),
|
||||
Vec2::new(-l / 2.0 - grow, w / 2.0 + grow),
|
||||
]),
|
||||
None => Loop2::circle(Vec2::ZERO, w / 2.0 + grow),
|
||||
};
|
||||
let holes = match t {
|
||||
Some(t) => vec![shape(-t - e).reversed()],
|
||||
None => Vec::new(),
|
||||
};
|
||||
let region = Region2 { outer: shape(e), holes };
|
||||
let base = Plane::new(*position - z * delta, x, y).ok_or_else(|| DocError::Invalid("rest frame".into()))?;
|
||||
let tool = if dr.abs() > 1e-12 {
|
||||
vec![kernel::extrude_tapered(&base, ®ion, h + delta, 1.0, -dr)?]
|
||||
} else {
|
||||
kernel::extrude(&base, &[region], 0.0, h + delta)?
|
||||
};
|
||||
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
}
|
||||
_ => 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())),
|
||||
}
|
||||
}
|
||||
@@ -1,946 +0,0 @@
|
||||
//! Parameter expressions: `2 * width + 5 mm`, `angle / 2`, `sqrt(a^2 + b^2)`, `atan2(h; w)`.
|
||||
//!
|
||||
//! Values carry a simple dimension (powers of length and angle). Lengths are millimetres and
|
||||
//! angles radians internally. A unit-less number added to a dimensioned value takes that value's
|
||||
//! unit: the parameter's own unit when it has one (`width + 5` adds 5 in for an inch
|
||||
//! parameter), else millimetres and degrees. A unit-less final result takes the expected unit
|
||||
//! (`2` for an inch parameter is 2 in). Trigonometric functions take an angle; a unit-less
|
||||
//! argument is read as degrees.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::DocError;
|
||||
|
||||
const MAX_LEN: usize = 4096;
|
||||
const MAX_DEPTH: usize = 64;
|
||||
const MAX_ARGS: usize = 64;
|
||||
|
||||
/// What an expression must evaluate to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Kind {
|
||||
Length,
|
||||
Angle,
|
||||
Unitless,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub fn from_unit(u: &str) -> Kind {
|
||||
unit_info(u).map(|(k, _)| k).unwrap_or(Kind::Length)
|
||||
}
|
||||
/// The unit a new parameter of this kind gets (`mm`, `deg` or none).
|
||||
pub fn default_unit(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Length => "mm",
|
||||
Kind::Angle => "deg",
|
||||
Kind::Unitless => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Units a parameter or an expression may use: name, kind and size (mm or radians).
|
||||
pub const UNITS: [(&str, Kind, f64); 14] = [
|
||||
("mm", Kind::Length, 1.0),
|
||||
("cm", Kind::Length, 10.0),
|
||||
("m", Kind::Length, 1000.0),
|
||||
("um", Kind::Length, 0.001),
|
||||
("µm", Kind::Length, 0.001),
|
||||
("in", Kind::Length, 25.4),
|
||||
("inch", Kind::Length, 25.4),
|
||||
("ft", Kind::Length, 304.8),
|
||||
("yd", Kind::Length, 914.4),
|
||||
("km", Kind::Length, 1_000_000.0),
|
||||
("mil", Kind::Length, 0.0254),
|
||||
("nm", Kind::Length, 1e-6),
|
||||
("deg", Kind::Angle, std::f64::consts::PI / 180.0),
|
||||
("rad", Kind::Angle, 1.0),
|
||||
];
|
||||
|
||||
/// Functions an expression may call (name, signature) — for autocomplete and help.
|
||||
pub const FUNCTIONS: [(&str, &str); 23] = [
|
||||
("sin", "sin(angle)"),
|
||||
("cos", "cos(angle)"),
|
||||
("tan", "tan(angle)"),
|
||||
("asin", "asin(x) → angle"),
|
||||
("acos", "acos(x) → angle"),
|
||||
("atan", "atan(x) → angle"),
|
||||
("atan2", "atan2(y, x) → angle"),
|
||||
("sqrt", "sqrt(x)"),
|
||||
("abs", "abs(x)"),
|
||||
("min", "min(a, b, …)"),
|
||||
("max", "max(a, b, …)"),
|
||||
("floor", "floor(x)"),
|
||||
("ceil", "ceil(x)"),
|
||||
("round", "round(x)"),
|
||||
("exp", "exp(x)"),
|
||||
("ln", "ln(x)"),
|
||||
("log", "log(x) (base 10)"),
|
||||
("log10", "log10(x)"),
|
||||
("sign", "sign(x)"),
|
||||
("hypot", "hypot(a, b)"),
|
||||
("pow", "pow(x, n)"),
|
||||
("trunc", "trunc(x)"),
|
||||
("mod", "mod(a, b)"),
|
||||
];
|
||||
|
||||
/// Constants an expression may use.
|
||||
pub const CONSTANTS: [&str; 2] = ["pi", "PI"];
|
||||
|
||||
/// Kind and size (mm or radians per unit) of a parameter's unit; empty means unit-less.
|
||||
pub fn unit_info(u: &str) -> Option<(Kind, f64)> {
|
||||
match u.trim() {
|
||||
"" | "none" | "unitless" => Some((Kind::Unitless, 1.0)),
|
||||
"°" | "degree" | "degrees" => Some((Kind::Angle, std::f64::consts::PI / 180.0)),
|
||||
t => UNITS.iter().find(|(n, _, _)| *n == t).map(|(_, k, s)| (*k, *s)),
|
||||
}
|
||||
}
|
||||
|
||||
/// How unit-less numbers are read: millimetres / degrees by default.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Defaults {
|
||||
/// Millimetres per unit-less length number.
|
||||
pub len: f64,
|
||||
/// Radians per unit-less angle number.
|
||||
pub ang: f64,
|
||||
}
|
||||
|
||||
impl Default for Defaults {
|
||||
fn default() -> Self {
|
||||
Defaults { len: 1.0, ang: std::f64::consts::PI / 180.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Defaults {
|
||||
/// Defaults for a parameter with this unit (`in` reads bare numbers as inches); the
|
||||
/// document's length unit covers the other kind.
|
||||
pub fn for_unit(unit: &str, doc_len: f64) -> Defaults {
|
||||
let mut d = Defaults { len: doc_len, ..Defaults::default() };
|
||||
match unit_info(unit) {
|
||||
Some((Kind::Length, s)) => d.len = s,
|
||||
Some((Kind::Angle, s)) => d.ang = s,
|
||||
_ => {}
|
||||
}
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
/// A dimensioned value.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Value {
|
||||
pub v: f64,
|
||||
pub len: i8,
|
||||
pub ang: i8,
|
||||
}
|
||||
|
||||
impl Value {
|
||||
pub fn num(v: f64) -> Value {
|
||||
Value { v, len: 0, ang: 0 }
|
||||
}
|
||||
pub fn length(mm: f64) -> Value {
|
||||
Value { v: mm, len: 1, ang: 0 }
|
||||
}
|
||||
pub fn angle(rad: f64) -> Value {
|
||||
Value { v: rad, len: 0, ang: 1 }
|
||||
}
|
||||
pub fn unitless(&self) -> bool {
|
||||
self.len == 0 && self.ang == 0
|
||||
}
|
||||
/// The value's kind, if it is a plain length, angle or number.
|
||||
pub fn kind(&self) -> Option<Kind> {
|
||||
match (self.len, self.ang) {
|
||||
(0, 0) => Some(Kind::Unitless),
|
||||
(1, 0) => Some(Kind::Length),
|
||||
(0, 1) => Some(Kind::Angle),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Convert to the expected kind (mm or radians); unit-less numbers are mm / degrees.
|
||||
pub fn to_kind(self, k: Kind) -> Result<f64, DocError> {
|
||||
self.to_kind_in(k, Defaults::default())
|
||||
}
|
||||
/// Convert to the expected kind, reading a unit-less result in the given defaults.
|
||||
pub fn to_kind_in(self, k: Kind, d: Defaults) -> Result<f64, DocError> {
|
||||
let out = match k {
|
||||
Kind::Length if self.len == 1 && self.ang == 0 => self.v,
|
||||
Kind::Length if self.unitless() => self.v * d.len,
|
||||
Kind::Angle if self.ang == 1 && self.len == 0 => self.v,
|
||||
Kind::Angle if self.unitless() => self.v * d.ang,
|
||||
Kind::Unitless if self.unitless() => self.v,
|
||||
_ => {
|
||||
return Err(DocError::Expr(format!("expected {}, got {}", kind_word(k), self.describe())));
|
||||
}
|
||||
};
|
||||
if out.is_finite() { Ok(out) } else { Err(DocError::Expr("result is not a finite number".into())) }
|
||||
}
|
||||
/// "a length", "an angle", "length²"…
|
||||
pub fn describe(&self) -> String {
|
||||
match self.kind() {
|
||||
Some(k) => kind_word(k).to_string(),
|
||||
None => {
|
||||
let pow = |name: &str, n: i8| match n {
|
||||
0 => String::new(),
|
||||
1 => name.to_string(),
|
||||
_ => format!("{name}^{n}"),
|
||||
};
|
||||
let parts: Vec<String> = [pow("length", self.len), pow("angle", self.ang)].into_iter().filter(|s| !s.is_empty()).collect();
|
||||
format!("a value in {}", parts.join("·"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_word(k: Kind) -> &'static str {
|
||||
match k {
|
||||
Kind::Length => "a length",
|
||||
Kind::Angle => "an angle",
|
||||
Kind::Unitless => "a unit-less number",
|
||||
}
|
||||
}
|
||||
|
||||
/// A number in the given unit (e.g. 50.8 mm in `in` is 2); unknown units leave it as is.
|
||||
pub fn value_in_unit(v: Value, unit: &str) -> f64 {
|
||||
match (unit_info(unit), v.kind()) {
|
||||
(Some((Kind::Length, s)), Some(Kind::Length)) | (Some((Kind::Angle, s)), Some(Kind::Angle)) if s != 0.0 => v.v / s,
|
||||
_ => v.v,
|
||||
}
|
||||
}
|
||||
|
||||
fn unit(name: &str) -> Option<Value> {
|
||||
UNITS.iter().find(|(n, _, _)| *n == name).map(|(_, k, s)| match k {
|
||||
Kind::Angle => Value::angle(*s),
|
||||
_ => Value::length(*s),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_constant(id: &str) -> bool {
|
||||
CONSTANTS.contains(&id)
|
||||
}
|
||||
|
||||
/// Is this a name the expression language reserves (unit, constant or function)?
|
||||
pub fn is_reserved(name: &str) -> bool {
|
||||
unit(name).is_some() || is_constant(name) || FUNCTIONS.iter().any(|(f, _)| *f == name)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Tok {
|
||||
Num(f64),
|
||||
Id(String),
|
||||
Op(char),
|
||||
}
|
||||
|
||||
/// Tokens with their byte ranges in the source.
|
||||
fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
if s.len() > MAX_LEN {
|
||||
return Err(DocError::Expr("expression too long".into()));
|
||||
}
|
||||
let cs: Vec<(usize, char)> = s.char_indices().collect();
|
||||
let at = |i: usize| cs.get(i).map(|x| x.0).unwrap_or(s.len());
|
||||
let mut i = 0;
|
||||
let mut out = Vec::new();
|
||||
while let Some(&(_, c)) = cs.get(i) {
|
||||
if c.is_whitespace() {
|
||||
i += 1;
|
||||
} else if c.is_ascii_digit() || c == '.' {
|
||||
let st = i;
|
||||
while cs.get(i).is_some_and(|(_, c)| c.is_ascii_digit() || *c == '.') {
|
||||
i += 1;
|
||||
}
|
||||
// Exponent: 1e-3 (but not `2em`).
|
||||
if cs.get(i).is_some_and(|(_, c)| *c == 'e' || *c == 'E') {
|
||||
let sign = cs.get(i + 1).is_some_and(|(_, c)| *c == '-' || *c == '+');
|
||||
let digit_at = if sign { i + 2 } else { i + 1 };
|
||||
if cs.get(digit_at).is_some_and(|(_, c)| c.is_ascii_digit()) {
|
||||
i = digit_at;
|
||||
while cs.get(i).is_some_and(|(_, c)| c.is_ascii_digit()) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let t = s.get(at(st)..at(i)).unwrap_or_default();
|
||||
let v: f64 = t.parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?;
|
||||
out.push((Tok::Num(v), at(st), at(i)));
|
||||
} else if c.is_alphabetic() || c == '_' || c == 'µ' {
|
||||
let st = i;
|
||||
while cs.get(i).is_some_and(|(_, c)| c.is_alphanumeric() || *c == '_') {
|
||||
i += 1;
|
||||
}
|
||||
out.push((Tok::Id(s.get(at(st)..at(i)).unwrap_or_default().to_string()), at(st), at(i)));
|
||||
} else if c == '°' {
|
||||
out.push((Tok::Id("deg".into()), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else if c == '×' || c == '·' {
|
||||
out.push((Tok::Op('*'), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else if c == '÷' {
|
||||
out.push((Tok::Op('/'), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else if c == '−' {
|
||||
out.push((Tok::Op('-'), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else if c == ';' {
|
||||
// Fusion separates function arguments with `;` in some locales.
|
||||
out.push((Tok::Op(','), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else if "+-*/^(),%".contains(c) {
|
||||
out.push((Tok::Op(c), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else {
|
||||
return Err(DocError::Expr(format!("unexpected `{c}`")));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn lex(s: &str) -> Result<Vec<Tok>, DocError> {
|
||||
Ok(lex_spans(s)?.into_iter().map(|(t, _, _)| t).collect())
|
||||
}
|
||||
|
||||
/// Is the identifier token at `i` a reference to a parameter (not a call, unit or constant)?
|
||||
fn is_reference(toks: &[Tok], i: usize, id: &str) -> bool {
|
||||
let is_call = matches!(toks.get(i + 1), Some(Tok::Op('(')));
|
||||
!is_call && unit(id).is_none() && !is_constant(id)
|
||||
}
|
||||
|
||||
/// Identifiers an expression refers to (excluding units, constants and functions).
|
||||
pub fn references(s: &str) -> Vec<String> {
|
||||
let Ok(toks) = lex(s) else { return Vec::new() };
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (i, t) in toks.iter().enumerate() {
|
||||
if let Tok::Id(id) = t
|
||||
&& is_reference(&toks, i, id)
|
||||
&& !out.contains(id)
|
||||
{
|
||||
out.push(id.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The expression with every reference to `old` renamed to `new` (text otherwise kept).
|
||||
pub fn rename_reference(s: &str, old: &str, new: &str) -> String {
|
||||
let Ok(spans) = lex_spans(s) else { return s.to_string() };
|
||||
let toks: Vec<Tok> = spans.iter().map(|(t, _, _)| t.clone()).collect();
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut last = 0;
|
||||
for (i, (t, a, b)) in spans.iter().enumerate() {
|
||||
if let Tok::Id(id) = t
|
||||
&& id == old
|
||||
&& is_reference(&toks, i, id)
|
||||
{
|
||||
out.push_str(s.get(last..*a).unwrap_or_default());
|
||||
out.push_str(new);
|
||||
last = *b;
|
||||
}
|
||||
}
|
||||
out.push_str(s.get(last..).unwrap_or_default());
|
||||
out
|
||||
}
|
||||
|
||||
/// Is the expression just a number (with an optional unit), like `20`, `20 mm` or `-1.5 in`?
|
||||
pub fn is_literal(s: &str) -> bool {
|
||||
let Ok(toks) = lex(s) else { return false };
|
||||
let toks: &[Tok] = match toks.first() {
|
||||
Some(Tok::Op('-' | '+')) => toks.get(1..).unwrap_or_default(),
|
||||
_ => &toks,
|
||||
};
|
||||
match toks {
|
||||
[Tok::Num(_)] => true,
|
||||
[Tok::Num(_), Tok::Id(u)] => unit(u).is_some(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
toks: Vec<Tok>,
|
||||
pos: usize,
|
||||
lookup: &'a dyn Fn(&str) -> Result<Value, DocError>,
|
||||
depth: usize,
|
||||
defaults: Defaults,
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
fn peek(&self) -> Option<&Tok> {
|
||||
self.toks.get(self.pos)
|
||||
}
|
||||
fn next(&mut self) -> Option<Tok> {
|
||||
let t = self.toks.get(self.pos).cloned();
|
||||
self.pos += 1;
|
||||
t
|
||||
}
|
||||
fn enter(&mut self) -> Result<(), DocError> {
|
||||
self.depth += 1;
|
||||
if self.depth > MAX_DEPTH { Err(DocError::Expr("expression nested too deeply".into())) } else { Ok(()) }
|
||||
}
|
||||
fn expr(&mut self) -> Result<Value, DocError> {
|
||||
self.enter()?;
|
||||
let mut a = self.term()?;
|
||||
while let Some(Tok::Op(c @ ('+' | '-'))) = self.peek().cloned() {
|
||||
self.pos += 1;
|
||||
let b = self.term()?;
|
||||
a = add(a, b, c == '-', self.defaults)?;
|
||||
}
|
||||
self.depth -= 1;
|
||||
Ok(a)
|
||||
}
|
||||
fn term(&mut self) -> Result<Value, DocError> {
|
||||
let mut a = self.unary()?;
|
||||
loop {
|
||||
match self.peek().cloned() {
|
||||
Some(Tok::Op('*')) => {
|
||||
self.pos += 1;
|
||||
let b = self.unary()?;
|
||||
a = mul(a, b, false)?;
|
||||
}
|
||||
Some(Tok::Op('/')) => {
|
||||
self.pos += 1;
|
||||
let b = self.unary()?;
|
||||
a = mul(a, b, true)?;
|
||||
}
|
||||
Some(Tok::Op('%')) => {
|
||||
self.pos += 1;
|
||||
let b = self.unary()?;
|
||||
a = modulo(a, b, self.defaults)?;
|
||||
}
|
||||
// Juxtaposition: `10 mm`, `2 width`.
|
||||
Some(Tok::Id(_) | Tok::Num(_)) | Some(Tok::Op('(')) => {
|
||||
let b = self.unary()?;
|
||||
a = mul(a, b, false)?;
|
||||
}
|
||||
_ => return Ok(a),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn unary(&mut self) -> Result<Value, DocError> {
|
||||
match self.peek() {
|
||||
Some(Tok::Op('-')) => {
|
||||
self.pos += 1;
|
||||
self.enter()?;
|
||||
let v = self.unary()?;
|
||||
self.depth -= 1;
|
||||
Ok(Value { v: -v.v, ..v })
|
||||
}
|
||||
Some(Tok::Op('+')) => {
|
||||
self.pos += 1;
|
||||
self.enter()?;
|
||||
let v = self.unary()?;
|
||||
self.depth -= 1;
|
||||
Ok(v)
|
||||
}
|
||||
_ => self.power(),
|
||||
}
|
||||
}
|
||||
fn power(&mut self) -> Result<Value, DocError> {
|
||||
let base = self.atom()?;
|
||||
if let Some(Tok::Op('^')) = self.peek() {
|
||||
self.pos += 1;
|
||||
self.enter()?;
|
||||
let e = self.unary()?;
|
||||
self.depth -= 1;
|
||||
return pow(base, e);
|
||||
}
|
||||
Ok(base)
|
||||
}
|
||||
fn atom(&mut self) -> Result<Value, DocError> {
|
||||
match self.next() {
|
||||
Some(Tok::Num(v)) => Ok(Value::num(v)),
|
||||
Some(Tok::Op('(')) => {
|
||||
let v = self.expr()?;
|
||||
match self.next() {
|
||||
Some(Tok::Op(')')) => Ok(v),
|
||||
_ => Err(DocError::Expr("missing `)`".into())),
|
||||
}
|
||||
}
|
||||
Some(Tok::Id(id)) => {
|
||||
if let Some(Tok::Op('(')) = self.peek() {
|
||||
self.pos += 1;
|
||||
let mut args = Vec::new();
|
||||
if !matches!(self.peek(), Some(Tok::Op(')'))) {
|
||||
loop {
|
||||
if args.len() >= MAX_ARGS {
|
||||
return Err(DocError::Expr(format!("too many arguments to `{id}`")));
|
||||
}
|
||||
args.push(self.expr()?);
|
||||
match self.next() {
|
||||
Some(Tok::Op(',')) => continue,
|
||||
Some(Tok::Op(')')) => break,
|
||||
_ => return Err(DocError::Expr(format!("missing `)` after the arguments of `{id}`"))),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.pos += 1;
|
||||
}
|
||||
return call(&id, &args, self.defaults);
|
||||
}
|
||||
match id.as_str() {
|
||||
"pi" | "PI" => return Ok(Value::num(std::f64::consts::PI)),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(u) = unit(&id) {
|
||||
return Ok(u);
|
||||
}
|
||||
(self.lookup)(&id)
|
||||
}
|
||||
Some(Tok::Op(c)) => Err(DocError::Expr(format!("unexpected `{c}`"))),
|
||||
None => Err(DocError::Expr("unexpected end of expression".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit-less number next to a dimensioned value, in that value's default unit.
|
||||
fn scale_bare(x: Value, len: i8, ang: i8, d: Defaults) -> f64 {
|
||||
if !x.unitless() {
|
||||
x.v
|
||||
} else if len == 1 && ang == 0 {
|
||||
x.v * d.len
|
||||
} else if ang == 1 && len == 0 {
|
||||
x.v * d.ang
|
||||
} else {
|
||||
x.v
|
||||
}
|
||||
}
|
||||
|
||||
fn add(a: Value, b: Value, sub: bool, d: Defaults) -> Result<Value, DocError> {
|
||||
let (len, ang) = if a.unitless() {
|
||||
(b.len, b.ang)
|
||||
} else if b.unitless() || (a.len == b.len && a.ang == b.ang) {
|
||||
(a.len, a.ang)
|
||||
} else {
|
||||
return Err(DocError::Expr(format!("cannot {} {} and {}", if sub { "subtract" } else { "add" }, a.describe(), b.describe())));
|
||||
};
|
||||
let (x, y) = (scale_bare(a, len, ang, d), scale_bare(b, len, ang, d));
|
||||
Ok(Value { v: if sub { x - y } else { x + y }, len, ang })
|
||||
}
|
||||
|
||||
fn dims(a: i8, b: i8, sub: bool) -> Result<i8, DocError> {
|
||||
let r = if sub { a.checked_sub(b) } else { a.checked_add(b) };
|
||||
r.filter(|x| x.abs() <= 16).ok_or_else(|| DocError::Expr("unit powers too large".into()))
|
||||
}
|
||||
|
||||
fn mul(a: Value, b: Value, div: bool) -> Result<Value, DocError> {
|
||||
if div {
|
||||
if b.v == 0.0 {
|
||||
return Err(DocError::Expr("division by zero".into()));
|
||||
}
|
||||
Ok(Value { v: a.v / b.v, len: dims(a.len, b.len, true)?, ang: dims(a.ang, b.ang, true)? })
|
||||
} else {
|
||||
Ok(Value { v: a.v * b.v, len: dims(a.len, b.len, false)?, ang: dims(a.ang, b.ang, false)? })
|
||||
}
|
||||
}
|
||||
|
||||
fn modulo(a: Value, b: Value, d: Defaults) -> Result<Value, DocError> {
|
||||
let (len, ang) = if a.unitless() { (b.len, b.ang) } else { (a.len, a.ang) };
|
||||
if !(a.unitless() || b.unitless() || (a.len == b.len && a.ang == b.ang)) {
|
||||
return Err(DocError::Expr(format!("cannot take {} modulo {}", a.describe(), b.describe())));
|
||||
}
|
||||
let (x, y) = (scale_bare(a, len, ang, d), scale_bare(b, len, ang, d));
|
||||
if y == 0.0 {
|
||||
return Err(DocError::Expr("modulo by zero".into()));
|
||||
}
|
||||
Ok(Value { v: x.rem_euclid(y), len, ang })
|
||||
}
|
||||
|
||||
fn pow(base: Value, e: Value) -> Result<Value, DocError> {
|
||||
if !e.unitless() {
|
||||
return Err(DocError::Expr(format!("an exponent must be a unit-less number, not {}", e.describe())));
|
||||
}
|
||||
let n = e.v;
|
||||
let k = if (n - n.round()).abs() < 1e-12 && n.abs() < 16.0 { n.round() as i8 } else { 0 };
|
||||
if !base.unitless() && k == 0 {
|
||||
return Err(DocError::Expr("a value with units needs a small whole-number exponent".into()));
|
||||
}
|
||||
Ok(Value { v: base.v.powf(n), len: dims(0, base.len.saturating_mul(k), false)?, ang: dims(0, base.ang.saturating_mul(k), false)? })
|
||||
}
|
||||
|
||||
fn call(f: &str, args: &[Value], d: Defaults) -> Result<Value, DocError> {
|
||||
let one = || match args {
|
||||
[a] => Ok(*a),
|
||||
_ => Err(DocError::Expr(format!("`{f}` takes one argument"))),
|
||||
};
|
||||
let two = || match args {
|
||||
[a, b] => Ok((*a, *b)),
|
||||
_ => Err(DocError::Expr(format!("`{f}` takes two arguments"))),
|
||||
};
|
||||
let rad = |a: Value| {
|
||||
if a.ang == 1 && a.len == 0 {
|
||||
Ok(a.v)
|
||||
} else if a.unitless() {
|
||||
Ok(a.v * d.ang)
|
||||
} else {
|
||||
Err(DocError::Expr(format!("`{f}` needs an angle, not {}", a.describe())))
|
||||
}
|
||||
};
|
||||
let num = |a: Value| if a.unitless() { Ok(a.v) } else { Err(DocError::Expr(format!("`{f}` needs a unit-less number, not {}", a.describe()))) };
|
||||
let same = |a: Value, b: Value| -> Result<(Value, Value), DocError> {
|
||||
// Bring a bare number to the other operand's unit (as in `a + b`).
|
||||
let z = add(a, Value { v: 0.0, ..b }, false, d)?;
|
||||
let w = add(b, Value { v: 0.0, ..a }, false, d)?;
|
||||
Ok((z, w))
|
||||
};
|
||||
let unit_fn = |x: f64, a: Value| Value { v: x, ..a };
|
||||
Ok(match f {
|
||||
"sin" => Value::num(rad(one()?)?.sin()),
|
||||
"cos" => Value::num(rad(one()?)?.cos()),
|
||||
"tan" => Value::num(rad(one()?)?.tan()),
|
||||
"asin" | "acos" => {
|
||||
let x = num(one()?)?;
|
||||
if !(-1.0..=1.0).contains(&x) {
|
||||
return Err(DocError::Expr(format!("`{f}` needs a number between -1 and 1")));
|
||||
}
|
||||
Value::angle(if f == "asin" { x.asin() } else { x.acos() })
|
||||
}
|
||||
"atan" => Value::angle(num(one()?)?.atan()),
|
||||
"atan2" => {
|
||||
let (y, x) = two()?;
|
||||
let (y, x) = same(y, x)?;
|
||||
if y.len != x.len || y.ang != x.ang {
|
||||
return Err(DocError::Expr("`atan2` needs two values with the same units".into()));
|
||||
}
|
||||
Value::angle(y.v.atan2(x.v))
|
||||
}
|
||||
"sqrt" => {
|
||||
let a = one()?;
|
||||
if a.len % 2 != 0 || a.ang % 2 != 0 {
|
||||
return Err(DocError::Expr("sqrt of an odd power of a unit".into()));
|
||||
}
|
||||
if a.v < 0.0 {
|
||||
return Err(DocError::Expr("sqrt of a negative number".into()));
|
||||
}
|
||||
Value { v: a.v.sqrt(), len: a.len / 2, ang: a.ang / 2 }
|
||||
}
|
||||
"abs" => {
|
||||
let a = one()?;
|
||||
unit_fn(a.v.abs(), a)
|
||||
}
|
||||
"sign" => Value::num(one()?.v.signum()),
|
||||
"floor" | "ceil" | "round" | "trunc" => {
|
||||
let a = one()?;
|
||||
// Round in the value's own default unit (round(2.4 in) = 2 in for an inch parameter).
|
||||
let scale = scale_bare(Value::num(1.0), a.len, a.ang, d);
|
||||
let x = if scale != 0.0 { a.v / scale } else { a.v };
|
||||
let r = match f {
|
||||
"floor" => x.floor(),
|
||||
"ceil" => x.ceil(),
|
||||
"trunc" => x.trunc(),
|
||||
_ => x.round(),
|
||||
};
|
||||
unit_fn(r * if scale != 0.0 { scale } else { 1.0 }, a)
|
||||
}
|
||||
"exp" => Value::num(num(one()?)?.exp()),
|
||||
"ln" | "log" | "log10" => {
|
||||
let x = num(one()?)?;
|
||||
if x <= 0.0 {
|
||||
return Err(DocError::Expr(format!("`{f}` needs a positive number")));
|
||||
}
|
||||
Value::num(if f == "ln" { x.ln() } else { x.log10() })
|
||||
}
|
||||
"hypot" => {
|
||||
let (a, b) = two()?;
|
||||
let (a, b) = same(a, b)?;
|
||||
if a.len != b.len || a.ang != b.ang {
|
||||
return Err(DocError::Expr("`hypot` needs two values with the same units".into()));
|
||||
}
|
||||
unit_fn(a.v.hypot(b.v), a)
|
||||
}
|
||||
"pow" => {
|
||||
let (a, b) = two()?;
|
||||
pow(a, b)?
|
||||
}
|
||||
"mod" => {
|
||||
let (a, b) = two()?;
|
||||
modulo(a, b, d)?
|
||||
}
|
||||
"min" | "max" => {
|
||||
let Some(first) = args.first().copied() else { return Err(DocError::Expr(format!("`{f}` needs arguments"))) };
|
||||
let mut acc = first;
|
||||
for a in args.iter().skip(1) {
|
||||
let (x, y) = same(*a, acc)?;
|
||||
if x.len != y.len || x.ang != y.ang {
|
||||
return Err(DocError::Expr(format!("`{f}` needs values with the same units")));
|
||||
}
|
||||
acc = if (f == "min" && x.v < y.v) || (f == "max" && x.v > y.v) { x } else { y };
|
||||
}
|
||||
acc
|
||||
}
|
||||
_ => return Err(DocError::Expr(format!("unknown function `{f}`"))),
|
||||
})
|
||||
}
|
||||
|
||||
/// Evaluate an expression; `lookup` resolves parameter names. Bare numbers are mm / degrees.
|
||||
pub fn eval_with(s: &str, lookup: &dyn Fn(&str) -> Result<Value, DocError>) -> Result<Value, DocError> {
|
||||
eval_with_defaults(s, lookup, Defaults::default())
|
||||
}
|
||||
|
||||
/// Evaluate an expression reading bare numbers next to lengths / angles in `defaults`.
|
||||
pub fn eval_with_defaults(s: &str, lookup: &dyn Fn(&str) -> Result<Value, DocError>, defaults: Defaults) -> Result<Value, DocError> {
|
||||
let toks = lex(s)?;
|
||||
if toks.is_empty() {
|
||||
return Err(DocError::Expr("empty expression".into()));
|
||||
}
|
||||
let mut p = Parser { toks, pos: 0, lookup, depth: 0, defaults };
|
||||
let v = p.expr()?;
|
||||
if p.pos < p.toks.len() {
|
||||
return Err(DocError::Expr(format!("unexpected input in `{s}`")));
|
||||
}
|
||||
if !v.v.is_finite() {
|
||||
return Err(DocError::Expr(format!("`{s}` is not a finite number")));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// One parameter to evaluate: name, expression and unit (`mm`, `in`, `deg`, empty…).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ParamDef {
|
||||
pub name: String,
|
||||
pub expr: String,
|
||||
pub unit: String,
|
||||
}
|
||||
|
||||
/// Evaluate all parameters in dependency order: values (mm / rad / unit-less) and errors by
|
||||
/// name. Circular references are reported with their path (`a → b → a`). `doc_len` is the
|
||||
/// document's length unit in mm (bare numbers of unit-less-unit parameters next to lengths).
|
||||
pub fn eval_params(params: &[ParamDef], doc_len: f64) -> (BTreeMap<String, Value>, BTreeMap<String, String>) {
|
||||
let mut done: BTreeMap<String, Value> = BTreeMap::new();
|
||||
let mut errors: BTreeMap<String, String> = BTreeMap::new();
|
||||
let index: BTreeMap<&str, &ParamDef> = params.iter().map(|p| (p.name.as_str(), p)).collect();
|
||||
struct Ctx<'a> {
|
||||
index: BTreeMap<&'a str, &'a ParamDef>,
|
||||
done: BTreeMap<String, Value>,
|
||||
errors: BTreeMap<String, String>,
|
||||
stack: Vec<String>,
|
||||
doc_len: f64,
|
||||
}
|
||||
fn visit(name: &str, cx: &mut Ctx) -> Result<Value, DocError> {
|
||||
if let Some(v) = cx.done.get(name) {
|
||||
return Ok(*v);
|
||||
}
|
||||
if let Some(e) = cx.errors.get(name) {
|
||||
return Err(DocError::Expr(format!("`{name}` has an error: {e}")));
|
||||
}
|
||||
let Some(p) = cx.index.get(name).copied() else {
|
||||
return Err(DocError::Expr(format!("unknown parameter `{name}`")));
|
||||
};
|
||||
if let Some(at) = cx.stack.iter().position(|s| s == name) {
|
||||
let mut path: Vec<&str> = cx.stack.iter().skip(at).map(String::as_str).collect();
|
||||
path.push(name);
|
||||
return Err(DocError::Expr(format!("circular reference: {}", path.join(" → "))));
|
||||
}
|
||||
if cx.stack.len() > 512 {
|
||||
return Err(DocError::Expr("parameters nested too deeply".into()));
|
||||
}
|
||||
let Some((kind, _)) = unit_info(&p.unit).or(Some((Kind::Length, 1.0))) else {
|
||||
return Err(DocError::Expr(format!("unknown unit `{}`", p.unit)));
|
||||
};
|
||||
let defaults = Defaults::for_unit(&p.unit, cx.doc_len);
|
||||
cx.stack.push(name.to_string());
|
||||
let mut vals: BTreeMap<String, Value> = BTreeMap::new();
|
||||
let mut err = None;
|
||||
for d in references(&p.expr) {
|
||||
match visit(&d, cx) {
|
||||
Ok(v) => {
|
||||
vals.insert(d, v);
|
||||
}
|
||||
Err(e) => {
|
||||
err = Some(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.stack.pop();
|
||||
let r = match err {
|
||||
Some(e) => Err(e),
|
||||
None => {
|
||||
eval_with_defaults(&p.expr, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))), defaults)
|
||||
.and_then(|v| {
|
||||
let x = v.to_kind_in(kind, defaults)?;
|
||||
Ok(match kind {
|
||||
Kind::Length => Value::length(x),
|
||||
Kind::Angle => Value::angle(x),
|
||||
Kind::Unitless => Value::num(x),
|
||||
})
|
||||
})
|
||||
}
|
||||
};
|
||||
match &r {
|
||||
Ok(v) => {
|
||||
cx.done.insert(name.to_string(), *v);
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
cx.errors.insert(name.to_string(), msg.strip_prefix("expression: ").unwrap_or(&msg).to_string());
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
let mut cx = Ctx { index, done: std::mem::take(&mut done), errors: std::mem::take(&mut errors), stack: Vec::new(), doc_len };
|
||||
for p in params {
|
||||
cx.stack.clear();
|
||||
let _ = visit(&p.name, &mut cx);
|
||||
}
|
||||
(cx.done, cx.errors)
|
||||
}
|
||||
|
||||
/// Dependency edges between parameters: (parameter, parameter it references).
|
||||
pub fn param_edges(params: &[ParamDef]) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for p in params {
|
||||
for r in references(&p.expr) {
|
||||
out.push((p.name.clone(), r));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ev(s: &str, k: Kind) -> Result<f64, DocError> {
|
||||
let look = |n: &str| match n {
|
||||
"width" => Ok(Value::length(40.0)),
|
||||
"ang" => Ok(Value::angle(std::f64::consts::FRAC_PI_2)),
|
||||
"count" => Ok(Value::num(3.0)),
|
||||
_ => Err(DocError::Expr(format!("unknown {n}"))),
|
||||
};
|
||||
eval_with(s, &look)?.to_kind(k)
|
||||
}
|
||||
|
||||
fn def(n: &str, e: &str, u: &str) -> ParamDef {
|
||||
ParamDef { name: n.into(), expr: e.into(), unit: u.into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_and_units() {
|
||||
assert_eq!(ev("10", Kind::Length).unwrap(), 10.0);
|
||||
assert!((ev("90 deg", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
|
||||
assert_eq!(ev("10 mm", Kind::Length).unwrap(), 10.0);
|
||||
assert_eq!(ev("1 in", Kind::Length).unwrap(), 25.4);
|
||||
assert_eq!(ev("2 cm + 5", Kind::Length).unwrap(), 25.0);
|
||||
assert_eq!(ev("width / 2 + 1", Kind::Length).unwrap(), 21.0);
|
||||
assert_eq!(ev("width * count", Kind::Length).unwrap(), 120.0);
|
||||
assert_eq!(ev("-(2+3)*2^2", Kind::Unitless).unwrap(), -20.0);
|
||||
assert!((ev("45", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_4).abs() < 1e-12);
|
||||
assert!((ev("ang / 2", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_4).abs() < 1e-12);
|
||||
assert!((ev("ang + 90", Kind::Angle).unwrap() - std::f64::consts::PI).abs() < 1e-12);
|
||||
assert!((ev("sqrt((3 mm) ^ 2 + (4 mm) ^ 2)", Kind::Length).unwrap() - 5.0).abs() < 1e-12);
|
||||
assert!((ev("sin(30)", Kind::Unitless).unwrap() - 0.5).abs() < 1e-12);
|
||||
assert!((ev("2*pi", Kind::Unitless).unwrap() - std::f64::consts::TAU).abs() < 1e-12);
|
||||
assert_eq!(ev("max(1 mm, 3, 2)", Kind::Length).unwrap(), 3.0);
|
||||
assert_eq!(ev("min(1 in, 30 mm)", Kind::Length).unwrap(), 25.4);
|
||||
assert_eq!(ev("1e-3 m", Kind::Length).unwrap(), 1.0);
|
||||
assert_eq!(ev("1 ft - 1 in", Kind::Length).unwrap(), 304.8 - 25.4);
|
||||
assert!((ev("atan2(1 mm, 1 mm)", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_4).abs() < 1e-12);
|
||||
assert!((ev("atan2(1; 1)", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_4).abs() < 1e-12);
|
||||
assert!((ev("exp(ln(5))", Kind::Unitless).unwrap() - 5.0).abs() < 1e-12);
|
||||
assert!((ev("log(1000)", Kind::Unitless).unwrap() - 3.0).abs() < 1e-12);
|
||||
assert_eq!(ev("floor(2.7) + ceil(0.2) + round(1.5) + abs(-1)", Kind::Unitless).unwrap(), 6.0);
|
||||
assert!((ev("1 rad", Kind::Angle).unwrap() - 1.0).abs() < 1e-12);
|
||||
assert!((ev("acos(0)", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
|
||||
assert_eq!(ev("2 × 3 ÷ 4 − 1", Kind::Unitless).unwrap(), 0.5);
|
||||
assert_eq!(ev("hypot(3 mm, 4 mm)", Kind::Length).unwrap(), 5.0);
|
||||
assert_eq!(ev("7 % 4", Kind::Unitless).unwrap(), 3.0);
|
||||
assert_eq!(ev("width^2 / width", Kind::Length).unwrap(), 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors() {
|
||||
assert!(ev("width + ang", Kind::Length).is_err());
|
||||
let e = ev("10 mm + 5 deg", Kind::Length).unwrap_err().to_string();
|
||||
assert!(e.contains("cannot add a length and an angle"), "{e}");
|
||||
assert!(ev("width * width", Kind::Length).unwrap_err().to_string().contains("expected a length"));
|
||||
assert!(ev("nope", Kind::Length).is_err());
|
||||
assert!(ev("(1", Kind::Length).is_err());
|
||||
assert!(ev("1 / 0", Kind::Length).is_err());
|
||||
assert!(ev("", Kind::Length).is_err());
|
||||
assert!(ev("1 $ 2", Kind::Length).is_err());
|
||||
assert!(ev("sqrt(-1)", Kind::Unitless).is_err());
|
||||
assert!(ev("asin(2)", Kind::Angle).is_err());
|
||||
assert!(ev("ln(0)", Kind::Unitless).is_err());
|
||||
assert!(ev("sin(1 mm)", Kind::Unitless).is_err());
|
||||
assert!(ev("atan2(1 mm, 1 deg)", Kind::Angle).is_err());
|
||||
assert!(ev("10 ^ 400", Kind::Unitless).is_err());
|
||||
assert!(ev("width ^ 1.5", Kind::Unitless).is_err());
|
||||
assert!(ev("max()", Kind::Unitless).is_err());
|
||||
assert!(ev("frob(1)", Kind::Unitless).is_err());
|
||||
let deep = "(".repeat(500) + "1" + &")".repeat(500);
|
||||
assert!(ev(&deep, Kind::Length).is_err());
|
||||
assert!(ev(&"-".repeat(500), Kind::Length).is_err());
|
||||
assert!(ev(&"+".repeat(500), Kind::Length).is_err());
|
||||
assert!(ev(&"width*".repeat(100), Kind::Length).is_err());
|
||||
assert!(ev(&("2^".repeat(200) + "2"), Kind::Unitless).is_err());
|
||||
}
|
||||
|
||||
/// Hostile input never panics: random strings over the expression alphabet.
|
||||
#[test]
|
||||
fn fuzz_never_panics() {
|
||||
let alphabet: Vec<char> = "0123456789.eE+-*/^(),;% abcdmnpitrsgxw_°×÷−µ\u{0}\u{7f}é\"'".chars().collect();
|
||||
let words = ["width", "sin(", "atan2(", "mm", "deg", "pi", "1e308", "1e-400", "(", ")", "^", "max(", "in", "count", ","];
|
||||
let mut seed: u64 = 0x9e37_79b9_7f4a_7c15;
|
||||
let mut rnd = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed
|
||||
};
|
||||
for _ in 0..20_000 {
|
||||
let n = (rnd() % 40) as usize;
|
||||
let mut s = String::new();
|
||||
for _ in 0..n {
|
||||
if rnd() % 4 == 0 {
|
||||
s.push_str(words[(rnd() % words.len() as u64) as usize]);
|
||||
} else {
|
||||
s.push(alphabet[(rnd() % alphabet.len() as u64) as usize]);
|
||||
}
|
||||
}
|
||||
for k in [Kind::Length, Kind::Angle, Kind::Unitless] {
|
||||
let _ = ev(&s, k);
|
||||
}
|
||||
let _ = references(&s);
|
||||
let _ = rename_reference(&s, "width", "w2");
|
||||
let _ = is_literal(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_in_order_with_cycles() {
|
||||
let p = vec![def("b", "a * 2", "mm"), def("a", "10 mm", "mm"), def("x", "y", "mm"), def("y", "z + 1", "mm"), def("z", "x", "mm")];
|
||||
let (v, e) = eval_params(&p, 1.0);
|
||||
assert_eq!(v.get("b").unwrap().v, 20.0);
|
||||
assert!(e.contains_key("x") && e.contains_key("y") && e.contains_key("z"));
|
||||
assert!(e["x"].contains("circular reference: x → y → z → x"), "{}", e["x"]);
|
||||
assert_eq!(references("2*width + sin(ang) + 3 mm + pi"), vec!["width".to_string(), "ang".to_string()]);
|
||||
let (_, e) = eval_params(&[def("s", "s", "mm")], 1.0);
|
||||
assert!(e["s"].contains("s → s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_units() {
|
||||
let p = vec![
|
||||
def("a", "2", "in"),
|
||||
def("b", "a + 1", "in"),
|
||||
def("c", "b", "mm"),
|
||||
def("t", "0.5", "rad"),
|
||||
def("u", "t + 1", "rad"),
|
||||
def("k", "3", ""),
|
||||
def("bad", "a + t", "mm"),
|
||||
def("mixed", "1 in + 2", "mm"),
|
||||
];
|
||||
let (v, e) = eval_params(&p, 1.0);
|
||||
assert!((v["a"].v - 50.8).abs() < 1e-9);
|
||||
assert!((v["b"].v - 76.2).abs() < 1e-9);
|
||||
assert!((v["c"].v - 76.2).abs() < 1e-9);
|
||||
assert!((v["u"].v - 1.5).abs() < 1e-12);
|
||||
assert_eq!(v["k"], Value::num(3.0));
|
||||
assert!(e["bad"].contains("cannot add"));
|
||||
assert!((v["mixed"].v - 27.4).abs() < 1e-9);
|
||||
assert!((value_in_unit(v["b"], "in") - 3.0).abs() < 1e-12);
|
||||
assert!((value_in_unit(Value::angle(std::f64::consts::PI), "deg") - 180.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_and_literals() {
|
||||
assert_eq!(rename_reference("width*2 + widths + width(1) + sin(width)", "width", "w"), "w*2 + widths + width(1) + sin(w)");
|
||||
assert_eq!(rename_reference("10 mm", "mm", "x"), "10 mm");
|
||||
assert!(is_literal("20") && is_literal("-1.5 in") && is_literal("3 deg"));
|
||||
assert!(!is_literal("d1") && !is_literal("2 * 3") && !is_literal("2 width"));
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
//! The design file format version and the migrations that bring older files up to date.
|
||||
//!
|
||||
//! A design file starts with `"format": "solvecraft/N"`. A file of an older format is read and
|
||||
//! upgraded step by step (N → N+1 → … → current): JSON-level steps first, for changes in the
|
||||
//! file's shape, then document-level steps. A file of a newer format than this build knows is
|
||||
//! refused with a message saying so, never guessed at.
|
||||
//!
|
||||
//! History:
|
||||
//! - 1: every design up to 2026-10-08 (format string since M0). Fields added over time all
|
||||
//! have defaults; designs from before feature input names and before occurrences are filled
|
||||
//! in on upgrade.
|
||||
//! - 2: the same shape; files say 2 once they have been upgraded, so later readers can tell.
|
||||
//! - 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.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 5;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solvecraft/{n}")
|
||||
}
|
||||
|
||||
/// The version in a format string (`solvecraft/N`).
|
||||
pub fn version_of(s: &str) -> Option<u32> {
|
||||
s.strip_prefix("solvecraft/")?.parse().ok()
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// Joints placed with the old convention (aligned unless flipped) keep their placement.
|
||||
fn v3_to_v4(v: &mut Value) -> Result<()> {
|
||||
if let Some(Value::Array(js)) = v.get_mut("assembly").and_then(|a| a.get_mut("joints")) {
|
||||
for j in js.iter_mut() {
|
||||
if let Some(o) = j.as_object_mut() {
|
||||
let f = o.get("flip").and_then(Value::as_bool).unwrap_or(false);
|
||||
o.insert("flip".into(), Value::Bool(!f));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Body colours `{name: [r, g, b]}` become named, opaque body appearances.
|
||||
fn v2_to_v3(v: &mut Value) -> Result<()> {
|
||||
let Some(Value::Object(old)) = v.get("appearances") else { return Ok(()) };
|
||||
if !old.values().all(Value::is_array) {
|
||||
return Ok(());
|
||||
}
|
||||
let bodies: serde_json::Map<String, Value> =
|
||||
old.iter().map(|(k, c)| (k.clone(), serde_json::json!({"name": "Custom", "color": c, "opacity": 1.0}))).collect();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("appearances".into(), serde_json::json!({ "bodies": bodies }));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a design file's JSON, upgrading older formats.
|
||||
pub fn read(s: &str) -> Result<Document> {
|
||||
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolveCraft design: {e}")))?;
|
||||
let Some(obj) = v.as_object() else {
|
||||
return Err(DocError::Invalid("not a SolveCraft design (expected a JSON object)".into()));
|
||||
};
|
||||
let version = match obj.get("format") {
|
||||
None => 1,
|
||||
Some(Value::String(f)) => version_of(f).ok_or_else(|| DocError::Invalid(format!("not a SolveCraft design (format `{f}`)")))?,
|
||||
Some(_) => return Err(DocError::Invalid("not a SolveCraft design (format is not a string)".into())),
|
||||
};
|
||||
if version == 0 {
|
||||
return Err(DocError::Invalid("not a SolveCraft design (format 0)".into()));
|
||||
}
|
||||
if version > FORMAT {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"this design was saved by a newer SolveCraft (file format {version}; this version reads formats up to {FORMAT}): update SolveCraft to open it"
|
||||
)));
|
||||
}
|
||||
check_numbers(&v, 0)?;
|
||||
for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) {
|
||||
step(&mut v)?;
|
||||
}
|
||||
let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
|
||||
if d.features.len() > crate::document::MAX_FEATURES
|
||||
|| d.params.len() > crate::document::MAX_PARAMS
|
||||
|| d.components.len() > crate::document::MAX_FEATURES
|
||||
{
|
||||
return Err(DocError::Invalid("document too large".into()));
|
||||
}
|
||||
for o in &d.occurrences {
|
||||
if !rigid(&o.transform) {
|
||||
return Err(DocError::Invalid(format!("damaged design: occurrence `{}` has an invalid placement", o.name)));
|
||||
}
|
||||
}
|
||||
// Fill in what older designs (and hand-edited files) may lack; a no-op otherwise.
|
||||
normalise(&mut d)?;
|
||||
d.format = format_string(FORMAT);
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Largest coordinate or size a design may hold (mm): a kilometre-scale part is already far
|
||||
/// outside what the kernel's tolerances serve.
|
||||
const MAX_REAL: f64 = 1e9;
|
||||
|
||||
/// Every non-integer number in the file is finite and within ±`MAX_REAL` (a damaged or hostile
|
||||
/// file is refused instead of overflowing the geometry later).
|
||||
fn check_numbers(v: &Value, depth: usize) -> Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(DocError::Invalid("damaged design: nested too deeply".into()));
|
||||
}
|
||||
match v {
|
||||
Value::Number(n) if !(n.is_u64() || n.is_i64()) => {
|
||||
let x = n.as_f64().unwrap_or(f64::NAN);
|
||||
if !(x.is_finite() && x.abs() <= MAX_REAL) {
|
||||
return Err(DocError::Invalid(format!("damaged design: the number {n} is out of range")));
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter().try_for_each(|x| check_numbers(x, depth + 1))?,
|
||||
Value::Object(m) => m.values().try_for_each(|x| check_numbers(x, depth + 1))?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A placement: rotation (orthonormal, not mirrored) and a translation in range.
|
||||
fn rigid(m: &crate::Mat) -> bool {
|
||||
let row = |i: usize| m.get(i).copied().unwrap_or_default();
|
||||
let (x, y, z, t) = (row(0), row(1), row(2), row(3));
|
||||
let dot = |a: [f64; 4], b: [f64; 4]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
let unit = |a: [f64; 4]| (dot(a, a) - 1.0).abs() < 1e-6;
|
||||
let det = x[0] * (y[1] * z[2] - y[2] * z[1]) - x[1] * (y[0] * z[2] - y[2] * z[0]) + x[2] * (y[0] * z[1] - y[1] * z[0]);
|
||||
m.iter().flatten().all(|v| v.is_finite())
|
||||
&& unit(x)
|
||||
&& unit(y)
|
||||
&& unit(z)
|
||||
&& dot(x, y).abs() < 1e-6
|
||||
&& dot(y, z).abs() < 1e-6
|
||||
&& dot(x, z).abs() < 1e-6
|
||||
&& det > 0.0
|
||||
&& [x[3], y[3], z[3]].iter().all(|v| v.abs() < 1e-9)
|
||||
&& (t[3] - 1.0).abs() < 1e-9
|
||||
&& t[..3].iter().all(|v| v.abs() <= MAX_REAL)
|
||||
}
|
||||
|
||||
/// Feature input names and component occurrences, which format 1 files may predate.
|
||||
fn normalise(d: &mut Document) -> Result<()> {
|
||||
let ids: Vec<u64> = d.features.iter().map(|f| f.id).collect();
|
||||
for id in ids {
|
||||
d.name_feature_inputs(id);
|
||||
}
|
||||
let missing: Vec<(u64, u64)> =
|
||||
d.components.iter().filter(|c| !d.occurrences.iter().any(|o| o.component == c.id)).map(|c| (c.id, c.parent)).collect();
|
||||
for (c, p) in missing {
|
||||
d.add_occurrence(c, p, crate::IDENTITY)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn versions() {
|
||||
assert_eq!(version_of("solvecraft/1"), Some(1));
|
||||
assert_eq!(version_of("solvecraft/x"), None);
|
||||
let d = Document::new("A");
|
||||
assert_eq!(d.format, format_string(FORMAT));
|
||||
let back = read(&d.to_json()).unwrap();
|
||||
assert_eq!(back, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_upgraded_newer_refused_others_rejected() {
|
||||
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
|
||||
v["format"] = Value::from("solvecraft/1");
|
||||
assert_eq!(read(&v.to_string()).unwrap().format, format_string(FORMAT));
|
||||
v.as_object_mut().unwrap().remove("format");
|
||||
assert!(read(&v.to_string()).is_ok(), "a file without a format is format 1");
|
||||
v["format"] = Value::from(format_string(FORMAT + 1));
|
||||
let e = read(&v.to_string()).unwrap_err().to_string();
|
||||
assert!(e.contains("newer SolveCraft"), "{e}");
|
||||
for bad in ["solvecraft/0", "acad/3", "solvecraft/-1", "solvecraft/99999999999999999999"] {
|
||||
v["format"] = Value::from(bad);
|
||||
assert!(read(&v.to_string()).is_err(), "{bad}");
|
||||
}
|
||||
assert!(read("[]").is_err());
|
||||
// Format 2 body colours become body appearances.
|
||||
let mut old: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
|
||||
old["format"] = Value::from("solvecraft/2");
|
||||
old["appearances"] = serde_json::json!({"Body1": [200, 10, 20]});
|
||||
let d = read(&old.to_string()).unwrap();
|
||||
let l = d.appearances.bodies.get("Body1").unwrap();
|
||||
// Format 3 joints (aligned unless flipped) keep their placement.
|
||||
let mut j: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
|
||||
j["format"] = Value::from("solvecraft/3");
|
||||
j["assembly"] = serde_json::json!({"joints": [{"id": 1, "name": "J", "kind": "rigid", "a": {"occurrence": 0, "snap": {"type": "point", "point": [0, 0, 0]}}, "b": {"occurrence": 0, "snap": {"type": "point", "point": [0, 0, 0]}}}]});
|
||||
let dj = read(&j.to_string()).unwrap();
|
||||
assert!(dj.assembly.joints[0].flip, "aligned before = not mated now");
|
||||
assert_eq!((l.color, l.opacity, l.name.as_str()), ([200, 10, 20], 1.0, "Custom"));
|
||||
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
|
||||
v["huge"] = Value::from(1e300);
|
||||
assert!(read(&v.to_string()).is_err(), "out-of-range numbers anywhere");
|
||||
assert!(read("{\"format\": 2}").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
//! Joints between occurrences: joint origins (snap frames on component geometry, re-found on
|
||||
//! every solve so they follow edits), joints (rigid, revolute, slider, cylindrical, pin-slot,
|
||||
//! planar, ball) with their motion values and limits, motion links, and the solver that places
|
||||
//! occurrences from the joint graph.
|
||||
//!
|
||||
//! A joint makes B's joint frame coincide with A's frame moved by the joint's motion:
|
||||
//! `T_B · F_b = T_A · F_a · M(q) · Flip`, so `T_B = T_A · F_a · M(q) · Flip · F_b⁻¹`, where `T`
|
||||
//! are occurrence world transforms, `F` the joint frames in each component's own frame, and `q`
|
||||
//! the joint's values (angles in radians, distances in mm). The solver walks the graph from
|
||||
//! grounded occurrences (and the root, which never moves); joints that close a loop are checked
|
||||
//! and reported when they disagree.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::{DocError, Document, IDENTITY, Mat, ModelState, Result, apply_point, apply_vector, mat_inverse, mat_mul};
|
||||
|
||||
const MAX_JOINTS: usize = 10_000;
|
||||
|
||||
/// Where a joint origin sits on a component's geometry (component coordinates).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Snap {
|
||||
/// An explicit frame (origin, z axis, x axis).
|
||||
Frame {
|
||||
origin: Vec3,
|
||||
z: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
x: Option<Vec3>,
|
||||
},
|
||||
/// The centre of the planar face nearest `pick`, z along its outward normal. `face` is the
|
||||
/// face's persistent name (found first).
|
||||
FaceCenter {
|
||||
pick: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
face: Option<String>,
|
||||
},
|
||||
/// The centre of the circular end nearest `pick` of the cylindrical face there, z along its
|
||||
/// axis (pointing out of the face's end).
|
||||
CircleCenter {
|
||||
pick: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
face: Option<String>,
|
||||
},
|
||||
/// A point, z along `z` (default +Z).
|
||||
Point {
|
||||
point: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
z: Option<Vec3>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A joint origin: a snap on an occurrence's component (occurrence 0 = the root design).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct JointOrigin {
|
||||
pub occurrence: u64,
|
||||
pub snap: Snap,
|
||||
}
|
||||
|
||||
/// A named joint origin (Joint Origin command), referenced by joints by name.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NamedOrigin {
|
||||
pub name: String,
|
||||
pub origin: JointOrigin,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum JointKind {
|
||||
Rigid,
|
||||
Revolute,
|
||||
Slider,
|
||||
Cylindrical,
|
||||
PinSlot,
|
||||
Planar,
|
||||
Ball,
|
||||
}
|
||||
|
||||
impl JointKind {
|
||||
pub fn parse(s: &str) -> Option<JointKind> {
|
||||
Some(match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
"rigid" => JointKind::Rigid,
|
||||
"revolute" | "rotate" => JointKind::Revolute,
|
||||
"slider" | "slide" => JointKind::Slider,
|
||||
"cylindrical" => JointKind::Cylindrical,
|
||||
"pinslot" => JointKind::PinSlot,
|
||||
"planar" => JointKind::Planar,
|
||||
"ball" => JointKind::Ball,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Names of the joint's values, in order (angles in radians, distances in mm).
|
||||
pub fn dofs(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
JointKind::Rigid => &[],
|
||||
JointKind::Revolute => &["angle"],
|
||||
JointKind::Slider => &["distance"],
|
||||
JointKind::Cylindrical => &["angle", "distance"],
|
||||
JointKind::PinSlot => &["angle", "slide"],
|
||||
JointKind::Planar => &["x", "y", "angle"],
|
||||
JointKind::Ball => &["yaw", "pitch", "roll"],
|
||||
}
|
||||
}
|
||||
pub fn is_angle(self, i: usize) -> bool {
|
||||
matches!(self.dofs().get(i), Some(&("angle" | "yaw" | "pitch" | "roll")))
|
||||
}
|
||||
}
|
||||
|
||||
/// A joint between two occurrences.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Joint {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub kind: JointKind,
|
||||
pub a: JointOrigin,
|
||||
pub b: JointOrigin,
|
||||
/// Current values of the joint's degrees of freedom.
|
||||
#[serde(default)]
|
||||
pub values: Vec<f64>,
|
||||
/// Per value: (min, max) limits.
|
||||
#[serde(default)]
|
||||
pub limits: Vec<Option<(f64, f64)>>,
|
||||
/// Don't mate: B's z along A's instead of against it (by default the origins mate, as two
|
||||
/// faces meeting).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub flip: bool,
|
||||
/// Distance from A's frame to B's along z, and a turn about it (radians).
|
||||
#[serde(default)]
|
||||
pub offset: f64,
|
||||
#[serde(default)]
|
||||
pub angle: f64,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub suppressed: bool,
|
||||
}
|
||||
|
||||
/// Value of joint `b` (index `ib`) = ratio · value of joint `a` (index `ia`) + offset.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MotionLink {
|
||||
pub a: u64,
|
||||
#[serde(default)]
|
||||
pub ia: usize,
|
||||
pub b: u64,
|
||||
#[serde(default)]
|
||||
pub ib: usize,
|
||||
pub ratio: f64,
|
||||
#[serde(default)]
|
||||
pub offset: f64,
|
||||
}
|
||||
|
||||
/// Joints, joint origins and motion links of a design.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Assembly {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub joints: Vec<Joint>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub origins: Vec<NamedOrigin>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub links: Vec<MotionLink>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub contact_sets: Vec<crate::motion::ContactSet>,
|
||||
/// Contact sets take effect.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub contact_enabled: bool,
|
||||
/// Every occurrence is in contact with every other (Enable All Contact).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub contact_all: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub studies: Vec<crate::motion::MotionStudy>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub exploded: Vec<crate::motion::ExplodedView>,
|
||||
}
|
||||
|
||||
impl Assembly {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.joints.is_empty()
|
||||
&& self.origins.is_empty()
|
||||
&& self.links.is_empty()
|
||||
&& self.contact_sets.is_empty()
|
||||
&& !self.contact_enabled
|
||||
&& !self.contact_all
|
||||
&& self.studies.is_empty()
|
||||
&& self.exploded.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame: origin and orthonormal axes, as a transform (local → parent).
|
||||
fn frame(o: Vec3, z: Vec3, x: Option<Vec3>) -> Option<Mat> {
|
||||
let z = z.normalized()?;
|
||||
let x = x.and_then(|x| (x - z * x.dot(z)).normalized()).unwrap_or_else(|| z.any_perp());
|
||||
let y = z.cross(x).normalized()?;
|
||||
Some([[x.x, x.y, x.z, 0.0], [y.x, y.y, y.z, 0.0], [z.x, z.y, z.z, 0.0], [o.x, o.y, o.z, 1.0]])
|
||||
}
|
||||
|
||||
fn rot_z(a: f64) -> Mat {
|
||||
let (s, c) = a.sin_cos();
|
||||
[[c, s, 0.0, 0.0], [-s, c, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]
|
||||
}
|
||||
fn rot_y(a: f64) -> Mat {
|
||||
let (s, c) = a.sin_cos();
|
||||
[[c, 0.0, -s, 0.0], [0.0, 1.0, 0.0, 0.0], [s, 0.0, c, 0.0], [0.0, 0.0, 0.0, 1.0]]
|
||||
}
|
||||
fn shift(x: f64, y: f64, z: f64) -> Mat {
|
||||
let mut m = IDENTITY;
|
||||
m[3] = [x, y, z, 1.0];
|
||||
m
|
||||
}
|
||||
|
||||
/// The joint's motion in A's frame for its values.
|
||||
pub fn motion(kind: JointKind, q: &[f64]) -> Mat {
|
||||
let v = |i: usize| q.get(i).copied().unwrap_or(0.0);
|
||||
match kind {
|
||||
JointKind::Rigid => IDENTITY,
|
||||
JointKind::Revolute => rot_z(v(0)),
|
||||
JointKind::Slider => shift(0.0, 0.0, v(0)),
|
||||
JointKind::Cylindrical => mat_mul(&rot_z(v(0)), &shift(0.0, 0.0, v(1))),
|
||||
JointKind::PinSlot => mat_mul(&shift(v(1), 0.0, 0.0), &rot_z(v(0))),
|
||||
JointKind::Planar => mat_mul(&shift(v(0), v(1), 0.0), &rot_z(v(2))),
|
||||
JointKind::Ball => mat_mul(&mat_mul(&rot_z(v(0)), &rot_y(v(1))), &rot_z(v(2))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bodies of a component in the model (its own frame).
|
||||
fn component_bodies<'a>(doc: &Document, st: &'a ModelState, component: u64) -> Vec<&'a crate::ModelBody> {
|
||||
st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component).collect()
|
||||
}
|
||||
|
||||
/// The component an occurrence places (0 for the root).
|
||||
fn component_of(doc: &Document, occ: u64) -> Result<u64> {
|
||||
if occ == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
doc.occurrences.iter().find(|o| o.id == occ).map(|o| o.component).ok_or_else(|| DocError::Unknown(format!("occurrence {occ}")))
|
||||
}
|
||||
|
||||
/// Resolve a joint origin to a frame in its component's coordinates.
|
||||
/// A snap's pick point moved onto its named face (an error when the face is gone: a joint must
|
||||
/// not quietly attach elsewhere).
|
||||
fn named_pick(st: &ModelState, pick: Vec3, face: Option<&str>) -> Result<Vec3> {
|
||||
match face {
|
||||
Some(n) => crate::naming::point_on_face(st, n, pick)
|
||||
.map(|(q, _)| q)
|
||||
.ok_or_else(|| DocError::Invalid(format!("joint origin: the face `{n}` no longer exists (edit the joint to pick again)"))),
|
||||
None => Ok(pick),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Result<Mat> {
|
||||
let bad = || DocError::Invalid("joint origin: degenerate frame".into());
|
||||
match &o.snap {
|
||||
Snap::Frame { origin, z, x } => frame(*origin, *z, *x).ok_or_else(bad),
|
||||
Snap::Point { point, z } => frame(*point, z.unwrap_or(Vec3::Z), None).ok_or_else(bad),
|
||||
Snap::FaceCenter { pick, face } => {
|
||||
let pick = &named_pick(st, *pick, face.as_deref())?;
|
||||
let comp = component_of(doc, o.occurrence)?;
|
||||
let mut best: Option<(f64, Vec3, Vec3)> = None;
|
||||
for b in component_bodies(doc, st, comp) {
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
for f in b.body.faces(tol)? {
|
||||
let Some(n) = f.plane_normal else { continue };
|
||||
let d = (*pick - f.centroid).dot(n).abs() + 1e-3 * (*pick - f.centroid).len();
|
||||
if best.as_ref().is_none_or(|(bd, _, _)| d < *bd) {
|
||||
best = Some((d, f.centroid, n));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (_, c, n) = best.ok_or_else(|| DocError::Invalid("joint origin: no planar face there".into()))?;
|
||||
frame(c, n, None).ok_or_else(bad)
|
||||
}
|
||||
Snap::CircleCenter { pick, face } => {
|
||||
let pick = &named_pick(st, *pick, face.as_deref())?;
|
||||
let comp = component_of(doc, o.occurrence)?;
|
||||
let cyl = component_bodies(doc, st, comp)
|
||||
.into_iter()
|
||||
.filter_map(|b| solvecraft_kernel::cylinder_face_at(&b.body, *pick))
|
||||
.min_by(|a, b| {
|
||||
let off = |c: &solvecraft_kernel::CylinderFace| {
|
||||
let v = *pick - c.axis_point;
|
||||
((v - c.axis * v.dot(c.axis)).len() - c.radius).abs()
|
||||
};
|
||||
off(a).total_cmp(&off(b))
|
||||
})
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: no cylindrical face there".into()))?;
|
||||
let s = (*pick - cyl.axis_point).dot(cyl.axis);
|
||||
let (at, dir) = if (s - cyl.start).abs() <= (cyl.end - s).abs() { (cyl.start, -1.0) } else { (cyl.end, 1.0) };
|
||||
frame(cyl.axis_point + cyl.axis * at, cyl.axis * dir, None).ok_or_else(bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// World transform of an occurrence (through its parents).
|
||||
pub fn occurrence_world(doc: &Document, occ: u64) -> Mat {
|
||||
let mut m = IDENTITY;
|
||||
let mut cur = doc.occurrences.iter().find(|o| o.id == occ);
|
||||
for _ in 0..1000 {
|
||||
let Some(o) = cur else { break };
|
||||
m = mat_mul(&o.transform, &m);
|
||||
if o.parent == 0 {
|
||||
break;
|
||||
}
|
||||
cur = doc.occurrence_of(o.parent);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Clamp values to the joint's limits.
|
||||
pub fn clamp(j: &Joint, q: &mut [f64]) {
|
||||
for (i, v) in q.iter_mut().enumerate() {
|
||||
if let Some(Some((lo, hi))) = j.limits.get(i) {
|
||||
*v = v.clamp(lo.min(*hi), hi.max(*lo));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of placing occurrences by their joints.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct Solution {
|
||||
/// New world transforms of occurrences the joints place.
|
||||
#[serde(skip)]
|
||||
pub world: BTreeMap<u64, Mat>,
|
||||
/// Joints that disagree with the placement found through others: (joint id, name, why).
|
||||
pub conflicts: Vec<(u64, String, String)>,
|
||||
/// Joints that could not be applied (bad origin…): (joint id, name, why).
|
||||
pub errors: Vec<(u64, String, String)>,
|
||||
/// Remaining degrees of freedom per occurrence (6 = free, 0 = fixed).
|
||||
pub dof: BTreeMap<u64, usize>,
|
||||
/// Occurrences fixed in place (grounded, or the root's).
|
||||
pub fixed: Vec<u64>,
|
||||
}
|
||||
|
||||
/// How far two placements differ: (translation mm, rotation rad).
|
||||
fn mismatch(a: &Mat, b: &Mat) -> (f64, f64) {
|
||||
let t = Vec3::new(a[3][0] - b[3][0], a[3][1] - b[3][1], a[3][2] - b[3][2]).len();
|
||||
let mut tr = 0.0;
|
||||
for i in 0..3 {
|
||||
for k in 0..3 {
|
||||
tr += a[i][k] * b[i][k];
|
||||
}
|
||||
}
|
||||
(t, ((tr - 1.0) / 2.0).clamp(-1.0, 1.0).acos())
|
||||
}
|
||||
|
||||
/// Effective values of every joint after motion links and limits.
|
||||
pub fn joint_values(asm: &Assembly) -> BTreeMap<u64, Vec<f64>> {
|
||||
let mut vals: BTreeMap<u64, Vec<f64>> = asm
|
||||
.joints
|
||||
.iter()
|
||||
.map(|j| {
|
||||
let mut q = j.values.clone();
|
||||
q.resize(j.kind.dofs().len(), 0.0);
|
||||
(j.id, q)
|
||||
})
|
||||
.collect();
|
||||
// Links in order, a few passes for chains.
|
||||
for _ in 0..4 {
|
||||
for l in &asm.links {
|
||||
let Some(src) = vals.get(&l.a).and_then(|q| q.get(l.ia)).copied() else { continue };
|
||||
if let Some(v) = vals.get_mut(&l.b).and_then(|q| q.get_mut(l.ib)) {
|
||||
*v = l.ratio * src + l.offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
for j in &asm.joints {
|
||||
if let Some(q) = vals.get_mut(&j.id) {
|
||||
clamp(j, q);
|
||||
}
|
||||
}
|
||||
vals
|
||||
}
|
||||
|
||||
/// Place occurrences from the joint graph.
|
||||
pub fn solve(doc: &Document, st: &ModelState) -> Solution {
|
||||
let asm = &doc.assembly;
|
||||
let mut sol = Solution::default();
|
||||
let joints: Vec<&Joint> = asm.joints.iter().filter(|j| !j.suppressed).take(MAX_JOINTS).collect();
|
||||
let vals = joint_values(asm);
|
||||
// Joint frames in component coordinates (errors leave the joint out).
|
||||
let mut frames: BTreeMap<u64, (Mat, Mat)> = BTreeMap::new();
|
||||
for j in &joints {
|
||||
match (resolve_origin(doc, st, &j.a), resolve_origin(doc, st, &j.b)) {
|
||||
(Ok(fa), Ok(fb)) => {
|
||||
frames.insert(j.id, (fa, fb));
|
||||
}
|
||||
(Err(e), _) | (_, Err(e)) => sol.errors.push((j.id, j.name.clone(), e.to_string())),
|
||||
}
|
||||
}
|
||||
// Fixed: the root, grounded occurrences; with neither involved, the first joint's A side.
|
||||
let mut placed: BTreeMap<u64, Mat> = BTreeMap::new();
|
||||
let mut dof: BTreeMap<u64, usize> = BTreeMap::new();
|
||||
placed.insert(0, IDENTITY);
|
||||
dof.insert(0, 0);
|
||||
for o in doc.occurrences.iter().filter(|o| o.grounded) {
|
||||
placed.insert(o.id, occurrence_world(doc, o.id));
|
||||
dof.insert(o.id, 0);
|
||||
}
|
||||
let involved: BTreeSet<u64> = joints.iter().flat_map(|j| [j.a.occurrence, j.b.occurrence]).collect();
|
||||
if !involved.iter().any(|o| placed.contains_key(o))
|
||||
&& let Some(j) = joints.first()
|
||||
{
|
||||
placed.insert(j.a.occurrence, occurrence_world(doc, j.a.occurrence));
|
||||
dof.insert(j.a.occurrence, 0);
|
||||
}
|
||||
sol.fixed = placed.keys().copied().collect();
|
||||
let flip_m = |j: &Joint| {
|
||||
let mut m = rot_z(j.angle);
|
||||
m[3][2] = j.offset;
|
||||
// Origins mate (B's z against A's, as faces meet) unless flipped.
|
||||
if !j.flip { mat_mul(&m, &[[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) } else { m }
|
||||
};
|
||||
// B's world transform from A's (or A's from B's, going the other way).
|
||||
let through = |j: &Joint, from_a: bool, t: &Mat| -> Option<Mat> {
|
||||
let (fa, fb) = frames.get(&j.id)?;
|
||||
let q = vals.get(&j.id).cloned().unwrap_or_default();
|
||||
let link = mat_mul(&mat_mul(&motion(j.kind, &q), &flip_m(j)), &mat_inverse(fb)?);
|
||||
if from_a {
|
||||
Some(mat_mul(&mat_mul(t, fa), &link))
|
||||
} else {
|
||||
// T_A = T_B · link⁻¹ · F_a⁻¹
|
||||
Some(mat_mul(&mat_mul(t, &mat_inverse(&link)?), &mat_inverse(fa)?))
|
||||
}
|
||||
};
|
||||
let mut used: BTreeSet<u64> = BTreeSet::new();
|
||||
let mut queue: VecDeque<u64> = placed.keys().copied().collect();
|
||||
while let Some(occ) = queue.pop_front() {
|
||||
let Some(t) = placed.get(&occ).copied() else { continue };
|
||||
for j in &joints {
|
||||
if used.contains(&j.id) || !frames.contains_key(&j.id) {
|
||||
continue;
|
||||
}
|
||||
let (other, from_a) = if j.a.occurrence == occ {
|
||||
(j.b.occurrence, true)
|
||||
} else if j.b.occurrence == occ {
|
||||
(j.a.occurrence, false)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if placed.contains_key(&other) {
|
||||
continue;
|
||||
}
|
||||
let Some(m) = through(j, from_a, &t) else { continue };
|
||||
used.insert(j.id);
|
||||
placed.insert(other, m);
|
||||
dof.insert(other, dof.get(&occ).copied().unwrap_or(0) + j.kind.dofs().len());
|
||||
queue.push_back(other);
|
||||
}
|
||||
}
|
||||
// Joints that close loops: do they agree?
|
||||
for j in &joints {
|
||||
if used.contains(&j.id) || !frames.contains_key(&j.id) {
|
||||
continue;
|
||||
}
|
||||
let (Some(ta), Some(tb)) = (placed.get(&j.a.occurrence), placed.get(&j.b.occurrence)) else { continue };
|
||||
let Some(want) = through(j, true, ta) else { continue };
|
||||
let (dt, da) = mismatch(&want, tb);
|
||||
if dt > 1e-6 || da > 1e-8 {
|
||||
sol.conflicts.push((
|
||||
j.id,
|
||||
j.name.clone(),
|
||||
format!("off by {dt:.4} mm and {:.3}° from the placement the other joints give", da.to_degrees()),
|
||||
));
|
||||
}
|
||||
}
|
||||
for o in &doc.occurrences {
|
||||
dof.entry(o.id).or_insert(6);
|
||||
}
|
||||
placed.remove(&0);
|
||||
sol.world = placed;
|
||||
sol.dof = dof;
|
||||
sol
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Apply a solution: occurrence transforms (in their parents) for the occurrences it places.
|
||||
/// Returns true when something moved.
|
||||
pub fn apply_joint_solution(&mut self, sol: &Solution) -> bool {
|
||||
let mut changed = false;
|
||||
let parents: BTreeMap<u64, u64> = self.occurrences.iter().map(|o| (o.id, o.parent)).collect();
|
||||
for (occ, world) in &sol.world {
|
||||
let parent = parents.get(occ).copied().unwrap_or(0);
|
||||
let pw = if parent == 0 { IDENTITY } else { self.component_transform(parent) };
|
||||
let Some(local) = mat_inverse(&pw).map(|inv| mat_mul(&inv, world)) else { continue };
|
||||
if let Some(o) = self.occurrences.iter_mut().find(|o| o.id == *occ) {
|
||||
let (dt, da) = mismatch(&o.transform, &local);
|
||||
if dt > 1e-9 || da > 1e-10 {
|
||||
o.transform = local;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// A frame for an as-built joint: the world frame `w` seen from an occurrence.
|
||||
pub fn frame_in_occurrence(&self, occ: u64, w: &Mat) -> Option<Snap> {
|
||||
let t = if occ == 0 { IDENTITY } else { occurrence_world(self, occ) };
|
||||
let local = mat_mul(&mat_inverse(&t)?, w);
|
||||
let o = apply_point(&local, Vec3::ZERO);
|
||||
let z = apply_vector(&local, Vec3::Z);
|
||||
let x = apply_vector(&local, Vec3::X);
|
||||
Some(Snap::Frame { origin: o, z, x: Some(x) })
|
||||
}
|
||||
|
||||
/// A joint origin's frame in the world (for as-built joints and display).
|
||||
pub fn origin_world(&self, st: &ModelState, o: &JointOrigin) -> Result<Mat> {
|
||||
let f = resolve_origin(self, st, o)?;
|
||||
let t = if o.occurrence == 0 { IDENTITY } else { occurrence_world(self, o.occurrence) };
|
||||
Ok(mat_mul(&t, &f))
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame matrix for an explicit origin / z / x (public for commands).
|
||||
pub fn frame_of(o: Vec3, z: Vec3, x: Option<Vec3>) -> Option<Mat> {
|
||||
frame(o, z, x)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: &Mat, b: &Mat) -> bool {
|
||||
let (t, r) = mismatch(a, b);
|
||||
t < 1e-9 && r < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motions_compose() {
|
||||
assert!(close(&motion(JointKind::Revolute, &[std::f64::consts::FRAC_PI_2]), &rot_z(std::f64::consts::FRAC_PI_2)));
|
||||
let m = motion(JointKind::Cylindrical, &[0.0, 5.0]);
|
||||
assert!((m[3][2] - 5.0).abs() < 1e-12);
|
||||
let m = motion(JointKind::PinSlot, &[0.0, 3.0]);
|
||||
assert!((m[3][0] - 3.0).abs() < 1e-12);
|
||||
assert_eq!(JointKind::Ball.dofs().len(), 3);
|
||||
assert!(JointKind::parse("Pin Slot").is_some() && JointKind::parse("nope").is_none());
|
||||
let f = frame(Vec3::new(1.0, 2.0, 3.0), Vec3::Z, None).unwrap();
|
||||
assert!(close(&mat_mul(&f, &mat_inverse(&f).unwrap()), &IDENTITY));
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
//! The SolveCraft document: user and model parameters, and a parametric timeline of features
|
||||
//! (sketches, extrudes, revolves, fillets, primitives, combines…). Evaluating the timeline with
|
||||
//! the kernel produces the model (bodies and solved sketches). Evaluation is incremental: a
|
||||
//! feature is recomputed only when it, a parameter it uses, or an earlier feature changed.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod appearance;
|
||||
mod assembly;
|
||||
pub mod canvas;
|
||||
mod clipboard;
|
||||
pub mod config;
|
||||
pub mod construct;
|
||||
mod document;
|
||||
mod eval;
|
||||
pub mod expr;
|
||||
pub mod format;
|
||||
pub mod joints;
|
||||
pub mod motion;
|
||||
pub mod naming;
|
||||
mod params;
|
||||
pub mod plastic;
|
||||
pub mod project;
|
||||
mod project3d;
|
||||
pub use project3d::iso_grid;
|
||||
pub mod sheet;
|
||||
|
||||
pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, mat_inverse, mat_mul, rigid};
|
||||
pub use document::*;
|
||||
pub use eval::{
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices,
|
||||
world_state,
|
||||
};
|
||||
pub use params::{ParamRow, ParamUser, cycles};
|
||||
pub use solvecraft_kernel as kernel;
|
||||
pub use solvecraft_sketch as sketch;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum DocError {
|
||||
#[error("expression: {0}")]
|
||||
Expr(String),
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
#[error("unknown {0}")]
|
||||
Unknown(String),
|
||||
#[error(transparent)]
|
||||
Sketch(#[from] solvecraft_sketch::SketchError),
|
||||
#[error(transparent)]
|
||||
Kernel(#[from] solvecraft_kernel::KernelError),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DocError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,76 +0,0 @@
|
||||
//! Assembly extras: contact sets (occurrences that stop each other when joints are driven),
|
||||
//! motion studies (joint values keyed along a timeline) and exploded views (occurrence moves
|
||||
//! shown without changing the design).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
/// Occurrences whose bodies may touch but not pass through each other while joints move.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ContactSet {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub occurrences: Vec<u64>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub suppressed: bool,
|
||||
}
|
||||
|
||||
/// One joint value along a motion study: (step, value) points, values in internal units
|
||||
/// (radians, mm), linear in between and held before the first and after the last.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StudyKey {
|
||||
pub joint: u64,
|
||||
#[serde(default)]
|
||||
pub index: usize,
|
||||
pub points: Vec<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl StudyKey {
|
||||
pub fn value_at(&self, step: f64) -> Option<f64> {
|
||||
let first = self.points.first()?;
|
||||
let last = self.points.last()?;
|
||||
if step <= first.0 {
|
||||
return Some(first.1);
|
||||
}
|
||||
if step >= last.0 {
|
||||
return Some(last.1);
|
||||
}
|
||||
self.points.windows(2).find_map(|w| {
|
||||
let (a, b) = (w.first()?, w.get(1)?);
|
||||
(step >= a.0 && step <= b.0).then(|| if b.0 - a.0 < 1e-12 { b.1 } else { a.1 + (b.1 - a.1) * (step - a.0) / (b.0 - a.0) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A motion study: a timeline of `steps` steps along which joints follow their keys.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MotionStudy {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub steps: u32,
|
||||
pub keys: Vec<StudyKey>,
|
||||
}
|
||||
|
||||
/// An exploded view: occurrences moved apart (shown only; the design keeps its placements).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ExplodedView {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// (occurrence, world translation)
|
||||
pub moves: Vec<(u64, Vec3)>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keys_interpolate_and_hold() {
|
||||
let k = StudyKey { joint: 1, index: 0, points: vec![(0.0, 0.0), (10.0, 1.0), (20.0, -1.0)] };
|
||||
assert_eq!(k.value_at(-5.0), Some(0.0));
|
||||
assert_eq!(k.value_at(5.0), Some(0.5));
|
||||
assert_eq!(k.value_at(15.0), Some(0.0));
|
||||
assert_eq!(k.value_at(99.0), Some(-1.0));
|
||||
assert_eq!(StudyKey { joint: 1, index: 0, points: vec![] }.value_at(1.0), None);
|
||||
}
|
||||
}
|
||||
@@ -1,657 +0,0 @@
|
||||
//! Persistent names of faces and edges (plan/adr/0002-persistent-naming.md).
|
||||
//!
|
||||
//! Every face of an evaluated body is named after how it was made: a face that lies on a face
|
||||
//! of the feature's input keeps that face's name; a face new to the feature is named by its role
|
||||
//! (`F<feature>:side:<sketch curve>`, `F<feature>:start`/`end`, `F<feature>:blend:<k>`, …).
|
||||
//! Pieces of a split face get `#1`, `#2`, … along the longest axis. Edges are named by the faces
|
||||
//! they separate. Names are worked out lazily, on first request, from the body, the feature
|
||||
//! that made it and the model before that feature.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use solvecraft_geom::{Mesh, Seg2, Vec2, Vec3};
|
||||
|
||||
use crate::{Feature, FeatureKind, ModelBody, ModelState};
|
||||
|
||||
/// How a body came to be: the feature and the model just before it.
|
||||
#[derive(Debug)]
|
||||
pub struct NamingOrigin {
|
||||
pub feature: Feature,
|
||||
pub before: Arc<ModelState>,
|
||||
pub depth: usize,
|
||||
/// Instance transforms of a pattern or mirror (its copies are named after their sources).
|
||||
pub mats: Vec<crate::Mat>,
|
||||
}
|
||||
|
||||
/// The lazily computed names of a body, shared by its clones.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct NameCell {
|
||||
pub origin: Option<Arc<NamingOrigin>>,
|
||||
names: Arc<OnceLock<Arc<Vec<String>>>>,
|
||||
}
|
||||
|
||||
impl NameCell {
|
||||
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
|
||||
NameCell { origin, names: Arc::new(OnceLock::new()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Deeper chains than this are named by position only (bounded work and stack).
|
||||
const MAX_DEPTH: usize = 400;
|
||||
|
||||
/// Up to this many sample points per face.
|
||||
const SAMPLES: usize = 9;
|
||||
|
||||
/// Unit normals of each face's triangles.
|
||||
fn normals(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
|
||||
let mut per: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
if let (Some(v), Some([a, b, c])) = (per.get_mut(*f as usize), m.tri(t))
|
||||
&& let Some(n) = (b - a).cross(c - a).normalized()
|
||||
{
|
||||
v.push(n);
|
||||
}
|
||||
}
|
||||
per
|
||||
}
|
||||
|
||||
/// Triangle centroids spread over each face: face → points.
|
||||
fn samples(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
|
||||
let mut per: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
if let (Some(v), Some([a, b, c])) = (per.get_mut(*f as usize), m.tri(t)) {
|
||||
v.push((a + b + c) * (1.0 / 3.0));
|
||||
}
|
||||
}
|
||||
per.into_iter()
|
||||
.map(|v| {
|
||||
if v.len() <= SAMPLES {
|
||||
return v;
|
||||
}
|
||||
let step = v.len() as f64 / SAMPLES as f64;
|
||||
(0..SAMPLES).filter_map(|i| v.get((i as f64 * step) as usize).copied()).collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
let (ab, ac, ap) = (b - a, c - a, p - a);
|
||||
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
|
||||
if d1 <= 0.0 && d2 <= 0.0 {
|
||||
return a;
|
||||
}
|
||||
let bp = p - b;
|
||||
let (d3, d4) = (ab.dot(bp), ac.dot(bp));
|
||||
if d3 >= 0.0 && d4 <= d3 {
|
||||
return b;
|
||||
}
|
||||
let vc = d1 * d4 - d3 * d2;
|
||||
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
|
||||
return a + ab * (d1 / (d1 - d3));
|
||||
}
|
||||
let cp = p - c;
|
||||
let (d5, d6) = (ab.dot(cp), ac.dot(cp));
|
||||
if d6 >= 0.0 && d5 <= d6 {
|
||||
return c;
|
||||
}
|
||||
let vb = d5 * d2 - d1 * d6;
|
||||
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
|
||||
return a + ac * (d2 / (d2 - d6));
|
||||
}
|
||||
let va = d3 * d6 - d5 * d4;
|
||||
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
|
||||
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
|
||||
}
|
||||
let den = va + vb + vc;
|
||||
if den.abs() < 1e-300 {
|
||||
return a;
|
||||
}
|
||||
a + ab * (vb / den) + ac * (vc / den)
|
||||
}
|
||||
|
||||
/// A body's faces as triangle lists with bounding boxes, for "does this point lie on face f".
|
||||
struct FaceIndex {
|
||||
faces: Vec<(Vec3, Vec3, Vec<[Vec3; 3]>)>,
|
||||
}
|
||||
|
||||
impl FaceIndex {
|
||||
fn new(m: &Mesh, nf: usize) -> FaceIndex {
|
||||
let mut faces: Vec<(Vec3, Vec3, Vec<[Vec3; 3]>)> =
|
||||
vec![(Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN), Vec::new()); nf];
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
if let (Some(slot), Some(tri)) = (faces.get_mut(*f as usize), m.tri(t)) {
|
||||
for p in tri {
|
||||
slot.0 = Vec3::new(slot.0.x.min(p.x), slot.0.y.min(p.y), slot.0.z.min(p.z));
|
||||
slot.1 = Vec3::new(slot.1.x.max(p.x), slot.1.y.max(p.y), slot.1.z.max(p.z));
|
||||
}
|
||||
slot.2.push(tri);
|
||||
}
|
||||
}
|
||||
FaceIndex { faces }
|
||||
}
|
||||
|
||||
/// The face a point lies on (within `tol`).
|
||||
fn face_at(&self, p: Vec3, tol: f64) -> Option<usize> {
|
||||
let mut best: Option<(f64, usize)> = None;
|
||||
for (i, (lo, hi, tris)) in self.faces.iter().enumerate() {
|
||||
if p.x < lo.x - tol || p.y < lo.y - tol || p.z < lo.z - tol || p.x > hi.x + tol || p.y > hi.y + tol || p.z > hi.z + tol {
|
||||
continue;
|
||||
}
|
||||
let d = tris.iter().map(|[a, b, c]| closest(p, *a, *b, *c).dist(p)).fold(f64::MAX, f64::min);
|
||||
if d <= tol && best.is_none_or(|(bd, _)| d < bd) {
|
||||
best = Some((d, i));
|
||||
}
|
||||
}
|
||||
best.map(|(_, i)| i)
|
||||
}
|
||||
}
|
||||
|
||||
fn face_count(b: &ModelBody) -> usize {
|
||||
b.mesh().tri_face.iter().map(|f| *f as usize + 1).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Names of a body's faces (by B-rep face index).
|
||||
pub fn face_names(b: &ModelBody) -> Arc<Vec<String>> {
|
||||
b.names
|
||||
.names
|
||||
.get_or_init(|| {
|
||||
let nf = face_count(b);
|
||||
let names = match b.names.origin.as_ref() {
|
||||
Some(o) if o.depth < MAX_DEPTH => derive(b, o, nf),
|
||||
_ => (0..nf).map(|k| format!("{}:face:{k}", b.name)).collect(),
|
||||
};
|
||||
Arc::new(names)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Work out a body's face names from the feature that made it and the model before it.
|
||||
fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
let m = b.mesh();
|
||||
let size = b.body.size().max(1e-6);
|
||||
let tol = (size * 1e-4).max(1e-6);
|
||||
let pts = samples(&m, nf);
|
||||
let bb = m.bounds();
|
||||
// Inputs that overlap this body.
|
||||
let overlaps = |x: &ModelBody| {
|
||||
let xb = x.mesh().bounds();
|
||||
!(xb.max.x < bb.min.x - tol
|
||||
|| xb.min.x > bb.max.x + tol
|
||||
|| xb.max.y < bb.min.y - tol
|
||||
|| xb.min.y > bb.max.y + tol
|
||||
|| xb.max.z < bb.min.z - tol
|
||||
|| xb.min.z > bb.max.z + tol)
|
||||
};
|
||||
let inputs: Vec<(&ModelBody, FaceIndex, Arc<Vec<String>>)> = o
|
||||
.before
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|x| !x.body.is_mesh() && overlaps(x))
|
||||
.take(64)
|
||||
.map(|x| {
|
||||
let n = face_count(x);
|
||||
(x, FaceIndex::new(&x.mesh(), n), face_names(x))
|
||||
})
|
||||
.collect();
|
||||
let fid = o.feature.id;
|
||||
let ns = normals(&m, nf);
|
||||
let mut names: Vec<Option<String>> = pts.iter().map(|ps| vote(ps, &inputs, tol, None)).collect();
|
||||
// A pattern or mirror copy: its faces lie on its source's faces moved by one instance.
|
||||
let found = names.iter().filter(|n| n.is_some()).count();
|
||||
if !o.mats.is_empty() && found * 2 < nf.max(1) {
|
||||
let all: Vec<(&ModelBody, FaceIndex, Arc<Vec<String>>)> = o
|
||||
.before
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|x| !x.body.is_mesh())
|
||||
.take(64)
|
||||
.map(|x| (x, FaceIndex::new(&x.mesh(), face_count(x)), face_names(x)))
|
||||
.collect();
|
||||
let mut best: Option<(usize, usize, Vec<Option<String>>)> = None;
|
||||
for (k, mat) in o.mats.iter().enumerate() {
|
||||
let Some(inv) = crate::mat_inverse(mat) else { continue };
|
||||
let got: Vec<Option<String>> = pts.iter().map(|ps| vote(ps, &all, tol, Some(&inv))).collect();
|
||||
let n = got.iter().filter(|x| x.is_some()).count();
|
||||
if n > found && best.as_ref().is_none_or(|(bn, _, _)| n > *bn) {
|
||||
best = Some((n, k, got));
|
||||
}
|
||||
}
|
||||
if let Some((_, k, got)) = best {
|
||||
names = got.into_iter().map(|n| n.map(|n| format!("{n}@F{fid}.{}", k + 1))).collect();
|
||||
}
|
||||
}
|
||||
let empty = Vec::new();
|
||||
let mut names: Vec<String> = names
|
||||
.into_iter()
|
||||
.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)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
number_pieces(&mut names, &pts, '#');
|
||||
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> {
|
||||
let mut votes: Vec<(String, usize)> = Vec::new();
|
||||
for p in ps {
|
||||
let p = match map {
|
||||
Some(m) => crate::apply_point(m, *p),
|
||||
None => *p,
|
||||
};
|
||||
for (_, idx, nm) in inputs {
|
||||
if let Some(f) = idx.face_at(p, tol)
|
||||
&& let Some(n) = nm.get(f)
|
||||
{
|
||||
match votes.iter_mut().find(|(v, _)| v == n) {
|
||||
Some(v) => v.1 += 1,
|
||||
None => votes.push((n.clone(), 1)),
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
votes.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
votes.first().filter(|(_, n)| *n * 2 >= ps.len().max(1)).map(|(n, _)| strip_piece(n).to_string())
|
||||
}
|
||||
|
||||
/// A face name without its piece number (`#k`).
|
||||
pub fn strip_piece(n: &str) -> &str {
|
||||
strip_mark(n, '#')
|
||||
}
|
||||
|
||||
/// An edge name without piece numbers: its own (`~k`) and its faces' (`#k`), so pieces of a
|
||||
/// split edge, or of an edge between split faces, match.
|
||||
pub fn strip_edge_piece(n: &str) -> String {
|
||||
let n = strip_mark(n, '~');
|
||||
let mut out = String::with_capacity(n.len());
|
||||
let mut it = n.chars().peekable();
|
||||
while let Some(c) = it.next() {
|
||||
if c == '#' && it.peek().is_some_and(|d| d.is_ascii_digit()) {
|
||||
while it.peek().is_some_and(|d| d.is_ascii_digit()) {
|
||||
it.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn strip_mark(n: &str, mark: char) -> &str {
|
||||
match n.rfind(mark) {
|
||||
Some(i) if n[i + 1..].chars().all(|c| c.is_ascii_digit()) && i + 1 < n.len() => &n[..i],
|
||||
_ => n,
|
||||
}
|
||||
}
|
||||
|
||||
/// Faces sharing a name are pieces of one: number them along the longest axis of their spread.
|
||||
fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for i in 0..names.len() {
|
||||
let n = names[i].clone();
|
||||
if seen.contains(&n) {
|
||||
continue;
|
||||
}
|
||||
seen.push(n.clone());
|
||||
let same: Vec<usize> = (0..names.len()).filter(|j| names[*j] == n).collect();
|
||||
if same.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let centre = |j: usize| {
|
||||
let ps = pts.get(j).cloned().unwrap_or_default();
|
||||
ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64)
|
||||
};
|
||||
let cs: Vec<(usize, Vec3)> = same.iter().map(|j| (*j, centre(*j))).collect();
|
||||
let (lo, hi) = cs.iter().fold((Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN)), |(l, h), (_, c)| {
|
||||
(Vec3::new(l.x.min(c.x), l.y.min(c.y), l.z.min(c.z)), Vec3::new(h.x.max(c.x), h.y.max(c.y), h.z.max(c.z)))
|
||||
});
|
||||
let ext = hi - lo;
|
||||
let key = |c: Vec3| {
|
||||
if ext.x >= ext.y && ext.x >= ext.z {
|
||||
(c.x, c.y, c.z)
|
||||
} else if ext.y >= ext.z {
|
||||
(c.y, c.z, c.x)
|
||||
} else {
|
||||
(c.z, c.x, c.y)
|
||||
}
|
||||
};
|
||||
let mut order = cs.clone();
|
||||
order.sort_by(|a, b| key(a.1).partial_cmp(&key(b.1)).unwrap_or(std::cmp::Ordering::Equal));
|
||||
for (k, (j, _)) in order.iter().enumerate() {
|
||||
if let Some(slot) = names.get_mut(*j) {
|
||||
*slot = format!("{n}{mark}{}", k + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of a face new to `f`.
|
||||
fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usize, fid: u64) -> String {
|
||||
let c = ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64);
|
||||
match &f.kind {
|
||||
FeatureKind::Extrude { sketch, .. } => {
|
||||
let Some(ss) = before.sketch(*sketch) else { return format!("F{fid}:face:{fi}") };
|
||||
let n = ss.plane.normal();
|
||||
// A face across the extrusion is a cap; others sweep a sketch curve.
|
||||
let flat = !ns.is_empty() && ns.iter().all(|m| m.cross(n).len() < 1e-6);
|
||||
if flat {
|
||||
// On the sketch plane, above it or below it.
|
||||
let h = (c - ss.plane.origin).dot(n);
|
||||
let tol = 1e-6 * (1.0 + h.abs());
|
||||
return format!(
|
||||
"F{fid}:{}",
|
||||
if h.abs() <= tol {
|
||||
"start"
|
||||
} else if h > 0.0 {
|
||||
"top"
|
||||
} else {
|
||||
"bottom"
|
||||
}
|
||||
);
|
||||
}
|
||||
let q = ss.plane.to_local(c);
|
||||
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::Revolve { sketch, axis, .. } => {
|
||||
let Some(ss) = before.sketch(*sketch) else { return format!("F{fid}:face:{fi}") };
|
||||
let Ok((o2, d2)) = crate::eval::revolve_axis(ss, axis) else { return format!("F{fid}:face:{fi}") };
|
||||
let (o, d) = (ss.plane.to_world(o2), (ss.plane.to_world(o2 + d2) - ss.plane.to_world(o2)).normalized().unwrap_or(Vec3::Z));
|
||||
// A flat face holding the axis is an angle cap: on the sketch plane, or the far one.
|
||||
let flat = !ns.is_empty() && ns.windows(2).all(|w| w[0].cross(w[1]).len() < 1e-6) && ns.iter().all(|m| m.dot(d).abs() < 1e-6);
|
||||
if flat {
|
||||
let on_plane = ps.iter().all(|p| (*p - ss.plane.origin).dot(ss.plane.normal()).abs() < 1e-6 * (1.0 + p.len()));
|
||||
return format!("F{fid}:{}", if on_plane { "start" } else { "end" });
|
||||
}
|
||||
// A swept face: turn its centre back into the sketch plane and find the curve.
|
||||
let q = c - o;
|
||||
let t = q.dot(d);
|
||||
let r = (q - d * t).len();
|
||||
let u3 = ss.plane.normal().cross(d).normalized().unwrap_or(Vec3::X);
|
||||
let best = [u3, u3 * -1.0]
|
||||
.iter()
|
||||
.filter_map(|u| {
|
||||
let w = ss.plane.to_local(o + d * t + *u * r);
|
||||
let q2 = Vec2::new(w.x, w.y);
|
||||
nearest_curve_dist(ss, q2)
|
||||
})
|
||||
.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| {
|
||||
if v > 0.5 {
|
||||
Some(p.to_string())
|
||||
} else if v < -0.5 {
|
||||
Some(m.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let role =
|
||||
axis(n.x, "+x", "-x").or_else(|| axis(n.y, "+y", "-y")).or_else(|| axis(n.z, "+z", "-z")).unwrap_or_else(|| format!("face:{fi}"));
|
||||
format!("F{fid}:{role}")
|
||||
}
|
||||
FeatureKind::Cylinder { axis, .. } => {
|
||||
let flat = !ns.is_empty() && ns.iter().all(|m| m.cross(*axis).len() < 1e-6);
|
||||
let role = if !flat {
|
||||
"side"
|
||||
} else if ns.first().is_some_and(|m| m.dot(*axis) > 0.0) {
|
||||
"top"
|
||||
} else {
|
||||
"bottom"
|
||||
};
|
||||
format!("F{fid}:{role}")
|
||||
}
|
||||
FeatureKind::Fillet { edges, .. } | FeatureKind::Chamfer { edges, .. } => {
|
||||
let k = edges.iter().enumerate().min_by(|a, b| a.1.dist(c).total_cmp(&b.1.dist(c))).map(|(k, _)| k).unwrap_or(0);
|
||||
match f.edge_names.get(k) {
|
||||
Some(e) => format!("F{fid}:blend:{e}"),
|
||||
None => format!("F{fid}:blend:{k}"),
|
||||
}
|
||||
}
|
||||
_ => format!("F{fid}:face:{fi}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sketch curve (id) nearest a point in the sketch plane, and how near.
|
||||
fn nearest_curve_dist(ss: &crate::SolvedSketch, q: Vec2) -> Option<(f64, String)> {
|
||||
let mut best: Option<(f64, String)> = None;
|
||||
for (ci, c) in ss.sketch.curves.iter().enumerate() {
|
||||
if c.construction {
|
||||
continue;
|
||||
}
|
||||
for s in ss.sketch.segs(ci) {
|
||||
let d = seg_dist(&s, q);
|
||||
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
|
||||
best = Some((d, c.id.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// The sketch curve (id) nearest a point in the sketch plane.
|
||||
fn nearest_curve(ss: &crate::SolvedSketch, q: Vec2) -> Option<String> {
|
||||
let mut best: Option<(f64, String)> = None;
|
||||
for (ci, c) in ss.sketch.curves.iter().enumerate() {
|
||||
if c.construction {
|
||||
continue;
|
||||
}
|
||||
for s in ss.sketch.segs(ci) {
|
||||
let d = seg_dist(&s, q);
|
||||
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
|
||||
best = Some((d, c.id.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, id)| id)
|
||||
}
|
||||
|
||||
fn seg_dist(s: &Seg2, q: Vec2) -> f64 {
|
||||
let pts = s.polyline(1e-3);
|
||||
pts.windows(2)
|
||||
.map(|w| {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let d = b - a;
|
||||
let t = if d.len2() > 0.0 { ((q - a).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(a + d * t).dist(q)
|
||||
})
|
||||
.fold(f64::MAX, f64::min)
|
||||
}
|
||||
|
||||
/// Names of a body's edges (by mesh edge index): `<face A>|<face B>`, numbered when repeated.
|
||||
pub fn edge_names(b: &ModelBody) -> Vec<String> {
|
||||
let m = b.mesh();
|
||||
let faces = face_names(b);
|
||||
let mut names: Vec<String> = m
|
||||
.edge_faces
|
||||
.iter()
|
||||
.map(|fs| {
|
||||
let mut n: Vec<&str> = fs.iter().filter_map(|f| faces.get(*f as usize).map(String::as_str)).collect();
|
||||
n.sort();
|
||||
n.join("|")
|
||||
})
|
||||
.collect();
|
||||
let pts: Vec<Vec<Vec3>> = m.edges.clone();
|
||||
number_pieces(&mut names, &pts, '~');
|
||||
names
|
||||
}
|
||||
|
||||
/// The edge of a body with this name: exact, else the pieces of the same edge nearest `near`
|
||||
/// (`Err` explains when there is none).
|
||||
pub fn find_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<(Vec3, bool), String> {
|
||||
let m = b.mesh();
|
||||
let names = edge_names(b);
|
||||
let mid = |i: usize| -> Option<Vec3> {
|
||||
let e = m.edges.get(i)?;
|
||||
let total: f64 = e.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let mut acc = 0.0;
|
||||
for w in e.windows(2) {
|
||||
let l = w[0].dist(w[1]);
|
||||
if acc + l >= total / 2.0 {
|
||||
let t = if l > 0.0 { (total / 2.0 - acc) / l } else { 0.0 };
|
||||
return Some(w[0] + (w[1] - w[0]) * t);
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
e.first().copied()
|
||||
};
|
||||
if let Some(i) = names.iter().position(|n| n == name) {
|
||||
// Keep the picked point when it is on that edge.
|
||||
let on = m.edges.get(i).is_some_and(|e| e.windows(2).any(|w| near.dist_to_segment(w[0], w[1]) < (b.body.size() * 2e-3).max(1e-3)));
|
||||
return mid(i).map(|p| (if on { near } else { p }, false)).ok_or_else(|| "edge".into());
|
||||
}
|
||||
let stem = strip_edge_piece(name);
|
||||
let cands: Vec<usize> = (0..names.len()).filter(|i| names.get(*i).is_some_and(|n| strip_edge_piece(n) == stem)).collect();
|
||||
cands
|
||||
.iter()
|
||||
.filter_map(|i| mid(*i))
|
||||
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
|
||||
.map(|p| (p, true))
|
||||
.ok_or_else(|| format!("edge `{name}` no longer exists"))
|
||||
}
|
||||
|
||||
/// Names of the edges nearest each point on a body (for storing references).
|
||||
pub fn edge_names_at(b: &ModelBody, pts: &[Vec3]) -> Vec<String> {
|
||||
let m = b.mesh();
|
||||
let names = edge_names(b);
|
||||
pts.iter()
|
||||
.map(|p| {
|
||||
m.edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| (e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::MAX, f64::min), i))
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.and_then(|(_, i)| names.get(i).cloned())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Names of the faces holding each point (any body; empty when none does).
|
||||
pub fn face_names_at(st: &ModelState, pts: &[Vec3]) -> Vec<String> {
|
||||
pts.iter()
|
||||
.map(|p| {
|
||||
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
|
||||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||||
let idx = FaceIndex::new(&b.mesh(), face_count(b));
|
||||
if let Some(f) = idx.face_at(*p, tol) {
|
||||
return face_names(b).get(f).cloned().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The point on the face with this name nearest `p` (a split face: its nearest piece, with
|
||||
/// `true` for "it was split"); `None` when no face has the name.
|
||||
pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool)> {
|
||||
let stem = strip_piece(name);
|
||||
let mut best: Option<(f64, Vec3, bool)> = None;
|
||||
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
|
||||
let names = face_names(b);
|
||||
let m = b.mesh();
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let Some(n) = names.get(*f as usize) else { continue };
|
||||
let exact = n == name;
|
||||
if !exact && strip_piece(n) != stem {
|
||||
continue;
|
||||
}
|
||||
let Some([a, bb, c]) = m.tri(t) else { continue };
|
||||
let q = closest(p, a, bb, c);
|
||||
let d = q.dist(p) - if exact { 1e-9 } else { 0.0 };
|
||||
if best.is_none_or(|(bd, _, _)| d < bd) {
|
||||
best = Some((d, q, !exact));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, q, split)| (q, split))
|
||||
}
|
||||
@@ -1,874 +0,0 @@
|
||||
//! Parameters: user parameters, model parameters of sketch dimensions, and feature inputs
|
||||
//! (extrude distance, fillet radius…) which also appear as model parameters `dN` without being
|
||||
//! stored twice: the feature keeps its expression and the parameter name refers to it. Every
|
||||
//! parameter can be referenced by name from any expression; the dependency graph and cycles
|
||||
//! come from the expression references.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::expr::{self, Kind, ParamDef, Value};
|
||||
use crate::{DocError, Document, FeatureKind, HoleKind, MAX_PARAMS, PatternKind, PlaneRef, Result};
|
||||
|
||||
fn plane_inputs_mut<'a>(p: &'a mut PlaneRef, v: &mut Vec<(&'static str, &'a mut String, Kind)>) {
|
||||
match p {
|
||||
PlaneRef::Offset { base, distance } => {
|
||||
v.push(("Offset", distance, Kind::Length));
|
||||
plane_inputs_mut(base, v);
|
||||
}
|
||||
PlaneRef::AtAngle { base, angle, .. } => {
|
||||
v.push(("Angle", angle, Kind::Angle));
|
||||
plane_inputs_mut(base, v);
|
||||
}
|
||||
PlaneRef::AlongPath { t, .. } => v.push(("Distance (0 to 1)", t, Kind::Unitless)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
/// The feature's numeric inputs: (label, expression, kind), in a fixed order.
|
||||
pub fn inputs_mut(&mut self) -> Vec<(&'static str, &mut String, Kind)> {
|
||||
use Kind::{Angle as A, Length as L, Unitless as U};
|
||||
let mut v: Vec<(&'static str, &mut String, Kind)> = Vec::new();
|
||||
match self {
|
||||
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 {
|
||||
v.push(("Distance 2", d, L));
|
||||
}
|
||||
if let Some(d) = &mut extent.start_offset {
|
||||
v.push(("Start offset", d, L));
|
||||
}
|
||||
if let Some(d) = &mut extent.taper {
|
||||
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::Chamfer { distance, distance2, angle, .. } => {
|
||||
v.push(("Distance", distance, L));
|
||||
if let Some(d) = distance2 {
|
||||
v.push(("Distance 2", d, L));
|
||||
}
|
||||
if let Some(a) = angle {
|
||||
v.push(("Angle", a, A));
|
||||
}
|
||||
}
|
||||
FeatureKind::Box { length, width, height, .. } => {
|
||||
v.push(("Length", length, L));
|
||||
v.push(("Width", width, L));
|
||||
v.push(("Height", height, L));
|
||||
}
|
||||
FeatureKind::Cylinder { radius, height, .. } => {
|
||||
v.push(("Radius", radius, L));
|
||||
v.push(("Height", height, L));
|
||||
}
|
||||
FeatureKind::Sphere { radius, .. } => v.push(("Radius", radius, L)),
|
||||
FeatureKind::Torus { major, minor, .. } => {
|
||||
v.push(("Major radius", major, L));
|
||||
v.push(("Minor radius", minor, L));
|
||||
}
|
||||
FeatureKind::Pattern { pattern, .. } => match pattern {
|
||||
PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => {
|
||||
v.push(("Quantity", count1, U));
|
||||
v.push(("Spacing", spacing1, L));
|
||||
if let Some(c) = count2 {
|
||||
v.push(("Quantity 2", c, U));
|
||||
}
|
||||
if let Some(sp) = spacing2 {
|
||||
v.push(("Spacing 2", sp, L));
|
||||
}
|
||||
}
|
||||
PatternKind::Path { count, spacing, .. } => {
|
||||
v.push(("Quantity", count, U));
|
||||
v.push(("Distance", spacing, L));
|
||||
}
|
||||
PatternKind::Circular { count, angle, .. } => {
|
||||
v.push(("Quantity", count, U));
|
||||
v.push(("Total angle", angle, A));
|
||||
}
|
||||
},
|
||||
FeatureKind::Shell { thickness, .. } => v.push(("Thickness", thickness, L)),
|
||||
FeatureKind::Draft { angle, neutral, .. } => {
|
||||
v.push(("Angle", angle, A));
|
||||
plane_inputs_mut(neutral, &mut v);
|
||||
}
|
||||
FeatureKind::Hole { diameter, depth, hole, .. } => {
|
||||
v.push(("Diameter", diameter, L));
|
||||
if let Some(d) = depth {
|
||||
v.push(("Depth", d, L));
|
||||
}
|
||||
match hole {
|
||||
HoleKind::Simple => {}
|
||||
HoleKind::Drilled { tip_angle } => v.push(("Tip angle", tip_angle, A)),
|
||||
HoleKind::Counterbore { cb_diameter, cb_depth } => {
|
||||
v.push(("Counterbore diameter", cb_diameter, L));
|
||||
v.push(("Counterbore depth", cb_depth, L));
|
||||
}
|
||||
HoleKind::Countersink { cs_diameter, cs_angle } => {
|
||||
v.push(("Countersink diameter", cs_diameter, L));
|
||||
v.push(("Countersink angle", cs_angle, A));
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::Thread { length, .. } => {
|
||||
if let Some(l) = length {
|
||||
v.push(("Length", l, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::Scale { factor, factors, .. } => {
|
||||
v.push(("Scale factor", factor, U));
|
||||
if let Some([x, y, z]) = factors {
|
||||
v.push(("X scale", x, U));
|
||||
v.push(("Y scale", y, U));
|
||||
v.push(("Z scale", z, U));
|
||||
}
|
||||
}
|
||||
FeatureKind::OffsetFace { distance, .. } => v.push(("Distance", distance, L)),
|
||||
FeatureKind::BoundingSolid { margin, .. } => v.push(("Margin", margin, L)),
|
||||
FeatureKind::Pipe { diameter, wall, .. } => {
|
||||
v.push(("Diameter", diameter, L));
|
||||
if let Some(w) = wall {
|
||||
v.push(("Wall", w, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::Move { translate, angle, .. } => {
|
||||
let [x, y, z] = translate;
|
||||
v.push(("X distance", x, L));
|
||||
v.push(("Y distance", y, L));
|
||||
v.push(("Z distance", z, L));
|
||||
if let Some(a) = angle {
|
||||
v.push(("Angle", a, A));
|
||||
}
|
||||
}
|
||||
FeatureKind::Emboss { depth, .. } => v.push(("Depth", depth, L)),
|
||||
FeatureKind::Boss { diameter, height, hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset, .. } => {
|
||||
v.push(("Diameter", diameter, L));
|
||||
v.push(("Height", height, L));
|
||||
for (l, o, k) in [
|
||||
("Hole diameter", hole_diameter, L),
|
||||
("Hole depth", hole_depth, L),
|
||||
("Draft angle", draft, A),
|
||||
("Root fillet", fillet, L),
|
||||
("Ribs", ribs, U),
|
||||
("Rib thickness", rib_thickness, L),
|
||||
("Rib length", rib_length, L),
|
||||
("Rib offset", rib_offset, L),
|
||||
] {
|
||||
if let Some(e) = o {
|
||||
v.push((l, e, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::Lip { width, height, gap, .. } => {
|
||||
v.push(("Width", width, L));
|
||||
v.push(("Height", height, L));
|
||||
if let Some(g) = gap {
|
||||
v.push(("Gap", g, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::Rest { width, length, height, draft, thickness, .. } => {
|
||||
v.push(("Width", width, L));
|
||||
v.push(("Height", height, L));
|
||||
for (l, o, k) in [("Length", length, L), ("Draft angle", draft, A), ("Wall thickness", thickness, L)] {
|
||||
if let Some(e) = o {
|
||||
v.push((l, e, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::SnapFit { length, thickness, width, catch_depth, catch_length, .. } => {
|
||||
v.push(("Length", length, L));
|
||||
v.push(("Thickness", thickness, L));
|
||||
v.push(("Width", width, L));
|
||||
v.push(("Catch depth", catch_depth, L));
|
||||
v.push(("Catch length", catch_length, L));
|
||||
}
|
||||
FeatureKind::SheetContour { distance, .. } => v.push(("Distance", distance, L)),
|
||||
FeatureKind::SheetFlange { height, angle, radius, .. } => {
|
||||
v.push(("Height", height, L));
|
||||
v.push(("Angle", angle, A));
|
||||
if let Some(r) = radius {
|
||||
v.push(("Bend radius", r, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetFold { angle, radius, .. } => {
|
||||
v.push(("Angle", angle, A));
|
||||
if let Some(r) = radius {
|
||||
v.push(("Bend radius", r, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetHem { length, gap, .. } => {
|
||||
v.push(("Length", length, L));
|
||||
if let Some(g) = gap {
|
||||
v.push(("Gap", g, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, .. } => {
|
||||
v.push(("Diameter", diameter, L));
|
||||
v.push(("Pitch", pitch, L));
|
||||
v.push(("Revolutions", turns, U));
|
||||
v.push(("Section size", section_size, L));
|
||||
if let Some(a) = start_angle {
|
||||
v.push(("Start angle", a, A));
|
||||
}
|
||||
}
|
||||
FeatureKind::Rib { thickness, depth, .. } => {
|
||||
v.push(("Thickness", thickness, L));
|
||||
if let Some(d) = depth {
|
||||
v.push(("Depth", d, L));
|
||||
}
|
||||
}
|
||||
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 { .. }
|
||||
| FeatureKind::MeshImport { .. } => {}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Read-only view of [`FeatureKind::inputs_mut`]: (label, expression, kind).
|
||||
pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> {
|
||||
// Imports have no inputs (and their STEP text is large: don't clone it).
|
||||
if matches!(self, FeatureKind::Import { .. } | FeatureKind::MeshImport { .. }) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut c = self.clone();
|
||||
c.inputs_mut().into_iter().map(|(l, e, k)| (l, e.clone(), k)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the parameters table.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ParamRow {
|
||||
pub name: String,
|
||||
pub expression: String,
|
||||
pub unit: String,
|
||||
pub value: Option<f64>,
|
||||
pub error: Option<String>,
|
||||
pub comment: String,
|
||||
/// "user", "sketch" (a dimension) or "feature".
|
||||
pub source: &'static str,
|
||||
/// For feature inputs: the feature and the input's label.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub feature: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub input: Option<&'static str>,
|
||||
pub favorite: bool,
|
||||
/// Names this one's expression uses, and names whose expressions use this one.
|
||||
pub uses: Vec<String>,
|
||||
pub used_by: Vec<String>,
|
||||
}
|
||||
|
||||
fn unit_of(k: Kind) -> &'static str {
|
||||
match k {
|
||||
Kind::Length => "mm",
|
||||
Kind::Angle => "deg",
|
||||
Kind::Unitless => "",
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Every (name, expression, kind) the expressions can refer to: stored parameters and the
|
||||
/// named feature inputs.
|
||||
pub fn all_param_exprs(&self) -> Vec<(String, String, Kind)> {
|
||||
let mut v: Vec<(String, String, Kind)> = self.params.iter().map(|p| (p.name.clone(), p.expr.clone(), p.kind())).collect();
|
||||
for f in &self.features {
|
||||
for (name, (_, e, k)) in f.param_names.iter().zip(f.kind.inputs()) {
|
||||
if !name.is_empty() {
|
||||
v.push((name.clone(), e, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn name_taken(&self, n: &str) -> bool {
|
||||
self.param(n).is_some() || self.features.iter().any(|f| f.param_names.iter().any(|x| x == n))
|
||||
}
|
||||
|
||||
/// The next free `dN` name.
|
||||
pub fn next_d_name(&self) -> String {
|
||||
let mut n = 1;
|
||||
loop {
|
||||
let c = format!("d{n}");
|
||||
if !self.name_taken(&c) {
|
||||
return c;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Give a feature's inputs parameter names (keeping existing names in place).
|
||||
pub fn name_feature_inputs(&mut self, id: u64) {
|
||||
let Some(idx) = self.feature_index(id) else { return };
|
||||
let n = self.features.get(idx).map(|f| f.kind.inputs().len()).unwrap_or(0);
|
||||
for k in 0..n {
|
||||
let have = self.features.get(idx).and_then(|f| f.param_names.get(k)).is_some_and(|x| !x.is_empty());
|
||||
if have {
|
||||
continue;
|
||||
}
|
||||
let name = self.next_d_name();
|
||||
if let Some(f) = self.features.get_mut(idx) {
|
||||
while f.param_names.len() <= k {
|
||||
f.param_names.push(String::new());
|
||||
}
|
||||
if let Some(slot) = f.param_names.get_mut(k) {
|
||||
*slot = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = self.features.get_mut(idx) {
|
||||
f.param_names.truncate(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) };
|
||||
if expr_s.len() > 4096 {
|
||||
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();
|
||||
}
|
||||
if let Some(c) = comment {
|
||||
next.param_comments.insert(name.to_string(), c.to_string());
|
||||
}
|
||||
let (_, errors) = next.param_values();
|
||||
if let Some(e) = errors.get(name) {
|
||||
return Err(DocError::Expr(e.clone()));
|
||||
}
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The parameters table (user, sketch dimensions, feature inputs).
|
||||
pub fn param_rows(&self) -> Vec<ParamRow> {
|
||||
let (vals, errs) = self.param_values();
|
||||
let all = self.all_param_exprs();
|
||||
let refs: BTreeMap<String, Vec<String>> = all.iter().map(|(n, e, _)| (n.clone(), expr::references(e))).collect();
|
||||
let used_by = |n: &str| -> Vec<String> { refs.iter().filter(|(_, r)| r.iter().any(|x| x == n)).map(|(k, _)| k.clone()).collect() };
|
||||
let value = |n: &str, k: Kind| vals.get(n).and_then(|v| v.to_kind(k).ok()).map(|x| if k == Kind::Angle { x.to_degrees() } else { x });
|
||||
let mut rows = Vec::new();
|
||||
for p in &self.params {
|
||||
rows.push(ParamRow {
|
||||
name: p.name.clone(),
|
||||
expression: p.expr.clone(),
|
||||
unit: p.unit.clone(),
|
||||
value: self.param_display_value(&vals, &p.name),
|
||||
error: errs.get(&p.name).cloned(),
|
||||
comment: p.comment.clone(),
|
||||
source: if p.model { "sketch" } else { "user" },
|
||||
feature: None,
|
||||
input: None,
|
||||
favorite: self.favorites.contains(&p.name),
|
||||
uses: refs.get(&p.name).cloned().unwrap_or_default(),
|
||||
used_by: used_by(&p.name),
|
||||
});
|
||||
}
|
||||
for f in &self.features {
|
||||
for (name, (label, e, k)) in f.param_names.iter().zip(f.kind.inputs()) {
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
rows.push(ParamRow {
|
||||
name: name.clone(),
|
||||
expression: e,
|
||||
unit: unit_of(k).into(),
|
||||
value: value(name, k),
|
||||
error: errs.get(name).cloned(),
|
||||
comment: self.param_comments.get(name).cloned().unwrap_or_default(),
|
||||
source: "feature",
|
||||
feature: Some(f.name.clone()),
|
||||
input: Some(label),
|
||||
favorite: self.favorites.contains(name),
|
||||
uses: refs.get(name).cloned().unwrap_or_default(),
|
||||
used_by: used_by(name),
|
||||
});
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
pub fn set_favorite(&mut self, name: &str, on: bool) -> Result<()> {
|
||||
if !self.name_taken(name) {
|
||||
return Err(DocError::Unknown(format!("parameter `{name}`")));
|
||||
}
|
||||
if on {
|
||||
self.favorites.insert(name.to_string());
|
||||
} else {
|
||||
self.favorites.remove(name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// User parameters as CSV (`name,unit,expression,value,comment`).
|
||||
pub fn params_csv(&self) -> String {
|
||||
let q = |s: &str| if s.contains([',', '"', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };
|
||||
let mut out = String::from("name,unit,expression,value,comment,favorite\n");
|
||||
for r in self.param_rows().into_iter().filter(|r| r.source == "user") {
|
||||
out += &format!(
|
||||
"{},{},{},{},{},{}\n",
|
||||
q(&r.name),
|
||||
q(&r.unit),
|
||||
q(&r.expression),
|
||||
r.value.map(|v| format!("{v}")).unwrap_or_default(),
|
||||
q(&r.comment),
|
||||
r.favorite
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Import user parameters (CSV with a header, or JSON `[{name, expression, unit?, comment?}]`):
|
||||
/// existing ones are updated, new ones added, all checked before anything changes.
|
||||
pub fn import_params(&mut self, text: &str) -> Result<Vec<String>> {
|
||||
let rows: Vec<(String, String, Option<String>, Option<String>)> = if text.trim_start().starts_with('[') {
|
||||
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| DocError::Invalid(format!("parameters JSON: {e}")))?;
|
||||
v.as_array()
|
||||
.ok_or_else(|| DocError::Invalid("parameters JSON must be a list".into()))?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let s = |k: &str| r.get(k).and_then(|x| x.as_str().map(str::to_string).or_else(|| x.as_f64().map(|n| n.to_string())));
|
||||
Ok((
|
||||
s("name").ok_or_else(|| DocError::Invalid("a parameter without `name`".into()))?,
|
||||
s("expression").or_else(|| s("value")).unwrap_or_default(),
|
||||
s("unit"),
|
||||
s("comment"),
|
||||
))
|
||||
})
|
||||
.collect::<Result<_>>()?
|
||||
} else {
|
||||
let mut lines = text.lines();
|
||||
let header: Vec<String> = parse_csv_line(lines.next().unwrap_or_default()).into_iter().map(|h| h.trim().to_ascii_lowercase()).collect();
|
||||
let col = |n: &str| header.iter().position(|h| h == n);
|
||||
let (Some(ni), Some(ei)) = (col("name"), col("expression").or(col("value"))) else {
|
||||
return Err(DocError::Invalid("the CSV needs `name` and `expression` columns".into()));
|
||||
};
|
||||
let (ui, ci) = (col("unit"), col("comment"));
|
||||
lines
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
let c = parse_csv_line(l);
|
||||
let get = |i: Option<usize>| i.and_then(|i| c.get(i)).map(|s| s.trim().to_string());
|
||||
(get(Some(ni)).unwrap_or_default(), get(Some(ei)).unwrap_or_default(), get(ui), get(ci))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if rows.len() > 10_000 {
|
||||
return Err(DocError::Invalid("too many parameters".into()));
|
||||
}
|
||||
let mut next = self.clone();
|
||||
let mut names = Vec::new();
|
||||
for (n, e, u, c) in rows {
|
||||
next.change_param(&n, &e, u.as_deref(), c.as_deref())?;
|
||||
names.push(n);
|
||||
}
|
||||
*self = next;
|
||||
Ok(names)
|
||||
}
|
||||
}
|
||||
|
||||
/// Something that uses a parameter.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ParamUser {
|
||||
/// Another parameter's expression (a stored one or a feature input).
|
||||
Parameter { name: String },
|
||||
/// A feature input (`input` is its label, e.g. `Distance`).
|
||||
Feature { feature: u64, name: String, input: String },
|
||||
/// A sketch dimension (`constraint` is its id).
|
||||
Dimension { feature: u64, name: String, constraint: String },
|
||||
}
|
||||
|
||||
impl ParamUser {
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
ParamUser::Parameter { name } => format!("parameter {name}"),
|
||||
ParamUser::Feature { name, input, .. } => format!("{name} ({input})"),
|
||||
ParamUser::Dimension { name, constraint, .. } => format!("{name} dimension {constraint}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Length unit of the document in mm (bare numbers of unit-less parameters next to lengths).
|
||||
fn doc_len(units: &str) -> f64 {
|
||||
match expr::unit_info(units) {
|
||||
Some((Kind::Length, s)) => s,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Every parameter with its unit, for evaluation: stored parameters and named feature
|
||||
/// inputs (mm / deg / none by their kind).
|
||||
pub fn param_defs(&self) -> Vec<ParamDef> {
|
||||
let mut v: Vec<ParamDef> =
|
||||
self.params.iter().map(|p| ParamDef { name: p.name.clone(), expr: p.expr.clone(), unit: p.unit.clone() }).collect();
|
||||
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
|
||||
}
|
||||
|
||||
/// Evaluate all parameters: values (mm / rad / unit-less) and errors by name. Errors name
|
||||
/// the problem (`circular reference: a → b → a`, `cannot add a length and an angle`).
|
||||
pub fn param_values(&self) -> (BTreeMap<String, Value>, BTreeMap<String, String>) {
|
||||
expr::eval_params(&self.param_defs(), doc_len(&self.units))
|
||||
}
|
||||
|
||||
/// A parameter's value in its own unit (e.g. 2 for a 50.8 mm parameter in inches).
|
||||
pub fn param_display_value(&self, vals: &BTreeMap<String, Value>, name: &str) -> Option<f64> {
|
||||
let v = vals.get(name)?;
|
||||
let unit = match self.param(name) {
|
||||
Some(p) => p.unit.clone(),
|
||||
None => v.kind().map(|k| unit_of(k).to_string()).unwrap_or_default(),
|
||||
};
|
||||
Some(expr::value_in_unit(*v, &unit))
|
||||
}
|
||||
|
||||
/// The unit for a new parameter with this expression: that of its value; a bare number is
|
||||
/// a length (mm).
|
||||
pub(crate) fn infer_unit(&self, e: &str) -> String {
|
||||
let (vals, _) = self.param_values();
|
||||
let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))));
|
||||
match v.ok().and_then(|v| v.kind()) {
|
||||
Some(Kind::Unitless) if !expr::references(e).is_empty() => String::new(),
|
||||
Some(Kind::Angle) => "deg".into(),
|
||||
_ => "mm".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything that uses a parameter: other parameters (and feature inputs) whose
|
||||
/// expressions refer to it, and the sketch dimensions it drives.
|
||||
pub fn param_users(&self, name: &str) -> Vec<ParamUser> {
|
||||
let mut out = Vec::new();
|
||||
for p in &self.params {
|
||||
if p.name != name && expr::references(&p.expr).iter().any(|r| r == name) {
|
||||
out.push(ParamUser::Parameter { name: p.name.clone() });
|
||||
}
|
||||
}
|
||||
for f in &self.features {
|
||||
for (k, (label, e, _)) in f.kind.inputs().into_iter().enumerate() {
|
||||
if expr::references(&e).iter().any(|r| r == name) {
|
||||
match f.param_names.get(k).filter(|n| !n.is_empty()) {
|
||||
Some(n) => out.push(ParamUser::Parameter { name: n.clone() }),
|
||||
None => out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: label.to_string() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
for c in &sketch.constraints {
|
||||
if c.param.as_deref() == Some(name) {
|
||||
out.push(ParamUser::Dimension { feature: f.id, name: f.name.clone(), constraint: c.id.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Remove a stored parameter; refused while something uses it (the error lists the users).
|
||||
pub fn remove_param(&mut self, name: &str) -> Result<()> {
|
||||
if self.param(name).is_none() {
|
||||
if self.features.iter().any(|f| f.param_names.iter().any(|n| n == name)) {
|
||||
return Err(DocError::Invalid(format!("`{name}` is a feature's input: delete or edit the feature instead")));
|
||||
}
|
||||
return Err(DocError::Unknown(format!("parameter `{name}`")));
|
||||
}
|
||||
let users = self.param_users(name);
|
||||
if !users.is_empty() {
|
||||
let list: Vec<String> = users.iter().map(ParamUser::describe).collect();
|
||||
return Err(DocError::Invalid(format!("`{name}` is used by {}", list.join(", "))));
|
||||
}
|
||||
self.params.retain(|p| p.name != name);
|
||||
self.favorites.remove(name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename a parameter (stored or a feature input); every expression, feature input,
|
||||
/// dimension, favourite and comment that refers to it follows. Returns how many
|
||||
/// references were updated.
|
||||
pub fn rename_param(&mut self, old: &str, new: &str) -> Result<usize> {
|
||||
let new = new.trim();
|
||||
if !self.name_taken(old) {
|
||||
return Err(DocError::Unknown(format!("parameter `{old}`")));
|
||||
}
|
||||
if old == new {
|
||||
return Ok(0);
|
||||
}
|
||||
if let Some(p) = Self::param_name_problem(new) {
|
||||
return Err(DocError::Invalid(p));
|
||||
}
|
||||
if self.name_taken(new) {
|
||||
return Err(DocError::Invalid(format!("there is already a parameter `{new}`")));
|
||||
}
|
||||
let mut n = 0;
|
||||
for p in &mut self.params {
|
||||
if p.name == old {
|
||||
p.name = new.to_string();
|
||||
}
|
||||
let r = expr::rename_reference(&p.expr, old, new);
|
||||
if r != p.expr {
|
||||
p.expr = r;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for f in &mut self.features {
|
||||
for x in &mut f.param_names {
|
||||
if x == old {
|
||||
*x = new.to_string();
|
||||
}
|
||||
}
|
||||
for (_, e, _) in f.kind.inputs_mut() {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
*e = r;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Sketch { sketch, .. } = &mut f.kind {
|
||||
for c in &mut sketch.constraints {
|
||||
if c.param.as_deref() == Some(old) {
|
||||
c.param = Some(new.to_string());
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.favorites.remove(old) {
|
||||
self.favorites.insert(new.to_string());
|
||||
}
|
||||
for c in &mut self.configs.columns {
|
||||
if *c == crate::config::Column::Param(old.to_string()) {
|
||||
*c = crate::config::Column::Param(new.to_string());
|
||||
}
|
||||
}
|
||||
// Configuration cells refer to parameters too.
|
||||
for r in &mut self.configs.rows {
|
||||
for cell in &mut r.cells {
|
||||
*cell = expr::rename_reference(cell, old, new);
|
||||
}
|
||||
}
|
||||
if let Some(c) = self.param_comments.remove(old) {
|
||||
self.param_comments.insert(new.to_string(), c);
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Set a parameter's comment (stored parameters keep it, feature inputs in `param_comments`).
|
||||
pub fn set_param_comment(&mut self, name: &str, comment: &str) -> Result<()> {
|
||||
if comment.len() > 4096 {
|
||||
return Err(DocError::Invalid("comment too long".into()));
|
||||
}
|
||||
if let Some(p) = self.params.iter_mut().find(|p| p.name == name) {
|
||||
p.comment = comment.to_string();
|
||||
return Ok(());
|
||||
}
|
||||
if !self.name_taken(name) {
|
||||
return Err(DocError::Unknown(format!("parameter `{name}`")));
|
||||
}
|
||||
if comment.is_empty() {
|
||||
self.param_comments.remove(name);
|
||||
} else {
|
||||
self.param_comments.insert(name.to_string(), comment.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dependency edges: (user, parameter used). Users are parameter names, or
|
||||
/// `Feature:<name>` for a feature whose inputs or dimensions use the parameter.
|
||||
pub fn param_graph(&self) -> Vec<(String, String)> {
|
||||
let mut out = expr::param_edges(&self.param_defs());
|
||||
for f in &self.features {
|
||||
let mut names: BTreeSet<String> = f.param_names.iter().filter(|n| !n.is_empty()).cloned().collect();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
|
||||
}
|
||||
out.extend(names.into_iter().map(|n| (format!("Feature:{}", f.name), n)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Features whose result depends on a parameter (directly or through other parameters).
|
||||
pub fn features_using_param(&self, name: &str) -> Vec<u64> {
|
||||
let defs = self.param_defs();
|
||||
let mut affected: BTreeSet<String> = BTreeSet::from([name.to_string()]);
|
||||
for _ in 0..defs.len().min(MAX_PARAMS * 2) {
|
||||
let before = affected.len();
|
||||
for d in &defs {
|
||||
if expr::references(&d.expr).iter().any(|r| affected.contains(r)) {
|
||||
affected.insert(d.name.clone());
|
||||
}
|
||||
}
|
||||
if affected.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for f in &self.features {
|
||||
let mut hit = f.param_names.iter().any(|n| affected.contains(n));
|
||||
hit |= f.kind.expressions().iter().any(|e| expr::references(e).iter().any(|r| affected.contains(r)));
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
hit |= sketch.constraints.iter().any(|c| c.param.as_ref().is_some_and(|p| affected.contains(p)));
|
||||
}
|
||||
if hit {
|
||||
out.push(f.id);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Add a user parameter (refused when the name exists).
|
||||
pub fn add_user_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> {
|
||||
if self.name_taken(name.trim()) {
|
||||
return Err(DocError::Invalid(format!("there is already a parameter `{}`", name.trim())));
|
||||
}
|
||||
if self.params.len() >= MAX_PARAMS {
|
||||
return Err(DocError::Invalid("too many parameters".into()));
|
||||
}
|
||||
self.set_param(name.trim(), expr_s, unit, comment)
|
||||
}
|
||||
}
|
||||
|
||||
/// One CSV line with quoted fields.
|
||||
fn parse_csv_line(l: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut quoted = false;
|
||||
let mut chars = l.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match (c, quoted) {
|
||||
('"', true) if chars.peek() == Some(&'"') => {
|
||||
cur.push('"');
|
||||
chars.next();
|
||||
}
|
||||
('"', _) => quoted = !quoted,
|
||||
(',', false) => out.push(std::mem::take(&mut cur)),
|
||||
(c, _) => cur.push(c),
|
||||
}
|
||||
}
|
||||
out.push(cur);
|
||||
out
|
||||
}
|
||||
|
||||
/// Parameters whose definitions depend on each other in a loop.
|
||||
pub fn cycles(list: &[(String, String, Kind)]) -> BTreeSet<String> {
|
||||
let refs: BTreeMap<&str, Vec<String>> = list.iter().map(|(n, e, _)| (n.as_str(), expr::references(e))).collect();
|
||||
let mut bad = BTreeSet::new();
|
||||
for (start, _, _) in list {
|
||||
// Depth-first from `start`; reaching it again is a cycle.
|
||||
let mut stack: Vec<(String, usize)> = refs.get(start.as_str()).map(|r| r.iter().map(|x| (x.clone(), 1)).collect()).unwrap_or_default();
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some((n, d)) = stack.pop() {
|
||||
if &n == start {
|
||||
bad.insert(start.clone());
|
||||
break;
|
||||
}
|
||||
if d > 10_000 || !seen.insert(n.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = refs.get(n.as_str()) {
|
||||
stack.extend(r.iter().map(|x| (x.clone(), d + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
bad
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
//! Plastic rules: wall thickness, draft, radii and clearances for molded parts, as expressions
|
||||
//! (`Thickness` inside them is the rule's thickness). A body assigned a rule (or every body,
|
||||
//! when a rule is active) takes its draft and clearance as the defaults of plastic features.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::expr::{self, Kind, Value};
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PlasticRule {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub material: String,
|
||||
pub thickness: String,
|
||||
pub nominal_radius: String,
|
||||
pub clearance: String,
|
||||
pub knife_edge: String,
|
||||
pub reveal_height: String,
|
||||
pub thickness_variation: String,
|
||||
pub draft: String,
|
||||
pub max_thickness: String,
|
||||
pub min_thickness: String,
|
||||
pub min_draft: String,
|
||||
}
|
||||
|
||||
impl PlasticRule {
|
||||
fn new(name: &str, material: &str, inch: bool, thickness: &str, max: &str, min: &str) -> PlasticRule {
|
||||
let (clearance, knife, reveal) = if inch { ("0.004 in", "0.04 in", "0.02 in") } else { ("0.1 mm", "1 mm", "0.5 mm") };
|
||||
PlasticRule {
|
||||
name: name.into(),
|
||||
material: material.into(),
|
||||
thickness: thickness.into(),
|
||||
nominal_radius: "0.5 * Thickness".into(),
|
||||
clearance: clearance.into(),
|
||||
knife_edge: knife.into(),
|
||||
reveal_height: reveal.into(),
|
||||
thickness_variation: "0.15 * Thickness".into(),
|
||||
draft: "2 deg".into(),
|
||||
max_thickness: max.into(),
|
||||
min_thickness: min.into(),
|
||||
min_draft: "0.5 deg".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rule library (the first is the default).
|
||||
pub fn library() -> Vec<PlasticRule> {
|
||||
vec![
|
||||
PlasticRule::new("ABS (1.5mm)", "ABS Plastic", false, "1.5 mm", "3.5 mm", "1.2 mm"),
|
||||
PlasticRule::new("Nylon (PA6) (2.5mm)", "Nylon 6", false, "2.5 mm", "3 mm", "0.8 mm"),
|
||||
PlasticRule::new("Polypropylene (PP) (3 mm)", "Polypropylene", false, "3 mm", "3.8 mm", "0.8 mm"),
|
||||
PlasticRule::new("ABS (0.1 in)", "ABS Plastic", true, "0.1 in", "3.5 mm", "1.2 mm"),
|
||||
PlasticRule::new("Nylon (PA6) (0.1 in)", "Nylon 6", true, "0.1 in", "3 mm", "0.8 mm"),
|
||||
PlasticRule::new("Polypropylene (PP) (0.1 in)", "Polypropylene", true, "0.1 in", "3.8 mm", "0.8 mm"),
|
||||
]
|
||||
}
|
||||
|
||||
/// The expressions, by field name (as commands and the dialog name them).
|
||||
pub fn fields(&self) -> [(&'static str, &String, Kind); 10] {
|
||||
[
|
||||
("thickness", &self.thickness, Kind::Length),
|
||||
("nominal_radius", &self.nominal_radius, Kind::Length),
|
||||
("clearance", &self.clearance, Kind::Length),
|
||||
("knife_edge", &self.knife_edge, Kind::Length),
|
||||
("reveal_height", &self.reveal_height, Kind::Length),
|
||||
("thickness_variation", &self.thickness_variation, Kind::Length),
|
||||
("draft", &self.draft, Kind::Angle),
|
||||
("max_thickness", &self.max_thickness, Kind::Length),
|
||||
("min_thickness", &self.min_thickness, Kind::Length),
|
||||
("min_draft", &self.min_draft, Kind::Angle),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn field_mut(&mut self, name: &str) -> Option<&mut String> {
|
||||
Some(match name {
|
||||
"thickness" => &mut self.thickness,
|
||||
"nominal_radius" => &mut self.nominal_radius,
|
||||
"clearance" => &mut self.clearance,
|
||||
"knife_edge" => &mut self.knife_edge,
|
||||
"reveal_height" => &mut self.reveal_height,
|
||||
"thickness_variation" => &mut self.thickness_variation,
|
||||
"draft" => &mut self.draft,
|
||||
"max_thickness" => &mut self.max_thickness,
|
||||
"min_thickness" => &mut self.min_thickness,
|
||||
"min_draft" => &mut self.min_draft,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The design's own plastic rules, the active one and the rules assigned to bodies.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PlasticSettings {
|
||||
/// Rules made or edited in this design (a library rule edited here is copied in).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rules: Vec<PlasticRule>,
|
||||
/// Applies to bodies with no rule of their own.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active: Option<String>,
|
||||
/// Body name → rule name.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub assigned: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl PlasticSettings {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty() && self.active.is_none() && self.assigned.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// A rule's values (mm, radians).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct PlasticValues {
|
||||
pub thickness: f64,
|
||||
pub nominal_radius: f64,
|
||||
pub clearance: f64,
|
||||
pub knife_edge: f64,
|
||||
pub reveal_height: f64,
|
||||
pub thickness_variation: f64,
|
||||
pub draft: f64,
|
||||
pub max_thickness: f64,
|
||||
pub min_thickness: f64,
|
||||
pub min_draft: f64,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// A rule by name: the design's own first, then the library.
|
||||
pub fn plastic_rule(&self, name: &str) -> Option<PlasticRule> {
|
||||
self.plastic.rules.iter().find(|r| r.name == name).cloned().or_else(|| PlasticRule::library().into_iter().find(|r| r.name == name))
|
||||
}
|
||||
|
||||
/// Every rule: the library (as edited in this design) and the design's own.
|
||||
pub fn plastic_rules(&self) -> Vec<PlasticRule> {
|
||||
let mut out: Vec<PlasticRule> = PlasticRule::library().into_iter().map(|l| self.plastic_rule(&l.name).unwrap_or(l)).collect();
|
||||
let own: Vec<PlasticRule> = self.plastic.rules.iter().filter(|r| !out.iter().any(|o| o.name == r.name)).cloned().collect();
|
||||
out.extend(own);
|
||||
out
|
||||
}
|
||||
|
||||
/// The rule that applies to a body: its own, else the active one.
|
||||
pub fn plastic_rule_for(&self, body: &str) -> Option<PlasticRule> {
|
||||
self.plastic.assigned.get(body).or(self.plastic.active.as_ref()).and_then(|n| self.plastic_rule(n))
|
||||
}
|
||||
|
||||
pub fn plastic_values(&self, vals: &BTreeMap<String, Value>, r: &PlasticRule) -> Result<PlasticValues> {
|
||||
let look = |n: &str| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")));
|
||||
let t = expr::eval_with(&r.thickness, &look)?.to_kind(Kind::Length)?;
|
||||
if !(t > 0.0 && t < 1e4) {
|
||||
return Err(DocError::Invalid("the plastic thickness must be positive".into()));
|
||||
}
|
||||
let with_t = |e: &str, k: Kind| -> Result<f64> {
|
||||
let f = |n: &str| if n == "Thickness" && !vals.contains_key("Thickness") { Ok(Value::length(t)) } else { look(n) };
|
||||
let v = expr::eval_with(e, &f)?.to_kind(k)?;
|
||||
if !v.is_finite() || v < 0.0 {
|
||||
return Err(DocError::Invalid(format!("plastic rule `{}`: `{e}` must not be negative", r.name)));
|
||||
}
|
||||
Ok(v)
|
||||
};
|
||||
Ok(PlasticValues {
|
||||
thickness: t,
|
||||
nominal_radius: with_t(&r.nominal_radius, Kind::Length)?,
|
||||
clearance: with_t(&r.clearance, Kind::Length)?,
|
||||
knife_edge: with_t(&r.knife_edge, Kind::Length)?,
|
||||
reveal_height: with_t(&r.reveal_height, Kind::Length)?,
|
||||
thickness_variation: with_t(&r.thickness_variation, Kind::Length)?,
|
||||
draft: with_t(&r.draft, Kind::Angle)?,
|
||||
max_thickness: with_t(&r.max_thickness, Kind::Length)?,
|
||||
min_thickness: with_t(&r.min_thickness, Kind::Length)?,
|
||||
min_draft: with_t(&r.min_draft, Kind::Angle)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn library_values() {
|
||||
let d = Document::default();
|
||||
let vals = BTreeMap::new();
|
||||
let abs = d.plastic_values(&vals, &PlasticRule::library()[0]).unwrap();
|
||||
assert_eq!((abs.thickness, abs.nominal_radius, abs.clearance), (1.5, 0.75, 0.1));
|
||||
assert!((abs.thickness_variation - 0.225).abs() < 1e-12);
|
||||
assert!((abs.draft - 2f64.to_radians()).abs() < 1e-12);
|
||||
let pp_in = d.plastic_values(&vals, &d.plastic_rule("Polypropylene (PP) (0.1 in)").unwrap()).unwrap();
|
||||
assert!((pp_in.thickness - 2.54).abs() < 1e-9 && (pp_in.nominal_radius - 1.27).abs() < 1e-9 && (pp_in.clearance - 0.1016).abs() < 1e-9);
|
||||
assert_eq!(d.plastic_rules().len(), 6);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,462 +0,0 @@
|
||||
//! 3D sketch curves from the model: curves projected onto faces, intersection curves of bodies
|
||||
//! and faces (on their display meshes), and spun profiles.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use solvecraft_geom::{Mesh, Plane, Vec2, Vec3};
|
||||
use solvecraft_sketch::LinkSource;
|
||||
|
||||
use crate::eval::ModelState;
|
||||
|
||||
/// Most triangle pairs examined by one intersection.
|
||||
const MAX_PAIRS: usize = 20_000_000;
|
||||
|
||||
/// The mesh (and face, for a face source) of a body or face reference.
|
||||
pub(crate) fn mesh_of(st: &ModelState, src: &LinkSource) -> Option<(Arc<Mesh>, Option<u32>)> {
|
||||
match src {
|
||||
LinkSource::Body { body } => Some((st.body(body)?.mesh(), None)),
|
||||
LinkSource::Face { body, at } => {
|
||||
let b = st.body(body).or_else(|| st.bodies.iter().min_by(|x, y| dist_to(&x.mesh(), *at).total_cmp(&dist_to(&y.mesh(), *at))))?;
|
||||
let m = b.mesh();
|
||||
let (f, _, _) = crate::project::nearest_face(&m, *at)?;
|
||||
Some((m, Some(f)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn dist_to(m: &Mesh, p: Vec3) -> f64 {
|
||||
crate::project::nearest_face(m, p).map(|x| x.2).unwrap_or(f64::INFINITY)
|
||||
}
|
||||
|
||||
fn tris(m: &Mesh, face: Option<u32>) -> Vec<[Vec3; 3]> {
|
||||
m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| face.is_none_or(|x| x == **f)).filter_map(|(t, _)| m.tri(t)).collect()
|
||||
}
|
||||
|
||||
/// Ray–triangle hit parameter (Möller–Trumbore), any sign.
|
||||
fn ray_hit(o: Vec3, d: Vec3, t: &[Vec3; 3]) -> Option<f64> {
|
||||
let (e1, e2) = (t[1] - t[0], t[2] - t[0]);
|
||||
let p = d.cross(e2);
|
||||
let det = e1.dot(p);
|
||||
if det.abs() < 1e-14 {
|
||||
return None;
|
||||
}
|
||||
let inv = 1.0 / det;
|
||||
let s = o - t[0];
|
||||
let u = s.dot(p) * inv;
|
||||
if !(-1e-9..=1.0 + 1e-9).contains(&u) {
|
||||
return None;
|
||||
}
|
||||
let q = s.cross(e1);
|
||||
let v = d.dot(q) * inv;
|
||||
if v < -1e-9 || u + v > 1.0 + 1e-9 {
|
||||
return None;
|
||||
}
|
||||
Some(e2.dot(q) * inv)
|
||||
}
|
||||
|
||||
/// Project a world polyline onto face `f` along `dir` (either way, nearest hit). Points that
|
||||
/// miss the face split the result.
|
||||
pub(crate) fn onto_face(m: &Mesh, f: u32, pts: &[Vec3], dir: Vec3) -> Vec<Vec<Vec3>> {
|
||||
let Some(d) = dir.normalized() else { return Vec::new() };
|
||||
let ts = tris(m, Some(f));
|
||||
let size = m.bounds().diagonal().max(1e-9);
|
||||
// Resample so the projected curve follows the surface.
|
||||
let step = size / 200.0;
|
||||
let mut dense: Vec<Vec3> = Vec::new();
|
||||
for w in pts.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let n = ((a.dist(b) / step).ceil() as usize).clamp(1, 2000);
|
||||
for i in 0..n {
|
||||
dense.push(a.lerp(b, i as f64 / n as f64));
|
||||
}
|
||||
if dense.len() > 200_000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(l) = pts.last() {
|
||||
dense.push(*l);
|
||||
}
|
||||
let mut out: Vec<Vec<Vec3>> = Vec::new();
|
||||
let mut cur: Vec<Vec3> = Vec::new();
|
||||
for p in dense {
|
||||
let hit = ts.iter().filter_map(|t| ray_hit(p, d, t)).min_by(|a, b| a.abs().total_cmp(&b.abs()));
|
||||
match hit {
|
||||
Some(t) => cur.push(p + d * t),
|
||||
None => {
|
||||
if cur.len() >= 2 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
} else {
|
||||
cur.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cur.len() >= 2 {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Segment where two triangles cross (None when they do not, or are coplanar).
|
||||
fn tri_tri(a: &[Vec3; 3], b: &[Vec3; 3]) -> Option<(Vec3, Vec3)> {
|
||||
let nb = (b[1] - b[0]).cross(b[2] - b[0]).normalized()?;
|
||||
let na = (a[1] - a[0]).cross(a[2] - a[0]).normalized()?;
|
||||
// Points where one triangle's edges pass through the other's plane.
|
||||
let cut = |t: &[Vec3; 3], n: Vec3, o: Vec3| -> Option<(Vec3, Vec3)> {
|
||||
let h: Vec<f64> = t.iter().map(|p| (*p - o).dot(n)).collect();
|
||||
if h.iter().all(|x| *x > 1e-12) || h.iter().all(|x| *x < -1e-12) {
|
||||
return None;
|
||||
}
|
||||
let mut pts = Vec::new();
|
||||
for (i, j) in [(0usize, 1usize), (1, 2), (2, 0)] {
|
||||
let (hi, hj) = (h[i], h[j]);
|
||||
if (hi > 0.0) != (hj > 0.0) {
|
||||
pts.push(t[i].lerp(t[j], hi / (hi - hj)));
|
||||
} else if hi == 0.0 {
|
||||
pts.push(t[i]);
|
||||
}
|
||||
}
|
||||
match pts[..] {
|
||||
[p, q, ..] => Some((p, q)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if na.cross(nb).len() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let (a0, a1) = cut(a, nb, b[0])?;
|
||||
let (b0, b1) = cut(b, na, a[0])?;
|
||||
// Overlap of the two segments on the common line.
|
||||
let dir = na.cross(nb);
|
||||
let pr = |p: Vec3| p.dot(dir);
|
||||
let (sa0, sa1) = if pr(a0) <= pr(a1) { (a0, a1) } else { (a1, a0) };
|
||||
let (sb0, sb1) = if pr(b0) <= pr(b1) { (b0, b1) } else { (b1, b0) };
|
||||
let lo = if pr(sa0) >= pr(sb0) { sa0 } else { sb0 };
|
||||
let hi = if pr(sa1) <= pr(sb1) { sa1 } else { sb1 };
|
||||
(pr(hi) - pr(lo) > 1e-12).then_some((lo, hi))
|
||||
}
|
||||
|
||||
/// Where two meshes (or faces of them) meet, as polylines.
|
||||
pub(crate) fn mesh_intersection(ma: &Mesh, fa: Option<u32>, mb: &Mesh, fb: Option<u32>) -> Vec<Vec<Vec3>> {
|
||||
let (ta, tb) = (tris(ma, fa), tris(mb, fb));
|
||||
let bbox = |t: &[Vec3; 3]| (t[0].min(t[1]).min(t[2]), t[0].max(t[1]).max(t[2]));
|
||||
// Uniform grid over B.
|
||||
let lo = tb.iter().fold(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), |m, t| m.min(bbox(t).0));
|
||||
let hi = tb.iter().fold(Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |m, t| m.max(bbox(t).1));
|
||||
if !(lo.is_finite() && hi.is_finite()) {
|
||||
return Vec::new();
|
||||
}
|
||||
let n = ((tb.len() as f64).cbrt().ceil() as usize).clamp(1, 48);
|
||||
let ext = hi - lo;
|
||||
let cell = |p: Vec3| -> [usize; 3] {
|
||||
let f = |v: f64, l: f64, e: f64| if e > 1e-12 { (((v - l) / e) * n as f64).floor().clamp(0.0, (n - 1) as f64) as usize } else { 0 };
|
||||
[f(p.x, lo.x, ext.x), f(p.y, lo.y, ext.y), f(p.z, lo.z, ext.z)]
|
||||
};
|
||||
let mut grid: BTreeMap<[usize; 3], Vec<usize>> = BTreeMap::new();
|
||||
for (i, t) in tb.iter().enumerate() {
|
||||
let (b0, b1) = bbox(t);
|
||||
let (c0, c1) = (cell(b0), cell(b1));
|
||||
for x in c0[0]..=c1[0] {
|
||||
for y in c0[1]..=c1[1] {
|
||||
for z in c0[2]..=c1[2] {
|
||||
grid.entry([x, y, z]).or_default().push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut segs: Vec<(Vec3, Vec3)> = Vec::new();
|
||||
let mut pairs = 0usize;
|
||||
for t in &ta {
|
||||
let (b0, b1) = bbox(t);
|
||||
if b1.x < lo.x || b1.y < lo.y || b1.z < lo.z || b0.x > hi.x || b0.y > hi.y || b0.z > hi.z {
|
||||
continue;
|
||||
}
|
||||
let (c0, c1) = (cell(b0), cell(b1));
|
||||
let mut cand: Vec<usize> = Vec::new();
|
||||
for x in c0[0]..=c1[0] {
|
||||
for y in c0[1]..=c1[1] {
|
||||
for z in c0[2]..=c1[2] {
|
||||
if let Some(v) = grid.get(&[x, y, z]) {
|
||||
cand.extend(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cand.sort_unstable();
|
||||
cand.dedup();
|
||||
for j in cand {
|
||||
pairs += 1;
|
||||
if pairs > MAX_PAIRS {
|
||||
break;
|
||||
}
|
||||
if let Some(u) = tb.get(j)
|
||||
&& let Some(sg) = tri_tri(t, u)
|
||||
{
|
||||
segs.push(sg);
|
||||
}
|
||||
}
|
||||
}
|
||||
chain3(&segs, ma.bounds().diagonal().max(mb.bounds().diagonal()).max(1e-9))
|
||||
}
|
||||
|
||||
/// Chain 3D segments into polylines by shared end points.
|
||||
fn chain3(segs: &[(Vec3, Vec3)], size: f64) -> Vec<Vec<Vec3>> {
|
||||
let q = size * 1e-7;
|
||||
let key = |p: Vec3| ((p.x / q).round() as i64, (p.y / q).round() as i64, (p.z / q).round() as i64);
|
||||
let mut adj: BTreeMap<(i64, i64, i64), (Vec3, Vec<usize>)> = BTreeMap::new();
|
||||
let mut uniq: Vec<((i64, i64, i64), (i64, i64, i64))> = Vec::new();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for (a, b) in segs {
|
||||
let (ka, kb) = (key(*a), key(*b));
|
||||
if ka == kb {
|
||||
continue;
|
||||
}
|
||||
let k = if ka <= kb { (ka, kb) } else { (kb, ka) };
|
||||
if !seen.insert(k) {
|
||||
continue;
|
||||
}
|
||||
let i = uniq.len();
|
||||
uniq.push(k);
|
||||
adj.entry(ka).or_insert_with(|| (*a, Vec::new())).1.push(i);
|
||||
adj.entry(kb).or_insert_with(|| (*b, Vec::new())).1.push(i);
|
||||
}
|
||||
let mut used = vec![false; uniq.len()];
|
||||
let mut out = Vec::new();
|
||||
let starts: Vec<(i64, i64, i64)> = adj.iter().filter(|(_, v)| v.1.len() != 2).map(|(k, _)| *k).chain(adj.keys().copied()).collect();
|
||||
for s in starts {
|
||||
while let Some(first) = adj.get(&s).and_then(|v| v.1.iter().copied().find(|e| !used.get(*e).copied().unwrap_or(true))) {
|
||||
let mut chain = vec![adj.get(&s).map(|v| v.0).unwrap_or_default()];
|
||||
let mut cur = s;
|
||||
let mut e = first;
|
||||
loop {
|
||||
if let Some(u) = used.get_mut(e) {
|
||||
*u = true;
|
||||
}
|
||||
let Some(&(ka, kb)) = uniq.get(e) else { break };
|
||||
cur = if ka == cur { kb } else { ka };
|
||||
chain.push(adj.get(&cur).map(|v| v.0).unwrap_or_default());
|
||||
let Some(next) = adj
|
||||
.get(&cur)
|
||||
.and_then(|v| if v.1.len() == 2 { v.1.iter().copied().find(|x| !used.get(*x).copied().unwrap_or(true)) } else { None })
|
||||
else {
|
||||
break;
|
||||
};
|
||||
e = next;
|
||||
}
|
||||
if chain.len() >= 2 {
|
||||
out.push(chain);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Outline of a body spun about the axis `origin + t·dir`: the largest distance from the axis
|
||||
/// at each station along it, laid into the sketch plane on the side of the axis the plane's
|
||||
/// in-plane perpendicular points to.
|
||||
pub(crate) fn spun_outline(m: &Mesh, origin: Vec3, dir: Vec3, plane: &Plane) -> Vec<Vec2> {
|
||||
let Some(d) = dir.normalized() else { return Vec::new() };
|
||||
let Some(u) = plane.normal().cross(d).normalized() else { return Vec::new() };
|
||||
let ax = |p: Vec3| (p - origin).dot(d);
|
||||
let rad = |p: Vec3| {
|
||||
let v = p - origin;
|
||||
(v - d * v.dot(d)).len()
|
||||
};
|
||||
let (t0, t1) = m.positions.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), p| (a.min(ax(*p)), b.max(ax(*p))));
|
||||
if !(t0.is_finite() && t1 > t0) {
|
||||
return Vec::new();
|
||||
}
|
||||
let n = 240;
|
||||
let mut out = Vec::new();
|
||||
for i in 0..=n {
|
||||
let t = t0 + (t1 - t0) * i as f64 / n as f64;
|
||||
let mut r = f64::NEG_INFINITY;
|
||||
for tri in &m.triangles {
|
||||
let Some(p) = m.tri(tri) else { continue };
|
||||
for (a, b) in [(p[0], p[1]), (p[1], p[2]), (p[2], p[0])] {
|
||||
let (ha, hb) = (ax(a) - t, ax(b) - t);
|
||||
if ha.abs() < 1e-12 {
|
||||
r = r.max(rad(a));
|
||||
}
|
||||
if (ha > 0.0) != (hb > 0.0) && (ha - hb).abs() > 1e-15 {
|
||||
r = r.max(rad(a.lerp(b, ha / (ha - hb))));
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.is_finite() {
|
||||
out.push(plane.to_local(origin + d * t + u * r));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An isoparametric curve of face `f` through `p`: the face cut by a plane through `p`. For
|
||||
/// curved faces `u` runs around (the plane across the face's straight direction, e.g. a
|
||||
/// cylinder's axis) and `v` along it; planar faces use the face plane's own axes. An explicit
|
||||
/// direction `"x,y,z"` cuts along it.
|
||||
pub(crate) fn iso_curve(m: &Mesh, f: u32, p: Vec3, dir: &str) -> Vec<Vec<Vec3>> {
|
||||
let ns: Vec<Vec3> = m
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, x)| **x == f)
|
||||
.filter_map(|(t, _)| m.tri(t))
|
||||
.filter_map(|t| (t[1] - t[0]).cross(t[2] - t[0]).normalized())
|
||||
.collect();
|
||||
let Some(n0) = crate::project::nearest_face(m, p).and_then(|_| ns.first().copied()) else { return Vec::new() };
|
||||
// Normal at p: of the nearest triangle of the face.
|
||||
let np = m
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, x)| **x == f)
|
||||
.filter_map(|(t, _)| m.tri(t))
|
||||
.min_by(|a, b| tri_dist(p, a).total_cmp(&tri_dist(p, b)))
|
||||
.and_then(|t| (t[1] - t[0]).cross(t[2] - t[0]).normalized())
|
||||
.unwrap_or(n0);
|
||||
// The face's straight direction: perpendicular to how its normals vary.
|
||||
let straight = ns.iter().map(|n| np.cross(*n)).filter(|c| c.len() > 1e-3).fold(None::<Vec3>, |acc, c| match acc {
|
||||
None => c.normalized(),
|
||||
Some(a) => (a + if a.dot(c) < 0.0 { -c } else { c }).normalized(),
|
||||
});
|
||||
let along = match dir.trim().to_ascii_lowercase().as_str() {
|
||||
"u" | "v" => {
|
||||
let s = straight.unwrap_or_else(|| Plane::from_normal(p, np).map(|pl| pl.x).unwrap_or(Vec3::X));
|
||||
if dir.trim().eq_ignore_ascii_case("v") { s } else { np.cross(s) }
|
||||
}
|
||||
other => {
|
||||
let v: Vec<f64> = other.split([',', ' ']).filter_map(|x| x.trim().parse().ok()).collect();
|
||||
match v[..] {
|
||||
[x, y, z] => Vec3::new(x, y, z),
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
}
|
||||
};
|
||||
// Cutting plane through p containing the face normal and the curve direction.
|
||||
let Some(cut_n) = np.cross(along).normalized() else { return Vec::new() };
|
||||
// Nudged off the point so the cut does not run exactly along mesh edges (a seam); the side
|
||||
// that cuts more of the face wins.
|
||||
let nudge = m.bounds().diagonal().max(1e-9) * 1e-8;
|
||||
let cut = |off: f64| -> Vec<Vec<Vec3>> {
|
||||
let Some(pl) = Plane::from_normal(p + cut_n * off, cut_n) else { return Vec::new() };
|
||||
let geom = crate::project::section(&pl, m, Some(f));
|
||||
let pieces: Vec<Vec<Vec3>> =
|
||||
geom.iter().map(|g| link_polyline(g).into_iter().map(|q| pl.to_world(q)).collect()).filter(|w: &Vec<Vec3>| w.len() >= 2).collect();
|
||||
join_wires(pieces, m.bounds().diagonal().max(1e-9) * 1e-6)
|
||||
};
|
||||
let len = |ws: &Vec<Vec<Vec3>>| ws.iter().flat_map(|w| w.windows(2).map(|x| x[0].dist(x[1]))).sum::<f64>();
|
||||
let (a, b) = (cut(nudge), cut(-nudge));
|
||||
if len(&a) >= len(&b) { a } else { b }
|
||||
}
|
||||
|
||||
/// Isocurve analysis: `count` curves each way across face `f` (picked at `p`), as world
|
||||
/// polylines. Each family is the face cut by parallel planes spaced evenly over the face.
|
||||
pub fn iso_grid(m: &Mesh, f: u32, p: Vec3, count: usize) -> Vec<Vec<Vec3>> {
|
||||
let count = count.clamp(1, 100);
|
||||
let verts: Vec<Vec3> = m.triangles.iter().zip(&m.tri_face).filter(|(_, x)| **x == f).filter_map(|(t, _)| m.tri(t)).flatten().collect();
|
||||
if verts.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for dir in ["u", "v"] {
|
||||
// The family's cutting direction, from the curve through p.
|
||||
let Some(first) = iso_curve(m, f, p, dir).into_iter().next() else { continue };
|
||||
let pts: Vec<Vec3> = first.to_vec();
|
||||
// Normal of the plane that curve lies in.
|
||||
let Some(c) = pts.first().copied() else { continue };
|
||||
let mut n = Vec3::ZERO;
|
||||
for w in pts.windows(2) {
|
||||
n += (w[0] - c).cross(w[1] - c);
|
||||
}
|
||||
let cut_n = match n.normalized() {
|
||||
Some(n) => n,
|
||||
// A straight curve: across it, within the face.
|
||||
None => {
|
||||
let d = pts.last().copied().unwrap_or(c) - c;
|
||||
let np = (verts[1] - verts[0]).cross(verts[2] - verts[0]);
|
||||
match d.cross(np).normalized() {
|
||||
Some(x) => x,
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
};
|
||||
let (lo, hi) = verts.iter().map(|v| v.dot(cut_n)).fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(x), b.max(x)));
|
||||
if !(hi - lo > 1e-9) {
|
||||
continue;
|
||||
}
|
||||
for k in 1..=count {
|
||||
let d = lo + (hi - lo) * k as f64 / (count + 1) as f64;
|
||||
let Some(pl) = Plane::from_normal(cut_n * d, cut_n) else { continue };
|
||||
for g in crate::project::section(&pl, m, Some(f)) {
|
||||
let w: Vec<Vec3> = link_polyline(&g).into_iter().map(|q| pl.to_world(q)).collect();
|
||||
if w.len() >= 2 {
|
||||
out.push(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Join polylines whose ends meet into longer ones.
|
||||
fn join_wires(mut ws: Vec<Vec<Vec3>>, tol: f64) -> Vec<Vec<Vec3>> {
|
||||
let mut out: Vec<Vec<Vec3>> = Vec::new();
|
||||
while let Some(mut cur) = ws.pop() {
|
||||
let mut grew = true;
|
||||
while grew {
|
||||
grew = false;
|
||||
for i in 0..ws.len() {
|
||||
let (Some(cs), Some(ce)) = (cur.first().copied(), cur.last().copied()) else { break };
|
||||
let (Some(ws0), Some(we)) = (ws[i].first().copied(), ws[i].last().copied()) else { continue };
|
||||
let mut w = if ce.dist(ws0) < tol || cs.dist(we) < tol || ce.dist(we) < tol || cs.dist(ws0) < tol { ws.remove(i) } else { continue };
|
||||
if ce.dist(ws0) < tol {
|
||||
cur.extend(w.drain(1..));
|
||||
} else if ce.dist(we) < tol {
|
||||
w.reverse();
|
||||
cur.extend(w.drain(1..));
|
||||
} else if cs.dist(we) < tol {
|
||||
w.extend(cur.drain(1..));
|
||||
cur = w;
|
||||
} else {
|
||||
w.reverse();
|
||||
w.extend(cur.drain(1..));
|
||||
cur = w;
|
||||
}
|
||||
grew = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tri_dist(p: Vec3, t: &[Vec3; 3]) -> f64 {
|
||||
let c = (t[0] + t[1] + t[2]) / 3.0;
|
||||
c.dist(p)
|
||||
}
|
||||
|
||||
/// Sample a fitted planar curve.
|
||||
fn link_polyline(g: &solvecraft_sketch::LinkGeom) -> Vec<Vec2> {
|
||||
use solvecraft_sketch::LinkGeom;
|
||||
match *g {
|
||||
LinkGeom::Line(a, b) => vec![a, b],
|
||||
LinkGeom::Circle(c, r) => (0..=96).map(|i| c + Vec2::from_angle(i as f64 / 96.0 * std::f64::consts::TAU) * r).collect(),
|
||||
LinkGeom::Arc { c, a, b } => {
|
||||
let (s0, mut sw) = ((a - c).angle(), (b - c).angle() - (a - c).angle());
|
||||
while sw <= 1e-12 {
|
||||
sw += std::f64::consts::TAU;
|
||||
}
|
||||
let r = c.dist(a);
|
||||
(0..=48).map(|i| c + Vec2::from_angle(s0 + sw * i as f64 / 48.0) * r).collect()
|
||||
}
|
||||
LinkGeom::Conic { a, apex, b, rho } => (0..=32).map(|i| solvecraft_sketch::conic_point(a, apex, b, rho, i as f64 / 32.0)).collect(),
|
||||
LinkGeom::Ellipse { c, major, minor } => {
|
||||
(0..=96).map(|i| solvecraft_sketch::ellipse_point(c, major, minor, i as f64 / 96.0 * std::f64::consts::TAU)).collect()
|
||||
}
|
||||
LinkGeom::Spline { ref pts, control, degree } => solvecraft_sketch::spline_polyline(pts, control, degree),
|
||||
LinkGeom::Point(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,277 +0,0 @@
|
||||
use solvecraft_geom::{Vec2, Vec3};
|
||||
use solvecraft_kernel::measure;
|
||||
use solvecraft_sketch::{ConstraintKind, Sketch};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
|
||||
/// 40 x 30 rectangle with its width and height driven by `width` / d2.
|
||||
fn plate_doc() -> (Document, u64) {
|
||||
let mut doc = Document::new("plate");
|
||||
doc.set_param("width", "40 mm", Some("mm"), None).unwrap();
|
||||
let mut sk = Sketch::new();
|
||||
let pts = [Vec2::new(0.0, 0.0), Vec2::new(38.0, 0.5), Vec2::new(39.0, 31.0), Vec2::new(0.5, 29.0)];
|
||||
let ids: Vec<usize> = pts.iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
|
||||
let l: Vec<usize> = (0..4).map(|i| sk.add_line_pts(ids[i], ids[(i + 1) % 4], None).unwrap()).collect();
|
||||
use ConstraintKind::*;
|
||||
sk.add_constraint(Coincident { p: ids[0], q: 0 }, None).unwrap();
|
||||
sk.add_constraint(Horizontal { l: l[0] }, None).unwrap();
|
||||
sk.add_constraint(Horizontal { l: l[2] }, None).unwrap();
|
||||
sk.add_constraint(Vertical { l: l[1] }, None).unwrap();
|
||||
sk.add_constraint(Vertical { l: l[3] }, None).unwrap();
|
||||
let d1 = doc.new_model_param("width", "mm");
|
||||
sk.add_constraint(Length { l: l[0], value: 40.0 }, Some(d1)).unwrap();
|
||||
let d2 = doc.new_model_param("30 mm", "mm");
|
||||
sk.add_constraint(Length { l: l[1], value: 30.0 }, Some(d2)).unwrap();
|
||||
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: s,
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
distance: "20".into(),
|
||||
direction: Direction::Positive,
|
||||
distance2: None,
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
taper: None,
|
||||
to: None,
|
||||
to_offset: None,
|
||||
from: None,
|
||||
},
|
||||
operation: Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
(doc, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parametric_plate_reevaluates() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results);
|
||||
let st = m.state();
|
||||
assert_eq!(st.bodies.len(), 1);
|
||||
assert_eq!(st.bodies[0].name, "Body1");
|
||||
assert!(rel(measure(&st.bodies[0].body).unwrap().volume, 24000.0) < 1e-9);
|
||||
assert!(st.sketches[0].report.fully_constrained());
|
||||
|
||||
// No change: nothing recomputed.
|
||||
m.evaluate(&doc);
|
||||
assert_eq!(m.last_recomputed, 0);
|
||||
|
||||
doc.set_param("width", "50 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!(m.last_recomputed, 2);
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 30000.0) < 1e-9);
|
||||
|
||||
// Changing only the extrude distance re-evaluates only the extrude.
|
||||
if let Some(FeatureKind::Extrude { extent, .. }) = doc.features.get_mut(1).map(|f| &mut f.kind) {
|
||||
extent.distance = "width / 5".into();
|
||||
}
|
||||
m.evaluate(&doc);
|
||||
assert_eq!(m.last_recomputed, 1);
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
|
||||
}
|
||||
|
||||
#[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();
|
||||
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();
|
||||
doc.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: s2,
|
||||
profiles: ProfileSel::Points { points: vec![Vec2::new(20.0, 15.0)] },
|
||||
extent: Extent {
|
||||
distance: "20".into(),
|
||||
direction: Direction::Positive,
|
||||
distance2: None,
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
taper: None,
|
||||
to: None,
|
||||
to_offset: None,
|
||||
from: None,
|
||||
},
|
||||
operation: Operation::Cut,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
|
||||
let st = m.state();
|
||||
let v = measure(&st.bodies[0].body).unwrap().volume;
|
||||
let expect = 24000.0 - (9.0 - std::f64::consts::PI * 9.0 / 4.0) * 20.0 - std::f64::consts::PI * 25.0 * 20.0;
|
||||
assert!(rel(v, expect) < 1e-3, "{v} vs {expect}");
|
||||
// Roll back to before the fillet.
|
||||
doc.marker = Some(2);
|
||||
m.evaluate(&doc);
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 24000.0) < 1e-9);
|
||||
assert!(m.results[3].skipped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_reported_not_fatal() {
|
||||
let (mut doc, s) = plate_doc();
|
||||
doc.add_feature(
|
||||
FeatureKind::Revolve {
|
||||
sketch: s,
|
||||
profiles: ProfileSel::All,
|
||||
axis: AxisRef::SketchAxis { axis: "y".into() },
|
||||
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::Box { corner: Vec3::ZERO, length: "nope".into(), width: "1".into(), height: "1".into(), operation: Operation::NewBody },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results[2].error.is_none(), "{:?}", m.results[2]);
|
||||
assert!(m.results[3].error.is_some());
|
||||
assert!(m.results[4].error.as_deref().unwrap_or("").contains("nope"));
|
||||
assert_eq!(m.state().bodies.len(), 2);
|
||||
let quarter = measure(&m.state().bodies[1].body).unwrap().volume;
|
||||
// Revolving the 40x30 rectangle (x in [0,40]) 90° about sketch y: π/4 · 40² · 30.
|
||||
assert!(rel(quarter, std::f64::consts::FRAC_PI_4 * 1600.0 * 30.0) < 1e-3, "{quarter}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_and_serialization() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
assert!(doc.set_param("bad name", "1", None, None).is_err());
|
||||
assert!(doc.set_param("mm", "1", None, None).is_err());
|
||||
assert!(doc.set_param("loop", "loop + 1", None, None).is_err());
|
||||
assert!(doc.remove_param("width").is_err(), "used by d1");
|
||||
doc.set_param("spare", "3", None, None).unwrap();
|
||||
doc.remove_param("spare").unwrap();
|
||||
let json = doc.to_json();
|
||||
let back = Document::from_json(&json).unwrap();
|
||||
assert_eq!(back, doc);
|
||||
assert!(Document::from_json("{").is_err());
|
||||
let gone = doc.delete_feature(doc.features[0].id).unwrap();
|
||||
assert_eq!(gone.len(), 2, "the extrude goes with its sketch");
|
||||
assert!(doc.param("d1").is_none(), "unused model parameters are pruned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primitives_and_combine() {
|
||||
let mut doc = Document::new("p");
|
||||
doc.add_feature(
|
||||
FeatureKind::Box { corner: Vec3::ZERO, length: "10".into(), width: "10".into(), height: "10".into(), operation: Operation::NewBody },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Cylinder {
|
||||
base: Vec3::new(5.0, 5.0, 0.0),
|
||||
axis: Vec3::Z,
|
||||
radius: "2".into(),
|
||||
height: "20".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(FeatureKind::Combine { target: "Body1".into(), tools: vec!["Body2".into()], operation: Operation::Cut, keep_tools: false }, None)
|
||||
.unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results);
|
||||
let st = m.state();
|
||||
assert_eq!(st.bodies.len(), 1);
|
||||
assert!(rel(measure(&st.bodies[0].body).unwrap().volume, 1000.0 - std::f64::consts::PI * 40.0) < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_expressions_never_panic() {
|
||||
// A deterministic stream of nasty inputs: random tokens, deep nesting, huge numbers.
|
||||
let toks = [
|
||||
"(",
|
||||
")",
|
||||
"+",
|
||||
"-",
|
||||
"*",
|
||||
"/",
|
||||
"^",
|
||||
",",
|
||||
"1e308",
|
||||
"0",
|
||||
"-0",
|
||||
"mm",
|
||||
"deg",
|
||||
"x",
|
||||
"sqrt",
|
||||
"pow",
|
||||
"min",
|
||||
"max",
|
||||
"°",
|
||||
"1.2.3",
|
||||
"e",
|
||||
"pi",
|
||||
"in",
|
||||
"nan",
|
||||
"9999999999999999999999",
|
||||
];
|
||||
let mut state: u64 = 0x9e3779b97f4a7c15;
|
||||
let mut next = || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
for _ in 0..5000 {
|
||||
let n = (next() % 24) as usize;
|
||||
let s: String = (0..n).map(|_| toks[(next() % toks.len() as u64) as usize]).collect::<Vec<_>>().join(" ");
|
||||
let _ = crate::expr::eval_with(&s, &|n| {
|
||||
if n == "x" { Ok(crate::expr::Value::length(2.0)) } else { Err(crate::DocError::Expr("unknown".into())) }
|
||||
});
|
||||
}
|
||||
let deep = format!("{}1{}", "(".repeat(5000), ")".repeat(5000));
|
||||
assert!(crate::expr::eval_with(&deep, &|_| Err(crate::DocError::Expr("x".into()))).is_err());
|
||||
assert!(crate::expr::eval_with("pow(2 mm, 3)", &|_| Err(crate::DocError::Expr("x".into()))).is_ok());
|
||||
assert!(crate::expr::eval_with("pow(2 mm, 300)", &|_| Err(crate::DocError::Expr("x".into()))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_cycles_are_found() {
|
||||
use crate::expr::Kind;
|
||||
let list = vec![
|
||||
("a".to_string(), "b + 1".to_string(), Kind::Length),
|
||||
("b".to_string(), "c * 2".to_string(), Kind::Length),
|
||||
("c".to_string(), "a".to_string(), Kind::Length),
|
||||
("d".to_string(), "5".to_string(), Kind::Length),
|
||||
("e".to_string(), "d + e".to_string(), Kind::Length),
|
||||
];
|
||||
let c = crate::cycles(&list);
|
||||
assert_eq!(c.into_iter().collect::<Vec<_>>(), vec!["a", "b", "c", "e"]);
|
||||
let defs: Vec<crate::expr::ParamDef> =
|
||||
list.iter().map(|(n, e, _)| crate::expr::ParamDef { name: n.clone(), expr: e.clone(), unit: "mm".into() }).collect();
|
||||
let (vals, errs) = crate::expr::eval_params(&defs, 1.0);
|
||||
assert!(errs["a"].contains("circular reference: a → b → c → a"), "{}", errs["a"]);
|
||||
assert!(vals.contains_key("d") && errs.contains_key("a") && errs.contains_key("e"));
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
[package]
|
||||
name = "solvecraft-engine"
|
||||
description = "SolveCraft engine: the session, the command registry (everything is a command), undo/redo and selection"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solvecraft-geom = { workspace = true }
|
||||
solvecraft-sketch = { workspace = true }
|
||||
solvecraft-kernel = { workspace = true }
|
||||
solvecraft-doc = { workspace = true }
|
||||
solvecraft-io = { workspace = true }
|
||||
solvecraft-render = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -1,501 +0,0 @@
|
||||
//! Browser commands: renaming bodies, moving a sketch to another plane, and the browser's
|
||||
//! groups and item order (kept in the design, so they are saved and undone like features).
|
||||
//!
|
||||
//! Browser items are keyed per component folder (`bodies`, `sketches`, `construction`): a body
|
||||
//! by name, a sketch or construction plane by feature id.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use solvecraft_doc::FeatureKind;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("body.rename", "Rename Body", rename_body).params("body: its name, name: the new name (features that use the body follow)"),
|
||||
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch)
|
||||
.icon("plane")
|
||||
.params("sketch: id or name; plane: XY|XZ|YZ | construction plane name | {face: [x,y,z]} | {origin, normal} (the sketch keeps its geometry)"),
|
||||
CommandSpec::new("browser.group", "New Group", group)
|
||||
.icon("folder")
|
||||
.params("folder: bodies|sketches|construction; component?: id (default 0); name?; items?: [keys] (they leave other groups)"),
|
||||
CommandSpec::new("browser.ungroup", "Ungroup", ungroup).params("group: id (its items go back to the folder)"),
|
||||
CommandSpec::new("browser.rename_group", "Rename Group", rename_group).params("group: id, name"),
|
||||
CommandSpec::new("browser.move", "Move Browser Items", move_items)
|
||||
.params("folder, component?, items: [keys]; group?: id (into it; omit to take them out of groups); before?: key (position in the group)"),
|
||||
CommandSpec::new("document.units", "Change Design Units", document_units)
|
||||
.icon("settings")
|
||||
.params("units: mm|cm|m|in|ft (display and unit-less parameters; values keep their meaning)"),
|
||||
CommandSpec::new("view.save", "New Named View", view_save)
|
||||
.icon("camera")
|
||||
.params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"),
|
||||
CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"),
|
||||
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).noundo(),
|
||||
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
|
||||
];
|
||||
|
||||
const FOLDERS: [&str; 3] = ["bodies", "sketches", "construction"];
|
||||
|
||||
fn folder_of(p: &Value, cmd: &str) -> Result<String> {
|
||||
match str_(p, "folder") {
|
||||
Some(f) if FOLDERS.contains(&f) => Ok(f.to_string()),
|
||||
_ => Err(bad(cmd, "`folder` must be bodies, sketches or construction")),
|
||||
}
|
||||
}
|
||||
|
||||
fn component_of(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
let c = p.get("component").and_then(Value::as_u64).unwrap_or(0);
|
||||
if c != 0 && !s.doc.components.iter().any(|x| x.id == c) {
|
||||
return Err(bad(cmd, format!("no component {c}")));
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
fn keys(p: &Value, k: &str, cmd: &str) -> Result<Vec<String>> {
|
||||
let v = match p.get(k) {
|
||||
Some(Value::Array(a)) => {
|
||||
a.iter().map(|x| x.as_str().map(str::to_string).or_else(|| x.as_u64().map(|n| n.to_string()))).collect::<Option<Vec<_>>>()
|
||||
}
|
||||
None => Some(Vec::new()),
|
||||
_ => None,
|
||||
};
|
||||
let v = v.ok_or_else(|| bad(cmd, format!("`{k}` must be a list of item keys")))?;
|
||||
if v.len() > 10_000 || v.iter().any(|x| x.is_empty() || x.len() > 256) {
|
||||
return Err(bad(cmd, format!("`{k}` has bad keys")));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn group_id(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
let id = p.get("group").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`group` must be a group id"))?;
|
||||
if s.doc.browser_groups.iter().any(|g| g.id == id) { Ok(id) } else { Err(bad(cmd, format!("no group {id}"))) }
|
||||
}
|
||||
|
||||
fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "browser.group";
|
||||
let folder = folder_of(p, cmd)?;
|
||||
let component = component_of(s, p, cmd)?;
|
||||
let items = keys(p, "items", cmd)?;
|
||||
let d = s.doc_mut();
|
||||
let id = d.browser_groups.iter().map(|g| g.id).max().unwrap_or(0) + 1;
|
||||
let name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
Some(n) if n.len() <= 128 => n.to_string(),
|
||||
Some(_) => return Err(bad(cmd, "`name` is too long")),
|
||||
None => format!("Group{}", d.browser_groups.iter().filter(|g| g.folder == folder).count() + 1),
|
||||
};
|
||||
for g in d.browser_groups.iter_mut().filter(|g| g.component == component && g.folder == folder) {
|
||||
g.items.retain(|k| !items.contains(k));
|
||||
}
|
||||
d.browser_groups.push(solvecraft_doc::BrowserGroup { id, name: name.clone(), component, folder, items });
|
||||
Ok(json!({ "group": id, "name": name }))
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = group_id(s, p, "browser.ungroup")?;
|
||||
s.doc_mut().browser_groups.retain(|g| g.id != id);
|
||||
Ok(json!({ "group": id }))
|
||||
}
|
||||
|
||||
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "browser.rename_group";
|
||||
let id = group_id(s, p, 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"))?;
|
||||
if let Some(g) = s.doc_mut().browser_groups.iter_mut().find(|g| g.id == id) {
|
||||
g.name = name.to_string();
|
||||
}
|
||||
Ok(json!({ "group": id, "name": name }))
|
||||
}
|
||||
|
||||
fn move_items(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "browser.move";
|
||||
let folder = folder_of(p, cmd)?;
|
||||
let component = component_of(s, p, cmd)?;
|
||||
let items = keys(p, "items", cmd)?;
|
||||
if items.is_empty() {
|
||||
return Err(bad(cmd, "`items` must list item keys"));
|
||||
}
|
||||
let to = match p.get("group") {
|
||||
Some(Value::Null) | None => None,
|
||||
Some(_) => Some(group_id(s, p, cmd)?),
|
||||
};
|
||||
if let Some(g) = to
|
||||
&& !s.doc.browser_groups.iter().any(|x| x.id == g && x.component == component && x.folder == folder)
|
||||
{
|
||||
return Err(bad(cmd, "that group is in another folder"));
|
||||
}
|
||||
let before = str_(p, "before").map(str::to_string);
|
||||
let d = s.doc_mut();
|
||||
for g in d.browser_groups.iter_mut().filter(|g| g.component == component && g.folder == folder) {
|
||||
g.items.retain(|k| !items.contains(k));
|
||||
if Some(g.id) == to {
|
||||
let at = before.as_ref().and_then(|b| g.items.iter().position(|k| k == b)).unwrap_or(g.items.len());
|
||||
for (i, k) in items.iter().enumerate() {
|
||||
g.items.insert(at + i, k.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({ "items": items, "group": to }))
|
||||
}
|
||||
|
||||
fn order(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "browser.order";
|
||||
let folder = folder_of(p, cmd)?;
|
||||
let component = component_of(s, p, cmd)?;
|
||||
let order = keys(p, "order", cmd)?;
|
||||
let key = format!("{component}/{folder}");
|
||||
let d = s.doc_mut();
|
||||
if order.is_empty() {
|
||||
d.browser_order.remove(&key);
|
||||
} else {
|
||||
d.browser_order.insert(key, order);
|
||||
}
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "document.units";
|
||||
let u = str_(p, "units").ok_or_else(|| bad(cmd, "`units` is required"))?;
|
||||
if !["mm", "cm", "m", "in", "ft"].contains(&u) {
|
||||
return Err(bad(cmd, "`units` must be mm, cm, m, in or ft"));
|
||||
}
|
||||
let old = s.doc.units.clone();
|
||||
if old == u {
|
||||
return Ok(json!({ "units": u, "kept": [] }));
|
||||
}
|
||||
// What the geometry reads: every feature's length inputs.
|
||||
let before = s.doc.length_inputs();
|
||||
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();
|
||||
let mut kept = Vec::new();
|
||||
if same(&after) < before.len() {
|
||||
let bare: Vec<(String, String)> = s
|
||||
.doc
|
||||
.params
|
||||
.iter()
|
||||
.filter(|p| !p.model && p.unit.is_empty() && solvecraft_doc::expr::is_literal(&p.expr) && p.expr.trim().parse::<f64>().is_ok())
|
||||
.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())
|
||||
};
|
||||
// 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)?;
|
||||
kept.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({ "units": u, "kept": kept }))
|
||||
}
|
||||
|
||||
fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "view.save";
|
||||
let c = p.get("camera").ok_or_else(|| bad(cmd, "`camera` is required"))?;
|
||||
let mut v: solvecraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
|
||||
let ok = [v.target.x, v.target.y, v.target.z, v.yaw, v.pitch, v.distance, v.fov].iter().all(|x| x.is_finite()) && v.distance > 0.0;
|
||||
if !ok {
|
||||
return Err(bad(cmd, "the camera must be finite with a positive distance"));
|
||||
}
|
||||
let n = s.doc.named_views.len();
|
||||
v.name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
Some(n) if n.len() <= 128 => n.to_string(),
|
||||
Some(_) => return Err(bad(cmd, "`name` is too long")),
|
||||
None => (n + 1..).map(|k| format!("Named View{k}")).find(|x| !s.doc.named_views.iter().any(|v| v.name == *x)).unwrap_or_default(),
|
||||
};
|
||||
if n >= 1000 {
|
||||
return Err(bad(cmd, "too many named views"));
|
||||
}
|
||||
let name = v.name.clone();
|
||||
let d = s.doc_mut();
|
||||
d.named_views.retain(|x| x.name != name);
|
||||
d.named_views.push(v);
|
||||
Ok(json!({ "view": name }))
|
||||
}
|
||||
|
||||
fn view_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let k = str_(p, "view").ok_or_else(|| bad(cmd, "`view` must be a view name"))?;
|
||||
s.doc.named_views.iter().position(|v| v.name == k).ok_or_else(|| bad(cmd, format!("no named view `{k}`")))
|
||||
}
|
||||
|
||||
fn view_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "view.rename";
|
||||
let i = view_index(s, p, 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"))?;
|
||||
if s.doc.named_views.iter().enumerate().any(|(j, v)| j != i && v.name == name) {
|
||||
return Err(bad(cmd, format!("another view is called `{name}`")));
|
||||
}
|
||||
if let Some(v) = s.doc_mut().named_views.get_mut(i) {
|
||||
v.name = name.to_string();
|
||||
}
|
||||
Ok(json!({ "view": name }))
|
||||
}
|
||||
|
||||
fn view_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = view_index(s, p, "view.delete")?;
|
||||
let v = s.doc_mut().named_views.remove(i);
|
||||
Ok(json!({ "deleted": v.name }))
|
||||
}
|
||||
|
||||
fn view_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "views": s.doc.named_views }))
|
||||
}
|
||||
|
||||
/// Rename an item key in the browser's groups and orders of a folder.
|
||||
fn rename_key(d: &mut solvecraft_doc::Document, folder: &str, old: &str, new: &str) {
|
||||
for g in d.browser_groups.iter_mut().filter(|g| g.folder == folder) {
|
||||
for k in g.items.iter_mut().filter(|k| *k == old) {
|
||||
*k = new.to_string();
|
||||
}
|
||||
}
|
||||
for (_, v) in d.browser_order.iter_mut().filter(|(k, _)| k.ends_with(&format!("/{folder}"))) {
|
||||
for k in v.iter_mut().filter(|k| *k == old) {
|
||||
*k = new.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace every string equal to `old` with `new`; true when something changed.
|
||||
fn replace_str(v: &mut Value, old: &str, new: &str, depth: usize) -> bool {
|
||||
if depth > 64 {
|
||||
return false;
|
||||
}
|
||||
match v {
|
||||
Value::String(s) if s == old => {
|
||||
*s = new.to_string();
|
||||
true
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().fold(false, |acc, x| replace_str(x, old, new, depth + 1) | acc),
|
||||
Value::Object(o) => o.values_mut().fold(false, |acc, x| replace_str(x, old, new, depth + 1) | acc),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "body.rename";
|
||||
let old = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must be a body name"))?.to_string();
|
||||
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"))?;
|
||||
if name == old {
|
||||
return Ok(json!({"body": old, "name": name}));
|
||||
}
|
||||
let st = s.model.state();
|
||||
let b = st.body(&old).ok_or_else(|| bad(cmd, format!("no body `{old}`")))?;
|
||||
if st.body(name).is_some() {
|
||||
return Err(bad(cmd, format!("another body is called `{name}`")));
|
||||
}
|
||||
// The body is the k-th new body of its feature; the names before it are kept as they are.
|
||||
let fid = b.feature;
|
||||
let siblings: Vec<String> = st.bodies.iter().filter(|x| x.feature == fid).map(|x| x.name.clone()).collect();
|
||||
let k = siblings.iter().position(|n| *n == old).unwrap_or(0);
|
||||
let d = s.doc_mut();
|
||||
let f = d.feature_mut(fid).ok_or_else(|| bad(cmd, "the body's feature is gone"))?;
|
||||
while f.body_names.len() <= k {
|
||||
let i = f.body_names.len();
|
||||
f.body_names.push(siblings.get(i).cloned().unwrap_or_default());
|
||||
}
|
||||
if let Some(slot) = f.body_names.get_mut(k) {
|
||||
*slot = name.to_string();
|
||||
}
|
||||
// Later features refer to bodies by name.
|
||||
let mut failed = None;
|
||||
for f in d.features.iter_mut().filter(|f| f.id != fid) {
|
||||
let Ok(mut v) = serde_json::to_value(&f.kind) else { continue };
|
||||
if replace_str(&mut v, &old, name, 0) {
|
||||
match serde_json::from_value(v) {
|
||||
Ok(k) => f.kind = k,
|
||||
Err(e) => failed = Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(e) = failed {
|
||||
return Err(bad(cmd, e));
|
||||
}
|
||||
if let Some(m) = d.materials.remove(&old) {
|
||||
d.materials.insert(name.to_string(), m);
|
||||
}
|
||||
if let Some(c) = d.appearances.bodies.remove(&old) {
|
||||
d.appearances.bodies.insert(name.to_string(), c);
|
||||
}
|
||||
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);
|
||||
}
|
||||
rename_key(d, "bodies", &old, name);
|
||||
Ok(json!({"body": old, "name": name}))
|
||||
}
|
||||
|
||||
fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.redefine";
|
||||
let key = match p.get("sketch") {
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`sketch` must be an id or name")),
|
||||
};
|
||||
let id = match s.doc.find_feature(&key) {
|
||||
Some(f) if matches!(f.kind, FeatureKind::Sketch { .. }) => f.id,
|
||||
_ => return Err(bad(cmd, format!("no sketch `{key}`"))),
|
||||
};
|
||||
if p.get("plane").is_none() {
|
||||
return Err(bad(cmd, "`plane` is required"));
|
||||
}
|
||||
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()))?;
|
||||
if let Some(FeatureKind::Sketch { plane: slot, .. }) = s.doc_mut().feature_mut(id).map(|f| &mut f.kind) {
|
||||
*slot = plane;
|
||||
}
|
||||
Ok(json!({ "sketch": id }))
|
||||
}
|
||||
|
||||
#[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 names(s: &Session) -> Vec<String> {
|
||||
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[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]}));
|
||||
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, "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");
|
||||
// Duplicate names and unknown bodies are refused; undo restores the old name.
|
||||
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!({}));
|
||||
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();
|
||||
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
|
||||
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
|
||||
let z = zmax(&mut s);
|
||||
assert!(z > 9.0, "the profile stands up on XZ: {z}");
|
||||
assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err());
|
||||
assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err());
|
||||
}
|
||||
|
||||
#[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();
|
||||
let v0 = vol(&mut s);
|
||||
run(&mut s, "document.units", json!({"units": "in"}));
|
||||
assert_eq!(s.doc.units, "in");
|
||||
assert!((vol(&mut s) - v0).abs() < 1e-6, "feature inputs keep their millimetres");
|
||||
assert!(s.execute("document.units", &json!({"units": "furlong"})).is_err());
|
||||
// A unit-less parameter used as a length keeps its millimetres; a plain count stays a number.
|
||||
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}));
|
||||
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}));
|
||||
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!({}));
|
||||
assert_eq!(s.doc.units, "mm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_views_are_saved_renamed_and_deleted() {
|
||||
let mut s = Session::default();
|
||||
let cam = json!({"target": [1, 2, 3], "yaw": 0.5, "pitch": 0.3, "distance": 100});
|
||||
assert_eq!(run(&mut s, "view.save", json!({"camera": cam}))["view"], "Named View1");
|
||||
run(&mut s, "view.save", json!({"camera": cam, "name": "Detail"}));
|
||||
run(&mut s, "view.rename", json!({"view": "Named View1", "name": "Overview"}));
|
||||
assert!(s.execute("view.rename", &json!({"view": "Overview", "name": "Detail"})).is_err());
|
||||
let back: solvecraft_doc::Document = serde_json::from_str(&serde_json::to_string(&*s.doc).unwrap()).unwrap();
|
||||
assert_eq!(back.named_views.len(), 2);
|
||||
assert_eq!(back.named_views[0].target.z, 3.0);
|
||||
run(&mut s, "view.delete", json!({"view": "Detail"}));
|
||||
assert_eq!(run(&mut s, "view.list", json!({}))["views"].as_array().unwrap().len(), 1);
|
||||
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!({}));
|
||||
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]}));
|
||||
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");
|
||||
run(&mut s, "browser.move", json!({"folder": "bodies", "items": [b.clone()], "group": g, "before": a.clone()}));
|
||||
assert_eq!(s.doc.browser_groups[0].items, vec![b.clone(), a.clone()]);
|
||||
run(&mut s, "browser.rename_group", json!({"group": g, "name": "Fasteners"}));
|
||||
run(&mut s, "body.rename", json!({"body": a, "name": "Bolt"}));
|
||||
assert_eq!(s.doc.browser_groups[0].items, vec![b.clone(), "Bolt".to_string()]);
|
||||
// Out of the group, then a folder order; saved with the design.
|
||||
run(&mut s, "browser.move", json!({"folder": "bodies", "items": [b.clone()]}));
|
||||
assert_eq!(s.doc.browser_groups[0].items, vec!["Bolt".to_string()]);
|
||||
run(&mut s, "browser.order", json!({"folder": "bodies", "order": [b.clone(), "Bolt"]}));
|
||||
let saved: solvecraft_doc::Document = serde_json::from_str(&serde_json::to_string(&*s.doc).unwrap()).unwrap();
|
||||
assert_eq!(saved.browser_groups, s.doc.browser_groups);
|
||||
assert_eq!(saved.browser_order["0/bodies"], vec![b, "Bolt".to_string()]);
|
||||
run(&mut s, "browser.ungroup", json!({"group": g}));
|
||||
assert!(s.doc.browser_groups.is_empty());
|
||||
// Hostile input is refused.
|
||||
for (c, p) in [
|
||||
("browser.group", json!({"folder": "nope"})),
|
||||
("browser.group", json!({"folder": "bodies", "component": 99})),
|
||||
("browser.group", json!({"folder": "bodies", "items": "x"})),
|
||||
("browser.move", json!({"folder": "bodies", "items": []})),
|
||||
("browser.move", json!({"folder": "bodies", "items": ["x"], "group": 42})),
|
||||
("browser.ungroup", json!({"group": -1})),
|
||||
("browser.rename_group", json!({"group": g, "name": "x"})),
|
||||
] {
|
||||
assert!(s.execute(c, &p).is_err(), "{c} {p}");
|
||||
}
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.browser_groups.len(), 1, "ungroup undoes");
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
//! Copy and paste of timeline features. Pasted features are independent copies: sketches get
|
||||
//! their own dimension parameters, feature inputs their own names, and references inside the
|
||||
//! copied set point at the copies.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{FeatureKind, PlaneRef};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("timeline.copy", "Copy Features", copy).noundo().params("features: [ids or names] (in timeline order on paste)"),
|
||||
CommandSpec::new("timeline.paste", "Paste Features", paste)
|
||||
.params("translate?: [x,y,z] (moves the copies' geometry); the copies go at the marker"),
|
||||
];
|
||||
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.copy";
|
||||
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()));
|
||||
}
|
||||
if keys.is_empty() || keys.len() > 1000 {
|
||||
return Err(bad(cmd, "`features` must list 1…1000 features"));
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for k in &keys {
|
||||
ids.push(s.doc.find_feature(k).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{k}`")))?);
|
||||
}
|
||||
// Keep timeline order so sketches come before the features that use them.
|
||||
let copied: Vec<solvecraft_doc::Feature> = s.doc.features.iter().filter(|f| ids.contains(&f.id)).cloned().collect();
|
||||
let names: Vec<String> = copied.iter().map(|f| f.name.clone()).collect();
|
||||
s.clipboard = copied;
|
||||
Ok(json!({"copied": names}))
|
||||
}
|
||||
|
||||
/// Origin planes become explicit planes so a translation can move them.
|
||||
fn explicit_planes(p: &mut PlaneRef) {
|
||||
match p {
|
||||
PlaneRef::Origin { name } => {
|
||||
if let Some(plane) = solvecraft_geom::Plane::named(name) {
|
||||
*p = PlaneRef::Custom { plane };
|
||||
}
|
||||
}
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => explicit_planes(base),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.paste";
|
||||
if s.clipboard.is_empty() {
|
||||
return Err(bad(cmd, "nothing copied (timeline.copy first)"));
|
||||
}
|
||||
let translate = match p.get("translate") {
|
||||
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`translate` must be [x, y, z]"))?),
|
||||
None => None,
|
||||
};
|
||||
let feats = s.clipboard.clone();
|
||||
let comp = s.active_component;
|
||||
let mut sketches: BTreeMap<u64, u64> = BTreeMap::new();
|
||||
let mut names: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut made = Vec::new();
|
||||
for f in feats {
|
||||
let mut kind = f.kind.clone();
|
||||
kind.remap_refs(&sketches, &names);
|
||||
let doc = s.doc_mut();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &mut kind {
|
||||
// Each dimension of the copy gets its own parameter, with the same expression.
|
||||
for c in &mut sketch.constraints {
|
||||
if let Some(pn) = c.param.clone() {
|
||||
let (e, u) = doc.param(&pn).map(|x| (x.expr.clone(), x.unit.clone())).unwrap_or_else(|| (pn.clone(), "mm".into()));
|
||||
c.param = Some(doc.new_model_param(&e, &u));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(t) = translate {
|
||||
if let FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } = &mut kind {
|
||||
explicit_planes(plane);
|
||||
}
|
||||
let mut m = solvecraft_doc::IDENTITY;
|
||||
m[3][0] = t.x;
|
||||
m[3][1] = t.y;
|
||||
m[3][2] = t.z;
|
||||
kind.transform_geometry(&m);
|
||||
}
|
||||
let id = doc.add_feature(kind, None)?;
|
||||
if let Some(nf) = doc.feature_mut(id) {
|
||||
nf.component = comp;
|
||||
nf.body_names = f.body_names.iter().map(|b| format!("{b} (copy)")).collect();
|
||||
}
|
||||
let new_name = doc.feature(id).map(|x| x.name.clone()).unwrap_or_default();
|
||||
if matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
sketches.insert(f.id, id);
|
||||
}
|
||||
names.insert(f.name.clone(), new_name.clone());
|
||||
made.push(json!({"id": id, "name": new_name, "from": f.name}));
|
||||
}
|
||||
s.refresh();
|
||||
let errors: Vec<Value> = made
|
||||
.iter()
|
||||
.filter_map(|m| m["id"].as_u64())
|
||||
.filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|e| json!({"feature": id, "error": e})))
|
||||
.collect();
|
||||
Ok(json!({"pasted": made, "errors": errors}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: serde_json::Value) -> serde_json::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_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}));
|
||||
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!((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"}));
|
||||
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"}));
|
||||
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();
|
||||
assert!(t.execute("timeline.paste", &json!({})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
//! Components and occurrences: the design as a tree (see `solvecraft_doc` assembly docs).
|
||||
//! New sketches and features go into the active component, authored in its frame; bodies
|
||||
//! belong to their feature's component unless moved; occurrences place components (instances
|
||||
//! share contents, "paste new" copies them) and can be grounded and moved.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{FeatureKind, Mat, Occurrence};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
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)
|
||||
.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)
|
||||
.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)"),
|
||||
CommandSpec::new("component.list", "List Components", list).noundo(),
|
||||
CommandSpec::new("component.rename", "Rename Component", rename).params("component, name"),
|
||||
CommandSpec::new("component.delete", "Delete Component", delete).params("component: deletes it, its subcomponents, occurrences and features"),
|
||||
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"),
|
||||
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).params("sketches: [ids or names], component"),
|
||||
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence)
|
||||
.params("component (or occurrence): what to place again; parent?: component (default: the same parent); translate?: [x,y,z]"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: copied with its features into a new, independent component; translate?: [x,y,z]"),
|
||||
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"),
|
||||
];
|
||||
|
||||
fn key(v: Option<&Value>) -> Option<String> {
|
||||
match v {
|
||||
Some(Value::Number(n)) => Some(n.to_string()),
|
||||
Some(Value::String(s)) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn component(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<u64> {
|
||||
let kk = key(p.get(k)).ok_or_else(|| bad(cmd, format!("`{k}` must be a component id or name")))?;
|
||||
s.doc.find_component(&kk).ok_or_else(|| bad(cmd, format!("no component `{kk}`")))
|
||||
}
|
||||
|
||||
fn occurrence(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
let kk = key(p.get("occurrence")).ok_or_else(|| bad(cmd, "`occurrence` must be an id or name"))?;
|
||||
s.doc.occurrences.iter().find(|o| o.id.to_string() == kk || o.name == kk).map(|o| o.id).ok_or_else(|| bad(cmd, format!("no occurrence `{kk}`")))
|
||||
}
|
||||
|
||||
fn translation(t: Vec3) -> Mat {
|
||||
solvecraft_doc::rigid(t, Vec3::ZERO, Vec3::Z, 0.0)
|
||||
}
|
||||
|
||||
fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.create";
|
||||
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) {
|
||||
s.active_component = id;
|
||||
}
|
||||
let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
let occ = s.doc.occurrence_of(id).map(|o| o.id);
|
||||
Ok(json!({"component": id, "name": name, "parent": parent, "occurrence": occ}))
|
||||
}
|
||||
|
||||
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.from_bodies";
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() || bodies.len() > 1000 {
|
||||
return Err(bad(cmd, "`bodies` must list 1…1000 body names"));
|
||||
}
|
||||
let st = s.model.state();
|
||||
for b in &bodies {
|
||||
if st.body(b).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{b}`")));
|
||||
}
|
||||
}
|
||||
let parent = s.active_component;
|
||||
let mut made = Vec::new();
|
||||
for b in bodies {
|
||||
let id = s.doc_mut().add_component(Some(&b), parent)?;
|
||||
s.doc_mut().body_components.insert(b.clone(), id);
|
||||
made.push(json!({"component": id, "body": b}));
|
||||
}
|
||||
Ok(json!({ "components": made }))
|
||||
}
|
||||
|
||||
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = component(s, p, "component", "component.activate")?;
|
||||
s.active_component = id;
|
||||
s.revision += 1;
|
||||
Ok(json!({ "active": id }))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let doc = &s.doc;
|
||||
let node = |id: u64, name: &str, parent: Option<u64>| {
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == id).map(|b| b.name.as_str()).collect();
|
||||
let features: Vec<&str> = doc
|
||||
.features
|
||||
.iter()
|
||||
.filter(|f| f.component == id && !matches!(f.kind, FeatureKind::Sketch { .. } | FeatureKind::ConstructionPlane { .. }))
|
||||
.map(|f| f.name.as_str())
|
||||
.collect();
|
||||
let sketches: Vec<&str> =
|
||||
doc.features.iter().filter(|f| f.component == id && matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.name.as_str()).collect();
|
||||
let construction: Vec<&str> = doc
|
||||
.features
|
||||
.iter()
|
||||
.filter(|f| f.component == id && matches!(f.kind, FeatureKind::ConstructionPlane { .. }))
|
||||
.map(|f| f.name.as_str())
|
||||
.collect();
|
||||
let occurrences: Vec<&Occurrence> = doc.occurrences.iter().filter(|o| o.component == id).collect();
|
||||
let children: Vec<&Occurrence> = doc.occurrences.iter().filter(|o| o.parent == id).collect();
|
||||
json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": features, "sketches": sketches,
|
||||
"construction": construction, "occurrences": occurrences, "children": children})
|
||||
};
|
||||
let mut out = vec![node(0, &doc.name, None)];
|
||||
for c in &doc.components {
|
||||
out.push(node(c.id, &c.name, Some(c.parent)));
|
||||
}
|
||||
Ok(json!({"active": s.active_component, "components": out, "pending_moves": s.pending_moves.keys().collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.rename";
|
||||
let id = component(s, p, "component", 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"))?;
|
||||
if id == 0 {
|
||||
s.doc_mut().name = name.to_string();
|
||||
} else if let Some(c) = s.doc_mut().components.iter_mut().find(|c| c.id == id) {
|
||||
c.name = name.to_string();
|
||||
}
|
||||
Ok(json!({"component": id, "name": name}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.delete";
|
||||
let id = component(s, p, "component", cmd)?;
|
||||
if id == 0 {
|
||||
return Err(bad(cmd, "the design itself can't be deleted"));
|
||||
}
|
||||
let gone: Vec<u64> = s.doc.components.iter().filter(|c| s.doc.component_within(c.id, id)).map(|c| c.id).collect();
|
||||
let feats: Vec<u64> = s.doc.features.iter().filter(|f| gone.contains(&f.component)).map(|f| f.id).collect();
|
||||
let d = s.doc_mut();
|
||||
for f in &feats {
|
||||
let _ = d.delete_feature(*f);
|
||||
}
|
||||
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;
|
||||
}
|
||||
Ok(json!({"deleted": gone, "features": feats}))
|
||||
}
|
||||
|
||||
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.move_bodies";
|
||||
let to = component(s, p, "component", cmd)?;
|
||||
let bodies = string_list(p, "bodies");
|
||||
let st = s.model.state();
|
||||
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
|
||||
return Err(bad(cmd, "`bodies` must list existing bodies"));
|
||||
}
|
||||
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);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
Ok(json!({"bodies": bodies, "component": to}))
|
||||
}
|
||||
|
||||
fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.move_sketches";
|
||||
let to = component(s, p, "component", cmd)?;
|
||||
let keys = string_list(p, "sketches");
|
||||
let mut ids = Vec::new();
|
||||
for k in &keys {
|
||||
match s.doc.find_feature(k) {
|
||||
Some(f) if matches!(f.kind, FeatureKind::Sketch { .. }) => ids.push(f.id),
|
||||
_ => return Err(bad(cmd, format!("no sketch `{k}`"))),
|
||||
}
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "`sketches` must list sketches"));
|
||||
}
|
||||
for id in &ids {
|
||||
if let Some(f) = s.doc_mut().feature_mut(*id) {
|
||||
f.component = to;
|
||||
}
|
||||
}
|
||||
Ok(json!({"sketches": ids, "component": to}))
|
||||
}
|
||||
|
||||
fn copy_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.copy";
|
||||
let comp = if p.get("occurrence").is_some() {
|
||||
let o = occurrence(s, p, cmd)?;
|
||||
s.doc.occurrences.iter().find(|x| x.id == o).map(|x| x.component).unwrap_or(0)
|
||||
} else {
|
||||
component(s, p, "component", cmd)?
|
||||
};
|
||||
let src = s.doc.occurrence_of(comp).cloned().ok_or_else(|| bad(cmd, "that component has no occurrence"))?;
|
||||
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { src.parent };
|
||||
let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let m = solvecraft_doc::mat_mul(&translation(t), &src.transform);
|
||||
let id = s.doc_mut().add_occurrence(comp, parent, m)?;
|
||||
Ok(json!({"occurrence": id, "component": comp}))
|
||||
}
|
||||
|
||||
fn paste_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.paste_new";
|
||||
let comp = component(s, p, "component", cmd)?;
|
||||
if comp == 0 {
|
||||
return Err(bad(cmd, "pick a component, not the design"));
|
||||
}
|
||||
let src = s.doc.components.iter().find(|c| c.id == comp).cloned().ok_or_else(|| bad(cmd, "component"))?;
|
||||
let occ = s.doc.occurrence_of(comp).cloned();
|
||||
let new_id = s.doc_mut().add_component(Some(&format!("{} (copy)", src.name)), src.parent)?;
|
||||
// Copy the component's features with fresh ids and names; references between them follow.
|
||||
let feats: Vec<solvecraft_doc::Feature> = s.doc.features.iter().filter(|f| f.component == comp).cloned().collect();
|
||||
let mut idmap: std::collections::HashMap<u64, u64> = Default::default();
|
||||
let mut namemap: std::collections::HashMap<String, String> = Default::default();
|
||||
let mut new_ids = Vec::new();
|
||||
for f in &feats {
|
||||
let nid = s.doc_mut().add_feature(f.kind.clone(), Some(&format!("{} (copy)", f.name)))?;
|
||||
idmap.insert(f.id, nid);
|
||||
let nname = s.doc.feature(nid).map(|x| x.name.clone()).unwrap_or_default();
|
||||
namemap.insert(f.name.clone(), nname);
|
||||
new_ids.push(nid);
|
||||
}
|
||||
for nid in &new_ids {
|
||||
let Some(f) = s.doc.feature(*nid).cloned() else { continue };
|
||||
let mut v = serde_json::to_value(&f.kind).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
remap(&mut v, &idmap, &namemap, 0);
|
||||
let kind: FeatureKind = serde_json::from_value(v).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if let Some(slot) = s.doc_mut().feature_mut(*nid) {
|
||||
slot.kind = kind;
|
||||
slot.component = new_id;
|
||||
}
|
||||
}
|
||||
let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let base = occ.map(|o| o.transform).unwrap_or(solvecraft_doc::IDENTITY);
|
||||
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.component == new_id) {
|
||||
o.transform = solvecraft_doc::mat_mul(&translation(t), &base);
|
||||
}
|
||||
Ok(json!({"component": new_id, "features": new_ids}))
|
||||
}
|
||||
|
||||
/// Point copied features at their copied sketches and features.
|
||||
fn remap(v: &mut Value, ids: &std::collections::HashMap<u64, u64>, names: &std::collections::HashMap<String, String>, depth: usize) {
|
||||
if depth > 32 {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
for (k, x) in o.iter_mut() {
|
||||
match (k.as_str(), &*x) {
|
||||
("sketch" | "path_sketch", Value::Number(n)) => {
|
||||
if let Some(m) = n.as_u64().and_then(|i| ids.get(&i)) {
|
||||
*x = json!(m);
|
||||
}
|
||||
}
|
||||
("features", Value::Array(_)) => {
|
||||
if let Value::Array(a) = x {
|
||||
for e in a.iter_mut() {
|
||||
if let Some(m) = e.as_str().and_then(|n| names.get(n)) {
|
||||
*e = json!(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => remap(x, ids, names, depth + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(|x| remap(x, ids, names, depth + 1)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.move";
|
||||
let id = occurrence(s, p, cmd)?;
|
||||
let o = s.doc.occurrences.iter().find(|o| o.id == id).cloned().ok_or_else(|| bad(cmd, "occurrence"))?;
|
||||
if o.grounded {
|
||||
return Err(bad(cmd, format!("{} is grounded", o.name)));
|
||||
}
|
||||
let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let axis = p.get("axis").and_then(vec3).unwrap_or(Vec3::Z);
|
||||
let angle = match p.get("angle") {
|
||||
Some(Value::String(e)) => s.doc.eval(e, solvecraft_doc::expr::Kind::Angle).map_err(|e| bad(cmd, e.to_string()))?,
|
||||
_ => num(p, "angle").unwrap_or(0.0),
|
||||
};
|
||||
if !(t.is_finite() && angle.is_finite()) {
|
||||
return Err(bad(cmd, "non-finite move"));
|
||||
}
|
||||
let origin = p.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let delta = solvecraft_doc::rigid(t, origin, axis, angle);
|
||||
let cur = s.pending_moves.get(&id).copied().unwrap_or(o.transform);
|
||||
let next = solvecraft_doc::mat_mul(&delta, &cur);
|
||||
if bool_(p, "capture").unwrap_or(true) {
|
||||
s.pending_moves.remove(&id);
|
||||
if let Some(x) = s.doc_mut().occurrences.iter_mut().find(|x| x.id == id) {
|
||||
x.transform = next;
|
||||
}
|
||||
} else {
|
||||
s.pending_moves.insert(id, next);
|
||||
s.revision += 1;
|
||||
}
|
||||
Ok(json!({"occurrence": id, "transform": next, "pending": !bool_(p, "capture").unwrap_or(true)}))
|
||||
}
|
||||
|
||||
fn ground(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.ground";
|
||||
let id = occurrence(s, p, cmd)?;
|
||||
let want = bool_(p, "grounded");
|
||||
let o = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id).ok_or_else(|| bad(cmd, "occurrence"))?;
|
||||
o.grounded = want.unwrap_or(!o.grounded);
|
||||
let g = o.grounded;
|
||||
Ok(json!({"occurrence": id, "grounded": g}))
|
||||
}
|
||||
|
||||
fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let moves = std::mem::take(&mut s.pending_moves);
|
||||
let n = moves.len();
|
||||
for (id, m) in moves {
|
||||
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id) {
|
||||
o.transform = m;
|
||||
}
|
||||
}
|
||||
s.revision += 1;
|
||||
Ok(json!({ "captured": n }))
|
||||
}
|
||||
|
||||
fn revert(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let n = s.pending_moves.len();
|
||||
s.pending_moves.clear();
|
||||
s.revision += 1;
|
||||
Ok(json!({ "reverted": n }))
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
return;
|
||||
}
|
||||
let m = s.doc.component_transform(s.active_component);
|
||||
if solvecraft_doc::is_identity(&m) {
|
||||
return;
|
||||
}
|
||||
if let Some(inv) = solvecraft_doc::mat_inverse(&m) {
|
||||
kind.transform_geometry(&inv);
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
//! Configurations: the design's configuration table (rows of parameter values and feature
|
||||
//! suppressions) and switching between its rows.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::config::{Column, ConfigRow, MAX_COLUMNS, MAX_ROWS};
|
||||
|
||||
use super::CommandSpec;
|
||||
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(
|
||||
"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)
|
||||
.at("SOLID", "CONFIGURE")
|
||||
.icon("params")
|
||||
.noundo()
|
||||
.params("→ columns (param:<name> or suppress:<feature>), rows, the active row and whether the design still matches it"),
|
||||
CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? (name); delete?: bool"),
|
||||
CommandSpec::new("config.row", "Configuration Row", row).params(
|
||||
"name; from? (row to copy; default the current values); values?: {param name or feature name: expression or true/false}; rename?; delete?: bool",
|
||||
),
|
||||
CommandSpec::new("config.activate", "Activate Configuration", activate).params("row: name — sets its parameter values and suppressions"),
|
||||
];
|
||||
|
||||
fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
|
||||
if let Some(n) = str_(p, "param") {
|
||||
if !s.doc.all_param_exprs().iter().any(|(m, _, _)| m == n) {
|
||||
return Err(bad(cmd, format!("no parameter `{n}`")));
|
||||
}
|
||||
return Ok(Column::Param(n.to_string()));
|
||||
}
|
||||
let f = str_(p, "feature").ok_or_else(|| bad(cmd, "give `param` or `feature`"))?;
|
||||
let id = s.doc.find_feature(f).map(|x| x.id).ok_or_else(|| bad(cmd, format!("no feature `{f}`")))?;
|
||||
Ok(Column::Suppress(id))
|
||||
}
|
||||
|
||||
/// Add a column, filling existing rows with the design's current value.
|
||||
fn add_column(s: &mut Session, c: Column, cmd: &str) -> Result<()> {
|
||||
if s.doc.configs.columns.contains(&c) {
|
||||
return Ok(());
|
||||
}
|
||||
if s.doc.configs.columns.len() >= MAX_COLUMNS {
|
||||
return Err(bad(cmd, "too many columns"));
|
||||
}
|
||||
let cur = s.doc.config_current(&c).unwrap_or_default();
|
||||
let t = &mut s.doc_mut().configs;
|
||||
t.columns.push(c);
|
||||
for r in &mut t.rows {
|
||||
r.cells.push(cur.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "config.configure";
|
||||
for n in string_list(p, "params") {
|
||||
let c = column_of(s, &json!({ "param": n }), cmd)?;
|
||||
add_column(s, c, cmd)?;
|
||||
}
|
||||
for n in string_list(p, "features") {
|
||||
let c = column_of(s, &json!({ "feature": n }), cmd)?;
|
||||
add_column(s, c, cmd)?;
|
||||
}
|
||||
if s.doc.configs.rows.is_empty() {
|
||||
let cells = s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect();
|
||||
let t = &mut s.doc_mut().configs;
|
||||
t.rows.push(ConfigRow { name: "Default".into(), cells });
|
||||
t.active = Some("Default".into());
|
||||
}
|
||||
show(s, p)
|
||||
}
|
||||
|
||||
fn show(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let t = &s.doc.configs;
|
||||
let keys: Vec<String> = t.columns.iter().map(|c| s.doc.config_key(c)).collect();
|
||||
let rows: Vec<Value> = t
|
||||
.rows
|
||||
.iter()
|
||||
.map(|r| json!({"name": r.name, "values": keys.iter().zip(&r.cells).map(|(k, v)| (k.clone(), json!(v))).collect::<serde_json::Map<_, _>>()}))
|
||||
.collect();
|
||||
Ok(json!({"columns": keys, "rows": rows, "active": t.active, "matches": s.doc.configuration_matches()}))
|
||||
}
|
||||
|
||||
fn column(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "config.column";
|
||||
let c = column_of(s, p, cmd)?;
|
||||
if bool_(p, "delete").unwrap_or(false) {
|
||||
let t = &mut s.doc_mut().configs;
|
||||
let i = t.columns.iter().position(|x| *x == c).ok_or_else(|| bad(cmd, "not a column of the table"))?;
|
||||
t.columns.remove(i);
|
||||
for r in &mut t.rows {
|
||||
if i < r.cells.len() {
|
||||
r.cells.remove(i);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
add_column(s, c, cmd)?;
|
||||
}
|
||||
show(s, p)
|
||||
}
|
||||
|
||||
fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "config.row";
|
||||
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();
|
||||
let idx = s.doc.configs.rows.iter().position(|r| r.name == name);
|
||||
if bool_(p, "delete").unwrap_or(false) {
|
||||
let i = idx.ok_or_else(|| bad(cmd, format!("no row `{name}`")))?;
|
||||
let t = &mut s.doc_mut().configs;
|
||||
t.rows.remove(i);
|
||||
if t.active.as_deref() == Some(name.as_str()) {
|
||||
t.active = None;
|
||||
}
|
||||
return show(s, p);
|
||||
}
|
||||
let mut cells: Vec<String> = match (idx, str_(p, "from")) {
|
||||
(_, Some(f)) => s.doc.configs.rows.iter().find(|r| r.name == f).map(|r| r.cells.clone()).ok_or_else(|| bad(cmd, format!("no row `{f}`")))?,
|
||||
(Some(i), None) => s.doc.configs.rows.get(i).map(|r| r.cells.clone()).unwrap_or_default(),
|
||||
(None, None) => s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect(),
|
||||
};
|
||||
if let Some(vals) = p.get("values") {
|
||||
let vals = vals.as_object().ok_or_else(|| bad(cmd, "`values` must map columns to values"))?;
|
||||
for (k, v) in vals {
|
||||
let text = match v {
|
||||
Value::String(t) => t.clone(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
_ => return Err(bad(cmd, format!("value for `{k}` must be an expression or true/false"))),
|
||||
};
|
||||
if text.len() > 4096 {
|
||||
return Err(bad(cmd, "value too long"));
|
||||
}
|
||||
let key = k.strip_prefix("param:").or_else(|| k.strip_prefix("suppress:")).unwrap_or(k);
|
||||
let col = s
|
||||
.doc
|
||||
.configs
|
||||
.columns
|
||||
.iter()
|
||||
.position(|c| match c {
|
||||
Column::Param(n) => n == key,
|
||||
Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == key),
|
||||
})
|
||||
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
|
||||
if let Some(cell) = cells.get_mut(col) {
|
||||
*cell = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
let rename = str_(p, "rename").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).map(str::to_string);
|
||||
let t = &mut s.doc_mut().configs;
|
||||
match idx {
|
||||
Some(i) => {
|
||||
if let Some(r) = t.rows.get_mut(i) {
|
||||
r.cells = cells;
|
||||
if let Some(n) = rename {
|
||||
if t.active.as_deref() == Some(r.name.as_str()) {
|
||||
t.active = Some(n.clone());
|
||||
}
|
||||
r.name = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if t.rows.len() >= MAX_ROWS {
|
||||
return Err(bad(cmd, "too many rows"));
|
||||
}
|
||||
t.rows.push(ConfigRow { name, cells });
|
||||
}
|
||||
}
|
||||
show(s, p)
|
||||
}
|
||||
|
||||
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "config.activate";
|
||||
let r = str_(p, "row").ok_or_else(|| bad(cmd, "`row` is required"))?.to_string();
|
||||
s.doc_mut().apply_configuration(&r).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
s.refresh();
|
||||
let errors: Vec<String> = s.model.results.iter().filter_map(|x| x.error.clone()).collect();
|
||||
let mut out = show(s, p)?;
|
||||
out["errors"] = json!(errors);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[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 volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", 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}));
|
||||
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]}));
|
||||
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}}));
|
||||
let filleted = 8000.0 - (9.0 - std::f64::consts::PI * 9.0 / 4.0) * 10.0;
|
||||
assert!((volume(&mut s) - filleted).abs() < 0.5);
|
||||
run(&mut s, "config.activate", json!({"row": "Long"}));
|
||||
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!({}));
|
||||
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!({}));
|
||||
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!({}));
|
||||
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);
|
||||
// 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());
|
||||
run(&mut s, "config.row", json!({"name": "Broken", "values": {"length": "1/"}}));
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
//! Delete for everything that can be selected, in one undo step: sketch entities (while
|
||||
//! sketching), timeline features and sketches (with the features that depend on them),
|
||||
//! construction planes, bodies (a Remove feature from here on in the timeline), occurrences and
|
||||
//! components, and canvases. `dry_run` reports what would go and what would fail without
|
||||
//! changing anything, so the UI can ask first.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::FeatureKind;
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_};
|
||||
use crate::{EngineError, Result, Sel, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.delete", "Delete", delete).icon("delete").params(
|
||||
"items?: [selection items]; components?, occurrences?, canvases?: [ids or names]; dry_run?: bool → deleted, would_fail, skipped (one undo step)",
|
||||
)];
|
||||
|
||||
/// Run another command's body inside this one (so the whole delete is one undo step).
|
||||
fn sub(s: &mut Session, id: &str, p: Value) -> Result<Value> {
|
||||
let spec = super::find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
(spec.enabled)(s).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let r = (spec.run)(s, &p)?;
|
||||
s.refresh();
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn keys(p: &Value, k: &str) -> Vec<String> {
|
||||
match p.get(k) {
|
||||
Some(Value::Array(a)) => {
|
||||
a.iter().take(10_000).filter_map(|v| v.as_str().map(str::to_string).or_else(|| v.as_u64().map(|n| n.to_string()))).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_(p, "dry_run").unwrap_or(false) {
|
||||
let mut t = s.scratch();
|
||||
return apply(&mut t, p);
|
||||
}
|
||||
let r = apply(s, p)?;
|
||||
s.selection.clear();
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "selection.delete";
|
||||
let items: Vec<Sel> = match p.get("items") {
|
||||
None | Some(Value::Null) => Vec::new(),
|
||||
Some(Value::Array(a)) if a.len() <= 10_000 => {
|
||||
a.iter().map(|v| serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("bad item {v}: {e}")))).collect::<Result<_>>()?
|
||||
}
|
||||
Some(_) => return Err(bad(cmd, "`items` must be a list of selection items")),
|
||||
};
|
||||
let before = s.model.clone();
|
||||
let features_before: Vec<(u64, String)> = s.doc.features.iter().map(|f| (f.id, f.name.clone())).collect();
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
let mut skip = |why: String| {
|
||||
if !skipped.contains(&why) {
|
||||
skipped.push(why);
|
||||
}
|
||||
};
|
||||
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()),
|
||||
Sel::Feature { id } => {
|
||||
if s.doc.feature(*id).is_none() {
|
||||
return Err(bad(cmd, format!("no feature {id}")));
|
||||
}
|
||||
features.push(*id);
|
||||
}
|
||||
Sel::Plane { name } => match s.doc.find_feature(name) {
|
||||
Some(f) if matches!(f.kind, FeatureKind::ConstructionPlane { .. }) => features.push(f.id),
|
||||
_ => skip("the origin planes can't be deleted".into()),
|
||||
},
|
||||
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::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()),
|
||||
}
|
||||
}
|
||||
features.dedup();
|
||||
let mut done = false;
|
||||
if !entities.is_empty() {
|
||||
if s.active_sketch.is_none() {
|
||||
skip("sketch entities are deleted while editing their sketch".into());
|
||||
} else {
|
||||
sub(s, "sketch.delete", json!({ "entities": entities }))?;
|
||||
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 }))?;
|
||||
done = true;
|
||||
}
|
||||
// Bodies still there (not made by a feature deleted above) get a Remove feature.
|
||||
let st = s.model.state();
|
||||
bodies.retain(|b| st.body(b).is_some());
|
||||
bodies.dedup();
|
||||
if !bodies.is_empty() {
|
||||
sub(s, "solid.remove", json!({ "bodies": bodies }))?;
|
||||
done = true;
|
||||
}
|
||||
// An occurrence goes alone while its component has others; the last one takes the component.
|
||||
let mut components: Vec<u64> = Vec::new();
|
||||
for k in keys(p, "occurrences") {
|
||||
let Some(o) = s.doc.occurrences.iter().find(|o| o.id.to_string() == k || o.name == k).cloned() else {
|
||||
return Err(bad(cmd, format!("no occurrence `{k}`")));
|
||||
};
|
||||
if s.doc.occurrences.iter().filter(|x| x.component == o.component).count() > 1 {
|
||||
s.doc_mut().occurrences.retain(|x| x.id != o.id);
|
||||
s.pending_moves.remove(&o.id);
|
||||
done = true;
|
||||
} else {
|
||||
components.push(o.component);
|
||||
}
|
||||
}
|
||||
for k in keys(p, "components") {
|
||||
let id = s.doc.find_component(&k).ok_or_else(|| bad(cmd, format!("no component `{k}`")))?;
|
||||
if id == 0 {
|
||||
return Err(bad(cmd, "the design itself can't be deleted"));
|
||||
}
|
||||
components.push(id);
|
||||
}
|
||||
components.dedup();
|
||||
for c in components {
|
||||
// A parent deleted first takes its subcomponents with it.
|
||||
if s.doc.components.iter().any(|x| x.id == c) {
|
||||
sub(s, "component.delete", json!({ "component": c }))?;
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
for k in keys(p, "canvases") {
|
||||
sub(s, "canvas.delete", json!({ "canvas": k }))?;
|
||||
done = true;
|
||||
}
|
||||
if !done {
|
||||
return Err(bad(cmd, skipped.first().cloned().unwrap_or_else(|| "nothing to delete".into())));
|
||||
}
|
||||
s.refresh();
|
||||
let deleted: Vec<&String> = features_before.iter().filter(|(id, _)| s.doc.feature(*id).is_none()).map(|(_, n)| n).collect();
|
||||
let would_fail: Vec<String> = s
|
||||
.model
|
||||
.results
|
||||
.iter()
|
||||
.filter(|r| r.error.is_some() && before.result(r.id).is_some_and(|b| b.error.is_none() && !b.skipped))
|
||||
.map(|r| r.name.clone())
|
||||
.collect();
|
||||
Ok(json!({ "deleted": deleted, "bodies": bodies, "would_fail": would_fail, "skipped": skipped }))
|
||||
}
|
||||
|
||||
#[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 bodies(s: &Session) -> usize {
|
||||
s.model.state().bodies.len()
|
||||
}
|
||||
|
||||
/// 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}));
|
||||
(s, sk)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sketch_goes_with_its_dependents_after_a_dry_run() {
|
||||
let (mut s, sk) = part();
|
||||
let n = s.doc.features.len();
|
||||
let dry = run(&mut s, "selection.delete", json!({"items": [{"type": "feature", "id": sk}], "dry_run": true}));
|
||||
assert_eq!(s.doc.features.len(), n, "a dry run changes nothing");
|
||||
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!({}));
|
||||
assert_eq!(s.doc.features.len(), n);
|
||||
assert_eq!(bodies(&s), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_selection_is_one_undo_step() {
|
||||
let (mut s, _) = part();
|
||||
let body = s.model.state().bodies[1].name.clone();
|
||||
let depth = s.undo.len();
|
||||
let n = s.doc.features.len();
|
||||
let r = run(
|
||||
&mut s,
|
||||
"selection.delete",
|
||||
json!({"items": [{"type": "body", "name": body}, {"type": "plane", "name": "Plane1"}, {"type": "plane", "name": "XY"}]}),
|
||||
);
|
||||
assert_eq!(bodies(&s), 1, "{r}");
|
||||
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!({}));
|
||||
assert_eq!((bodies(&s), s.doc.features.len()), (2, n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_alone_are_refused_and_hostile_input_too() {
|
||||
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}");
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"items": "x"}),
|
||||
json!({"items": [{"type": "nope"}]}),
|
||||
json!({"items": [{"type": "feature", "id": 999}]}),
|
||||
json!({"components": ["0"]}),
|
||||
] {
|
||||
assert!(s.execute("selection.delete", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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]}));
|
||||
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]}));
|
||||
assert_eq!(s.doc.occurrences.len(), 2);
|
||||
// One instance goes alone; the last one takes its component.
|
||||
run(&mut s, "selection.delete", json!({"occurrences": [o1]}));
|
||||
assert_eq!((s.doc.occurrences.len(), s.doc.components.len()), (1, 1));
|
||||
let o2 = s.doc.occurrences[0].id;
|
||||
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]]}));
|
||||
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}]}));
|
||||
assert!(s.model.state().sketch(sk).unwrap().sketch.curves.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
//! Undo/redo, the timeline, deletion and selection.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Feature, FeatureKind};
|
||||
|
||||
use super::CommandSpec;
|
||||
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)
|
||||
.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("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)
|
||||
.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"),
|
||||
CommandSpec::new("timeline.redefine", "Edit Feature", redefine)
|
||||
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"),
|
||||
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents)
|
||||
.noundo()
|
||||
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature)
|
||||
.params("feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}}"),
|
||||
CommandSpec::new("select.set", "Select", select_set)
|
||||
.noundo()
|
||||
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"),
|
||||
CommandSpec::new("select.clear", "Clear Selection", select_clear).noundo(),
|
||||
];
|
||||
|
||||
fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) };
|
||||
let label = prev.label.clone();
|
||||
s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch });
|
||||
s.doc = prev.doc;
|
||||
s.active_sketch = prev.active_sketch;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
Ok(json!({"undone": label}))
|
||||
}
|
||||
|
||||
fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) };
|
||||
let label = next.label.clone();
|
||||
s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch });
|
||||
s.doc = next.doc;
|
||||
s.active_sketch = next.active_sketch;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
Ok(json!({"redone": label}))
|
||||
}
|
||||
|
||||
fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.model = solvecraft_doc::Model::new();
|
||||
s.refresh();
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
Ok(json!({"features": s.model.results.len(), "errors": errors}))
|
||||
}
|
||||
|
||||
fn feature_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64> {
|
||||
let key = match v {
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`feature` must be an id or name")),
|
||||
};
|
||||
s.doc.find_feature(&key).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{key}`")))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.delete";
|
||||
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()));
|
||||
}
|
||||
if let Some(f) = p.get("feature") {
|
||||
keys.push(match f {
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::String(x) => x.clone(),
|
||||
_ => String::new(),
|
||||
});
|
||||
}
|
||||
if keys.is_empty() {
|
||||
return Err(bad(cmd, "`features` must list features"));
|
||||
}
|
||||
let mut gone = Vec::new();
|
||||
for k in keys {
|
||||
let Some(id) = s.doc.find_feature(&k).map(|f| f.id) else {
|
||||
if gone.is_empty() {
|
||||
return Err(bad(cmd, format!("no feature `{k}`")));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
gone.extend(s.doc_mut().delete_feature(id)?);
|
||||
}
|
||||
if s.active_sketch.is_some_and(|a| gone.contains(&a)) {
|
||||
s.active_sketch = None;
|
||||
}
|
||||
s.selection.clear();
|
||||
Ok(json!({"deleted": gone}))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.rename";
|
||||
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"))?;
|
||||
if s.doc.features.iter().any(|f| f.name == name && f.id != id) {
|
||||
return Err(bad(cmd, format!("another feature is called `{name}`")));
|
||||
}
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
f.name = name.to_string();
|
||||
}
|
||||
Ok(json!({"feature": id, "name": name}))
|
||||
}
|
||||
|
||||
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")?;
|
||||
s.doc.feature_index(id).map(|i| i + 1)
|
||||
}
|
||||
None => num(p, "position").map(|x| x.max(0.0) as usize),
|
||||
};
|
||||
s.doc_mut().marker = match pos {
|
||||
Some(k) if k < n => Some(k),
|
||||
_ => None,
|
||||
};
|
||||
s.selection.clear();
|
||||
Ok(json!({"marker": s.doc.marker.unwrap_or(n), "features": n}))
|
||||
}
|
||||
|
||||
fn suppress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = feature_id(s, p.get("feature"), "timeline.suppress")?;
|
||||
let want = bool_(p, "suppressed");
|
||||
let f = s.doc_mut().feature_mut(id).ok_or_else(|| EngineError::Other("feature".into()))?;
|
||||
f.suppressed = want.unwrap_or(!f.suppressed);
|
||||
Ok(json!({"feature": id, "suppressed": f.suppressed}))
|
||||
}
|
||||
|
||||
fn merge(dst: &mut Value, src: &Value, depth: usize) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
match (dst, src) {
|
||||
(Value::Object(d), Value::Object(s)) => {
|
||||
for (k, v) in s {
|
||||
match d.get_mut(k) {
|
||||
Some(slot) if slot.is_object() && v.is_object() => merge(slot, v, depth + 1),
|
||||
_ => {
|
||||
d.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(d, s) => *d = s.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.edit";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let set = p.get("set").filter(|v| v.is_object()).ok_or_else(|| bad(cmd, "`set` must be an object of fields"))?;
|
||||
let f = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let mut v = serde_json::to_value(&f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
merge(&mut v, set, 0);
|
||||
let mut nf: Feature = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid feature: {e}")))?;
|
||||
nf.id = f.id;
|
||||
if std::mem::discriminant(&nf.kind) != std::mem::discriminant(&f.kind) {
|
||||
return Err(bad(cmd, "the feature type cannot change"));
|
||||
}
|
||||
if let (FeatureKind::Sketch { sketch: a, .. }, FeatureKind::Sketch { sketch: b, .. }) = (&nf.kind, &f.kind)
|
||||
&& a != b
|
||||
{
|
||||
return Err(bad(cmd, "edit sketch geometry with the sketch commands"));
|
||||
}
|
||||
// New edges for a blend: remember where they sit now (in the model before the feature).
|
||||
if let (
|
||||
FeatureKind::Fillet { edges: a, body, .. } | FeatureKind::Chamfer { edges: a, body, .. },
|
||||
FeatureKind::Fillet { edges: b, .. } | FeatureKind::Chamfer { edges: b, .. },
|
||||
) = (&nf.kind, &f.kind)
|
||||
&& a != b
|
||||
{
|
||||
let before = s.model.state_before(id);
|
||||
nf.edge_refs = solvecraft_doc::edge_refs(&before, body, a);
|
||||
nf.edge_names = solvecraft_doc::edge_names_for(&before, body, a);
|
||||
}
|
||||
// New face points: their faces' names now.
|
||||
if nf.kind.face_points() != f.kind.face_points() {
|
||||
nf.face_names = solvecraft_doc::naming::face_names_at(&s.model.state_before(id), &nf.kind.face_points());
|
||||
}
|
||||
if let Some(slot) = s.doc_mut().feature_mut(id) {
|
||||
*slot = nf;
|
||||
}
|
||||
s.refresh();
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
|
||||
}
|
||||
|
||||
fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items = p.get("items").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
if items.len() > 10_000 {
|
||||
return Err(bad("select.set", "too many items"));
|
||||
}
|
||||
let mut sel: Vec<Sel> = Vec::new();
|
||||
for it in items {
|
||||
let one: Sel = serde_json::from_value(it).map_err(|e| bad("select.set", e.to_string()))?;
|
||||
sel.push(one);
|
||||
}
|
||||
if !bool_(p, "add").unwrap_or(false) {
|
||||
s.selection.clear();
|
||||
}
|
||||
for x in sel {
|
||||
if !s.selection.contains(&x) {
|
||||
s.selection.push(x);
|
||||
}
|
||||
}
|
||||
s.revision += 1;
|
||||
Ok(json!({"selected": s.selection.len()}))
|
||||
}
|
||||
|
||||
fn select_clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.selection.clear();
|
||||
s.revision += 1;
|
||||
Ok(json!({"selected": 0}))
|
||||
}
|
||||
|
||||
/// Features that worked in `before` and fail in `after`: (name, error).
|
||||
fn newly_broken(before: &solvecraft_doc::Model, after: &solvecraft_doc::Model) -> Vec<(String, String)> {
|
||||
after
|
||||
.results
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let e = r.error.clone()?;
|
||||
let was_ok = before.result(r.id).is_some_and(|b| b.error.is_none() && !b.skipped);
|
||||
was_ok.then(|| (r.name.clone(), e))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.reorder";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let n = s.doc.features.len();
|
||||
let to = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?.min(n.saturating_sub(1));
|
||||
let from = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
if from == to {
|
||||
return Ok(json!({"feature": id, "position": to}));
|
||||
}
|
||||
let mut next = (*s.doc).clone();
|
||||
let f = next.features.remove(from);
|
||||
let name = f.name.clone();
|
||||
next.features.insert(to, f);
|
||||
// Everything that evaluated before must still evaluate.
|
||||
let mut after = solvecraft_doc::Model::new();
|
||||
after.evaluate(&next);
|
||||
let broken = newly_broken(&s.model, &after);
|
||||
if !broken.is_empty() {
|
||||
let list: Vec<String> = broken.iter().map(|(n, e)| format!("{n} ({e})")).collect();
|
||||
return Err(bad(cmd, format!("moving {name} there breaks {}", list.join("; "))));
|
||||
}
|
||||
*s.doc_mut() = next;
|
||||
Ok(json!({"feature": id, "position": to}))
|
||||
}
|
||||
|
||||
fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.redefine";
|
||||
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.") {
|
||||
return Err(bad(cmd, format!("`{cid}` does not make a feature")));
|
||||
}
|
||||
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let marker = s.doc.marker;
|
||||
// Build the new definition where the feature sits, against the model before it.
|
||||
s.doc_mut().marker = Some(idx);
|
||||
s.refresh();
|
||||
let r = (spec.run)(s, ¶ms);
|
||||
let made = r.as_ref().ok().and_then(|v| v.get("feature")).and_then(Value::as_u64);
|
||||
let new = made.and_then(|nid| s.doc.feature(nid).cloned());
|
||||
if let Some(nid) = made {
|
||||
s.doc_mut().features.retain(|f| f.id != nid);
|
||||
}
|
||||
s.doc_mut().marker = marker;
|
||||
r?;
|
||||
let new = new.ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) {
|
||||
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
|
||||
}
|
||||
if let Some(slot) = s.doc_mut().feature_mut(id) {
|
||||
slot.kind = new.kind;
|
||||
}
|
||||
s.refresh();
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed}))
|
||||
}
|
||||
|
||||
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.dependents";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let mut next = (*s.doc).clone();
|
||||
let gone = next.delete_feature(id)?;
|
||||
let mut after = solvecraft_doc::Model::new();
|
||||
after.evaluate(&next);
|
||||
let names = |ids: &[u64]| -> Vec<String> { ids.iter().filter(|x| **x != id).filter_map(|x| s.doc.feature(*x).map(|f| f.name.clone())).collect() };
|
||||
let broken: Vec<String> = newly_broken(&s.model, &after).into_iter().map(|(n, _)| n).collect();
|
||||
Ok(json!({"deleted_with_it": names(&gone), "would_fail": broken}))
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
//! Sketches made from a planar body face, so a face can be extruded like a profile (Extrude
|
||||
//! with `face`, Press Pull). The face boundary is copied into a new sketch on the face: straight
|
||||
//! edges become lines, circular edges circles or arcs, anything else a chain of lines.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::{Mesh, Vec2, Vec3};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
let n = (b - a).cross(c - a);
|
||||
let Some(nn) = n.normalized() else { return f64::INFINITY };
|
||||
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) }
|
||||
}
|
||||
|
||||
/// The outward normal of the planar body face at `p`.
|
||||
pub(super) fn face_normal(s: &Session, p: Vec3) -> Option<Vec3> {
|
||||
let (m, f, d) = face_at(s, p)?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return None;
|
||||
}
|
||||
m.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, tf)| **tf == f)
|
||||
.filter_map(|(t, _)| m.tri(t))
|
||||
.find_map(|[a, b, c]| (b - a).cross(c - a).normalized())
|
||||
}
|
||||
|
||||
/// Is the body face at `p` planar (`None`: no face there)?
|
||||
pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
|
||||
let (m, f, d) = face_at(s, p)?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return None;
|
||||
}
|
||||
let ns: Vec<Vec3> = m
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, tf)| **tf == f)
|
||||
.filter_map(|(t, _)| m.tri(t))
|
||||
.filter_map(|[a, b, c]| (b - a).cross(c - a).normalized())
|
||||
.collect();
|
||||
let n0 = *ns.first()?;
|
||||
Some(ns.iter().all(|n| n.dot(n0) > 1.0 - 1e-6))
|
||||
}
|
||||
|
||||
/// The body face under a point: (mesh, face index, distance).
|
||||
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64)> {
|
||||
let st = s.model.state();
|
||||
let mut best: Option<(std::sync::Arc<Mesh>, u32, f64)> = None;
|
||||
for b in &st.bodies {
|
||||
let m = b.mesh();
|
||||
let mut near: Option<(u32, f64)> = None;
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let Some([a, bb, c]) = m.tri(t) else { continue };
|
||||
let d = point_triangle_dist(p, a, bb, c);
|
||||
if near.is_none_or(|(_, nd)| d < nd) {
|
||||
near = Some((*f, d));
|
||||
}
|
||||
}
|
||||
if let Some((f, d)) = near
|
||||
&& best.as_ref().is_none_or(|x| d < x.2)
|
||||
{
|
||||
best = Some((m.clone(), f, d));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Circle through three points.
|
||||
fn circumcircle(a: Vec2, b: Vec2, c: Vec2) -> Option<(Vec2, f64)> {
|
||||
let d = 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
|
||||
if d.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let (a2, b2, c2) = (a.len2(), b.len2(), c.len2());
|
||||
let o = Vec2::new((a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d, (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d);
|
||||
let r = o.dist(a);
|
||||
(o.is_finite() && r.is_finite() && r < 1e8).then_some((o, r))
|
||||
}
|
||||
|
||||
fn xy(v: Vec2) -> Value {
|
||||
json!([v.x, v.y])
|
||||
}
|
||||
|
||||
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
|
||||
/// the sketch id and a point inside the face (sketch coordinates). The sketch is left active.
|
||||
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<(u64, Vec2)> {
|
||||
let (m, f, d) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return Err(bad(cmd, "no body face at `face`"));
|
||||
}
|
||||
let tris: Vec<[Vec3; 3]> = m.triangles.iter().zip(&m.tri_face).filter(|(_, tf)| **tf == f).filter_map(|(t, _)| m.tri(t)).collect();
|
||||
let normal = tris.iter().find_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).ok_or_else(|| bad(cmd, "degenerate face"))?;
|
||||
if tris.iter().any(|[a, b, c]| (*b - *a).cross(*c - *a).normalized().is_some_and(|n| n.dot(normal) < 1.0 - 1e-6)) {
|
||||
return Err(bad(cmd, "only planar faces can be extruded"));
|
||||
}
|
||||
// A point well inside the face: the centroid of its largest triangle.
|
||||
let inside = tris
|
||||
.iter()
|
||||
.max_by(|x, y| (x[1] - x[0]).cross(x[2] - x[0]).len().total_cmp(&(y[1] - y[0]).cross(y[2] - y[0]).len()))
|
||||
.map(|[a, b, c]| (*a + *b + *c) / 3.0)
|
||||
.unwrap_or(point);
|
||||
let edges: Vec<Vec<Vec3>> =
|
||||
m.face_edges(f).into_iter().filter(|e| !m.seams.get(*e).copied().unwrap_or(false)).filter_map(|e| m.edges.get(e).cloned()).collect();
|
||||
if edges.is_empty() || edges.len() > 5000 {
|
||||
return Err(bad(cmd, "the face has no usable boundary"));
|
||||
}
|
||||
let run = |s: &mut Session, id: &str, p: Value| -> Result<Value> {
|
||||
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 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"))?;
|
||||
// The face's edges came in as linked projections: they follow the face when earlier
|
||||
// features change, so nothing is copied.
|
||||
if r.get("projected").is_some_and(|x| !x.is_null()) {
|
||||
return Ok((sid, plane.to_local(inside)));
|
||||
}
|
||||
for e in &edges {
|
||||
let pts: Vec<Vec2> = e.iter().map(|p| plane.to_local(*p)).collect();
|
||||
let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied()) else { continue };
|
||||
let closed = pts.len() > 3 && first.dist(last) < tol;
|
||||
let circle = if pts.len() >= 4 {
|
||||
let n = pts.len() - usize::from(closed);
|
||||
let (a, b, c) = (pts.first(), pts.get(n / 3), pts.get(2 * n / 3));
|
||||
match (a, b, c) {
|
||||
(Some(a), Some(b), Some(c)) => circumcircle(*a, *b, *c).filter(|(o, r)| pts.iter().all(|q| (q.dist(*o) - r).abs() < tol)),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match circle {
|
||||
Some((o, r)) if closed => run(s, "sketch.circle.center", 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)}))?
|
||||
}
|
||||
None => {
|
||||
let mut list: Vec<Value> = Vec::new();
|
||||
for q in &pts {
|
||||
// Drop points that repeat (a closed polyline ends where it starts).
|
||||
if list.last().is_none_or(|l| l != &xy(*q)) {
|
||||
list.push(xy(*q));
|
||||
}
|
||||
}
|
||||
let closed_poly = list.len() > 2 && list.first() == list.last();
|
||||
if closed_poly {
|
||||
list.pop();
|
||||
}
|
||||
run(s, "sketch.line", json!({"points": list, "closed": closed_poly}))?
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok((sid, plane.to_local(inside)))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,291 +0,0 @@
|
||||
//! Rib, Web, Emboss, Replace Face, Align and Remove.
|
||||
|
||||
use serde_json::Value;
|
||||
use solvecraft_doc::FeatureKind;
|
||||
use solvecraft_doc::expr::Kind;
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, check_expr, feature_sketch, plane_param, profiles};
|
||||
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(
|
||||
"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)
|
||||
.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)
|
||||
.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)
|
||||
.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)
|
||||
.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)
|
||||
.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)"),
|
||||
];
|
||||
|
||||
fn coil(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.coil";
|
||||
let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let axis = match p.get("axis") {
|
||||
None => Vec3::Z,
|
||||
Some(Value::String(a)) => match a.to_ascii_uppercase().as_str() {
|
||||
"X" => Vec3::X,
|
||||
"Y" => Vec3::Y,
|
||||
"Z" => Vec3::Z,
|
||||
_ => return Err(bad(cmd, "`axis` must be X, Y, Z or [x, y, z]")),
|
||||
},
|
||||
Some(v) => vec3(v).and_then(|a| a.normalized()).ok_or_else(|| bad(cmd, "`axis` must be X, Y, Z or a non-zero [x, y, z]"))?,
|
||||
};
|
||||
let diameter = req_expr(cmd, p, "diameter")?;
|
||||
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
|
||||
let turns = expr(p, "revolutions").or_else(|| expr(p, "turns"));
|
||||
let (height, pitch) = (expr(p, "height"), expr(p, "pitch"));
|
||||
let (turns, pitch) = match (turns, height, pitch) {
|
||||
(Some(n), _, Some(pt)) => (n, pt),
|
||||
(Some(n), Some(h), None) => (n.clone(), format!("({h}) / ({n})")),
|
||||
(None, Some(h), Some(pt)) => (format!("({h}) / ({pt})"), pt),
|
||||
_ => return Err(bad(cmd, "give two of `revolutions`, `height` and `pitch`")),
|
||||
};
|
||||
check_expr(s, &turns, Kind::Unitless, cmd, "revolutions")?;
|
||||
check_expr(s, &pitch, Kind::Length, cmd, "pitch")?;
|
||||
let section_size = req_expr(cmd, p, "section_size")?;
|
||||
check_expr(s, §ion_size, Kind::Length, cmd, "section_size")?;
|
||||
let section = match str_(p, "section").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("circular" | "circle") => solvecraft_doc::CoilSection::Circular,
|
||||
Some("square") => solvecraft_doc::CoilSection::Square,
|
||||
Some(o) => return Err(bad(cmd, format!("unknown section `{o}` (circular or square)"))),
|
||||
};
|
||||
let position = match str_(p, "section_position").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("center" | "on" | "on_center") => 0,
|
||||
Some("inside") => -1,
|
||||
Some("outside") => 1,
|
||||
Some(o) => return Err(bad(cmd, format!("unknown section position `{o}` (inside, center or outside)"))),
|
||||
};
|
||||
let start_angle = expr(p, "start_angle");
|
||||
if let Some(a) = &start_angle {
|
||||
check_expr(s, a, Kind::Angle, cmd, "start_angle")?;
|
||||
}
|
||||
let clockwise = bool_(p, "clockwise").unwrap_or(false);
|
||||
let operation = super::features::operation(p, cmd)?;
|
||||
add_feature(
|
||||
s,
|
||||
p,
|
||||
FeatureKind::Coil {
|
||||
base,
|
||||
axis,
|
||||
diameter,
|
||||
pitch,
|
||||
turns,
|
||||
section_size,
|
||||
section,
|
||||
position,
|
||||
start_angle,
|
||||
clockwise,
|
||||
operation,
|
||||
targets: string_list(p, "targets"),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn curves_param(p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
let c = string_list(p, "curves");
|
||||
if c.is_empty() || c.len() > 1000 {
|
||||
return Err(bad(cmd, "`curves` must list 1…1000 sketch curve ids"));
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
fn rib_like(s: &mut Session, p: &Value, cmd: &str, web: bool) -> Result<Value> {
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let curves = curves_param(p, cmd)?;
|
||||
{
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?;
|
||||
if let Some(c) = curves.iter().find(|c| ss.sketch.curve_index(c).is_none()) {
|
||||
return Err(bad(cmd, format!("no curve `{c}` in the sketch")));
|
||||
}
|
||||
}
|
||||
let thickness = req_expr(cmd, p, "thickness")?;
|
||||
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
|
||||
let depth = expr(p, "depth");
|
||||
if let Some(d) = &depth {
|
||||
check_expr(s, d, Kind::Length, cmd, "depth")?;
|
||||
}
|
||||
let flip = bool_(p, "flip").unwrap_or(false);
|
||||
add_feature(s, p, FeatureKind::Rib { sketch, curves, thickness, depth, flip, web })
|
||||
}
|
||||
|
||||
fn rib(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
rib_like(s, p, "solid.rib", false)
|
||||
}
|
||||
|
||||
fn web(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
rib_like(s, p, "solid.web", true)
|
||||
}
|
||||
|
||||
fn emboss(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.emboss";
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let profiles = profiles(p, cmd)?;
|
||||
let depth = req_expr(cmd, p, "depth")?;
|
||||
check_expr(s, &depth, Kind::Length, cmd, "depth")?;
|
||||
let deboss = match str_(p, "mode").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("emboss") => false,
|
||||
Some("deboss") => true,
|
||||
Some(m) => return Err(bad(cmd, format!("unknown mode `{m}` (emboss or deboss)"))),
|
||||
};
|
||||
if let Some(fp) = p.get("face").and_then(vec3)
|
||||
&& super::face::face_normal(s, fp).is_none()
|
||||
{
|
||||
return Err(bad(cmd, "not supported yet: embossing onto a curved face"));
|
||||
}
|
||||
add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets: string_list(p, "targets") })
|
||||
}
|
||||
|
||||
fn face_points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z]")))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
return Err(bad(cmd, format!("`{k}` must list 1…1000 points")));
|
||||
}
|
||||
a.iter().map(|v| vec3(v).ok_or_else(|| bad(cmd, format!("`{k}` must contain [x, y, z] points")))).collect()
|
||||
}
|
||||
|
||||
fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.replace_face";
|
||||
let faces = face_points(p, "faces", cmd)?;
|
||||
for f in &faces {
|
||||
if super::face::face_normal(s, *f).is_none() {
|
||||
return Err(bad(cmd, "not supported yet: replacing a face that is not planar"));
|
||||
}
|
||||
}
|
||||
let target = match p.get("target_face").and_then(vec3) {
|
||||
Some(tp) => {
|
||||
let n = super::face::face_normal(s, tp).ok_or_else(|| bad(cmd, "the target face must be planar"))?;
|
||||
let plane = solvecraft_geom::Plane::from_normal(tp, n).ok_or_else(|| bad(cmd, "the target face's plane"))?;
|
||||
solvecraft_doc::PlaneRef::Custom { plane }
|
||||
}
|
||||
None => plane_param(s, p.get("target"), cmd)?,
|
||||
};
|
||||
let body = str_(p, "body").map(str::to_string);
|
||||
add_feature(s, p, FeatureKind::ReplaceFace { faces, target, body })
|
||||
}
|
||||
|
||||
fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.align";
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() {
|
||||
return Err(bad(cmd, "`bodies` must list the bodies to move"));
|
||||
}
|
||||
{
|
||||
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}`")));
|
||||
}
|
||||
}
|
||||
let pick = |s: &Session, pk: &str, fk: &str| -> Result<(Vec3, Option<Vec3>)> {
|
||||
if let Some(fp) = p.get(fk).and_then(vec3) {
|
||||
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")?;
|
||||
let (to, to_normal) = pick(s, "to", "to_face")?;
|
||||
let (from_normal, to_normal) = match (from_normal, to_normal) {
|
||||
(Some(a), Some(b)) => (Some(a), Some(b)),
|
||||
_ => (None, None),
|
||||
};
|
||||
let flip = bool_(p, "flip").unwrap_or(false);
|
||||
add_feature(s, p, FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip })
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.remove";
|
||||
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}`")));
|
||||
}
|
||||
add_feature(s, p, FeatureKind::Remove { bodies })
|
||||
}
|
||||
|
||||
#[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) })
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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 measure(s: &mut Session) -> Value {
|
||||
run(s, "inspect.measure", json!({}))
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
measure(s)["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
|
||||
/// 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"}));
|
||||
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!({}));
|
||||
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}));
|
||||
// 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"}));
|
||||
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}));
|
||||
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}));
|
||||
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}));
|
||||
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}));
|
||||
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"}));
|
||||
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());
|
||||
}
|
||||
|
||||
#[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]}}));
|
||||
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]}}));
|
||||
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"}));
|
||||
// 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]}));
|
||||
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());
|
||||
for (got, want) in [(&lo[0], 0.0), (&lo[2], 10.0), (&hi[0], 10.0), (&hi[2], 20.0)] {
|
||||
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]}));
|
||||
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"]}));
|
||||
assert_eq!(measure(&mut s)["body_count"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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}));
|
||||
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",
|
||||
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");
|
||||
// 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"}]}));
|
||||
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());
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
//! Documents and files: new, open (designs and STEP), insert STEP, save, export.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::Document;
|
||||
use solvecraft_io::Format;
|
||||
|
||||
use super::CommandSpec;
|
||||
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("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)
|
||||
.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)
|
||||
.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("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"),
|
||||
CommandSpec::new("doc.recover", "Recover Design", recover)
|
||||
.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)
|
||||
.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"),
|
||||
];
|
||||
|
||||
fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_(p, "name").unwrap_or("Untitled");
|
||||
*s = Session::new(Document::new(name));
|
||||
Ok(json!({"name": name}))
|
||||
}
|
||||
|
||||
fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
|
||||
str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return Err(EngineError::Other(format!("{path}: file too large")));
|
||||
}
|
||||
let bytes = solvecraft_io::vfs::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}")))
|
||||
}
|
||||
|
||||
/// Add an Import feature for a STEP file to the session's design.
|
||||
fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
|
||||
let sf = read_step(path)?;
|
||||
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&sf.name).to_string();
|
||||
let id = s.doc_mut().add_feature(sf.kind, Some(&fname))?;
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
f.body_names = sf.body_names;
|
||||
}
|
||||
s.active_sketch = None;
|
||||
s.refresh();
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
|
||||
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "warnings": sf.warnings}))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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 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))?;
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
f.body_names = mf.body_names;
|
||||
}
|
||||
s.active_sketch = None;
|
||||
s.refresh();
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
|
||||
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "warnings": mf.warnings}))
|
||||
}
|
||||
|
||||
fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.insert_mesh";
|
||||
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"));
|
||||
}
|
||||
add_mesh(s, path, str_(p, "name"))
|
||||
}
|
||||
|
||||
fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.insert_step";
|
||||
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"));
|
||||
}
|
||||
add_step(s, path, str_(p, "name"))
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = path_arg(p, "doc.open")?;
|
||||
if solvecraft_io::is_step_path(path) || solvecraft_io::is_mesh_path(path) {
|
||||
// A new, unsaved design holding the file's bodies.
|
||||
let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
|
||||
let mut fresh = Session::new(Document::new(&stem));
|
||||
let r = if solvecraft_io::is_step_path(path) { add_step(&mut fresh, path, None)? } else { add_mesh(&mut fresh, path, None)? };
|
||||
fresh.undo.clear();
|
||||
*s = fresh;
|
||||
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 {
|
||||
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 doc = solvecraft_io::read_design(&bytes)?;
|
||||
*s = Session::new(doc);
|
||||
s.path = Some(path.to_string());
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors}))
|
||||
}
|
||||
|
||||
fn write(s: &mut Session, path: &str) -> Result<Value> {
|
||||
let bytes = solvecraft_io::write_design(&s.doc);
|
||||
// Atomic, keeping the version being replaced as `<file>.bak`.
|
||||
solvecraft_io::write_atomic(std::path::Path::new(path), &bytes, true).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
s.path = Some(path.to_string());
|
||||
s.mark_saved();
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
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`"))?,
|
||||
};
|
||||
write(s, &path)
|
||||
}
|
||||
|
||||
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = path_arg(p, "file.save_as")?.to_string();
|
||||
write(s, &path)
|
||||
}
|
||||
|
||||
fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Result<Value> {
|
||||
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
|
||||
// A design with components exports as a STEP assembly; otherwise bodies as placed.
|
||||
let bytes = if format == Format::Step && bodies.is_empty() && !s.doc.occurrences.is_empty() {
|
||||
solvecraft_io::step_assembly(&s.doc, &s.doc.painted(&s.model.state()), &name)?
|
||||
} else {
|
||||
// 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}")))?;
|
||||
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 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 f = if bool_(p, "ascii").unwrap_or(false) { Format::StlAscii } else { Format::StlBinary };
|
||||
export_to(s, path, f, &string_list(p, "bodies"))
|
||||
}
|
||||
|
||||
fn recovery_dir(p: &Value, cmd: &str) -> Result<std::path::PathBuf> {
|
||||
match str_(p, "dir").filter(|d| !d.trim().is_empty() && d.len() < 4096) {
|
||||
Some(d) => Ok(d.into()),
|
||||
None => crate::recovery::default_dir().ok_or_else(|| bad(cmd, "no recovery folder on this system (give `dir`)")),
|
||||
}
|
||||
}
|
||||
|
||||
fn recovery_list(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = recovery_dir(p, "doc.recovery_list")?;
|
||||
Ok(json!({"dir": dir, "designs": crate::recovery::orphans(&dir)}))
|
||||
}
|
||||
|
||||
fn recover(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "doc.recover";
|
||||
let dir = recovery_dir(p, cmd)?;
|
||||
let id = str_(p, "id").ok_or_else(|| bad(cmd, "`id` is required (see doc.recovery_list)"))?;
|
||||
let (doc, entry) = crate::recovery::load(&dir, id)?;
|
||||
*s = Session::new(doc);
|
||||
s.path = entry.path.clone();
|
||||
s.mark_unsaved();
|
||||
if bool_(p, "discard").unwrap_or(false) {
|
||||
crate::recovery::remove(&dir, id);
|
||||
}
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
Ok(json!({"id": id, "path": entry.path, "name": entry.name, "features": s.doc.features.len(), "errors": errors}))
|
||||
}
|
||||
|
||||
fn recovery_discard(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "doc.recovery_discard";
|
||||
let dir = recovery_dir(p, cmd)?;
|
||||
let ids: Vec<String> = if bool_(p, "all").unwrap_or(false) {
|
||||
crate::recovery::orphans(&dir).into_iter().map(|e| e.id).collect()
|
||||
} else {
|
||||
vec![str_(p, "id").ok_or_else(|| bad(cmd, "`id` or `all` is required"))?.to_string()]
|
||||
};
|
||||
for id in &ids {
|
||||
crate::recovery::remove(&dir, id);
|
||||
}
|
||||
Ok(json!({"discarded": ids}))
|
||||
}
|
||||
@@ -1,498 +0,0 @@
|
||||
//! Inspection: measure bodies, describe the document, sketches and model topology.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_sketch::CurveKind;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_, string_list};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("inspect.measure", "Measure", measure)
|
||||
.at("SOLID", "INSPECT")
|
||||
.icon("measure")
|
||||
.key("I")
|
||||
.noundo()
|
||||
.params("bodies?: [names] (default all); or items: [1-2 selections] → length/area/volume, minimum distance (from, to) and angle"),
|
||||
CommandSpec::new("document.inspect", "Inspect Design", inspect).noundo().params("measure?: bool (include body measurements)"),
|
||||
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).noundo().params("sketch?: id|name (default active)"),
|
||||
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)
|
||||
.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(
|
||||
"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",
|
||||
),
|
||||
CommandSpec::new("appearance.library", "Appearance Library", appearance_library)
|
||||
.noundo()
|
||||
.params("→ the built-in appearances (name, colour, opacity)"),
|
||||
CommandSpec::new("appearance.list", "List Appearances", appearance_list)
|
||||
.noundo()
|
||||
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
|
||||
CommandSpec::new("material.list", "List Materials", material_list).noundo(),
|
||||
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
|
||||
];
|
||||
|
||||
pub fn measure_json(b: &solvecraft_doc::ModelBody) -> Value {
|
||||
match solvecraft_kernel::measure(&b.body) {
|
||||
Ok(m) => json!({
|
||||
"name": b.name,
|
||||
"volume_mm3": m.volume,
|
||||
"area_mm2": m.area,
|
||||
"center_of_mass": m.centroid,
|
||||
"bbox": {"min": m.bbox.min, "max": m.bbox.max},
|
||||
"faces": m.merged.faces,
|
||||
"edges": m.merged.edges,
|
||||
"vertices": m.merged.vertices,
|
||||
"face_types": m.merged.face_types,
|
||||
"kernel": {"faces": m.faces, "edges": m.edges, "vertices": m.vertices, "shells": m.shells},
|
||||
}),
|
||||
Err(e) => json!({"name": b.name, "error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
fn measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(items) = p.get("items") {
|
||||
return super::measure_sel::measure_items(s, items);
|
||||
}
|
||||
let want = string_list(p, "bodies");
|
||||
let st = s.world_state();
|
||||
let mut bodies: Vec<Value> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).map(measure_json).collect();
|
||||
// Mass from each body's material.
|
||||
for b in &mut bodies {
|
||||
let name = b["name"].as_str().unwrap_or_default().to_string();
|
||||
if let Some(v) = b["volume_mm3"].as_f64() {
|
||||
let rho = s.doc.density(&name);
|
||||
b["material"] = json!(s.doc.materials.get(&name).cloned().unwrap_or_else(|| "Default".into()));
|
||||
b["density_g_cm3"] = json!(rho);
|
||||
b["mass_g"] = json!(v * rho / 1000.0);
|
||||
}
|
||||
}
|
||||
if !want.is_empty() && bodies.len() != want.len() {
|
||||
return Err(bad("inspect.measure", "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.
|
||||
let sheets = s.model.state().sheets.clone();
|
||||
for b in &mut bodies {
|
||||
if let Some(sh) = sheets.iter().find(|x| b["name"].as_str() == Some(x.body.as_str())) {
|
||||
let (df, de, dv) = sh.round_hole_correction();
|
||||
for (k, d) in [("faces", df), ("edges", de), ("vertices", dv)] {
|
||||
b[k] = json!(b["kernel"][k].as_i64().unwrap_or(0) + d);
|
||||
}
|
||||
b["sheet_metal"] = json!(true);
|
||||
}
|
||||
}
|
||||
let tv: f64 = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).sum();
|
||||
let ta: f64 = bodies.iter().filter_map(|b| b["area_mm2"].as_f64()).sum();
|
||||
let tf: u64 = bodies.iter().filter_map(|b| b["faces"].as_u64()).sum();
|
||||
let te: u64 = bodies.iter().filter_map(|b| b["edges"].as_u64()).sum();
|
||||
let tvx: u64 = bodies.iter().filter_map(|b| b["vertices"].as_u64()).sum();
|
||||
let tm: f64 = bodies.iter().filter_map(|b| b["mass_g"].as_f64()).sum();
|
||||
Ok(
|
||||
json!({"body_count": bodies.len(), "bodies": bodies, "total": {"volume_mm3": tv, "area_mm2": ta, "mass_g": tm, "faces": tf, "edges": te, "vertices": tvx}}),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let det = ss.report.curve_determined.get(i).copied().unwrap_or(false);
|
||||
let pid = |k: usize| sk.points.get(k).map(|p| p.id.clone()).unwrap_or_default();
|
||||
let mut v = match c.kind {
|
||||
CurveKind::Line { a, b } => json!({"id": c.id, "type": "line", "start": pid(a), "end": pid(b), "start_at": sk.point(a), "end_at": sk.point(b), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Spline { ref pts, control, degree } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::<Vec<_>>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::<Vec<_>>(), "control": control, "degree": degree, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
};
|
||||
if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) {
|
||||
o.insert("link".into(), json!(l));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let locked = sk.locked_points();
|
||||
let points: Vec<Value> = sk
|
||||
.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)}))
|
||||
.collect();
|
||||
let constraints: Vec<Value> = sk
|
||||
.constraints
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut v = serde_json::to_value(&c.kind).unwrap_or_default();
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("id".into(), json!(c.id));
|
||||
o.insert("name".into(), json!(c.kind.name()));
|
||||
if let Some(p) = &c.param {
|
||||
o.insert("param".into(), json!(p));
|
||||
o.insert("expression".into(), json!(s.doc.param(p).map(|x| x.expr.clone())));
|
||||
}
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let profiles: Vec<Value> = ss
|
||||
.profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| json!({"index": i, "area_mm2": p.area, "centroid_sketch": p.centroid, "curves": p.outer_curves, "holes": p.hole_curves}))
|
||||
.collect();
|
||||
Some(json!({
|
||||
"id": id,
|
||||
"name": ss.name,
|
||||
"plane": ss.plane,
|
||||
"status": ss.report.status,
|
||||
"dof": ss.report.dof,
|
||||
"fully_constrained": ss.report.fully_constrained(),
|
||||
"failing": ss.report.failing,
|
||||
"curves": curves,
|
||||
"points": points,
|
||||
"constraints": constraints,
|
||||
"profiles": profiles,
|
||||
"links": sk.links,
|
||||
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn sketch_inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = match p.get("sketch") {
|
||||
Some(Value::Number(n)) => s.doc.find_feature(&n.to_string()).map(|f| f.id),
|
||||
Some(Value::String(x)) => s.doc.find_feature(x).map(|f| f.id),
|
||||
_ => s.active_sketch,
|
||||
}
|
||||
.ok_or_else(|| bad("sketch.inspect", "no such sketch (and none is active)"))?;
|
||||
sketch_json(s, id).ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into()))
|
||||
}
|
||||
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let (vals, perr) = s.doc.param_values();
|
||||
let params: Vec<Value> = s
|
||||
.doc
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| json!({"name": p.name, "expression": p.expr, "unit": p.unit, "value": vals.get(&p.name).map(|v| v.v), "model": p.model, "comment": p.comment, "error": perr.get(&p.name)}))
|
||||
.collect();
|
||||
let marker = s.doc.marker.unwrap_or(s.doc.features.len());
|
||||
let timeline: Vec<Value> = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let r = s.model.result(f.id);
|
||||
json!({
|
||||
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": i >= marker,
|
||||
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()), "ms": r.map(|r| r.ms),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let with_measure = bool_(p, "measure").unwrap_or(false);
|
||||
let bodies: Vec<Value> =
|
||||
st.bodies.iter().map(|b| if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) }).collect();
|
||||
let sketches: Vec<Value> = st
|
||||
.sketches
|
||||
.iter()
|
||||
.map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len()}))
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"name": s.doc.name, "units": s.doc.units, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
|
||||
"active_sketch": s.active_sketch, "marker": marker,
|
||||
"params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches,
|
||||
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn body_of<'a>(st: &'a solvecraft_doc::ModelState, p: &Value, cmd: &str) -> Result<&'a solvecraft_doc::ModelBody> {
|
||||
let n = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?;
|
||||
st.body(n).ok_or_else(|| bad(cmd, format!("no body `{n}`")))
|
||||
}
|
||||
|
||||
fn model_edges(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.world_state();
|
||||
let b = body_of(&st, p, "model.edges")?;
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
let names = solvecraft_doc::naming::edge_names(b);
|
||||
let edges: Vec<Value> = b
|
||||
.body
|
||||
.edges(tol)?
|
||||
.iter()
|
||||
.map(|e| json!({"index": e.index, "name": names.get(e.index), "mid": e.mid, "length": e.length, "start": e.points.first(), "end": e.points.last()}))
|
||||
.collect();
|
||||
Ok(json!({"body": b.name, "edges": edges}))
|
||||
}
|
||||
|
||||
fn model_faces(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.world_state();
|
||||
let b = body_of(&st, p, "model.faces")?;
|
||||
let names = solvecraft_doc::naming::face_names(b);
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
let faces: Vec<Value> = b
|
||||
.body
|
||||
.faces(tol)?
|
||||
.iter()
|
||||
.map(|f| json!({"index": f.index, "name": names.get(f.index), "area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal}))
|
||||
.collect();
|
||||
Ok(json!({"body": b.name, "faces": faces}))
|
||||
}
|
||||
|
||||
fn commands(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(crate::command_specs().iter().map(|c| serde_json::to_value(c.info(s)).unwrap_or_default()).collect()))
|
||||
}
|
||||
|
||||
fn model_threads(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.world_state();
|
||||
Ok(json!({ "threads": st.threads }))
|
||||
}
|
||||
|
||||
fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "material.assign";
|
||||
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
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(material.trim()))
|
||||
.map(|(n, _)| n.to_string())
|
||||
.ok_or_else(|| bad(cmd, format!("unknown material `{material}` (see material.list)")))?;
|
||||
let st = s.model.state();
|
||||
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
|
||||
return Err(bad(cmd, "`bodies` must list existing bodies"));
|
||||
}
|
||||
for b in &bodies {
|
||||
if canonical == "Default" {
|
||||
s.doc_mut().materials.remove(b);
|
||||
} else {
|
||||
s.doc_mut().materials.insert(b.clone(), canonical.clone());
|
||||
}
|
||||
}
|
||||
Ok(json!({"bodies": bodies, "material": canonical}))
|
||||
}
|
||||
|
||||
/// A colour parameter: `"#rrggbb"` or `[r, g, b]` (0–255).
|
||||
fn color_param(v: &Value) -> Option<[u8; 3]> {
|
||||
if let Some(t) = v.as_str() {
|
||||
let h = t.trim().trim_start_matches('#');
|
||||
if h.len() != 6 || !h.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
|
||||
return Some([byte(0)?, byte(2)?, byte(4)?]);
|
||||
}
|
||||
let a = v.as_array().filter(|a| a.len() == 3)?;
|
||||
let c = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite() && (0.0..=255.0).contains(x)).map(|x| x.round() as u8);
|
||||
Some([c(0)?, c(1)?, c(2)?])
|
||||
}
|
||||
|
||||
fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use solvecraft_doc::appearance::{FaceLook, Look, library};
|
||||
let cmd = "appearance.assign";
|
||||
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() => {
|
||||
let c = color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?;
|
||||
let o = p.get("opacity").and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(1.0);
|
||||
let mut l = Look::custom(c, o);
|
||||
l.name = n.chars().take(128).collect();
|
||||
Some(l)
|
||||
}
|
||||
(Some(n), _) => {
|
||||
let mut l = library(n).ok_or_else(|| bad(cmd, format!("no appearance `{n}` in the library (appearance.library)")))?;
|
||||
if let Some(o) = p.get("opacity").and_then(Value::as_f64).filter(|x| x.is_finite()) {
|
||||
l.opacity = o.clamp(0.0, 1.0);
|
||||
}
|
||||
Some(l)
|
||||
}
|
||||
(None, None | Some(Value::Null)) => None,
|
||||
(None, Some(v)) => {
|
||||
let c = color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?;
|
||||
let o = match p.get("opacity") {
|
||||
Some(o) => o.as_f64().filter(|x| x.is_finite() && (0.0..=1.0).contains(x)).ok_or_else(|| bad(cmd, "`opacity` must be 0…1"))?,
|
||||
None => 1.0,
|
||||
};
|
||||
Some(Look::custom(c, o))
|
||||
}
|
||||
};
|
||||
let bodies = string_list(p, "bodies");
|
||||
let st = s.model.state();
|
||||
if bodies.iter().any(|b| st.body(b).is_none()) {
|
||||
return Err(bad(cmd, "`bodies` must list existing bodies"));
|
||||
}
|
||||
// Faces: a point on each, on the named body or the nearest one.
|
||||
let mut faces: Vec<(String, solvecraft_geom::Vec3)> = Vec::new();
|
||||
if let Some(list) = p.get("faces") {
|
||||
let list = list.as_array().filter(|a| a.len() <= 10_000).ok_or_else(|| bad(cmd, "`faces` must list points [x, y, z]"))?;
|
||||
for v in list {
|
||||
let pt = crate::params::vec3(v)
|
||||
.or_else(|| v.get("point").and_then(crate::params::vec3))
|
||||
.ok_or_else(|| bad(cmd, "`faces` must list points [x, y, z]"))?;
|
||||
let on = |b: &solvecraft_doc::ModelBody| solvecraft_doc::appearance::face_index_at(b, pt).is_some();
|
||||
let body = match v.get("body").and_then(Value::as_str).or_else(|| str_(p, "body")) {
|
||||
Some(n) => st.body(n).filter(|b| on(b)).map(|b| b.name.clone()),
|
||||
None => st.bodies.iter().filter(|b| on(b)).min_by(|a, b| dist_to(a, pt).total_cmp(&dist_to(b, pt))).map(|b| b.name.clone()),
|
||||
};
|
||||
faces.push((body.ok_or_else(|| bad(cmd, format!("no face at {pt:?}")))?, pt));
|
||||
}
|
||||
}
|
||||
let mut comps: Vec<u64> = Vec::new();
|
||||
for c in string_list(p, "components") {
|
||||
let id = s
|
||||
.doc
|
||||
.find_component(&c)
|
||||
.or_else(|| s.doc.occurrences.iter().find(|o| o.name == c).map(|o| o.component))
|
||||
.filter(|id| *id != 0)
|
||||
.ok_or_else(|| bad(cmd, format!("no component `{c}`")))?;
|
||||
comps.push(id);
|
||||
}
|
||||
if bodies.is_empty() && faces.is_empty() && comps.is_empty() {
|
||||
return Err(bad(cmd, "give `bodies`, `faces` or `components`"));
|
||||
}
|
||||
// Persistent names of the picked faces.
|
||||
let st_names: Vec<(String, solvecraft_geom::Vec3, Option<String>)> = faces
|
||||
.iter()
|
||||
.map(|(b, pt)| {
|
||||
let n = st.body(b).and_then(|mb| {
|
||||
solvecraft_doc::appearance::face_index_at(mb, *pt).and_then(|i| solvecraft_doc::naming::face_names(mb).get(i).cloned())
|
||||
});
|
||||
(b.clone(), *pt, n)
|
||||
})
|
||||
.collect();
|
||||
let a = &mut s.doc_mut().appearances;
|
||||
for b in &bodies {
|
||||
match &look {
|
||||
Some(l) => a.bodies.insert(b.clone(), l.clone()),
|
||||
None => a.bodies.remove(b),
|
||||
};
|
||||
}
|
||||
for (body, pt) in &faces {
|
||||
// One entry per face: a new one replaces any at the same spot.
|
||||
a.faces.retain(|f| !(f.body == *body && f.point.dist(*pt) < 1e-9));
|
||||
if let Some(l) = &look {
|
||||
if a.faces.len() >= 100_000 {
|
||||
return Err(bad(cmd, "too many face appearances"));
|
||||
}
|
||||
let name = st_names.iter().find(|(b, p, _)| b == body && p.dist(*pt) < 1e-12).and_then(|(_, _, n)| n.clone());
|
||||
a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name });
|
||||
}
|
||||
}
|
||||
for c in &comps {
|
||||
match &look {
|
||||
Some(l) => a.components.insert(*c, l.clone()),
|
||||
None => a.components.remove(c),
|
||||
};
|
||||
}
|
||||
// Clearing a face: drop every entry on that face, not just at the same point.
|
||||
if look.is_none() && !faces.is_empty() {
|
||||
let st = s.model.state();
|
||||
let doc = s.doc_mut();
|
||||
let picked: Vec<(String, Option<usize>)> =
|
||||
faces.iter().map(|(b, pt)| (b.clone(), st.body(b).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, *pt)))).collect();
|
||||
doc.appearances.faces.retain(|f| {
|
||||
let idx = st.body(&f.body).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, f.point));
|
||||
!picked.iter().any(|(b, i)| *b == f.body && i.is_some() && *i == idx)
|
||||
});
|
||||
}
|
||||
Ok(json!({
|
||||
"bodies": bodies,
|
||||
"faces": faces.iter().map(|(b, p)| json!({"body": b, "point": p})).collect::<Vec<_>>(),
|
||||
"components": comps,
|
||||
"appearance": look.as_ref().map(|l| json!({"name": l.name, "color": l.hex(), "opacity": l.opacity})),
|
||||
}))
|
||||
}
|
||||
|
||||
fn dist_to(b: &solvecraft_doc::ModelBody, p: solvecraft_geom::Vec3) -> f64 {
|
||||
let bb = b.mesh().bounds();
|
||||
let c = solvecraft_geom::Vec3::new(p.x.clamp(bb.min.x, bb.max.x), p.y.clamp(bb.min.y, bb.max.y), p.z.clamp(bb.min.z, bb.max.z));
|
||||
c.dist(p)
|
||||
}
|
||||
|
||||
fn look_json(l: &solvecraft_doc::appearance::Look) -> Value {
|
||||
json!({"name": l.name, "color": l.hex(), "opacity": l.opacity})
|
||||
}
|
||||
|
||||
fn appearance_library(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let list: Vec<Value> = solvecraft_doc::appearance::LIBRARY
|
||||
.iter()
|
||||
.map(|(n, c, o)| {
|
||||
let category = n.split(" - ").next().unwrap_or(n);
|
||||
json!({"name": n, "category": category, "color": format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]), "opacity": o})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"appearances": list}))
|
||||
}
|
||||
|
||||
fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = &s.doc.appearances;
|
||||
// "In this design": each look used, with where.
|
||||
let mut used: Vec<(solvecraft_doc::appearance::Look, Vec<Value>, Vec<Value>, Vec<Value>)> = Vec::new();
|
||||
fn slot(used: &mut Vec<(solvecraft_doc::appearance::Look, Vec<Value>, Vec<Value>, Vec<Value>)>, l: &solvecraft_doc::appearance::Look) -> usize {
|
||||
match used.iter().position(|(u, ..)| u == l) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
used.push((l.clone(), Vec::new(), Vec::new(), Vec::new()));
|
||||
used.len() - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
for (b, l) in &a.bodies {
|
||||
let i = slot(&mut used, l);
|
||||
if let Some(u) = used.get_mut(i) {
|
||||
u.1.push(json!(b));
|
||||
}
|
||||
}
|
||||
for f in &a.faces {
|
||||
let i = slot(&mut used, &f.look);
|
||||
if let Some(u) = used.get_mut(i) {
|
||||
u.2.push(json!({"body": f.body, "point": f.point}));
|
||||
}
|
||||
}
|
||||
for (c, l) in &a.components {
|
||||
let i = slot(&mut used, l);
|
||||
if let Some(u) = used.get_mut(i) {
|
||||
u.3.push(json!(c));
|
||||
}
|
||||
}
|
||||
let in_design: Vec<Value> = used
|
||||
.iter()
|
||||
.map(|(l, b, f, c)| json!({"name": l.name, "color": l.hex(), "opacity": l.opacity, "bodies": b, "faces": f, "components": c}))
|
||||
.collect();
|
||||
let mut out = json!({
|
||||
"in_design": in_design,
|
||||
"bodies": a.bodies.iter().map(|(b, l)| (b.clone(), look_json(l))).collect::<serde_json::Map<_, _>>(),
|
||||
"faces": a.faces.iter().map(|f| json!({"body": f.body, "point": f.point, "appearance": look_json(&f.look)})).collect::<Vec<_>>(),
|
||||
"components": a.components.iter().map(|(c, l)| json!({"component": c, "appearance": look_json(l)})).collect::<Vec<_>>(),
|
||||
});
|
||||
if let Some(name) = str_(p, "body") {
|
||||
let st = s.model.state();
|
||||
let b = st.body(name).ok_or_else(|| bad("appearance.list", format!("no body `{name}`")))?;
|
||||
out["body"] = json!({
|
||||
"name": name,
|
||||
"appearance": s.doc.body_look(&b.name, b.feature).map(|l| look_json(&l)),
|
||||
"color": s.doc.body_color(b),
|
||||
"faces": s.doc.face_colors(b).iter().map(|(i, l)| json!({"face": i, "appearance": look_json(l)})).collect::<Vec<_>>(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn material_list(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"materials": solvecraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d})).collect::<Vec<_>>()}))
|
||||
}
|
||||
@@ -1,568 +0,0 @@
|
||||
//! Joints between occurrences (`solvecraft_doc::joints`): Joint Origin, Joint, As-built Joint,
|
||||
//! Rigid Group, Drive Joints, Motion Link, limits, and Interference. Every change re-solves the
|
||||
//! joint graph and moves the occurrences it places; the session re-solves after any edit too,
|
||||
//! so joints follow the geometry their origins snap to.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::expr::Kind;
|
||||
use solvecraft_doc::joints::{self, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Snap};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
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(
|
||||
"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(
|
||||
"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(
|
||||
"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(
|
||||
"joint: id|name; value | values: [angles in deg or expressions, distances] (clamped to the limits)",
|
||||
),
|
||||
CommandSpec::new("joint.motion_link", "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)"),
|
||||
CommandSpec::new("joint.limits", "Joint Limits", limits).params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
|
||||
CommandSpec::new("joint.edit", "Edit Joint", edit).params("joint; type?, flip?, offset?, angle?, suppressed?, name?"),
|
||||
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(
|
||||
"bodies?: [names] (default: all, in world placement) → pairs that overlap with the overlap volume, area, bounding box and centre",
|
||||
),
|
||||
];
|
||||
|
||||
/// Re-solve the joints and move the occurrences they place (after any edit).
|
||||
pub fn resolve(s: &mut Session) -> joints::Solution {
|
||||
let st = s.model.state();
|
||||
let sol = joints::solve(&s.doc, &st);
|
||||
if !sol.world.is_empty() {
|
||||
let mut next = (*s.doc).clone();
|
||||
if next.apply_joint_solution(&sol) {
|
||||
*s.doc_mut() = next;
|
||||
s.revision += 1;
|
||||
}
|
||||
}
|
||||
sol
|
||||
}
|
||||
|
||||
fn sol_json(sol: &joints::Solution) -> Value {
|
||||
json!({
|
||||
"conflicts": sol.conflicts.iter().map(|(id, n, w)| json!({"joint": id, "name": n, "why": w})).collect::<Vec<_>>(),
|
||||
"errors": sol.errors.iter().map(|(id, n, w)| json!({"joint": id, "name": n, "why": w})).collect::<Vec<_>>(),
|
||||
"dof": sol.dof.iter().map(|(o, d)| json!({"occurrence": o, "dof": d})).collect::<Vec<_>>(),
|
||||
"fixed": sol.fixed,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn occurrence_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64> {
|
||||
let key = match v {
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
_ => return Err(bad(cmd, "an occurrence must be an id or a name")),
|
||||
};
|
||||
if key == "0" || key.eq_ignore_ascii_case("root") {
|
||||
return Ok(0);
|
||||
}
|
||||
if let Some(o) = s.doc.occurrences.iter().find(|o| o.id.to_string() == key || o.name == key) {
|
||||
return Ok(o.id);
|
||||
}
|
||||
// A component name: its first occurrence.
|
||||
s.doc.find_component(&key).and_then(|c| s.doc.occurrence_of(c)).map(|o| o.id).ok_or_else(|| bad(cmd, format!("no occurrence `{key}`")))
|
||||
}
|
||||
|
||||
/// The occurrence whose bodies are nearest a world point (0 = root bodies).
|
||||
pub(crate) 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 {
|
||||
let comp = s.doc.body_component(&b.name, b.feature);
|
||||
let occs: Vec<u64> = if comp == 0 { vec![0] } else { s.doc.occurrences.iter().filter(|o| o.component == comp).map(|o| o.id).collect() };
|
||||
for occ in occs {
|
||||
let t = if occ == 0 { solvecraft_doc::IDENTITY } else { joints::occurrence_world(&s.doc, occ) };
|
||||
let inv = solvecraft_doc::mat_inverse(&t)?;
|
||||
let q = solvecraft_doc::apply_point(&inv, p);
|
||||
let bb = b.mesh().bounds();
|
||||
let c = Vec3::new(q.x.clamp(bb.min.x, bb.max.x), q.y.clamp(bb.min.y, bb.max.y), q.z.clamp(bb.min.z, bb.max.z));
|
||||
let d = c.dist(q);
|
||||
if best.is_none_or(|(bd, _)| d < bd) {
|
||||
best = Some((d, occ));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, o)| o)
|
||||
}
|
||||
|
||||
/// A joint origin from parameters: a named origin, or a snap given in world coordinates.
|
||||
fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<JointOrigin> {
|
||||
let v = v.ok_or_else(|| bad(cmd, "joint origins `a` and `b` are required"))?;
|
||||
if let Value::String(name) = v {
|
||||
return s
|
||||
.doc
|
||||
.assembly
|
||||
.origins
|
||||
.iter()
|
||||
.find(|o| &o.name == name)
|
||||
.map(|o| o.origin.clone())
|
||||
.ok_or_else(|| bad(cmd, format!("no joint origin `{name}`")));
|
||||
}
|
||||
let pick = ["face", "circle", "point"].iter().find_map(|k| v.get(*k).and_then(vec3).map(|p| (*k, p)));
|
||||
let (what, world) = match (pick, v.get("frame")) {
|
||||
(Some(x), _) => x,
|
||||
(None, Some(f)) => ("frame", f.get("origin").and_then(vec3).ok_or_else(|| bad(cmd, "`frame` needs origin and z"))?),
|
||||
_ => return Err(bad(cmd, "a joint origin needs face, circle, point or frame [x, y, z]")),
|
||||
};
|
||||
let occurrence = match v.get("occurrence") {
|
||||
Some(o) => occurrence_id(s, Some(o), cmd)?,
|
||||
None => occurrence_at(s, world).ok_or_else(|| bad(cmd, "no body there"))?,
|
||||
};
|
||||
// World → the component's own coordinates.
|
||||
let t = if occurrence == 0 { solvecraft_doc::IDENTITY } else { joints::occurrence_world(&s.doc, occurrence) };
|
||||
let inv = solvecraft_doc::mat_inverse(&t).ok_or_else(|| bad(cmd, "occurrence transform"))?;
|
||||
let local = |p: Vec3| solvecraft_doc::apply_point(&inv, p);
|
||||
let local_dir = |d: Vec3| solvecraft_doc::apply_vector(&inv, d);
|
||||
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());
|
||||
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) },
|
||||
_ => {
|
||||
let f = v.get("frame").cloned().unwrap_or_default();
|
||||
let z = f.get("z").and_then(vec3).ok_or_else(|| bad(cmd, "`frame` needs a z axis"))?;
|
||||
Snap::Frame { origin: local(world), z: local_dir(z), x: f.get("x").and_then(vec3).map(local_dir) }
|
||||
}
|
||||
};
|
||||
let o = JointOrigin { occurrence, snap };
|
||||
joints::resolve_origin(&s.doc, &s.model.state(), &o).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
pub(crate) fn joint_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64> {
|
||||
let key = match v {
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`joint` must be an id or a name")),
|
||||
};
|
||||
s.doc.assembly.joints.iter().find(|j| j.id.to_string() == key || j.name == key).map(|j| j.id).ok_or_else(|| bad(cmd, format!("no joint `{key}`")))
|
||||
}
|
||||
|
||||
/// A joint value: numbers are degrees for angles and mm for distances; text is an expression.
|
||||
pub(crate) fn value(s: &Session, v: &Value, angle: bool, cmd: &str) -> Result<f64> {
|
||||
let kind = if angle { Kind::Angle } else { Kind::Length };
|
||||
let e = match v {
|
||||
Value::Number(n) => n.as_f64().filter(|x| x.is_finite() && x.abs() < 1e9).map(|x| x.to_string()),
|
||||
Value::String(t) => Some(t.clone()),
|
||||
_ => None,
|
||||
}
|
||||
.ok_or_else(|| bad(cmd, "values must be numbers or expressions"))?;
|
||||
s.doc.eval(&e, kind).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn values_param(s: &Session, p: &Value, kind: JointKind, cmd: &str) -> Result<Vec<f64>> {
|
||||
let n = kind.dofs().len();
|
||||
let list: Vec<Value> = match (p.get("values"), p.get("value")) {
|
||||
(Some(Value::Array(a)), _) => a.clone(),
|
||||
(_, Some(v)) => vec![v.clone()],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
if list.len() > n {
|
||||
return Err(bad(cmd, format!("a {kind:?} joint has {n} values ({})", kind.dofs().join(", "))));
|
||||
}
|
||||
let mut out = vec![0.0; n];
|
||||
for (i, v) in list.iter().enumerate() {
|
||||
if let Some(slot) = out.get_mut(i) {
|
||||
*slot = value(s, v, kind.is_angle(i), cmd)?;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn limits_param(s: &Session, p: &Value, kind: JointKind, cmd: &str) -> Result<Vec<Option<(f64, f64)>>> {
|
||||
let n = kind.dofs().len();
|
||||
let mut out = vec![None; n];
|
||||
if let Some(Value::Array(a)) = p.get("limits") {
|
||||
if a.len() > n {
|
||||
return Err(bad(cmd, "more limits than values"));
|
||||
}
|
||||
for (i, l) in a.iter().enumerate() {
|
||||
if let (Some(lo), Some(hi)) = (l.get(0), l.get(1))
|
||||
&& let Some(slot) = out.get_mut(i)
|
||||
{
|
||||
*slot = Some((value(s, lo, kind.is_angle(i), cmd)?, value(s, hi, kind.is_angle(i), cmd)?));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn next_joint_id(s: &Session) -> u64 {
|
||||
s.doc.assembly.joints.iter().map(|j| j.id).max().unwrap_or(0) + 1
|
||||
}
|
||||
|
||||
fn add_joint(s: &mut Session, j: Joint) -> Result<Value> {
|
||||
if s.doc.assembly.joints.len() >= 10_000 {
|
||||
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"));
|
||||
}
|
||||
let id = j.id;
|
||||
let name = j.name.clone();
|
||||
s.doc_mut().assembly.joints.push(j);
|
||||
let sol = resolve(s);
|
||||
let mut out = sol_json(&sol);
|
||||
out["joint"] = json!(id);
|
||||
out["name"] = json!(name);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn joint_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "joint.origin";
|
||||
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(),
|
||||
None => (1..).map(|k| format!("Joint Origin{k}")).find(|n| !s.doc.assembly.origins.iter().any(|o| &o.name == n)).unwrap_or_default(),
|
||||
};
|
||||
if s.doc.assembly.origins.iter().any(|o| o.name == name) {
|
||||
return Err(bad(cmd, format!("there is already a joint origin `{name}`")));
|
||||
}
|
||||
s.doc_mut().assembly.origins.push(NamedOrigin { name: name.clone(), origin });
|
||||
Ok(json!({"origin": name}))
|
||||
}
|
||||
|
||||
fn kind_param(p: &Value, cmd: &str) -> Result<JointKind> {
|
||||
str_(p, "type")
|
||||
.and_then(JointKind::parse)
|
||||
.ok_or_else(|| bad(cmd, "`type` must be rigid, revolute, slider, cylindrical, pin_slot, planar or ball"))
|
||||
}
|
||||
|
||||
fn joint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "joint.create";
|
||||
let kind = kind_param(p, cmd)?;
|
||||
let a = origin_param(s, p.get("a"), cmd)?;
|
||||
let b = origin_param(s, p.get("b"), cmd)?;
|
||||
let id = next_joint_id(s);
|
||||
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Joint{id}"));
|
||||
let offset = match p.get("offset") {
|
||||
Some(v) => value(s, v, false, cmd)?,
|
||||
None => 0.0,
|
||||
};
|
||||
let angle = match p.get("angle") {
|
||||
Some(v) => value(s, v, true, cmd)?,
|
||||
None => 0.0,
|
||||
};
|
||||
let j = Joint {
|
||||
id,
|
||||
name,
|
||||
kind,
|
||||
a,
|
||||
b,
|
||||
values: values_param(s, p, kind, cmd)?,
|
||||
limits: limits_param(s, p, kind, cmd)?,
|
||||
flip: bool_(p, "flip").unwrap_or(false),
|
||||
offset,
|
||||
angle,
|
||||
suppressed: false,
|
||||
};
|
||||
add_joint(s, j)
|
||||
}
|
||||
|
||||
fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "joint.as_built";
|
||||
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.
|
||||
let w = match p.get("at") {
|
||||
Some(v) => {
|
||||
let o = origin_param(s, Some(v), cmd)?;
|
||||
s.doc.origin_world(&s.model.state(), &o).map_err(|e| bad(cmd, e.to_string()))?
|
||||
}
|
||||
None => joints::occurrence_world(&s.doc, ob),
|
||||
};
|
||||
let fa = s.doc.frame_in_occurrence(oa, &w).ok_or_else(|| bad(cmd, "occurrence transform"))?;
|
||||
let fb = s.doc.frame_in_occurrence(ob, &w).ok_or_else(|| bad(cmd, "occurrence transform"))?;
|
||||
let id = next_joint_id(s);
|
||||
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Joint{id}"));
|
||||
let n = kind.dofs().len();
|
||||
let j = Joint {
|
||||
id,
|
||||
name,
|
||||
kind,
|
||||
a: JointOrigin { occurrence: oa, snap: fa },
|
||||
b: JointOrigin { occurrence: ob, snap: fb },
|
||||
values: vec![0.0; n],
|
||||
limits: limits_param(s, p, kind, cmd)?,
|
||||
flip: true, // as built: the origins coincide as they are, not mated
|
||||
offset: 0.0,
|
||||
angle: 0.0,
|
||||
suppressed: false,
|
||||
};
|
||||
add_joint(s, j)
|
||||
}
|
||||
|
||||
fn rigid_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "joint.rigid_group";
|
||||
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"));
|
||||
}
|
||||
let ids: Vec<u64> = list.iter().map(|v| occurrence_id(s, Some(v), cmd)).collect::<Result<_>>()?;
|
||||
let first = ids.first().copied().unwrap_or(0);
|
||||
let mut made = Vec::new();
|
||||
for other in ids.iter().skip(1) {
|
||||
let r = as_built(s, &json!({"type": "rigid", "a": first, "b": other, "name": format!("Rigid Group {first}:{other}")}))?;
|
||||
made.push(r["joint"].clone());
|
||||
}
|
||||
Ok(json!({"joints": made}))
|
||||
}
|
||||
|
||||
fn drive(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "joint.drive";
|
||||
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 {
|
||||
return Err(bad(cmd, "a rigid joint does not move"));
|
||||
}
|
||||
let mut q = values_param(s, p, kind, cmd)?;
|
||||
if p.get("values").is_none() && p.get("value").is_none() {
|
||||
return Err(bad(cmd, "give `value` or `values`"));
|
||||
}
|
||||
let doc = s.doc_mut();
|
||||
let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == id) else { return Err(bad(cmd, "joint")) };
|
||||
// Values not given keep theirs.
|
||||
if let Some(Value::Array(a)) = p.get("values") {
|
||||
for (i, v) in q.iter_mut().enumerate().skip(a.len()) {
|
||||
*v = j.values.get(i).copied().unwrap_or(*v);
|
||||
}
|
||||
}
|
||||
joints::clamp(j, &mut q);
|
||||
// Moved as far toward `q` as contact sets allow.
|
||||
let r = super::motion::drive_joint(s, id, q)?;
|
||||
let sol = joints::solve(&s.doc, &s.model.state());
|
||||
let mut out = sol_json(&sol);
|
||||
out["joint"] = json!(id);
|
||||
out["values"] = json!(s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.values.clone()));
|
||||
out["stopped_by_contact"] = r["stopped_by_contact"].clone();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn motion_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "joint.motion_link";
|
||||
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"));
|
||||
}
|
||||
let ratio = num(p, "ratio").ok_or_else(|| bad(cmd, "`ratio` must be a number"))?;
|
||||
let (ia, ib) = (num(p, "ia").unwrap_or(0.0).max(0.0) as usize, num(p, "ib").unwrap_or(0.0).max(0.0) as usize);
|
||||
let dofs = |id: u64| s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.kind.dofs().len()).unwrap_or(0);
|
||||
if ia >= dofs(a) || ib >= dofs(b) {
|
||||
return Err(bad(cmd, "the linked joints need moving values at those indices"));
|
||||
}
|
||||
let offset = num(p, "offset").unwrap_or(0.0);
|
||||
s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset });
|
||||
let sol = resolve(s);
|
||||
Ok(sol_json(&sol))
|
||||
}
|
||||
|
||||
fn limits(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "joint.limits";
|
||||
let id = joint_id(s, p.get("joint"), cmd)?;
|
||||
let i = num(p, "index").unwrap_or(0.0).max(0.0) as usize;
|
||||
let kind = s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.kind).ok_or_else(|| bad(cmd, "joint"))?;
|
||||
if i >= kind.dofs().len() {
|
||||
return Err(bad(cmd, "no such value"));
|
||||
}
|
||||
let lim = match (p.get("min"), p.get("max")) {
|
||||
(Some(lo), Some(hi)) => Some((value(s, lo, kind.is_angle(i), cmd)?, value(s, hi, kind.is_angle(i), cmd)?)),
|
||||
(None, None) => None,
|
||||
_ => return Err(bad(cmd, "give both `min` and `max`, or neither to clear")),
|
||||
};
|
||||
let doc = s.doc_mut();
|
||||
let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == id) else { return Err(bad(cmd, "joint")) };
|
||||
j.limits.resize(kind.dofs().len(), None);
|
||||
if let Some(slot) = j.limits.get_mut(i) {
|
||||
*slot = lim;
|
||||
}
|
||||
let mut q = j.values.clone();
|
||||
q.resize(kind.dofs().len(), 0.0);
|
||||
joints::clamp(j, &mut q);
|
||||
j.values = q;
|
||||
let sol = resolve(s);
|
||||
Ok(sol_json(&sol))
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "joint.edit";
|
||||
let id = joint_id(s, p.get("joint"), cmd)?;
|
||||
let kind = match str_(p, "type") {
|
||||
Some(t) => Some(JointKind::parse(t).ok_or_else(|| bad(cmd, "unknown joint type"))?),
|
||||
None => None,
|
||||
};
|
||||
let offset = match p.get("offset") {
|
||||
Some(v) => Some(value(s, v, false, cmd)?),
|
||||
None => None,
|
||||
};
|
||||
let angle = match p.get("angle") {
|
||||
Some(v) => Some(value(s, v, true, cmd)?),
|
||||
None => None,
|
||||
};
|
||||
let doc = s.doc_mut();
|
||||
let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == id) else { return Err(bad(cmd, "joint")) };
|
||||
if let Some(k) = kind {
|
||||
j.kind = k;
|
||||
j.values.resize(k.dofs().len(), 0.0);
|
||||
j.limits.resize(k.dofs().len(), None);
|
||||
}
|
||||
if let Some(o) = offset {
|
||||
j.offset = o;
|
||||
}
|
||||
if let Some(a) = angle {
|
||||
j.angle = a;
|
||||
}
|
||||
if let Some(f) = bool_(p, "flip") {
|
||||
j.flip = f;
|
||||
}
|
||||
if let Some(x) = bool_(p, "suppressed") {
|
||||
j.suppressed = x;
|
||||
}
|
||||
if let Some(n) = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128) {
|
||||
j.name = n.to_string();
|
||||
}
|
||||
let sol = resolve(s);
|
||||
Ok(sol_json(&sol))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "joint.delete";
|
||||
if let Some(name) = str_(p, "origin") {
|
||||
let used = s.doc.assembly.joints.len();
|
||||
let doc = s.doc_mut();
|
||||
let before = doc.assembly.origins.len();
|
||||
doc.assembly.origins.retain(|o| o.name != name);
|
||||
if doc.assembly.origins.len() == before {
|
||||
return Err(bad(cmd, format!("no joint origin `{name}`")));
|
||||
}
|
||||
return Ok(json!({"deleted_origin": name, "joints": used}));
|
||||
}
|
||||
if let Some(i) = num(p, "link") {
|
||||
let i = i.max(0.0) as usize;
|
||||
let doc = s.doc_mut();
|
||||
if i >= doc.assembly.links.len() {
|
||||
return Err(bad(cmd, "no such motion link"));
|
||||
}
|
||||
doc.assembly.links.remove(i);
|
||||
return Ok(json!({"deleted_link": i}));
|
||||
}
|
||||
let id = joint_id(s, p.get("joint"), cmd)?;
|
||||
let doc = s.doc_mut();
|
||||
doc.assembly.joints.retain(|j| j.id != id);
|
||||
doc.assembly.links.retain(|l| l.a != id && l.b != id);
|
||||
let sol = resolve(s);
|
||||
let mut out = sol_json(&sol);
|
||||
out["deleted"] = json!(id);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let sol = joints::solve(&s.doc, &st);
|
||||
let vals = joints::joint_values(&s.doc.assembly);
|
||||
let js: Vec<Value> = s
|
||||
.doc
|
||||
.assembly
|
||||
.joints
|
||||
.iter()
|
||||
.map(|j| {
|
||||
let q = vals.get(&j.id).cloned().unwrap_or_default();
|
||||
let named: Vec<Value> = j
|
||||
.kind
|
||||
.dofs()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let v = q.get(i).copied().unwrap_or(0.0);
|
||||
json!({"name": n, "value": if j.kind.is_angle(i) { v.to_degrees() } else { v }, "unit": if j.kind.is_angle(i) { "deg" } else { "mm" },
|
||||
"limits": j.limits.get(i).copied().flatten().map(|(a, b)| if j.kind.is_angle(i) { [a.to_degrees(), b.to_degrees()] } else { [a, b] })})
|
||||
})
|
||||
.collect();
|
||||
let world = |o: &JointOrigin| s.doc.origin_world(&st, o).ok().map(|m| json!({"origin": [m[3][0], m[3][1], m[3][2]], "z": [m[2][0], m[2][1], m[2][2]]}));
|
||||
json!({"id": j.id, "name": j.name, "type": j.kind, "a": j.a.occurrence, "b": j.b.occurrence, "values": named,
|
||||
"flip": j.flip, "offset": j.offset, "suppressed": j.suppressed, "frame_a": world(&j.a), "frame_b": world(&j.b)})
|
||||
})
|
||||
.collect();
|
||||
let links: Vec<Value> =
|
||||
s.doc.assembly.links.iter().map(|l| json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": l.ratio, "offset": l.offset})).collect();
|
||||
let origins: Vec<Value> = s.doc.assembly.origins.iter().map(|o| json!({"name": o.name, "occurrence": o.origin.occurrence})).collect();
|
||||
let mut out = sol_json(&sol);
|
||||
out["joints"] = json!(js);
|
||||
out["links"] = json!(links);
|
||||
out["origins"] = json!(origins);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn solve_cmd(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let sol = resolve(s);
|
||||
Ok(sol_json(&sol))
|
||||
}
|
||||
|
||||
fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.interference";
|
||||
let want = string_list(p, "bodies");
|
||||
let st = s.world_state();
|
||||
let bodies: Vec<&solvecraft_doc::ModelBody> =
|
||||
st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).filter(|b| !b.body.is_mesh()).collect();
|
||||
if bodies.len() > 200 {
|
||||
return Err(bad(cmd, "too many bodies (200 at most)"));
|
||||
}
|
||||
let mut hits = Vec::new();
|
||||
for (i, a) in bodies.iter().enumerate() {
|
||||
for b in bodies.iter().skip(i + 1) {
|
||||
let (ba, bb) = (a.mesh().bounds(), b.mesh().bounds());
|
||||
let apart = ba.max.x < bb.min.x
|
||||
|| bb.max.x < ba.min.x
|
||||
|| ba.max.y < bb.min.y
|
||||
|| bb.max.y < ba.min.y
|
||||
|| ba.max.z < bb.min.z
|
||||
|| bb.max.z < ba.min.z;
|
||||
if apart {
|
||||
continue;
|
||||
}
|
||||
let Ok(Some(o)) = solvecraft_kernel::boolean(&a.body, &b.body, solvecraft_kernel::BoolOp::Intersect) else { continue };
|
||||
let Ok(m) = solvecraft_kernel::measure(&o) else { continue };
|
||||
if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) {
|
||||
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len()}))
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
|
||||
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 occ(s: &Session, component: &str) -> u64 {
|
||||
let c = s.doc.find_component(component).unwrap_or(0);
|
||||
s.doc.occurrence_of(c).map(|o| o.id).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn place(s: &Session, component: &str, p: Vec3) -> Vec3 {
|
||||
solvecraft_doc::apply_point(&solvecraft_doc::joints::occurrence_world(&s.doc, occ(s, component)), p)
|
||||
}
|
||||
|
||||
fn near(a: Vec3, b: Vec3) -> bool {
|
||||
a.dist(b) < 1e-6
|
||||
}
|
||||
|
||||
/// A hinge leaf (oracle 63's knuckle geometry): a 40 x 30 x 3 plate and an r4 knuckle along X
|
||||
/// 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"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolute_hinge_turns_about_the_knuckle() {
|
||||
let mut s = Session::default();
|
||||
leaf(&mut s, "LeafA");
|
||||
leaf(&mut s, "LeafB");
|
||||
let o = occ(&s, "LeafA");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
|
||||
let r = run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({
|
||||
"type": "revolute", "a": {"occurrence": "LeafA", "circle": [1, 37, 4]}, "b": {"occurrence": "LeafB", "circle": [1, 37, 4]}, "flip": true, "name": "Hinge"}),
|
||||
);
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
// Same frames: B stays put at angle 0.
|
||||
assert!(near(place(&s, "LeafB", Vec3::new(20.0, 0.0, 0.0)), Vec3::new(20.0, 0.0, 0.0)));
|
||||
let l = run(&mut s, "joint.list", json!({}));
|
||||
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}));
|
||||
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));
|
||||
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 29.0, 37.0)));
|
||||
// LeafA did not move; the knuckle axis is fixed.
|
||||
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"}));
|
||||
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 66.0, 8.0)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(near(place(&s, "LeafB", p), Vec3::new(20.0, 29.0, 37.0)));
|
||||
}
|
||||
|
||||
/// Two 10 mm cubes in components A (grounded) and B.
|
||||
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]}));
|
||||
}
|
||||
let o = occ(&s, "A");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slider_limits_clamp() {
|
||||
let mut s = two_cubes();
|
||||
// B's bottom face onto A's top face (faces meet), sliding along their common normal.
|
||||
let r = run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
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}));
|
||||
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}));
|
||||
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, "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.
|
||||
let h = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.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"}));
|
||||
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)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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, "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"}));
|
||||
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!({}));
|
||||
assert!(l["dof"].as_array().unwrap().iter().all(|d| d["dof"] == 0), "{l}");
|
||||
// A joint that wants C elsewhere conflicts and says so.
|
||||
let r = run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
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();
|
||||
assert_eq!(c.len(), 1, "{r}");
|
||||
assert!(c[0]["why"].as_str().unwrap_or("").contains("off by"), "{r}");
|
||||
// Suppressing it clears the conflict.
|
||||
let r = run(&mut s, "joint.edit", json!({"joint": "Wrong", "suppressed": true}));
|
||||
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0));
|
||||
assert!(
|
||||
s.execute(
|
||||
"joint.create",
|
||||
&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());
|
||||
}
|
||||
|
||||
#[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]}));
|
||||
// No overlap while B sits on A; then push B 4 mm into A.
|
||||
let i = run(&mut s, "inspect.interference", 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!({}));
|
||||
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,
|
||||
"joint.create",
|
||||
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]}));
|
||||
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();
|
||||
assert_eq!(back.assembly, s.doc.assembly);
|
||||
let mut t = Session::new(back);
|
||||
let l = run(&mut t, "joint.list", json!({}));
|
||||
assert_eq!(l["joints"].as_array().map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
/// Two faces joined mate: B's bottom face lands on A's top face, B on top of A (not inside
|
||||
/// it); Flip turns B over instead.
|
||||
#[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"}));
|
||||
// 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)));
|
||||
run(&mut s, "joint.edit", json!({"joint": "Stack", "flip": true}));
|
||||
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 0.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,177 +0,0 @@
|
||||
//! Measuring picked geometry (`inspect.measure` 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.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::{Mesh, Vec3};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
/// Geometry of a picked item, as points or triangles.
|
||||
enum Geo {
|
||||
Points(Vec<Vec3>),
|
||||
Tris(Vec<[Vec3; 3]>),
|
||||
}
|
||||
|
||||
/// Caps that keep a measurement interactive on big meshes.
|
||||
const MAX_POINTS: usize = 4000;
|
||||
const MAX_TRIS: usize = 20000;
|
||||
|
||||
fn closest_on_triangle(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
let n = (b - a).cross(c - a);
|
||||
if let Some(nn) = n.normalized() {
|
||||
let q = p - nn * (p - a).dot(nn);
|
||||
if [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-12) {
|
||||
return q;
|
||||
}
|
||||
}
|
||||
let on_seg = |u: Vec3, v: Vec3| {
|
||||
let d = v - u;
|
||||
let l2 = d.dot(d);
|
||||
let t = if l2 > 0.0 { ((p - u).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
u + d * t
|
||||
};
|
||||
[on_seg(a, b), on_seg(b, c), on_seg(c, a)].into_iter().min_by(|x, y| x.dist(p).total_cmp(&y.dist(p))).unwrap_or(a)
|
||||
}
|
||||
|
||||
fn polyline_len(pts: &[Vec3]) -> f64 {
|
||||
pts.windows(2).map(|w| w[0].dist(w[1])).sum()
|
||||
}
|
||||
|
||||
/// A polyline with extra points so no gap is longer than 1/64 of it.
|
||||
fn densify(pts: &[Vec3]) -> Vec<Vec3> {
|
||||
let step = (polyline_len(pts) / 64.0).max(1e-9);
|
||||
let mut out = Vec::new();
|
||||
for w in pts.windows(2) {
|
||||
let n = ((w[0].dist(w[1]) / step).ceil() as usize).clamp(1, 64);
|
||||
for k in 0..n {
|
||||
out.push(w[0].lerp(w[1], k as f64 / n as f64));
|
||||
}
|
||||
}
|
||||
out.extend(pts.last().copied());
|
||||
out
|
||||
}
|
||||
|
||||
/// The mesh edge a selection means: by index if it still passes through the point, otherwise
|
||||
/// the nearest.
|
||||
fn edge_points(m: &Mesh, index: usize, point: Vec3) -> Option<Vec<Vec3>> {
|
||||
let near = |e: &Vec<Vec3>| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||||
let tol = m.bounds().diagonal().max(1.0) * 1e-6;
|
||||
if let Some(e) = m.edges.get(index).filter(|e| near(e) < tol) {
|
||||
return Some(e.clone());
|
||||
}
|
||||
m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).cloned()
|
||||
}
|
||||
|
||||
fn face_tris(m: &Mesh, f: usize) -> Vec<[Vec3; 3]> {
|
||||
m.triangles.iter().zip(&m.tri_face).filter(|(_, tf)| **tf as usize == f).filter_map(|(t, _)| m.tri(t)).collect()
|
||||
}
|
||||
|
||||
/// Geometry, direction (planar face normal, straight edge direction) and a description.
|
||||
fn describe(s: &Session, sel: &Sel) -> Option<(Geo, Option<Vec3>, Value)> {
|
||||
let st = s.world_state();
|
||||
Some(match sel {
|
||||
Sel::Vertex { point, .. } => (Geo::Points(vec![*point]), None, json!({"type": "vertex", "position": point})),
|
||||
Sel::Edge { body, index, point } => {
|
||||
let m = st.body(body)?.mesh();
|
||||
let pts = edge_points(&m, *index, *point)?;
|
||||
let (a, b) = (*pts.first()?, *pts.last()?);
|
||||
let straight = (b - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(b))));
|
||||
let len = polyline_len(&pts);
|
||||
(Geo::Points(densify(&pts)), straight, json!({"type": "edge", "length_mm": len}))
|
||||
}
|
||||
Sel::Face { body, index, .. } => {
|
||||
let m = st.body(body)?.mesh();
|
||||
let tris = face_tris(&m, *index);
|
||||
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
|
||||
let normals: Vec<Vec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect();
|
||||
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
|
||||
(Geo::Tris(tris), planar, json!({"type": "face", "area_mm2": area, "planar": planar.is_some()}))
|
||||
}
|
||||
Sel::Body { name } => {
|
||||
let m = st.body(name)?.mesh();
|
||||
let tris: Vec<[Vec3; 3]> = m.triangles.iter().filter_map(|t| m.tri(t)).collect();
|
||||
let v = m.measure().volume;
|
||||
(Geo::Tris(tris), None, json!({"type": "body", "name": name, "volume_mm3": v}))
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn sample<T: Copy>(v: &[T], max: usize) -> Vec<T> {
|
||||
let step = v.len().div_ceil(max).max(1);
|
||||
v.iter().step_by(step).copied().collect()
|
||||
}
|
||||
|
||||
/// Closest pair of points between two pieces of geometry.
|
||||
fn closest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> {
|
||||
let pts_to_tris = |pts: &[Vec3], tris: &[[Vec3; 3]]| -> Option<(Vec3, Vec3)> {
|
||||
let pts = sample(pts, MAX_POINTS);
|
||||
let tris = sample(tris, MAX_TRIS);
|
||||
let mut best: Option<(f64, Vec3, Vec3)> = None;
|
||||
for p in &pts {
|
||||
for [x, y, z] in &tris {
|
||||
let q = closest_on_triangle(*p, *x, *y, *z);
|
||||
let d = p.dist(q);
|
||||
if best.is_none_or(|(bd, _, _)| d < bd) {
|
||||
best = Some((d, *p, q));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, p, q)| (p, q))
|
||||
};
|
||||
let verts = |tris: &[[Vec3; 3]]| -> Vec<Vec3> { tris.iter().flat_map(|t| t.iter().copied()).collect() };
|
||||
match (a, b) {
|
||||
(Geo::Points(p), Geo::Points(q)) => {
|
||||
let (p, q) = (sample(p, MAX_POINTS), sample(q, MAX_POINTS));
|
||||
let mut best: Option<(f64, Vec3, Vec3)> = None;
|
||||
for x in &p {
|
||||
for y in &q {
|
||||
let d = x.dist(*y);
|
||||
if best.is_none_or(|(bd, _, _)| d < bd) {
|
||||
best = Some((d, *x, *y));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, x, y)| (x, y))
|
||||
}
|
||||
(Geo::Points(p), Geo::Tris(t)) => pts_to_tris(p, t),
|
||||
(Geo::Tris(t), Geo::Points(p)) => pts_to_tris(p, t).map(|(x, y)| (y, x)),
|
||||
(Geo::Tris(t1), Geo::Tris(t2)) => {
|
||||
let ab = pts_to_tris(&verts(t1), t2);
|
||||
let ba = pts_to_tris(&verts(t2), t1).map(|(x, y)| (y, x));
|
||||
[ab, ba].into_iter().flatten().min_by(|x, y| x.0.dist(x.1).total_cmp(&y.0.dist(y.1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `inspect.measure` with `items` (one or two selections).
|
||||
pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.measure";
|
||||
let list = items.as_array().ok_or_else(|| bad(cmd, "`items` must be a list of selections"))?;
|
||||
if list.is_empty() || list.len() > 2 {
|
||||
return Err(bad(cmd, "measure one or two items"));
|
||||
}
|
||||
let sels: Vec<Sel> = list.iter().map(|v| serde_json::from_value::<Sel>(v.clone()).map_err(|e| bad(cmd, e.to_string()))).collect::<Result<_>>()?;
|
||||
let mut described = Vec::new();
|
||||
for x in &sels {
|
||||
described.push(describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces or bodies)"))?);
|
||||
}
|
||||
let mut out = json!({"items": described.iter().map(|d| d.2.clone()).collect::<Vec<_>>()});
|
||||
if let [(ga, da, _), (gb, db, _)] = described.as_slice() {
|
||||
if let Some((p, q)) = closest(ga, gb) {
|
||||
out["distance_mm"] = json!(p.dist(q));
|
||||
out["from"] = json!(p);
|
||||
out["to"] = json!(q);
|
||||
out["delta"] = json!(q - p);
|
||||
}
|
||||
if let (Some(a), Some(b)) = (da, db) {
|
||||
let both_faces = matches!(sels.as_slice(), [Sel::Face { .. }, Sel::Face { .. }]);
|
||||
let c = a.dot(*b).clamp(-1.0, 1.0);
|
||||
let c = if both_faces { c } else { c.abs() };
|
||||
out["angle_deg"] = json!(c.acos().to_degrees());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
//! 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`).
|
||||
|
||||
mod browser;
|
||||
mod clipboard;
|
||||
mod component;
|
||||
mod construct;
|
||||
mod delete;
|
||||
mod edit;
|
||||
mod face;
|
||||
mod features;
|
||||
mod features_more;
|
||||
pub use features::auto_operation;
|
||||
mod config;
|
||||
mod file;
|
||||
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;
|
||||
mod sketch;
|
||||
mod sketch_3d;
|
||||
mod sketch_canvas;
|
||||
mod sketch_constraints;
|
||||
mod sketch_create;
|
||||
mod sketch_freeform;
|
||||
mod sketch_import;
|
||||
mod sketch_inspect;
|
||||
mod sketch_modify;
|
||||
mod sketch_project;
|
||||
mod surface;
|
||||
mod surface_analysis;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
|
||||
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
|
||||
|
||||
pub struct CommandSpec {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
/// Toolbar tab (`SOLID`, `SKETCH`…); empty = not on the toolbar.
|
||||
pub tab: &'static str,
|
||||
/// Toolbar panel (`CREATE`, `MODIFY`…).
|
||||
pub panel: &'static str,
|
||||
/// Icon name for the UI (drawn in code).
|
||||
pub icon: &'static str,
|
||||
pub shortcut: Option<&'static str>,
|
||||
/// One-line parameter documentation.
|
||||
pub params: &'static str,
|
||||
pub enabled: Enabled,
|
||||
pub run: Run,
|
||||
pub undoable: bool,
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
pub const fn new(id: &'static str, label: &'static str, run: Run) -> Self {
|
||||
CommandSpec { id, label, tab: "", panel: "", icon: "", shortcut: None, params: "", enabled: always, run, undoable: true }
|
||||
}
|
||||
pub const fn at(mut self, tab: &'static str, panel: &'static str) -> Self {
|
||||
self.tab = tab;
|
||||
self.panel = panel;
|
||||
self
|
||||
}
|
||||
pub const fn icon(mut self, i: &'static str) -> Self {
|
||||
self.icon = i;
|
||||
self
|
||||
}
|
||||
pub const fn key(mut self, k: &'static str) -> Self {
|
||||
self.shortcut = Some(k);
|
||||
self
|
||||
}
|
||||
pub const fn params(mut self, p: &'static str) -> Self {
|
||||
self.params = p;
|
||||
self
|
||||
}
|
||||
pub const fn enabled(mut self, e: Enabled) -> Self {
|
||||
self.enabled = e;
|
||||
self
|
||||
}
|
||||
pub const fn noundo(mut self) -> Self {
|
||||
self.undoable = false;
|
||||
self
|
||||
}
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
CommandInfo {
|
||||
id: self.id,
|
||||
label: self.label,
|
||||
tab: self.tab,
|
||||
panel: self.panel,
|
||||
icon: self.icon,
|
||||
shortcut: self.shortcut,
|
||||
params: self.params,
|
||||
enabled: e.is_ok(),
|
||||
disabled_reason: e.err(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CommandInfo {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub tab: &'static str,
|
||||
pub panel: &'static str,
|
||||
pub icon: &'static str,
|
||||
pub shortcut: Option<&'static str>,
|
||||
pub params: &'static str,
|
||||
pub enabled: bool,
|
||||
pub disabled_reason: Option<String>,
|
||||
}
|
||||
|
||||
pub fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn in_sketch(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active_sketch.is_some() { Ok(()) } else { Err("no sketch is being edited (Create Sketch or Edit Sketch first)".into()) }
|
||||
}
|
||||
|
||||
/// Every command, in toolbar order.
|
||||
pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
let mut v: Vec<&'static CommandSpec> = Vec::new();
|
||||
v.extend(sketch::COMMANDS.iter());
|
||||
v.extend(sketch_create::COMMANDS.iter());
|
||||
v.extend(sketch_constraints::COMMANDS.iter());
|
||||
v.extend(sketch_import::COMMANDS.iter());
|
||||
v.extend(sketch_inspect::COMMANDS.iter());
|
||||
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());
|
||||
v.extend(clipboard::COMMANDS.iter());
|
||||
v.extend(parameters::COMMANDS.iter());
|
||||
v.extend(file::COMMANDS.iter());
|
||||
v.extend(inspect::COMMANDS.iter());
|
||||
v.extend(section::COMMANDS.iter());
|
||||
v
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -1,688 +0,0 @@
|
||||
//! Contact Sets, Motion Study and Exploded views.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::Mat;
|
||||
use solvecraft_doc::joints::{self, JointKind};
|
||||
use solvecraft_doc::motion::{ContactSet, ExplodedView, MotionStudy, StudyKey};
|
||||
use solvecraft_geom::Vec3;
|
||||
use solvecraft_kernel::Body;
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::joints::{joint_id, occurrence_id, resolve, value};
|
||||
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(
|
||||
"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)
|
||||
.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)
|
||||
.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(
|
||||
"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)",
|
||||
),
|
||||
CommandSpec::new("motion.play", "Play Motion Study", play)
|
||||
.params("study: id|name; step (0…steps) — sets the joints to the study's values there (contact sets stop them) → positions"),
|
||||
CommandSpec::new("motion.export", "Export Motion Study", export)
|
||||
.noundo()
|
||||
.params("study; samples? (default steps + 1); path? (.json or .csv) → occurrence positions per step (the design is not changed)"),
|
||||
CommandSpec::new("motion.list", "List Motion Studies", list_studies).noundo(),
|
||||
CommandSpec::new("motion.delete", "Delete Motion Study", delete_study).params("study"),
|
||||
CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("joint").params(
|
||||
"name?; scale? (automatic: each occurrence pushed away from the assembly's centre by scale × its offset, default 1); \
|
||||
moves?: [{occurrence, translate: [x,y,z]}] (instead of automatic)",
|
||||
),
|
||||
CommandSpec::new("explode.show", "Show Exploded View", explode_show)
|
||||
.noundo()
|
||||
.params("view: id|name (or none: back to the assembled placement) — shown only; the design keeps its placements"),
|
||||
CommandSpec::new("explode.list", "List Exploded Views", explode_list).noundo(),
|
||||
CommandSpec::new("explode.delete", "Delete Exploded View", explode_delete).params("view"),
|
||||
];
|
||||
|
||||
const MAX_ITEMS: usize = 10_000;
|
||||
|
||||
fn next_id(s: &Session) -> u64 {
|
||||
let a = &s.doc.assembly;
|
||||
let ids = a.contact_sets.iter().map(|c| c.id).chain(a.studies.iter().map(|m| m.id)).chain(a.exploded.iter().map(|e| e.id));
|
||||
ids.max().unwrap_or(0) + 1
|
||||
}
|
||||
|
||||
fn occurrences(s: &Session, p: &Value, cmd: &str) -> Result<Vec<u64>> {
|
||||
let list = p.get("occurrences").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`occurrences` must list occurrences"))?;
|
||||
if list.len() > MAX_ITEMS {
|
||||
return Err(bad(cmd, "too many occurrences"));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for v in list {
|
||||
let id = occurrence_id(s, Some(v), cmd)?;
|
||||
if id == 0 {
|
||||
return Err(bad(cmd, "the root is not an occurrence"));
|
||||
}
|
||||
if !out.contains(&id) {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- Contact ----
|
||||
|
||||
/// The placed bodies of the design, each with the occurrences it sits in.
|
||||
fn placed(s: &Session) -> Vec<(Vec<u64>, Body)> {
|
||||
let st = s.model.state();
|
||||
let mut out = Vec::new();
|
||||
for (comp, path, m) in s.doc.placements() {
|
||||
if comp == 0 {
|
||||
continue;
|
||||
}
|
||||
for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp && !b.body.is_mesh()) {
|
||||
if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, m) {
|
||||
out.push((path.clone(), t));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pairs of bodies in one active contact set that pass through each other: (occurrence names).
|
||||
pub fn collisions(s: &Session) -> Vec<(String, String, f64)> {
|
||||
let a = &s.doc.assembly;
|
||||
if !a.contact_enabled {
|
||||
return Vec::new();
|
||||
}
|
||||
let sets: Vec<Vec<u64>> = if a.contact_all {
|
||||
vec![s.doc.occurrences.iter().map(|o| o.id).collect()]
|
||||
} else {
|
||||
a.contact_sets.iter().filter(|c| !c.suppressed).map(|c| c.occurrences.clone()).collect()
|
||||
};
|
||||
let bodies = placed(s);
|
||||
let name = |id: u64| s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
for set in &sets {
|
||||
// The member of the set each body belongs to (its innermost occurrence in the set).
|
||||
let tagged: Vec<(u64, &Body)> = bodies.iter().filter_map(|(path, b)| path.iter().rev().find(|o| set.contains(o)).map(|o| (*o, b))).collect();
|
||||
for (i, (oa, ba)) in tagged.iter().enumerate() {
|
||||
for (ob, bb) in tagged.iter().skip(i + 1) {
|
||||
if oa == ob {
|
||||
continue;
|
||||
}
|
||||
if let Some(v) = overlap(ba, bb) {
|
||||
out.push((name(*oa), name(*ob), v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// How deep one body reaches into the other, when more than a touch (`None`: apart or
|
||||
/// touching). Points of each tessellation (vertices, edge midpoints, triangle centres) are
|
||||
/// tested against the other closed mesh: inside by ray parity, and deeper than the tolerance.
|
||||
fn overlap(a: &Body, b: &Body) -> Option<f64> {
|
||||
let (ma, mb) = (a.tessellate((a.size() * 1e-3).max(1e-3)).ok()?, b.tessellate((b.size() * 1e-3).max(1e-3)).ok()?);
|
||||
let (ba, bb) = (ma.bounds(), mb.bounds());
|
||||
let tol = 1e-4 * a.size().max(b.size()).max(1.0);
|
||||
if ba.max.x < bb.min.x + tol
|
||||
|| bb.max.x < ba.min.x + tol
|
||||
|| ba.max.y < bb.min.y + tol
|
||||
|| bb.max.y < ba.min.y + tol
|
||||
|| ba.max.z < bb.min.z + tol
|
||||
|| bb.max.z < ba.min.z + tol
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let depth = deepest(&ma, &mb, tol).max(deepest(&mb, &ma, tol));
|
||||
(depth > tol).then_some(depth)
|
||||
}
|
||||
|
||||
fn tris(m: &solvecraft_geom::Mesh) -> Vec<[Vec3; 3]> {
|
||||
m.triangles.iter().filter_map(|t| m.tri(t)).collect()
|
||||
}
|
||||
|
||||
/// The deepest any sample point of `a` lies inside `b` (0 if none).
|
||||
fn deepest(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, tol: f64) -> f64 {
|
||||
let tb = tris(b);
|
||||
let bx = b.bounds();
|
||||
let mut pts: Vec<Vec3> = a.positions.clone();
|
||||
for [p, q, r] in tris(a) {
|
||||
pts.extend([(p + q) * 0.5, (q + r) * 0.5, (r + p) * 0.5, (p + q + r) * (1.0 / 3.0)]);
|
||||
}
|
||||
let mut best = 0.0f64;
|
||||
for p in pts.into_iter().take(200_000) {
|
||||
if p.x < bx.min.x - tol
|
||||
|| p.x > bx.max.x + tol
|
||||
|| p.y < bx.min.y - tol
|
||||
|| p.y > bx.max.y + tol
|
||||
|| p.z < bx.min.z - tol
|
||||
|| p.z > bx.max.z + tol
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let d = tb.iter().map(|[a, b, c]| closest(p, *a, *b, *c).dist(p)).fold(f64::MAX, f64::min);
|
||||
if d > tol.max(best) && inside(p, &tb) {
|
||||
best = d;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Ray parity along a skew direction (no axis-aligned edge hits).
|
||||
fn inside(p: Vec3, tris: &[[Vec3; 3]]) -> bool {
|
||||
let dir = Vec3::new(0.577_215_664_9, 0.327_118_441_9, 0.751_891_438_7);
|
||||
let mut n = 0;
|
||||
for [a, b, c] in tris {
|
||||
let (e1, e2) = (*b - *a, *c - *a);
|
||||
let h = dir.cross(e2);
|
||||
let det = e1.dot(h);
|
||||
if det.abs() < 1e-14 {
|
||||
continue;
|
||||
}
|
||||
let f = 1.0 / det;
|
||||
let sv = p - *a;
|
||||
let u = f * sv.dot(h);
|
||||
if !(0.0..=1.0).contains(&u) {
|
||||
continue;
|
||||
}
|
||||
let q = sv.cross(e1);
|
||||
let v = f * dir.dot(q);
|
||||
if v < 0.0 || u + v > 1.0 {
|
||||
continue;
|
||||
}
|
||||
if f * e2.dot(q) > 0.0 {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n % 2 == 1
|
||||
}
|
||||
|
||||
/// The point of triangle abc nearest p.
|
||||
fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
let (ab, ac, ap) = (b - a, c - a, p - a);
|
||||
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
|
||||
if d1 <= 0.0 && d2 <= 0.0 {
|
||||
return a;
|
||||
}
|
||||
let bp = p - b;
|
||||
let (d3, d4) = (ab.dot(bp), ac.dot(bp));
|
||||
if d3 >= 0.0 && d4 <= d3 {
|
||||
return b;
|
||||
}
|
||||
let vc = d1 * d4 - d3 * d2;
|
||||
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
|
||||
return a + ab * (d1 / (d1 - d3));
|
||||
}
|
||||
let cp = p - c;
|
||||
let (d5, d6) = (ab.dot(cp), ac.dot(cp));
|
||||
if d6 >= 0.0 && d5 <= d6 {
|
||||
return c;
|
||||
}
|
||||
let vb = d5 * d2 - d1 * d6;
|
||||
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
|
||||
return a + ac * (d2 / (d2 - d6));
|
||||
}
|
||||
let va = d3 * d6 - d5 * d4;
|
||||
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
|
||||
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
|
||||
}
|
||||
let den = va + vb + vc;
|
||||
if den.abs() < 1e-300 {
|
||||
return a;
|
||||
}
|
||||
a + ab * (vb / den) + ac * (vc / den)
|
||||
}
|
||||
|
||||
/// Set joint values, stopping short where contact sets would collide: the values are moved
|
||||
/// from where they are toward `want` as far as they go without a collision.
|
||||
fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)]) -> Result<Value> {
|
||||
let start: Vec<(u64, Vec<f64>)> =
|
||||
want.iter().map(|(id, _)| (*id, s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone()).unwrap_or_default())).collect();
|
||||
let set = |s: &mut Session, t: f64| {
|
||||
let doc = s.doc_mut();
|
||||
for ((id, a), (_, b)) in start.iter().zip(want) {
|
||||
if let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == *id) {
|
||||
let mut q: Vec<f64> = b.iter().enumerate().map(|(i, bv)| a.get(i).copied().unwrap_or(*bv) * (1.0 - t) + bv * t).collect();
|
||||
joints::clamp(j, &mut q);
|
||||
j.values = q;
|
||||
}
|
||||
}
|
||||
resolve(s)
|
||||
};
|
||||
let mut sol = set(s, 1.0);
|
||||
let mut stopped = None;
|
||||
if s.doc.assembly.contact_enabled
|
||||
&& let Some(hit) = collisions(s).into_iter().next()
|
||||
{
|
||||
// Already colliding where it started: don't search, just report.
|
||||
set(s, 0.0);
|
||||
if collisions(s).is_empty() {
|
||||
let (mut lo, mut hi) = (0.0, 1.0);
|
||||
for _ in 0..14 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
set(s, mid);
|
||||
if collisions(s).is_empty() { lo = mid } else { hi = mid }
|
||||
}
|
||||
sol = set(s, lo);
|
||||
stopped = Some(json!({"a": hit.0, "b": hit.1, "fraction": lo}));
|
||||
} else {
|
||||
sol = set(s, 1.0);
|
||||
}
|
||||
}
|
||||
let values: Vec<Value> = want
|
||||
.iter()
|
||||
.map(|(id, _)| json!({"joint": id, "values": s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone())}))
|
||||
.collect();
|
||||
Ok(json!({"values": values, "stopped_by_contact": stopped, "conflicts": sol.conflicts.len()}))
|
||||
}
|
||||
|
||||
/// Drive one joint (joint.drive), honouring contact sets.
|
||||
pub fn drive_joint(s: &mut Session, id: u64, q: Vec<f64>) -> Result<Value> {
|
||||
drive_with_contact(s, &[(id, q)])
|
||||
}
|
||||
|
||||
fn enable_sets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = bool_(p, "enabled").unwrap_or(true);
|
||||
s.doc_mut().assembly.contact_enabled = on;
|
||||
Ok(json!({"enabled": on}))
|
||||
}
|
||||
|
||||
fn enable_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let a = &mut s.doc_mut().assembly;
|
||||
a.contact_enabled = true;
|
||||
a.contact_all = true;
|
||||
Ok(json!({"enabled": true, "all": true}))
|
||||
}
|
||||
|
||||
fn disable_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let a = &mut s.doc_mut().assembly;
|
||||
a.contact_enabled = false;
|
||||
a.contact_all = false;
|
||||
Ok(json!({"enabled": false}))
|
||||
}
|
||||
|
||||
fn new_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "contact.create";
|
||||
let occ = occurrences(s, p, cmd)?;
|
||||
if occ.len() < 2 {
|
||||
return Err(bad(cmd, "a contact set needs two or more occurrences"));
|
||||
}
|
||||
if s.doc.assembly.contact_sets.len() >= MAX_ITEMS {
|
||||
return Err(bad(cmd, "too many contact sets"));
|
||||
}
|
||||
let id = next_id(s);
|
||||
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Contact Set{}", s.doc.assembly.contact_sets.len() + 1));
|
||||
let a = &mut s.doc_mut().assembly;
|
||||
a.contact_sets.push(ContactSet { id, name: name.clone(), occurrences: occ, suppressed: false });
|
||||
a.contact_enabled = true;
|
||||
Ok(json!({"set": id, "name": name}))
|
||||
}
|
||||
|
||||
fn set_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let key = match p.get("set") {
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`set` must be an id or a name")),
|
||||
};
|
||||
s.doc
|
||||
.assembly
|
||||
.contact_sets
|
||||
.iter()
|
||||
.position(|c| c.id.to_string() == key || c.name == key)
|
||||
.ok_or_else(|| bad(cmd, format!("no contact set `{key}`")))
|
||||
}
|
||||
|
||||
fn edit_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "contact.edit";
|
||||
let i = set_index(s, p, cmd)?;
|
||||
if bool_(p, "delete").unwrap_or(false) {
|
||||
s.doc_mut().assembly.contact_sets.remove(i);
|
||||
return Ok(json!({"deleted": true}));
|
||||
}
|
||||
let occ = if p.get("occurrences").is_some() { Some(occurrences(s, p, cmd)?) } else { None };
|
||||
if occ.as_ref().is_some_and(|o| o.len() < 2) {
|
||||
return Err(bad(cmd, "a contact set needs two or more occurrences"));
|
||||
}
|
||||
let Some(c) = s.doc_mut().assembly.contact_sets.get_mut(i) else { return Err(bad(cmd, "set")) };
|
||||
if let Some(o) = occ {
|
||||
c.occurrences = o;
|
||||
}
|
||||
if let Some(n) = str_(p, "name").filter(|n| !n.trim().is_empty()) {
|
||||
c.name = n.to_string();
|
||||
}
|
||||
if let Some(x) = bool_(p, "suppressed") {
|
||||
c.suppressed = x;
|
||||
}
|
||||
Ok(json!({"set": c.id}))
|
||||
}
|
||||
|
||||
fn list_sets(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let a = &s.doc.assembly;
|
||||
Ok(json!({"enabled": a.contact_enabled, "all": a.contact_all, "sets": a.contact_sets}))
|
||||
}
|
||||
|
||||
fn check(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let c = collisions(s);
|
||||
Ok(json!({"collisions": c.iter().map(|(a, b, d)| json!({"a": a, "b": b, "depth_mm": d})).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
// ---- Motion studies ----
|
||||
|
||||
fn study_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let key = match p.get("study") {
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`study` must be an id or a name")),
|
||||
};
|
||||
s.doc.assembly.studies.iter().position(|m| m.id.to_string() == key || m.name == key).ok_or_else(|| bad(cmd, format!("no motion study `{key}`")))
|
||||
}
|
||||
|
||||
fn study(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "motion.study";
|
||||
let existing = if p.get("study").is_some() { Some(study_index(s, p, cmd)?) } else { None };
|
||||
let steps = match p.get("steps") {
|
||||
Some(v) => {
|
||||
let n = v.as_f64().filter(|n| n.is_finite() && *n >= 1.0 && *n <= 100_000.0).ok_or_else(|| bad(cmd, "`steps` must be 1…100000"))?;
|
||||
Some(n.round() as u32)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut keys = Vec::new();
|
||||
if let Some(list) = p.get("keys") {
|
||||
let list = list.as_array().ok_or_else(|| bad(cmd, "`keys` must be a list"))?;
|
||||
if list.len() > 1000 {
|
||||
return Err(bad(cmd, "too many keys"));
|
||||
}
|
||||
for k in list {
|
||||
let joint = joint_id(s, k.get("joint"), cmd)?;
|
||||
let kind = s.doc.assembly.joints.iter().find(|j| j.id == joint).map(|j| j.kind).unwrap_or(JointKind::Rigid);
|
||||
let index = k.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
if index >= kind.dofs().len() {
|
||||
return Err(bad(cmd, format!("joint {joint} has no moving value {index}")));
|
||||
}
|
||||
let pts = k.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "each key needs `points`: [[step, value], …]"))?;
|
||||
if pts.is_empty() || pts.len() > 10_000 {
|
||||
return Err(bad(cmd, "a key needs 1…10000 points"));
|
||||
}
|
||||
let mut points = Vec::new();
|
||||
for pt in pts {
|
||||
let (Some(st), Some(v)) = (pt.get(0).and_then(Value::as_f64).filter(|x| x.is_finite() && x.abs() < 1e9), pt.get(1)) else {
|
||||
return Err(bad(cmd, "points are [step, value]"));
|
||||
};
|
||||
points.push((st, value(s, v, kind.is_angle(index), cmd)?));
|
||||
}
|
||||
points.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
keys.push(StudyKey { joint, index, points });
|
||||
}
|
||||
}
|
||||
let id = next_id(s);
|
||||
let count = s.doc.assembly.studies.len();
|
||||
let a = &mut s.doc_mut().assembly;
|
||||
let out = match existing.and_then(|i| a.studies.get_mut(i)) {
|
||||
Some(m) => {
|
||||
if let Some(n) = steps {
|
||||
m.steps = n;
|
||||
}
|
||||
if p.get("keys").is_some() {
|
||||
m.keys = keys;
|
||||
}
|
||||
if let Some(n) = str_(p, "name").filter(|n| !n.trim().is_empty()) {
|
||||
m.name = n.to_string();
|
||||
}
|
||||
m.clone()
|
||||
}
|
||||
None => {
|
||||
if count >= MAX_ITEMS {
|
||||
return Err(bad(cmd, "too many motion studies"));
|
||||
}
|
||||
let m = MotionStudy {
|
||||
id,
|
||||
name: str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Motion Study{}", count + 1)),
|
||||
steps: steps.unwrap_or(100),
|
||||
keys,
|
||||
};
|
||||
a.studies.push(m.clone());
|
||||
m
|
||||
}
|
||||
};
|
||||
Ok(json!({"study": out.id, "name": out.name, "steps": out.steps, "keys": out.keys.len()}))
|
||||
}
|
||||
|
||||
/// Joint values of a study at a step: (joint, full value vector).
|
||||
fn values_at(s: &Session, m: &MotionStudy, step: f64) -> Vec<(u64, Vec<f64>)> {
|
||||
let mut out: Vec<(u64, Vec<f64>)> = Vec::new();
|
||||
for k in &m.keys {
|
||||
let Some(v) = k.value_at(step) else { continue };
|
||||
let pos = match out.iter().position(|(j, _)| *j == k.joint) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
let cur = s.doc.assembly.joints.iter().find(|j| j.id == k.joint).map(|j| j.values.clone()).unwrap_or_default();
|
||||
out.push((k.joint, cur));
|
||||
out.len() - 1
|
||||
}
|
||||
};
|
||||
if let Some((_, q)) = out.get_mut(pos) {
|
||||
if q.len() <= k.index {
|
||||
q.resize(k.index + 1, 0.0);
|
||||
}
|
||||
if let Some(x) = q.get_mut(k.index) {
|
||||
*x = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where each occurrence is: (name, world transform).
|
||||
fn positions(s: &Session) -> Vec<(String, Mat)> {
|
||||
s.doc.occurrences.iter().map(|o| (o.name.clone(), joints::occurrence_world(&s.doc, o.id))).collect()
|
||||
}
|
||||
|
||||
fn pose_json(m: &Mat) -> Value {
|
||||
json!({"origin": [m[3][0], m[3][1], m[3][2]], "x": [m[0][0], m[0][1], m[0][2]], "y": [m[1][0], m[1][1], m[1][2]], "z": [m[2][0], m[2][1], m[2][2]]})
|
||||
}
|
||||
|
||||
fn play(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "motion.play";
|
||||
let i = study_index(s, p, cmd)?;
|
||||
let m = s.doc.assembly.studies.get(i).cloned().ok_or_else(|| bad(cmd, "study"))?;
|
||||
let step = p.get("step").and_then(Value::as_f64).filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, "`step` must be a number"))?;
|
||||
let want = values_at(s, &m, step.clamp(0.0, f64::from(m.steps)));
|
||||
let mut out = drive_with_contact(s, &want)?;
|
||||
out["positions"] = positions(s).iter().map(|(n, t)| json!({"occurrence": n, "pose": pose_json(t)})).collect();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "motion.export";
|
||||
let i = study_index(s, p, cmd)?;
|
||||
let m = s.doc.assembly.studies.get(i).cloned().ok_or_else(|| bad(cmd, "study"))?;
|
||||
let samples = match p.get("samples") {
|
||||
Some(v) => v.as_f64().filter(|n| n.is_finite() && *n >= 2.0 && *n <= 10_000.0).ok_or_else(|| bad(cmd, "`samples` must be 2…10000"))? as usize,
|
||||
None => (m.steps as usize + 1).min(10_000),
|
||||
};
|
||||
// On a copy: the design stays as it is.
|
||||
let mut t = s.scratch();
|
||||
let mut rows = Vec::new();
|
||||
for k in 0..samples {
|
||||
let step = f64::from(m.steps) * k as f64 / (samples - 1) as f64;
|
||||
let want = values_at(&t, &m, step);
|
||||
let r = drive_with_contact(&mut t, &want)?;
|
||||
rows.push(json!({
|
||||
"step": step,
|
||||
"stopped_by_contact": r["stopped_by_contact"],
|
||||
"positions": positions(&t).iter().map(|(n, x)| json!({"occurrence": n, "pose": pose_json(x)})).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
let out = json!({"study": m.name, "steps": m.steps, "samples": rows});
|
||||
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096) {
|
||||
let text = if path.to_ascii_lowercase().ends_with(".csv") {
|
||||
let mut c = String::from("step,occurrence,x,y,z,xx,xy,xz,yx,yy,yz,zx,zy,zz\n");
|
||||
for r in &rows {
|
||||
for p in r["positions"].as_array().into_iter().flatten() {
|
||||
let pose = &p["pose"];
|
||||
let n = |k: &str, i: usize| pose[k][i].as_f64().unwrap_or(0.0);
|
||||
c += &format!(
|
||||
"{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
|
||||
r["step"],
|
||||
p["occurrence"].as_str().unwrap_or_default().replace(',', " "),
|
||||
n("origin", 0),
|
||||
n("origin", 1),
|
||||
n("origin", 2),
|
||||
n("x", 0),
|
||||
n("x", 1),
|
||||
n("x", 2),
|
||||
n("y", 0),
|
||||
n("y", 1),
|
||||
n("y", 2),
|
||||
n("z", 0),
|
||||
n("z", 1),
|
||||
n("z", 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
c
|
||||
} else {
|
||||
serde_json::to_string_pretty(&out).unwrap_or_default()
|
||||
};
|
||||
solvecraft_io::write_atomic(std::path::Path::new(path), text.as_bytes(), false).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
return Ok(json!({"path": path, "samples": rows.len()}));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn list_studies(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"studies": s.doc.assembly.studies}))
|
||||
}
|
||||
|
||||
fn delete_study(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = study_index(s, p, "motion.delete")?;
|
||||
let m = s.doc_mut().assembly.studies.remove(i);
|
||||
Ok(json!({"deleted": m.name}))
|
||||
}
|
||||
|
||||
// ---- Exploded views ----
|
||||
|
||||
fn view_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let key = match p.get("view") {
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`view` must be an id or a name")),
|
||||
};
|
||||
s.doc.assembly.exploded.iter().position(|e| e.id.to_string() == key || e.name == key).ok_or_else(|| bad(cmd, format!("no exploded view `{key}`")))
|
||||
}
|
||||
|
||||
/// World centre of each top-level occurrence's bodies.
|
||||
fn centres(s: &Session) -> Vec<(u64, Vec3)> {
|
||||
let bodies = placed(s);
|
||||
let mut out = Vec::new();
|
||||
for o in s.doc.occurrences.iter().filter(|o| o.parent == 0) {
|
||||
let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN));
|
||||
for (_, b) in bodies.iter().filter(|(path, _)| path.first() == Some(&o.id)) {
|
||||
if let Ok(m) = b.tessellate((b.size() * 1e-2).max(1e-2)) {
|
||||
let bb = m.bounds();
|
||||
lo = Vec3::new(lo.x.min(bb.min.x), lo.y.min(bb.min.y), lo.z.min(bb.min.z));
|
||||
hi = Vec3::new(hi.x.max(bb.max.x), hi.y.max(bb.max.y), hi.z.max(bb.max.z));
|
||||
}
|
||||
}
|
||||
if lo.x <= hi.x {
|
||||
out.push((o.id, (lo + hi) * 0.5));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn explode_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "explode.create";
|
||||
let moves: Vec<(u64, Vec3)> = match p.get("moves") {
|
||||
Some(Value::Array(list)) => {
|
||||
if list.len() > MAX_ITEMS {
|
||||
return Err(bad(cmd, "too many moves"));
|
||||
}
|
||||
list.iter()
|
||||
.map(|m| {
|
||||
let o = occurrence_id(s, m.get("occurrence"), cmd)?;
|
||||
let t = m
|
||||
.get("translate")
|
||||
.and_then(vec3)
|
||||
.filter(|v| v.len() < 1e7)
|
||||
.ok_or_else(|| bad(cmd, "each move needs `translate`: [x, y, z]"))?;
|
||||
Ok((o, t))
|
||||
})
|
||||
.collect::<Result<_>>()?
|
||||
}
|
||||
Some(_) => return Err(bad(cmd, "`moves` must be a list")),
|
||||
None => {
|
||||
let scale = match p.get("scale") {
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite() && x.abs() <= 100.0).ok_or_else(|| bad(cmd, "`scale` must be a number up to 100"))?,
|
||||
None => 1.0,
|
||||
};
|
||||
let c = centres(s);
|
||||
if c.len() < 2 {
|
||||
return Err(bad(cmd, "an exploded view needs two or more occurrences"));
|
||||
}
|
||||
let mid = c.iter().fold(Vec3::ZERO, |a, (_, p)| a + *p) * (1.0 / c.len() as f64);
|
||||
c.iter().map(|(o, p)| (*o, (*p - mid) * scale)).collect()
|
||||
}
|
||||
};
|
||||
if s.doc.assembly.exploded.len() >= MAX_ITEMS {
|
||||
return Err(bad(cmd, "too many exploded views"));
|
||||
}
|
||||
let id = next_id(s);
|
||||
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Exploded View{}", s.doc.assembly.exploded.len() + 1));
|
||||
s.doc_mut().assembly.exploded.push(ExplodedView { id, name: name.clone(), moves: moves.clone() });
|
||||
Ok(json!({"view": id, "name": name, "moves": moves.iter().map(|(o, t)| json!({"occurrence": o, "translate": t})).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "explode.show";
|
||||
if p.get("view").is_none_or(|v| v.is_null() || v.as_str().is_some_and(|x| x.eq_ignore_ascii_case("none"))) {
|
||||
s.pending_moves.clear();
|
||||
s.revision += 1;
|
||||
return Ok(json!({"view": null}));
|
||||
}
|
||||
let i = view_index(s, p, cmd)?;
|
||||
let v = s.doc.assembly.exploded.get(i).cloned().ok_or_else(|| bad(cmd, "view"))?;
|
||||
s.pending_moves.clear();
|
||||
for (o, t) in &v.moves {
|
||||
let Some(occ) = s.doc.occurrences.iter().find(|x| x.id == *o) else { continue };
|
||||
let mut m = occ.transform;
|
||||
if let Some(r) = m.get_mut(3) {
|
||||
r[0] += t.x;
|
||||
r[1] += t.y;
|
||||
r[2] += t.z;
|
||||
}
|
||||
s.pending_moves.insert(*o, m);
|
||||
}
|
||||
s.revision += 1;
|
||||
Ok(json!({"view": v.id, "name": v.name}))
|
||||
}
|
||||
|
||||
fn explode_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"views": s.doc.assembly.exploded}))
|
||||
}
|
||||
|
||||
fn explode_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = view_index(s, p, "explode.delete")?;
|
||||
let v = s.doc_mut().assembly.exploded.remove(i);
|
||||
Ok(json!({"deleted": v.name}))
|
||||
}
|
||||
|
||||
/// World position of a point of an occurrence (tests).
|
||||
#[cfg(test)]
|
||||
pub fn world_point(s: &Session, occurrence: u64, p: Vec3) -> Vec3 {
|
||||
solvecraft_doc::apply_point(&joints::occurrence_world(&s.doc, occurrence), p)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "motion_tests.rs"]
|
||||
mod tests;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user