mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a2c5a0036 | |||
| 29c594e1f6 |
@@ -1,2 +0,0 @@
|
||||
[alias]
|
||||
xtask = "run --package xtask --"
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Help reproduce and fix a SolveCraft problem
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Behavior
|
||||
|
||||
What did you expect, and what happened instead?
|
||||
|
||||
## Reproduction
|
||||
|
||||
Include steps, SolveCraft version/commit and relevant environment details. Prefer a small example without private design data.
|
||||
|
||||
## Screenshots of the behavior (when observable)
|
||||
|
||||
For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful.
|
||||
|
||||
Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts.
|
||||
@@ -1,17 +0,0 @@
|
||||
## Change
|
||||
|
||||
Describe the problem and resulting behavior. Link the issue when applicable.
|
||||
|
||||
## Validation
|
||||
|
||||
What did you run, and what were the results? Include expected and measured values for geometry changes.
|
||||
|
||||
## Screenshots of the behavior (when observable)
|
||||
|
||||
For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful.
|
||||
|
||||
Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts.
|
||||
|
||||
## Integration status
|
||||
|
||||
Record the upstream commit and PR status checked before handoff, any conflicts resolved, and validation after integration. Keep up with upstream merges and monitor this PR while the task is active; rebase before pushing and never force-push.
|
||||
@@ -1,43 +0,0 @@
|
||||
SolidCraft is open-source parametric 3D CAD in Rust: sketch, constrain, extrude, and change a
|
||||
parameter to watch the timeline rebuild. [Documentation](https://bherbruck.github.io/solidcraft/)
|
||||
· [try it in the browser](https://bherbruck.github.io/solidcraft/app/) ·
|
||||
[contributing](https://github.com/bherbruck/solidcraft/blob/main/CONTRIBUTING.md) ·
|
||||
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
|
||||
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
|
||||
Compatibility names are unchanged: existing `.solvecraft` designs, `solvecraft-cli`, package
|
||||
IDs and archive filenames still work. Published 0.2.0 and 0.2.1 releases keep their original
|
||||
SolveCraft branding. Documentation, the browser app and the Flatpak feed use the new
|
||||
SolidCraft project Pages URLs; old-name Pages addresses are not permanent aliases.
|
||||
|
||||
**Which file?**
|
||||
- **Windows:** `SolveCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
|
||||
`-portable.zip` (x64, x86 and arm64).
|
||||
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolveCraft.app` onto Applications.
|
||||
The CLI is in `solvecraft-cli-{version}-macos-universal.zip`.
|
||||
- **Linux** (x86_64 and aarch64, glibc 2.35+): the `.AppImage` (`chmod +x`, then run it), the
|
||||
`.deb` or `.rpm`, the `.flatpak` (`flatpak install --user <file>`), or the `.tar.gz`.
|
||||
- **FreeBSD:** `tar -xzf solvecraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
|
||||
- **Web:** `solvecraft-web-{version}.zip` is a static site to host yourself.
|
||||
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`.
|
||||
|
||||
For Flatpak feed installation and updates, add Flathub for the required runtime before
|
||||
installing SolidCraft; keep both remotes in the same user installation:
|
||||
|
||||
```sh
|
||||
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak remote-add --user --if-not-exists solvecraft https://bherbruck.github.io/solidcraft/flatpak/solvecraft.flatpakrepo
|
||||
flatpak install --user solvecraft ai.storyteller.solvecraft
|
||||
flatpak run ai.storyteller.solvecraft
|
||||
```
|
||||
|
||||
Single-file `.flatpak` bundles include the runtime repository address; missing runtimes
|
||||
still need an internet connection. [Linux installation details](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
|
||||
|
||||
The builds aren't signed yet. On macOS, right-click `SolveCraft.app` in Applications and choose
|
||||
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolveCraft.app`).
|
||||
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
|
||||
Licences are built in (Help ▸ About ▸ Licences, `solvecraft-cli licences`); the packages also
|
||||
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
|
||||
[ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
# Fast checks always run; native builds run for code/CI changes.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cross_compile:
|
||||
description: "Also check WebAssembly and RISC-V (before a release)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Select checks
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
native: ${{ steps.paths.outputs.native }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: paths
|
||||
name: Detect code and CI changes
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
native=false
|
||||
if [ "$EVENT_NAME" = workflow_dispatch ]; then
|
||||
native=true
|
||||
else
|
||||
# Compare both old and new paths of renames; unknown paths run native checks.
|
||||
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/ci-paths"
|
||||
while IFS= read -r -d '' path; do
|
||||
case "$path" in
|
||||
.github/workflows/ci.yml) native=true ;;
|
||||
*.rs|Cargo.toml|Cargo.lock|*/Cargo.toml|.cargo/*|rust-toolchain*) native=true ;;
|
||||
docs/*|*.md|.github/*|packaging/*|LICENSE*|NOTICE|THIRD-PARTY-LICENSES.txt) ;;
|
||||
*) native=true ;;
|
||||
esac
|
||||
done < "$RUNNER_TEMP/ci-paths"
|
||||
fi
|
||||
echo "native=$native" >> "$GITHUB_OUTPUT"
|
||||
|
||||
checks:
|
||||
name: Format and repository checks
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: repo-checks
|
||||
- name: Format
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Asset attribution
|
||||
run: cargo xtask assets
|
||||
- name: Licences
|
||||
run: cargo xtask licences --check
|
||||
- name: Dependency layers
|
||||
run: cargo xtask layers
|
||||
|
||||
clippy:
|
||||
name: Native Linux Clippy
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-clippy
|
||||
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
|
||||
|
||||
tests:
|
||||
name: Native Linux tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-tests
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
- name: Compile tests
|
||||
run: cargo test --workspace --profile ci --locked --no-run
|
||||
- name: Run tests
|
||||
# Every test binary at once (cargo test runs them one after another).
|
||||
run: cargo xtask test --locked
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
needs: [changes, checks, clippy, tests]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
env:
|
||||
CHANGES: ${{ needs.changes.result }}
|
||||
CHECKS: ${{ needs.checks.result }}
|
||||
NATIVE: ${{ needs.changes.outputs.native }}
|
||||
CLIPPY: ${{ needs.clippy.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
run: |
|
||||
test "$CHANGES" = success
|
||||
test "$CHECKS" = success
|
||||
if [ "$NATIVE" = true ]; then
|
||||
test "$CLIPPY" = success
|
||||
test "$TESTS" = success
|
||||
fi
|
||||
|
||||
wasm:
|
||||
name: Compile WebAssembly (manual)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: wasm
|
||||
- run: cargo xtask wasm
|
||||
|
||||
riscv64-gates:
|
||||
name: Compile RISC-V (manual)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: riscv64
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
# Ubuntu's regular archive serves amd64; RISC-V packages live in ports.
|
||||
# The 24.04 runner uses deb822 sources, so scope every Ubuntu stanza.
|
||||
sudo sed -i '/^Architectures:/d; /^Types:/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
sudo tee /etc/apt/sources.list.d/riscv64.sources >/dev/null <<'EOF'
|
||||
Types: deb
|
||||
URIs: http://ports.ubuntu.com/ubuntu-ports
|
||||
Suites: noble noble-updates noble-backports noble-security
|
||||
Components: main restricted universe multiverse
|
||||
Architectures: riscv64
|
||||
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
|
||||
EOF
|
||||
sudo dpkg --add-architecture riscv64
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-riscv64-linux-gnu libasound2-dev:riscv64 libxkbcommon-dev:riscv64 \
|
||||
libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 libxi-dev:riscv64 libgl1-mesa-dev:riscv64
|
||||
- name: Cargo check on RISC-V
|
||||
env:
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: 1
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts"
|
||||
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
|
||||
@@ -1,37 +0,0 @@
|
||||
name: Publish Flatpak repository to Pages
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Published GitHub release tag to publish (for example, v1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: flatpak-pages-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Request the combined docs, web, and Flatpak deployment
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
|
||||
steps:
|
||||
- name: Deploy through the shared Pages workflow
|
||||
run: |
|
||||
set -euo pipefail
|
||||
published="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" \
|
||||
--jq 'select(.draft == false and .published_at != null) | .tag_name')"
|
||||
test "$published" = "$RELEASE_TAG"
|
||||
gh workflow run pages.yml --repo "$GITHUB_REPOSITORY" --ref main \
|
||||
-f release_tag="$RELEASE_TAG"
|
||||
echo "Requested the combined Pages deployment for $RELEASE_TAG" >>"$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,59 +0,0 @@
|
||||
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, the
|
||||
# packaging shell and PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
|
||||
# Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
name: Packaging lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: [".github/workflows/**", "packaging/**"]
|
||||
pull_request:
|
||||
paths: [".github/workflows/**", "packaging/**"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: packaging-lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml jq
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: '"$RUNNER_TEMP/actionlint" -color'
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
|
||||
- name: Nightly release selection regression
|
||||
run: python3 packaging/tests/nightly_test.py
|
||||
- name: Windows executable icon validation regression
|
||||
run: python3 packaging/tests/windows_icon_test.py
|
||||
- name: Flatpak desktop smoke validation regression
|
||||
run: python3 packaging/tests/flatpak_test.py
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/solvecraft.wxs packaging/windows/solvecraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
desktop-file-validate packaging/linux/ai.storyteller.solvecraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solvecraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solvecraft'"
|
||||
@@ -1,82 +0,0 @@
|
||||
# The documentation site on GitHub Pages: the book (docs/book) at /solidcraft/ and the web app
|
||||
# at /solidcraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
|
||||
# repository is private.
|
||||
name: Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Published release for Flatpak (empty: latest stable release)"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
- 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 /solidcraft/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
|
||||
id: pages
|
||||
- name: Install Flatpak repository tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends flatpak ostree
|
||||
- name: Include the published Flatpak repository
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.release_tag || '' }}
|
||||
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
|
||||
run: bash packaging/flatpak-site.sh site
|
||||
- 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,408 +0,0 @@
|
||||
# Release pipeline, after PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
#
|
||||
# Push to `release` to build packages and create/update a DRAFT GitHub Release. The version
|
||||
# comes from [workspace.package] in Cargo.toml. An agent verifies the artifacts and publishes
|
||||
# the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases.
|
||||
# At midnight Eastern, changed main commits build and publish a nightly pre-release.
|
||||
# A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
|
||||
#
|
||||
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .tar.gz packaging/linux
|
||||
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
|
||||
# macOS universal .dmg, and the CLI as a .zip packaging/macos
|
||||
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
|
||||
# Web a .zip of the static site packaging/web
|
||||
#
|
||||
# A dry run: dispatch with draft=false (the default). Only the release branch may create drafts.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
timezone: America/New_York
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
draft:
|
||||
description: "Create/update a draft (release branch only; off: build artifacts only)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build: ${{ steps.changed.outputs.build }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: changed
|
||||
name: Skip unchanged nightly builds
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: bash packaging/nightly.sh
|
||||
- id: v
|
||||
if: steps.changed.outputs.build == 'true'
|
||||
name: Resolve version
|
||||
env:
|
||||
REQUEST_DRAFT: ${{ inputs.draft || false }}
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
if [ "$GITHUB_EVENT_NAME" = schedule ]; then
|
||||
# Unique SemVer without committing daily version bumps to main.
|
||||
VERSION="${VERSION%%-*}-nightly.$(TZ=America/New_York date +%Y%m%d).$GITHUB_RUN_NUMBER.$GITHUB_RUN_ATTEMPT"
|
||||
fi
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$'; then
|
||||
echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$REQUEST_DRAFT" = true ] && [ "$GITHUB_REF" != refs/heads/release ]; then
|
||||
echo "::error::Draft releases must be built from the release branch"
|
||||
exit 1
|
||||
fi
|
||||
case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
|
||||
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
|
||||
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
|
||||
icons:
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-icons
|
||||
- run: cargo build --release --locked -p solvecraft-cli
|
||||
- run: |
|
||||
. packaging/env.sh
|
||||
make_icons target/release/solvecraft-cli icons
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: icons
|
||||
path: icons
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends desktop-file-utils appstream zsync rpm
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft$|usr/bin/solvecraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
|
||||
rpm -qip dist/release/*.rpm
|
||||
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
|
||||
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
|
||||
ldd target/release/solvecraft
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common xvfb xauth weston dbus-x11
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/solvecraft-$SOLVECRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "SOLVECRAFT_VERSION SOLVECRAFT_BUILD_DATE"
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
|
||||
# runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/solvecraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/solvecraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/solvecraft$|/bin/solvecraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solvecraft/THIRD-PARTY-LICENSES\.txt$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: macos-14
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Build and package (dmg, CLI zip; the dmg is mounted and checked)
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: [version, icons]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: icons
|
||||
path: icons
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build and package (msi, portable zip)
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
- name: Build and zip
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: GitHub Release or dry-run artifacts
|
||||
needs: [version, linux, flatpak, freebsd, macos, windows, web]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums and notes
|
||||
run: |
|
||||
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
|
||||
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
|
||||
- name: Publish nightly pre-release
|
||||
if: github.event_name == 'schedule'
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
sha256sum --check SHA256SUMS.txt
|
||||
# Stage all packages before publishing. Published tags are never replaced.
|
||||
gh release create "$tag" --draft --prerelease --latest=false \
|
||||
--title "SolidCraft v$VERSION (nightly)" --target "$GITHUB_SHA" \
|
||||
--notes-file ../notes.md --generate-notes -- *
|
||||
gh release edit "$tag" --draft=false --prerelease --latest=false
|
||||
echo "### Published nightly: $tag (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
- name: Create or update the draft release
|
||||
if: github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft)
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
# Refuse existing tags: a draft must point at this build, never an older tag.
|
||||
# matching-refs returns [] for an absent tag and fails on API/auth errors.
|
||||
existing_refs="$(gh api "repos/$GH_REPO/git/matching-refs/tags/$tag" \
|
||||
--jq '.[].ref')"
|
||||
if printf '%s\n' "$existing_refs" | grep -Fxq "refs/tags/$tag"; then
|
||||
echo "::error::Tag $tag already exists. Choose a new version; never move a release tag."
|
||||
exit 1
|
||||
fi
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != true ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version before releasing."
|
||||
exit 1
|
||||
fi
|
||||
# Replace the complete draft asset set, including assets removed in this build.
|
||||
assets="$(gh release view "$tag" --json assets --jq '.assets[].name')"
|
||||
while IFS= read -r asset; do
|
||||
if [ -n "$asset" ]; then gh release delete-asset "$tag" "$asset" --yes; fi
|
||||
done <<<"$assets"
|
||||
gh release edit "$tag" --draft --title "SolidCraft v$VERSION" \
|
||||
--target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
gh release create "$tag" --draft --title "SolidCraft v$VERSION" \
|
||||
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft **SolidCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
- name: Keep the files (dry run)
|
||||
if: github.event_name == 'workflow_dispatch' && !inputs.draft
|
||||
run: cp notes.md artifacts/release-notes.md
|
||||
- if: github.event_name == 'workflow_dispatch' && !inputs.draft
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-assets
|
||||
path: artifacts/*
|
||||
@@ -1,9 +0,0 @@
|
||||
/target*/
|
||||
/plan/
|
||||
/dist/
|
||||
*~
|
||||
.DS_Store
|
||||
*.swp
|
||||
/.claude/worktrees/
|
||||
/target-snapshot
|
||||
__pycache__/
|
||||
@@ -1,132 +0,0 @@
|
||||
# SolidCraft — instructions for agents
|
||||
|
||||
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
|
||||
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.
|
||||
|
||||
## Product name and compatibility
|
||||
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
|
||||
`solvecraft` crate/binary names, `.solvecraft` files, format markers, environment variables,
|
||||
settings/recovery paths and package IDs compatible. Published SolveCraft release artifacts
|
||||
and attribution records keep their historical names. Public docs/web/Flatpak deployments
|
||||
use project Pages `/solidcraft/`; do not add permanent old-name Pages aliases. See README's
|
||||
compatibility notes.
|
||||
|
||||
## 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 SolidCraft 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.** SolidCraft 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/solidcraft. 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`).
|
||||
|
||||
## Showing and following through on changes
|
||||
|
||||
- Help reviewers see what is broken or changed. For observable CAD, UI or model behavior,
|
||||
issues and PRs must include actual descriptive screenshots or a short recording from a
|
||||
small reproduction. This includes kernel, geometry, assembly and backend changes whose
|
||||
effects can be shown in the app. Coding agents should run the app and capture this evidence
|
||||
themselves; do not ask a human for evidence the agent can produce. Show the actual failure
|
||||
or result, preferably before and after, with brief reproduction steps, expected/actual
|
||||
behavior and annotations that point out the relevant detail.
|
||||
- Flowcharts and Mermaid diagrams cannot substitute for screenshots or recordings of visible
|
||||
behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate
|
||||
the point. Changes with no useful observable behavior do not need screenshot or video evidence.
|
||||
- Use a small public-safe example and check for private designs, paths, account details or
|
||||
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
|
||||
recording; do not commit generated screenshots or videos just to provide review evidence.
|
||||
- Throughout the work and before handoff, check current upstream commits and the relevant
|
||||
PR's merge status. Keep your branch current with upstream merges, resolve conflicts without
|
||||
discarding others' work, and re-run validation when integration changes the result. Use
|
||||
`git pull --rebase origin main` before pushing as above; never force-push. Report the checked
|
||||
upstream/PR state, any conflicts resolved and the validation performed. If a PR remains open,
|
||||
keep monitoring it while the task is active and make its pending status clear at handoff;
|
||||
do not claim it merged without checking.
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
|
||||
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
|
||||
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
|
||||
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
|
||||
version bumps solely from commit prefixes, overwrite published versions, or push release tags
|
||||
before the draft is verified.
|
||||
|
||||
## 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,68 +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 axis view, 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/screenshots/enclosure-demo-0.2.1.gif | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/enclosure-demo-0.2.1.mp4 | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | 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 | — |
|
||||
| .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 |
|
||||
|
||||
## Recovered contributions
|
||||
|
||||
| SolveCraft file | Original contribution | Licence |
|
||||
|---|---|---|
|
||||
| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `axis_view_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solvecraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solvecraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solvecraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the axis gizmo | MIT OR Apache-2.0 |
|
||||
|
||||
## 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) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
## Vendored renderer compatibility fix
|
||||
|
||||
`vendor/egui-wgpu` is egui-wgpu 0.36.2 from https://github.com/emilk/egui
|
||||
(MIT OR Apache-2.0; both original licence texts are included). Rust sources and
|
||||
its original `egui.wgsl` shader are retained; no images or other artwork are copied.
|
||||
SolidCraft's narrow patch selects a safe 1x MSAA path on the chosen OpenGL adapter
|
||||
and shares that effective count with the surface attachments and CAD callbacks.
|
||||
Vulkan, DirectX 12, Metal and WebGPU retain their requested multisampling.
|
||||
@@ -1,109 +0,0 @@
|
||||
# SolidCraft — instructions for agents
|
||||
|
||||
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
|
||||
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.
|
||||
|
||||
## Product name and compatibility
|
||||
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
|
||||
`solvecraft` crate/binary names, `.solvecraft` files, format markers, environment variables,
|
||||
settings/recovery paths and package IDs compatible. Published SolveCraft release artifacts
|
||||
and attribution records keep their historical names. Public docs/web/Flatpak deployments
|
||||
use project Pages `/solidcraft/`; do not add permanent old-name Pages aliases. See README's
|
||||
compatibility notes.
|
||||
|
||||
## 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 SolidCraft 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.** SolidCraft 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/solidcraft. 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`).
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
|
||||
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
|
||||
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
|
||||
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
|
||||
version bumps solely from commit prefixes, overwrite published versions, or push release tags
|
||||
before the draft is verified.
|
||||
|
||||
## 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`.
|
||||
-216
@@ -1,216 +0,0 @@
|
||||
# Contributing to SolidCraft
|
||||
|
||||
SolidCraft is an open-source parametric 3D CAD app written in pure Rust.
|
||||
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.95 or newer (required by egui/eframe 0.36). 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/solidcraft && cd solidcraft
|
||||
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
|
||||
cargo xtask test # unit, engine and UI-scenario tests
|
||||
cargo xtask ci # everything a PR must pass (see below)
|
||||
```
|
||||
|
||||
SolidCraft is the product and repository name. Cargo packages and executables retain
|
||||
their `solvecraft` names for compatibility with existing scripts and MCP clients.
|
||||
Build the desktop app with `cargo build --release -p solvecraft`, or the CLI with
|
||||
`cargo build --release -p solvecraft-cli`. Run the CLI with
|
||||
`cargo run --release -p solvecraft-cli -- commands` (or `target/release/solvecraft-cli commands`
|
||||
after building). There is no Cargo package named `solidcraft`.
|
||||
|
||||
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`.
|
||||
|
||||
### NixOS: `nix develop`
|
||||
|
||||
NixOS has no `/usr/lib`, so nothing is on `LD_LIBRARY_PATH` and a plain `cargo run` finds none of
|
||||
the libraries the app loads at run time: the Wayland ones winit opens the window through, the EGL
|
||||
loader wgpu renders through, and libdbus-1, which rfd's file dialogs reach xdg-desktop-portal
|
||||
through (a dialog then never appears and the pick returns no path):
|
||||
|
||||
```
|
||||
ERROR solvecraft: winit EventLoopError: ... The wayland library could not be loaded
|
||||
ERROR eframe::native::run: Exiting because of error: WGPU error: Failed to create surface for any enabled backend: {}
|
||||
ERROR rfd::backend::xdg_desktop_portal::portal::libdbus: Can't connect to a portal: libdbus-1.so not found
|
||||
```
|
||||
|
||||
`nix develop` (the [`flake.nix`](flake.nix) in the repository root) supplies Wayland,
|
||||
X11, EGL, Vulkan-loader and D-Bus runtime libraries. It also exposes the NixOS
|
||||
driver manifests in `/run/opengl-driver/share`, so Vulkan can discover the system
|
||||
drivers. Enable `hardware.graphics.enable` on NixOS; other Linux distributions
|
||||
may need a matching [nixGL wrapper](https://github.com/nix-community/nixGL).
|
||||
The default development shell retains the Rust/C toolchain already on `PATH`;
|
||||
`nix develop .#rust` supplies the pinned toolchain and builds in `target/nix`.
|
||||
|
||||
To launch, use `nix run .#solidcraft -- --sample`. To install the app's terminal
|
||||
commands, desktop entry and generated icons, use `nix profile install .#solidcraft`.
|
||||
`nix build` alone creates a build result; it does not install a launcher in your
|
||||
desktop menu. The installed `solidcraft` command and compatible `solvecraft`
|
||||
command run the same wrapped app; `solvecraft-cli` is included as well. See
|
||||
[Nix installation and troubleshooting](docs/nix.md) for remote-flake commands,
|
||||
desktop integration and how to distinguish an old executable on `PATH`.
|
||||
|
||||
## 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. SolidCraft 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, and run
|
||||
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
|
||||
3. **Every asset is original or openly licensed and has a row in
|
||||
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
|
||||
`cargo xtask assets` enforces this.
|
||||
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 xtask test` (or `cargo test --workspace --profile ci`) |
|
||||
| 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.
|
||||
|
||||
**Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables,
|
||||
no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It
|
||||
builds several times faster than `--release`. If a test fails only in a plain debug build, say so
|
||||
in the PR. `cargo xtask test` builds the tests with it and then runs every test binary at once
|
||||
(`cargo test` runs them one after another); arguments after `--` go to each binary, e.g.
|
||||
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
|
||||
depend on wall-clock timing.
|
||||
|
||||
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
|
||||
SolidCraft 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/solidcraft/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, the asset, licence and layer checks,
|
||||
clippy (`-D warnings`), then the tests with the wasm build check alongside. 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).
|
||||
|
||||
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
|
||||
include actual descriptive screenshots or a short recording in the issue or PR. This evidence
|
||||
is required for changes or bugs that can be demonstrated in the app, including kernel,
|
||||
geometry, assembly and backend work. Coding agents should run a small reproduction and
|
||||
capture the evidence themselves; please don't ask a human for evidence the agent can produce.
|
||||
Show the actual failure or result, preferably before and after, with brief steps and
|
||||
expected/actual behavior. An arrow or annotation can help identify the detail that matters.
|
||||
|
||||
Flowcharts and Mermaid diagrams cannot replace evidence of visible behavior. Diagrams are
|
||||
useful for architectural reasoning when actual screenshots cannot illustrate the point.
|
||||
Changes with no useful observable behavior do not need screenshot or video evidence. Use a
|
||||
small example without private designs or sensitive details; attach the media or link to an
|
||||
accessible recording rather than committing generated review artifacts.
|
||||
|
||||
Keep checking upstream commits and the relevant PR's merge status throughout the work and
|
||||
before handoff. Integrate upstream merges, resolve conflicts without losing others' changes,
|
||||
and validate the integrated result. Before pushing, run `git pull --rebase origin main`, re-run
|
||||
CI if it brought changes, and push without force. Report the upstream/PR state you checked,
|
||||
any conflicts resolved and validation results. While a task remains active, follow an open
|
||||
PR through its status changes; at handoff, clearly identify anything still awaiting merge.
|
||||
|
||||
Much of SolidCraft 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/solidcraft/labels/help%20wanted) and
|
||||
[`good first issue`](https://github.com/bherbruck/solidcraft/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.** SolidCraft 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 SolidCraft. SolidCraft isn't affiliated with Autodesk. Fusion is a trademark of Autodesk, Inc.
|
||||
Generated
-5151
File diff suppressed because it is too large
Load Diff
-105
@@ -1,105 +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.3.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
authors = ["SolidCraft contributors"]
|
||||
repository = "https://github.com/bherbruck/solidcraft"
|
||||
|
||||
[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"
|
||||
sha2 = "0.10"
|
||||
|
||||
# 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
|
||||
|
||||
# Tests and lints (`cargo xtask ci`, GitHub CI): quick to build, quick enough to run. Light
|
||||
# optimisation, line tables only (file:line in backtraces), no LTO. Debug assertions are off because
|
||||
# truck's debug-only checks reject valid geometry (seam edges, closed edges); that was the only
|
||||
# reason the tests used to build with --release, which cost most of CI's time.
|
||||
[profile.ci]
|
||||
inherits = "dev"
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
debug-assertions = false
|
||||
overflow-checks = true
|
||||
incremental = false
|
||||
|
||||
[profile.ci.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
[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" }
|
||||
matext4cgmath = { path = "vendor/matext4cgmath" }
|
||||
|
||||
# Keep OpenGL pipelines, resolve textures and CAD callbacks at the same safe sample count.
|
||||
egui-wgpu = { path = "vendor/egui-wgpu" }
|
||||
-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,27 +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; their licences are in THIRD-PARTY-LICENSES.txt
|
||||
(`cargo xtask licences`), which the programs also carry (Help > About > Licences,
|
||||
`solvecraft-cli licences`). The geometry kernel builds on the truck crates
|
||||
(truck-modeling, truck-shapeops, truck-meshalgo, truck-stepio, truck-topology and their
|
||||
dependencies, https://github.com/ricosjp/truck), licensed under the Apache License 2.0 and used
|
||||
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolveCraft
|
||||
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.
|
||||
The release packaging (.github/workflows/release.yml, packaging/) is adapted from PhotoCraft
|
||||
(https://github.com/storytold/photocraft), licensed under the Apache License 2.0.
|
||||
|
||||
None of the bundled assets come from third parties: every icon is drawn in code. The per-file
|
||||
list is in ATTRIBUTION.md.
|
||||
|
||||
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,171 +0,0 @@
|
||||
# SolidCraft
|
||||
|
||||
**An open-source parametric 3D CAD application built in pure Rust.**
|
||||
|
||||
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
|
||||
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
|
||||
model through the built-in MCP server. Part of the storytold "craft" family.
|
||||
|
||||

|
||||
|
||||
*Start with an empty design and draw the original parametric electronics enclosure in the English desktop UI: rounded corners, a shell, a USB port sketched directly on the wall and a four-boss pattern. Change width from 80 → 100 mm and wall thickness from 2 → 3 mm in Parameters and watch it rebuild. Original animated pointer and click cues; 4× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/enclosure-demo-0.2.1.mp4), generated with the [demo script](examples/demo/record_ui.sh).*
|
||||
|
||||
**Documentation:** the [SolidCraft book](https://bherbruck.github.io/solidcraft/) (user guide,
|
||||
developer guide and command reference; sources in [docs/book](docs/book)), and the
|
||||
[web app](https://bherbruck.github.io/solidcraft/app/) to try it in the browser.
|
||||
|
||||
Linux Flatpak installation needs the Freedesktop runtime from Flathub as well as the
|
||||
SolidCraft application feed. See [install and launch commands](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
|
||||
|
||||
**Status: early but usable for simple parts.** The historical benchmark report records
|
||||
76 of 76 reference parts matching volume, area and topology ([docs/oracle.md](docs/oracle.md)).
|
||||
These checks do not establish complete geometric or workflow equivalence. Feature coverage
|
||||
is tracked in [docs/parity.md](docs/parity.md) and [ROADMAP.md](ROADMAP.md).
|
||||
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solidcraft/issues),
|
||||
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Name and compatibility
|
||||
|
||||
SolidCraft was previously called SolveCraft. The product display name and repository are
|
||||
changing; existing identifiers remain compatible: `.solvecraft` designs, `solvecraft-cli`,
|
||||
Cargo package names, settings/recovery locations and package IDs keep their existing names.
|
||||
Published 0.2.0 and 0.2.1 packages and demo recordings retain their original SolveCraft branding
|
||||
and filenames.
|
||||
|
||||
Use the SolidCraft documentation and web-app URLs above. GitHub redirects links to the
|
||||
renamed repository; old-name Pages and Flatpak URLs are not maintained as permanent aliases.
|
||||
|
||||
## What works
|
||||
|
||||
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
|
||||
arcs, polygons, slots, ellipses, fit/control splines, conics and text; construction geometry,
|
||||
projection, trimming, fillets and chamfers, including corners at the origin.
|
||||
New face attachments retain supported construction identities and chosen occurrences;
|
||||
missing sources expose an explicit plane repair workflow. See [face attachments](docs/face-attachments.md).
|
||||
Explicitly closed fit splines and closed associative offsets use periodic C2 interpolation.
|
||||
Supported convex line-and-arc offsets retain their identity when an inset consumes corner
|
||||
arcs, and linked extrusions follow the current boundary. Breaking the association freezes
|
||||
the boundary and keeps existing extrusions usable.
|
||||
Curve drawing and picking adapt together to zoom and display scale, including small circles
|
||||
at Fit and sketches in placed components.
|
||||
- **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.
|
||||
Select a line or its two endpoints, type its dimension and press Enter to commit one undo
|
||||
step. Fit keeps sketch geometry clear of the palette and navigation controls.
|
||||
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
|
||||
Newly captured complete adaptive boundaries retain their outer and hole source identities
|
||||
when spline span counts change. Partial or ambiguous boundaries do not qualify.
|
||||
- **Features** on a parametric timeline: extrude, revolve, sweep, loft, fillet/chamfer,
|
||||
shell, draft, holes, threads, patterns, mirror, split, combine, move and construction geometry.
|
||||
The timeline rebuilds incrementally; features can be edited, suppressed, renamed, reordered
|
||||
and deleted. Supported geometry and current limitations are described in [ROADMAP.md](ROADMAP.md).
|
||||
New revolves preserve symmetric and two-sided angular placement across reversed axes;
|
||||
older designs retain their saved convention when reopened or edited.
|
||||
- **Parameters and configurations** with unit-aware expressions (`2 * width + 5 mm`,
|
||||
`angle / 2`), ordered rules, feature suppression and per-row body materials/appearances.
|
||||
- **Assemblies** with nested components and occurrences, joints, motion studies, contact sets,
|
||||
interference and material-aware mass properties. Recorded body transfers preserve modelling
|
||||
history and world placement; 3D sketch wires retain their coordinate frames through edits.
|
||||
- **Sheet metal** with flanges, hems, fold/unfold/refold, material cutouts, flat patterns,
|
||||
retained shallow prismatic pockets, exact line/arc/circle DXF outlines and bend tables;
|
||||
plastic enclosure features and rules. Pocket depth and side survive unfold/refold and appear
|
||||
separately from through-hole outlines in the flat preview and DXF.
|
||||
- **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
|
||||
axis view and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
|
||||
control channel so agents and tests can drive it.
|
||||
Feature errors and Help can open an editable **Create Issue** draft with relevant context
|
||||
and an optional reviewed screenshot; opening GitHub does not submit the report.
|
||||
- **Headless CLI** `solvecraft-cli`: run command scripts, measure (volume, area, centre of mass,
|
||||
face/edge/vertex counts), export, render PNG snapshots and replay reference 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`.
|
||||
|
||||
On Windows, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
|
||||
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
|
||||
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
|
||||
page in Preferences has an on/off switch and a speed control for the 3D mouse.
|
||||
|
||||
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
|
||||
|
||||
Coming from Autodesk Fusion? SolidCraft uses familiar parametric CAD workflows: constrained
|
||||
sketches, solid features, an editable timeline, parameters and assemblies. See the
|
||||
[roadmap](ROADMAP.md) for supported operations and current limitations.
|
||||
|
||||
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. Developed under the
|
||||
[clean-room contribution rules](CLAUDE.md); third-party code and assets retain their
|
||||
documented licences and attribution.
|
||||
|
||||
References to other products describe workflow comparisons and benchmark provenance.
|
||||
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk. Autodesk and
|
||||
Fusion are trademarks of Autodesk, Inc.
|
||||
-314
@@ -1,314 +0,0 @@
|
||||
# SolidCraft roadmap
|
||||
|
||||
Status as of **2026-10-11**. SolidCraft supports parametric modelling (sketch →
|
||||
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Where are we? | **M0 and M13 done; M1, 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) | **335 / 541 (62%)** in-scope commands; SOLID + SKETCH **188 / 291 (65%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity |
|
||||
| Fusion oracle | **76 / 76 parts match** in the checked-in historical report — [docs/oracle.md](docs/oracle.md). The current sprint adds independent analytic and UI regressions; the local Fusion oracle corpus has not been rerun. |
|
||||
| Tests | 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 and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
|
||||
| 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)
|
||||
|
||||
The issue batch adds preservation of blind pockets in through cuts, associative projected
|
||||
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
|
||||
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
|
||||
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
|
||||
acceptance scenarios now include board resizes; STEP reopening is automated. Inspection in
|
||||
Fusion remains an external acceptance step. See [WIP reconciliation](docs/wip-reconciliation.md).
|
||||
|
||||
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: command coverage implemented — 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. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 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); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 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: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), 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); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), 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. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace | deferred | — |
|
||||
| M9 | Components and assemblies: 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. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. 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 CSV/JSON and DXF export with exact circle/arc/line cutouts), 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: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 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). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | 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/12"`, 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 |
|
||||
|
||||
## Dimensioning and error reporting
|
||||
|
||||
- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints.
|
||||
Type a value and press Enter to commit one undo step; rejected values remain editable and
|
||||
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
|
||||
and navigation controls, including enlarged displays and nested pending placements.
|
||||
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
|
||||
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
|
||||
useful reproduction context. The draft is editable, with optional capture of the actual
|
||||
viewport and copy/save controls. Opening GitHub does not submit the issue. Help also exposes
|
||||
reporting for unexpected results that did not produce an error.
|
||||
- The header exposes **Report a bug**, including an accessible compact control on
|
||||
narrow windows. It opens the same editable report and screenshot review without
|
||||
changing the active model or a pending dimension.
|
||||
|
||||
## 0.2.0 release batch
|
||||
|
||||
- Includes the new axis navigation, adaptive sketch drawing/picking, origin-corner repairs,
|
||||
durable supported profile/face references, endpoint dimensioning and contextual reporting.
|
||||
- Smooth ruled-wall display joins are hidden only within the supported geometric certificate;
|
||||
authored splits and actual creases remain protected. Wireframe retains physical edges.
|
||||
- Paste New uses a bounded current-geometry comparison for supported convex line/circle
|
||||
prisms with separated circular holes; unsupported shapes retain the existing strict check.
|
||||
- Common-bore Union retries check additional boundary curves before recutting the bore,
|
||||
preventing the reproduced thin-web loss. Unknown additional trims refuse; inherited
|
||||
common-cylinder trims retain their existing policy, so this is not a general Boolean proof.
|
||||
- Exact full-path assembly joint editing remains separate work and is not part of this batch.
|
||||
- Stable 0.2.0 is published: all release payload checksums, native cold reopening, deployed
|
||||
web app and both Flatpak architectures were verified. The replacement README demo uses
|
||||
the original electronics enclosure story and animated pointer in the actual English
|
||||
0.2.0 UI, at 4× playback. Publication follows [the release workflow](docs/releasing.md).
|
||||
|
||||
## 0.2.1 critical fixes
|
||||
|
||||
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
|
||||
the command palette open, at normal and enlarged display scales. Fit still accounts for
|
||||
dialog coverage; covered controls do not receive clicks through dialogs.
|
||||
- Eligible planar faces survive fillet, chamfer and shell operations with exact source
|
||||
certificates. Sketches placed directly on the enclosure wall and inner floor follow
|
||||
width and wall changes, including after the USB cut and cold reopening.
|
||||
- Remove artificial straight-wall and rounded-corner subdivisions left by shell and
|
||||
extrusion cuts. Identical generated wall segments span adjacent slabs as one physical
|
||||
face, with exact full-height supports and boundary stitching. Preserve authored face
|
||||
splits and face appearances. Permanent regressions cover material, STEP, reopening and
|
||||
unaffected bodies across the extrusion end plane.
|
||||
- Feature dialogs use the available screen height for their fields, with OK and Cancel
|
||||
outside the scrolling region, including enlarged display scales.
|
||||
- Design format 13 records shell-face attachments. Older designs remain readable;
|
||||
newly saved designs require 0.2.1 or later. Ambiguous or unsupported faces still refuse
|
||||
attachment rather than guessing a plane.
|
||||
- Saved projections from a removed strip retain their geometry and report the lost
|
||||
reference. Explicitly reselect the wall to repair that projection.
|
||||
|
||||
## View navigation
|
||||
|
||||
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
|
||||
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
|
||||
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
|
||||
top/bottom heading are preserved. Diagonals include 45-degree edge and isometric corner views.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Associative shell-face sketches require a unique certified planar source. Nested shell
|
||||
attachments and ambiguous coincident sources remain unsupported.
|
||||
- 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 (equal-distance chamfers also meet sloped planar end faces, including inner
|
||||
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
|
||||
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
|
||||
connected equal-distance chamfers also trim each other across unequal sloped rim walls;
|
||||
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. Qualified axial cuts preserve
|
||||
annular holes with overrun cutters, including inward Press Pull after a joined extrusion;
|
||||
curved wall qualification rejects false transverse axes instead of filling holes.
|
||||
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. Bounded containment classification preserves fully internal cavities and nested
|
||||
material islands independently of shell order, with STEP round-trip and material-membership
|
||||
regressions. Near-contact curved shells use the general boolean path rather than a
|
||||
containment shortcut. 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; analytic offsets on planes, cylinders, cones, spheres
|
||||
and regular tori are read exactly (signed/nested distances, units and face sense). Freeform
|
||||
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.
|
||||
New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face
|
||||
source certificates with checked target and author occurrence paths. A missing or changed
|
||||
source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while
|
||||
retaining current geometry and dimension identities. Unsupported modified faces need an
|
||||
explicit fixed plane. Legacy attachments retain their original resolver behavior.
|
||||
- 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.
|
||||
- Explicit sketch breaks preserve exact ellipses, fit/control splines (including high degrees)
|
||||
and conics; analytic trim/break also sees free-form cutters. 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).
|
||||
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
|
||||
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
|
||||
certificate and refine within bounded node limits while retaining existing IDs. Singular,
|
||||
self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth
|
||||
closed natural-spline seams remain limitations. New explicitly closed fit splines and closed
|
||||
offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older
|
||||
natural splines are preserved. Clicking the starting fit point deliberately closes a new loop.
|
||||
Newly captured adaptive profile references with complete source coverage retain their outer
|
||||
and hole identities through span-count changes, including parameter edits after extrusion.
|
||||
Partial, intersected or ambiguous boundaries do not qualify for these whole-source references.
|
||||
Legacy curve-span multisets remain exact and unchanged; already-lost selections may need
|
||||
reselection. Format 12 stores the new bounded whole-boundary references.
|
||||
Projected analytic boundaries follow nested loop identities through resizing, including
|
||||
stationary holes and rounded corners. Supported convex, tangent line-and-arc boundaries
|
||||
can lose or regain corner arcs during an inset while preserving reserved curve identities
|
||||
and linked profile selections. Format 12 saves those associations; compatible older closed
|
||||
offsets upgrade structurally without changing their curve IDs. Breaking an association
|
||||
freezes its current boundary and materializes downstream profile references. Child offsets
|
||||
of a boundary with retired arcs still require breaking its association first; mixed or
|
||||
nonconvex junctions and retained references to disappearing individual arcs remain limited.
|
||||
- Extrude and Revolve editors retain original direction, extent expressions, explicit targets,
|
||||
axes, From/To inputs and All-profile intent. No-op edits preserve downstream dimension names
|
||||
and selected occurrence frames; cancel and successful edits preserve one-step undo history.
|
||||
Placed feature edits preview through the edited timeline entry, and cylindrical Emboss guides
|
||||
use the sketch's actual target, author or uniquely placed third-component frame.
|
||||
Revolve rotators follow the actual signed axis and support symmetric and two-sided extents;
|
||||
dragging a To Object extent converts its derived value to an explicit angle. Legacy reversed-axis
|
||||
symmetric geometry is preserved through format 11's explicit legacy convention. New revolves
|
||||
use the sketch-normal direction for each selected profile, including To Object and curved
|
||||
target trimming, independently of the selected axis sign.
|
||||
- Cylindrical emboss/deboss wraps bounded orthogonal line profiles, including holes, concave
|
||||
outlines and nested islands, onto complete exterior cylinders or coaxial sleeves with exact
|
||||
radial walls. New features infer a unique eligible cylinder when the face field is empty;
|
||||
ambiguous targets require an explicit face. Saved target
|
||||
and author occurrence paths retain the chosen placements; missing/split faces refuse rather
|
||||
than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces
|
||||
remain unsupported. STEP round-trip and independent radial/volume checks cover this subset.
|
||||
- Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not:
|
||||
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
|
||||
floor falling to a drain, as long as the corner's edges are all convex or all concave); 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.
|
||||
|
||||
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
|
||||
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
|
||||
the origin; clicked UI regressions verify geometry, closed profiles and undo.
|
||||
- Sketch display: drawing, picking, highlighting, tool previews and profile shading share
|
||||
adaptive projected curve boundaries. Fit, zoom, display scale and placed sketch frames
|
||||
invalidate the display cache without changing CAD geometry or timeline history. Work
|
||||
budgets and clipped paths prevent unbounded sampling or invented edges across the eye plane.
|
||||
The Sketch Palette scrolls its options on short windows and enlarged displays while
|
||||
keeping constraint status and Finish Sketch visible.
|
||||
- Face projection: closed boundaries have a deterministic outer-first order. Refresh matches
|
||||
existing geometry before assigning positions, retaining saved curve IDs and constraints
|
||||
across enumeration changes and resize, including concentric circles. Ambiguous identities
|
||||
retain the last geometry and report a lost reference; permutation matching is bounded.
|
||||
- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
|
||||
selecting visited values for replacement. Expression completion retains forward-Tab
|
||||
priority; real keyboard regressions cover rectangles, lines and single-value circles.
|
||||
Typed lengths, radii, line angles, signed arc sweeps and slot lengths persist as constraints
|
||||
and expressions through save/reopen. Shape creation and its dimensions form one undo step;
|
||||
rejected values keep the editable field focused.
|
||||
- Repeated components: Browser selection, visibility, properties, copy and Move/Copy use
|
||||
checked occurrence paths. Active nested occurrences author planar sketches and solids in
|
||||
their selected frame. Pending moves compose through parents; dialog cancellation restores
|
||||
its selected move. Source materials and body/face appearances follow transformed instances.
|
||||
Shared nested definitions remain shared. Deep Paste New copies reachable definition graphs,
|
||||
independent feature/input histories, styles and internal motion records, with bounded geometry
|
||||
checks and explicit external-link notices. Paste New selects the copy while preserving the active
|
||||
authoring context. Imported periodic STEP seams survive placement and reexport with shared
|
||||
topology and appearances intact. Recorded body transfers preserve historical Join/Cut membership,
|
||||
material, placements and machining data through edits, copying, undo and reopen. New 3D wires
|
||||
record their coordinate frame; legacy wires migrate from known provenance without changing
|
||||
coordinates. Broken legacy links with missing provenance can require explicit refresh or relink.
|
||||
See [occurrence workflows](docs/occurrence-workflows.md).
|
||||
- Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel.
|
||||
Parameters opens the user/model variable editor; Configurations opens the configuration table.
|
||||
Configure remains available in the panel dropdown.
|
||||
- Parameter creation supports Enter with validation and continued keyboard entry. Shared
|
||||
expression autocomplete supports Up/Down selection, Enter/Tab acceptance and Escape dismissal;
|
||||
accepting a suggestion does not submit a parameter or trigger viewport shortcuts.
|
||||
- Movable window title bars follow the app's dark/light header palette. Radial right-click
|
||||
menus are opt-in in Preferences > Navigation; ordinary context menus remain the default.
|
||||
- Desktop distribution: running Windows windows and executable resources use the app icon;
|
||||
packaging validates icon resources. Nix installs launchers, MIME data and generated icons,
|
||||
with explicit launch apps and Vulkan-loader/driver discovery. Flatpak feed instructions
|
||||
register the required runtime source; release checks exercise desktop modelling on X11/Wayland.
|
||||
- OpenGL rendering clamps effective anti-aliasing to 1x across UI pipelines, textures and CAD
|
||||
callbacks to prevent black frames. Other backends retain the requested sample count, and
|
||||
stored preferences remain unchanged.
|
||||
- Configuration rules: ordered typed conditions can drive parameters and suppressions or
|
||||
restrict values. Preview explains effective values without modifying the document; applying
|
||||
rules validates rows atomically. Length expressions retain their authoring units across
|
||||
document-unit changes. The UI supports structured All/Any/Not conditions and rule ordering.
|
||||
Component styles and certified semantic face styles are available; unsupported lineage refuses
|
||||
atomically, and stale certified face paint cannot jump to another surface. See [configurations](docs/configurations.md).
|
||||
- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained
|
||||
islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material
|
||||
pieces survive unfold/refold as one logical sheet group, including split flanged sheets.
|
||||
Circular cuts crossing bends remain unsupported; free-form bases use bounded sampling.
|
||||
Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and
|
||||
unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves
|
||||
exact holes and retained islands, including the union of multiple selected profiles, and
|
||||
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
|
||||
tapered or bend-crossing machining refuses atomically instead of restoring material later.
|
||||
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
|
||||
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
|
||||
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
|
||||
check, not a new Fusion oracle result. Under-resolved fits return an error.
|
||||
- Contact driving checks the trajectory between clear endpoints, including linked joints,
|
||||
playback and sparse motion exports. Checks use bounded tessellation samples, not exact
|
||||
continuous collision detection; local thin walls and small details remain limitations.
|
||||
See [contact-set limits and recovery](docs/contact-sets.md).
|
||||
|
||||
## Document-opening performance
|
||||
|
||||
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
|
||||
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
|
||||
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
|
||||
benchmark runs reduce median rebuild time by 26% on the public sensor enclosure and 22%
|
||||
on a local case assembly; shape measurements and topology match the original build.
|
||||
See [performance measurements](docs/perf.md). Further cold Boolean and import costs remain.
|
||||
- Prepared assembly geometry survives selection and view changes while real geometry and
|
||||
placement changes invalidate it. A Pi assembly edge-selection probe drops from 9.24 s to
|
||||
0.57 s; initial opening is unchanged. Actual Windows desktop profiling measures 15.87 s
|
||||
median opening, with 82% spent rebuilding geometry; halving full opening remains open.
|
||||
|
||||
## Kernel
|
||||
|
||||
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
|
||||
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
|
||||
support and shared topology, preserving real steps and intentional boundaries. The local
|
||||
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
|
||||
cover stepped stock, STEP round trips and saved projections from removed pieces.
|
||||
|
||||
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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,126 +0,0 @@
|
||||
//! Original SolidCraft mark, shared by desktop, packaging and CLI.
|
||||
/// This block is the icon image data
|
||||
pub fn rgba(size: usize) -> Result<Vec<u8>, String> {
|
||||
if !(1..=2048).contains(&size) {
|
||||
return Err("icon size must be 1..=2048".into());
|
||||
}
|
||||
type P = (f32, f32);
|
||||
// Coverage buffer at 4x4 samples per pixel (2x2 for big sizes), averaged down at the end.
|
||||
let ss = if size > 512 { 2 } else { 4 };
|
||||
let n = size * ss;
|
||||
let mut buf = vec![[0u8; 4]; n * n];
|
||||
let s = n as f32 / 412.217; // SVG units -> samples: the logo fills the height
|
||||
let ox = (n as f32 - 357.0 * s) / 2.0; // and is centred sideways
|
||||
// Fill a convex polygon given in SVG units.
|
||||
let mut fill = |pts: &[P], col: [u8; 3]| {
|
||||
let v: Vec<P> = pts.iter().map(|p| (ox + p.0 * s, p.1 * s)).collect();
|
||||
let (top, bottom) = v.iter().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.1), b.max(p.1)));
|
||||
for y in ((top - 0.5).ceil() as i32).max(0)..=((bottom - 0.5).floor() as i32).min(n as i32 - 1) {
|
||||
let cy = y as f32 + 0.5;
|
||||
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
|
||||
for i in 0..v.len() {
|
||||
let (a, b) = (v[i], v[(i + 1) % v.len()]);
|
||||
if (a.1 <= cy) != (b.1 <= cy) {
|
||||
let x = a.0 + (cy - a.1) / (b.1 - a.1) * (b.0 - a.0);
|
||||
lo = lo.min(x);
|
||||
hi = hi.max(x);
|
||||
}
|
||||
}
|
||||
if lo > hi {
|
||||
continue;
|
||||
}
|
||||
for x in ((lo - 0.5).ceil() as i32).max(0)..=((hi - 0.5).floor() as i32).min(n as i32 - 1) {
|
||||
buf[y as usize * n + x as usize] = [col[0], col[1], col[2], 255];
|
||||
}
|
||||
}
|
||||
};
|
||||
// Faces: each is a few overlapping convex pieces (the filler only does convex shapes).
|
||||
let (light, mid, dark) = ([96, 158, 236], [58, 136, 238], [55, 108, 197]);
|
||||
fill(&[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)], light);
|
||||
fill(&[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], light);
|
||||
fill(&[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)], mid);
|
||||
fill(&[(3.5, 105.9), (3.5, 205.7), (90.3, 256.2), (177.0, 206.1)], mid);
|
||||
fill(&[(177.0, 206.1), (90.3, 256.2), (90.0, 356.2), (177.0, 406.4)], mid);
|
||||
fill(&[(264.1, 155.8), (177.0, 206.1), (177.0, 406.4), (263.8, 256.2)], dark);
|
||||
fill(&[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], dark);
|
||||
fill(&[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)], dark);
|
||||
fill(&[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)], dark);
|
||||
// White edge lines, 10 units wide: a rectangle per segment and a disc at each inner corner.
|
||||
let white = [255, 255, 255];
|
||||
let mut stroke = |pts: &[P], closed: bool| {
|
||||
let m = pts.len();
|
||||
for i in 0..(if closed { m } else { m - 1 }) {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % m]);
|
||||
let l = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
|
||||
let (nx, ny) = (-(b.1 - a.1) / l * 5.0, (b.0 - a.0) / l * 5.0);
|
||||
fill(&[(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)], white);
|
||||
}
|
||||
for c in &pts[if closed { 0 } else { 1 }..if closed { m } else { m - 1 }] {
|
||||
let disc: Vec<P> = (0..16)
|
||||
.map(|k| {
|
||||
let t = k as f32 * std::f32::consts::TAU / 16.0;
|
||||
(c.0 + 5.0 * t.cos(), c.1 + 5.0 * t.sin())
|
||||
})
|
||||
.collect();
|
||||
fill(&disc, white);
|
||||
}
|
||||
};
|
||||
stroke(&[(263.8, 55.9), (177.1, 105.9), (263.8, 156.0), (263.8, 256.2), (350.6, 206.1)], false);
|
||||
stroke(&[(5.0, 105.9), (178.5, 206.1), (178.5, 406.4)], false);
|
||||
stroke(&[(177.0, 206.1), (263.8, 156.0)], false);
|
||||
stroke(
|
||||
&[
|
||||
(178.5, 5.8),
|
||||
(5.0, 105.9),
|
||||
(5.0, 206.1),
|
||||
(91.7, 256.2),
|
||||
(91.7, 356.4),
|
||||
(178.5, 406.4),
|
||||
(352.0, 306.3),
|
||||
(352.0, 206.1),
|
||||
(265.2, 156.0),
|
||||
(265.2, 55.9),
|
||||
],
|
||||
true,
|
||||
);
|
||||
// Average each ss x ss block down to one pixel (straight alpha = covered share).
|
||||
let mut rgba = Vec::with_capacity(size * size * 4);
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let (mut sum, mut cnt) = ([0u32; 3], 0u32);
|
||||
for j in 0..ss {
|
||||
for i in 0..ss {
|
||||
let p = buf[(y * ss + j) * n + x * ss + i];
|
||||
if p[3] != 0 {
|
||||
cnt += 1;
|
||||
for k in 0..3 {
|
||||
sum[k] += u32::from(p[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cnt == 0 {
|
||||
rgba.extend_from_slice(&[0, 0, 0, 0]);
|
||||
} else {
|
||||
let a = 255 * cnt / (ss * ss) as u32;
|
||||
rgba.extend_from_slice(&[(sum[0] / cnt) as u8, (sum[1] / cnt) as u8, (sum[2] / cnt) as u8, a as u8]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(rgba)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn shared_mark_has_transparency_and_original_colors_at_window_and_package_sizes() {
|
||||
for size in [16, 24, 32, 48, 64, 128, 256] {
|
||||
let pixels = super::rgba(size).expect("valid size");
|
||||
assert_eq!(pixels.len(), size * size * 4);
|
||||
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] == 0));
|
||||
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] == 255 && p[2] > p[0]));
|
||||
}
|
||||
assert!(super::rgba(0).is_err());
|
||||
assert!(super::rgba(2049).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
[package]
|
||||
name = "solvecraft-cli"
|
||||
description = "SolidCraft 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,224 +0,0 @@
|
||||
//! `solvecraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
|
||||
//!
|
||||
//! Each seed runs in its own process (`fuzz-one`) with a time limit, so a hang or a hard crash
|
||||
//! is recorded like any other failure.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::fuzz;
|
||||
use solvecraft_engine::fuzz::boolean_case;
|
||||
|
||||
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// One seed: a design and a boolean case, as JSON on stdout (progress on stderr).
|
||||
pub fn one(args: &[String]) -> Result<(), String> {
|
||||
let seed: u64 = args.first().and_then(|s| s.parse().ok()).ok_or("fuzz-one <seed> [--steps N] [--minimise]")?;
|
||||
let steps = flag(args, "--steps", 8usize);
|
||||
let minimise = args.iter().any(|a| a == "--minimise");
|
||||
// Panics caught inside the kernel: harmless natively, fatal in the web build.
|
||||
static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
std::panic::set_hook(Box::new(|_| {
|
||||
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}));
|
||||
eprintln!("design {seed}");
|
||||
let run = fuzz::run_with(seed, steps, &mut |st| eprintln!("step {}", serde_json::to_string(st).unwrap_or_default()));
|
||||
let mut design = json!({
|
||||
"outcome": run.outcome,
|
||||
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
|
||||
"unsupported": run.unsupported,
|
||||
"rejected": run.rejected,
|
||||
"panics": PANICS.swap(0, std::sync::atomic::Ordering::SeqCst),
|
||||
});
|
||||
if run.outcome.is_bug() {
|
||||
design["script"] = run.script();
|
||||
if minimise {
|
||||
eprintln!("minimise");
|
||||
let m = fuzz::minimise(&run);
|
||||
design["minimal"] = json!({"outcome": m.outcome, "steps": m.steps.iter().map(|s| s.kind).collect::<Vec<_>>(), "script": m.script()});
|
||||
}
|
||||
}
|
||||
let (a, b) = solvecraft_engine::fuzz::boolean_shapes(seed);
|
||||
eprintln!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
|
||||
let b = boolean_case(seed);
|
||||
let bp = PANICS.swap(0, std::sync::atomic::Ordering::SeqCst);
|
||||
println!("{}", json!({"seed": seed, "design": design, "boolean": b, "boolean_panics": bp}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Job {
|
||||
seed: u64,
|
||||
child: std::process::Child,
|
||||
started: Instant,
|
||||
err: std::sync::mpsc::Receiver<String>,
|
||||
last: String,
|
||||
}
|
||||
|
||||
/// Run seeds `--from`…`--from + --count` in `--jobs` processes, `--timeout` seconds each;
|
||||
/// write failing scripts to `--out` and a summary to `--report`.
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let from = flag(args, "--from", 0u64);
|
||||
let count = flag(args, "--count", 100u64);
|
||||
let last = from.checked_add(count.saturating_sub(1)).ok_or("seed range exceeds u64::MAX: reduce --from or --count")?;
|
||||
let mut seeds = (count > 0).then_some(from..=last).into_iter().flatten().peekable();
|
||||
let jobs = flag(args, "--jobs", 4usize).clamp(1, 64);
|
||||
let timeout = Duration::from_secs(flag(args, "--timeout", 120u64));
|
||||
let steps = flag(args, "--steps", 8usize);
|
||||
let out_dir = args.iter().position(|a| a == "--out").and_then(|i| args.get(i + 1)).cloned();
|
||||
let report = args.iter().position(|a| a == "--report").and_then(|i| args.get(i + 1)).cloned();
|
||||
let minimise = args.iter().any(|a| a == "--minimise");
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
if let Some(d) = &out_dir {
|
||||
std::fs::create_dir_all(d).map_err(|e| format!("{d}: {e}"))?;
|
||||
}
|
||||
let mut running: Vec<Job> = Vec::new();
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
let t0 = Instant::now();
|
||||
while seeds.peek().is_some() || !running.is_empty() {
|
||||
while running.len() < jobs {
|
||||
let Some(seed) = seeds.next() else { break };
|
||||
let mut cmd = Command::new(&exe);
|
||||
cmd.args(["fuzz-one", &seed.to_string(), "--steps", &steps.to_string()]);
|
||||
if minimise {
|
||||
cmd.arg("--minimise");
|
||||
}
|
||||
let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| e.to_string())?;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
if let Some(err) = child.stderr.take() {
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
running.push(Job { seed, child, started: Instant::now(), err: rx, last: String::new() });
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let mut i = 0;
|
||||
while i < running.len() {
|
||||
let Some(j) = running.get_mut(i) else { break };
|
||||
while let Ok(l) = j.err.try_recv() {
|
||||
if l.starts_with("step") || l.starts_with("boolean") || l.starts_with("minimise") {
|
||||
j.last = l;
|
||||
}
|
||||
}
|
||||
let done = match j.child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let mut s = String::new();
|
||||
if let Some(mut o) = j.child.stdout.take() {
|
||||
let _ = o.read_to_string(&mut s);
|
||||
}
|
||||
Some(match serde_json::from_str::<Value>(s.trim()) {
|
||||
Ok(v) => v,
|
||||
Err(_) => json!({"seed": j.seed, "crash": format!("exit {status}"), "at": j.last}),
|
||||
})
|
||||
}
|
||||
Ok(None) if j.started.elapsed() > timeout => {
|
||||
let _ = j.child.kill();
|
||||
let _ = j.child.wait();
|
||||
Some(json!({"seed": j.seed, "timeout": timeout.as_secs(), "at": j.last}))
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(e) => Some(json!({"seed": j.seed, "crash": e.to_string(), "at": j.last})),
|
||||
};
|
||||
match done {
|
||||
Some(v) => {
|
||||
record(&v, out_dir.as_deref());
|
||||
results.push(v);
|
||||
running.swap_remove(i);
|
||||
}
|
||||
None => i += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
let summary = summarise(&results, t0.elapsed());
|
||||
println!("{summary}");
|
||||
if let Some(r) = report {
|
||||
std::fs::write(&r, &summary).map_err(|e| format!("{r}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print a failure as it comes in and keep its script.
|
||||
fn record(v: &Value, out_dir: Option<&str>) {
|
||||
let seed = v["seed"].as_u64().unwrap_or(0);
|
||||
let d = &v["design"];
|
||||
let bug = matches!(d["outcome"]["kind"].as_str(), Some("failed" | "panic" | "invalid" | "volume"));
|
||||
if bug || v.get("timeout").is_some() || v.get("crash").is_some() {
|
||||
eprintln!("seed {seed}: {}", if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") });
|
||||
}
|
||||
let bk = v["boolean"]["outcome"].as_str().unwrap_or("ok");
|
||||
if bk != "ok" {
|
||||
eprintln!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]);
|
||||
}
|
||||
if let Some(dir) = out_dir {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(format!("{dir}/results.jsonl")) {
|
||||
let _ = writeln!(f, "{v}");
|
||||
}
|
||||
if bug {
|
||||
let script = if d.get("minimal").is_some() { &d["minimal"]["script"] } else { &d["script"] };
|
||||
let _ = std::fs::write(format!("{dir}/design-{seed}.json"), serde_json::to_string_pretty(script).unwrap_or_default());
|
||||
}
|
||||
if bk != "ok" {
|
||||
let _ = std::fs::write(format!("{dir}/boolean-{seed}.json"), serde_json::to_string_pretty(&v["boolean"]).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn summarise(results: &[Value], took: Duration) -> String {
|
||||
let n = results.len().max(1);
|
||||
let mut design: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut boolean: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut at_step: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut unsupported: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut steps = 0;
|
||||
let (mut dp, mut bp) = (0, 0);
|
||||
for v in results {
|
||||
dp += usize::from(v["design"]["panics"].as_u64().unwrap_or(0) > 0);
|
||||
bp += usize::from(v["boolean_panics"].as_u64().unwrap_or(0) > 0);
|
||||
let k = if v.get("timeout").is_some() {
|
||||
"timeout".to_string()
|
||||
} else if v.get("crash").is_some() {
|
||||
"crash".to_string()
|
||||
} else {
|
||||
v["design"]["outcome"]["kind"].as_str().unwrap_or("?").to_string()
|
||||
};
|
||||
if k != "ok" {
|
||||
let last = v["design"]["steps"].as_array().and_then(|a| a.last()).and_then(Value::as_str).unwrap_or("?");
|
||||
*at_step.entry(format!("{k} at {last}")).or_default() += 1;
|
||||
}
|
||||
*design.entry(k).or_default() += 1;
|
||||
*boolean.entry(v["boolean"]["outcome"].as_str().unwrap_or("not run").to_string()).or_default() += 1;
|
||||
steps += v["design"]["steps"].as_array().map(Vec::len).unwrap_or(0);
|
||||
for u in v["design"]["unsupported"].as_array().into_iter().flatten() {
|
||||
*unsupported.entry(u[0].as_str().unwrap_or("?").to_string()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let pct = |c: usize| format!("{c} ({:.1} %)", 100.0 * c as f64 / n as f64);
|
||||
let mut s = format!("{} seeds, {steps} design steps, {:.0} s\n\nDesigns:\n", results.len(), took.as_secs_f64());
|
||||
for (k, c) in &design {
|
||||
s += &format!("- {k}: {}\n", pct(*c));
|
||||
}
|
||||
s += "\nFailures by step:\n";
|
||||
for (k, c) in &at_step {
|
||||
s += &format!("- {k}: {c}\n");
|
||||
}
|
||||
s += "\nSteps refused as not supported (undone, the design went on):\n";
|
||||
for (k, c) in &unsupported {
|
||||
s += &format!("- {k}: {c}\n");
|
||||
}
|
||||
s += &format!("\nCaught panics (fatal in the web build): designs {}, boolean checks {}\n", pct(dp), pct(bp));
|
||||
s += "\nBoolean identity (union, intersection, cut of two primitives):\n";
|
||||
for (k, c) in &boolean {
|
||||
s += &format!("- {k}: {}\n", pct(*c));
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -1,387 +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 bench-open <design.solvecraft | part.step> [--parse-only] loading timings as JSON lines
|
||||
//! 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)
|
||||
//! solvecraft-cli licences SolveCraft's licence and the third-party licences
|
||||
//! solvecraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod fuzz;
|
||||
mod oracle;
|
||||
mod recipe;
|
||||
mod seams;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
#[path = "../../app_icon.rs"]
|
||||
mod app_icon;
|
||||
|
||||
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 bench-open <design.solvecraft | part.step> [--parse-only]
|
||||
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 fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
|
||||
solvecraft-cli step-corpus <case-dir>... [--json] [--iges]
|
||||
solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
|
||||
solvecraft-cli licences
|
||||
solvecraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
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") => {
|
||||
use std::io::Write as _;
|
||||
let mut s = Session::default();
|
||||
s.execute("engine.commands", &json!({})).map_err(|e| e.to_string()).and_then(|v| {
|
||||
// A pipeline consumer may stop early (e.g. `commands | head`).
|
||||
// Unlike println!, writing explicitly lets us handle that without a panic.
|
||||
match writeln!(std::io::stdout().lock(), "{}", pretty(&v)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
|
||||
Err(e) => Err(format!("stdout: {e}")),
|
||||
}
|
||||
})
|
||||
}
|
||||
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("fuzz") => fuzz::run(&args[1..]),
|
||||
Some("fuzz-one") => fuzz::one(&args[1..]),
|
||||
Some("mcp") => cmd_mcp(&args[1..]),
|
||||
Some("icon") => cmd_icon(&args[1..]),
|
||||
Some("licences" | "licenses") => {
|
||||
// Written, not print!ed: a closed pipe (`| head`) is not a crash.
|
||||
use std::io::Write as _;
|
||||
let _ = std::io::stdout().lock().write_all(solvecraft_engine::licences::all().as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
// Hidden, for the kill-mid-save test: save a large design over and over.
|
||||
Some("save-stress") => cmd_save_stress(&args[1..]),
|
||||
// Hidden: time recomputes after parameter edits (docs/perf.md).
|
||||
Some("bench-edit") => cmd_bench_edit(&args[1..]),
|
||||
Some("bench-open") => cmd_bench_open(&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> {
|
||||
// Through the engine's file access, which refuses anything that is not a regular file and
|
||||
// reads no more than the cap: a character device or a FIFO would otherwise never end.
|
||||
let s = solvecraft_engine::io::vfs::read_to_string(path, solvecraft_engine::io::MAX_DESIGN_BYTES).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(),
|
||||
};
|
||||
let results = if args.iter().any(|a| a == "--results") {
|
||||
// Collect every command's result (scripts only), then process requested files too.
|
||||
let v = read_json(path.ok_or("--results needs a script")?)?;
|
||||
let r = s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
Some(Value::Array(r))
|
||||
} else {
|
||||
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)?,
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
};
|
||||
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 let Some(results) = results {
|
||||
println!("{}", pretty(&results));
|
||||
} else 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(())
|
||||
}
|
||||
|
||||
/// The app icon: the SolidCraft logo, drawn in code on a transparent background
|
||||
/// (so packages need no committed image). `.ico` holds the Windows sizes as PNGs.
|
||||
fn cmd_icon(args: &[String]) -> Result<(), String> {
|
||||
let out = flag(args, "--out").ok_or("icon needs --out FILE.png or FILE.ico")?;
|
||||
let size: usize = flag(args, "--size").and_then(|x| x.parse().ok()).unwrap_or(256).clamp(8, 2048);
|
||||
let bytes = if out.to_ascii_lowercase().ends_with(".ico") {
|
||||
let pngs: Vec<(usize, Vec<u8>)> =
|
||||
[16, 20, 24, 32, 40, 48, 64, 128, 256].into_iter().map(|s| icon_png(s).map(|p| (s, p))).collect::<Result<_, _>>()?;
|
||||
ico(&pngs)?
|
||||
} else {
|
||||
icon_png(size)?
|
||||
};
|
||||
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
println!("{}", pretty(&json!({"path": out, "bytes": bytes.len()})));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
|
||||
solvecraft_engine::render::png(size, size, app_icon::rgba(size)?).ok_or_else(|| "icon: PNG encoding failed".to_string())
|
||||
}
|
||||
|
||||
/// A Windows icon file holding PNG images (Vista and later read these).
|
||||
fn ico(images: &[(usize, Vec<u8>)]) -> Result<Vec<u8>, String> {
|
||||
let n = u16::try_from(images.len()).map_err(|_| "too many icon sizes")?;
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&[0, 0, 1, 0]);
|
||||
out.extend_from_slice(&n.to_le_bytes());
|
||||
let mut offset = 6 + 16 * images.len();
|
||||
for (size, data) in images {
|
||||
let dim = if *size >= 256 { 0 } else { *size as u8 };
|
||||
out.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]);
|
||||
out.extend_from_slice(&u32::try_from(data.len()).map_err(|_| "icon too large")?.to_le_bytes());
|
||||
out.extend_from_slice(&u32::try_from(offset).map_err(|_| "icon too large")?.to_le_bytes());
|
||||
offset += data.len();
|
||||
}
|
||||
for (_, data) in images {
|
||||
out.extend_from_slice(data);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
let id = args.first().ok_or_else(|| USAGE.to_string())?;
|
||||
let p: Value = match args.get(1) {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
|
||||
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
let path = args.first().ok_or("usage: solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]")?;
|
||||
let t = std::time::Instant::now();
|
||||
let step = solvecraft_engine::io::is_step_path(path);
|
||||
let limit = if step { solvecraft_engine::io::MAX_STEP_BYTES } else { solvecraft_engine::io::MAX_DESIGN_BYTES };
|
||||
let bytes = solvecraft_engine::io::vfs::read(path, limit).map_err(|e| format!("{path}: {e}"))?;
|
||||
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()}));
|
||||
let t = std::time::Instant::now();
|
||||
let doc = if step {
|
||||
let imported = solvecraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
|
||||
let mut doc = solvecraft_engine::doc::Document::new(&imported.name);
|
||||
doc.add_feature(imported.kind, Some(&imported.name)).map_err(|e| e.to_string())?;
|
||||
doc
|
||||
} else {
|
||||
solvecraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?
|
||||
};
|
||||
let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len()}));
|
||||
if args.iter().any(|a| a == "--parse-only") {
|
||||
return Ok(());
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let s = Session::new(doc);
|
||||
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0}));
|
||||
for r in &s.model.results {
|
||||
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
let st = s.world_state();
|
||||
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
|
||||
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,277 +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; SolidCraft 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 SolidCraft/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; SolidCraft'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.
|
||||
/// Open the case's `part.step` (or `part.igs` with `iges`) and compare with measure.json.
|
||||
fn check_step_case(dir: &str, iges: bool) -> 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}/{}", if iges { "part.igs" } else { "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| {
|
||||
let text = String::from_utf8_lossy(&b);
|
||||
if iges {
|
||||
solvecraft_engine::kernel::iges_import(&text).ok().map(std::sync::Arc::new)
|
||||
} else {
|
||||
solvecraft_engine::kernel::step_import_shared(&text).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 iges = args.iter().any(|a| a == "--iges");
|
||||
let results: Vec<Value> = dirs.iter().map(|d| check_step_case(d, iges)).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,26 +0,0 @@
|
||||
//! The registry remains valid JSON and a closed pipeline reader must not cause a panic.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn registry_output_is_complete_json() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let registry: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
let commands = registry.as_array().unwrap();
|
||||
assert!(commands.iter().any(|command| command["id"] == "solid.box"));
|
||||
assert!(commands.iter().any(|command| command["id"] == "engine.commands"));
|
||||
assert!(output.stdout.ends_with(b"\n"));
|
||||
assert!(output.stderr.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_registry_pipe_exits_without_panicking() {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
// Close our read end before waiting. No sleeps or timing threshold are needed:
|
||||
// the registry is larger than the pipe buffer, so it cannot all be written first.
|
||||
drop(child.stdout.take());
|
||||
let output = child.wait_with_output().unwrap();
|
||||
assert!(output.status.success(), "status: {}; stderr: {}", output.status, String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
@@ -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,251 +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!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostile shapes that no mutation of a saved file produces: plane references that close a
|
||||
/// cycle, which resolve through the model and back into the document.
|
||||
fn generated() -> Vec<(String, Vec<u8>)> {
|
||||
let to = |name: &str| json!({"type": "plane", "plane": {"type": "construction", "name": name}});
|
||||
let plane = |id: u64, name: &str, side: Value| {
|
||||
json!({"id": id, "name": name, "suppressed": false, "type": "construction_plane",
|
||||
"plane": {"type": "midplane", "a": side, "b": {"type": "plane", "plane": {"type": "origin", "name": "XZ"}}}})
|
||||
};
|
||||
let design = |name: &str, features: Value| {
|
||||
json!({"format": "solvecraft/11", "name": name, "units": "mm", "params": [], "next_id": 9, "features": features}).to_string().into_bytes()
|
||||
};
|
||||
// A sketch past the caps interactive editing enforces: solving it would size dense matrices
|
||||
// by the point count and abort on the allocation.
|
||||
let point = json!({"id": "p1", "pos": [0.0, 0.0], "fixed": false});
|
||||
let oversized = json!([{"id": 1, "name": "Sketch1", "suppressed": false, "type": "sketch",
|
||||
"plane": {"type": "origin", "name": "XY"},
|
||||
"sketch": {"points": vec![point; 200_000], "curves": [], "constraints": []}}]);
|
||||
vec![
|
||||
("construction plane is its own side".into(), design("selfplane", json!([plane(1, "P1", to("P1"))]))),
|
||||
("two construction planes refer to each other".into(), design("twoplanes", json!([plane(1, "P1", to("P2")), plane(2, "P2", to("P1"))]))),
|
||||
("sketch with 200,000 points".into(), design("bigsketch", oversized)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_and_hostile_design_files_never_panic() {
|
||||
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
|
||||
// The cases come from one seeded sequence; trying them is independent, so threads share it.
|
||||
let mut all_cases: Vec<(String, String, Vec<u8>)> = 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()));
|
||||
all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b)));
|
||||
}
|
||||
all_cases.extend(generated().into_iter().map(|(what, b)| ("generated".to_string(), what, b)));
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
|
||||
let failures: Vec<String> = std::thread::scope(|scope| {
|
||||
let workers: Vec<_> = (0..threads)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
let mut failures = Vec::new();
|
||||
while let Some((name, what, b)) = all_cases.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
failures
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
|
||||
});
|
||||
assert!(failures.is_empty(), "panics:\n{}", failures.join("\n"));
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
//! Seed ranges must not crash or wrap, including at the last representable seed.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn overflowing_seed_range_is_an_error_not_a_panic() {
|
||||
let output =
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("seed range exceeds u64::MAX"), "{stderr}");
|
||||
assert!(!stderr.contains("panicked"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_seed_range_at_maximum_is_valid() {
|
||||
let output =
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(String::from_utf8_lossy(&output.stdout).starts_with("0 seeds, 0 design steps,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_seed_runs_once_without_wrapping() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
|
||||
.args(["fuzz", "--from", "18446744073709551615", "--count", "1", "--steps", "0", "--timeout", "0"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(String::from_utf8_lossy(&output.stdout).starts_with("1 seeds,"));
|
||||
}
|
||||
@@ -1,55 +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();
|
||||
let modified = || std::fs::metadata(&path).and_then(|m| m.modified()).ok();
|
||||
for i in 0..25u64 {
|
||||
let before = modified();
|
||||
let mut child = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "100000"]).spawn().unwrap();
|
||||
// Wait until it is in its save loop (its first save replaced the file) and has reached a
|
||||
// round that varies from kill to kill, however busy the machine is; then kill it a
|
||||
// varying while later.
|
||||
let target = i % 10;
|
||||
let reached = || modified() != before && round_of(&path).and_then(|r| r.parse::<u64>().ok()).is_some_and(|r| r >= target);
|
||||
let t0 = std::time::Instant::now();
|
||||
while !reached() && t0.elapsed() < Duration::from_secs(20) && child.try_wait().unwrap().is_none() {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis((i * 7) % 30));
|
||||
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,97 +0,0 @@
|
||||
//! Result reporting must not skip requested files or execute a script twice.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-run-results-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
|
||||
fn script(&self) -> PathBuf {
|
||||
let path = self.0.join("box.json");
|
||||
std::fs::write(&path, json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}}]}).to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
|
||||
}
|
||||
|
||||
fn json_output(output: Output) -> Value {
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
serde_json::from_slice(&output.stdout).unwrap()
|
||||
}
|
||||
|
||||
fn body_count(path: &Path) -> Value {
|
||||
json_output(cli().arg("eval").arg(path).output().unwrap())["body_count"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_still_save_and_export_the_script_once() {
|
||||
let dir = Scratch::new();
|
||||
let saved = dir.0.join("box.solvecraft");
|
||||
let stl = dir.0.join("box.stl");
|
||||
let obj = dir.0.join("box.obj");
|
||||
let result = json_output(
|
||||
cli().arg("run").arg(dir.script()).arg("--results").arg("--save").arg(&saved).arg("--out").arg(&stl).arg("--out").arg(&obj).output().unwrap(),
|
||||
);
|
||||
assert_eq!(result.as_array().unwrap().len(), 1);
|
||||
assert_eq!(body_count(&saved), 1);
|
||||
for exported in [&stl, &obj] {
|
||||
assert!(std::fs::metadata(exported).unwrap().len() > 0);
|
||||
assert_eq!(body_count(exported), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_with_base_design_and_quiet_still_report_and_save() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script();
|
||||
let base = dir.0.join("base.solvecraft");
|
||||
let saved = dir.0.join("combined.solvecraft");
|
||||
json_output(cli().arg("run").arg(&script).arg("--save").arg(&base).output().unwrap());
|
||||
let result = json_output(cli().args(["run", "--results", "--quiet", "--in"]).arg(&base).arg(&script).arg("--save").arg(&saved).output().unwrap());
|
||||
assert_eq!(result.as_array().unwrap().len(), 1);
|
||||
assert_eq!(body_count(&saved), 2);
|
||||
assert_eq!(body_count(&base), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_propagate_output_errors() {
|
||||
let dir = Scratch::new();
|
||||
for flag in ["--save", "--out"] {
|
||||
let path = dir.0.join("missing").join(if flag == "--save" { "box.solvecraft" } else { "box.stl" });
|
||||
let output = cli().arg("run").arg(dir.script()).arg("--results").arg(flag).arg(&path).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(!output.stderr.is_empty());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_without_script_still_fail() {
|
||||
let dir = Scratch::new();
|
||||
let base = dir.0.join("base.solvecraft");
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
|
||||
let output = cli().args(["run", "--results", "--in"]).arg(&base).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("--results needs a script"));
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
[package]
|
||||
name = "solvecraft-web"
|
||||
description = "SolidCraft 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",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget",
|
||||
] }
|
||||
@@ -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>SolidCraft</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 SolidCraft…</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}"), solvecraft_engine::io::MAX_DESIGN_BYTES).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,425 +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;
|
||||
|
||||
thread_local! {
|
||||
/// An open design has unsaved changes: leaving the page asks first.
|
||||
static UNSAVED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
|
||||
/// unsaved changes (the page can't show its own dialog when it is being closed).
|
||||
fn ask_before_unload(window: &web_sys::Window) {
|
||||
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
|
||||
if UNSAVED.with(std::cell::Cell::get) {
|
||||
e.prevent_default();
|
||||
e.set_return_value("unsaved");
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
on_unload.forget();
|
||||
}
|
||||
|
||||
struct Shell {
|
||||
app: SolveApp,
|
||||
files: Rc<Files>,
|
||||
/// When the last autosave was (seconds), and the revision it holds (none stored: `None`).
|
||||
autosaved_at: f64,
|
||||
autosaved: Option<u64>,
|
||||
/// A stored design being renamed: (its name, the new name).
|
||||
renaming: Option<(String, String)>,
|
||||
/// The rename field takes the keyboard when it appears.
|
||||
rename_focus: bool,
|
||||
/// The start page's recent designs were filled from the stored ones.
|
||||
recents_synced: bool,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
/// Files from the computer, writes to store, messages and the autosave.
|
||||
fn files_tick(&mut self, ctx: &egui::Context) {
|
||||
if self.files.ctx.borrow().is_none() {
|
||||
*self.files.ctx.borrow_mut() = Some(ctx.clone());
|
||||
}
|
||||
files::flush(&self.files);
|
||||
for (text, error) in self.files.messages.borrow_mut().drain(..) {
|
||||
self.app.set_status(text, error);
|
||||
}
|
||||
let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
|
||||
for (name, bytes, intent) in incoming {
|
||||
let design = name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
|
||||
let path = if design && intent == Intent::Open {
|
||||
// A design from the computer is kept in the browser (a copy), so Save works.
|
||||
let path = files::free_name(&name);
|
||||
if let Err(e) = vfs::write(&path, &bytes) {
|
||||
self.app.set_status(e.to_string(), true);
|
||||
}
|
||||
files::flush(&self.files);
|
||||
path
|
||||
} else {
|
||||
let path = format!("{}{name}", files::UPLOAD);
|
||||
vfs::insert(&path, bytes);
|
||||
path
|
||||
};
|
||||
match intent {
|
||||
Intent::Open => self.app.open_path(&path),
|
||||
Intent::Insert => self.app.drop_path(&path),
|
||||
}
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
if self.files.ready.get() && !self.recents_synced {
|
||||
self.recents_synced = true;
|
||||
self.sync_recents();
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
// Unsaved work is kept (at most every AUTOSAVE_S); saved work needs no autosave.
|
||||
if self.files.ready.get() && self.files.recovery.borrow().is_none() {
|
||||
let s = &self.app.session;
|
||||
let changed = s.is_dirty() && self.autosaved != Some(s.revision);
|
||||
let wait = AUTOSAVE_S - (now - self.autosaved_at);
|
||||
if changed && wait <= 0.0 {
|
||||
files::autosave(&self.files, Some((&s.doc, s.path.as_deref())));
|
||||
self.autosaved = Some(s.revision);
|
||||
self.autosaved_at = now;
|
||||
} else if changed {
|
||||
// Come back for it even when nothing else happens.
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
|
||||
} else if !s.is_dirty() && self.autosaved.is_some() {
|
||||
files::autosave(&self.files, None);
|
||||
self.autosaved = None;
|
||||
}
|
||||
}
|
||||
if !self.files.ready.get() || !self.files.incoming.borrow().is_empty() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// The start page's recent designs are the stored ones: the remembered order first (those
|
||||
/// still stored), then the others, newest first.
|
||||
fn sync_recents(&mut self) {
|
||||
let mut entries = self.files.entries.borrow().clone();
|
||||
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
|
||||
let recent = &mut self.app.home.recent;
|
||||
recent.retain(|p| vfs::exists(p));
|
||||
for e in entries.iter().filter(|e| e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT))) {
|
||||
let p = format!("{}{}", files::OPFS, e.name);
|
||||
if !recent.contains(&p) {
|
||||
recent.push(p);
|
||||
}
|
||||
}
|
||||
recent.truncate(solvecraft_ui_egui::home::MAX_RECENT);
|
||||
}
|
||||
|
||||
/// The stored designs: open, insert, rename, delete, download; open from the computer.
|
||||
fn file_panel(&mut self, ctx: &egui::Context) {
|
||||
if !self.files.panel.get() {
|
||||
return;
|
||||
}
|
||||
let mut open = true;
|
||||
let mut act: Option<(&'static str, String)> = None;
|
||||
egui::Window::new("Files in this browser")
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(true)
|
||||
.default_width(520.0)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
.default_pos(ctx.content_rect().center())
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Open from computer…").clicked() {
|
||||
files::pick_from_disk(&self.files, Intent::Open);
|
||||
}
|
||||
if ui.button("Insert from computer…").on_hover_text("STEP, 3MF or STL into the open design").clicked() {
|
||||
files::pick_from_disk(&self.files, Intent::Insert);
|
||||
}
|
||||
if ui.button("Download open design").clicked() {
|
||||
act = Some(("download_open", String::new()));
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if !self.files.ready.get() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Reading the browser's storage…");
|
||||
});
|
||||
return;
|
||||
}
|
||||
let entries = self.files.entries.borrow().clone();
|
||||
if entries.is_empty() {
|
||||
ui.label("Designs you save appear here. They stay in this browser.");
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
egui::Grid::new("opfs_files").num_columns(3).spacing([12.0, 6.0]).show(ui, |ui| {
|
||||
for e in &entries {
|
||||
match &mut self.renaming {
|
||||
Some((from, to)) if *from == e.name => {
|
||||
let r = ui.add(egui::TextEdit::singleline(to).desired_width(200.0));
|
||||
if std::mem::take(&mut self.rename_focus) {
|
||||
r.request_focus();
|
||||
}
|
||||
if r.lost_focus() {
|
||||
act = Some(if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
("rename", to.clone())
|
||||
} else {
|
||||
("rename_cancel", String::new())
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
ui.label(&e.name);
|
||||
}
|
||||
}
|
||||
ui.label(format!("{:.1} KB", e.size as f64 / 1024.0));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.small_button("Open").clicked() {
|
||||
act = Some(("open", e.name.clone()));
|
||||
}
|
||||
let design = e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
|
||||
if !design && ui.small_button("Insert").on_hover_text("Into the open design (STEP, 3MF, STL)").clicked() {
|
||||
act = Some(("insert", e.name.clone()));
|
||||
}
|
||||
if ui.small_button("Rename").clicked() {
|
||||
self.renaming = Some((e.name.clone(), e.name.clone()));
|
||||
self.rename_focus = true;
|
||||
}
|
||||
if ui.small_button("Download").clicked() {
|
||||
act = Some(("download", e.name.clone()));
|
||||
}
|
||||
if ui.small_button("Delete").clicked() {
|
||||
act = Some(("delete", e.name.clone()));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if !open {
|
||||
self.files.panel.set(false);
|
||||
self.renaming = None;
|
||||
}
|
||||
let Some((what, name)) = act else { return };
|
||||
let path = format!("{}{name}", files::OPFS);
|
||||
match what {
|
||||
"open" => {
|
||||
self.app.open_path(&path);
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
"insert" => {
|
||||
self.app.insert_path(&path);
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
"download" => match vfs::read(&path, solvecraft_engine::io::MAX_DESIGN_BYTES) {
|
||||
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);
|
||||
UNSAVED.with(|u| u.set(solvecraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
}
|
||||
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(window) = web_sys::window() else { return };
|
||||
ask_before_unload(&window);
|
||||
let Some(document) = window.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 {
|
||||
// eframe's web painter has no stencil buffer.
|
||||
app.set_wgpu(rs, DEPTH_BITS, 0, 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>SolidCraft 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,29 +0,0 @@
|
||||
[package]
|
||||
name = "solvecraft"
|
||||
description = "SolidCraft 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", "links"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource = "3.0.11"
|
||||
@@ -1,61 +0,0 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#[path = "../app_icon.rs"]
|
||||
mod app_icon;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("cargo:rerun-if-changed=../app_icon.rs");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" {
|
||||
return Ok(());
|
||||
}
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").ok_or("missing OUT_DIR")?);
|
||||
let sizes = [16usize, 24, 32, 48, 64, 128, 256];
|
||||
let mut images = Vec::new();
|
||||
for size in sizes {
|
||||
let rgba = app_icon::rgba(size)?;
|
||||
// ICO DIB: bottom-up BGRA plus a padded one-bit transparency mask.
|
||||
let mask_stride = size.div_ceil(32) * 4;
|
||||
let mut dib = Vec::new();
|
||||
dib.extend_from_slice(&40u32.to_le_bytes());
|
||||
dib.extend_from_slice(&(size as i32).to_le_bytes());
|
||||
dib.extend_from_slice(&((size * 2) as i32).to_le_bytes());
|
||||
dib.extend_from_slice(&1u16.to_le_bytes());
|
||||
dib.extend_from_slice(&32u16.to_le_bytes());
|
||||
dib.extend_from_slice(&[0; 24]);
|
||||
for row in rgba.chunks_exact(size * 4).rev() {
|
||||
for p in row.as_chunks::<4>().0.iter() {
|
||||
dib.extend_from_slice(&[p[2], p[1], p[0], p[3]]);
|
||||
}
|
||||
}
|
||||
for row in rgba.chunks_exact(size * 4).rev() {
|
||||
let mut mask = vec![0u8; mask_stride];
|
||||
for (x, p) in row.as_chunks::<4>().0.iter().enumerate() {
|
||||
if p[3] == 0 {
|
||||
mask[x / 8] |= 0x80 >> (x % 8);
|
||||
}
|
||||
}
|
||||
dib.extend_from_slice(&mask);
|
||||
}
|
||||
images.push((size, dib));
|
||||
}
|
||||
let mut ico = vec![0, 0, 1, 0];
|
||||
ico.extend_from_slice(&(images.len() as u16).to_le_bytes());
|
||||
let mut offset = 6 + 16 * images.len();
|
||||
for (size, data) in &images {
|
||||
let dim = if *size == 256 { 0 } else { *size as u8 };
|
||||
ico.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]);
|
||||
ico.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
ico.extend_from_slice(&(offset as u32).to_le_bytes());
|
||||
offset += data.len();
|
||||
}
|
||||
for (_, data) in images {
|
||||
ico.extend_from_slice(&data);
|
||||
}
|
||||
let icon = out.join("solidcraft.ico");
|
||||
std::fs::write(&icon, ico)?;
|
||||
let rc = out.join("solidcraft.rc");
|
||||
std::fs::write(&rc, format!("1 ICON \"{}\"\n", icon.display().to_string().replace('\\', "/")))?;
|
||||
// Missing resource tools are a build error, rather than silently shipping a generic icon.
|
||||
embed_resource::compile_for(&rc, ["solvecraft"], embed_resource::NONE).manifest_required()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,170 +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::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::MAX_LINE;
|
||||
use solvecraft_ui_egui::ControlRequest;
|
||||
|
||||
/// Connections served at once. The channel has no authentication beyond being bound to
|
||||
/// loopback, so a flood must not be able to spawn threads without end.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
/// Lines that are not JSON before the connection is closed, so garbage cannot loop forever.
|
||||
const MAX_BAD_LINES: usize = 8;
|
||||
|
||||
static OPEN: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
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() {
|
||||
if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
let mut refused = stream;
|
||||
let _ = writeln!(refused, "{}", json!({"ok": false, "error": "too many connections"}));
|
||||
continue;
|
||||
}
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, tx, ctx);
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
/// One line without its newline, or `None` at the end of the stream. A client that sends
|
||||
/// gigabytes with no newline would otherwise make this thread allocate until the process is
|
||||
/// killed, so the line is refused as soon as it passes `max`.
|
||||
fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<Vec<u8>>> {
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
let (done, used) = {
|
||||
let available = r.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
return Ok((!line.is_empty()).then_some(line));
|
||||
}
|
||||
match available.iter().position(|b| *b == b'\n') {
|
||||
Some(i) => {
|
||||
line.extend_from_slice(available.get(..i).unwrap_or(&[]));
|
||||
(true, i + 1)
|
||||
}
|
||||
None => {
|
||||
line.extend_from_slice(available);
|
||||
(false, available.len())
|
||||
}
|
||||
}
|
||||
};
|
||||
r.consume(used);
|
||||
if done {
|
||||
return Ok(Some(line));
|
||||
}
|
||||
if line.len() > max {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "request too large"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut bad = 0usize;
|
||||
loop {
|
||||
let line = match read_line(&mut reader, MAX_LINE) {
|
||||
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e.to_string()}));
|
||||
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) => {
|
||||
bad += 1;
|
||||
json!({"ok": false, "error": format!("bad JSON: {e}")})
|
||||
}
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
if bad >= MAX_BAD_LINES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A request longer than the cap is refused instead of allocated.
|
||||
#[test]
|
||||
fn lines_past_the_cap_are_refused() {
|
||||
let flood = vec![b'A'; MAX_LINE + 2];
|
||||
let mut r = BufReader::new(flood.as_slice());
|
||||
let e = read_line(&mut r, MAX_LINE).unwrap_err();
|
||||
assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(e.to_string().contains("request too large"), "{e}");
|
||||
}
|
||||
|
||||
/// Ordinary framing: lines split on newlines, blank lines kept, no trailing newline needed.
|
||||
#[test]
|
||||
fn lines_split_on_newlines() {
|
||||
let text = b"{\"a\":1}\n\n{\"b\":2}\nlast".to_vec();
|
||||
let mut r = BufReader::new(text.as_slice());
|
||||
let mut got = Vec::new();
|
||||
while let Some(line) = read_line(&mut r, MAX_LINE).unwrap() {
|
||||
got.push(String::from_utf8(line).unwrap());
|
||||
}
|
||||
assert_eq!(got, vec!["{\"a\":1}", "", "{\"b\":2}", "last"]);
|
||||
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
|
||||
}
|
||||
|
||||
/// A line that spans several reads of the underlying stream still arrives whole.
|
||||
#[test]
|
||||
fn lines_longer_than_one_buffer_arrive_whole() {
|
||||
let long = "x".repeat(200_000);
|
||||
let text = format!("{long}\n{long}\n").into_bytes();
|
||||
let mut r = BufReader::with_capacity(64, text.as_slice());
|
||||
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
|
||||
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
|
||||
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
|
||||
// The same line against a smaller cap is refused.
|
||||
let mut r = BufReader::with_capacity(64, text.as_slice());
|
||||
assert!(read_line(&mut r, 1000).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,263 +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)]
|
||||
|
||||
#[path = "../../app_icon.rs"]
|
||||
mod app_icon;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod space_mouse;
|
||||
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_ui_egui::{Services, SolveApp};
|
||||
|
||||
struct App {
|
||||
solve: SolveApp,
|
||||
#[cfg(target_os = "windows")]
|
||||
space_mouse: space_mouse::SpaceMouse,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
|
||||
// designs first; the window closes once they are answered (`quit_requested`).
|
||||
if ctx.input(|i| i.viewport().close_requested()) && !self.solve.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
solvecraft_ui_egui::documents::request_quit(&mut self.solve);
|
||||
}
|
||||
self.solve.logic(ctx);
|
||||
#[cfg(target_os = "windows")]
|
||||
self.space_mouse.tick(&mut self.solve, ctx);
|
||||
if self.solve.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.solve.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.solve.ui(ui);
|
||||
}
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(PREFS_KEY, self.solve.prefs());
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
solvecraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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("SolidCraft 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("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
|
||||
.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()),
|
||||
show_in_folder: Some(Box::new(|path| {
|
||||
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
|
||||
"explorer"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
} else {
|
||||
"xdg-open"
|
||||
});
|
||||
command.arg(folder);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x08000000);
|
||||
}
|
||||
let _ = command.spawn();
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
/// The section cap marks where it shows in the stencil.
|
||||
const STENCIL_BITS: u8 = 8;
|
||||
const MSAA: u16 = 4;
|
||||
|
||||
fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
|
||||
Ok(egui::ViewportBuilder::default()
|
||||
.with_title("SolidCraft")
|
||||
.with_icon(egui::IconData {
|
||||
rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?,
|
||||
width: 64,
|
||||
height: 64,
|
||||
})
|
||||
.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"))
|
||||
}
|
||||
|
||||
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: app_viewport()?,
|
||||
depth_buffer: DEPTH_BITS,
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
multisampling: samples,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut options = options;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; SOLVECRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("SOLVECRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
"SolidCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
solvecraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
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 {
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
|
||||
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_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_async(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)));
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
#[cfg(target_os = "windows")]
|
||||
space_mouse,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod icon_tests {
|
||||
#[test]
|
||||
fn desktop_viewport_supplies_the_original_mark_to_the_window() {
|
||||
let viewport = super::app_viewport().expect("viewport");
|
||||
let icon = viewport.icon.expect("running window must have an icon");
|
||||
assert_eq!((icon.width, icon.height), (64, 64));
|
||||
assert_eq!(icon.rgba, super::app_icon::rgba(64).expect("original mark"));
|
||||
assert_eq!(viewport.app_id.as_deref(), Some("ai.storyteller.solvecraft"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod msaa_tests {
|
||||
#[test]
|
||||
fn saved_multisampling_uses_the_selected_backend_policy() {
|
||||
use eframe::wgpu::Backend;
|
||||
for requested in [1, 2, 4, 8] {
|
||||
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(Backend::Gl, requested), 1);
|
||||
for backend in [Backend::Vulkan, Backend::Dx12, Backend::Metal] {
|
||||
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(backend, requested), requested);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
//! Windows 3Dconnexion HID navigation. The device reader never touches the document or UI;
|
||||
//! it publishes the latest axes, and the egui thread applies them once per frame.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use egui::Context;
|
||||
use hidapi::{DeviceInfo, HidApi};
|
||||
use solvecraft_engine::render::Camera;
|
||||
use solvecraft_ui_egui::SolveApp;
|
||||
|
||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(2);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Axes {
|
||||
translation: [i16; 3],
|
||||
rotation: [i16; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Reading {
|
||||
axes: Axes,
|
||||
translation_at: Instant,
|
||||
rotation_at: Instant,
|
||||
}
|
||||
|
||||
impl Reading {
|
||||
fn new(now: Instant) -> Self {
|
||||
Self { axes: Axes::default(), translation_at: now, rotation_at: now }
|
||||
}
|
||||
|
||||
fn current(self, now: Instant) -> Axes {
|
||||
Axes {
|
||||
translation: if now.duration_since(self.translation_at) < STALE_AFTER { self.axes.translation } else { [0; 3] },
|
||||
rotation: if now.duration_since(self.rotation_at) < STALE_AFTER { self.axes.rotation } else { [0; 3] },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The old SpaceNavigator uses Logitech's VID; newer 3Dconnexion devices use their own.
|
||||
/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice.
|
||||
fn is_3d_mouse(info: &DeviceInfo) -> bool {
|
||||
matches!(info.vendor_id(), 0x046d | 0x256f) && info.usage_page() == 0x01 && info.usage() == 0x08
|
||||
}
|
||||
|
||||
fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
|
||||
let &[x0, x1, y0, y1, z0, z1] = bytes.get(..6)? else { return None };
|
||||
Some([i16::from_le_bytes([x0, x1]), i16::from_le_bytes([y0, y1]), i16::from_le_bytes([z0, z1])])
|
||||
}
|
||||
|
||||
/// SpaceNavigator reports 1 (translation) and 2 (rotation), each with three signed
|
||||
/// little-endian 16-bit values. The HID layer may pad either report to its maximum size.
|
||||
fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool {
|
||||
match report.first().copied() {
|
||||
Some(1) => {
|
||||
let Some(translation) = report.get(1..).and_then(three_axes) else { return false };
|
||||
reading.axes.translation = translation;
|
||||
reading.translation_at = now;
|
||||
true
|
||||
}
|
||||
Some(2) => {
|
||||
let Some(rotation) = report.get(1..).and_then(three_axes) else { return false };
|
||||
reading.axes.rotation = rotation;
|
||||
reading.rotation_at = now;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
let Ok(mut api) = HidApi::new() else {
|
||||
log::warn!("3D mouse: HID initialization failed");
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
if let Err(error) = api.refresh_devices() {
|
||||
log::warn!("3D mouse: device scan failed: {error}");
|
||||
} else if let Some(device) = api.device_list().find(|info| is_3d_mouse(info)).and_then(|info| info.open_device(&api).ok()) {
|
||||
let mut report = [0_u8; 64];
|
||||
loop {
|
||||
match device.read_timeout(&mut report, 100) {
|
||||
Ok(0) => {}
|
||||
Ok(size) => {
|
||||
if let Ok(mut reading) = shared.lock()
|
||||
&& decode_report(&report[..size], &mut reading, Instant::now())
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
*reading = Reading::new(Instant::now());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SpaceMouse {
|
||||
shared: Arc<Mutex<Reading>>,
|
||||
last_frame: Instant,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
pub fn start(ctx: &Context) -> Self {
|
||||
let now = Instant::now();
|
||||
let shared = Arc::new(Mutex::new(Reading::new(now)));
|
||||
let worker_state = Arc::clone(&shared);
|
||||
let worker_ctx = ctx.clone();
|
||||
if let Err(error) = std::thread::Builder::new().name("solvecraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
|
||||
log::warn!("3D mouse: reader could not start: {error}");
|
||||
}
|
||||
Self { shared, last_frame: now }
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
|
||||
let now = Instant::now();
|
||||
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
|
||||
self.last_frame = now;
|
||||
if !app.preferences.space_mouse_enabled || app.home.open {
|
||||
return;
|
||||
}
|
||||
let axes = match self.shared.lock() {
|
||||
Ok(reading) => reading.current(now),
|
||||
Err(_) => return,
|
||||
};
|
||||
if axes == Axes::default() {
|
||||
return;
|
||||
}
|
||||
let speed = f64::from(app.preferences.space_mouse_speed);
|
||||
if !speed.is_finite() {
|
||||
return;
|
||||
}
|
||||
app.begin_external_navigation();
|
||||
let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0)));
|
||||
navigate(&mut app.cam, axes, seconds * speed.clamp(0.1, 5.0), height, app.session.active_sketch.is_some());
|
||||
ctx.request_repaint_after(Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
/// A small dead zone avoids drift at rest. A full cap deflection takes roughly one viewport
|
||||
/// per second to pan and about two radians per second to turn, independent of frame rate.
|
||||
fn normalized(value: i16) -> f64 {
|
||||
let v = f64::from(value);
|
||||
if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) }
|
||||
}
|
||||
|
||||
fn navigate(cam: &mut Camera, axes: Axes, seconds: f64, height: f64, sketch: bool) {
|
||||
if !(seconds > 0.0) || !seconds.is_finite() {
|
||||
return;
|
||||
}
|
||||
let [x, y, z] = axes.translation.map(normalized);
|
||||
let [rx, ry, rz] = axes.rotation.map(normalized);
|
||||
cam.pan(x * height * 0.6 * seconds, -y * height * 0.6 * seconds, height);
|
||||
cam.zoom_at((-z * 1.5 * seconds).exp(), None);
|
||||
if !sketch {
|
||||
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
|
||||
cam.roll_by(rz * 2.0 * seconds);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
assert!(!decode_report(&[1, 1, 2], &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes::default());
|
||||
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], &mut reading, now));
|
||||
assert_eq!(reading.current(now).translation, [-100, 300, 0]);
|
||||
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], &mut reading, now));
|
||||
assert_eq!(reading.current(now).rotation, [0, 1, -1]);
|
||||
let later = now + STALE_AFTER + Duration::from_millis(1);
|
||||
assert_eq!(reading.current(later), Axes::default());
|
||||
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], &mut reading, later));
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], &mut reading, later));
|
||||
assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
|
||||
let mut cam = Camera::default();
|
||||
let before = cam;
|
||||
navigate(&mut cam, Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }, 0.02, 600.0, false);
|
||||
assert_ne!(cam.target, before.target);
|
||||
assert!(cam.distance < before.distance);
|
||||
assert_ne!(cam.yaw, before.yaw);
|
||||
assert_ne!(cam.pitch, before.pitch);
|
||||
assert_ne!(cam.roll, before.roll);
|
||||
let orientation = (cam.yaw, cam.pitch, cam.roll);
|
||||
navigate(&mut cam, Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }, 0.02, 600.0, true);
|
||||
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
|
||||
assert!(cam.is_valid());
|
||||
}
|
||||
}
|
||||
@@ -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,20 +0,0 @@
|
||||
[package]
|
||||
name = "solvecraft-doc"
|
||||
description = "SolidCraft 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 }
|
||||
sha2 = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
@@ -1,21 +0,0 @@
|
||||
//! Presentation/projection state for an exact selected planar source placement.
|
||||
use crate::{DocError, Document, FaceAnchor, ModelBody, ModelState, Result};
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anchor: &FaceAnchor, author: u64) -> Result<Cow<'a, ModelState>> {
|
||||
let frame = anchor.author_frame(doc, author)?;
|
||||
let raw = state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("anchored body `{}`", anchor.target.body)))?;
|
||||
let relative = anchor.source_to_author(doc, author, raw)?;
|
||||
let mut output = crate::state_in_frame_at(doc, state, author, frame).into_owned();
|
||||
let shape = if crate::is_identity(&relative) { raw.body.clone() } else { solvecraft_kernel::transform_matrix(&raw.body, relative)? };
|
||||
let mut body = ModelBody::new(raw.name.clone(), shape, raw.feature);
|
||||
body.component = raw.component;
|
||||
body.transferred = raw.transferred;
|
||||
body.source_name = raw.source_name.clone();
|
||||
body.source_transform = crate::mat_mul(&relative, &raw.source_transform);
|
||||
body.imported = raw.imported.clone();
|
||||
body.names = crate::naming::NameCell::known(crate::naming::face_names(raw));
|
||||
let slot = output.bodies.iter_mut().find(|body| body.name == raw.name).ok_or_else(|| DocError::Unknown("anchored body frame".into()))?;
|
||||
*slot = body;
|
||||
Ok(Cow::Owned(output))
|
||||
}
|
||||
@@ -1,306 +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>,
|
||||
/// Strict semantic assignments never transfer to a nearby replacement face.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub strict_name: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub certificate: Option<crate::config::FaceCertificate>,
|
||||
}
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Shared definition appearance for a computed occurrence body.
|
||||
pub fn model_body_look(&self, body: &crate::ModelBody) -> Option<Look> {
|
||||
let name = body.definition_name();
|
||||
self.appearances
|
||||
.bodies
|
||||
.get(name)
|
||||
.cloned()
|
||||
.or_else(|| self.appearances.components.get(&self.model_body_component(body)).cloned())
|
||||
.or_else(|| self.materials.get(name).and_then(|material| material_look(material)))
|
||||
}
|
||||
/// Effective body opacity, including paint carried from an imported body.
|
||||
pub fn model_body_opacity(&self, body: &crate::ModelBody) -> f64 {
|
||||
self.model_body_look(body)
|
||||
.map(|look| look.opacity)
|
||||
.or_else(|| body.body.paint().map(|paint| paint.opacity as f64))
|
||||
.or_else(|| body.imported.as_ref().and_then(|paint| paint.opacity.map(f64::from)))
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
/// 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.definition_name()) {
|
||||
let (p, look) = (crate::apply_point(&b.source_transform, f.point), f.look.clone());
|
||||
if f.strict_name {
|
||||
let Some(name) = f.name.as_ref() else { continue };
|
||||
let Some(origin_feature) = name.strip_prefix('F').and_then(|name| name.split_once(':')).and_then(|(id, _)| id.parse().ok()) else {
|
||||
continue;
|
||||
};
|
||||
let target = crate::config::ConfigFaceTarget {
|
||||
body: f.body.clone(),
|
||||
origin_feature,
|
||||
name: crate::naming::strip_piece(name).into(),
|
||||
point: f.point,
|
||||
certificate: f.certificate.clone(),
|
||||
};
|
||||
let Ok(indices) = self.configuration_face_indices(b, &target) else { continue };
|
||||
for index in indices {
|
||||
out.retain(|(old, _)| *old != index);
|
||||
out.push((index, look.clone()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 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 !f.strict_name
|
||||
&& 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,495 +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 { .. }
|
||||
| PlaneRef::Perpendicular { .. } => {
|
||||
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));
|
||||
}
|
||||
|
||||
/// Map a point given as expressions by `m`: numbers stay numbers; a coordinate that is an
|
||||
/// expression stays one (`m · (x, y, z) + t`, written out), so it still follows its parameters.
|
||||
pub fn map_point_expr(m: &Mat, p: &mut crate::PointExpr) {
|
||||
let num = |e: &String| e.trim().parse::<f64>().ok().filter(|v| v.is_finite());
|
||||
if let [Some(x), Some(y), Some(z)] = [num(&p[0]), num(&p[1]), num(&p[2])] {
|
||||
*p = crate::point_expr(apply_point(m, Vec3::new(x, y, z)));
|
||||
return;
|
||||
}
|
||||
let old = p.clone();
|
||||
for (row, out) in p.iter_mut().enumerate() {
|
||||
let mut constant = m[3][row];
|
||||
let mut terms: Vec<String> = Vec::new();
|
||||
for (col, e) in old.iter().enumerate() {
|
||||
let c = m[col][row];
|
||||
if c.abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
match num(e) {
|
||||
Some(v) => constant += c * v,
|
||||
None if (c - 1.0).abs() < 1e-12 => terms.push(format!("({e})")),
|
||||
None => terms.push(format!("{c} * ({e})")),
|
||||
}
|
||||
}
|
||||
let mut e = terms.join(" + ");
|
||||
if terms.is_empty() {
|
||||
e = format!("{constant} mm");
|
||||
} else if constant.abs() >= 1e-12 {
|
||||
e = format!("{e} {} {} mm", if constant < 0.0 { '-' } else { '+' }, constant.abs());
|
||||
}
|
||||
*out = e;
|
||||
}
|
||||
}
|
||||
|
||||
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, .. } => map_point_expr(m, corner),
|
||||
FeatureKind::Cylinder { base, axis, .. } => {
|
||||
map_point_expr(m, base);
|
||||
dir(axis);
|
||||
}
|
||||
FeatureKind::Sphere { center, .. } | FeatureKind::Torus { center, .. } => map_point_expr(m, 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, .. } => {
|
||||
map_point_expr(m, 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::BodyTransfer { .. } => {}
|
||||
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)> {
|
||||
self.body_frames_with_source(st).into_iter().map(|(world, _, component, path, matrix)| (world, component, path, matrix)).collect()
|
||||
}
|
||||
|
||||
/// Body frames with explicit definition names; callers must not infer them from labels.
|
||||
pub fn body_frames_with_source(&self, st: &crate::ModelState) -> Vec<(String, String, u64, Vec<u64>, Mat)> {
|
||||
let mut out = Vec::new();
|
||||
for (comp, path, m) in self.placements() {
|
||||
for b in st.bodies.iter().filter(|b| self.model_body_component(b) == comp) {
|
||||
let name = self.world_body_name(st, &b.name, comp, &path);
|
||||
let w = match self.model_body_offset(b) {
|
||||
Some(o) => mat_mul(&m, o),
|
||||
None => m,
|
||||
};
|
||||
out.push((name, b.definition_name().to_string(), comp, path.clone(), w));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Computed placement name. Source names retain priority; ambiguous labels use full IDs.
|
||||
/// Resolves label ambiguity with bounded dynamic counts instead of expanding all placements.
|
||||
pub fn world_body_name(&self, st: &crate::ModelState, source: &str, component: u64, path: &[u64]) -> String {
|
||||
if path == self.first_path(component) {
|
||||
return source.to_string();
|
||||
}
|
||||
let names: Vec<&str> = path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect();
|
||||
let label = names.join("/");
|
||||
let candidate = format!("{source} ({label})");
|
||||
let mut counts = std::collections::BTreeMap::from([(0, 1usize)]);
|
||||
for name in &names {
|
||||
let mut next = std::collections::BTreeMap::<u64, usize>::new();
|
||||
for o in self.occurrences.iter().filter(|o| o.name == *name) {
|
||||
if let Some(count) = counts.get(&o.parent) {
|
||||
let n = next.entry(o.component).or_default();
|
||||
*n = (*n + count).min(2);
|
||||
}
|
||||
}
|
||||
counts = next;
|
||||
}
|
||||
let ambiguous = counts.get(&component).copied().unwrap_or(0) > 1;
|
||||
// Slash and reserved-marker names could alias concatenated labels or fallback keys.
|
||||
let reserved =
|
||||
self.occurrences.iter().any(|o| o.name.contains('/') || o.name.contains("@occ[")) || st.bodies.iter().any(|b| b.name.contains("@occ["));
|
||||
if !ambiguous && !reserved && st.body(&candidate).is_none() {
|
||||
return candidate;
|
||||
}
|
||||
let ids = path.iter().map(u64::to_string).collect::<Vec<_>>().join("/");
|
||||
let encoded = serde_json::to_string(source).unwrap_or_else(|_| source.to_string());
|
||||
let mut key = format!("{encoded}@occ[{ids}]");
|
||||
while st.body(&key).is_some() {
|
||||
key.push('#');
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
/// 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,80 +0,0 @@
|
||||
//! Recorded ownership changes are evaluated at their timeline position.
|
||||
use crate::{DocError, Document, Mat, ModelBody, ModelState, Result};
|
||||
use solvecraft_geom::Vec3;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub fn valid_transfer_matrix(m: &Mat) -> bool {
|
||||
let axis = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]);
|
||||
let (x, y, z) = (axis(0), axis(1), axis(2));
|
||||
m.iter().flatten().all(|v| v.is_finite())
|
||||
&& m[3][..3].iter().all(|v| v.abs() <= 1e9)
|
||||
&& [x, y, z].iter().all(|v| (v.dot(*v) - 1.0).abs() < 1e-6)
|
||||
&& x.dot(y).abs() < 1e-6
|
||||
&& x.dot(z).abs() < 1e-6
|
||||
&& y.dot(z).abs() < 1e-6
|
||||
&& x.cross(y).dot(z) > 0.0
|
||||
&& [m[0][3], m[1][3], m[2][3]].iter().all(|v| v.abs() < 1e-9)
|
||||
&& (m[3][3] - 1.0).abs() < 1e-9
|
||||
}
|
||||
|
||||
/// Validate the stored boundary before allocation, loading, or evaluation.
|
||||
pub fn validate_body_transfer(doc: &Document, bodies: &[String], from: u64, to: u64, transform: &Mat) -> Result<()> {
|
||||
if bodies.is_empty()
|
||||
|| bodies.len() > 1000
|
||||
|| bodies.iter().any(|name| name.trim().is_empty() || name.len() > 512 || name.chars().any(char::is_control))
|
||||
|| bodies.iter().collect::<BTreeSet<_>>().len() != bodies.len()
|
||||
|| !valid_transfer_matrix(transform)
|
||||
|| [from, to].iter().any(|id| *id != 0 && !doc.components.iter().any(|c| c.id == *id))
|
||||
{
|
||||
return Err(DocError::Invalid("invalid body transfer boundary".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn evaluate(doc: &Document, st: &mut ModelState, bodies: &[String], from: u64, to: u64, transform: Mat) -> Result<()> {
|
||||
validate_body_transfer(doc, bodies, from, to, &transform)?;
|
||||
let mut next = st.clone();
|
||||
for name in bodies {
|
||||
let old = st.body(name).ok_or_else(|| DocError::Unknown(format!("transferred body `{name}`")))?;
|
||||
if doc.model_body_component(old) != from {
|
||||
return Err(DocError::Invalid(format!("body `{name}` no longer belongs to transfer source component {from}")));
|
||||
}
|
||||
let shape = if crate::is_identity(&transform) { old.body.clone() } else { solvecraft_kernel::transform_matrix(&old.body, transform)? };
|
||||
let mut body = ModelBody::new(old.name.clone(), shape, old.feature);
|
||||
body.component = to;
|
||||
body.transferred = true;
|
||||
body.source_name = old.source_name.clone();
|
||||
body.source_transform = crate::mat_mul(&transform, &old.source_transform);
|
||||
body.imported = old.imported.clone();
|
||||
body.names = crate::naming::NameCell::known(crate::naming::face_names(old));
|
||||
let slot = next.bodies.iter_mut().find(|b| b.name == *name).ok_or_else(|| DocError::Unknown(format!("transferred body `{name}`")))?;
|
||||
*slot = body;
|
||||
}
|
||||
crate::body_transfer_sheet::remap_sheet_metadata(&mut next, bodies, &transform)?;
|
||||
*st = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn invalid_transfer_constructor_preserves_document_and_allocator() {
|
||||
let mut doc = Document::default();
|
||||
let before = doc.to_json();
|
||||
let mut scaled = crate::IDENTITY;
|
||||
scaled[0][0] = 2.0;
|
||||
for (bodies, from, transform) in [
|
||||
(vec!["Block".into()], 42, crate::IDENTITY),
|
||||
(vec!["Block".into(), "Block".into()], 0, crate::IDENTITY),
|
||||
(vec![String::new()], 0, crate::IDENTITY),
|
||||
(vec![" ".into()], 0, crate::IDENTITY),
|
||||
(vec!["bad\nname".into()], 0, crate::IDENTITY),
|
||||
(vec!["Block".into()], 0, scaled),
|
||||
] {
|
||||
assert!(doc.add_feature(crate::FeatureKind::BodyTransfer { bodies, from, to: 0, transform }, None).is_err());
|
||||
assert_eq!(doc.to_json(), before);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
//! World-frame metadata at a timeline body ownership boundary.
|
||||
use solvecraft_geom::Plane;
|
||||
|
||||
use crate::{DocError, Mat, ModelState, Result, apply_point, apply_vector};
|
||||
|
||||
pub(crate) fn remap_sheet_metadata(st: &mut ModelState, names: &[String], transform: &Mat) -> Result<()> {
|
||||
if names.len() > 1000 || st.sheets.len().saturating_add(st.threads.len()) > 100_000 || !transform.iter().flatten().all(|v| v.is_finite()) {
|
||||
return Err(DocError::Invalid("body transfer metadata exceeds its finite processing budget".into()));
|
||||
}
|
||||
let names: std::collections::BTreeSet<&str> = names.iter().map(String::as_str).collect();
|
||||
if !st.sheets.iter().any(|s| names.contains(s.body.as_str())) && !st.threads.iter().any(|t| names.contains(t.body.as_str())) {
|
||||
return Ok(());
|
||||
}
|
||||
if !crate::valid_transfer_matrix(transform) {
|
||||
return Err(DocError::Invalid("sheet and thread transfer requires a rigid placement".into()));
|
||||
}
|
||||
let frames: Vec<_> = st
|
||||
.sheets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| names.contains(s.body.as_str()))
|
||||
.map(|(i, s)| {
|
||||
let frame = Plane::new(apply_point(transform, s.frame.origin), apply_vector(transform, s.frame.x), apply_vector(transform, s.frame.y))
|
||||
.ok_or_else(|| DocError::Invalid("invalid transferred sheet frame".into()))?;
|
||||
Ok((i, frame))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let threads: Vec<_> = st
|
||||
.threads
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| names.contains(t.body.as_str()))
|
||||
.map(|(i, t)| {
|
||||
let point = apply_point(transform, t.axis_point);
|
||||
let axis = apply_vector(transform, t.axis).normalized().ok_or_else(|| DocError::Invalid("invalid transferred thread axis".into()))?;
|
||||
if !point.is_finite() {
|
||||
return Err(DocError::Invalid("invalid transferred thread placement".into()));
|
||||
}
|
||||
Ok((i, point, axis))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
for (i, frame) in frames {
|
||||
if let Some(s) = st.sheets.get_mut(i) {
|
||||
s.frame = frame;
|
||||
}
|
||||
}
|
||||
for (i, point, axis) in threads {
|
||||
if let Some(t) = st.threads.get_mut(i) {
|
||||
t.axis_point = point;
|
||||
t.axis = axis;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,182 +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,
|
||||
/// The component it belongs to (its plane is in that component's frame, so it moves with
|
||||
/// the component's occurrence); 0 = the root.
|
||||
#[serde(default, skip_serializing_if = "is_root")]
|
||||
pub component: u64,
|
||||
}
|
||||
|
||||
fn is_root(c: &u64) -> bool {
|
||||
*c == 0
|
||||
}
|
||||
|
||||
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 on `plane` (in whatever frame the plane is given).
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Document {
|
||||
/// A canvas's plane in the world: resolved in its component's frame, then placed where the
|
||||
/// component's occurrence puts it.
|
||||
pub fn canvas_plane(&self, vals: &std::collections::BTreeMap<String, crate::expr::Value>, c: &Canvas) -> crate::Result<Plane> {
|
||||
let p = self.resolve_plane(vals, &c.plane, 0)?;
|
||||
let m = self.component_transform(c.component);
|
||||
if crate::is_identity(&m) {
|
||||
return Ok(p);
|
||||
}
|
||||
Ok(Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)).unwrap_or(p))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,463 +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::SheetBase { sketch, .. }
|
||||
| FeatureKind::SheetContour { sketch, .. }
|
||||
| FeatureKind::Rib { sketch, .. } => sk(sketch),
|
||||
FeatureKind::Patch { sketch: Some(s), .. } => sk(s),
|
||||
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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels.
|
||||
pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>) -> String {
|
||||
// Imported names belong to the complete canonical body prefix, even when that prefix
|
||||
// happens to contain text resembling a feature token.
|
||||
if let Some((body, suffix)) = name.rsplit_once(":face:")
|
||||
&& let Some(new) = bodies.get(body)
|
||||
{
|
||||
return format!("{new}:face:{suffix}");
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut at = 0;
|
||||
while at < name.len() {
|
||||
let rest = &name[at..];
|
||||
if rest.starts_with('F') && (at == 0 || matches!(name.as_bytes().get(at - 1), Some(b'@') | Some(b':') | Some(b'|'))) {
|
||||
let end = rest.as_bytes().iter().skip(1).take_while(|b| b.is_ascii_digit()).count() + 1;
|
||||
if end > 1
|
||||
&& matches!(rest.as_bytes().get(end), Some(b':') | Some(b'.'))
|
||||
&& let Ok(id) = rest[1..end].parse::<u64>()
|
||||
&& let Some(new) = ids.get(&id)
|
||||
{
|
||||
out.push_str(&format!("F{new}"));
|
||||
at += end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(ch) = rest.chars().next() else { break };
|
||||
out.push(ch);
|
||||
at += ch.len_utf8();
|
||||
}
|
||||
// Import faces have a canonical body prefix rather than a feature token.
|
||||
if let Some((body, suffix)) = out.rsplit_once(":face:")
|
||||
&& let Some(new) = bodies.get(body)
|
||||
{
|
||||
return format!("{new}:face:{suffix}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn mapped_name(n: &mut String, names: &BTreeMap<String, String>) {
|
||||
if let Some(new) = names.get(n) {
|
||||
*n = new.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Simultaneous generated-parameter substitution: map values may themselves be old keys.
|
||||
pub fn clone_expression(source: &str, parameters: &BTreeMap<String, String>) -> String {
|
||||
let mut result = source.to_string();
|
||||
let mut temporary = Vec::new();
|
||||
for (index, (old, new)) in parameters.iter().enumerate() {
|
||||
let mut token = format!("__clone_reference_{index}");
|
||||
while source.contains(&token) || parameters.values().any(|n| n == &token) {
|
||||
token.push('_');
|
||||
}
|
||||
result = crate::expr::rename_reference(&result, old, &token);
|
||||
temporary.push((token, new));
|
||||
}
|
||||
for (token, new) in temporary {
|
||||
result = crate::expr::rename_reference(&result, &token, new);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn remap_geo(
|
||||
g: &mut crate::construct::GeoRef,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
depth: usize,
|
||||
) -> crate::Result<()> {
|
||||
use crate::construct::GeoRef;
|
||||
if depth > 64 {
|
||||
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
|
||||
}
|
||||
match g {
|
||||
GeoRef::SketchPoint { sketch, .. } | GeoRef::SketchCurve { sketch, .. } => {
|
||||
if let Some(new) = ids.get(sketch) {
|
||||
*sketch = *new;
|
||||
}
|
||||
}
|
||||
GeoRef::Face { name: Some(n), .. } | GeoRef::Edge { name: Some(n), .. } => *n = remap_topology(n, ids, bodies),
|
||||
GeoRef::ConstructPoint { name } | GeoRef::Axis { name } => mapped_name(name, names),
|
||||
GeoRef::Plane { plane } => remap_plane(plane, ids, names, bodies, depth + 1)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remap_plane(
|
||||
p: &mut PlaneRef,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
depth: usize,
|
||||
) -> crate::Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
|
||||
}
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => remap_plane(base, ids, names, bodies, depth + 1)?,
|
||||
PlaneRef::Construction { name } => mapped_name(name, names),
|
||||
PlaneRef::Face { name, anchor, .. } => {
|
||||
if let Some(name) = name {
|
||||
*name = remap_topology(name, ids, bodies);
|
||||
}
|
||||
if let Some(anchor) = anchor {
|
||||
anchor.remap_target(ids, bodies)?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for r in p.refs_mut() {
|
||||
remap_geo(r, ids, names, bodies, depth + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remap_link(
|
||||
source: &mut solvecraft_sketch::LinkSource,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
depth: usize,
|
||||
) {
|
||||
use solvecraft_sketch::LinkSource;
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
match source {
|
||||
LinkSource::Edge { body, .. }
|
||||
| LinkSource::Face { body, .. }
|
||||
| LinkSource::Body { body }
|
||||
| LinkSource::Vertex { body, .. }
|
||||
| LinkSource::Iso { body, .. }
|
||||
| LinkSource::Spun { body, .. } => mapped_name(body, bodies),
|
||||
LinkSource::SketchCurve { sketch, .. } | LinkSource::SketchPoint { sketch, .. } => {
|
||||
if let Some(new) = ids.get(sketch) {
|
||||
*sketch = *new;
|
||||
}
|
||||
}
|
||||
LinkSource::OnSurface { sketch, body, .. } => {
|
||||
if let Some(new) = ids.get(sketch) {
|
||||
*sketch = *new;
|
||||
}
|
||||
mapped_name(body, bodies);
|
||||
}
|
||||
LinkSource::Intersection { a, b } => {
|
||||
remap_link(a, ids, names, bodies, depth + 1);
|
||||
remap_link(b, ids, names, bodies, depth + 1);
|
||||
}
|
||||
LinkSource::Plane { name } => mapped_name(name, names),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Feature {
|
||||
/// Clone identities through typed reference fields. Sketch-local entity IDs stay local.
|
||||
pub fn remap_clone(
|
||||
&mut self,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
parameters: &BTreeMap<String, String>,
|
||||
) -> crate::Result<()> {
|
||||
use FeatureKind as K;
|
||||
match &self.kind {
|
||||
K::Sketch { plane, sketch } => {
|
||||
checked_plane(plane, 0)?;
|
||||
for link in &sketch.links {
|
||||
checked_link(&link.source, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPlane { plane }
|
||||
| K::Split { plane, .. }
|
||||
| K::SplitFace { plane, .. }
|
||||
| K::Mirror { plane, .. }
|
||||
| K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?,
|
||||
K::Draft { neutral, .. } => checked_plane(neutral, 0)?,
|
||||
K::ReplaceFace { target, .. } => checked_plane(target, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPoint { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.kind.remap_refs(ids, names);
|
||||
for n in &mut self.body_names {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
for n in &mut self.participants {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
for n in &mut self.param_names {
|
||||
mapped_name(n, parameters);
|
||||
}
|
||||
for n in self.face_names.iter_mut().chain(&mut self.edge_names) {
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
for (_, e, _) in self.kind.inputs_mut() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
for e in self.kind.point_expr_mut().into_iter().flatten() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
match &mut self.kind {
|
||||
K::Emboss { face: Some(face), .. } => mapped_name(&mut face.body, bodies),
|
||||
K::Split { tool: Some(tool), .. } | K::SplitFace { tool: Some(tool), .. } | K::SurfaceTrim { tool: Some(tool), .. } => {
|
||||
mapped_name(&mut tool.body, bodies)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match &mut self.kind {
|
||||
K::Extrude { targets, .. }
|
||||
| K::Revolve { targets, .. }
|
||||
| K::Loft { targets, .. }
|
||||
| K::Sweep { targets, .. }
|
||||
| K::Pipe { targets, .. }
|
||||
| K::Emboss { targets, .. } => {
|
||||
for n in targets {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Combine { target, tools, .. } => {
|
||||
mapped_name(target, bodies);
|
||||
for n in tools {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Pattern { bodies: list, .. }
|
||||
| K::Mirror { bodies: list, .. }
|
||||
| K::Scale { bodies: list, .. }
|
||||
| K::BoundingSolid { bodies: list, .. }
|
||||
| K::Align { bodies: list, .. }
|
||||
| K::Remove { bodies: list }
|
||||
| K::Stitch { bodies: list, .. }
|
||||
| K::Move { bodies: list, .. }
|
||||
| K::BodyTransfer { bodies: list, .. } => {
|
||||
for n in list {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Thicken { bodies: list, targets, .. } => {
|
||||
for n in list.iter_mut().chain(targets) {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::BoundaryFill { tools, .. } => {
|
||||
for n in tools {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Fillet { body, .. }
|
||||
| K::Chamfer { body, .. }
|
||||
| K::Shell { body, .. }
|
||||
| K::Draft { body, .. }
|
||||
| K::SplitFace { body, .. }
|
||||
| K::OffsetFace { body, .. }
|
||||
| K::DeleteFace { body, .. }
|
||||
| K::ReplaceFace { body, .. }
|
||||
| K::SheetFlange { body, .. }
|
||||
| K::SheetHem { body, .. }
|
||||
| K::SheetFold { body, .. }
|
||||
| K::SheetUnfold { body, .. }
|
||||
| K::Boss { body, .. }
|
||||
| K::Lip { body, .. }
|
||||
| K::SnapFit { body, .. }
|
||||
| K::Rest { body, .. }
|
||||
| K::Patch { body, .. } => {
|
||||
if let Some(n) = body {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Split { body, .. } | K::SheetConvert { body, .. } | K::SurfaceExtend { body, .. } | K::SurfaceTrim { body, .. } => {
|
||||
mapped_name(body, bodies)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match &mut self.kind {
|
||||
K::Sketch { plane, sketch } => {
|
||||
remap_plane(plane, ids, names, bodies, 0)?;
|
||||
for c in &mut sketch.constraints {
|
||||
if let Some(n) = &mut c.param {
|
||||
mapped_name(n, parameters);
|
||||
}
|
||||
}
|
||||
for link in &mut sketch.links {
|
||||
remap_link(&mut link.source, ids, names, bodies, 0);
|
||||
if let Some(n) = &mut link.face_name {
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
}
|
||||
}
|
||||
K::ConstructionPlane { plane }
|
||||
| K::Split { plane, .. }
|
||||
| K::SplitFace { plane, .. }
|
||||
| K::Mirror { plane, .. }
|
||||
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0)?,
|
||||
K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0)?,
|
||||
K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs_mut() {
|
||||
remap_geo(r, ids, names, bodies, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPoint { def } => {
|
||||
for r in def.refs_mut() {
|
||||
remap_geo(r, ids, names, bodies, 0)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_geo(g: &crate::construct::GeoRef, depth: usize) -> crate::Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into()));
|
||||
}
|
||||
if let crate::construct::GeoRef::Plane { plane } = g {
|
||||
checked_plane(plane, depth + 1)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into()));
|
||||
}
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => checked_plane(base, depth + 1)?,
|
||||
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => {
|
||||
checked_geo(a, depth + 1)?;
|
||||
checked_geo(b, depth + 1)?;
|
||||
}
|
||||
PlaneRef::Perpendicular { plane, line } => {
|
||||
checked_geo(plane, depth + 1)?;
|
||||
checked_geo(line, depth + 1)?;
|
||||
}
|
||||
PlaneRef::ThreePoints { a, b, c } => {
|
||||
checked_geo(a, depth + 1)?;
|
||||
checked_geo(b, depth + 1)?;
|
||||
checked_geo(c, depth + 1)?;
|
||||
}
|
||||
PlaneRef::Tangent { face, .. } => checked_geo(face, depth + 1)?,
|
||||
PlaneRef::AlongPath { path, .. } => checked_geo(path, depth + 1)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn checked_link(source: &solvecraft_sketch::LinkSource, depth: usize) -> crate::Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(crate::DocError::Invalid("copy link nesting exceeds 64".into()));
|
||||
}
|
||||
if let solvecraft_sketch::LinkSource::Intersection { a, b } = source {
|
||||
checked_link(a, depth + 1)?;
|
||||
checked_link(b, depth + 1)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod clone_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn imported_names_and_topology_tokens_have_separate_identity() {
|
||||
let ids = BTreeMap::from([(1, 2), (10, 20)]);
|
||||
let bodies = BTreeMap::from([("F1:Housing".into(), "F1:Housing (copy)".into())]);
|
||||
assert_eq!(remap_topology("F1:Housing:face:0", &ids, &bodies), "F1:Housing (copy):face:0");
|
||||
assert_eq!(remap_topology("F1:+x|F10:end@F1.2", &ids, &bodies), "F2:+x|F20:end@F2.2");
|
||||
assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label");
|
||||
}
|
||||
#[test]
|
||||
fn parameter_remap_is_simultaneous_and_token_scoped() {
|
||||
let map = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]);
|
||||
assert_eq!(clone_expression("d1 + d2 + d10 + 2 mm", &map), "d2 + d3 + d10 + 2 mm");
|
||||
}
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
//! Configurations: a table whose rows are variants of the design. Each column is a parameter
|
||||
//! expression, feature suppression, body material or body appearance. Activating a row sets
|
||||
//! those values in the design atomically, which then rebuilds as usual.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
#[path = "config_rules.rs"]
|
||||
mod rules;
|
||||
pub use rules::*;
|
||||
#[path = "config_styles.rs"]
|
||||
mod styles;
|
||||
pub use styles::*;
|
||||
#[path = "config_certificates.rs"]
|
||||
mod certificates;
|
||||
pub use certificates::*;
|
||||
#[path = "config_clone.rs"]
|
||||
mod clone;
|
||||
pub use clone::*;
|
||||
|
||||
#[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),
|
||||
/// A body's physical material (library name, or Default to clear).
|
||||
Material(String),
|
||||
/// A body's appearance (library name, serialized Look, or empty to clear).
|
||||
Appearance(String),
|
||||
/// Shared component definition's direct appearance.
|
||||
ComponentAppearance(u64),
|
||||
/// A certified semantic face lineage in a shared definition.
|
||||
FaceAppearance(ConfigFaceTarget),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConfigRow {
|
||||
pub name: String,
|
||||
/// One cell per column: expression, suppression, material name or appearance look.
|
||||
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>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rules: Vec<ConfigRule>,
|
||||
}
|
||||
|
||||
pub const MAX_COLUMNS: usize = 500;
|
||||
pub const MAX_ROWS: usize = 2000;
|
||||
|
||||
pub fn suppression_cell(value: &str) -> Result<bool> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "1" | "yes" => Ok(true),
|
||||
"false" | "0" | "no" => Ok(false),
|
||||
_ => Err(DocError::Invalid("suppression must be true or false".into())),
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigTable {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.columns.is_empty() && self.rows.is_empty() && self.rules.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an appearance cell. Empty/Default follows the body's material; named library looks
|
||||
/// and JSON Look objects support both convenient editing and lossless custom colours.
|
||||
pub fn appearance_cell(value: &str) -> Result<Option<crate::appearance::Look>> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.eq_ignore_ascii_case("default") {
|
||||
return Ok(None);
|
||||
}
|
||||
if value.len() > 4096 {
|
||||
return Err(DocError::Invalid("appearance value too long".into()));
|
||||
}
|
||||
if let Some(look) = crate::appearance::library(value) {
|
||||
return Ok(Some(look));
|
||||
}
|
||||
let look: crate::appearance::Look =
|
||||
serde_json::from_str(value).map_err(|_| DocError::Invalid(format!("unknown configuration appearance `{value}`")))?;
|
||||
if look.name.chars().count() > 128 || !look.opacity.is_finite() || !(0.0..=1.0).contains(&look.opacity) {
|
||||
return Err(DocError::Invalid("appearance requires a name up to 128 characters and opacity in 0..1".into()));
|
||||
}
|
||||
Ok(Some(look))
|
||||
}
|
||||
|
||||
/// Parse a physical material cell, returning None for the default material.
|
||||
pub fn material_cell(value: &str) -> Result<Option<String>> {
|
||||
let name = crate::MATERIALS
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(value.trim()))
|
||||
.map(|(n, _)| *n)
|
||||
.ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
|
||||
Ok((name != "Default").then(|| name.to_string()))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Freeze design-unit-sensitive configuration literals when changing display units.
|
||||
/// Explicit units and nonliteral expressions retain their source text.
|
||||
pub fn preserve_configuration_units(&mut self, previous_unit: &str, kept_parameters: &[String]) {
|
||||
let feature_lengths: Vec<String> = self
|
||||
.features
|
||||
.iter()
|
||||
.flat_map(|feature| {
|
||||
feature
|
||||
.param_names
|
||||
.iter()
|
||||
.zip(feature.kind.inputs())
|
||||
.filter(|(_, (_, _, kind))| *kind == crate::expr::Kind::Length)
|
||||
.map(|(name, _)| name.clone())
|
||||
})
|
||||
.collect();
|
||||
let lengths: std::collections::BTreeSet<String> = feature_lengths.into_iter().chain(kept_parameters.iter().cloned()).collect();
|
||||
for row in &mut self.configs.rows {
|
||||
for (column, cell) in self.configs.columns.iter().zip(&mut row.cells) {
|
||||
if matches!(column,Column::Param(name) if lengths.contains(name)) && cell.trim().parse::<f64>().is_ok() {
|
||||
*cell = format!("{} {previous_unit}", cell.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
for rule in &mut self.configs.rules {
|
||||
rule.preserve_length_units(&lengths, previous_unit);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, kind)| {
|
||||
// Feature literals are stored in base units, unlike newly typed bare
|
||||
// row cells. Capture their unit explicitly before editing in an inch design.
|
||||
if self.param(n).is_none() && kind == crate::expr::Kind::Length && self.units != "mm" && e.trim().parse::<f64>().is_ok() {
|
||||
format!("{} mm", e.trim())
|
||||
} else {
|
||||
e
|
||||
}
|
||||
}),
|
||||
Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()),
|
||||
Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| "Default".into())),
|
||||
Column::Appearance(body) => Some(self.appearances.bodies.get(body).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()),
|
||||
Column::ComponentAppearance(id) => {
|
||||
Some(self.appearances.components.get(id).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default())
|
||||
}
|
||||
Column::FaceAppearance(target) => {
|
||||
Some(self.config_face_look(target).and_then(|look| serde_json::to_string(&look).ok()).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A column's display key, prefixed by its kind to distinguish targets with equal names.
|
||||
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())),
|
||||
Column::Material(body) => format!("material:{body}"),
|
||||
Column::Appearance(body) => format!("appearance:{body}"),
|
||||
Column::ComponentAppearance(id) => format!("component_appearance:{id}"),
|
||||
Column::FaceAppearance(target) => format!("face_appearance:{}", target.key()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the design to a row's values (all or nothing).
|
||||
pub fn apply_configuration(&mut self, row: &str) -> Result<()> {
|
||||
self.apply_configuration_with_trace(row).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn apply_configuration_with_trace(&mut self, row: &str) -> Result<Vec<RuleTrace>> {
|
||||
let r = self.configs.rows.iter().find(|r| r.name == row).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{row}`")))?;
|
||||
if self.configs.columns.len() > MAX_COLUMNS || self.configs.rows.len() > MAX_ROWS || r.cells.len() != self.configs.columns.len() {
|
||||
return Err(DocError::Invalid("configuration table exceeds limits or has incomplete rows".into()));
|
||||
}
|
||||
let mut next = self.clone();
|
||||
let changes: Vec<(String, String)> = self
|
||||
.configs
|
||||
.columns
|
||||
.iter()
|
||||
.zip(&r.cells)
|
||||
.filter_map(|(c, v)| match c {
|
||||
Column::Param(name) => Some((name.clone(), v.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
next.change_params_atomic(&changes).map_err(|e| DocError::Invalid(format!("configuration `{row}`: {e}")))?;
|
||||
for (c, v) in self.configs.columns.iter().zip(&r.cells) {
|
||||
if v.len() > 4096 {
|
||||
return Err(DocError::Invalid("configuration value too long".into()));
|
||||
}
|
||||
match c {
|
||||
Column::Param(_) => {}
|
||||
Column::Suppress(id) => {
|
||||
let on = suppression_cell(v)?;
|
||||
let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?;
|
||||
f.suppressed = on;
|
||||
}
|
||||
Column::Material(body) => match material_cell(v)? {
|
||||
Some(material) => {
|
||||
next.materials.insert(body.clone(), material);
|
||||
}
|
||||
None => {
|
||||
next.materials.remove(body);
|
||||
}
|
||||
},
|
||||
Column::Appearance(body) => match appearance_cell(v)? {
|
||||
Some(look) => {
|
||||
next.appearances.bodies.insert(body.clone(), look);
|
||||
}
|
||||
None => {
|
||||
next.appearances.bodies.remove(body);
|
||||
}
|
||||
},
|
||||
Column::ComponentAppearance(id) => {
|
||||
if *id == 0 || !next.components.iter().any(|component| component.id == *id) {
|
||||
return Err(DocError::Unknown(format!("component {id} is gone")));
|
||||
}
|
||||
match appearance_cell(v)? {
|
||||
Some(look) => {
|
||||
next.appearances.components.insert(*id, look);
|
||||
}
|
||||
None => {
|
||||
next.appearances.components.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Column::FaceAppearance(target) => {
|
||||
next.certify_config_face(target)?;
|
||||
next.appearances.faces.retain(|face| !target.matches(face));
|
||||
if let Some(look) = appearance_cell(v)? {
|
||||
next.appearances.faces.push(crate::appearance::FaceLook {
|
||||
body: target.body.clone(),
|
||||
point: target.point,
|
||||
look,
|
||||
name: Some(target.name.clone()),
|
||||
strict_name: true,
|
||||
certificate: target.certificate.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
next.configs.active = Some(row.to_string());
|
||||
let trace = next.evaluate_configuration_rules()?;
|
||||
*self = next;
|
||||
Ok(trace)
|
||||
}
|
||||
|
||||
/// 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 };
|
||||
if r.cells.len() != self.configs.columns.len() {
|
||||
return false;
|
||||
}
|
||||
if !self.configs.rules.is_empty() {
|
||||
let mut expected = self.clone();
|
||||
if expected.apply_configuration(&r.name).is_err() {
|
||||
return false;
|
||||
}
|
||||
return self.configs.columns.iter().all(|column| self.config_current(column) == expected.config_current(column));
|
||||
}
|
||||
self.configs.columns.iter().zip(&r.cells).all(|(c, v)| match c {
|
||||
Column::Suppress(id) => suppression_cell(v).is_ok_and(|on| self.feature(*id).is_some_and(|f| f.suppressed == on)),
|
||||
Column::Param(name) => {
|
||||
let kind = self
|
||||
.features
|
||||
.iter()
|
||||
.find_map(|f| f.param_names.iter().position(|n| n == name).and_then(|k| f.kind.inputs().get(k).map(|input| input.2)));
|
||||
let expected = kind.map_or_else(|| v.trim().to_string(), |kind| self.with_design_unit(v, kind));
|
||||
self.config_current(c)
|
||||
.is_some_and(|current| current.chars().filter(|c| !c.is_whitespace()).eq(expected.chars().filter(|c| !c.is_whitespace())))
|
||||
}
|
||||
Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
|
||||
Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()),
|
||||
Column::ComponentAppearance(id) => appearance_cell(v).is_ok_and(|look| self.appearances.components.get(id) == look.as_ref()),
|
||||
Column::FaceAppearance(target) => appearance_cell(v).is_ok_and(|look| self.config_face_look(target) == look),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rows_use_final_graph_and_missing_targets_never_reappear() {
|
||||
let mut d = Document::new("A");
|
||||
d.set_param("a", "1 mm", None, None).unwrap();
|
||||
d.set_param("b", "a", None, None).unwrap();
|
||||
d.configs.columns = vec![Column::Param("a".into()), Column::Param("b".into())];
|
||||
d.configs.rows.push(ConfigRow { name: "Reordered".into(), cells: vec!["b".into(), "2 mm".into()] });
|
||||
d.apply_configuration("Reordered").unwrap();
|
||||
assert!(d.configuration_matches());
|
||||
assert_eq!(d.param_values().0["a"].v, 2.0);
|
||||
d.configs.rows.push(ConfigRow { name: "Cycle".into(), cells: vec!["b".into(), "a".into()] });
|
||||
let before = d.clone();
|
||||
assert!(d.apply_configuration("Cycle").is_err());
|
||||
assert_eq!(d, before);
|
||||
d.params.retain(|p| p.name != "a");
|
||||
let before = d.clone();
|
||||
assert!(d.apply_configuration("Reordered").is_err());
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_changes_reject_new_errors_in_unchanged_dependents() {
|
||||
let mut d = Document::new("A");
|
||||
d.set_param("length", "2 mm", None, None).unwrap();
|
||||
d.set_param("twice", "length * 2", None, None).unwrap();
|
||||
let before = d.clone();
|
||||
assert!(d.change_params_atomic(&[("length".into(), "1 / 0".into())]).is_err());
|
||||
assert_eq!(d, before);
|
||||
assert!(d.change_params_atomic(&[("length".into(), "3 mm".into()), ("length".into(), "4 mm".into())]).is_err());
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_erroneous_configured_parameter_is_a_noop_but_changed_error_rejects() {
|
||||
let mut d = Document::new("Existing broken design");
|
||||
d.set_param("width", "10 mm", None, None).unwrap();
|
||||
d.params[0].expr = "1/".into();
|
||||
d.configs.columns = vec![Column::Param("width".into())];
|
||||
d.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["1/".into()] });
|
||||
d.apply_configuration("Default").unwrap();
|
||||
assert!(d.configuration_matches());
|
||||
d.configs.rows.push(ConfigRow { name: "Different error".into(), cells: vec!["missing + 1".into()] });
|
||||
let before = d.clone();
|
||||
assert!(d.apply_configuration("Different error").is_err());
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_saved_appearance_rolls_back_material_and_parameters() {
|
||||
let mut d = Document::new("A");
|
||||
d.set_param("width", "10 mm", None, None).unwrap();
|
||||
d.configs.columns = vec![Column::Param("width".into()), Column::Material("Body".into()), Column::Appearance("Body".into())];
|
||||
d.configs.rows.push(ConfigRow {
|
||||
name: "Broken".into(),
|
||||
cells: vec!["20 mm".into(), "Steel".into(), r#"{"name":"Invalid","color":[1,2,3],"opacity":2}"#.into()],
|
||||
});
|
||||
let before = d.clone();
|
||||
assert!(d.apply_configuration("Broken").is_err());
|
||||
assert_eq!(d, before);
|
||||
assert!(appearance_cell(r#"{"name":"Invalid","color":[1,2,3],"opacity":1e999}"#).is_err());
|
||||
assert!(appearance_cell(&"a".repeat(4097)).is_err());
|
||||
assert!(appearance_cell("default").unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
//! Certificates are producer identities, never a nearest-surface selection.
|
||||
use crate::{DocError, Document, FeatureKind, ModelBody, Result, SolvedSketch};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::Seg2;
|
||||
|
||||
fn valid_signature(outer: &[String], holes: &[Vec<String>]) -> bool {
|
||||
let valid = |ids: &[String]| {
|
||||
!ids.is_empty() && ids.len() <= 2000 && ids.iter().all(|id| !id.is_empty() && id.len() <= 128) && ids.windows(2).all(|pair| pair[0] < pair[1])
|
||||
};
|
||||
valid(outer)
|
||||
&& holes.len() <= 128
|
||||
&& holes.iter().all(|ids| valid(ids))
|
||||
&& outer.len() + holes.iter().map(Vec::len).sum::<usize>() <= 2000
|
||||
&& holes.windows(2).all(|pair| pair[0] < pair[1])
|
||||
&& holes.iter().flatten().all(|id| outer.binary_search(id).is_err())
|
||||
&& holes.iter().flatten().collect::<std::collections::BTreeSet<_>>().len() == holes.iter().map(Vec::len).sum::<usize>()
|
||||
}
|
||||
fn valid_signature_json(outer: Option<&serde_json::Value>, holes: Option<&serde_json::Value>) -> bool {
|
||||
let valid = |value: &serde_json::Value| {
|
||||
value.as_array().is_some_and(|ids| {
|
||||
!ids.is_empty()
|
||||
&& ids.len() <= 2000
|
||||
&& ids.iter().all(|id| id.as_str().is_some_and(|id| !id.is_empty() && id.len() <= 128))
|
||||
&& ids.windows(2).all(|pair| pair[0].as_str() < pair[1].as_str())
|
||||
})
|
||||
};
|
||||
let Some(outer) = outer.filter(|value| valid(value)).and_then(serde_json::Value::as_array) else {
|
||||
return false;
|
||||
};
|
||||
let Some(holes) = holes.and_then(serde_json::Value::as_array).filter(|holes| holes.len() <= 128) else {
|
||||
return false;
|
||||
};
|
||||
if !holes.iter().all(valid) {
|
||||
return false;
|
||||
}
|
||||
let total = outer.len() + holes.iter().filter_map(serde_json::Value::as_array).map(Vec::len).sum::<usize>();
|
||||
let ids: Vec<_> = outer.iter().filter_map(serde_json::Value::as_str).collect();
|
||||
total <= 2000
|
||||
&& holes
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_array)
|
||||
.flatten()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.len()
|
||||
== total - outer.len()
|
||||
&& holes
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_array)
|
||||
.flatten()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.all(|id| ids.binary_search(&id).is_err())
|
||||
&& holes.windows(2).all(|pair| {
|
||||
let a: Vec<_> = pair[0].as_array().into_iter().flatten().filter_map(serde_json::Value::as_str).collect();
|
||||
let b: Vec<_> = pair[1].as_array().into_iter().flatten().filter_map(serde_json::Value::as_str).collect();
|
||||
a < b
|
||||
})
|
||||
}
|
||||
pub(crate) fn cap_key(outer: &[String], holes: &[Vec<String>]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hash = Sha256::new();
|
||||
for ids in std::iter::once(outer).chain(holes.iter().map(Vec::as_slice)) {
|
||||
hash.update((ids.len() as u64).to_le_bytes());
|
||||
for id in ids {
|
||||
hash.update((id.len() as u64).to_le_bytes());
|
||||
hash.update(id.as_bytes());
|
||||
}
|
||||
}
|
||||
format!("{:x}", hash.finalize())
|
||||
}
|
||||
pub(crate) fn exact_region_signature(sketch: &SolvedSketch, region: &solvecraft_geom::Region2) -> Option<solvecraft_kernel::CapSignature> {
|
||||
if region.outer.segs.len() + region.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() > 4096 || region.holes.len() > 128 {
|
||||
return None;
|
||||
}
|
||||
let signature = |outline: &solvecraft_geom::Loop2| {
|
||||
let mut ids = outline.segs.iter().map(|segment| exact_sketch_curve(sketch, segment)).collect::<Option<Vec<_>>>()?;
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
Some(ids)
|
||||
};
|
||||
let outer = signature(®ion.outer)?;
|
||||
let mut holes = region.holes.iter().map(signature).collect::<Option<Vec<_>>>()?;
|
||||
holes.sort();
|
||||
valid_signature(&outer, &holes).then_some((outer, holes))
|
||||
}
|
||||
|
||||
/// Charge all source-curve comparisons before matching; over-budget tools stay usable but uncertified.
|
||||
type CapBindings = Vec<Vec<Option<solvecraft_kernel::CapSignature>>>;
|
||||
pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solvecraft_kernel::Body], budget: usize) -> Option<CapBindings> {
|
||||
use solvecraft_kernel::FaceSource;
|
||||
if sketch.sketch.curves.len() > 2000 {
|
||||
return None;
|
||||
}
|
||||
let mut faces = 0usize;
|
||||
let mut comparisons = 0usize;
|
||||
for body in bodies {
|
||||
for source in body.face_sources().into_iter().flatten() {
|
||||
faces = faces.checked_add(1)?;
|
||||
if faces > 50_000 {
|
||||
return None;
|
||||
}
|
||||
let segments = match source {
|
||||
Some(FaceSource::ExtrudeSide { .. }) => 1,
|
||||
Some(FaceSource::ExtrudeCap { region, .. }) => {
|
||||
region.outer.segs.len().checked_add(region.holes.iter().map(|hole| hole.segs.len()).sum::<usize>())?
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
comparisons = comparisons.checked_add(segments.checked_mul(sketch.sketch.curves.len())?)?;
|
||||
if comparisons > budget {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut counts = std::collections::BTreeMap::new();
|
||||
let mut bindings = Vec::with_capacity(bodies.len());
|
||||
for body in bodies {
|
||||
let mut cached: Option<(&solvecraft_geom::Region2, Option<solvecraft_kernel::CapSignature>)> = None;
|
||||
let mut values = Vec::new();
|
||||
for source in body.face_sources().into_iter().flatten() {
|
||||
let signature = if let Some(FaceSource::ExtrudeCap { region, high, .. }) = source {
|
||||
let signature = match &cached {
|
||||
Some((previous, signature)) if *previous == region => signature.clone(),
|
||||
_ => exact_region_signature(sketch, region),
|
||||
};
|
||||
cached = Some((region, signature.clone()));
|
||||
if let Some((outer, holes)) = &signature {
|
||||
*counts.entry((*high, outer.clone(), holes.clone())).or_insert(0usize) += 1;
|
||||
}
|
||||
signature
|
||||
} else {
|
||||
None
|
||||
};
|
||||
values.push(signature);
|
||||
}
|
||||
bindings.push(values);
|
||||
}
|
||||
for (body, values) in bodies.iter().zip(&mut bindings) {
|
||||
for (source, value) in body.face_sources().into_iter().flatten().zip(values) {
|
||||
if let (Some(FaceSource::ExtrudeCap { high, .. }), Some((outer, holes))) = (source, value.as_ref())
|
||||
&& counts.get(&(*high, outer.clone(), holes.clone())) != Some(&1)
|
||||
{
|
||||
*value = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(bindings)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum FaceCertificate {
|
||||
Box { axis: u8, high: bool },
|
||||
ExtrudeCap { sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>> },
|
||||
ExtrudeSide { sketch: u64, curve: String },
|
||||
Imported { payload_sha256: String, item: u64, face: u64 },
|
||||
ShellPlanar { source_producer: u64, source: Box<FaceCertificate>, inner: bool },
|
||||
}
|
||||
|
||||
impl FaceCertificate {
|
||||
pub fn remap_features(&mut self, features: &std::collections::BTreeMap<u64, u64>) {
|
||||
if let Self::ShellPlanar { source_producer, source, .. } = self {
|
||||
if let Some(mapped) = features.get(source_producer) {
|
||||
*source_producer = *mapped;
|
||||
}
|
||||
if !matches!(source.as_ref(), Self::ShellPlanar { .. }) {
|
||||
source.remap_features(features);
|
||||
}
|
||||
}
|
||||
if let Self::ExtrudeSide { sketch, .. } | Self::ExtrudeCap { sketch, .. } = self
|
||||
&& let Some(mapped) = features.get(sketch)
|
||||
{
|
||||
*sketch = *mapped;
|
||||
}
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let valid = match self {
|
||||
Self::Box { axis, .. } => *axis <= 2,
|
||||
Self::ExtrudeCap { sketch, outer, holes, .. } => *sketch != 0 && valid_signature(outer, holes),
|
||||
Self::ExtrudeSide { sketch, curve } => *sketch != 0 && !curve.is_empty() && curve.len() <= 128,
|
||||
Self::Imported { payload_sha256, item, face } => {
|
||||
*item != 0
|
||||
&& *face != 0
|
||||
&& payload_sha256.len() == 64
|
||||
&& payload_sha256.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}
|
||||
Self::ShellPlanar { source_producer, source, .. } => {
|
||||
*source_producer != 0 && !matches!(source.as_ref(), Self::ShellPlanar { .. }) && source.validate().is_ok()
|
||||
}
|
||||
};
|
||||
if valid { Ok(()) } else { Err(DocError::Invalid("invalid face source certificate".into())) }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()> {
|
||||
if value.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
let fields = value.as_object().ok_or_else(|| DocError::Invalid("face certificate must be an object".into()))?;
|
||||
let nonzero = |field: &str| fields.get(field).and_then(serde_json::Value::as_u64).is_some_and(|id| id != 0);
|
||||
let valid = match fields.get("kind").and_then(serde_json::Value::as_str) {
|
||||
Some("box") => {
|
||||
fields.len() == 3
|
||||
&& fields.keys().all(|key| ["kind", "axis", "high"].contains(&key.as_str()))
|
||||
&& fields.get("axis").and_then(serde_json::Value::as_u64).is_some_and(|axis| axis <= 2)
|
||||
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
|
||||
}
|
||||
Some("extrude_cap") => {
|
||||
fields.len() == 5
|
||||
&& fields.keys().all(|key| ["kind", "sketch", "high", "outer", "holes"].contains(&key.as_str()))
|
||||
&& nonzero("sketch")
|
||||
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
|
||||
&& valid_signature_json(fields.get("outer"), fields.get("holes"))
|
||||
}
|
||||
Some("extrude_side") => {
|
||||
fields.len() == 3
|
||||
&& fields.keys().all(|key| ["kind", "sketch", "curve"].contains(&key.as_str()))
|
||||
&& nonzero("sketch")
|
||||
&& fields.get("curve").and_then(serde_json::Value::as_str).is_some_and(|curve| !curve.is_empty() && curve.len() <= 128)
|
||||
}
|
||||
Some("imported") => {
|
||||
fields.len() == 4
|
||||
&& fields.keys().all(|key| ["kind", "payload_sha256", "item", "face"].contains(&key.as_str()))
|
||||
&& nonzero("item")
|
||||
&& nonzero("face")
|
||||
&& fields
|
||||
.get("payload_sha256")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
|
||||
}
|
||||
Some("shell_planar") => {
|
||||
fields.len() == 4
|
||||
&& fields.keys().all(|key| ["kind", "source_producer", "source", "inner"].contains(&key.as_str()))
|
||||
&& nonzero("source_producer")
|
||||
&& fields.get("inner").and_then(serde_json::Value::as_bool).is_some()
|
||||
&& fields.get("source").is_some_and(|source| {
|
||||
source.as_object().is_some_and(|source| source.get("kind").and_then(serde_json::Value::as_str) != Some("shell_planar"))
|
||||
&& validate_certificate_json(source).is_ok()
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if valid { Ok(()) } else { Err(DocError::Invalid("malformed or oversized face source certificate".into())) }
|
||||
}
|
||||
|
||||
/// Canonical diagnostic role; producer identity remains in the typed certificate.
|
||||
pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> Result<String> {
|
||||
source.validate()?;
|
||||
let role = match source {
|
||||
FaceCertificate::Box { axis, high } => {
|
||||
let axis = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid shell source axis".into()))?;
|
||||
format!("box:{}{axis}", if *high { "+" } else { "-" })
|
||||
}
|
||||
FaceCertificate::ExtrudeSide { curve, .. } => format!("side:{curve}"),
|
||||
FaceCertificate::ExtrudeCap { high, outer, holes, .. } => format!("cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes)),
|
||||
FaceCertificate::Imported { payload_sha256, item, face } => format!("import:{payload_sha256}:{item}:{face}"),
|
||||
FaceCertificate::ShellPlanar { .. } => return Err(DocError::Invalid("nested shell source certificates are unsupported".into())),
|
||||
};
|
||||
Ok(format!("shell:{}:{role}", if inner { "inner" } else { "outer" }))
|
||||
}
|
||||
|
||||
pub(crate) fn flat_source_certificate(source: &solvecraft_kernel::FaceSource) -> Option<(u64, FaceCertificate)> {
|
||||
use solvecraft_kernel::FaceSource;
|
||||
match source {
|
||||
FaceSource::Box { producer: Some(producer), axis, high, .. } => Some((*producer, FaceCertificate::Box { axis: *axis, high: *high })),
|
||||
FaceSource::CertifiedExtrudeSide { producer, sketch, curve, .. } => {
|
||||
Some((*producer, FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() }))
|
||||
}
|
||||
FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. } => {
|
||||
Some((*producer, FaceCertificate::ExtrudeCap { sketch: *sketch, high: *high, outer: outer.clone(), holes: holes.clone() }))
|
||||
}
|
||||
FaceSource::Imported { producer: Some(producer), payload_sha256: Some(digest), item, face } => {
|
||||
Some((*producer, FaceCertificate::Imported { payload_sha256: digest.clone(), item: *item, face: *face }))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solvecraft_kernel::FaceSource, producer: u64) -> bool {
|
||||
use solvecraft_kernel::FaceSource;
|
||||
match (certificate, source) {
|
||||
(FaceCertificate::Box { axis, high }, FaceSource::Box { producer: Some(current), axis: current_axis, high: current_high, .. }) => {
|
||||
producer == *current && axis == current_axis && high == current_high
|
||||
}
|
||||
(
|
||||
FaceCertificate::ExtrudeSide { sketch, curve },
|
||||
FaceSource::CertifiedExtrudeSide { producer: current, sketch: current_sketch, curve: current_curve, .. },
|
||||
) => producer == *current && sketch == current_sketch && curve == current_curve,
|
||||
(
|
||||
FaceCertificate::ExtrudeCap { sketch, high, outer, holes },
|
||||
FaceSource::CertifiedExtrudeCap {
|
||||
producer: current,
|
||||
sketch: current_sketch,
|
||||
high: current_high,
|
||||
outer: current_outer,
|
||||
holes: current_holes,
|
||||
..
|
||||
},
|
||||
) => producer == *current && sketch == current_sketch && high == current_high && outer == current_outer && holes == current_holes,
|
||||
(
|
||||
FaceCertificate::Imported { payload_sha256, item, face },
|
||||
FaceSource::Imported { producer: Some(current), payload_sha256: Some(digest), item: current_item, face: current_face },
|
||||
) => producer == *current && payload_sha256 == digest && item == current_item && face == current_face,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Match a generated segment to one exact analytic sketch curve. Coincident
|
||||
/// candidates are ambiguous and never resolved by distance or candidate order.
|
||||
pub(crate) fn exact_sketch_curve(sketch: &SolvedSketch, generated: &Seg2) -> Option<String> {
|
||||
if sketch.sketch.curves.len() > 2000 {
|
||||
return None;
|
||||
}
|
||||
let mut found = None;
|
||||
for (index, curve) in sketch.sketch.curves.iter().enumerate() {
|
||||
if curve.construction
|
||||
|| !matches!(
|
||||
curve.kind,
|
||||
solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Arc { .. } | solvecraft_sketch::CurveKind::Circle { .. }
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let matches = if let solvecraft_sketch::CurveKind::Circle { c, r } = curve.kind {
|
||||
sketch
|
||||
.sketch
|
||||
.point(c)
|
||||
.is_some_and(|center| exact_segment_contains(&Seg2::Arc { center, radius: r, start: 0., sweep: std::f64::consts::TAU }, generated))
|
||||
} else {
|
||||
sketch.sketch.segs(index).iter().any(|original| exact_segment_contains(original, generated))
|
||||
};
|
||||
if matches {
|
||||
if found.is_some() {
|
||||
return None;
|
||||
}
|
||||
found = Some(curve.id.clone());
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool {
|
||||
match (original, generated) {
|
||||
(Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => {
|
||||
let direction = *b - *a;
|
||||
let scale = direction.len().max((*d - *c).len()).max(1.);
|
||||
let interval = |point: solvecraft_geom::Vec2| {
|
||||
let t = (point - *a).dot(direction) / direction.len2();
|
||||
(-1e-10..=1. + 1e-10).contains(&t)
|
||||
};
|
||||
direction.len() > 1e-12
|
||||
&& direction.cross(*c - *a).abs() <= 1e-10 * scale * scale
|
||||
&& direction.cross(*d - *a).abs() <= 1e-10 * scale * scale
|
||||
&& interval(*c)
|
||||
&& interval(*d)
|
||||
}
|
||||
(Seg2::Arc { center: a, radius: ar, start: os, sweep: ow }, Seg2::Arc { center: b, radius: br, start: gs, sweep: gw }) => {
|
||||
let tau = std::f64::consts::TAU;
|
||||
let tolerance = 1e-10;
|
||||
let length = ow.abs();
|
||||
let generated_length = gw.abs();
|
||||
if a.dist(*b) > 1e-10 * ar.max(*br).max(1.)
|
||||
|| (ar - br).abs() > 1e-10 * ar.max(*br).max(1.)
|
||||
|| length > tau + tolerance
|
||||
|| generated_length > length + tolerance
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if length >= tau - tolerance {
|
||||
return true;
|
||||
}
|
||||
let original_low = if *ow >= 0. { *os } else { os + ow };
|
||||
let generated_low = if *gw >= 0. { *gs } else { gs + gw };
|
||||
let offset = (generated_low - original_low).rem_euclid(tau);
|
||||
let offset = if offset > tau - tolerance { 0. } else { offset };
|
||||
offset + generated_length <= length + tolerance
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn configuration_face_target(&self, body: &ModelBody, index: usize, point: solvecraft_geom::Vec3) -> Result<crate::config::ConfigFaceTarget> {
|
||||
let names = crate::naming::face_names(body);
|
||||
let mut name = names
|
||||
.get(index)
|
||||
.map(|name| crate::naming::strip_piece(name).to_string())
|
||||
.ok_or_else(|| DocError::Unknown("face source name missing".into()))?;
|
||||
let origin = match body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) {
|
||||
Some(solvecraft_kernel::FaceSource::Imported { producer: Some(producer), .. })
|
||||
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. })
|
||||
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. })
|
||||
| Some(solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), .. })
|
||||
| Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer,
|
||||
_ => name
|
||||
.strip_prefix('F')
|
||||
.and_then(|name| name.split_once(':'))
|
||||
.and_then(|(id, _)| id.parse().ok())
|
||||
.ok_or_else(|| DocError::Invalid("face source producer missing".into()))?,
|
||||
};
|
||||
let certificate = self.configuration_face_certificate(body, index)?;
|
||||
if let Some(FaceCertificate::ExtrudeSide { curve, .. }) = &certificate {
|
||||
name = format!("F{origin}:side:{curve}");
|
||||
} else if let Some(FaceCertificate::ShellPlanar { source, inner, .. }) = &certificate {
|
||||
name = format!("F{origin}:{}", shell_certificate_role(source, *inner)?);
|
||||
} else if !name.starts_with(&format!("F{origin}:")) {
|
||||
name = format!("F{origin}:face:{index}");
|
||||
}
|
||||
let inverse = crate::mat_inverse(&body.source_transform).ok_or_else(|| DocError::Invalid("invalid definition placement".into()))?;
|
||||
Ok(crate::config::ConfigFaceTarget {
|
||||
body: body.definition_name().into(),
|
||||
origin_feature: origin,
|
||||
name,
|
||||
point: crate::apply_point(&inverse, point),
|
||||
certificate,
|
||||
})
|
||||
}
|
||||
pub fn configuration_face_certificate(&self, body: &ModelBody, index: usize) -> Result<Option<FaceCertificate>> {
|
||||
let Some(source) = body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), source, inner, .. } = source {
|
||||
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Shell { .. })) {
|
||||
return Err(DocError::Invalid("shell source producer missing".into()));
|
||||
}
|
||||
let (source_producer, source) =
|
||||
flat_source_certificate(source).ok_or_else(|| DocError::Invalid("shell has no flat certified source".into()))?;
|
||||
if !self.flat_certificate_producer_matches(source_producer, &source) {
|
||||
return Err(DocError::Invalid("shell original source producer missing".into()));
|
||||
}
|
||||
let certificate = FaceCertificate::ShellPlanar { source_producer, source: Box::new(source), inner: *inner };
|
||||
certificate.validate()?;
|
||||
return Ok(Some(certificate));
|
||||
}
|
||||
if let solvecraft_kernel::FaceSource::Imported { item, face, payload_sha256: Some(digest), producer: Some(producer) } = source {
|
||||
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Import { .. })) {
|
||||
return Err(DocError::Invalid("import source producer missing".into()));
|
||||
}
|
||||
let certificate = FaceCertificate::Imported { payload_sha256: digest.clone(), item: *item, face: *face };
|
||||
certificate.validate()?;
|
||||
return Ok(Some(certificate));
|
||||
}
|
||||
let certificate = match source {
|
||||
solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch, curve, .. } => {
|
||||
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Extrude { .. })) {
|
||||
return Err(DocError::Invalid("side source producer missing".into()));
|
||||
}
|
||||
FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() }
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
certificate.validate()?;
|
||||
Ok(Some(certificate))
|
||||
}
|
||||
pub fn strong_face_target(&self, body: &ModelBody, index: usize, point: solvecraft_geom::Vec3) -> Result<crate::config::ConfigFaceTarget> {
|
||||
let mut target = self.configuration_face_target(body, index, point)?;
|
||||
match body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) {
|
||||
Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), axis, high, .. }) => {
|
||||
target.origin_feature = *producer;
|
||||
let axis_name = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid box face axis".into()))?;
|
||||
target.name = format!("F{producer}:{}{axis_name}", if *high { "+" } else { "-" });
|
||||
target.certificate = Some(FaceCertificate::Box { axis: *axis, high: *high });
|
||||
}
|
||||
Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. }) => {
|
||||
target.origin_feature = *producer;
|
||||
target.name = format!("F{producer}:cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes));
|
||||
target.certificate = Some(FaceCertificate::ExtrudeCap { sketch: *sketch, high: *high, outer: outer.clone(), holes: holes.clone() });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.certify_config_face(&target)?;
|
||||
Ok(target)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn payload_digest(payload: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(payload.as_bytes()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "config_shell_tests.rs"]
|
||||
mod shell_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn certificate_budgets_apply_before_saved_target_and_paint_decode() {
|
||||
for certificate in [
|
||||
json!({"kind":"extrude_side","sketch":1,"curve":"x".repeat(1_000_000)}),
|
||||
json!({"kind":"imported","payload_sha256":"0".repeat(64),"item":0,"face":1}),
|
||||
json!({"kind":"imported","payload_sha256":"g".repeat(64),"item":1,"face":1}),
|
||||
json!({"kind":"extrude_side","sketch":1,"curve":"c1","extra":[[[[[]]]]]}),
|
||||
] {
|
||||
assert!(validate_certificate_json(&certificate).is_err());
|
||||
let target = json!({"configs":{"columns":[{"face_appearance":{"body":"B","origin_feature":1,"name":"F1:side:c1","point":[0,0,0],"certificate":certificate}}]}});
|
||||
assert!(crate::config::validate_styles_json(&target).is_err());
|
||||
let paint = json!({"appearances":{"faces":[{"certificate":certificate}]}});
|
||||
assert!(crate::config::validate_styles_json(&paint).is_err());
|
||||
}
|
||||
assert!(validate_certificate_json(&json!({"kind":"imported","payload_sha256":"a".repeat(64),"item":1,"face":2})).is_ok());
|
||||
assert!(validate_certificate_json(&json!({"kind":"extrude_side","sketch":1,"curve":"c1"})).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn analytic_arc_interval_refuses_unsampled_gaps_and_accepts_wrap_reversal() {
|
||||
let radians = |degrees: f64| degrees.to_radians();
|
||||
let arc = |start, sweep| Seg2::Arc { center: solvecraft_geom::Vec2::default(), radius: 10., start: radians(start), sweep: radians(sweep) };
|
||||
assert!(!exact_segment_contains(&arc(10., 350.), &arc(20., 360.)), "five point probes missed the missing ten-degree gap");
|
||||
assert!(!exact_segment_contains(&arc(10., 350.), &arc(20., -360.)));
|
||||
assert!(exact_segment_contains(&arc(350., 40.), &arc(355., 20.)));
|
||||
assert!(exact_segment_contains(&arc(350., 40.), &arc(15., -20.)));
|
||||
assert!(exact_segment_contains(&arc(30., -40.), &arc(15., -20.)));
|
||||
assert!(!exact_segment_contains(&arc(350., 40.), &arc(345., 20.)));
|
||||
assert!(exact_segment_contains(&arc(0., 360.), &arc(20., -360.)));
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
//! Configuration graph remapping for independent component copies.
|
||||
use super::{Column, ConfigTable};
|
||||
use crate::{DocError, Result};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ConfigCloneMaps {
|
||||
pub features: BTreeMap<u64, u64>,
|
||||
pub components: BTreeMap<u64, u64>,
|
||||
pub bodies: BTreeMap<String, String>,
|
||||
pub parameters: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Simultaneous token-reference substitution, including maps whose values are other keys.
|
||||
pub(super) fn remap_expression(source: &str, parameters: &BTreeMap<String, String>) -> String {
|
||||
let refs = crate::expr::references(source);
|
||||
let mut result = source.to_string();
|
||||
let mut replacements = Vec::new();
|
||||
for (index, reference) in refs.iter().enumerate() {
|
||||
if let Some(mapped) = parameters.get(reference) {
|
||||
let mut temporary = format!("_config_clone_{index}");
|
||||
while refs.contains(&temporary) || parameters.contains_key(&temporary) || parameters.values().any(|value| value == &temporary) {
|
||||
temporary.push('_');
|
||||
}
|
||||
result = crate::expr::rename_reference(&result, reference, &temporary);
|
||||
replacements.push((temporary, mapped));
|
||||
}
|
||||
}
|
||||
for (temporary, mapped) in replacements {
|
||||
result = crate::expr::rename_reference(&result, &temporary, mapped);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
impl ConfigTable {
|
||||
/// Append mapped columns/cells/rules atomically; source targets and active row remain intact.
|
||||
pub fn clone_targets(&mut self, maps: &ConfigCloneMaps) -> Result<()> {
|
||||
if maps.features.values().chain(maps.components.values()).any(|id| *id == 0)
|
||||
|| maps.parameters.values().any(|name| crate::Document::param_name_problem(name).is_some())
|
||||
|| maps.bodies.values().any(|name| name.is_empty() || name.len() > 128)
|
||||
{
|
||||
return Err(DocError::Invalid("invalid configuration clone identity mapping".into()));
|
||||
}
|
||||
if self.columns.len() > super::MAX_COLUMNS
|
||||
|| self.rows.len() > super::MAX_ROWS
|
||||
|| self.rules.len() > super::MAX_RULES
|
||||
|| self.rows.iter().any(|row| row.cells.len() != self.columns.len())
|
||||
{
|
||||
return Err(DocError::Invalid("cannot clone an incomplete configuration table".into()));
|
||||
}
|
||||
let mut next = self.clone();
|
||||
for (index, column) in self.columns.iter().enumerate() {
|
||||
if let Column::FaceAppearance(target) = column
|
||||
&& maps.bodies.contains_key(&target.body)
|
||||
&& !maps.features.contains_key(&target.origin_feature)
|
||||
{
|
||||
return Err(DocError::Invalid(format!(
|
||||
"cannot copy configured face `{}`: imported foreign body has no certified cloned lineage",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
let mapped = match column {
|
||||
Column::Param(name) => maps.parameters.get(name).cloned().map(Column::Param),
|
||||
Column::Suppress(id) => maps.features.get(id).copied().map(Column::Suppress),
|
||||
Column::Material(name) => maps.bodies.get(name).cloned().map(Column::Material),
|
||||
Column::Appearance(name) => maps.bodies.get(name).cloned().map(Column::Appearance),
|
||||
Column::ComponentAppearance(id) => maps.components.get(id).copied().map(Column::ComponentAppearance),
|
||||
Column::FaceAppearance(target) => {
|
||||
maps.bodies.get(&target.body).zip(maps.features.get(&target.origin_feature)).map(|(body, origin)| {
|
||||
let mut target = target.clone();
|
||||
target.body = body.clone();
|
||||
target.name = target
|
||||
.name
|
||||
.strip_prefix(&format!("F{}:", target.origin_feature))
|
||||
.map(|role| format!("F{origin}:{role}"))
|
||||
.unwrap_or_else(|| target.name.clone());
|
||||
target.origin_feature = *origin;
|
||||
if let Some(certificate) = &mut target.certificate {
|
||||
certificate.remap_features(&maps.features);
|
||||
}
|
||||
Column::FaceAppearance(target)
|
||||
})
|
||||
}
|
||||
};
|
||||
if let Some(mapped) = mapped {
|
||||
if mapped == *column {
|
||||
continue;
|
||||
}
|
||||
if next.columns.iter().any(|column| {
|
||||
*column == mapped
|
||||
|| matches!((column,&mapped),(Column::FaceAppearance(a),Column::FaceAppearance(b)) if a.body == b.body && a.name == b.name)
|
||||
}) {
|
||||
return Err(DocError::Invalid("configuration copy target collides with an existing column".into()));
|
||||
}
|
||||
if next.columns.len() >= super::MAX_COLUMNS {
|
||||
return Err(DocError::Invalid("configuration copy exceeds column limit".into()));
|
||||
}
|
||||
next.columns.push(mapped);
|
||||
for (source, destination) in self.rows.iter().zip(&mut next.rows) {
|
||||
let value = source.cells.get(index).cloned().unwrap_or_default();
|
||||
let value = if matches!(column, Column::Param(_)) { remap_expression(&value, &maps.parameters) } else { value };
|
||||
if value.len() > 4096 {
|
||||
return Err(DocError::Invalid("configuration copied value exceeds length limit".into()));
|
||||
}
|
||||
destination.cells.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
for rule in &self.rules {
|
||||
if let Some(mut copied) = rule.clone_for_targets(maps) {
|
||||
if next.rules.len() >= super::MAX_RULES {
|
||||
return Err(DocError::Invalid("configuration copy exceeds rule limit".into()));
|
||||
}
|
||||
let stem: String = rule.id.chars().take(40).collect();
|
||||
copied.id = (1..=super::MAX_RULES + 1)
|
||||
.map(|i| format!("{stem}_copy{i}"))
|
||||
.find(|id| !next.rules.iter().any(|rule| rule.id == *id))
|
||||
.ok_or_else(|| DocError::Invalid("no configuration rule ID available".into()))?;
|
||||
copied.name = format!("{} (copy)", rule.name.chars().take(120).collect::<String>());
|
||||
next.rules.push(copied);
|
||||
}
|
||||
}
|
||||
super::validate_rules_json(&serde_json::json!({"configs":{"rules":next.rules}}))?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{ConfigFaceTarget, ConfigRow, ConfigRule, RuleCondition, RuleEffect};
|
||||
#[test]
|
||||
fn clone_preserves_source_variations_and_remaps_only_typed_targets_and_tokens() {
|
||||
let mut table = ConfigTable {
|
||||
columns: vec![
|
||||
Column::Param("d1".into()),
|
||||
Column::Param("d2".into()),
|
||||
Column::Appearance("Old".into()),
|
||||
Column::ComponentAppearance(2),
|
||||
Column::FaceAppearance(ConfigFaceTarget {
|
||||
body: "Old".into(),
|
||||
origin_feature: 3,
|
||||
name: "F3:+z".into(),
|
||||
point: solvecraft_geom::Vec3::new(1., 1., 2.),
|
||||
certificate: None,
|
||||
}),
|
||||
],
|
||||
rows: vec![ConfigRow {
|
||||
name: "Default".into(),
|
||||
cells: vec!["d1 * 2 + d2".into(), "4 mm".into(), "d1".into(), String::new(), String::new()],
|
||||
}],
|
||||
active: Some("Default".into()),
|
||||
rules: vec![ConfigRule {
|
||||
id: "Rule1".into(),
|
||||
name: "Rule1".into(),
|
||||
enabled: true,
|
||||
length_unit: Some("mm".into()),
|
||||
condition: RuleCondition::Suppression { feature: 3, value: false },
|
||||
effect: RuleEffect::DriveParameter { parameter: "d1".into(), expression: "d2 * 2 + d1".into() },
|
||||
}],
|
||||
};
|
||||
let original = table.clone();
|
||||
let maps = ConfigCloneMaps {
|
||||
features: BTreeMap::from([(3, 7)]),
|
||||
components: BTreeMap::from([(2, 8)]),
|
||||
bodies: BTreeMap::from([("Old".into(), "New".into())]),
|
||||
parameters: BTreeMap::from([("d1".into(), "copy_d1".into()), ("d2".into(), "copy_d2".into())]),
|
||||
};
|
||||
table.clone_targets(&maps).unwrap();
|
||||
assert_eq!(&table.columns[..5], &original.columns);
|
||||
assert_eq!(&table.rows[0].cells[..5], &original.rows[0].cells);
|
||||
assert_eq!(table.columns[5], Column::Param("copy_d1".into()));
|
||||
assert_eq!(table.rows[0].cells[5], "copy_d1 * 2 + copy_d2");
|
||||
assert_eq!(table.rows[0].cells[7], "d1"); // appearance strings are never parameter expressions
|
||||
assert_eq!(table.columns[8], Column::ComponentAppearance(8));
|
||||
let Column::FaceAppearance(face) = &table.columns[9] else { panic!() };
|
||||
assert_eq!(face.name, "F7:+z");
|
||||
assert_eq!(face.body, "New");
|
||||
assert_eq!(table.rules[0], original.rules[0]);
|
||||
assert!(
|
||||
matches!(&table.rules[1].effect,RuleEffect::DriveParameter{parameter,expression} if parameter=="copy_d1" && expression=="copy_d2 * 2 + copy_d1")
|
||||
);
|
||||
assert!(matches!(table.rules[1].condition, RuleCondition::Suppression { feature: 7, .. }));
|
||||
assert_eq!(table.active, original.active);
|
||||
let chained = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]);
|
||||
assert_eq!(remap_expression("d1 * 2 + d2", &chained), "d2 * 2 + d3");
|
||||
let collision = ConfigCloneMaps { parameters: chained, ..Default::default() };
|
||||
let mut original_copy = original.clone();
|
||||
assert!(original_copy.clone_targets(&collision).is_err());
|
||||
assert_eq!(original_copy, original);
|
||||
}
|
||||
#[test]
|
||||
fn foreign_strict_lineage_failure_is_atomic() {
|
||||
let mut table = ConfigTable {
|
||||
columns: vec![
|
||||
Column::Param("width".into()),
|
||||
Column::FaceAppearance(ConfigFaceTarget {
|
||||
body: "Foreign".into(),
|
||||
origin_feature: 3,
|
||||
name: "F3:+z".into(),
|
||||
point: solvecraft_geom::Vec3::ZERO,
|
||||
certificate: None,
|
||||
}),
|
||||
],
|
||||
rows: vec![ConfigRow { name: "Default".into(), cells: vec!["2 mm".into(), String::new()] }],
|
||||
..Default::default()
|
||||
};
|
||||
let original = table.clone();
|
||||
let maps = ConfigCloneMaps {
|
||||
bodies: BTreeMap::from([("Foreign".into(), "Copied".into())]),
|
||||
parameters: BTreeMap::from([("width".into(), "copied_width".into())]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(table.clone_targets(&maps).unwrap_err().to_string().contains("certified"));
|
||||
assert_eq!(table, original);
|
||||
}
|
||||
}
|
||||
@@ -1,684 +0,0 @@
|
||||
//! Bounded typed configuration rules. The saved row remains the editable source;
|
||||
//! ordered rules derive an effective design without overwriting its cells.
|
||||
use super::Column;
|
||||
use crate::{DocError, Document, Result, expr};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub const MAX_RULES: usize = 128;
|
||||
const MAX_NODES: usize = 64;
|
||||
const MAX_DEPTH: usize = 8;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigRule {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default = "enabled")]
|
||||
pub enabled: bool,
|
||||
/// Unit context for feature-length expressions, fixed when authoring a rule.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub length_unit: Option<String>,
|
||||
pub condition: RuleCondition,
|
||||
pub effect: RuleEffect,
|
||||
}
|
||||
fn enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum RuleCondition {
|
||||
Always,
|
||||
All { conditions: Vec<RuleCondition> },
|
||||
Any { conditions: Vec<RuleCondition> },
|
||||
Not { condition: Box<RuleCondition> },
|
||||
Parameter { parameter: String, operator: RuleOperator, expression: String },
|
||||
Suppression { feature: u64, value: bool },
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuleOperator {
|
||||
Eq,
|
||||
Ne,
|
||||
Lt,
|
||||
Le,
|
||||
Gt,
|
||||
Ge,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum RuleEffect {
|
||||
DriveParameter { parameter: String, expression: String },
|
||||
DriveSuppression { feature: u64, value: bool },
|
||||
ParameterRange { parameter: String, minimum: String, maximum: String },
|
||||
AllowParameterValues { parameter: String, expressions: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct RuleTrace {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub matched: bool,
|
||||
pub effect: RuleEffect,
|
||||
}
|
||||
|
||||
/// Check saved rule structure before recursive serde decoding. Absence is backward
|
||||
/// compatible; malformed present data is refused. Runtime evaluation checks it again.
|
||||
pub fn validate_rules_json(document: &Value) -> Result<()> {
|
||||
let Some(rules) = document.get("configs").and_then(|configs| configs.get("rules")) else { return Ok(()) };
|
||||
let rules = rules.as_array().ok_or_else(|| invalid("rules must be an array"))?;
|
||||
if rules.len() > MAX_RULES {
|
||||
return Err(invalid("too many rules"));
|
||||
}
|
||||
for rule in rules {
|
||||
let rule = rule.as_object().ok_or_else(|| invalid("rule must be an object"))?;
|
||||
for (key, limit) in [("id", 64), ("name", 128)] {
|
||||
let value = rule.get(key).and_then(Value::as_str).ok_or_else(|| invalid("rule id and name are required"))?;
|
||||
if value.trim().is_empty() || value.chars().count() > limit {
|
||||
return Err(invalid("rule id or name is empty or too long"));
|
||||
}
|
||||
}
|
||||
if let Some(unit) = rule.get("length_unit") {
|
||||
let unit =
|
||||
unit.as_str().filter(|unit| matches!(*unit, "mm" | "cm" | "m" | "in" | "ft")).ok_or_else(|| invalid("invalid rule length unit"))?;
|
||||
let _ = unit;
|
||||
}
|
||||
let mut pending = vec![(rule.get("condition").ok_or_else(|| invalid("condition required"))?, 1usize)];
|
||||
let mut count = 0;
|
||||
while let Some((condition, depth)) = pending.pop() {
|
||||
count += 1;
|
||||
if depth > MAX_DEPTH || count > MAX_NODES {
|
||||
return Err(invalid("condition nesting or node limit exceeded"));
|
||||
}
|
||||
let kind = condition.get("type").and_then(Value::as_str).ok_or_else(|| invalid("condition type required"))?;
|
||||
match kind {
|
||||
"all" | "any" => {
|
||||
let children = condition.get("conditions").and_then(Value::as_array).ok_or_else(|| invalid("conditions must be an array"))?;
|
||||
if children.is_empty() || children.len() > MAX_NODES {
|
||||
return Err(invalid("condition group is empty or too large"));
|
||||
}
|
||||
pending.extend(children.iter().map(|child| (child, depth + 1)));
|
||||
}
|
||||
"not" => pending.push((condition.get("condition").ok_or_else(|| invalid("nested condition required"))?, depth + 1)),
|
||||
"parameter" => {
|
||||
check_parameter(condition.get("parameter"))?;
|
||||
check_expression(condition.get("expression"))?;
|
||||
}
|
||||
"always" | "suppression" => {}
|
||||
_ => return Err(invalid("unknown condition type")),
|
||||
}
|
||||
}
|
||||
let effect = rule.get("effect").ok_or_else(|| invalid("effect required"))?;
|
||||
if effect.get("type").and_then(Value::as_str) != Some("drive_suppression") {
|
||||
check_parameter(effect.get("parameter"))?;
|
||||
}
|
||||
match effect.get("type").and_then(Value::as_str) {
|
||||
Some("drive_parameter") => check_expression(effect.get("expression"))?,
|
||||
Some("parameter_range") => {
|
||||
check_expression(effect.get("minimum"))?;
|
||||
check_expression(effect.get("maximum"))?;
|
||||
}
|
||||
Some("allow_parameter_values") => {
|
||||
let expressions = effect.get("expressions").and_then(Value::as_array).ok_or_else(|| invalid("allowed values must be an array"))?;
|
||||
if expressions.is_empty() || expressions.len() > 128 {
|
||||
return Err(invalid("allowed values are empty or too many"));
|
||||
}
|
||||
for expression in expressions {
|
||||
check_expression(Some(expression))?;
|
||||
}
|
||||
}
|
||||
Some("drive_suppression") => {}
|
||||
_ => return Err(invalid("unknown effect type")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn check_expression(value: Option<&Value>) -> Result<()> {
|
||||
let text = value.and_then(Value::as_str).ok_or_else(|| invalid("expression must be text"))?;
|
||||
if text.trim().is_empty() || text.len() > 4096 {
|
||||
return Err(invalid("expression is empty or too long"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn check_parameter(value: Option<&Value>) -> Result<()> {
|
||||
let name = value.and_then(Value::as_str).ok_or_else(|| invalid("parameter name required"))?;
|
||||
if name.is_empty() || name.len() > 64 || Document::param_name_problem(name).is_some() {
|
||||
return Err(invalid("invalid parameter name"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn invalid(message: &str) -> DocError {
|
||||
DocError::Invalid(format!("configuration rules: {message}"))
|
||||
}
|
||||
|
||||
fn operands(
|
||||
doc: &Document,
|
||||
values: &BTreeMap<String, expr::Value>,
|
||||
parameter: &str,
|
||||
expression: &str,
|
||||
length_unit: Option<&str>,
|
||||
) -> Result<(f64, f64)> {
|
||||
let lhs = values.get(parameter).copied().ok_or_else(|| invalid(&format!("parameter `{parameter}` is missing or invalid")))?;
|
||||
let unit = doc
|
||||
.param(parameter)
|
||||
.map(|p| p.unit.as_str())
|
||||
.or_else(|| lhs.kind().map(|kind| if kind == expr::Kind::Length { length_unit.unwrap_or(doc.units.as_str()) } else { kind.default_unit() }))
|
||||
.unwrap_or("");
|
||||
let defaults = expr::Defaults::for_unit(unit, expr::unit_info(&doc.units).map(|(_, scale)| scale).unwrap_or(1.0));
|
||||
let rhs = expr::eval_with_defaults(
|
||||
expression,
|
||||
&|name| values.get(name).copied().ok_or_else(|| invalid(&format!("unknown parameter `{name}`"))),
|
||||
defaults,
|
||||
)?;
|
||||
let kind = lhs.kind().ok_or_else(|| invalid("comparison requires length, angle or unitless values"))?;
|
||||
let right = rhs.to_kind_in(kind, defaults)?;
|
||||
if !lhs.v.is_finite() || !right.is_finite() {
|
||||
return Err(invalid("comparison requires finite values"));
|
||||
}
|
||||
Ok((lhs.v, right))
|
||||
}
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
|
||||
}
|
||||
fn validate_drive_graph(graph: &BTreeMap<String, Vec<String>>, parameter: &str, expression: &str) -> Result<()> {
|
||||
let mut pending = expr::references(expression);
|
||||
let mut visited = BTreeSet::new();
|
||||
while let Some(name) = pending.pop() {
|
||||
if name == parameter {
|
||||
return Err(invalid(&format!("drive for `{parameter}` creates a parameter cycle")));
|
||||
}
|
||||
if visited.insert(name.clone())
|
||||
&& let Some(references) = graph.get(&name)
|
||||
{
|
||||
pending.extend(references.iter().cloned());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn condition(doc: &Document, values: &BTreeMap<String, expr::Value>, value: &RuleCondition, length_unit: Option<&str>) -> Result<bool> {
|
||||
match value {
|
||||
RuleCondition::Always => Ok(true),
|
||||
RuleCondition::All { conditions } => {
|
||||
for c in conditions {
|
||||
if !condition(doc, values, c, length_unit)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
RuleCondition::Any { conditions } => {
|
||||
for c in conditions {
|
||||
if condition(doc, values, c, length_unit)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
RuleCondition::Not { condition: c } => Ok(!condition(doc, values, c, length_unit)?),
|
||||
RuleCondition::Suppression { feature, value } => {
|
||||
Ok(doc.feature(*feature).ok_or_else(|| invalid("condition feature is gone"))?.suppressed == *value)
|
||||
}
|
||||
RuleCondition::Parameter { parameter, operator, expression } => {
|
||||
let (a, b) = operands(doc, values, parameter, expression, length_unit)?;
|
||||
Ok(match operator {
|
||||
RuleOperator::Eq => close(a, b),
|
||||
RuleOperator::Ne => !close(a, b),
|
||||
RuleOperator::Lt => a < b,
|
||||
RuleOperator::Le => a <= b || close(a, b),
|
||||
RuleOperator::Gt => a > b,
|
||||
RuleOperator::Ge => a >= b || close(a, b),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Apply ordered drives, then enforce all matched restrictions on the final state.
|
||||
/// Running on a scratch document makes a failing rule fully atomic for callers.
|
||||
pub(super) fn evaluate_configuration_rules(&mut self) -> Result<Vec<RuleTrace>> {
|
||||
validate_rules_json(&serde_json::json!({"configs":{"rules":self.configs.rules}}))?;
|
||||
let mut ids = BTreeSet::new();
|
||||
let mut trace = Vec::new();
|
||||
let mut restrictions = Vec::new();
|
||||
let (mut values, _) = self.param_values();
|
||||
let parameter_names: BTreeSet<String> = self.all_param_exprs().into_iter().map(|(name, _, _)| name).collect();
|
||||
let mut dependency_graph: BTreeMap<String, Vec<String>> =
|
||||
self.all_param_exprs().into_iter().map(|(name, expression, _)| (name, expr::references(&expression))).collect();
|
||||
for rule in self.configs.rules.clone() {
|
||||
if !ids.insert(rule.id.clone()) {
|
||||
return Err(invalid("duplicate rule id"));
|
||||
}
|
||||
if let Some(name) = rule.parameter_references().iter().find(|name| !parameter_names.contains(*name)) {
|
||||
return Err(invalid(&format!("rule `{}` references missing parameter `{name}`", rule.name)));
|
||||
}
|
||||
// Targets must remain configured, including disabled rules, so dormant rules
|
||||
// cannot silently become invalid and surprise a subsequent activation.
|
||||
let target = match &rule.effect {
|
||||
RuleEffect::DriveParameter { parameter, .. }
|
||||
| RuleEffect::ParameterRange { parameter, .. }
|
||||
| RuleEffect::AllowParameterValues { parameter, .. } => Column::Param(parameter.clone()),
|
||||
RuleEffect::DriveSuppression { feature, .. } => Column::Suppress(*feature),
|
||||
};
|
||||
if !self.configs.columns.contains(&target) {
|
||||
return Err(invalid(&format!("rule `{}` target is not configured", rule.name)));
|
||||
}
|
||||
let matched = rule.enabled
|
||||
&& condition(self, &values, &rule.condition, rule.length_unit.as_deref())
|
||||
.map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?;
|
||||
trace.push(RuleTrace { id: rule.id, name: rule.name.clone(), matched, effect: rule.effect.clone() });
|
||||
if !matched {
|
||||
continue;
|
||||
}
|
||||
match rule.effect {
|
||||
RuleEffect::DriveParameter { parameter, mut expression } => {
|
||||
validate_drive_graph(&dependency_graph, ¶meter, &expression)?;
|
||||
dependency_graph.insert(parameter.clone(), expr::references(&expression));
|
||||
if self.param(¶meter).is_none()
|
||||
&& rule.length_unit.as_deref().is_some_and(|unit| unit != self.units)
|
||||
&& values.get(¶meter).is_some_and(|value| value.kind() == Some(expr::Kind::Length))
|
||||
{
|
||||
let (_, value) = operands(self, &values, ¶meter, &expression, rule.length_unit.as_deref())?;
|
||||
expression = format!("{value} mm");
|
||||
}
|
||||
self.change_params_atomic(&[(parameter, expression)]).map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?;
|
||||
values = self.param_values().0;
|
||||
}
|
||||
RuleEffect::DriveSuppression { feature, value } => {
|
||||
self.feature_mut(feature).ok_or_else(|| invalid("rule feature is gone"))?.suppressed = value
|
||||
}
|
||||
effect => restrictions.push((rule.name, effect, rule.length_unit)),
|
||||
}
|
||||
}
|
||||
for (name, effect, length_unit) in restrictions {
|
||||
let valid = match effect {
|
||||
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
|
||||
let (value, min) = operands(self, &values, ¶meter, &minimum, length_unit.as_deref())?;
|
||||
let (_, max) = operands(self, &values, ¶meter, &maximum, length_unit.as_deref())?;
|
||||
min <= max && (value >= min || close(value, min)) && (value <= max || close(value, max))
|
||||
}
|
||||
RuleEffect::AllowParameterValues { parameter, expressions } => {
|
||||
let mut valid = false;
|
||||
for expression in expressions {
|
||||
let (a, b) = operands(self, &values, ¶meter, &expression, length_unit.as_deref())?;
|
||||
valid |= close(a, b);
|
||||
}
|
||||
valid
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
if !valid {
|
||||
return Err(invalid(&format!("rule `{name}` restricts the final value")));
|
||||
}
|
||||
}
|
||||
Ok(trace)
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigRule {
|
||||
pub(super) fn preserve_length_units(&mut self, parameters: &BTreeSet<String>, unit: &str) {
|
||||
if self.length_unit.is_none() {
|
||||
self.length_unit = Some(unit.to_string());
|
||||
}
|
||||
fn annotate(parameter: &str, expression: &mut String, parameters: &BTreeSet<String>, unit: &str) {
|
||||
if parameters.contains(parameter) && expression.trim().parse::<f64>().is_ok() {
|
||||
*expression = format!("{} {unit}", expression.trim());
|
||||
}
|
||||
}
|
||||
fn walk(condition: &mut RuleCondition, parameters: &BTreeSet<String>, unit: &str) {
|
||||
match condition {
|
||||
RuleCondition::Parameter { parameter, expression, .. } => annotate(parameter, expression, parameters, unit),
|
||||
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
|
||||
for condition in conditions {
|
||||
walk(condition, parameters, unit);
|
||||
}
|
||||
}
|
||||
RuleCondition::Not { condition } => walk(condition, parameters, unit),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
walk(&mut self.condition, parameters, unit);
|
||||
match &mut self.effect {
|
||||
RuleEffect::DriveParameter { parameter, expression } => annotate(parameter, expression, parameters, unit),
|
||||
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
|
||||
annotate(parameter, minimum, parameters, unit);
|
||||
annotate(parameter, maximum, parameters, unit);
|
||||
}
|
||||
RuleEffect::AllowParameterValues { parameter, expressions } => {
|
||||
for expression in expressions {
|
||||
annotate(parameter, expression, parameters, unit);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameter references include targets and condition/effect expressions.
|
||||
pub fn parameter_references(&self) -> Vec<String> {
|
||||
let mut copy = self.clone();
|
||||
let mut references = Vec::new();
|
||||
copy.visit_parameters(&mut |text, expression| {
|
||||
if expression {
|
||||
references.extend(expr::references(text));
|
||||
} else {
|
||||
references.push(text.clone());
|
||||
}
|
||||
});
|
||||
references
|
||||
}
|
||||
pub fn rename_parameter(&mut self, old: &str, new: &str) {
|
||||
self.visit_parameters(&mut |text, expression| {
|
||||
if expression {
|
||||
*text = expr::rename_reference(text, old, new);
|
||||
} else if text == old {
|
||||
*text = new.to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
pub(super) fn clone_for_targets(&self, maps: &super::ConfigCloneMaps) -> Option<Self> {
|
||||
let mapped = match &self.effect {
|
||||
RuleEffect::DriveParameter { parameter, .. }
|
||||
| RuleEffect::ParameterRange { parameter, .. }
|
||||
| RuleEffect::AllowParameterValues { parameter, .. } => maps.parameters.contains_key(parameter),
|
||||
RuleEffect::DriveSuppression { feature, .. } => maps.features.contains_key(feature),
|
||||
};
|
||||
if !mapped {
|
||||
return None;
|
||||
}
|
||||
let mut copy = self.clone();
|
||||
copy.visit_parameters(&mut |text, expression| {
|
||||
*text = if expression {
|
||||
super::clone::remap_expression(text, &maps.parameters)
|
||||
} else {
|
||||
maps.parameters.get(text).cloned().unwrap_or_else(|| text.clone())
|
||||
};
|
||||
});
|
||||
fn remap(condition: &mut RuleCondition, maps: &super::ConfigCloneMaps) {
|
||||
match condition {
|
||||
RuleCondition::Suppression { feature, .. } => {
|
||||
*feature = maps.features.get(feature).copied().unwrap_or(*feature);
|
||||
}
|
||||
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
|
||||
for condition in conditions {
|
||||
remap(condition, maps);
|
||||
}
|
||||
}
|
||||
RuleCondition::Not { condition } => remap(condition, maps),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
remap(&mut copy.condition, maps);
|
||||
if let RuleEffect::DriveSuppression { feature, .. } = &mut copy.effect {
|
||||
*feature = maps.features.get(feature).copied().unwrap_or(*feature);
|
||||
}
|
||||
Some(copy)
|
||||
}
|
||||
fn visit_parameters(&mut self, visit: &mut impl FnMut(&mut String, bool)) {
|
||||
fn walk(c: &mut RuleCondition, visit: &mut impl FnMut(&mut String, bool)) {
|
||||
match c {
|
||||
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
|
||||
for c in conditions {
|
||||
walk(c, visit);
|
||||
}
|
||||
}
|
||||
RuleCondition::Not { condition } => walk(condition, visit),
|
||||
RuleCondition::Parameter { parameter, expression, .. } => {
|
||||
visit(parameter, false);
|
||||
visit(expression, true);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
walk(&mut self.condition, visit);
|
||||
match &mut self.effect {
|
||||
RuleEffect::DriveParameter { parameter, expression } => {
|
||||
visit(parameter, false);
|
||||
visit(expression, true);
|
||||
}
|
||||
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
|
||||
visit(parameter, false);
|
||||
visit(minimum, true);
|
||||
visit(maximum, true);
|
||||
}
|
||||
RuleEffect::AllowParameterValues { parameter, expressions } => {
|
||||
visit(parameter, false);
|
||||
for e in expressions {
|
||||
visit(e, true);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn references_features(&self, ids: &[u64]) -> bool {
|
||||
fn condition(c: &RuleCondition, ids: &[u64]) -> bool {
|
||||
match c {
|
||||
RuleCondition::Suppression { feature, .. } => ids.contains(feature),
|
||||
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => conditions.iter().any(|c| condition(c, ids)),
|
||||
RuleCondition::Not { condition: c } => condition(c, ids),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
condition(&self.condition, ids) || matches!(&self.effect,RuleEffect::DriveSuppression {feature,..} if ids.contains(feature))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::ConfigRow;
|
||||
fn design() -> Document {
|
||||
let mut doc = Document::new("Rules");
|
||||
doc.set_param("width", "10 mm", None, None).unwrap();
|
||||
doc.set_param("depth", "5 mm", None, None).unwrap();
|
||||
doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
|
||||
doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
|
||||
doc
|
||||
}
|
||||
fn rule(id: &str, condition: RuleCondition, effect: RuleEffect) -> ConfigRule {
|
||||
ConfigRule { id: id.into(), name: id.into(), enabled: true, length_unit: None, condition, effect }
|
||||
}
|
||||
#[test]
|
||||
fn ordered_rules_derive_values_without_overwriting_rows() {
|
||||
let mut doc = design();
|
||||
doc.configs.rules = vec![
|
||||
rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "1 in".into() }),
|
||||
rule(
|
||||
"second",
|
||||
RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Gt, expression: "20 mm".into() },
|
||||
RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() },
|
||||
),
|
||||
];
|
||||
let source = doc.configs.rows.clone();
|
||||
let trace = doc.apply_configuration_with_trace("Default").unwrap();
|
||||
assert!(trace.iter().all(|entry| entry.matched));
|
||||
assert!((doc.param_values().0["depth"].v - 12.7).abs() < 1e-9);
|
||||
assert_eq!(doc.configs.rows, source);
|
||||
assert!(doc.configuration_matches());
|
||||
doc.configs.rules.swap(0, 1);
|
||||
let trace = doc.apply_configuration_with_trace("Default").unwrap();
|
||||
assert!(!trace[0].matched);
|
||||
assert_eq!(doc.param_values().0["depth"].v, 5.0);
|
||||
}
|
||||
#[test]
|
||||
fn restrictions_intersect_and_fail_atomically_with_named_rule() {
|
||||
let mut doc = design();
|
||||
doc.configs.rules = vec![
|
||||
rule(
|
||||
"range",
|
||||
RuleCondition::Always,
|
||||
RuleEffect::ParameterRange { parameter: "width".into(), minimum: "5 mm".into(), maximum: "1 in".into() },
|
||||
),
|
||||
rule(
|
||||
"choices",
|
||||
RuleCondition::Always,
|
||||
RuleEffect::AllowParameterValues { parameter: "width".into(), expressions: vec!["1 cm".into(), "20 mm".into()] },
|
||||
),
|
||||
];
|
||||
doc.apply_configuration("Default").unwrap();
|
||||
doc.configs.rows[0].cells[0] = "15 mm".into();
|
||||
let before = doc.clone();
|
||||
assert!(doc.apply_configuration("Default").unwrap_err().to_string().contains("choices"));
|
||||
assert_eq!(doc, before);
|
||||
doc.configs.rules[0].effect = RuleEffect::ParameterRange { parameter: "width".into(), minimum: "1 deg".into(), maximum: "90 deg".into() };
|
||||
assert!(doc.apply_configuration("Default").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn rule_references_follow_rename_and_prevent_delete() {
|
||||
let mut doc = design();
|
||||
doc.configs.rules.push(rule(
|
||||
"derive",
|
||||
RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Ge, expression: "depth".into() },
|
||||
RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() },
|
||||
));
|
||||
assert!(doc.remove_param("width").is_err());
|
||||
doc.rename_param("width", "span").unwrap();
|
||||
let refs = doc.configs.rules[0].parameter_references();
|
||||
assert!(refs.contains(&"span".into()));
|
||||
assert!(!refs.contains(&"width".into()));
|
||||
doc.apply_configuration("Default").unwrap();
|
||||
assert_eq!(doc.param_values().0["depth"].v, 5.0);
|
||||
}
|
||||
#[test]
|
||||
fn angle_and_unitless_comparisons_keep_their_dimensions() {
|
||||
let mut doc = design();
|
||||
doc.set_param("angle", "45 deg", Some("deg"), None).unwrap();
|
||||
doc.set_param("count", "1", Some(""), None).unwrap();
|
||||
doc.configs.columns.push(Column::Param("count".into()));
|
||||
doc.configs.rows[0].cells.push("1".into());
|
||||
doc.configs.rules.push(rule(
|
||||
"angle-drive",
|
||||
RuleCondition::Parameter { parameter: "angle".into(), operator: RuleOperator::Gt, expression: "1".into() },
|
||||
RuleEffect::DriveParameter { parameter: "count".into(), expression: "2".into() },
|
||||
));
|
||||
let trace = doc.apply_configuration_with_trace("Default").unwrap();
|
||||
assert!(trace[0].matched);
|
||||
assert_eq!(doc.param_values().0["count"].v, 2.0);
|
||||
doc.configs.rules[0].condition =
|
||||
RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "1 deg".into() };
|
||||
let before = doc.clone();
|
||||
assert!(doc.apply_configuration("Default").is_err());
|
||||
assert_eq!(doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_rule_budgets_are_checked_before_deserialization() {
|
||||
let valid = serde_json::to_value(rule(
|
||||
"r",
|
||||
RuleCondition::Always,
|
||||
RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() },
|
||||
))
|
||||
.unwrap();
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[valid.clone()]}})).is_ok());
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":null}})).is_err());
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":vec![valid.clone();129]}})).is_err());
|
||||
let mut condition = serde_json::json!({"type":"always"});
|
||||
for _ in 0..8 {
|
||||
condition = serde_json::json!({"type":"not","condition":condition});
|
||||
}
|
||||
let mut deep = valid.clone();
|
||||
deep["condition"] = condition;
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[deep]}})).is_err());
|
||||
let mut wide = valid.clone();
|
||||
wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]});
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[wide]}})).is_err());
|
||||
let mut long = valid.clone();
|
||||
long["effect"]["expression"] = serde_json::json!("a".repeat(4097));
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[long]}})).is_err());
|
||||
let mut malformed = valid;
|
||||
malformed["condition"] = serde_json::json!({"type":"any","conditions":{}});
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[malformed]}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_conditions_short_circuit_guarded_numeric_expressions() {
|
||||
let mut doc = design();
|
||||
doc.set_param("count", "0", Some(""), None).unwrap();
|
||||
let guard = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "0".into() };
|
||||
let invalid_when_zero = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "1 / count".into() };
|
||||
doc.configs.rules = vec![rule(
|
||||
"guarded",
|
||||
RuleCondition::All { conditions: vec![guard, invalid_when_zero.clone()] },
|
||||
RuleEffect::DriveParameter { parameter: "width".into(), expression: "20 mm".into() },
|
||||
)];
|
||||
assert!(!doc.apply_configuration_with_trace("Default").unwrap()[0].matched);
|
||||
assert_eq!(doc.param_values().0["width"].v, 10.0);
|
||||
doc.configs.rules[0].condition = RuleCondition::Any {
|
||||
conditions: vec![
|
||||
RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "0".into() },
|
||||
invalid_when_zero,
|
||||
],
|
||||
};
|
||||
assert!(doc.apply_configuration_with_trace("Default").unwrap()[0].matched);
|
||||
assert_eq!(doc.param_values().0["width"].v, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_documents_reject_malformed_deep_and_oversized_rules_before_typed_decode() {
|
||||
let valid = serde_json::to_value(rule(
|
||||
"r",
|
||||
RuleCondition::Always,
|
||||
RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() },
|
||||
))
|
||||
.unwrap();
|
||||
let mut deep = valid.clone();
|
||||
let mut condition = serde_json::json!({"type":"always"});
|
||||
for _ in 0..8 {
|
||||
condition = serde_json::json!({"type":"not","condition":condition});
|
||||
}
|
||||
deep["condition"] = condition;
|
||||
let mut wide = valid.clone();
|
||||
wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]});
|
||||
let mut invalid_context = valid.clone();
|
||||
invalid_context["length_unit"] = serde_json::json!("mile".repeat(1000));
|
||||
for invalid in [
|
||||
Value::Null,
|
||||
serde_json::json!([deep]),
|
||||
serde_json::json!([wide]),
|
||||
serde_json::json!(vec![valid; 129]),
|
||||
serde_json::json!([invalid_context]),
|
||||
] {
|
||||
let mut saved = serde_json::to_value(design()).unwrap();
|
||||
saved["configs"]["rules"] = invalid;
|
||||
let error = crate::format::read(&saved.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("configuration rules"), "saved boundary must report bounded rule validation: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_unit_effective_literals_keep_declared_drive_cycle_checks() {
|
||||
let mut doc = Document::new("Cross-unit drive graph");
|
||||
doc.add_feature(
|
||||
crate::FeatureKind::Box {
|
||||
corner: ["0".into(), "0".into(), "0".into()],
|
||||
length: "10 mm".into(),
|
||||
width: "5 mm".into(),
|
||||
height: "10 mm".into(),
|
||||
operation: crate::Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let width = doc.features[0].param_names[0].clone();
|
||||
let depth = doc.features[0].param_names[1].clone();
|
||||
doc.rename_param(&width, "width").unwrap();
|
||||
doc.rename_param(&depth, "depth").unwrap();
|
||||
doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
|
||||
doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
|
||||
doc.units = "in".into();
|
||||
let mut first =
|
||||
rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "depth + 1".into() });
|
||||
first.length_unit = Some("mm".into());
|
||||
let mut second =
|
||||
rule("second", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width + 1".into() });
|
||||
second.length_unit = Some("mm".into());
|
||||
doc.configs.rules = vec![first];
|
||||
doc.apply_configuration("Default").unwrap();
|
||||
assert_eq!(doc.config_current(&Column::Param("width".into())).as_deref(), Some("6 mm"));
|
||||
doc.configs.rules.push(second);
|
||||
let before = doc.clone();
|
||||
assert!(doc.apply_configuration("Default").is_err());
|
||||
assert_eq!(doc, before);
|
||||
}
|
||||
}
|
||||
@@ -1,580 +0,0 @@
|
||||
use super::*;
|
||||
use crate::{FeatureKind, Model, PlaneRef};
|
||||
use serde_json::json;
|
||||
use solvecraft_geom::{Vec2, Vec3};
|
||||
|
||||
fn shell_document() -> (Document, u64, u64) {
|
||||
let mut doc = Document::new("certified shell");
|
||||
doc.set_param("width", "20", Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "2", Some("mm"), None).unwrap();
|
||||
let base = doc
|
||||
.add_feature(
|
||||
serde_json::from_value(json!({"type":"box","corner":[0,0,0],"length":"width","width":"12","height":"10"})).unwrap(),
|
||||
Some("Case"),
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(base).unwrap().body_names = vec!["Case".into()];
|
||||
let shell = doc
|
||||
.add_feature(
|
||||
FeatureKind::Shell {
|
||||
faces: vec![Vec3::new(5., 6., 10.)],
|
||||
thickness: "wall".into(),
|
||||
body: Some("Case".into()),
|
||||
direction: "inside".into(),
|
||||
tangent_chain: false,
|
||||
},
|
||||
Some("Shell"),
|
||||
)
|
||||
.unwrap();
|
||||
(doc, base, shell)
|
||||
}
|
||||
|
||||
fn face_index(body: &ModelBody, inner: bool) -> usize {
|
||||
body.body
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.position(|source| {
|
||||
matches!(source, Some(solvecraft_kernel::FaceSource::ShellPlanar { inner: current, source, .. })
|
||||
if *current == inner && matches!(source.as_ref(), solvecraft_kernel::FaceSource::Box { axis:0,high:true,.. }))
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_planar_raw_bounds_flat_identity_and_clone_remap() {
|
||||
let plane = solvecraft_geom::Plane::XY;
|
||||
let same = |planes: &[Option<solvecraft_geom::Plane>]| super::super::styles::shell_plane_family(planes.iter().copied());
|
||||
assert!(same(&[Some(plane), Some(plane)]));
|
||||
assert!(!same(&[]));
|
||||
assert!(!same(&[Some(plane), None]));
|
||||
for different in [
|
||||
solvecraft_geom::Plane { origin: Vec3::X, ..plane },
|
||||
solvecraft_geom::Plane { x: -plane.x, y: -plane.y, ..plane },
|
||||
solvecraft_geom::Plane { y: -plane.y, ..plane },
|
||||
] {
|
||||
assert!(!same(&[Some(plane), Some(different)]));
|
||||
}
|
||||
let valid = json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"extrude_side","sketch":2,"curve":"c4"},"inner":true});
|
||||
assert!(validate_certificate_json(&valid).is_ok());
|
||||
let mut certificate: FaceCertificate = serde_json::from_value(valid.clone()).unwrap();
|
||||
certificate.remap_features(&[(2, 20), (3, 30)].into_iter().collect());
|
||||
assert_eq!(serde_json::to_value(&certificate).unwrap()["source_producer"], 30);
|
||||
assert_eq!(serde_json::to_value(&certificate).unwrap()["source"]["sketch"], 20);
|
||||
for bad in [
|
||||
json!({"kind":"shell_planar","source_producer":0,"source":{"kind":"box","axis":0,"high":true},"inner":false}),
|
||||
json!({"kind":"shell_planar","source_producer":3,"source":null,"inner":false}),
|
||||
json!({"kind":"shell_planar","source_producer":3,"source":valid,"inner":false}),
|
||||
json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"extrude_side","sketch":2,"curve":"x".repeat(1_000_000)},"inner":false}),
|
||||
json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"box","axis":0,"high":true,"extra":[[[]]]},"inner":false}),
|
||||
] {
|
||||
assert!(validate_certificate_json(&bad).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_planar_anchor_rebuild_cold_geometry_and_missing_source_errors() {
|
||||
let (mut doc, base, shell) = shell_document();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.results.iter().all(|result| result.error.is_none()), "{:?}", model.results);
|
||||
let state = model.state();
|
||||
let body = state.body("Case").unwrap();
|
||||
let outer = doc.strong_face_target(body, face_index(body, false), Vec3::default()).unwrap();
|
||||
let outer_matches: Vec<_> = body.body.face_sources().unwrap().iter().enumerate().filter_map(|(index, source)| {
|
||||
matches!(source, Some(solvecraft_kernel::FaceSource::ShellPlanar { inner: false, source, .. }) if matches!(source.as_ref(), solvecraft_kernel::FaceSource::Box { axis: 0, high: true, .. })).then_some((index, body.body.face_plane(index)))
|
||||
}).collect();
|
||||
assert_eq!(outer_matches.len(), 1, "outer support must be one physical face: {outer_matches:?}");
|
||||
assert!(super::super::styles::shell_plane_family(outer_matches.iter().map(|(_, plane)| *plane)));
|
||||
let outer_plane = body.body.face_plane(face_index(body, false)).unwrap();
|
||||
let outer_anchor = doc.capture_face_anchor(body, face_index(body, false), &outer_plane, &[], 0, &[]).unwrap();
|
||||
let projected = crate::project::resolve_anchored_face(&doc, &state, &outer_plane, solvecraft_sketch::LinkKind::Project, &outer_anchor).unwrap();
|
||||
let mut linked = solvecraft_sketch::Sketch::new();
|
||||
let link = linked.add_link(solvecraft_sketch::LinkKind::Project, projected.source, &projected.geom).unwrap();
|
||||
assert!(!linked.update_link(&link, &projected.geom).unwrap());
|
||||
let inner_index = face_index(body, true);
|
||||
let inner = doc.strong_face_target(body, inner_index, Vec3::default()).unwrap();
|
||||
assert_ne!(outer.key(), inner.key());
|
||||
assert!(matches!(&inner.certificate, Some(FaceCertificate::ShellPlanar { source_producer,inner:true,.. }) if *source_producer==base));
|
||||
assert_eq!(inner.origin_feature, shell);
|
||||
let picked = body.body.face_plane(inner_index).unwrap();
|
||||
assert!((picked.origin.x - 18.).abs() < 1e-8);
|
||||
let anchor = doc.capture_face_anchor(body, inner_index, &picked, &[], 0, &[]).unwrap();
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
let points: Vec<_> =
|
||||
[(2., 2.), (4., 2.), (4., 4.), (2., 4.)].into_iter().map(|(x, y)| sketch.add_point(Vec2::new(x, y), None).unwrap()).collect();
|
||||
for edge in 0..4 {
|
||||
sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap();
|
||||
}
|
||||
let sk = doc
|
||||
.add_feature(
|
||||
FeatureKind::Sketch {
|
||||
plane: PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor.clone())) },
|
||||
sketch,
|
||||
},
|
||||
Some("Inner face sketch"),
|
||||
)
|
||||
.unwrap();
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"2"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
Some("Face boss"),
|
||||
)
|
||||
.unwrap();
|
||||
model.evaluate(&doc);
|
||||
let before = model.state().bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
doc.set_param("width", "30", Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "3", Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let changed = model.state();
|
||||
let plane = doc.resolve_face_anchor(&anchor, 0, &changed).unwrap();
|
||||
assert!((plane.origin.x - 27.).abs() < 1e-8);
|
||||
let after = changed.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
assert!((solvecraft_kernel::measure(&changed.bodies.iter().find(|body| body.feature == boss).unwrap().body).unwrap().volume - 8.).abs() < 1e-8);
|
||||
assert!((doc.resolve_face_anchor(&outer_anchor, 0, &changed).unwrap().origin.x - 30.).abs() < 1e-8);
|
||||
let changed_outer_plane = doc.resolve_face_anchor(&outer_anchor, 0, &changed).unwrap();
|
||||
let refreshed =
|
||||
crate::project::resolve_anchored_face(&doc, &changed, &changed_outer_plane, solvecraft_sketch::LinkKind::Project, &outer_anchor).unwrap();
|
||||
assert!(!linked.update_link(&link, &refreshed.geom).unwrap(), "same exterior boundary must retain projected IDs");
|
||||
assert!((after.min.x - before.min.x - 9.).abs() < 1e-8, "{before:?} -> {after:?}");
|
||||
assert!((after.max.x - before.max.x - 9.).abs() < 1e-8, "{before:?} -> {after:?}");
|
||||
assert!((after.min.y - before.min.y).abs() < 1e-8 && (after.min.z - before.min.z).abs() < 1e-8);
|
||||
let saved = doc.to_json();
|
||||
assert!(saved.contains("solvecraft/13"));
|
||||
let loaded = crate::format::read(&saved).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&loaded);
|
||||
let cold_state = cold.state();
|
||||
let cold_bounds = cold_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
assert!(after.min.dist(cold_bounds.min) < 1e-9 && after.max.dist(cold_bounds.max) < 1e-9);
|
||||
assert!(loaded.resolve_face_anchor(&anchor, 0, &cold_state).unwrap().origin.dist(plane.origin) < 1e-9);
|
||||
let mut wrong = anchor.clone();
|
||||
wrong.target.origin_feature = base;
|
||||
assert!(doc.resolve_face_anchor(&wrong, 0, &changed).is_err());
|
||||
let mut wrong_source = anchor.clone();
|
||||
if let Some(FaceCertificate::ShellPlanar { source_producer, .. }) = &mut wrong_source.target.certificate {
|
||||
*source_producer = shell;
|
||||
}
|
||||
assert!(doc.resolve_face_anchor(&wrong_source, 0, &changed).is_err());
|
||||
doc.feature_mut(shell).unwrap().suppressed = true;
|
||||
model.evaluate(&doc);
|
||||
let lost = model.state();
|
||||
assert!(doc.resolve_face_anchor(&anchor, 0, &lost).is_err());
|
||||
assert!(!lost.bodies.iter().any(|body| body.feature == boss), "lost attachment must prevent downstream geometry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
|
||||
let (doc, _, _) = shell_document();
|
||||
let mut legacy: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solvecraft/12");
|
||||
let loaded = crate::format::read(&legacy.to_string()).unwrap();
|
||||
let mut current: serde_json::Value = serde_json::from_str(&loaded.to_json()).unwrap();
|
||||
assert_eq!(current["format"], "solvecraft/13");
|
||||
current["format"] = json!("solvecraft/12");
|
||||
assert_eq!(legacy, current, "identity migration must preserve every prior field");
|
||||
legacy["format"] = json!("solvecraft/14");
|
||||
assert!(crate::format::read(&legacy.to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regularized_shell_keeps_legacy_and_explicit_single_face_projections_closed() {
|
||||
let (mut doc, _, _) = shell_document();
|
||||
let mut model = Model::new();
|
||||
for (width, wall) in [(20., 2.), (30., 3.)] {
|
||||
doc.set_param("width", &width.to_string(), Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let loaded = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&loaded);
|
||||
for state in [model.state(), cold.state()] {
|
||||
let body = state.body("Case").unwrap();
|
||||
let index = face_index(body, false);
|
||||
let mesh = body.mesh();
|
||||
assert_eq!(body.body.face_sources().unwrap().iter().filter(|source|matches!(source,Some(solvecraft_kernel::FaceSource::ShellPlanar {inner:false,source,..}) if matches!(source.as_ref(),solvecraft_kernel::FaceSource::Box {axis:0,high:true,..}))).count(),1,"no physical outer-wall strip face may remain");
|
||||
let plane = body.body.face_plane(index).unwrap();
|
||||
let point = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.find_map(|(tri, face)| (*face as usize == index).then(|| mesh.tri(tri)).flatten())
|
||||
.map(|[a, b, c]| (a + b + c) / 3.)
|
||||
.unwrap();
|
||||
let source = solvecraft_sketch::LinkSource::Face { body: "Case".into(), at: point };
|
||||
let (values, _) = doc.param_values();
|
||||
let legacy = crate::project::resolve(&doc, &values, &state, &plane, solvecraft_sketch::LinkKind::Project, &source).unwrap();
|
||||
let explicit = crate::project::resolve_face_indices(&state, &plane, solvecraft_sketch::LinkKind::Project, "Case", &[index]).unwrap();
|
||||
assert_eq!(legacy.geom, explicit.geom);
|
||||
assert_eq!(explicit.geom.len(), 4, "raw planar piece keeps all four edges");
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
let link = sketch.add_link(solvecraft_sketch::LinkKind::Project, source, &legacy.geom).unwrap();
|
||||
sketch.links[0].face_name = Some(crate::naming::face_names(body)[index].clone());
|
||||
let warnings = crate::project::refresh_links(&doc, &values, &state, &plane, &mut sketch);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert!(!sketch.links[0].lost);
|
||||
assert!(!sketch.update_link(&link, &explicit.geom).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material() {
|
||||
let (mut doc, _, shell) = shell_document();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let state = model.state();
|
||||
let body = state.body("Case").unwrap();
|
||||
let index = face_index(body, false);
|
||||
let plane = body.body.face_plane(index).unwrap();
|
||||
let stem = crate::naming::face_names(body)[index].clone();
|
||||
assert_eq!(crate::naming::strip_piece(&stem), stem);
|
||||
// Reconstruct the original raw shell before certificate stamping/regularization,
|
||||
// and run the unchanged historical naming/capture path on its actual strip.
|
||||
let before = model.state_before(shell);
|
||||
// Legacy Shell did not propagate construction certificates. Omit transient
|
||||
// metadata here; operand paint also disables new wall-run coalescence so
|
||||
// this control retains the actual historical raw pieces.
|
||||
let raw_source = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(20., 12., 10.)).unwrap().with_paint(Some(solvecraft_kernel::Paint {
|
||||
opacity: 1.,
|
||||
faces: vec![solvecraft_kernel::FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }],
|
||||
}));
|
||||
let raw = solvecraft_kernel::shell(&raw_source, &[Vec3::new(5., 6., 10.)], 2.).unwrap();
|
||||
let mut old_body = ModelBody::new("Case".into(), raw, shell);
|
||||
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
|
||||
feature: doc.feature(shell).unwrap().clone(),
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
})));
|
||||
let former = crate::naming::face_name_at_body(&old_body, Vec3::new(20., 6., 1.)).unwrap();
|
||||
assert_eq!(former, format!("{stem}#1"), "historical actual strip capture records full piece name");
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
let corners = [Vec2::new(0., 0.), Vec2::new(12., 0.), Vec2::new(12., 2.), Vec2::new(0., 2.)];
|
||||
let geometry: Vec<_> = (0..4).map(|i| solvecraft_sketch::LinkGeom::Line(corners[i], corners[(i + 1) % 4])).collect();
|
||||
sketch
|
||||
.add_link(
|
||||
solvecraft_sketch::LinkKind::Project,
|
||||
solvecraft_sketch::LinkSource::Face { body: "Case".into(), at: Vec3::new(20., 6., 1.) },
|
||||
&geometry,
|
||||
)
|
||||
.unwrap();
|
||||
sketch.links[0].face_name = Some(former.clone());
|
||||
let original = sketch.clone();
|
||||
let sk = doc
|
||||
.add_feature(
|
||||
FeatureKind::Sketch { plane: PlaneRef::Face { plane, at: Vec3::new(20., 6., 1.), name: Some(former), anchor: None }, sketch },
|
||||
Some("Saved strip sketch"),
|
||||
)
|
||||
.unwrap();
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
Some("Saved strip profile material"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut legacy: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solvecraft/12");
|
||||
let mut doc = crate::format::read(&legacy.to_string()).unwrap();
|
||||
for width in [20., 30.] {
|
||||
doc.set_param("width", &width.to_string(), Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "3", Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let loaded = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&loaded);
|
||||
for current in [&model, &cold] {
|
||||
let warning = current.results.iter().find(|r| r.id == sk).unwrap().warning.as_deref().unwrap();
|
||||
assert!(warning.contains("no longer exists; last geometry retained"), "{warning}");
|
||||
let state = current.state();
|
||||
let solved = state.sketch(sk).unwrap();
|
||||
assert!(solved.sketch.links[0].lost);
|
||||
assert_eq!(solved.sketch.curves, original.curves);
|
||||
assert_eq!(solved.sketch.points, original.points);
|
||||
let material = state.bodies.iter().find(|body| body.feature == boss).unwrap();
|
||||
assert!(
|
||||
(solvecraft_kernel::measure(&material.body).unwrap().volume - 24.).abs() < 1e-8,
|
||||
"old24mm3 profile must not silently become120mm3 wall profile"
|
||||
);
|
||||
let bounds = material.mesh().bounds();
|
||||
assert!((bounds.min.x - width).abs() < 1e-8 && (bounds.max.x - width - 1.).abs() < 1e-8);
|
||||
assert!((bounds.max.y - 12.).abs() < 1e-8 && (bounds.max.z - 2.).abs() < 1e-8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
|
||||
use solvecraft_kernel::{FacePaint, Paint};
|
||||
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
|
||||
let mut doc = Document::new("saved curved strip");
|
||||
doc.set_param("width", "80", Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "2", Some("mm"), None).unwrap();
|
||||
let base = doc
|
||||
.add_feature(
|
||||
serde_json::from_value(json!({"type":"box","corner":["-width/2",-30,0],"length":"width","width":"60","height":"30"})).unwrap(),
|
||||
Some("Case"),
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(base).unwrap().body_names = vec!["Case".into()];
|
||||
doc.add_feature(
|
||||
serde_json::from_value(json!({"type":"fillet","body":"Case","edges":[[40,30,15],[-40,30,15],[-40,-30,15],[40,-30,15]],"radius":"6"}))
|
||||
.unwrap(),
|
||||
Some("Corners"),
|
||||
)
|
||||
.unwrap();
|
||||
let shell = doc
|
||||
.add_feature(
|
||||
FeatureKind::Shell {
|
||||
faces: vec![Vec3::new(0., 0., 30.)],
|
||||
thickness: "wall".into(),
|
||||
body: Some("Case".into()),
|
||||
direction: "inside".into(),
|
||||
tangent_chain: false,
|
||||
},
|
||||
Some("Shell"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results);
|
||||
let before = model.state_before(shell);
|
||||
// Per-face paint disables the construction optimization: this control keeps
|
||||
// the real pre-repair slab faces without forging a name or source witness.
|
||||
let old_source = before
|
||||
.body("Case")
|
||||
.unwrap()
|
||||
.body
|
||||
.clone()
|
||||
.with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
|
||||
let cavity_box = solvecraft_kernel::box_solid(Vec3::new(-38., -28., 2.), Vec3::new(38., 28., 35.)).unwrap();
|
||||
let cavity = solvecraft_kernel::fillet(
|
||||
&cavity_box,
|
||||
&[Vec3::new(38., 28., 15.), Vec3::new(-38., 28., 15.), Vec3::new(-38., -28., 15.), Vec3::new(38., -28., 15.)],
|
||||
4.,
|
||||
)
|
||||
.unwrap();
|
||||
let raw = solvecraft_kernel::boolean(&old_source, &cavity, solvecraft_kernel::BoolOp::Cut).unwrap().unwrap();
|
||||
let mut old_body = ModelBody::new("Case".into(), raw, shell);
|
||||
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
|
||||
feature: doc.feature(shell).unwrap().clone(),
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
})));
|
||||
let t = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let at = Vec3::new(34. + 6. * t, 24. + 6. * t, 1.);
|
||||
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
|
||||
let stem = crate::naming::strip_piece(&former).to_string();
|
||||
assert_ne!(former, stem, "actual old cylindrical strip must have a piece suffix");
|
||||
let state = model.state();
|
||||
let current = state.body("Case").unwrap();
|
||||
let names = crate::naming::face_names(current);
|
||||
let index = names.iter().position(|name| name == &stem).unwrap();
|
||||
assert!(current.body.face_was_coalesced(index));
|
||||
assert!(current.body.face_plane(index).is_none(), "witness grants no planar attachment authority");
|
||||
assert!(old_body.body.face_sources().is_none_or(|sources| sources.iter().enumerate().all(|(index, _)| !old_body.body.face_was_coalesced(index))));
|
||||
let mut old_state = (*model.state()).clone();
|
||||
old_state.bodies = vec![old_body];
|
||||
let source = LinkSource::Face { body: "Case".into(), at };
|
||||
// Recover the saved projection from the old face's complete raw topology.
|
||||
// Display seam suppression must not discard a historical strip boundary.
|
||||
let old_body = old_state.body("Case").unwrap();
|
||||
let old_index = crate::naming::face_on_body(old_body, &former, at).unwrap().0;
|
||||
let mesh = old_body.mesh();
|
||||
let mut geometry = Vec::new();
|
||||
for (index, faces) in mesh.edge_faces.iter().enumerate() {
|
||||
if !faces.contains(&(old_index as u32)) {
|
||||
continue;
|
||||
}
|
||||
let points = &mesh.edges[index];
|
||||
let a = solvecraft_geom::Plane::YZ.to_local(*points.first().unwrap());
|
||||
let b = solvecraft_geom::Plane::YZ.to_local(*points.last().unwrap());
|
||||
let (a, b) = (Vec2::new(a.x, a.y), Vec2::new(b.x, b.y));
|
||||
assert!(a.dist(b) > 1e-6);
|
||||
for point in points {
|
||||
let q = solvecraft_geom::Plane::YZ.to_local(*point);
|
||||
let q = Vec2::new(q.x, q.y);
|
||||
let direction = b - a;
|
||||
let t = ((q - a).dot(direction) / direction.dot(direction)).clamp(0., 1.);
|
||||
assert!(q.dist(a + direction * t) < 1e-7, "exact cylindrical quadrant projects to a straight support");
|
||||
}
|
||||
geometry.push(solvecraft_sketch::LinkGeom::Line(a, b));
|
||||
}
|
||||
assert_eq!(geometry.len(), 4, "historical curved strip has a complete four-edge rectangular projection");
|
||||
let mut sketch = Sketch::new();
|
||||
sketch.add_link(LinkKind::Project, source, &geometry).unwrap();
|
||||
sketch.links[0].face_name = Some(former);
|
||||
let original = sketch.clone();
|
||||
let sk = doc
|
||||
.add_feature(FeatureKind::Sketch { plane: PlaneRef::Custom { plane: solvecraft_geom::Plane::YZ }, sketch }, Some("Saved corner strip"))
|
||||
.unwrap();
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
Some("Retained strip material"),
|
||||
)
|
||||
.unwrap();
|
||||
for (width, wall) in [(80., 2.), (100., 3.)] {
|
||||
doc.set_param("width", &width.to_string(), Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let loaded = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&loaded);
|
||||
for current in [&model, &cold] {
|
||||
let result = current.results.iter().find(|r| r.id == sk).unwrap();
|
||||
assert!(result.error.is_none());
|
||||
assert!(result.warning.as_deref().unwrap().contains("no longer exists; last geometry retained"));
|
||||
let state = current.state();
|
||||
let solved = state.sketch(sk).unwrap();
|
||||
assert!(solved.sketch.links[0].lost);
|
||||
assert_eq!(solved.sketch.points, original.points);
|
||||
assert_eq!(solved.sketch.curves, original.curves);
|
||||
let material = state.bodies.iter().find(|body| body.feature == boss).unwrap();
|
||||
assert!(
|
||||
(solvecraft_kernel::measure(&material.body).unwrap().volume - 12.).abs() < 1e-7,
|
||||
"two-mm strip cannot expand to a full thirty-mm wall"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
|
||||
use solvecraft_kernel::{BoolOp, FacePaint, Paint};
|
||||
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
|
||||
let mut doc = Document::new("public stepped tab case");
|
||||
doc.set_param("length", "40", Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "2", Some("mm"), None).unwrap();
|
||||
for (name, kind) in [
|
||||
("Case", json!({"type":"box","corner":[0,0,0],"length":"length","width":"30","height":"20"})),
|
||||
("Tab", json!({"type":"box","corner":["length",10,12],"length":"8","width":"10","height":"8"})),
|
||||
] {
|
||||
let id = doc.add_feature(serde_json::from_value(kind).unwrap(), Some(name)).unwrap();
|
||||
doc.feature_mut(id).unwrap().body_names = vec![name.into()];
|
||||
}
|
||||
doc.add_feature(
|
||||
FeatureKind::Combine { target: "Case".into(), tools: vec!["Tab".into()], operation: crate::Operation::Join, keep_tools: false },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let tool = doc
|
||||
.add_feature(
|
||||
serde_json::from_value(
|
||||
json!({"type":"box","corner":["wall","wall","wall"],"length":"length-2*wall","width":"30-2*wall","height":"22-wall"}),
|
||||
)
|
||||
.unwrap(),
|
||||
Some("Pocket tool"),
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(tool).unwrap().body_names = vec!["Pocket tool".into()];
|
||||
let cut = doc
|
||||
.add_feature(
|
||||
FeatureKind::Combine { target: "Case".into(), tools: vec!["Pocket tool".into()], operation: crate::Operation::Cut, keep_tools: false },
|
||||
Some("Pocket"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results);
|
||||
let before = model.state_before(cut);
|
||||
// A real per-face paint control preserves the former slab construction;
|
||||
// no historical names or geometry are fabricated.
|
||||
let old_stock = before
|
||||
.body("Case")
|
||||
.unwrap()
|
||||
.body
|
||||
.clone()
|
||||
.with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
|
||||
let old_cut = solvecraft_kernel::boolean(&old_stock, &before.body("Pocket tool").unwrap().body, BoolOp::Cut).unwrap().unwrap();
|
||||
let mut old_body = ModelBody::new("Case".into(), old_cut, cut);
|
||||
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
|
||||
feature: doc.feature(cut).unwrap().clone(),
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
})));
|
||||
let at = Vec3::new(0., 15., 1.);
|
||||
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
|
||||
assert_ne!(former, crate::naming::strip_piece(&former), "actual former bounded wall patch has a full piece name");
|
||||
let index = crate::naming::face_on_body(&old_body, &former, at).unwrap().0;
|
||||
let mut old_state = crate::ModelState::default();
|
||||
old_state.bodies.push(old_body);
|
||||
let plane = solvecraft_geom::Plane::YZ;
|
||||
let resolved = crate::project::resolve_face_indices(&old_state, &plane, LinkKind::Project, "Case", &[index]).unwrap();
|
||||
let current_state = model.state();
|
||||
let current = current_state.body("Case").unwrap();
|
||||
let stem = crate::naming::strip_piece(&former);
|
||||
let current_names = crate::naming::face_names(current);
|
||||
let merged = current_names.iter().position(|name| name == stem).unwrap();
|
||||
assert!(current.body.face_was_coalesced(merged), "new exact generated-wall construction supplies the loss-only witness");
|
||||
assert!(!current_names.contains(&former));
|
||||
let mut sketch = Sketch::new();
|
||||
sketch.add_link(LinkKind::Project, LinkSource::Face { body: "Case".into(), at }, &resolved.geom).unwrap();
|
||||
sketch.links[0].face_name = Some(former);
|
||||
let original = sketch.clone();
|
||||
let captured = original.points.iter().filter(|p| p.link.is_some()).map(|p| p.pos).collect::<Vec<_>>();
|
||||
assert_eq!(captured.len(), 4);
|
||||
assert!(captured.iter().all(|p| (p.x == 10. || p.x == 20.) && (p.y == 0. || p.y == 20.)));
|
||||
let sk = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch }, Some("Saved wall patch")).unwrap();
|
||||
let material = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
Some("Saved band stock"),
|
||||
)
|
||||
.unwrap();
|
||||
for (length, wall) in [(40., 2.), (50., 3.)] {
|
||||
doc.set_param("length", &length.to_string(), Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let reopened = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&reopened);
|
||||
for current in [&model, &cold] {
|
||||
let result = current.results.iter().find(|r| r.id == sk).unwrap();
|
||||
assert!(result.error.is_none(), "{result:?}");
|
||||
assert!(result.warning.as_deref().is_some_and(|w| w.contains("no longer exists; last geometry retained")), "{result:?}");
|
||||
let state = current.state();
|
||||
let saved = state.sketch(sk).unwrap();
|
||||
assert!(saved.sketch.links[0].lost);
|
||||
assert_eq!(saved.sketch.points, original.points);
|
||||
assert_eq!(saved.sketch.curves, original.curves);
|
||||
let stock = state.bodies.iter().find(|b| b.feature == material).unwrap();
|
||||
assert!(
|
||||
(solvecraft_kernel::measure(&stock.body).unwrap().volume - 200.).abs() < 1e-7,
|
||||
"saved10x20 patch must not become30x20 wall; actualvolume={}, bounds={:?}, originalpoints={:?}",
|
||||
solvecraft_kernel::measure(&stock.body).unwrap().volume,
|
||||
stock.mesh().bounds(),
|
||||
original.points
|
||||
);
|
||||
let case = state.body("Case").unwrap();
|
||||
let expected = length * 30. * 20. + 8. * 10. * 8. - (length - 2. * wall) * (30. - 2. * wall) * (20. - wall);
|
||||
assert!((solvecraft_kernel::measure(&case.body).unwrap().volume - expected).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
//! Stable configuration style targets. Display indices and placement names are never saved.
|
||||
use super::Column;
|
||||
use crate::{DocError, Document, FeatureKind, ModelState, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
pub(crate) fn shell_plane_family(planes: impl IntoIterator<Item = Option<solvecraft_geom::Plane>>) -> bool {
|
||||
let mut planes = planes.into_iter();
|
||||
let Some(Some(first)) = planes.next() else { return false };
|
||||
first.origin.is_finite() && first.x.is_finite() && first.y.is_finite() && planes.all(|plane| plane == Some(first))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigFaceTarget {
|
||||
pub body: String,
|
||||
pub origin_feature: u64,
|
||||
pub name: String,
|
||||
/// Definition-frame point for diagnostics and rendering; never a resolver fallback.
|
||||
pub point: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub certificate: Option<super::FaceCertificate>,
|
||||
}
|
||||
impl ConfigFaceTarget {
|
||||
pub fn key(&self) -> String {
|
||||
// Length-prefix encoding keeps punctuation in body/name unambiguous.
|
||||
format!("{}:{}{}", self.body.len(), self.body, self.name)
|
||||
}
|
||||
pub fn matches(&self, face: &crate::appearance::FaceLook) -> bool {
|
||||
face.body == self.body && face.name.as_ref().is_some_and(|name| crate::naming::strip_piece(name) == self.name)
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if let Some(certificate) = &self.certificate {
|
||||
certificate.validate()?;
|
||||
}
|
||||
if self.body.is_empty()
|
||||
|| self.body.len() > 128
|
||||
|| self.name.is_empty()
|
||||
|| self.name.len() > 512
|
||||
|| self.origin_feature == 0
|
||||
|| [self.point.x, self.point.y, self.point.z].iter().any(|v| !v.is_finite() || v.abs() > 1e9)
|
||||
{
|
||||
return Err(DocError::Invalid("invalid configuration face target".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_face_target_json(target: &Value) -> Result<()> {
|
||||
let fields = target.as_object().ok_or_else(|| DocError::Invalid("face target must be an object".into()))?;
|
||||
if !(4..=5).contains(&fields.len())
|
||||
|| fields.keys().any(|key| !["body", "origin_feature", "name", "point", "certificate"].contains(&key.as_str()))
|
||||
|| fields.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|
||||
|| fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|
||||
|| fields.get("origin_feature").and_then(Value::as_u64).is_none_or(|id| id == 0)
|
||||
|| fields.get("point").and_then(Value::as_array).is_none_or(|point| {
|
||||
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
|
||||
})
|
||||
{
|
||||
return Err(DocError::Invalid("invalid or oversized configuration face target".into()));
|
||||
}
|
||||
if let Some(certificate) = fields.get("certificate") {
|
||||
super::validate_certificate_json(certificate)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Budget malformed style nodes before decoding their typed representation.
|
||||
pub fn validate_styles_json(value: &Value) -> Result<()> {
|
||||
if let Some(columns) = value.get("configs").and_then(|v| v.get("columns")).and_then(Value::as_array) {
|
||||
if columns.len() > super::MAX_COLUMNS {
|
||||
return Err(DocError::Invalid("too many configuration columns".into()));
|
||||
}
|
||||
for column in columns {
|
||||
if let Some(target) = column.get("face_appearance") {
|
||||
validate_face_target_json(target)?;
|
||||
}
|
||||
if let Some(id) = column.get("component_appearance")
|
||||
&& id.as_u64().is_none_or(|id| id == 0)
|
||||
{
|
||||
return Err(DocError::Invalid("configuration component ID must be nonzero".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(faces) = value.get("appearances").and_then(|value| value.get("faces")).and_then(Value::as_array) {
|
||||
for face in faces {
|
||||
if let Some(certificate) = face.get("certificate") {
|
||||
super::validate_certificate_json(certificate)?;
|
||||
if !certificate.is_null()
|
||||
&& (face.get("strict_name").and_then(Value::as_bool) != Some(true)
|
||||
|| face.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|
||||
|| face.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|
||||
|| face.get("point").and_then(Value::as_array).is_none_or(|point| {
|
||||
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
|
||||
}))
|
||||
{
|
||||
return Err(DocError::Invalid("invalid certified face appearance".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub(crate) fn flat_certificate_producer_matches(&self, producer: u64, certificate: &super::FaceCertificate) -> bool {
|
||||
use super::FaceCertificate;
|
||||
let Some(feature) = self.feature(producer) else { return false };
|
||||
match (certificate, &feature.kind) {
|
||||
(FaceCertificate::Box { .. }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => true,
|
||||
(FaceCertificate::ExtrudeCap { sketch, .. }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => sketch == current && extent.taper.is_none(),
|
||||
(FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => {
|
||||
sketch == current && extent.taper.is_none()
|
||||
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. }))))
|
||||
}
|
||||
(FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Certify named generator lineage; generic index-derived naming is unsupported.
|
||||
pub fn certify_config_face(&self, target: &ConfigFaceTarget) -> Result<()> {
|
||||
target.validate()?;
|
||||
let prefix = format!("F{}:", target.origin_feature);
|
||||
let role = target.name.strip_prefix(&prefix).ok_or_else(|| DocError::Invalid("face lineage does not match its feature".into()))?;
|
||||
if crate::naming::strip_piece(&target.name) != target.name {
|
||||
return Err(DocError::Invalid("configure a face lineage, not a numbered split piece".into()));
|
||||
}
|
||||
let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?;
|
||||
if let Some(certificate) = &target.certificate {
|
||||
let valid = match (certificate, &feature.kind) {
|
||||
(super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => {
|
||||
["x", "y", "z"].get(*axis as usize).is_some_and(|axis| role == format!("{}{axis}", if *high { "+" } else { "-" }))
|
||||
}
|
||||
(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
|
||||
sketch == producer_sketch && extent.taper.is_none()
|
||||
&& role == format!("cap:{}:{}", if *high { "high" } else { "low" }, super::cap_key(outer, holes))
|
||||
}
|
||||
(super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
|
||||
sketch == producer_sketch && extent.taper.is_none() && role == format!("side:{curve}")
|
||||
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. }))))
|
||||
}
|
||||
(super::FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => role.strip_prefix("face:").is_some_and(|index| index.parse::<usize>().is_ok()),
|
||||
(super::FaceCertificate::ShellPlanar { source_producer, source, inner }, FeatureKind::Shell { .. }) => {
|
||||
*source_producer != target.origin_feature
|
||||
&& self.flat_certificate_producer_matches(*source_producer, source)
|
||||
&& super::shell_certificate_role(source, *inner).is_ok_and(|expected| role == expected)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
return if valid { Ok(()) } else { Err(DocError::Invalid("face certificate no longer matches its producer".into())) };
|
||||
}
|
||||
let valid = match &feature.kind {
|
||||
FeatureKind::Box { .. } => ["+x", "-x", "+y", "-y", "+z", "-z"].contains(&role),
|
||||
FeatureKind::Cylinder { .. } => ["top", "bottom", "side"].contains(&role),
|
||||
// Curved side lineage currently uses centroid/nearest-curve heuristics;
|
||||
// certify caps only until the naming layer supplies proven curve provenance.
|
||||
FeatureKind::Extrude { extent, .. } => {
|
||||
extent.from.is_none()
|
||||
&& extent.start_offset.as_deref().is_none_or(|value| {
|
||||
crate::expr::is_literal(value)
|
||||
&& crate::expr::eval_with(value, &|name| Err(DocError::Unknown(name.into())))
|
||||
.ok()
|
||||
.and_then(|value| value.to_kind(crate::expr::Kind::Length).ok())
|
||||
.is_some_and(|value| value.abs() < 1e-12)
|
||||
})
|
||||
&& extent.distance2.is_none()
|
||||
&& ["start", "top", "bottom"].contains(&role)
|
||||
}
|
||||
FeatureKind::Revolve { angle2, symmetric, .. } => angle2.is_none() && !symmetric && ["start", "end"].contains(&role),
|
||||
_ => false,
|
||||
};
|
||||
if !valid {
|
||||
return Err(DocError::Invalid("this face has no supported stable semantic lineage".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Verify the current named pieces describe one surface, not ambiguous generated roles.
|
||||
pub fn configuration_face_indices(&self, body: &crate::ModelBody, target: &ConfigFaceTarget) -> Result<Vec<usize>> {
|
||||
self.certify_config_face(target)?;
|
||||
if body.body.is_mesh() || body.definition_name() != target.body {
|
||||
return Err(DocError::Invalid("configured face definition does not match".into()));
|
||||
}
|
||||
if let Some(super::FaceCertificate::ShellPlanar { source_producer, source, inner }) = &target.certificate {
|
||||
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, current)| {
|
||||
matches!(current, Some(solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), source: current_source, inner: current_inner, .. }) if *producer == target.origin_feature && inner == current_inner && super::flat_source_matches(source, current_source, *source_producer)).then_some(index)
|
||||
}).collect();
|
||||
// A shell can split one original support into several pieces. All pieces
|
||||
// must retain the same canonical frame, not merely lie on a common plane.
|
||||
if !shell_plane_family(indices.iter().map(|index| body.body.face_plane(*index))) {
|
||||
return Err(DocError::Unknown("shell planar face certificate is missing or ambiguous".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) {
|
||||
let indices: Vec<_> = body
|
||||
.body
|
||||
.face_sources()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.filter_map(|(index, source)| {
|
||||
let matched = match (&target.certificate, source) {
|
||||
(
|
||||
Some(super::FaceCertificate::Box { axis, high }),
|
||||
Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), axis: current_axis, high: current_high, .. }),
|
||||
) => *producer == target.origin_feature && axis == current_axis && high == current_high,
|
||||
(
|
||||
Some(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }),
|
||||
Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap {
|
||||
producer,
|
||||
sketch: current_sketch,
|
||||
high: current_high,
|
||||
outer: current_outer,
|
||||
holes: current_holes,
|
||||
..
|
||||
}),
|
||||
) => {
|
||||
*producer == target.origin_feature
|
||||
&& sketch == current_sketch
|
||||
&& high == current_high
|
||||
&& outer == current_outer
|
||||
&& holes == current_holes
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
matched.then_some(index)
|
||||
})
|
||||
.collect();
|
||||
if indices.len() != 1 {
|
||||
return Err(DocError::Unknown("attached planar face certificate is missing or ambiguous".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate {
|
||||
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
|
||||
matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve, .. }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index)
|
||||
}).collect();
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown("constructed side certificate no longer exists".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
if let Some(super::FaceCertificate::Imported { payload_sha256, item, face }) = &target.certificate {
|
||||
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
|
||||
matches!(source, Some(solvecraft_kernel::FaceSource::Imported { item: current_item, face: current_face, payload_sha256: Some(digest), producer: Some(producer) }) if current_item == item && current_face == face && digest == payload_sha256 && *producer == target.origin_feature).then_some(index)
|
||||
}).collect();
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown("immutable import face certificate no longer exists".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
let names = crate::naming::face_names(body);
|
||||
let indices: Vec<usize> =
|
||||
names.iter().enumerate().filter(|(_, name)| crate::naming::strip_piece(name) == target.name).map(|(index, _)| index).collect();
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown(format!("configured face `{}` on `{}` no longer exists", target.name, target.body)));
|
||||
}
|
||||
if let Some(certificate) = &target.certificate {
|
||||
for index in &indices {
|
||||
if self.configuration_face_certificate(body, *index)?.as_ref() != Some(certificate) {
|
||||
return Err(DocError::Invalid("face pieces do not share the exact certified source".into()));
|
||||
}
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
if target.name.ends_with(":side") {
|
||||
if indices.len() != 1 {
|
||||
return Err(DocError::Invalid("split curved face lineage is not yet certified".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
// Caps and box faces must share an oriented plane. This rejects generic cap-role
|
||||
// collisions while permitting the coplanar pieces of a split supported face.
|
||||
let mesh = body.mesh();
|
||||
let mut plane: Option<(Vec3, Vec3)> = None;
|
||||
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if indices.binary_search(&(*face as usize)).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Some([a, b, c]) = mesh.tri(triangle) else { continue };
|
||||
let Some(normal) = (b - a).cross(c - a).normalized() else { continue };
|
||||
if let Some((point, base)) = plane {
|
||||
let magnitude = [point.x, point.y, point.z, a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z]
|
||||
.iter()
|
||||
.fold(body.body.size(), |scale, value| scale.max(value.abs()));
|
||||
let tolerance = (magnitude * f64::EPSILON * 128.0).max(1e-7);
|
||||
if normal.dot(base) < 1.0 - 1e-6 || [a, b, c].iter().any(|vertex| (*vertex - point).dot(base).abs() > tolerance) {
|
||||
return Err(DocError::Invalid("face lineage names more than one oriented surface".into()));
|
||||
}
|
||||
} else {
|
||||
plane = Some((a, normal));
|
||||
}
|
||||
}
|
||||
if plane.is_none() {
|
||||
return Err(DocError::Invalid("configured face has no valid surface".into()));
|
||||
}
|
||||
Ok(indices)
|
||||
}
|
||||
pub fn config_face_look(&self, target: &ConfigFaceTarget) -> Option<crate::appearance::Look> {
|
||||
self.appearances.faces.iter().rev().find(|face| target.matches(face)).map(|face| face.look.clone())
|
||||
}
|
||||
/// Validate against the final rebuilt definition state. Missing names never pick nearby faces.
|
||||
pub fn configuration_style_status(&self, state: &ModelState) -> Result<Vec<serde_json::Value>> {
|
||||
let mut result = Vec::new();
|
||||
for column in &self.configs.columns {
|
||||
match column {
|
||||
Column::ComponentAppearance(id) => {
|
||||
let component =
|
||||
self.components.iter().find(|c| c.id == *id).ok_or_else(|| DocError::Unknown(format!("component {id} is gone")))?;
|
||||
result.push(serde_json::json!({"key":self.config_key(column),"target":component.name,"shared":true,"dormant":false}));
|
||||
}
|
||||
Column::FaceAppearance(target) => {
|
||||
self.certify_config_face(target)?;
|
||||
let dormant = match state.body(&target.body) {
|
||||
None if self.feature(target.origin_feature).is_some_and(|feature| feature.suppressed) => true,
|
||||
None => return Err(DocError::Unknown(format!("configured face body `{}` is gone", target.body))),
|
||||
Some(body) => {
|
||||
self.configuration_face_indices(body, target)?;
|
||||
false
|
||||
}
|
||||
};
|
||||
result.push(serde_json::json!({"key":self.config_key(column),"target":format!("{} / {}",target.body,target.name),"shared":true,"dormant":dormant}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
/// Maintain columns and row cell alignment when component definitions are deleted.
|
||||
pub fn remove_configuration_components(&mut self, ids: &[u64]) {
|
||||
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c,Column::ComponentAppearance(id) if ids.contains(id))).collect();
|
||||
let mut mask = keep.iter();
|
||||
self.configs.columns.retain(|_| mask.next().copied().unwrap_or(false));
|
||||
for row in &mut self.configs.rows {
|
||||
let mut mask = keep.iter();
|
||||
row.cells.retain(|_| mask.next().copied().unwrap_or(false));
|
||||
}
|
||||
self.appearances.components.retain(|id, _| !ids.contains(id));
|
||||
}
|
||||
}
|
||||
@@ -1,657 +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,
|
||||
/// How deep the plane resolution that led here already is. A plane built from model
|
||||
/// geometry resolves its sides through the document again, so the count has to continue
|
||||
/// across that hop; restarting it lets a plane that refers to itself recurse forever.
|
||||
pub depth: usize,
|
||||
}
|
||||
|
||||
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, ctx.depth + 1, 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::Perpendicular { plane: base, line: l } => {
|
||||
let pl = plane(ctx, base)?;
|
||||
let (o, d) = line(ctx, l)?;
|
||||
let n = d
|
||||
.cross(pl.normal())
|
||||
.normalized()
|
||||
.ok_or_else(|| bad("the line is square to the plane: every plane through it is perpendicular; pick a line in or along the plane"))?;
|
||||
Plane::new(o, d, n.cross(d)).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, ctx.depth + 1, 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::Perpendicular { plane, line } => vec![plane.as_mut(), line.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
File diff suppressed because it is too large
Load Diff
@@ -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,561 +0,0 @@
|
||||
//! Exact ordinary face attachments. Missing identities never invoke a proximity fallback.
|
||||
use crate::config::ConfigFaceTarget;
|
||||
use crate::{DocError, Document, EmbossFace, FeatureKind, Model, ModelBody, ModelState, PlaneRef, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use solvecraft_geom::Plane;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FaceAnchor {
|
||||
pub target: ConfigFaceTarget,
|
||||
pub component: u64,
|
||||
pub path: Vec<u64>,
|
||||
pub author_path: Vec<u64>,
|
||||
/// Origin and x direction in the exact canonical face plane's coordinates.
|
||||
pub coordinates: [f64; 2],
|
||||
pub x_axis: [f64; 2],
|
||||
}
|
||||
|
||||
impl FaceAnchor {
|
||||
pub fn remap_target(
|
||||
&mut self,
|
||||
features: &std::collections::BTreeMap<u64, u64>,
|
||||
bodies: &std::collections::BTreeMap<String, String>,
|
||||
) -> Result<()> {
|
||||
if bodies.contains_key(&self.target.body) && !features.contains_key(&self.target.origin_feature) {
|
||||
return Err(DocError::Invalid("cannot clone an attached face without its certified producer".into()));
|
||||
}
|
||||
self.target.name = crate::remap_topology(&self.target.name, features, bodies);
|
||||
if let Some(body) = bodies.get(&self.target.body) {
|
||||
self.target.body = body.clone();
|
||||
}
|
||||
if let Some(feature) = features.get(&self.target.origin_feature) {
|
||||
self.target.origin_feature = *feature;
|
||||
}
|
||||
if let Some(certificate) = &mut self.target.certificate {
|
||||
certificate.remap_features(features);
|
||||
}
|
||||
self.validate()
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.target.validate()?;
|
||||
if self.target.certificate.is_none()
|
||||
|| self.path.len() > 64
|
||||
|| self.author_path.len() > 64
|
||||
|| self.path.iter().chain(&self.author_path).any(|id| *id == 0)
|
||||
|| (self.component == 0 && !self.path.is_empty())
|
||||
|| (self.component != 0 && self.path.is_empty())
|
||||
|| self.coordinates.iter().any(|value| !value.is_finite() || value.abs() > 1e9)
|
||||
|| self.x_axis.iter().any(|value| !value.is_finite())
|
||||
|| (self.x_axis[0].hypot(self.x_axis[1]) - 1.).abs() > 1e-6
|
||||
{
|
||||
return Err(DocError::Invalid("invalid exact face attachment".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn author_frame(&self, doc: &Document, author: u64) -> Result<crate::Mat> {
|
||||
if author != 0 && self.author_path.is_empty() {
|
||||
return Err(DocError::Invalid(
|
||||
"attached sketch requires an explicit author occurrence path; activate a placement or choose a fixed plane".into(),
|
||||
));
|
||||
}
|
||||
EmbossFace::component_frame(doc, author, &self.author_path)
|
||||
}
|
||||
pub fn source_to_author(&self, doc: &Document, author: u64, body: &ModelBody) -> Result<crate::Mat> {
|
||||
self.validate()?;
|
||||
if doc.model_body_component(body) != self.component {
|
||||
return Err(DocError::Invalid("attached face's component ownership changed; reattach explicitly".into()));
|
||||
}
|
||||
let inverse = crate::mat_inverse(&self.author_frame(doc, author)?)
|
||||
.ok_or_else(|| DocError::Invalid("attached sketch author frame is invalid".into()))?;
|
||||
let source =
|
||||
EmbossFace { body: self.target.body.clone(), at: self.target.point, path: self.path.clone(), author_path: self.author_path.clone() };
|
||||
Ok(crate::mat_mul(&inverse, &source.body_frame(doc, body)?))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plane_face_anchor(plane: &PlaneRef) -> Option<&FaceAnchor> {
|
||||
match plane {
|
||||
PlaneRef::Face { anchor, .. } => anchor.as_deref(),
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane_face_anchor(base),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_face_reference(plane: &PlaneRef) -> bool {
|
||||
match plane {
|
||||
PlaneRef::Face { .. } => true,
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => has_face_reference(base),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn resolve_attached_plane(
|
||||
&self,
|
||||
vals: &std::collections::BTreeMap<String, crate::expr::Value>,
|
||||
plane: &PlaneRef,
|
||||
author: u64,
|
||||
state: &ModelState,
|
||||
framed: &ModelState,
|
||||
) -> Result<Plane> {
|
||||
fn replace(doc: &Document, plane: &mut PlaneRef, author: u64, state: &ModelState, depth: usize) -> Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(DocError::Invalid("attached plane nesting exceeds budget".into()));
|
||||
}
|
||||
match plane {
|
||||
PlaneRef::Face { anchor: Some(anchor), .. } => *plane = PlaneRef::Custom { plane: doc.resolve_face_anchor(anchor, author, state)? },
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => replace(doc, base, author, state, depth + 1)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut resolved = plane.clone();
|
||||
replace(self, &mut resolved, author, state, 0)?;
|
||||
self.resolve_plane_in(vals, &resolved, 0, Some(framed))
|
||||
}
|
||||
}
|
||||
|
||||
/// All status planes are world-space. A lost plane is a repair guide only.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FaceAttachmentStatus {
|
||||
Fixed { plane: Option<Plane> },
|
||||
Legacy { plane: Option<Plane> },
|
||||
Healthy { plane: Plane, target: ConfigFaceTarget, path: Vec<u64> },
|
||||
Lost { message: String, repair_plane: Option<Plane> },
|
||||
}
|
||||
|
||||
fn map_plane(matrix: &crate::Mat, plane: &Plane) -> Result<Plane> {
|
||||
Plane::new(crate::apply_point(matrix, plane.origin), crate::apply_vector(matrix, plane.x), crate::apply_vector(matrix, plane.y))
|
||||
.ok_or_else(|| DocError::Invalid("attached plane transform is degenerate".into()))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Capture a known selected face in the definition model, without another nearest pick.
|
||||
pub fn capture_face_anchor(
|
||||
&self,
|
||||
body: &ModelBody,
|
||||
index: usize,
|
||||
picked: &Plane,
|
||||
target_path: &[u64],
|
||||
author: u64,
|
||||
author_path: &[u64],
|
||||
) -> Result<FaceAnchor> {
|
||||
let source = body
|
||||
.body
|
||||
.face_plane(index)
|
||||
.ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))?;
|
||||
let target = self.strong_face_target(body, index, source.origin)?;
|
||||
if target.certificate.is_none() {
|
||||
return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into()));
|
||||
}
|
||||
self.configuration_face_indices(body, &target)?;
|
||||
let mut anchor = FaceAnchor {
|
||||
target,
|
||||
component: self.model_body_component(body),
|
||||
path: target_path.to_vec(),
|
||||
author_path: author_path.to_vec(),
|
||||
coordinates: [0., 0.],
|
||||
x_axis: [1., 0.],
|
||||
};
|
||||
let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &source)?;
|
||||
if picked.normal().dot(canonical.normal()) < 1. - 1e-7 || (picked.origin - canonical.origin).dot(canonical.normal()).abs() > 1e-7 {
|
||||
return Err(DocError::Invalid("picked plane does not match its exact source face".into()));
|
||||
}
|
||||
let delta = picked.origin - canonical.origin;
|
||||
anchor.coordinates = [delta.dot(canonical.x), delta.dot(canonical.y)];
|
||||
anchor.x_axis = [picked.x.dot(canonical.x), picked.x.dot(canonical.y)];
|
||||
anchor.validate()?;
|
||||
Ok(anchor)
|
||||
}
|
||||
|
||||
pub fn resolve_face_anchor(&self, anchor: &FaceAnchor, author: u64, state: &ModelState) -> Result<Plane> {
|
||||
anchor.validate()?;
|
||||
let body =
|
||||
state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("attached body `{}` no longer exists", anchor.target.body)))?;
|
||||
let indices = self.configuration_face_indices(body, &anchor.target)?;
|
||||
let first = indices.first().copied().ok_or_else(|| DocError::Unknown("attached face no longer exists".into()))?;
|
||||
let plane = body.body.face_plane(first).ok_or_else(|| DocError::Invalid("attached face no longer has an exact planar source".into()))?;
|
||||
for index in indices.iter().skip(1) {
|
||||
let other = body.body.face_plane(*index).ok_or_else(|| DocError::Invalid("attached face pieces are not exactly planar".into()))?;
|
||||
if plane.normal().dot(other.normal()) < 1. - 1e-8 || (other.origin - plane.origin).dot(plane.normal()).abs() > 1e-7 {
|
||||
return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into()));
|
||||
}
|
||||
}
|
||||
let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &plane)?;
|
||||
let origin = canonical.origin + canonical.x * anchor.coordinates[0] + canonical.y * anchor.coordinates[1];
|
||||
let x = canonical.x * anchor.x_axis[0] + canonical.y * anchor.x_axis[1];
|
||||
Plane::new(origin, x, canonical.normal().cross(x)).ok_or_else(|| DocError::Invalid("attached sketch basis is degenerate".into()))
|
||||
}
|
||||
|
||||
pub fn sketch_face_attachment(&self, feature: u64, model: &Model) -> FaceAttachmentStatus {
|
||||
let Some(feature) = self.feature(feature) else {
|
||||
return FaceAttachmentStatus::Lost { message: "sketch no longer exists".into(), repair_plane: None };
|
||||
};
|
||||
let FeatureKind::Sketch { plane, .. } = &feature.kind else {
|
||||
return FaceAttachmentStatus::Fixed { plane: None };
|
||||
};
|
||||
let Some(anchor) = plane_face_anchor(plane) else {
|
||||
let path = self.first_path(feature.component);
|
||||
let unique = path.iter().all(|id| {
|
||||
self.occurrences
|
||||
.iter()
|
||||
.find(|o| o.id == *id)
|
||||
.is_some_and(|selected| self.occurrences.iter().filter(|o| o.component == selected.component).count() == 1)
|
||||
});
|
||||
let world = unique.then(|| EmbossFace::component_frame(self, feature.component, &path).ok()).flatten();
|
||||
let current = model.state().sketch(feature.id).map(|sketch| sketch.plane).or_else(|| {
|
||||
let (vals, _) = self.param_values();
|
||||
self.resolve_plane(&vals, plane, 0).ok()
|
||||
});
|
||||
let current = world.and_then(|world| current.and_then(|plane| map_plane(&world, &plane).ok()));
|
||||
return if has_face_reference(plane) {
|
||||
FaceAttachmentStatus::Legacy { plane: current }
|
||||
} else {
|
||||
FaceAttachmentStatus::Fixed { plane: current }
|
||||
};
|
||||
};
|
||||
let (vals, _) = self.param_values();
|
||||
let picked = self.resolve_plane(&vals, plane, 0).ok();
|
||||
let world = anchor.author_frame(self, feature.component);
|
||||
let repair_plane = world.as_ref().ok().and_then(|matrix| picked.as_ref().and_then(|picked| map_plane(matrix, picked).ok()));
|
||||
let state = model.state_before(feature.id);
|
||||
match crate::state_in_face_anchor_frame(self, &state, anchor, feature.component)
|
||||
.and_then(|framed| self.resolve_attached_plane(&vals, plane, feature.component, &state, &framed))
|
||||
.and_then(|plane| map_plane(&world?, &plane))
|
||||
{
|
||||
Ok(plane) => FaceAttachmentStatus::Healthy { plane, target: anchor.target.clone(), path: anchor.path.clone() },
|
||||
Err(error) => FaceAttachmentStatus::Lost { message: format!("lost face attachment on `{}`: {error}", anchor.target.body), repair_plane },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_face_anchor_json(value: &Value) -> Result<()> {
|
||||
let fields = value.as_object().ok_or_else(|| DocError::Invalid("face anchor must be an object".into()))?;
|
||||
if fields.len() != 6 || fields.keys().any(|key| !["target", "component", "path", "author_path", "coordinates", "x_axis"].contains(&key.as_str()))
|
||||
{
|
||||
return Err(DocError::Invalid("unknown or missing face anchor fields".into()));
|
||||
}
|
||||
let target = fields.get("target").ok_or_else(|| DocError::Invalid("face anchor target missing".into()))?;
|
||||
crate::config::validate_face_target_json(target)?;
|
||||
if target.get("certificate").is_none_or(Value::is_null) {
|
||||
return Err(DocError::Invalid("face anchor needs a source certificate".into()));
|
||||
}
|
||||
let component =
|
||||
fields.get("component").and_then(Value::as_u64).ok_or_else(|| DocError::Invalid("face anchor component must be an integer".into()))?;
|
||||
for key in ["path", "author_path"] {
|
||||
if fields.get(key).and_then(Value::as_array).is_none_or(|path| {
|
||||
path.len() > 64
|
||||
|| path.iter().any(|id| id.as_u64().is_none_or(|id| id == 0))
|
||||
|| (key == "path" && component == 0 && !path.is_empty())
|
||||
|| (key == "path" && component != 0 && path.is_empty())
|
||||
}) {
|
||||
return Err(DocError::Invalid("face anchor path is invalid or oversized".into()));
|
||||
}
|
||||
}
|
||||
for key in ["coordinates", "x_axis"] {
|
||||
let values = fields
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.filter(|values| values.len() == 2)
|
||||
.ok_or_else(|| DocError::Invalid("face anchor vectors require exactly two values".into()))?;
|
||||
if values.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9)) {
|
||||
return Err(DocError::Invalid("face anchor vector is invalid".into()));
|
||||
}
|
||||
if key == "x_axis" && (values[0].as_f64().unwrap_or(0.).hypot(values[1].as_f64().unwrap_or(0.)) - 1.).abs() > 1e-6 {
|
||||
return Err(DocError::Invalid("face anchor basis must be normalized".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk reference-bearing JSON before typed decoding, excluding bulk geometry payloads.
|
||||
pub fn validate_face_anchors_json(value: &Value) -> Result<()> {
|
||||
let mut stack = vec![(value, 0usize)];
|
||||
let mut visited = 0usize;
|
||||
while let Some((value, depth)) = stack.pop() {
|
||||
visited += 1;
|
||||
if visited > 2_000_000 || depth > 128 {
|
||||
return Err(DocError::Invalid("face anchor structure exceeds work limits".into()));
|
||||
}
|
||||
match value {
|
||||
Value::Object(fields) => {
|
||||
if fields.get("type").and_then(Value::as_str) == Some("face")
|
||||
&& let Some(anchor) = fields.get("anchor")
|
||||
&& !anchor.is_null()
|
||||
{
|
||||
validate_face_anchor_json(anchor)?;
|
||||
}
|
||||
for (key, child) in fields {
|
||||
if !["sketch", "meshes", "step", "configs", "appearances", "materials"].contains(&key.as_str())
|
||||
&& matches!(child, Value::Array(_) | Value::Object(_))
|
||||
{
|
||||
stack.push((child, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for child in values {
|
||||
if matches!(child, Value::Array(_) | Value::Object(_)) {
|
||||
stack.push((child, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solvecraft_geom::Vec3;
|
||||
fn fixture() -> (Document, ModelBody, usize, Plane) {
|
||||
let mut doc = Document::default();
|
||||
let kind: FeatureKind =
|
||||
serde_json::from_value(serde_json::json!({"type":"box","corner":[0,0,0],"length":"10","width":"10","height":"10"})).unwrap();
|
||||
let id = doc.add_feature(kind, Some("Base")).unwrap();
|
||||
let body = ModelBody::new(
|
||||
"Base".into(),
|
||||
solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(id).unwrap(),
|
||||
id,
|
||||
);
|
||||
let index = body
|
||||
.body
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::Box { axis: 2, high: true, .. })))
|
||||
.unwrap();
|
||||
let canonical = body.body.face_plane(index).unwrap();
|
||||
let picked = Plane::new(canonical.origin + canonical.x * 2. + canonical.y * 3., canonical.y, -canonical.x).unwrap();
|
||||
(doc, body, index, picked)
|
||||
}
|
||||
#[test]
|
||||
fn exact_anchor_preserves_picked_basis_and_follows_height_without_parallel_rescue() {
|
||||
let (doc, body, index, picked) = fixture();
|
||||
let anchor = doc.capture_face_anchor(&body, index, &picked, &[], 0, &[]).unwrap();
|
||||
let anchor: FaceAnchor = serde_json::from_str(&serde_json::to_string(&anchor).unwrap()).unwrap();
|
||||
assert!(anchor.author_frame(&doc, 1).is_err(), "strong author attachment cannot infer a first placement");
|
||||
let mut missing_source = anchor.clone();
|
||||
missing_source.component = 1;
|
||||
assert!(missing_source.validate().is_err(), "strong source attachment needs an explicit path");
|
||||
assert!(validate_face_anchor_json(&serde_json::to_value(missing_source).unwrap()).is_err());
|
||||
let mut state = ModelState::default();
|
||||
state.bodies.push(body);
|
||||
let initial = doc.resolve_face_anchor(&anchor, 0, &state).unwrap();
|
||||
assert!((initial.origin - picked.origin).len() < 1e-9);
|
||||
assert!((initial.x - picked.x).len() < 1e-9);
|
||||
state.bodies[0] = ModelBody::new(
|
||||
"Base".into(),
|
||||
solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 20.))
|
||||
.unwrap()
|
||||
.with_box_certificate(anchor.target.origin_feature)
|
||||
.unwrap(),
|
||||
anchor.target.origin_feature,
|
||||
);
|
||||
let changed = doc.resolve_face_anchor(&anchor, 0, &state).unwrap();
|
||||
assert!((changed.origin.z - 20.).abs() < 1e-9);
|
||||
assert!((changed.origin.x - initial.origin.x).abs() < 1e-9);
|
||||
state.bodies[0] = ModelBody::new(
|
||||
"Control".into(),
|
||||
solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 30.))
|
||||
.unwrap()
|
||||
.with_box_certificate(anchor.target.origin_feature)
|
||||
.unwrap(),
|
||||
anchor.target.origin_feature,
|
||||
);
|
||||
assert!(doc.resolve_face_anchor(&anchor, 0, &state).is_err());
|
||||
state.bodies[0].name = "Base".into();
|
||||
state.bodies[0].body = solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 30.))
|
||||
.unwrap()
|
||||
.with_box_certificate(anchor.target.origin_feature + 1)
|
||||
.unwrap();
|
||||
assert!(doc.resolve_face_anchor(&anchor, 0, &state).is_err(), "reused name cannot replace the producer");
|
||||
}
|
||||
#[test]
|
||||
fn nested_anchor_offset_tracks_source_and_raw_budgets_precede_decode() {
|
||||
let (doc, body, index, picked) = fixture();
|
||||
let anchor = doc.capture_face_anchor(&body, index, &picked, &[], 0, &[]).unwrap();
|
||||
let plane = PlaneRef::Offset {
|
||||
base: Box::new(PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor.clone())) }),
|
||||
distance: "2".into(),
|
||||
};
|
||||
let mut state = ModelState::default();
|
||||
state.bodies.push(body);
|
||||
let (vals, _) = doc.param_values();
|
||||
let result = doc.resolve_attached_plane(&vals, &plane, 0, &state, &state).unwrap();
|
||||
assert!((result.origin.z - 12.).abs() < 1e-9);
|
||||
state.bodies.clear();
|
||||
assert!(doc.resolve_attached_plane(&vals, &plane, 0, &state, &state).is_err());
|
||||
let mut raw = serde_json::to_value(anchor).unwrap();
|
||||
assert!(validate_face_anchor_json(&raw).is_ok());
|
||||
raw["path"] = serde_json::json!([1]);
|
||||
assert!(validate_face_anchor_json(&raw).is_err());
|
||||
raw["path"] = serde_json::json!([]);
|
||||
raw["target"]["name"] = Value::String("x".repeat(1_000_000));
|
||||
assert!(validate_face_anchor_json(&raw).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn extrude_cap_profile_reordering_never_retargets_an_ordinal_body() {
|
||||
use solvecraft_geom::Vec2;
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
for offset in [0., 30.] {
|
||||
let points: Vec<_> = [(0., 0.), (10., 0.), (10., 10.), (0., 10.)]
|
||||
.iter()
|
||||
.map(|(x, y)| sketch.add_point(Vec2::new(x + offset, *y), None).unwrap())
|
||||
.collect();
|
||||
for edge in 0..4 {
|
||||
sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap();
|
||||
}
|
||||
}
|
||||
let mut doc = Document::default();
|
||||
let source = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
let extent: crate::Extent = serde_json::from_value(serde_json::json!({"distance":"10"})).unwrap();
|
||||
let producer = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: source,
|
||||
profiles: crate::ProfileSel::Indices { indices: vec![0, 1] },
|
||||
extent,
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let state = model.state();
|
||||
assert_eq!(state.bodies.len(), 2);
|
||||
let solved = state.sketch(source).unwrap();
|
||||
let regions = crate::select_profiles(solved, &crate::ProfileSel::All).unwrap();
|
||||
let raw = solvecraft_kernel::extrude(&solved.plane, ®ions, 0., 10.).unwrap();
|
||||
assert!(
|
||||
crate::config::extrude_cap_bindings(solved, &raw, 0).is_none(),
|
||||
"exhausted certification work must leave the complete producer uncertified"
|
||||
);
|
||||
let bindings = crate::config::extrude_cap_bindings(solved, &raw, 10_000_000).unwrap();
|
||||
assert_eq!(bindings.iter().flatten().flatten().count(), 4, "two profiles each retain both exact caps within budget");
|
||||
let mut duplicate = raw.clone();
|
||||
duplicate.extend(raw);
|
||||
let ambiguous = crate::config::extrude_cap_bindings(solved, &duplicate, 10_000_000).unwrap();
|
||||
assert!(ambiguous.iter().flatten().all(Option::is_none), "duplicated profile identities cannot stamp ambiguous caps");
|
||||
let body = &state.bodies[0];
|
||||
let index = body
|
||||
.body
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { high: true, .. })))
|
||||
.unwrap();
|
||||
let picked = body.body.face_plane(index).unwrap();
|
||||
let anchor = doc.capture_face_anchor(body, index, &picked, &[], 0, &[]).unwrap();
|
||||
let bounds = body.mesh().bounds();
|
||||
let FeatureKind::Extrude { profiles, .. } = &mut doc.feature_mut(producer).unwrap().kind else {
|
||||
panic!("fixture must retain its extrude feature")
|
||||
};
|
||||
*profiles = crate::ProfileSel::Indices { indices: vec![1, 0] };
|
||||
model.evaluate(&doc);
|
||||
let changed = model.state();
|
||||
assert_eq!(changed.bodies.len(), 2);
|
||||
if doc.resolve_face_anchor(&anchor, 0, &changed).is_ok() {
|
||||
let current = changed.body(&anchor.target.body).unwrap().mesh().bounds();
|
||||
assert!((current.min.x - bounds.min.x).abs() < 1e-9, "a same-role cap of the other profile must never replace the source");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn wrapped_legacy_face_is_reported_as_legacy_without_inferred_upgrade() {
|
||||
let (mut doc, _, _, picked) = fixture();
|
||||
let id = doc
|
||||
.add_feature(
|
||||
FeatureKind::Sketch {
|
||||
plane: PlaneRef::Offset {
|
||||
base: Box::new(PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: None }),
|
||||
distance: "2".into(),
|
||||
},
|
||||
sketch: solvecraft_sketch::Sketch::new(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let FaceAttachmentStatus::Legacy { plane: Some(plane) } = doc.sketch_face_attachment(id, &model) else {
|
||||
panic!("wrapped legacy attachment must retain its classification and fixed-plane escape");
|
||||
};
|
||||
assert!((plane.origin.z - 12.).abs() < 1e-9);
|
||||
let FeatureKind::Sketch { plane, .. } = &doc.feature(id).unwrap().kind else { panic!("fixture must retain its sketch feature") };
|
||||
assert!(plane_face_anchor(plane).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn warm_root_body_offset_edit_matches_cold_attached_sketch_and_downstream_bounds() {
|
||||
use solvecraft_geom::Vec2;
|
||||
let (mut doc, _, _, _) = fixture();
|
||||
let mut warm = Model::new();
|
||||
warm.evaluate(&doc);
|
||||
let source = warm.state();
|
||||
let body = &source.bodies[0];
|
||||
let index = body
|
||||
.body
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::Box { axis: 2, high: true, .. })))
|
||||
.unwrap();
|
||||
let picked = body.body.face_plane(index).unwrap();
|
||||
let anchor = doc.capture_face_anchor(body, index, &picked, &[], 0, &[]).unwrap();
|
||||
let source_name = body.name.clone();
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
let points: Vec<_> =
|
||||
[(2., 2.), (4., 2.), (4., 4.), (2., 4.)].iter().map(|(x, y)| sketch.add_point(Vec2::new(*x, *y), None).unwrap()).collect();
|
||||
for edge in 0..4 {
|
||||
sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap();
|
||||
}
|
||||
let sk = doc
|
||||
.add_feature(
|
||||
FeatureKind::Sketch {
|
||||
plane: PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor)) },
|
||||
sketch,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let extent = serde_json::from_value(serde_json::json!({"distance":"2"})).unwrap();
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude { sketch: sk, profiles: crate::ProfileSel::All, extent, operation: crate::Operation::NewBody, targets: vec![] },
|
||||
Some("Boss"),
|
||||
)
|
||||
.unwrap();
|
||||
warm.evaluate(&doc);
|
||||
let initial = warm.state();
|
||||
let before = initial.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
let mut offset = crate::IDENTITY;
|
||||
offset[3][0] = 5.;
|
||||
offset[3][2] = 3.;
|
||||
doc.body_offsets.insert(source_name, offset);
|
||||
warm.evaluate(&doc);
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&doc);
|
||||
let warm_state = warm.state();
|
||||
let cold_state = cold.state();
|
||||
let after = warm_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
let rebuilt = cold_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
assert!(
|
||||
(after.min - before.min - Vec3::new(5., 0., 3.)).len() < 1e-7,
|
||||
"before={before:?}, warm={after:?}, cold={rebuilt:?}, warm_plane={:?}, cold_plane={:?}",
|
||||
warm_state.sketch(sk).map(|sk| sk.plane),
|
||||
cold_state.sketch(sk).map(|sk| sk.plane)
|
||||
);
|
||||
assert!((after.min - rebuilt.min).len() < 1e-7);
|
||||
assert!((after.max - rebuilt.max).len() < 1e-7);
|
||||
let FaceAttachmentStatus::Healthy { plane, .. } = doc.sketch_face_attachment(sk, &warm) else {
|
||||
panic!("offset source must remain attached");
|
||||
};
|
||||
assert!((plane.origin - warm_state.sketch(sk).unwrap().plane.origin).len() < 1e-9);
|
||||
assert!((warm_state.sketch(sk).unwrap().plane.origin - cold_state.sketch(sk).unwrap().plane.origin).len() < 1e-9);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,453 +0,0 @@
|
||||
//! Pre-allocation bounds for versioned analytic offset roles and boundary references.
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, FeatureKind, ProfileSel, Result};
|
||||
|
||||
fn invalid() -> DocError {
|
||||
DocError::Invalid("damaged design: invalid analytic offset topology or boundary reference".into())
|
||||
}
|
||||
|
||||
fn id(value: &Value) -> Result<&str> {
|
||||
value.as_str().filter(|id| !id.is_empty() && id.len() <= 512 && !id.chars().any(char::is_control)).ok_or_else(invalid)
|
||||
}
|
||||
|
||||
fn exact_keys(value: &Value, keys: &[&str]) -> Result<()> {
|
||||
let object = value.as_object().ok_or_else(invalid)?;
|
||||
if object.len() != keys.len() || object.keys().any(|key| !keys.contains(&key.as_str())) {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a complete-source profile reference before copying or decoding it.
|
||||
pub fn whole_profile_selection_from_value(value: &Value) -> Result<ProfileSel> {
|
||||
validate_whole_reference(value)?;
|
||||
serde_json::from_value(value.clone()).map_err(|_| invalid())
|
||||
}
|
||||
|
||||
fn validate_whole_reference(value: &Value) -> Result<()> {
|
||||
exact_keys(value, &["type", "profiles"])?;
|
||||
if value.get("type").and_then(Value::as_str) != Some("whole_boundaries") {
|
||||
return Err(invalid());
|
||||
}
|
||||
let profiles = value.get("profiles").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
if profiles.is_empty() || profiles.len() > 128 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut total = 0usize;
|
||||
let mut identities = BTreeSet::new();
|
||||
for profile in profiles {
|
||||
exact_keys(profile, &["outer", "holes"])?;
|
||||
let mut boundary = |value: &Value| -> Result<Vec<String>> {
|
||||
let values = value.as_array().ok_or_else(invalid)?;
|
||||
if values.is_empty() || values.len() > 512 {
|
||||
return Err(invalid());
|
||||
}
|
||||
total += values.len();
|
||||
if total > 20_000 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut previous = None;
|
||||
// Bound each raw ID before any owned allocation. Require canonical order.
|
||||
for value in values {
|
||||
let current = id(value)?;
|
||||
if previous.is_some_and(|previous| previous >= current) {
|
||||
return Err(invalid());
|
||||
}
|
||||
previous = Some(current);
|
||||
}
|
||||
values.iter().map(|value| Ok(id(value)?.to_owned())).collect()
|
||||
};
|
||||
let outer = boundary(profile.get("outer").ok_or_else(invalid)?)?;
|
||||
let holes = profile.get("holes").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
if holes.len() > 128 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut sorted_holes = Vec::with_capacity(holes.len());
|
||||
let mut all: BTreeSet<_> = outer.iter().cloned().collect();
|
||||
for hole in holes {
|
||||
let hole = boundary(hole)?;
|
||||
if sorted_holes.last().is_some_and(|previous| previous >= &hole) || hole.iter().any(|curve| !all.insert(curve.clone())) {
|
||||
return Err(invalid());
|
||||
}
|
||||
sorted_holes.push(hole);
|
||||
}
|
||||
if !identities.insert((outer, sorted_holes)) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_raw(value: &Value) -> Result<()> {
|
||||
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
|
||||
let mut total_roles = 0usize;
|
||||
for feature in features {
|
||||
if feature.get("type").and_then(Value::as_str) != Some("sketch") {
|
||||
continue;
|
||||
}
|
||||
let Some(offsets) = feature.get("sketch").and_then(|sketch| sketch.get("offsets")) else { continue };
|
||||
let offsets = offsets.as_array().ok_or_else(invalid)?;
|
||||
let mut groups = BTreeSet::new();
|
||||
let mut outputs = BTreeSet::new();
|
||||
let mut point_owners = std::collections::BTreeMap::new();
|
||||
let mut sketch_roles = 0usize;
|
||||
for offset in offsets {
|
||||
let Some(topology) = offset.get("topology").filter(|topology| !topology.is_null()) else { continue };
|
||||
if groups.len() >= 128 {
|
||||
return Err(invalid());
|
||||
}
|
||||
exact_keys(topology, &["version", "id", "roles"])?;
|
||||
let group = id(topology.get("id").ok_or_else(invalid)?)?;
|
||||
if !groups.insert(group)
|
||||
|| topology.get("version").and_then(Value::as_u64) != Some(1)
|
||||
|| offset.get("closed").and_then(Value::as_bool) != Some(true)
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let distance = offset.get("distance").and_then(Value::as_f64).ok_or_else(invalid)?;
|
||||
if !distance.is_finite() || distance.abs() <= 1e-9 || distance.abs() >= 1e9 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let sources = offset.get("sources").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
let roles = topology.get("roles").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
let active = offset.get("outputs").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
if roles.is_empty() || roles.len() > 512 || roles.len() != sources.len() || active.len() > roles.len() {
|
||||
return Err(invalid());
|
||||
}
|
||||
total_roles = total_roles.saturating_add(roles.len());
|
||||
sketch_roles = sketch_roles.saturating_add(roles.len());
|
||||
if total_roles > 1_000_000 || sketch_roles > 20_000 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut active_index = 0;
|
||||
let mut source_ids = BTreeSet::new();
|
||||
for (source, role) in sources.iter().zip(roles) {
|
||||
let source = source.as_array().ok_or_else(invalid)?;
|
||||
if source.len() != 2 || source.get(1).and_then(Value::as_bool).is_none() {
|
||||
return Err(invalid());
|
||||
}
|
||||
let source = id(source.first().ok_or_else(invalid)?)?;
|
||||
if !source_ids.insert(source) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let role_object = role.as_object().ok_or_else(invalid)?;
|
||||
if role_object.keys().any(|key| !matches!(key.as_str(), "output" | "kind" | "start" | "end" | "retired" | "flags")) {
|
||||
return Err(invalid());
|
||||
}
|
||||
if let Some(flags) = role.get("flags") {
|
||||
exact_keys(flags, &["construction", "centerline", "reversed", "fixed"])?;
|
||||
if flags.as_object().is_none_or(|flags| flags.values().any(|flag| flag.as_bool().is_none())) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
let output = id(role.get("output").ok_or_else(invalid)?)?;
|
||||
if source == output || !outputs.insert(output) {
|
||||
return Err(invalid());
|
||||
}
|
||||
if role.get("start") == role.get("end") {
|
||||
return Err(invalid());
|
||||
}
|
||||
for key in ["start", "end"] {
|
||||
let point = id(role.get(key).ok_or_else(invalid)?)?;
|
||||
if point == output || point == source || point_owners.insert(point, group).is_some_and(|owner| owner != group) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
let kind = role.get("kind").and_then(Value::as_str).ok_or_else(invalid)?;
|
||||
let retired = role.get("retired").and_then(Value::as_bool).ok_or_else(invalid)?;
|
||||
if !matches!(kind, "line" | "arc") || (retired && kind != "arc") {
|
||||
return Err(invalid());
|
||||
}
|
||||
if !retired {
|
||||
if active.get(active_index).map(id).transpose()?.is_none_or(|id| id != output) {
|
||||
return Err(invalid());
|
||||
}
|
||||
active_index += 1;
|
||||
}
|
||||
}
|
||||
if active_index != active.len() || active_index < 3 {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
if groups.iter().any(|group| outputs.contains(group) || point_owners.contains_key(group))
|
||||
|| outputs.iter().any(|output| point_owners.contains_key(output))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
fn references(value: &Value) -> Result<()> {
|
||||
if value.get("type").and_then(Value::as_str) == Some("whole_boundaries") {
|
||||
return validate_whole_reference(value);
|
||||
}
|
||||
if value.get("type").and_then(Value::as_str) == Some("offset_boundaries") {
|
||||
exact_keys(value, &["type", "associations"])?;
|
||||
let ids = value.get("associations").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
if ids.is_empty() || ids.len() > 128 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for value in ids {
|
||||
if !seen.insert(id(value)?) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// Only feature profile fields can contain this variant. Do not impose a new
|
||||
// document-wide node limit on unrelated legacy sketches or imported payloads.
|
||||
for feature in features {
|
||||
if let Some(selection) = feature.get("profiles") {
|
||||
references(selection)?;
|
||||
}
|
||||
if let Some(sections) = feature.get("sections").and_then(Value::as_array) {
|
||||
if sections.len() > 10_000 {
|
||||
return Err(invalid());
|
||||
}
|
||||
for section in sections {
|
||||
if let Some(selection) = section.get("profiles") {
|
||||
references(selection)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_typed(document: &Document) -> Result<()> {
|
||||
let mut sketches = std::collections::BTreeMap::new();
|
||||
for feature in &document.features {
|
||||
if let FeatureKind::Sketch { sketch, .. } = &feature.kind {
|
||||
sketch.validate_offset_topologies()?;
|
||||
sketches.insert(feature.id, sketch);
|
||||
}
|
||||
for (sketch, selection) in feature.kind.profile_sels() {
|
||||
if let ProfileSel::OffsetBoundaries { associations } = selection {
|
||||
// A deleted association or source sketch is a repairable model error,
|
||||
// just like an ordinary missing curve reference. Keep that design loadable.
|
||||
let Some(sketch) = sketches.get(&sketch) else { continue };
|
||||
for association in associations {
|
||||
let count =
|
||||
sketch.offsets.iter().filter_map(|offset| offset.topology.as_ref()).filter(|topology| &topology.id == association).count();
|
||||
if count > 1 {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn fixture() -> Value {
|
||||
json!({"features":[{"type":"sketch","sketch":{"offsets":[{
|
||||
"sources":[["s0",true],["s1",true],["s2",true],["s3",true]],
|
||||
"outputs":["l0","l1","l2"],"closed":true,"distance":1.4,
|
||||
"topology":{"version":1,"id":"o1","roles":[
|
||||
{"output":"l0","kind":"line","start":"p0","end":"p1","retired":false},
|
||||
{"output":"a0","kind":"arc","start":"p1","end":"p2","retired":true},
|
||||
{"output":"l1","kind":"line","start":"p2","end":"p3","retired":false},
|
||||
{"output":"l2","kind":"line","start":"p3","end":"p0","retired":false}
|
||||
]}
|
||||
}]}}]})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_boundaries_raw_identity_is_bounded_canonical_and_complete() {
|
||||
let good = json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s1"]]}]});
|
||||
assert!(matches!(whole_profile_selection_from_value(&good).unwrap(), ProfileSel::WholeBoundaries { profiles } if profiles.len() == 1));
|
||||
let malformed = [
|
||||
json!({"type":"whole_boundaries","profiles":[]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":[],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s1","s1"],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2","s1"],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s1"],"holes":[["s1"]]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s1"],["s1"]]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s3"],["s1"]]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[],"extra":true}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[]},{"outer":["s2"],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["x".repeat(513)],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["a\nb"],"holes":[]}]}),
|
||||
];
|
||||
for value in malformed {
|
||||
assert!(whole_profile_selection_from_value(&value).is_err(), "accepted malformed reference {value}");
|
||||
assert!(validate_raw(&json!({"features":[{"type":"extrude","profiles":value}]})).is_err());
|
||||
}
|
||||
let many = (0..129).map(|index| json!({"outer":[format!("c{index:03}")],"holes":[]})).collect::<Vec<_>>();
|
||||
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":&many[..128]})).is_ok());
|
||||
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":many})).is_err());
|
||||
let oversized = (0..513).map(|index| format!("c{index:03}")).collect::<Vec<_>>();
|
||||
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":[{"outer":oversized,"holes":[]}]})).is_err());
|
||||
let too_many_holes = (0..129).map(|index| vec![format!("hole{index:03}")]).collect::<Vec<_>>();
|
||||
assert!(
|
||||
whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":[{"outer":["outer"],"holes":too_many_holes}]})).is_err()
|
||||
);
|
||||
let aggregate = (0..40)
|
||||
.map(|profile| {
|
||||
let outer = (0..512).map(|curve| format!("profile{profile:03}curve{curve:03}")).collect::<Vec<_>>();
|
||||
json!({"outer":outer,"holes":[]})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":aggregate})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_offset_roles_reject_aliases_order_damage_and_hostile_shapes() {
|
||||
let good = fixture();
|
||||
assert!(validate_raw(&good).is_ok());
|
||||
let path = "/features/0/sketch/offsets/0";
|
||||
let mut flags = good.clone();
|
||||
flags.pointer_mut(&format!("{path}/topology/roles/1")).unwrap()["flags"] =
|
||||
json!({"construction":true,"centerline":false,"reversed":true,"fixed":false});
|
||||
assert!(validate_raw(&flags).is_ok());
|
||||
*flags.pointer_mut(&format!("{path}/topology/roles/1/flags/construction")).unwrap() = json!(1);
|
||||
assert!(validate_raw(&flags).is_err());
|
||||
for (suffix, replacement) in [
|
||||
("/topology/version", json!(2)),
|
||||
("/topology/id", json!("x".repeat(513))),
|
||||
("/closed", json!(false)),
|
||||
("/distance", json!(1e9)),
|
||||
("/outputs", json!(["l1", "l0", "l2"])),
|
||||
("/outputs", json!(["l0", "a0", "l1", "l2"])),
|
||||
("/topology/roles/1/kind", json!("line")),
|
||||
("/topology/roles/1/output", json!("l0")),
|
||||
("/topology/roles/1/start", json!("a0")),
|
||||
("/topology/roles/0/kind", json!("guess")),
|
||||
("/sources/0/1", json!(0)),
|
||||
("/sources/0/0", json!("l0")),
|
||||
("/topology/roles", json!(vec![json!({}); 513])),
|
||||
] {
|
||||
let mut damaged = good.clone();
|
||||
*damaged.pointer_mut(&format!("{path}{suffix}")).unwrap() = replacement;
|
||||
assert!(validate_raw(&damaged).is_err(), "accepted {suffix}");
|
||||
}
|
||||
let mut extra = good.clone();
|
||||
extra.pointer_mut(&format!("{path}/topology")).unwrap()["fallback"] = json!([]);
|
||||
assert!(validate_raw(&extra).is_err());
|
||||
let mut excessive = good.clone();
|
||||
let chain = excessive.pointer(path).unwrap().clone();
|
||||
let groups = (0..129)
|
||||
.map(|index| {
|
||||
let mut chain = chain.clone();
|
||||
chain["topology"]["id"] = json!(format!("o{index}"));
|
||||
for source in chain["sources"].as_array_mut().unwrap() {
|
||||
source[0] = json!(format!("g{index}-{}", source[0].as_str().unwrap()));
|
||||
}
|
||||
for output in chain["outputs"].as_array_mut().unwrap() {
|
||||
*output = json!(format!("g{index}-{}", output.as_str().unwrap()));
|
||||
}
|
||||
for role in chain["topology"]["roles"].as_array_mut().unwrap() {
|
||||
for key in ["output", "start", "end"] {
|
||||
role[key] = json!(format!("g{index}-{}", role[key].as_str().unwrap()));
|
||||
}
|
||||
}
|
||||
chain
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
excessive["features"][0]["sketch"]["offsets"] = json!(&groups[..128]);
|
||||
assert!(validate_raw(&excessive).is_ok(), "the permitted group boundary must be accepted");
|
||||
excessive["features"][0]["sketch"]["offsets"] = json!(groups);
|
||||
assert!(validate_raw(&excessive).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_boundary_references_are_bounded_and_future_versions_refuse_first() {
|
||||
let mut good = fixture();
|
||||
good["features"].as_array_mut().unwrap().push(json!({"type":"extrude","profiles":{"type":"offset_boundaries","associations":["o1"]}}));
|
||||
assert!(validate_raw(&good).is_ok());
|
||||
for associations in [json!([]), json!(["o1", "o1"]), json!(["x".repeat(513)]), json!(vec!["o"; 129]), json!([0])] {
|
||||
let mut damaged = good.clone();
|
||||
damaged["features"][1]["profiles"]["associations"] = associations;
|
||||
assert!(validate_raw(&damaged).is_err());
|
||||
damaged["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
|
||||
assert!(crate::format::read(&damaged.to_string()).unwrap_err().to_string().contains("newer SolidCraft"));
|
||||
}
|
||||
// Absent metadata preserves the historical one-to-one chain representation.
|
||||
let mut legacy = fixture();
|
||||
legacy["features"][0]["sketch"]["offsets"][0].as_object_mut().unwrap().remove("topology");
|
||||
legacy["features"][0]["sketch"]["offsets"][0]["closed"] = json!(false);
|
||||
assert!(validate_raw(&legacy).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_one_through_eleven_keep_legacy_offset_geometry_and_ids() {
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
let center = sketch.add_point(solvecraft_geom::Vec2::new(4., 7.), Some("center")).unwrap();
|
||||
sketch.add_circle(solvecraft_geom::Vec2::new(4., 7.), 10., Some(center), Some("source")).unwrap();
|
||||
sketch.add_circle(solvecraft_geom::Vec2::new(4., 7.), 9., Some(center), Some("offset")).unwrap();
|
||||
sketch.offsets.push(solvecraft_sketch::OffsetChain {
|
||||
sources: vec![("source".into(), true)],
|
||||
outputs: vec!["offset".into()],
|
||||
distance: 1.,
|
||||
closed: true,
|
||||
topology: None,
|
||||
});
|
||||
let mut document = Document::new("legacy offset circles");
|
||||
let sketch_id =
|
||||
document.add_feature(FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Circles")).unwrap();
|
||||
let original: Value = serde_json::from_str(&document.to_json()).unwrap();
|
||||
for version in 1..=11 {
|
||||
let mut old = original.clone();
|
||||
old["format"] = json!(crate::format::format_string(version));
|
||||
let upgraded = crate::format::read(&old.to_string()).unwrap();
|
||||
let saved: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
|
||||
assert_eq!(saved["features"][0]["sketch"]["offsets"], original["features"][0]["sketch"]["offsets"]);
|
||||
assert_eq!(saved["features"][0]["sketch"]["curves"], original["features"][0]["sketch"]["curves"]);
|
||||
assert_eq!(saved["features"][0]["sketch"]["points"], original["features"][0]["sketch"]["points"]);
|
||||
let mut model = crate::Model::new();
|
||||
model.evaluate(&upgraded);
|
||||
assert!(model.results.iter().all(|result| result.error.is_none()));
|
||||
let state = model.state();
|
||||
let solved = state.sketch(sketch_id).unwrap();
|
||||
let radius = solved.sketch.curves.iter().find(|curve| curve.id == "offset").map(|curve| &curve.kind);
|
||||
assert!(matches!(radius,Some(solvecraft_sketch::CurveKind::Circle {r,..}) if *r ==9.));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_boundary_or_defining_sketch_reopens_as_a_model_error_without_retarget() {
|
||||
for missing_sketch in [false, true] {
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
sketch.add_circle(solvecraft_geom::Vec2::new(0., 0.), 10., None, Some("unrelated-circle")).unwrap();
|
||||
let mut document = Document::new("repairable lost boundary");
|
||||
let id = document.add_feature(FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
document
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: if missing_sketch { 999_999 } else { id },
|
||||
profiles: ProfileSel::OffsetBoundaries { associations: vec!["deleted-o1".into()] },
|
||||
extent: crate::Extent {
|
||||
distance: "2".into(),
|
||||
direction: crate::Direction::Positive,
|
||||
distance2: None,
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
taper: None,
|
||||
to: None,
|
||||
to_offset: None,
|
||||
from: None,
|
||||
},
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let reopened = crate::format::read(&document.to_json()).unwrap();
|
||||
assert_eq!(reopened, document);
|
||||
let mut model = crate::Model::new();
|
||||
model.evaluate(&reopened);
|
||||
assert!(model.results.last().unwrap().error.is_some());
|
||||
assert!(model.state().bodies.is_empty(), "lost boundary must not select the remaining ordinary circle");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.model_body_component(b) == 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,71 +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;
|
||||
mod body_transfer;
|
||||
mod body_transfer_sheet;
|
||||
pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
|
||||
pub mod canvas;
|
||||
mod clipboard;
|
||||
pub use clipboard::{clone_expression, remap_topology};
|
||||
pub mod config;
|
||||
pub mod construct;
|
||||
mod document;
|
||||
mod eval;
|
||||
pub mod expr;
|
||||
pub mod face_anchor;
|
||||
pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
|
||||
pub mod format;
|
||||
mod format_offsets;
|
||||
pub use format_offsets::whole_profile_selection_from_value;
|
||||
pub mod joints;
|
||||
pub mod motion;
|
||||
pub mod naming;
|
||||
mod params;
|
||||
mod profile_refs;
|
||||
pub use profile_refs::{capture_profile_selection, profile_selection_indices};
|
||||
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, map_point_expr, mat_inverse, mat_mul, rigid};
|
||||
pub use document::*;
|
||||
pub use eval::{
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for, edge_refs,
|
||||
object_height, parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, state_in_frame,
|
||||
state_in_frame_at, through_all_distance, 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 named_projection_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
mod anchor_frame;
|
||||
pub use anchor_frame::state_in_face_anchor_frame;
|
||||
@@ -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,50 +0,0 @@
|
||||
use crate::{Document, ModelBody, ModelState};
|
||||
use solvecraft_geom::{Plane, Vec3};
|
||||
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn named_box(name: &str, width: f64, height: f64) -> ModelBody {
|
||||
let body = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(width, width, height)).unwrap();
|
||||
let mut names = vec!["unrelated".to_string(); body.faces(0.01).unwrap().len()];
|
||||
let top = body.faces(0.01).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.99)).unwrap().index;
|
||||
names[top] = "shared:top".into();
|
||||
let mut result = ModelBody::new(name.into(), body, 1);
|
||||
result.names = crate::naming::NameCell::known(Arc::new(names));
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_manual_projection_keeps_its_body_and_face_at_shared_boundaries() {
|
||||
let doc = Document::default();
|
||||
let mut initial = ModelState::default();
|
||||
initial.bodies.extend([named_box("Source", 20., 10.), named_box("Control", 5., 10.)]);
|
||||
let plane = Plane::XY.offset(20.);
|
||||
let source = LinkSource::Face { body: "Source".into(), at: Vec3::new(1., 1., 10.) };
|
||||
let resolved = crate::project::resolve(&doc, &BTreeMap::new(), &initial, &plane, LinkKind::Project, &source).unwrap();
|
||||
let mut sketch = Sketch::default();
|
||||
let link = sketch.add_link(LinkKind::Project, source, &resolved.geom).unwrap();
|
||||
sketch.links[0].face_name = Some("shared:top".into());
|
||||
sketch.links[0].source = LinkSource::Face { body: "Source".into(), at: Vec3::new(0., 0., 10.) };
|
||||
let mut current = ModelState::default();
|
||||
current.bodies.extend([named_box("Source", 20., 20.), named_box("Control", 5., 10.)]);
|
||||
assert!(crate::project::refresh_links(&doc, &BTreeMap::new(), ¤t, &plane, &mut sketch).is_empty());
|
||||
assert!(!sketch.links[0].lost);
|
||||
assert!(sketch.points.iter().any(|point| point.pos.x > 19.));
|
||||
assert!(sketch.points.iter().any(|point| point.pos.y > 19.));
|
||||
assert!(matches!(&sketch.links[0].source,LinkSource::Face{body,at} if body=="Source" && (at.z-20.).abs()<1e-6));
|
||||
// The closest named point can sit on the wall/top seam: retain its actual
|
||||
// face index rather than re-picking the adjacent vertical wall.
|
||||
let index = crate::naming::face_on_body(current.body("Source").unwrap(), "shared:top", Vec3::new(0., 0., 10.)).unwrap().0;
|
||||
assert!(current.body("Source").unwrap().body.faces(0.01).unwrap()[index].plane_normal.unwrap().z > 0.99);
|
||||
let saved = sketch.clone();
|
||||
let mut missing = ModelState::default();
|
||||
missing.bodies.push(named_box("Control", 5., 10.));
|
||||
let warnings = crate::project::refresh_links(&doc, &BTreeMap::new(), &missing, &plane, &mut sketch);
|
||||
assert!(sketch.links[0].lost);
|
||||
assert!(!warnings.is_empty());
|
||||
assert_eq!(sketch.curves, saved.curves);
|
||||
assert_eq!(sketch.points, saved.points);
|
||||
assert_eq!(sketch.links[0].id, link);
|
||||
assert!(matches!(&sketch.links[0].source,LinkSource::Face{body,..} if body=="Source"));
|
||||
}
|
||||
@@ -1,753 +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()) }
|
||||
}
|
||||
/// Names already known (a moved copy of a body keeps its faces' names).
|
||||
pub fn known(names: Arc<Vec<String>>) -> Self {
|
||||
let cell = OnceLock::new();
|
||||
let _ = cell.set(names);
|
||||
NameCell { origin: None, names: Arc::new(cell) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
// A rigid Move changes coordinates, not topology or face ownership. Geometric
|
||||
// overlap with the unmoved input cannot recover the names at the new position.
|
||||
if matches!(o.feature.kind, FeatureKind::Move { .. })
|
||||
&& let Some(input) = o.before.body(&b.name)
|
||||
{
|
||||
return face_names(input).as_ref().clone();
|
||||
}
|
||||
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)
|
||||
};
|
||||
// Independently created solids own their faces even when another body
|
||||
// occupies the same coordinates. Boolean operations still inherit inputs.
|
||||
let independent_solid = matches!(
|
||||
o.feature.kind,
|
||||
FeatureKind::Box { operation: crate::Operation::NewBody, .. }
|
||||
| FeatureKind::Cylinder { operation: crate::Operation::NewBody, .. }
|
||||
| FeatureKind::Sphere { operation: crate::Operation::NewBody, .. }
|
||||
| FeatureKind::Torus { operation: crate::Operation::NewBody, .. }
|
||||
| FeatureKind::Extrude { operation: crate::Operation::NewBody, .. }
|
||||
| FeatureKind::Revolve { operation: crate::Operation::NewBody, .. }
|
||||
);
|
||||
let inputs: Vec<(&ModelBody, FaceIndex, Arc<Vec<String>>)> = o
|
||||
.before
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|x| !independent_solid && !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)
|
||||
{
|
||||
// A later operation may merge several pieces of the same input face.
|
||||
// Count their shared ancestry before testing the majority; a piece
|
||||
// number identifies topology subdivision, not a different source.
|
||||
let n = strip_piece(n);
|
||||
match votes.iter_mut().find(|(v, _)| v == n) {
|
||||
Some(v) => v.1 += 1,
|
||||
None => votes.push((n.to_string(), 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) && votes.get(1).is_none_or(|(_, other)| n > other)).map(|(n, _)| n.clone())
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Capture a face name from the recorded projection body only.
|
||||
pub fn face_name_at_body(body: &ModelBody, point: Vec3) -> Option<String> {
|
||||
if body.body.is_mesh() || !point.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let mesh = body.mesh();
|
||||
let count = face_count(body);
|
||||
if mesh.triangles.len() > 2_000_000 || count > 50_000 {
|
||||
return None;
|
||||
}
|
||||
let tolerance = (body.body.size() * 1e-3).max(1e-3);
|
||||
let index = FaceIndex::new(&mesh, count).face_at(point, tolerance)?;
|
||||
face_names(body).get(index).filter(|name| !name.is_empty()).cloned()
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
/// Resolve a legacy named face only on its recorded source body. Return the
|
||||
/// selected face index as well as the closest point, which may lie on a shared
|
||||
/// edge and therefore must not be used to pick the face a second time.
|
||||
pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec3, bool)> {
|
||||
if body.body.is_mesh() {
|
||||
return None;
|
||||
}
|
||||
let names = face_names(body);
|
||||
let mesh = body.mesh();
|
||||
let stem = strip_piece(name);
|
||||
let mut best: Option<(f64, usize, Vec3, bool)> = None;
|
||||
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
let index = usize::try_from(*face).ok()?;
|
||||
let Some(candidate) = names.get(index) else { continue };
|
||||
let exact = candidate == name;
|
||||
if !exact && strip_piece(candidate) != stem {
|
||||
continue;
|
||||
}
|
||||
let Some([a, b, c]) = mesh.tri(triangle) else { continue };
|
||||
let q = closest(p, a, b, c);
|
||||
let distance = q.dist(p) - if exact { 1e-9 } else { 0. };
|
||||
if best.is_none_or(|(old, _, _, _)| distance < old) {
|
||||
best = Some((distance, index, q, !exact));
|
||||
}
|
||||
}
|
||||
best.map(|(_, index, q, split)| (index, q, split))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merged_face_votes_collectively_for_pieces_of_one_source() {
|
||||
let body = ModelBody::new("Input".into(), solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(4., 1., 1.)).unwrap(), 1);
|
||||
let mut mesh = Mesh::default();
|
||||
let mut points = Vec::new();
|
||||
for i in 0..3u32 {
|
||||
let x = f64::from(i);
|
||||
mesh.positions.extend([Vec3::new(x, 0., 0.), Vec3::new(x + 0.8, 0., 0.), Vec3::new(x, 0.8, 0.)]);
|
||||
mesh.triangles.push([i * 3, i * 3 + 1, i * 3 + 2]);
|
||||
mesh.tri_face.push(i);
|
||||
points.push(Vec3::new(x + 0.2, 0.2, 0.));
|
||||
}
|
||||
let pieces = Arc::new(vec!["F1:side:wall#1".into(), "F1:side:wall#2".into(), "F1:side:wall#3".into()]);
|
||||
assert_eq!(vote(&points, &[(&body, FaceIndex::new(&mesh, 3), pieces)], 1e-8, None).as_deref(), Some("F1:side:wall"));
|
||||
let distinct = Arc::new(vec!["F1:side:wall#1".into(), "F2:side:wall#2".into(), "F3:side:wall#3".into()]);
|
||||
let inputs = [(&body, FaceIndex::new(&mesh, 3), distinct)];
|
||||
assert_eq!(vote(&points, &inputs, 1e-8, None), None);
|
||||
assert_eq!(vote(&points[..2], &inputs, 1e-8, None), None, "equal different-source votes must not pick a lexicographic winner");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,505 +0,0 @@
|
||||
//! Exact profile references through analytic offset topology transitions.
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::{DocError, ProfileSel, Result, SolvedSketch, WholeProfileBoundary};
|
||||
|
||||
const MAX_SELECTIONS: usize = 128;
|
||||
|
||||
fn whole_boundary(profile: &solvecraft_sketch::Profile) -> Option<WholeProfileBoundary> {
|
||||
let (outer, holes) = profile.whole_curve_boundaries()?;
|
||||
Some(WholeProfileBoundary { outer, holes })
|
||||
}
|
||||
|
||||
fn validate_whole(profiles: &[WholeProfileBoundary]) -> Result<()> {
|
||||
if profiles.is_empty() || profiles.len() > MAX_SELECTIONS {
|
||||
return Err(DocError::Invalid("whole-source profile selection needs bounded unique profiles".into()));
|
||||
}
|
||||
let mut total = 0usize;
|
||||
let mut identities = BTreeSet::new();
|
||||
for profile in profiles {
|
||||
if profile.holes.len() > 128 || !identities.insert((&profile.outer, &profile.holes)) {
|
||||
return Err(DocError::Invalid("invalid whole-source profile holes or duplicate identity".into()));
|
||||
}
|
||||
let mut all = BTreeSet::new();
|
||||
for boundary in std::iter::once(&profile.outer).chain(&profile.holes) {
|
||||
total += boundary.len();
|
||||
if boundary.is_empty() || boundary.len() > 512 || total > 20_000 {
|
||||
return Err(DocError::Invalid("whole-source boundary exceeds its identity budget".into()));
|
||||
}
|
||||
let mut previous = None;
|
||||
for curve in boundary {
|
||||
if curve.is_empty()
|
||||
|| curve.len() > 512
|
||||
|| curve.chars().any(char::is_control)
|
||||
|| previous.is_some_and(|previous| previous >= curve)
|
||||
|| !all.insert(curve)
|
||||
{
|
||||
return Err(DocError::Invalid("whole-source boundary needs canonical disjoint curve identities".into()));
|
||||
}
|
||||
previous = Some(curve);
|
||||
}
|
||||
}
|
||||
if profile.holes.windows(2).any(|pair| pair[0] >= pair[1]) {
|
||||
return Err(DocError::Invalid("whole-source holes need canonical unique identities".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn whole_profile_indices(ss: &SolvedSketch, profiles: &[WholeProfileBoundary]) -> Result<Vec<usize>> {
|
||||
validate_whole(profiles)?;
|
||||
let current = ss.profiles.iter().map(whole_boundary).collect::<Vec<_>>();
|
||||
profiles
|
||||
.iter()
|
||||
.map(|identity| {
|
||||
let mut matches = current.iter().enumerate().filter(|(_, candidate)| candidate.as_ref() == Some(identity));
|
||||
let (index, _) = matches.next().ok_or_else(|| DocError::Unknown(format!("complete source profile in {}", ss.name)))?;
|
||||
if matches.next().is_some() {
|
||||
return Err(DocError::Invalid(format!("ambiguous complete source profile in {}", ss.name)));
|
||||
}
|
||||
Ok(index)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn same_ids(a: &[String], b: &[String]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let (mut a, mut b) = (a.iter().collect::<Vec<_>>(), b.iter().collect::<Vec<_>>());
|
||||
a.sort();
|
||||
b.sort();
|
||||
a == b
|
||||
}
|
||||
|
||||
fn unique_profile(ss: &SolvedSketch, curves: &[String]) -> Result<usize> {
|
||||
let mut matches = ss.profiles.iter().enumerate().filter(|(_, profile)| same_ids(curves, &profile.outer_curves));
|
||||
let (index, _) = matches.next().ok_or_else(|| DocError::Unknown(format!("profile bounded by {curves:?} in {}", ss.name)))?;
|
||||
if matches.next().is_some() {
|
||||
return Err(DocError::Invalid(format!("ambiguous profile boundary in {}", ss.name)));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn reserved_boundaries(ss: &SolvedSketch) -> Result<std::collections::BTreeMap<Vec<String>, String>> {
|
||||
let mut found = std::collections::BTreeMap::new();
|
||||
for offset in &ss.sketch.offsets {
|
||||
let Some(topology) = &offset.topology else { continue };
|
||||
let mut reserved = topology.roles.iter().map(|role| role.output.clone()).collect::<Vec<_>>();
|
||||
reserved.sort();
|
||||
if found.insert(reserved, topology.id.clone()).is_some() {
|
||||
return Err(DocError::Invalid("profile role set belongs to multiple offset associations".into()));
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Resolve exact selection identities to the solved sketch's current profile indices.
|
||||
/// Complete reserved role sets follow their association; partial sets never expand.
|
||||
pub fn profile_selection_indices(ss: &SolvedSketch, selection: &ProfileSel) -> Result<Vec<usize>> {
|
||||
let indices = match selection {
|
||||
ProfileSel::All => (0..ss.profiles.len()).collect(),
|
||||
ProfileSel::Indices { indices } => indices.clone(),
|
||||
ProfileSel::Curves { loops } => {
|
||||
let boundaries = reserved_boundaries(ss)?;
|
||||
let mut indices = Vec::with_capacity(loops.len());
|
||||
for curves in loops {
|
||||
let mut key = curves.clone();
|
||||
key.sort();
|
||||
if let Some(id) = boundaries.get(&key) {
|
||||
if curves.len() > 512 || curves.iter().collect::<BTreeSet<_>>().len() != curves.len() {
|
||||
return Err(DocError::Invalid("offset profile has repeated or excessive curve roles".into()));
|
||||
}
|
||||
indices.push(unique_profile(ss, &ss.sketch.offset_boundary(id)?)?);
|
||||
} else {
|
||||
// Repeated saved IDs identify exact graph spans. Their multiplicity
|
||||
// must not silently turn into a whole-source certificate on resize.
|
||||
if curves.iter().collect::<BTreeSet<_>>().len() != curves.len()
|
||||
&& !ss.profiles.iter().any(|profile| same_ids(curves, &profile.outer_curves))
|
||||
{
|
||||
return Err(crate::curve_pick_error(solvecraft_sketch::CurvePick::Missing, curves, &ss.name));
|
||||
}
|
||||
indices.push(
|
||||
solvecraft_sketch::profile_by_curves(&ss.profiles, curves)
|
||||
.map_err(|error| crate::curve_pick_error(error, curves, &ss.name))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
indices
|
||||
}
|
||||
ProfileSel::OffsetBoundaries { associations } => {
|
||||
if associations.is_empty()
|
||||
|| associations.len() > MAX_SELECTIONS
|
||||
|| associations.iter().collect::<BTreeSet<_>>().len() != associations.len()
|
||||
{
|
||||
return Err(DocError::Invalid("offset profile selection needs unique bounded association identities".into()));
|
||||
}
|
||||
associations.iter().map(|id| unique_profile(ss, &ss.sketch.offset_boundary(id)?)).collect::<Result<Vec<_>>>()?
|
||||
}
|
||||
ProfileSel::WholeBoundaries { profiles } => whole_profile_indices(ss, profiles)?,
|
||||
ProfileSel::Points { points } => {
|
||||
let mut indices = Vec::with_capacity(points.len());
|
||||
for point in points {
|
||||
let (index, _) = ss
|
||||
.profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, profile)| profile.region.contains(*point))
|
||||
.min_by(|(_, a), (_, b)| a.area.total_cmp(&b.area))
|
||||
.ok_or_else(|| DocError::Unknown(format!("profile at {:?} in {}", [point.x, point.y], ss.name)))?;
|
||||
indices.push(index);
|
||||
}
|
||||
indices
|
||||
}
|
||||
};
|
||||
if indices.iter().any(|index| *index >= ss.profiles.len()) {
|
||||
return Err(DocError::Invalid(format!("out-of-range profile selection in {}", ss.name)));
|
||||
}
|
||||
Ok(indices)
|
||||
}
|
||||
|
||||
/// Persist selected complete offset boundaries by association identity. Mixed selections
|
||||
/// retain exact ordinary loops and the complete reserved role set of each offset boundary.
|
||||
pub fn capture_profile_selection(ss: &SolvedSketch, indices: &[usize]) -> Result<ProfileSel> {
|
||||
let checked = profile_selection_indices(ss, &ProfileSel::Indices { indices: indices.to_vec() })?;
|
||||
if checked.is_empty() || checked.iter().collect::<BTreeSet<_>>().len() != checked.len() {
|
||||
return Ok(ProfileSel::Indices { indices: indices.to_vec() });
|
||||
}
|
||||
// Capture adaptive free-form boundaries only with complete source provenance,
|
||||
// including every hole. Ordinary analytic captures keep their historical form.
|
||||
let adaptive = checked.iter().any(|index| {
|
||||
ss.profiles.get(*index).is_some_and(|profile| {
|
||||
std::iter::once(&profile.outer_curves)
|
||||
.chain(&profile.hole_curves)
|
||||
.any(|curves| curves.iter().collect::<BTreeSet<_>>().len() != curves.len())
|
||||
})
|
||||
});
|
||||
if adaptive {
|
||||
let identities = checked.iter().map(|index| ss.profiles.get(*index).and_then(whole_boundary)).collect::<Option<Vec<_>>>();
|
||||
if let Some(profiles) = identities {
|
||||
// Dynamic analytic offset groups need their own association identities.
|
||||
// Do not silently flatten a mixed selection into current active curve IDs.
|
||||
let mut dynamic = false;
|
||||
for profile in &profiles {
|
||||
for boundary in std::iter::once(&profile.outer).chain(&profile.holes) {
|
||||
dynamic |= ss.sketch.offset_boundary_for_curves(boundary)?.is_some();
|
||||
}
|
||||
}
|
||||
if !dynamic && whole_profile_indices(ss, &profiles)? == checked {
|
||||
return Ok(ProfileSel::WholeBoundaries { profiles });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut associations = Vec::new();
|
||||
let mut loops = Vec::new();
|
||||
for index in checked {
|
||||
let curves = &ss.profiles.get(index).ok_or_else(|| DocError::Invalid("profile index changed during capture".into()))?.outer_curves;
|
||||
if curves.is_empty() {
|
||||
return Ok(ProfileSel::Indices { indices: indices.to_vec() });
|
||||
}
|
||||
// Ordinary free-form loops retain one curve ID per exact span. That
|
||||
// historical multiset is valid profile identity, but is not a candidate
|
||||
// for the unique reserved-role set of a topology-aware association.
|
||||
let association =
|
||||
if curves.iter().collect::<BTreeSet<_>>().len() == curves.len() { ss.sketch.offset_boundary_for_curves(curves)? } else { None };
|
||||
if let Some(id) = association {
|
||||
unique_profile(ss, curves)?;
|
||||
let offset = ss
|
||||
.sketch
|
||||
.offsets
|
||||
.iter()
|
||||
.filter_map(|offset| offset.topology.as_ref())
|
||||
.find(|topology| topology.id == id)
|
||||
.ok_or_else(|| DocError::Unknown(format!("offset association {id}")))?;
|
||||
loops.push(offset.roles.iter().map(|role| role.output.clone()).collect());
|
||||
associations.push(id);
|
||||
} else {
|
||||
if ss.profiles.iter().filter(|profile| same_ids(curves, &profile.outer_curves)).count() != 1 {
|
||||
return Ok(ProfileSel::Indices { indices: indices.to_vec() });
|
||||
}
|
||||
loops.push(curves.clone());
|
||||
}
|
||||
}
|
||||
if associations.len() > MAX_SELECTIONS {
|
||||
return Err(DocError::Invalid("too many offset association profiles".into()));
|
||||
}
|
||||
if associations.len() == loops.len() { Ok(ProfileSel::OffsetBoundaries { associations }) } else { Ok(ProfileSel::Curves { loops }) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use super::*;
|
||||
use solvecraft_geom::{Plane, Vec2};
|
||||
use solvecraft_sketch::{Sketch, find_profiles, solve};
|
||||
|
||||
fn rounded_source(sketch: &mut Sketch, radius: f64) -> Vec<(String, bool)> {
|
||||
let endpoints = [
|
||||
Vec2::new(-37., -32. - radius),
|
||||
Vec2::new(37., -32. - radius),
|
||||
Vec2::new(37. + radius, -32.),
|
||||
Vec2::new(37. + radius, 32.),
|
||||
Vec2::new(37., 32. + radius),
|
||||
Vec2::new(-37., 32. + radius),
|
||||
Vec2::new(-37. - radius, 32.),
|
||||
Vec2::new(-37. - radius, -32.),
|
||||
];
|
||||
let points: Vec<_> =
|
||||
endpoints.iter().enumerate().map(|(index, point)| sketch.add_point(*point, Some(&format!("source-p{index}"))).unwrap()).collect();
|
||||
let centers = [Vec2::new(37., -32.), Vec2::new(37., 32.), Vec2::new(-37., 32.), Vec2::new(-37., -32.)];
|
||||
for index in 0..8 {
|
||||
let id = format!("source-edge{index}");
|
||||
if index % 2 == 0 {
|
||||
sketch.add_line_pts(points[index], points[(index + 1) % 8], Some(&id)).unwrap();
|
||||
} else {
|
||||
let center = centers[index / 2];
|
||||
sketch
|
||||
.add_arc(
|
||||
center,
|
||||
endpoints[index],
|
||||
endpoints[(index + 1) % 8],
|
||||
[None, Some(points[index]), Some(points[(index + 1) % 8])],
|
||||
Some(&id),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
for point in &mut sketch.points {
|
||||
point.fixed = true;
|
||||
}
|
||||
(0..8).map(|index| (format!("source-edge{index}"), true)).collect()
|
||||
}
|
||||
|
||||
fn set_source_radius(sketch: &mut Sketch, radius: f64) {
|
||||
let points = [
|
||||
Vec2::new(-37., -32. - radius),
|
||||
Vec2::new(37., -32. - radius),
|
||||
Vec2::new(37. + radius, -32.),
|
||||
Vec2::new(37. + radius, 32.),
|
||||
Vec2::new(37., 32. + radius),
|
||||
Vec2::new(-37., 32. + radius),
|
||||
Vec2::new(-37. - radius, 32.),
|
||||
Vec2::new(-37. - radius, -32.),
|
||||
];
|
||||
for (index, value) in points.into_iter().enumerate() {
|
||||
sketch.points.iter_mut().find(|point| point.id == format!("source-p{index}")).unwrap().pos = value;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_sharp_offset_profile_survives_arc_resurrection_and_reopen() {
|
||||
let mut sketch = Sketch::new();
|
||||
let sources = rounded_source(&mut sketch, 1.);
|
||||
let created = sketch.add_convex_offset(sources, 1.4).unwrap();
|
||||
assert_eq!(created.curves.len(), 4);
|
||||
let mut document = crate::Document::new("selected logical inner boundary");
|
||||
let feature =
|
||||
document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
let mut model = crate::Model::new();
|
||||
model.evaluate(&document);
|
||||
let state = model.state();
|
||||
let solved = state.sketch(feature).unwrap();
|
||||
let index = unique_profile(solved, &created.curves).unwrap();
|
||||
let selection = capture_profile_selection(solved, &[index]).unwrap();
|
||||
assert_eq!(selection, ProfileSel::OffsetBoundaries { associations: vec![created.association.clone()] });
|
||||
let original_roles = solved.sketch.offsets[0].topology.as_ref().unwrap().roles.iter().map(|role| role.output.clone()).collect::<Vec<_>>();
|
||||
let sharp_area = crate::select_profiles(solved, &selection).unwrap()[0].area();
|
||||
assert!((sharp_area - 73.2 * 63.2).abs() < 1e-7, "{sharp_area}");
|
||||
document
|
||||
.add_feature(
|
||||
crate::FeatureKind::Extrude {
|
||||
sketch: feature,
|
||||
profiles: selection.clone(),
|
||||
extent: crate::Extent {
|
||||
distance: "2".into(),
|
||||
direction: crate::Direction::Positive,
|
||||
distance2: None,
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
taper: None,
|
||||
to: None,
|
||||
to_offset: None,
|
||||
from: None,
|
||||
},
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
model.evaluate(&document);
|
||||
let volume = solvecraft_kernel::measure(&model.state().bodies[0].body).unwrap().volume;
|
||||
assert!((volume - 2. * 73.2 * 63.2).abs() < 1e-6, "{volume}");
|
||||
let reopened = crate::Document::from_json(&document.to_json()).unwrap();
|
||||
assert_eq!(reopened, document);
|
||||
let mut cold = crate::Model::new();
|
||||
cold.evaluate(&reopened);
|
||||
assert!((solvecraft_kernel::measure(&cold.state().bodies[0].body).unwrap().volume - volume).abs() < 1e-6);
|
||||
let crate::FeatureKind::Sketch { sketch, .. } = &mut document.features[0].kind else { panic!("sketch") };
|
||||
set_source_radius(sketch, 2.);
|
||||
model.evaluate(&document);
|
||||
assert!(model.results.iter().all(|result| result.error.is_none()), "{:?}", model.results);
|
||||
let state = model.state();
|
||||
let solved = state.sketch(feature).unwrap();
|
||||
let active = solved.sketch.offset_boundary(&created.association).unwrap();
|
||||
assert_eq!(active, original_roles);
|
||||
let rounded_area = 75.2 * 65.2 - (4. - std::f64::consts::PI) * 0.6 * 0.6;
|
||||
assert!((crate::select_profiles(solved, &selection).unwrap()[0].area() - rounded_area).abs() < 1e-7);
|
||||
let volume = solvecraft_kernel::measure(&model.state().bodies[0].body).unwrap().volume;
|
||||
// Kernel volume integrates the curved display mesh; the analytic region above
|
||||
// checks the exact rounded geometry independently of that tessellation.
|
||||
assert!((volume - 2. * rounded_area).abs() < 0.002, "{volume}");
|
||||
assert!(
|
||||
crate::select_profiles(solved, &ProfileSel::Curves { loops: vec![created.curves.clone()] }).is_err(),
|
||||
"partial old active set must not expand after resurrection"
|
||||
);
|
||||
assert_eq!(
|
||||
profile_selection_indices(solved, &ProfileSel::Curves { loops: vec![original_roles] }).unwrap(),
|
||||
profile_selection_indices(solved, &selection).unwrap()
|
||||
);
|
||||
let reopened = crate::Document::from_json(&document.to_json()).unwrap();
|
||||
cold.evaluate(&reopened);
|
||||
assert!((solvecraft_kernel::measure(&cold.state().bodies[0].body).unwrap().volume - volume).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_profile_selection_keeps_legacy_large_and_duplicate_semantics() {
|
||||
let mut sketch = Sketch::new();
|
||||
sketch.add_circle(Vec2::new(0., 0.), 3., None, Some("circle")).unwrap();
|
||||
let report = solve(&mut sketch);
|
||||
let profiles = find_profiles(&sketch);
|
||||
let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles };
|
||||
assert_eq!(ss.profiles.len(), 1);
|
||||
let indices = vec![0; 600];
|
||||
let selection = ProfileSel::Indices { indices: indices.clone() };
|
||||
assert_eq!(profile_selection_indices(&ss, &selection).unwrap(), indices);
|
||||
assert_eq!(capture_profile_selection(&ss, &indices).unwrap(), selection);
|
||||
let points = ProfileSel::Points { points: vec![Vec2::new(0., 0.); 600] };
|
||||
assert_eq!(profile_selection_indices(&ss, &points).unwrap(), indices);
|
||||
let curves = ProfileSel::Curves { loops: vec![vec!["circle".into()]; 600] };
|
||||
let regions = crate::select_profiles(&ss, &curves).unwrap();
|
||||
assert_eq!(regions.len(), 600);
|
||||
assert!(regions.iter().all(|region| (region.area() - std::f64::consts::PI * 9.).abs() < 1e-9));
|
||||
assert!(profile_selection_indices(&ss, &ProfileSel::Indices { indices: vec![1] }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_offset_ring_whole_source_survives_adaptive_spans_and_reopen() {
|
||||
let mut sketch = Sketch::new();
|
||||
let major = sketch.add_point(Vec2::new(10., 0.), None).unwrap();
|
||||
let ellipse = sketch.add_curve(solvecraft_sketch::CurveKind::Ellipse { c: 0, m: major, r: 5. }, None).unwrap();
|
||||
let first = sketch.add_freeform_offset(ellipse, true, -1.).unwrap();
|
||||
sketch.add_freeform_offset(first, true, -0.5).unwrap();
|
||||
let mut document = crate::Document::new("periodic offset ring multiset");
|
||||
let feature =
|
||||
document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
let mut model = crate::Model::new();
|
||||
model.evaluate(&document);
|
||||
let state = model.state();
|
||||
let solved = state.sketch(feature).unwrap();
|
||||
assert_eq!(solved.profiles.len(), 3);
|
||||
let (index, profile) = solved.profiles.iter().enumerate().min_by(|(_, a), (_, b)| a.area.total_cmp(&b.area)).unwrap();
|
||||
assert!(profile.outer_curves.len() > 1);
|
||||
assert_eq!(profile.outer_curves.iter().collect::<BTreeSet<_>>().len(), 1);
|
||||
assert!(solved.sketch.offset_boundary_for_curves(&profile.outer_curves).is_err(), "strict association identity still refuses repeated roles");
|
||||
let selection = capture_profile_selection(solved, &[index]).unwrap();
|
||||
assert!(matches!(&selection, ProfileSel::WholeBoundaries { profiles } if profiles.len() == 1 && profiles[0].holes.len() == 1));
|
||||
let legacy_spans = ProfileSel::Curves { loops: vec![profile.outer_curves.clone()] };
|
||||
assert_eq!(profile_selection_indices(solved, &legacy_spans).unwrap(), vec![index]);
|
||||
let legacy_roundtrip: ProfileSel = serde_json::from_str(&serde_json::to_string(&legacy_spans).unwrap()).unwrap();
|
||||
assert_eq!(legacy_roundtrip, legacy_spans, "ordinary legacy span multiplicities remain serialized exactly");
|
||||
assert_eq!(profile_selection_indices(solved, &selection).unwrap(), vec![index]);
|
||||
let selected = crate::select_profiles(solved, &selection).unwrap();
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert!((selected[0].area() - profile.area).abs() < 1e-10);
|
||||
assert!(selected[0].contains(Vec2::new(11.25, 0.)));
|
||||
assert!(!selected[0].contains(Vec2::new(10.5, 0.)));
|
||||
assert!(!selected[0].contains(Vec2::new(11.75, 0.)));
|
||||
let saved = serde_json::to_string(&selection).unwrap();
|
||||
let reopened: ProfileSel = serde_json::from_str(&saved).unwrap();
|
||||
let reopened_doc = crate::Document::from_json(&document.to_json()).unwrap();
|
||||
let mut cold = crate::Model::new();
|
||||
cold.evaluate(&reopened_doc);
|
||||
let cold_state = cold.state();
|
||||
let regions = crate::select_profiles(cold_state.sketch(feature).unwrap(), &reopened).unwrap();
|
||||
assert_eq!(regions, selected);
|
||||
|
||||
// An identical complete identity in two profiles is ambiguous, even when
|
||||
// their boundary geometry and graph coverage certificates are identical.
|
||||
let mut ambiguous = cold_state.sketch(feature).unwrap().clone();
|
||||
ambiguous.profiles.push(ambiguous.profiles[index].clone());
|
||||
assert!(profile_selection_indices(&ambiguous, &reopened).is_err());
|
||||
|
||||
document
|
||||
.add_feature(
|
||||
crate::FeatureKind::Extrude {
|
||||
sketch: feature,
|
||||
profiles: selection.clone(),
|
||||
extent: crate::Extent {
|
||||
distance: "2".into(),
|
||||
direction: crate::Direction::Positive,
|
||||
distance2: None,
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
taper: None,
|
||||
to: None,
|
||||
to_offset: None,
|
||||
from: None,
|
||||
},
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
model.evaluate(&document);
|
||||
let initial_volume = solvecraft_kernel::measure(&model.state().bodies[0].body).unwrap().volume;
|
||||
// Kernel measurement integrates the curved mesh. Scale the established
|
||||
// periodic-ring tolerance (0.05 mm³ at height3) to this height2 extrusion.
|
||||
// Exact profile identity and cold/warm equality below remain strict.
|
||||
let volume_tolerance = 0.05 * 2. / 3.;
|
||||
assert!(
|
||||
(initial_volume - 2. * profile.area).abs() < volume_tolerance,
|
||||
"initial kernel volume {initial_volume}, analytic area {}",
|
||||
profile.area
|
||||
);
|
||||
let crate::FeatureKind::Sketch { sketch, .. } = &mut document.features[0].kind else { panic!("sketch") };
|
||||
sketch.points[major].pos.x = 12.;
|
||||
model.evaluate(&document);
|
||||
assert!(model.results.iter().all(|result| result.error.is_none()), "{:?}", model.results);
|
||||
let updated_state = model.state();
|
||||
let updated = updated_state.sketch(feature).unwrap();
|
||||
assert!(profile_selection_indices(updated, &legacy_spans).is_err(), "legacy tessellation-span multiplicity intentionally remains exact");
|
||||
let updated_index = profile_selection_indices(updated, &selection).unwrap()[0];
|
||||
let updated_region = &updated.profiles[updated_index].region;
|
||||
// Probe away from exact spline-span seams, using the independent analytic
|
||||
// ellipse point and unit outward gradient rather than a stored profile sample.
|
||||
let parameter = 0.2_f64;
|
||||
let source_point = Vec2::new(12. * parameter.cos(), 5. * parameter.sin());
|
||||
let normal = Vec2::new(parameter.cos() / 12., parameter.sin() / 5.).normalized().unwrap();
|
||||
let ring_point = source_point + normal * 1.25;
|
||||
assert!(updated_region.contains(ring_point));
|
||||
assert!(!updated_region.contains(source_point + normal * 0.5));
|
||||
assert!(!updated_region.contains(source_point + normal * 1.75));
|
||||
let updated_volume = solvecraft_kernel::measure(&updated_state.bodies[0].body).unwrap().volume;
|
||||
assert!(updated_volume > initial_volume);
|
||||
assert!(
|
||||
(updated_volume - 2. * updated_region.area()).abs() < volume_tolerance,
|
||||
"updated kernel volume {updated_volume}, analytic area {}",
|
||||
updated_region.area()
|
||||
);
|
||||
let reopened_document = crate::Document::from_json(&document.to_json()).unwrap();
|
||||
cold.evaluate(&reopened_document);
|
||||
assert!(cold.results.iter().all(|result| result.error.is_none()));
|
||||
assert!((solvecraft_kernel::measure(&cold.state().bodies[0].body).unwrap().volume - updated_volume).abs() < 1e-6);
|
||||
|
||||
// A new hole is a semantic boundary change, not the old ring. Whole-source
|
||||
// identities include holes and must not silently absorb the new material cutout.
|
||||
let crate::FeatureKind::Sketch { sketch, .. } = &mut document.features[0].kind else { panic!("sketch") };
|
||||
sketch.add_circle(ring_point, 0.1, None, Some("new-hole")).unwrap();
|
||||
model.evaluate(&document);
|
||||
let changed = model.state();
|
||||
assert!(profile_selection_indices(changed.sketch(feature).unwrap(), &selection).is_err());
|
||||
assert!(model.results.last().unwrap().error.is_some());
|
||||
}
|
||||
}
|
||||
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, periodic } => solvecraft_sketch::spline_polyline_mode(pts, control, degree, periodic),
|
||||
LinkGeom::Point(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user