mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 11:05:56 +00:00
Compare commits
356 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6090e89b3f | |||
| f06b76e14f | |||
| 4b8872e4d0 | |||
| e7b5b2618e | |||
| ef9bb0db09 | |||
| f538eeb5fa | |||
| d470bfc52a | |||
| f82b6beedb | |||
| 80672460ed | |||
| 57e6396fbb | |||
| a5cc1243e3 | |||
| afcec4d0bd | |||
| 6efbd6ff14 | |||
| c51735c885 | |||
| 4eaadf3099 | |||
| 882ef0e9c6 | |||
| d15869ab85 | |||
| 511d105dcb | |||
| 2ebceb6918 | |||
| 7db66bb824 | |||
| a7e47fdae6 | |||
| ce226a88eb | |||
| 0b122e6d4a | |||
| b2fc6d1fac | |||
| e6a95615a0 | |||
| 6dab0fb2c1 | |||
| a04ef4ca59 | |||
| 8e87a37511 | |||
| fe4d171e22 | |||
| 75a5609b23 | |||
| fc8a5b21cf | |||
| e6252ae094 | |||
| 198f45edee | |||
| 354a267830 | |||
| c33fe37798 | |||
| 667e05a0c9 | |||
| a9efae864d | |||
| cc668668ce | |||
| cfdd8a533b | |||
| 08cf526fe2 | |||
| 2bda050872 | |||
| 08d797235d | |||
| 97bcdb4d06 | |||
| 688f806682 | |||
| 43ddde4a5d | |||
| 08d7037176 | |||
| d5cb276965 | |||
| d69ebbee1b | |||
| 8c81bc60d0 | |||
| cc4955a8da | |||
| c87efa1f35 | |||
| 65a99608d9 | |||
| a9e4830ded | |||
| a3ff816176 | |||
| 06e517eb4e | |||
| 4d4b3a715f | |||
| 6bf86f1f1e | |||
| 516d80ae8d | |||
| 250d8a2c04 | |||
| e6e0a7ebb5 | |||
| cb8aa38cc9 | |||
| 14163d0965 | |||
| 3b8028a040 | |||
| c7f0139ad4 | |||
| f525704192 | |||
| db6ecaf446 | |||
| 59fabc16ac | |||
| aea16f2b5a | |||
| 93fd18eaad | |||
| b45cae1e48 | |||
| e12758ff91 | |||
| f0dda667bb | |||
| 611422e4df | |||
| 2cac9cad6a | |||
| 3d99ab4bb1 | |||
| e3938defd8 | |||
| f3751bd3ca | |||
| 7d7ef18f49 | |||
| f77da83c93 | |||
| 62847173f4 | |||
| df09fdbc32 | |||
| 87bb2f37f8 | |||
| 879aacacf8 | |||
| a9007d7b86 | |||
| 56a2ed1e4f | |||
| bf60758244 | |||
| d9613e29cb | |||
| 0a526c6f85 | |||
| 4fbf19f2f9 | |||
| 1fee62dd54 | |||
| ab72c5ad56 | |||
| df4c8a2994 | |||
| 417511367c | |||
| c3f94ab616 | |||
| 8eda982489 | |||
| cab698bf12 | |||
| c308a1ba7a | |||
| 874315db8e | |||
| 56b499e951 | |||
| 8c13914d6c | |||
| f0292b1606 | |||
| 732447d2dc | |||
| db0a2a007a | |||
| ed102b5393 | |||
| 970da30a99 | |||
| 113ded5fab | |||
| b4491ac605 | |||
| 53b092d287 | |||
| 07e263d7e9 | |||
| 25ea43e89f | |||
| c53aff7592 | |||
| 13aac4296d | |||
| 18d487a9bd | |||
| ff9653a353 | |||
| 400f200efa | |||
| 6dd202d277 | |||
| 16bf3ba593 | |||
| 8fa52c8e5e | |||
| 8601e38d4b | |||
| e171ff07ad | |||
| 6ae48ed70f | |||
| d8beaedfa4 | |||
| 4af2d06007 | |||
| 81ef51c6ce | |||
| 1fb4783f67 | |||
| a94bf2562b | |||
| 26b7b7336f | |||
| 8539ac1b40 | |||
| 78aa2aacfb | |||
| 92dbd240ae | |||
| 71cda4e7ee | |||
| a91596c1b8 | |||
| 7f98dd1c47 | |||
| 8cce89d78a | |||
| 6ce472c29e | |||
| 4e39869023 | |||
| 3f1b07d35c | |||
| f6e7d754e5 | |||
| 6c06168fa7 | |||
| af536a7907 | |||
| ee1b56cac4 | |||
| 344e52e207 | |||
| 35555c0b9f | |||
| 1d3048062d | |||
| da56d180bf | |||
| 33f0b9e3cc | |||
| ef8f6ca6f9 | |||
| cb5144b6e2 | |||
| 1aedbf6631 | |||
| 6f872c7789 | |||
| 9bcd8ffd56 | |||
| cc2ee6a0f9 | |||
| 9bfd1158d0 | |||
| 58c0ed8a90 | |||
| 6dc6372b13 | |||
| 1adb2cd69f | |||
| 9d7be3c292 | |||
| adecca41f6 | |||
| ac7d5d14cf | |||
| a7512e5e08 | |||
| 36f49811ab | |||
| 4c6afc6ba4 | |||
| f2f8a0df57 | |||
| 09bf9cafdf | |||
| d9d4dd262d | |||
| 2eee967d40 | |||
| 6140f89a42 | |||
| 66f351e93b | |||
| 5b54275626 | |||
| 7abfe69603 | |||
| 1a62c4513f | |||
| 2f78d3ef81 | |||
| 78f80143d5 | |||
| b2cf19c9fb | |||
| b5fb86f245 | |||
| a03e4b9413 | |||
| 0b5e15e2b9 | |||
| 5affff307e | |||
| ab05b24dee | |||
| 8a38a7354c | |||
| a094fa46b5 | |||
| 0167c358bb | |||
| 463441a119 | |||
| ce46faa19d | |||
| b93698c42e | |||
| 4e595deff2 | |||
| 49ca7ef878 | |||
| 5c4785f55c | |||
| 4c466466a2 | |||
| 4c4a0449a4 | |||
| 43a02625a2 | |||
| 6cbcf5c823 | |||
| b02c5c01f9 | |||
| 180c655ded | |||
| 93ca39c68e | |||
| 442c1f134b | |||
| d47b5d3a29 | |||
| 736730c398 | |||
| 32900813c5 | |||
| 839966c108 | |||
| 4667c3a600 | |||
| 6d4c031090 | |||
| 680c82365e | |||
| 79753040d5 | |||
| 413a1fb727 | |||
| 420ee723ee | |||
| 7e4dd4fd66 | |||
| 5a2174899f | |||
| f9d61590f3 | |||
| c11fc79288 | |||
| d9f7473ceb | |||
| 4b35484052 | |||
| b93d4905ac | |||
| 184e301c46 | |||
| 3dc0a6024e | |||
| 8970254672 | |||
| 21e0e99c86 | |||
| cc22672041 | |||
| adb1e5fec5 | |||
| 3573b96c7a | |||
| c67ad22129 | |||
| 2406957039 | |||
| a6bb8fc924 | |||
| 5073f5dd72 | |||
| 848e7aec0e | |||
| 75e9472a47 | |||
| c1d819f235 | |||
| 0398a813c8 | |||
| 07932d0f42 | |||
| d71bdb78ec | |||
| 3afd6ae447 | |||
| 8c1b1478ce | |||
| 22672585c9 | |||
| fc8b71956c | |||
| 74fed469dc | |||
| efa6706226 | |||
| c1b6478ab5 | |||
| 17e8e3eff2 | |||
| 4b7e1d317d | |||
| b375c2a92a | |||
| 5275911b44 | |||
| 95ae096265 | |||
| 5835bb10d4 | |||
| db4f885bac | |||
| 683d45c28d | |||
| 13dd4566ce | |||
| 7c667eda53 | |||
| e575bded0e | |||
| 1a1838c114 | |||
| 5cc65fe46b | |||
| ff1453f868 | |||
| e3ee1b359e | |||
| 2683b8dfed | |||
| 15bb64c2ab | |||
| feaed2e3f6 | |||
| 347230f5d3 | |||
| f33efa2b6e | |||
| c61b77613a | |||
| 6b46915507 | |||
| 9c6f9410d2 | |||
| 31dadcee63 | |||
| 4ac803ab52 | |||
| 63fd838f55 | |||
| ade30e9c4e | |||
| 9c09e82cff | |||
| 71a4f3061b | |||
| 1227fab860 | |||
| 9a2a3cef8b | |||
| 3086c32a9a | |||
| 5249ae84ef | |||
| f3a689f284 | |||
| f17ff913d6 | |||
| 6f16b31771 | |||
| ab23fc9906 | |||
| 54a86dc6f5 | |||
| acf4669f73 | |||
| e042a7b555 | |||
| 96ee326424 | |||
| d6cdec40da | |||
| 49bd89de13 | |||
| 90ce5261b3 | |||
| 81be4e74b8 | |||
| 0321778d38 | |||
| b5fd55cce6 | |||
| b1ec9fc1ce | |||
| 29796762de | |||
| aa268f0f0e | |||
| 4a41de72ec | |||
| 524ef6b8c2 | |||
| 74f70614cc | |||
| c90070c497 | |||
| 5ec6d3562e | |||
| 9508ee749c | |||
| a0f456d4f4 | |||
| 9a22d9df92 | |||
| b8bd43f797 | |||
| 5d5ff44db2 | |||
| 448ea769eb | |||
| 49d6682fe7 | |||
| d57e90a32a | |||
| 52d2936f94 | |||
| 365879c722 | |||
| ba0f683e89 | |||
| e5ef451214 | |||
| b314e3f175 | |||
| cf1ab075ec | |||
| 03790f5509 | |||
| be095132b9 | |||
| 8b60d77dad | |||
| 5f45f0789f | |||
| 4812ec6c0b | |||
| b5e74030c0 | |||
| 9d3cbb4acc | |||
| fd4d38d31e | |||
| 535bd5dfff | |||
| 794a394b28 | |||
| 9dd901649c | |||
| d1cdc4d061 | |||
| 62746045af | |||
| be18bd6573 | |||
| a7fe0429f0 | |||
| 72bc6fb010 | |||
| d1428ef27c | |||
| 7318b98b8d | |||
| 132acc9863 | |||
| 0e2d8efd5c | |||
| a5729f1f97 | |||
| eac7306d59 | |||
| 183301bce9 | |||
| 721c8b96a4 | |||
| 1e5356e056 | |||
| 1c23620bd9 | |||
| 2dd540397d | |||
| 7044399da1 | |||
| 7a98800699 | |||
| 88b4c5b883 | |||
| a3a1d15d02 | |||
| 61f86d073d | |||
| 307ef11c0a | |||
| ab574952b1 | |||
| 67c79ec91c | |||
| 80f5a58806 | |||
| 0e9ddef4d8 | |||
| f967b8d3ff | |||
| 6ab39679e7 | |||
| d7f9f0a516 | |||
| 1bf377c14b | |||
| ccde7a4837 | |||
| 5d69452db3 | |||
| 322a357dee | |||
| abb9d7460f | |||
| 141f3acc85 | |||
| a00153fc99 | |||
| 3a416ccb7d | |||
| 4c8f080691 | |||
| 06be3500f8 |
@@ -0,0 +1,29 @@
|
||||
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
|
||||
# PR runs build the tests once and run them in 4 shards (.github/workflows/ci.yml).
|
||||
|
||||
[profile.ci]
|
||||
# Heavy tests (one to four minutes each on a CI runner) that pull requests skip. They run on
|
||||
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`.
|
||||
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
|
||||
default-filter = """
|
||||
not (
|
||||
(package(solvecraft-engine) & (
|
||||
test(=tests::hostile_params_never_panic)
|
||||
| test(/^tests::robustness::/)
|
||||
| test(=pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen)
|
||||
| test(=pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs)
|
||||
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
|
||||
))
|
||||
| (package(solvecraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
|
||||
| (package(solvecraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
|
||||
)
|
||||
"""
|
||||
fail-fast = false
|
||||
# Every test's time in the log, so the slow ones are easy to find.
|
||||
status-level = "pass"
|
||||
final-status-level = "slow"
|
||||
failure-output = "immediate-final"
|
||||
slow-timeout = { period = "60s" }
|
||||
|
||||
[profile.ci.junit]
|
||||
path = "junit.xml"
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,17 @@
|
||||
## 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.
|
||||
+46
-17
@@ -1,25 +1,54 @@
|
||||
SolveCraft is open-source parametric 3D CAD in Rust: sketch, constrain, extrude, and change a
|
||||
parameter to watch the timeline rebuild. [Documentation](https://bherbruck.github.io/solvecraft/)
|
||||
· [try it in the browser](https://bherbruck.github.io/solvecraft/app/) ·
|
||||
[contributing](https://github.com/bherbruck/solvecraft/blob/main/CONTRIBUTING.md) ·
|
||||
[issues](https://github.com/bherbruck/solvecraft/issues). AI agents can model with it over MCP:
|
||||
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solvecraft/guide/mcp.html)).
|
||||
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:
|
||||
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
|
||||
**Renamed to SolidCraft.** Downloads, installers and programs now carry the SolidCraft name:
|
||||
the desktop app is `solidcraft`, the command line `solidcraft-cli`. `solvecraft-cli` is still
|
||||
installed as a deprecated alias that forwards to `solidcraft-cli` (it prints a one-line note on
|
||||
stderr), so scripts and MCP setups such as `claude mcp add solvecraft -- solvecraft-cli mcp`
|
||||
keep working; new setups use `claude mcp add solidcraft -- solidcraft-cli mcp`. Existing designs
|
||||
open as before. Upgrades: the `.msi` upgrades an earlier install in place (it now installs to
|
||||
`Program Files\SolidCraft`); the `solidcraft` `.deb`/`.rpm` replaces the old `solvecraft`
|
||||
package; the Flatpak app ID `ai.storyteller.solvecraft` is unchanged, so Flatpak updates
|
||||
continue; earlier AppImages still find updates.
|
||||
|
||||
**Which file?**
|
||||
- **Windows:** `SolveCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
|
||||
- **Windows:** `SolidCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
|
||||
`-portable.zip` (x64, x86 and arm64).
|
||||
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag SolveCraft onto Applications.
|
||||
The CLI is in `solvecraft-cli-{version}-macos-universal.zip`.
|
||||
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolidCraft.app` onto Applications.
|
||||
The CLI is in `solidcraft-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.
|
||||
- **FreeBSD:** `tar -xzf solidcraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
|
||||
- **Web:** `solidcraft-web-{version}.zip` is a static site to host yourself.
|
||||
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`.
|
||||
|
||||
The builds aren't signed yet. On macOS, right-click SolveCraft in Applications and choose
|
||||
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolveCraft.app`).
|
||||
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
|
||||
Licences are built in (Help ▸ About ▸ Licences, `solvecraft-cli licences`); the packages also
|
||||
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
|
||||
[ROADMAP.md](https://github.com/bherbruck/solvecraft/blob/main/ROADMAP.md#known-limitations).
|
||||
**Updating macOS from SolveCraft.app:** quit the app, rename the existing app in
|
||||
Applications to `SolidCraft.app`, then drag the new app from the DMG onto Applications
|
||||
and choose **Replace**. If both names are already installed, move the old `SolveCraft.app`
|
||||
to the Trash. Remove any Dock shortcut with the old name and pin the new app. Designs
|
||||
and settings are unchanged. The DMG includes these instructions.
|
||||
|
||||
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 solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo
|
||||
flatpak install --user solidcraft 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 `SolidCraft.app` in Applications and choose
|
||||
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolidCraft.app`).
|
||||
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
|
||||
Licences are built in (Help ▸ About ▸ Licences, `solidcraft-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).
|
||||
|
||||
|
||||
+301
-10
@@ -1,27 +1,318 @@
|
||||
# Every pull request must pass `cargo xtask ci`: fmt, clippy -D warnings, tests in release mode,
|
||||
# the asset and layer checks, and the wasm build check.
|
||||
# Fast checks always run; native builds run for code/CI changes.
|
||||
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
|
||||
# skipped on pull requests and run on pushes to main and nightly.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "17 6 * * *"
|
||||
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
|
||||
# Every push to main gets its full run; a new PR push replaces the old run.
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
changes:
|
||||
name: Select checks
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
native: ${{ steps.paths.outputs.native }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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" != pull_request ]; 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
|
||||
|
||||
build-tests:
|
||||
name: Build tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
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
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- name: Compile tests into a nextest archive
|
||||
run: cargo nextest archive --workspace --cargo-profile ci --locked --archive-file tests.tar.zst
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
path: tests.tar.zst
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
tests:
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: build-tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
- name: Run tests
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
|
||||
all=()
|
||||
if [ "$EVENT_NAME" != pull_request ]; then all=(--ignore-default-filter); fi
|
||||
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
|
||||
# the build job's path, which is the same checkout path here.
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --partition "slice:$SHARD/4" "${all[@]}"
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: junit-${{ matrix.shard }}
|
||||
path: target/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
doc-tests:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
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: doc-tests
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# nextest does not run doc tests.
|
||||
- run: cargo test --workspace --profile ci --locked --doc
|
||||
|
||||
oracle:
|
||||
name: Reference tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
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: oracle
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
- name: Build the command line
|
||||
run: cargo build -p solidcraft-cli --profile ci --locked
|
||||
- name: Replay tests/oracle and compare with expected.json
|
||||
run: cargo xtask oracle --cli target/ci/solidcraft-cli
|
||||
- name: Upload the report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: oracle-report
|
||||
path: docs/oracle.md
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle]
|
||||
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 }}
|
||||
BUILD: ${{ needs.build-tests.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
DOCS: ${{ needs.doc-tests.result }}
|
||||
ORACLE: ${{ needs.oracle.result }}
|
||||
run: |
|
||||
test "$CHANGES" = success
|
||||
test "$CHECKS" = success
|
||||
if [ "$NATIVE" = true ]; then
|
||||
test "$CLIPPY" = success
|
||||
test "$BUILD" = success
|
||||
test "$TESTS" = success
|
||||
test "$DOCS" = success
|
||||
test "$ORACLE" = 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
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo xtask ci
|
||||
- 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
|
||||
@@ -0,0 +1,37 @@
|
||||
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"
|
||||
@@ -0,0 +1,47 @@
|
||||
# Optional, run by hand: rebuild the reference cases whose expected values come from FreeCAD
|
||||
# (tools/freecad-oracle/) and show what changed. FreeCAD runs as a separate program on the runner;
|
||||
# it is not a dependency of SolidCraft. Nothing is committed: review the diff and the uploaded
|
||||
# files, then commit them in a pull request if the change is intended.
|
||||
name: FreeCAD reference values
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cases:
|
||||
description: "Case directories (space separated); empty = every case whose source is FreeCAD"
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
regenerate:
|
||||
name: Regenerate FreeCAD expected values
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install FreeCAD
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends freecad
|
||||
freecadcmd --version || true
|
||||
- name: Rebuild the cases
|
||||
env:
|
||||
CASES: ${{ inputs.cases }}
|
||||
run: |
|
||||
if [ -z "$CASES" ]; then
|
||||
CASES=$(grep -l '"source": *"freecad' tests/oracle/*/expected.json | xargs -n1 dirname | tr '\n' ' ')
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
tools/freecad-oracle/run.sh --write $CASES
|
||||
- name: Show the changes
|
||||
run: git diff --stat && git diff -- tests/oracle/*/expected.json
|
||||
- name: Upload the regenerated files
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: freecad-expected
|
||||
path: |
|
||||
tests/oracle/*/expected.json
|
||||
tests/oracle/*/reference.stl
|
||||
@@ -24,17 +24,23 @@ jobs:
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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
|
||||
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: |
|
||||
@@ -46,8 +52,8 @@ jobs:
|
||||
}
|
||||
- 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*
|
||||
xmllint --noout packaging/windows/solidcraft.wxs packaging/windows/solidcraft.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'"
|
||||
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'] == 'solidcraft'"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# The documentation site on GitHub Pages: the book (docs/book) at /solvecraft/ and the web app
|
||||
# at /solvecraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
|
||||
# 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
|
||||
|
||||
@@ -7,6 +7,12 @@ 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
|
||||
@@ -28,11 +34,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
- name: Install mdbook and trunk
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
@@ -43,13 +49,24 @@ jobs:
|
||||
run: cargo xtask book
|
||||
- name: Build the web app
|
||||
working-directory: apps/solvecraft-web
|
||||
run: trunk build --release --public-url /solvecraft/app/
|
||||
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
|
||||
|
||||
+142
-72
@@ -1,9 +1,10 @@
|
||||
# Release pipeline, after PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
#
|
||||
# Push a tag `v<version>` (matching [workspace.package] version in Cargo.toml) and this builds and
|
||||
# packages SolveCraft for every platform and publishes the GitHub release "SolveCraft v<version>"
|
||||
# with SHA256SUMS.txt, .github/release-notes.md and GitHub's generated "What's Changed". A
|
||||
# version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
|
||||
# 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
|
||||
@@ -11,17 +12,19 @@
|
||||
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
|
||||
# Web a .zip of the static site packaging/web
|
||||
#
|
||||
# A dry run: Actions > Release > Run workflow (publish off) builds everything and keeps it as
|
||||
# the release-assets artifact instead of releasing.
|
||||
# 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:
|
||||
tags: ["v*"]
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: "Publish the release (off: a dry run; the files become the release-assets artifact)"
|
||||
draft:
|
||||
description: "Create/update a draft (release branch only; off: build artifacts only)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
@@ -40,38 +43,59 @@ 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@v4
|
||||
- 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_REF_TYPE" = tag ] && [ "${GITHUB_REF_NAME#v}" != "$VERSION" ]; then
|
||||
echo "::error::tag $GITHUB_REF_NAME doesn't match the Cargo.toml version $VERSION"
|
||||
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 "### SolveCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
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
|
||||
# The app icon in every size, drawn by solidcraft-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@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-icons
|
||||
- run: cargo build --release --locked -p solvecraft-cli
|
||||
- run: cargo build --release --locked -p solidcraft-cli
|
||||
- run: |
|
||||
. packaging/env.sh
|
||||
make_icons target/release/solvecraft-cli icons
|
||||
- uses: actions/upload-artifact@v4
|
||||
make_icons target/release/solidcraft-cli icons
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: icons
|
||||
path: icons
|
||||
@@ -80,6 +104,7 @@ jobs:
|
||||
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:
|
||||
@@ -89,11 +114,11 @@ jobs:
|
||||
- { 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 }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -102,7 +127,7 @@ jobs:
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
@@ -110,18 +135,23 @@ jobs:
|
||||
- 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'
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solidcraft$|usr/bin/solidcraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
|
||||
# Compatibility shims (docs/compatibility.md): the old CLI name and the package rename.
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft-cli$'
|
||||
dpkg-deb --field dist/release/*.deb Replaces Conflicts Provides | grep -F solvecraft
|
||||
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
|
||||
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
|
||||
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
|
||||
ldd target/release/solidcraft
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
# Legacy AppImage update alias (docs/compatibility.md).
|
||||
ls dist/release/solvecraft-*-linux-*.AppImage.zsync
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -130,6 +160,7 @@ jobs:
|
||||
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 }}
|
||||
@@ -140,39 +171,42 @@ jobs:
|
||||
- { 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 }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- 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
|
||||
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@v4
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/solidcraft-$SOLIDCRAFT_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 }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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
|
||||
@@ -182,7 +216,7 @@ jobs:
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "SOLVECRAFT_VERSION SOLVECRAFT_BUILD_DATE"
|
||||
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_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
|
||||
@@ -192,11 +226,11 @@ jobs:
|
||||
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
|
||||
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/solvecraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/solvecraft$|/bin/solvecraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solvecraft/THIRD-PARTY-LICENSES\.txt$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
tar -tzf dist/release/solidcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
@@ -205,21 +239,22 @@ jobs:
|
||||
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 }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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@v2
|
||||
- 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@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
@@ -228,6 +263,7 @@ jobs:
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: [version, icons]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -238,17 +274,17 @@ jobs:
|
||||
# Windows on ARM: cross-compiled on the x64 runner.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- 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@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Install WiX
|
||||
@@ -259,9 +295,9 @@ jobs:
|
||||
- name: Build and package (msi, portable zip)
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
@@ -270,16 +306,17 @@ jobs:
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
@@ -288,15 +325,16 @@ jobs:
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
- name: Build and zip
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: GitHub 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
|
||||
@@ -306,8 +344,8 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
@@ -316,28 +354,60 @@ jobs:
|
||||
run: |
|
||||
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
|
||||
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
|
||||
- name: Publish the release
|
||||
if: github.event_name == 'push' || inputs.publish
|
||||
- name: Publish nightly pre-release
|
||||
if: github.event_name == 'schedule'
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); else pre=(--latest); fi
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
echo "::error::Release $tag already exists. Bump the version before tagging."
|
||||
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
|
||||
gh release create "$tag" --title "SolveCraft v$VERSION" --verify-tag --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
|
||||
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 "Published **SolveCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
|
||||
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.publish
|
||||
if: github.event_name == 'workflow_dispatch' && !inputs.draft
|
||||
run: cp notes.md artifacts/release-notes.md
|
||||
- if: github.event_name == 'workflow_dispatch' && !inputs.publish
|
||||
uses: actions/upload-artifact@v4
|
||||
- if: github.event_name == 'workflow_dispatch' && !inputs.draft
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-assets
|
||||
path: artifacts/*
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
# SolveCraft — instructions for agents
|
||||
# SolidCraft — instructions for agents
|
||||
|
||||
SolveCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application in the style of
|
||||
Autodesk Fusion: sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
|
||||
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
|
||||
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
|
||||
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
|
||||
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. Projects published by
|
||||
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
|
||||
Any code adapted from other projects (MIT OR Apache-2.0) 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).
|
||||
and the relevant `plan/architecture.md` section.
|
||||
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
|
||||
@@ -20,7 +25,7 @@ code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust SolveCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
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
|
||||
@@ -36,18 +41,22 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
|
||||
`#![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
|
||||
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
|
||||
general CAD conventions and user feedback. Reference tests use analytic values and open-source
|
||||
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
|
||||
materials. File formats come from public specifications.
|
||||
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
|
||||
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
|
||||
measurements, command lists, ids or files produced with them.
|
||||
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
|
||||
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
|
||||
runs FreeCAD as a separate program through its public scripting API to compute reference values;
|
||||
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
|
||||
operations are our own.
|
||||
- **Assets — absolutely essential.** SolveCraft contains **no Autodesk iconography, images,
|
||||
fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any
|
||||
file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
|
||||
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
|
||||
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
|
||||
artwork from commercial CAD products — 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 commercial CAD 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
|
||||
@@ -64,15 +73,49 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
|
||||
assets, layers). Commit after every feature arc that builds.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solvecraft (private). After each commit that
|
||||
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
|
||||
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
|
||||
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
|
||||
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
|
||||
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
|
||||
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
|
||||
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
|
||||
generated reference data; 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).
|
||||
- `cargo run --release -p solidcraft -- --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}}}`
|
||||
@@ -82,11 +125,12 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
|
||||
`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`.
|
||||
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
|
||||
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
|
||||
values never come from a commercial CAD product.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
## 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`.
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
|
||||
commands by area, from the registry) when commands are added or renamed.
|
||||
|
||||
+41
-3
@@ -10,7 +10,7 @@ third-party material under an open licence. Screenshots of Autodesk software are
|
||||
|
||||
Generated-in-code assets are original and have no file to list:
|
||||
- the UI icon set (`crates/ui-egui/src/icons.rs`);
|
||||
- the view cube, navigation bar and timeline glyphs (`crates/ui-egui/src/viewport.rs`,
|
||||
- the axis view, navigation bar and timeline glyphs (`crates/ui-egui/src/viewport.rs`,
|
||||
`crates/ui-egui/src/timeline.rs`).
|
||||
|
||||
## Files
|
||||
@@ -18,13 +18,35 @@ Generated-in-code assets are original and have no file to list:
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo.gif | SolveCraft contributors | screen recording of SolveCraft itself driven by examples/demo (3x speed) | MIT OR Apache-2.0 |
|
||||
| docs/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 |
|
||||
| apps/solvecraft-web/fonts/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in apps/solvecraft-web/fonts/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
|
||||
| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/23-bracket-multifeature/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/23-bracket-multifeature/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/28-blind-pocket-fillets/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/28-blind-pocket-fillets/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/30-fillet-after-shell/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/30-fillet-after-shell/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/31-fillet-l-all-edges/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/31-fillet-l-all-edges/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/34-chamfer-then-fillet/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/34-chamfer-then-fillet/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/35-fillet-curved-curved/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/35-fillet-curved-curved/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/36-corner-blend-two-radii/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/36-corner-blend-two-radii/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/37-pyramid-apex-blend/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/37-pyramid-apex-blend/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/39-tee-pipe/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/39-tee-pipe/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/41-tube-radial-holes/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/41-tube-radial-holes/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/42-overlapping-patterns/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/42-overlapping-patterns/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/47-draft-curved-pocket/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/47-draft-curved-pocket/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/51-loft-rect-circle-offset/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/51-loft-rect-circle-offset/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/56-holes-on-curved-face/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/56-holes-on-curved-face/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/58-phone-stand/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/58-phone-stand/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/59-enclosure-base/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/59-enclosure-base/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/61-shaft-keyway/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/61-shaft-keyway/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
@@ -42,10 +64,26 @@ Generated-in-code assets are original and have no file to list:
|
||||
| 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/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; degenerate intersection-curve rejection; face and edge history of 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,17 +1,22 @@
|
||||
# SolveCraft — instructions for agents
|
||||
# SolidCraft — instructions for agents
|
||||
|
||||
SolveCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application in the style of
|
||||
Autodesk Fusion: sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
|
||||
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
|
||||
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
|
||||
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
|
||||
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. Projects published by
|
||||
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
|
||||
Any code adapted from other projects (MIT OR Apache-2.0) 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).
|
||||
and the relevant `plan/architecture.md` section.
|
||||
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
|
||||
@@ -20,7 +25,7 @@ code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust SolveCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
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
|
||||
@@ -36,18 +41,22 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
|
||||
`#![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
|
||||
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
|
||||
general CAD conventions and user feedback. Reference tests use analytic values and open-source
|
||||
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
|
||||
materials. File formats come from public specifications.
|
||||
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
|
||||
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
|
||||
measurements, command lists, ids or files produced with them.
|
||||
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
|
||||
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
|
||||
runs FreeCAD as a separate program through its public scripting API to compute reference values;
|
||||
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
|
||||
operations are our own.
|
||||
- **Assets — absolutely essential.** SolveCraft contains **no Autodesk iconography, images,
|
||||
fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any
|
||||
file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
|
||||
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
|
||||
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
|
||||
artwork from commercial CAD products — 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 commercial CAD 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
|
||||
@@ -64,15 +73,26 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
|
||||
assets, layers). Commit after every feature arc that builds.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solvecraft (private). After each commit that
|
||||
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
|
||||
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
|
||||
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
|
||||
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
|
||||
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
|
||||
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
|
||||
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
|
||||
generated reference data; 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).
|
||||
- `cargo run --release -p solidcraft -- --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}}}`
|
||||
@@ -82,11 +102,12 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
|
||||
`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`.
|
||||
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
|
||||
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
|
||||
values never come from a commercial CAD product.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
## 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`.
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
|
||||
commands by area, from the registry) when commands are added or renamed.
|
||||
|
||||
+117
-31
@@ -1,7 +1,7 @@
|
||||
# Contributing to SolveCraft
|
||||
# Contributing to SolidCraft
|
||||
|
||||
SolveCraft is an open-source parametric 3D CAD app written in pure Rust, in the style of
|
||||
Autodesk Fusion. You sketch on a plane, constrain the sketch, turn profiles into solids, and
|
||||
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.
|
||||
@@ -12,21 +12,57 @@ needed most.
|
||||
|
||||
## Get it running (about 10 minutes on a fast machine)
|
||||
|
||||
You need stable Rust 1.90 or newer. For the web
|
||||
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/solvecraft && cd solvecraft
|
||||
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
|
||||
cargo test --workspace --release # unit, engine and UI-scenario tests
|
||||
git clone https://github.com/bherbruck/solidcraft && cd solidcraft
|
||||
cargo run --release -p solidcraft -- --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 solidcraft`, or the CLI with
|
||||
`cargo build --release -p solidcraft-cli`. Run the CLI with
|
||||
`cargo run --release -p solidcraft-cli -- commands` (or `target/release/solidcraft-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`.
|
||||
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -52,13 +88,16 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
|
||||
## 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).
|
||||
1. **Independent.** SolidCraft is independent. Behaviour is designed from public
|
||||
documentation, general CAD conventions and user feedback. Reference tests use analytic values
|
||||
and open-source tools. We don't drive, script, automate or extract data from commercial CAD
|
||||
products, and we never copy another product's code, icons, artwork, help text, tooltips,
|
||||
templates or materials. Command ids are our own (`solid.fillet`).
|
||||
2. **No GPL/LGPL/AGPL code, not even reading it.** That rules out OpenCASCADE, FreeCAD
|
||||
(including planegcs), SolveSpace, CadQuery/OCP, LibreCAD and QCAD. SolveCraft is
|
||||
MIT OR Apache-2.0, and its solver and blends are written from the maths. Permissive
|
||||
(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. (Running an
|
||||
open-source application to get reference values for a test is fine; reading its source is
|
||||
not.) 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
|
||||
@@ -81,9 +120,9 @@ the most complete description of the house style.
|
||||
|
||||
| What | Where | Run |
|
||||
|---|---|---|
|
||||
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo test --workspace --release` |
|
||||
| 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) |
|
||||
| Reference tests | `cargo xtask oracle` | `tests/oracle/` (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.
|
||||
@@ -102,36 +141,83 @@ press keys, then assert on the result.
|
||||
A new `.json` file in that folder is picked up automatically. To record a bug before it's fixed,
|
||||
put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the bug.
|
||||
|
||||
**Release mode matters.** CI runs the tests with `--release`. truck has debug-only assertions
|
||||
that can fail on valid geometry, so if a test fails only in debug, say so in the PR.
|
||||
**A failing test without its fix can't merge as-is:** it would turn `main`'s CI red for everyone.
|
||||
Either include the fix in the same PR, or mark the test `#[ignore = "fails until #123 is fixed"]`
|
||||
(it can then merge right away), and remove that line in the PR that fixes the bug.
|
||||
|
||||
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
|
||||
SolveCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's
|
||||
measurements; [docs/oracle.md](docs/oracle.md) has the current results. The recipes and
|
||||
measurements live outside the repo (`plan/`, gitignored), because they come from a Fusion
|
||||
install. If you work on geometry and want to check against it, ask in an issue.
|
||||
**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.
|
||||
|
||||
**CI shards the tests.** One job builds them into a cargo-nextest archive and four jobs run a
|
||||
quarter each, so every test runs in its own process: don't rely on state left by another test.
|
||||
Pull requests skip a few heavy tests (one to four minutes each), which run on every push to
|
||||
`main` and nightly; check the `main` run after merging. They are listed in
|
||||
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
|
||||
|
||||
- `solvecraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness`,
|
||||
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
|
||||
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
|
||||
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`
|
||||
- `solvecraft-kernel`: `tests::boolean_identity_on_tangent_primitives`
|
||||
- `solvecraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
|
||||
|
||||
**The reference tests** are 76 parts in [`tests/oracle/`](tests/oracle/), each a `recipe.json`
|
||||
in our own format with an `expected.json` beside it. SolidCraft rebuilds the recipe and compares
|
||||
body count, volume, area, centre of mass and bounding box (and, where there is a `reference.stl`,
|
||||
the surface itself). Expected values are either worked out in closed form
|
||||
(`tools/oracle-analytic/`) or computed by building the same recipe in FreeCAD, run as a separate
|
||||
program (`tools/freecad-oracle/`); each file says which. [docs/oracle.md](docs/oracle.md) has
|
||||
the current results, and CI runs `cargo xtask oracle` on every pull request. A case that fails
|
||||
because of a known bug names its issue in `expected.json` (`"known_failure": "#123"`); never
|
||||
change an expected value to match SolidCraft's output.
|
||||
|
||||
## Sending a change
|
||||
|
||||
1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solvecraft/issues), labelled
|
||||
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, clippy (`-D warnings`), the tests in
|
||||
release mode, the asset and layer checks, and the wasm build check. A PR has to pass it.
|
||||
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).
|
||||
|
||||
Much of SolveCraft so far was written by AI coding agents (Claude) working in parallel, each
|
||||
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/solvecraft/labels/help%20wanted) and
|
||||
[`good first issue`](https://github.com/bherbruck/solvecraft/labels/good%20first%20issue) labels.
|
||||
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).
|
||||
@@ -140,7 +226,7 @@ In order of how much they'd help users:
|
||||
faces, and fillets that consume neighbouring faces.
|
||||
2. **Boolean robustness.** A random-design fuzzer currently gets about half of its designs
|
||||
through without a kernel error. Each failing seed is a reproducible bug.
|
||||
3. **Y-up / Z-up.** SolveCraft is Z-up only, so most STEP files (from SolidWorks, Onshape, or
|
||||
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
|
||||
@@ -151,4 +237,4 @@ In order of how much they'd help users:
|
||||
## Licence
|
||||
|
||||
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
|
||||
of SolveCraft. SolveCraft isn't affiliated with Autodesk. Fusion is a trademark of Autodesk, Inc.
|
||||
of SolidCraft. SolidCraft isn't affiliated with Autodesk. Fusion is a trademark of Autodesk, Inc.
|
||||
|
||||
Generated
+464
-24
@@ -452,6 +452,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -731,6 +740,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -771,6 +789,16 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -801,6 +829,16 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
@@ -819,6 +857,17 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.3"
|
||||
@@ -922,8 +971,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui-wgpu"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -958,6 +1005,7 @@ dependencies = [
|
||||
"serde",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"webbrowser",
|
||||
"winit",
|
||||
]
|
||||
|
||||
@@ -991,6 +1039,20 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
"rustc_version",
|
||||
"toml",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1199,6 +1261,15 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -1254,6 +1325,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
@@ -1484,6 +1565,19 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hidapi"
|
||||
version = "2.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "818c0e1d27887aaf76fe737042e27a66b796a7b099e6d2e1a72d106c2dff3fa6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
@@ -1508,6 +1602,110 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
@@ -1729,6 +1927,12 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
@@ -1753,8 +1957,6 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
[[package]]
|
||||
name = "matext4cgmath"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6541e181de37f70f0aceb25441823a3d0efa9cc1d23f475a0d3926678949178"
|
||||
dependencies = [
|
||||
"cgmath",
|
||||
"katexit",
|
||||
@@ -2479,6 +2681,15 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -2907,6 +3118,26 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -3058,21 +3289,25 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.1.2"
|
||||
name = "solidcraft"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"embed-resource",
|
||||
"hidapi",
|
||||
"log",
|
||||
"quick-xml 0.38.4",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"solvecraft-ui-egui",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.1.2"
|
||||
name = "solidcraft-cli"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3081,11 +3316,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.1.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solvecraft-geom",
|
||||
"solvecraft-kernel",
|
||||
"solvecraft-sketch",
|
||||
@@ -3095,7 +3331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.1.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3111,15 +3347,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.1.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.1.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
@@ -3131,7 +3368,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.1.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3146,7 +3383,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.1.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3154,16 +3391,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.1.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solvecraft-geom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.1.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3174,7 +3412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.1.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3183,12 +3421,13 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"ttf-parser",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.1.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3223,6 +3462,12 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
@@ -3261,6 +3506,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -3337,6 +3593,16 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
@@ -3347,10 +3613,25 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
name = "toml"
|
||||
version = "1.1.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -3369,13 +3650,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
version = "1.1.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
@@ -3568,6 +3855,12 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -3603,6 +3896,24 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.27.0"
|
||||
@@ -3647,6 +3958,26 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vswhom"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"vswhom-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vswhom-sys"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -3883,6 +4214,22 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webbrowser"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
|
||||
dependencies = [
|
||||
"jni",
|
||||
"log",
|
||||
"ndk-context",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"url",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -4416,12 +4763,28 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
@@ -4487,11 +4850,34 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.19.0"
|
||||
@@ -4618,6 +5004,60 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "3.0.0"
|
||||
|
||||
+24
-4
@@ -5,12 +5,12 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.2"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
authors = ["SolveCraft contributors"]
|
||||
repository = "https://github.com/bherbruck/solvecraft"
|
||||
rust-version = "1.95"
|
||||
authors = ["SolidCraft contributors"]
|
||||
repository = "https://github.com/bherbruck/solidcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
@@ -29,6 +29,7 @@ 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"
|
||||
@@ -72,6 +73,21 @@ 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"
|
||||
@@ -83,3 +99,7 @@ debug = 0
|
||||
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" }
|
||||
|
||||
@@ -19,8 +19,9 @@ 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.
|
||||
Most bundled assets are drawn in code or contributed originally; third-party fonts (Liberation Sans
|
||||
for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL OFL 1.1. 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
|
||||
|
||||
@@ -1,72 +1,134 @@
|
||||
# SolveCraft
|
||||
# SolidCraft
|
||||
|
||||
SolveCraft is an open-source parametric 3D CAD application written in Rust, in the style of
|
||||
Autodesk Fusion: sketch on a plane, constrain and dimension the sketch, turn profiles into solids
|
||||
with extrude and revolve, round edges, combine bodies, and change a parameter to watch the whole
|
||||
timeline rebuild. It is a clean-room implementation (see [CLAUDE.md](CLAUDE.md)) and part of the
|
||||
storytold "craft" family.
|
||||
**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.
|
||||
|
||||
*An agent builds an electronics enclosure over MCP: sketches on faces, shell, a port cut, one screw boss patterned to four, then width 80 → 100 mm in the Parameters dialog and the timeline rebuilds. 3× speed; [full video](https://github.com/bherbruck/solvecraft/releases/download/v0.1.0/enclosure-demo-v3.mp4), script in [examples/demo](examples/demo).*
|
||||

|
||||
|
||||
**Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide,
|
||||
*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/solvecraft/app/) to try it in the browser.
|
||||
[web app](https://bherbruck.github.io/solidcraft/app/) to try it in the browser.
|
||||
|
||||
**Status: early but usable for simple parts.** 76 of 76 reference parts built in Fusion
|
||||
rebuild in SolveCraft with the same volume, area and topology ([docs/oracle.md](docs/oracle.md));
|
||||
command parity with Fusion's Design workspace is tracked in [docs/parity.md](docs/parity.md).
|
||||
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solvecraft/issues),
|
||||
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.** 75 of 76 public reference parts
|
||||
match their analytic or FreeCAD-computed volume, area, centre of mass and bounding box
|
||||
([docs/oracle.md](docs/oracle.md)); the other is a tracked bug.
|
||||
These checks do not establish complete geometric or workflow equivalence. The commands SolidCraft has
|
||||
are listed by area in [docs/features.md](docs/features.md); status and plans are in [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.
|
||||
|
||||
On macOS, the DMG contains `SolidCraft.app`. To replace an older `SolveCraft.app`
|
||||
without installing a second copy, follow the [macOS upgrade instructions](https://bherbruck.github.io/solidcraft/guide/getting-started.html#macos).
|
||||
|
||||
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 the origin planes, offset planes or planar faces: lines, rectangles (2-point,
|
||||
3-point, centre), circles (centre, 2-point, 3-point), arcs (3-point, centre), polygons, slots
|
||||
and points; construction geometry.
|
||||
- **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.
|
||||
- **Features** on a parametric timeline: extrude (one side, flipped, symmetric, two-sided, start
|
||||
offset; new body, join, cut, intersect), revolve, fillet and chamfer (straight convex edges),
|
||||
box, cylinder, sphere, torus, combine, move. The timeline rebuilds incrementally from the first
|
||||
changed feature; it can be rolled back, and features can be suppressed, renamed, edited and
|
||||
deleted.
|
||||
- **Parameters** with unit-aware expressions (`2 * width + 5 mm`, `angle / 2`).
|
||||
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);
|
||||
need B-rep bodies). Convert Mesh reconstructs supported closed constant-section parts
|
||||
as parametric solids with planar and circular cylindrical faces;
|
||||
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
|
||||
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
|
||||
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
|
||||
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
|
||||
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
|
||||
view cube and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
|
||||
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, replay Fusion oracle recipes.
|
||||
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 run --release -p solidcraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solidcraft-cli -- commands # the command registry
|
||||
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png
|
||||
cargo run --release -p solidcraft -- part.step # open a STEP file as a new design
|
||||
cargo run --release -p solidcraft-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`.
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
On Windows and Linux, 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.
|
||||
|
||||
On Linux SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the
|
||||
device. A udev rule that adds the `uaccess` tag does that, for example
|
||||
`/etc/udev/rules.d/70-3d-mouse.rules` for devices with 3Dconnexion's vendor id:
|
||||
|
||||
```
|
||||
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess"
|
||||
```
|
||||
|
||||
Older SpaceNavigators use Logitech's vendor id `046d`; add a rule with their product id (see `lsusb`)
|
||||
instead of matching every Logitech device. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then replug the device. The Flatpak
|
||||
sandbox does not expose hidraw devices, so the 3D mouse only works with native builds there for now.
|
||||
Devices that send all six axes in HID report 1 (some homemade ones do) are recognised from their
|
||||
report descriptor, so they work as well.
|
||||
|
||||
In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
||||
@@ -110,10 +172,20 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
## 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. SolveCraft is not affiliated with Autodesk; Fusion is a
|
||||
trademark of Autodesk, Inc.
|
||||
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.
|
||||
|
||||
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
|
||||
conventions and user feedback. Reference tests use analytic values and open-source tools.
|
||||
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk. Autodesk and
|
||||
Fusion are trademarks of Autodesk, Inc.
|
||||
|
||||
+302
-31
@@ -1,21 +1,27 @@
|
||||
# SolveCraft roadmap
|
||||
# SolidCraft roadmap
|
||||
|
||||
Status as of **2026-10-09**. SolveCraft targets Fusion-style parametric modelling (sketch →
|
||||
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, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
|
||||
| Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) |
|
||||
| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) |
|
||||
| Tests | 596 (41 of them UI scenarios): solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command |
|
||||
| Where are we? | **M0 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. |
|
||||
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
|
||||
| Reference tests | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (55) or FreeCAD-computed (21) expected values, run by CI on every PR. |
|
||||
| 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. 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,
|
||||
@@ -31,35 +37,125 @@ In scope, in priority order:
|
||||
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.
|
||||
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | Milestone | Status | Hours left |
|
||||
|---|---|---|---|
|
||||
| M0 | Vertical slice | done | — |
|
||||
| M1 | Sketch depth: done — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs | done | 4 |
|
||||
| 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. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. 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) | planned | 30 |
|
||||
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 |
|
||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| 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 — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), 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. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | 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 (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 |
|
||||
| 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. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, 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: installers, signing, docs, performance | planned | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Left: configuration rules, per-row materials and appearances | in progress | 4 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/2"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
| 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
|
||||
|
||||
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
|
||||
arguments reject such a name when an older design's construction plane already has it. Use
|
||||
the other alias, the construction feature's numeric ID or an explicit plane; existing
|
||||
stored references keep their saved meaning. Section Analysis uses the same check.
|
||||
- Sketch text can be edited from a selected outline's context menu or the Edit Text command.
|
||||
Content, baseline X/Y, height and rotation update the original text source in one undo step;
|
||||
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
|
||||
use the document units captured when editing starts; generated outlines remain associated
|
||||
with their text source, and untouched inputs preserve their saved internal values.
|
||||
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
|
||||
waits for a distinct point without a degenerate-shape error or history entry. Typed input
|
||||
and programmatic commands retain their validation.
|
||||
- Circular-pattern and angled-plane axes reject explicitly supplied malformed or expression
|
||||
origins instead of silently rotating about zero. Numeric origins remain in millimetres;
|
||||
associative axis expressions and construction-axis references remain unsupported.
|
||||
- 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.
|
||||
- Sketch annotations, live drawing lengths and Measure use document units. Inches show
|
||||
three decimals and retain a meaningful fourth digit. Bare length dimension entry captures
|
||||
the current design unit; changing units leaves existing geometry and expressions intact.
|
||||
Component/canvas distance controls, sketch cursor coordinates, flat-pattern dimensions
|
||||
and pocket depths, interference volumes and parts-library lengths also use design units.
|
||||
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
|
||||
consumes complete point/curve selections consistently; mixed or ambiguous selections
|
||||
retain the picking workflow. Rejected constraints preserve selection and undo history.
|
||||
- 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
|
||||
|
||||
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
|
||||
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
|
||||
- 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; whole smooth loops of a planar face, picked whole or by one edge (tangent
|
||||
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;
|
||||
@@ -68,31 +164,206 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
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.
|
||||
edges, where straight or curved cap edges are trimmed to the contacts (the straight edge
|
||||
between two overlapping extruded arcs).
|
||||
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
|
||||
sections stacked end to end) are combined exactly in 2D; bodies touching along one whole face
|
||||
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. Fully internal voids are not supported. Spheres are built from six pole-free patches.
|
||||
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; offset surfaces and pcurve-only edges are not read yet,
|
||||
- STEP import (our own reader, crates/kernel/src/step_in) 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.
|
||||
- Persistent naming follows the kernel's exact face history (docs/book/src/dev/naming.md):
|
||||
references resolve by name first, follow splits and merges, and warn when the entity split,
|
||||
merged or vanished, or was found by geometry only (operations that report no history 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.
|
||||
- Free-form curves cut at a tangent touch (no clean crossing) enter that profile as polylines.
|
||||
- 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); 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
|
||||
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.
|
||||
- Project accepts complete supported preselection in one undo step. Visible external sketch
|
||||
curves and points retain their source sketch identity; model geometry, axes and planes use
|
||||
the existing linked projection commands. Rejection preserves the document and selection;
|
||||
mixed selections retain ordinary tool-first picking.
|
||||
- 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. 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).
|
||||
|
||||
## 3D sketch bridges
|
||||
|
||||
3D sketch drawing snaps to points on other visible sketch planes without flattening them onto
|
||||
the active plane. Bridge endpoints capture their source positions; associative endpoint links,
|
||||
construction-plane selection of 3D wires and loft guide rails remain separate work.
|
||||
Feature dialog header chevrons collapse and restore the form without discarding selections
|
||||
or edited values, including the Appearance panel.
|
||||
## Construction on 3D paths
|
||||
|
||||
Plane Along Path has a viewport path selector, fractional position, live preview and timeline
|
||||
editing. Construction planes and points resolve drawn 3D wires without applying their sketch
|
||||
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
|
||||
and reference-object placement remain separate work, as do loft rails and centerlines.
|
||||
|
||||
## Document-opening performance
|
||||
|
||||
- Desktop STEP import prepares consecutive independent solids in bounded batches with up
|
||||
to eight workers, retaining serial traversal, result order and persistent source identities.
|
||||
The web path remains serial. Original multi-solid fixtures show 64–81% lower complete
|
||||
import times; single-solid assembly parts are not scheduled across nodes. See
|
||||
[measurements and limits](docs/perf.md#parallel-step-body-preparation-2026-10-12).
|
||||
- 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
|
||||
|
||||
- Successful Boolean retries report their strategy and numerical adjustments as
|
||||
feature warnings and structured inspection data. Diagnostics survive cached
|
||||
evaluation, undo/redo and cold reopening; the robustness runner counts retry
|
||||
events by strategy and reports any bounded-log truncation. Existing geometry
|
||||
acceptance checks remain in force.
|
||||
|
||||
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
|
||||
polylines without panicking. Direct constructor regressions retain valid open endpoints
|
||||
and closed spherical intersections; the original interactive trigger remains unidentified.
|
||||
- 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.
|
||||
|
||||
+1554
-69
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
//! 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,17 +1,24 @@
|
||||
[package]
|
||||
name = "solvecraft-cli"
|
||||
description = "SolveCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
|
||||
name = "solidcraft-cli"
|
||||
description = "SolidCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
default-run = "solidcraft-cli"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "solvecraft-cli"
|
||||
name = "solidcraft-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Compatibility alias for the CLI's former name: forwards to solidcraft-cli with a deprecation
|
||||
# note on stderr. Shipped in every package for at least one release.
|
||||
[[bin]]
|
||||
name = "solvecraft-cli"
|
||||
path = "src/alias.rs"
|
||||
|
||||
[dependencies]
|
||||
solvecraft-engine = { workspace = true }
|
||||
solvecraft-mcp = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! `solvecraft-cli`: the CLI's former name, kept for at least one release so existing scripts
|
||||
//! and MCP client setups (`claude mcp add solvecraft -- solvecraft-cli mcp`) keep working.
|
||||
//! Compatibility shim: it runs `solidcraft-cli` (next to this executable, else on PATH) with the
|
||||
//! same arguments and standard streams, after a one-line deprecation note on stderr (stdout
|
||||
//! stays clean for MCP's JSON-RPC), and exits with its status.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
const TARGET: &str = "solidcraft-cli";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
eprintln!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
|
||||
let sibling = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|dir| dir.join(format!("{TARGET}{}", std::env::consts::EXE_SUFFIX))))
|
||||
.filter(|p| p.is_file());
|
||||
let program = sibling.map(|p| p.into_os_string()).unwrap_or_else(|| TARGET.into());
|
||||
match Command::new(&program).args(std::env::args_os().skip(1)).status() {
|
||||
Ok(status) => match status.code() {
|
||||
Some(code) => ExitCode::from(u8::try_from(code).unwrap_or(1)),
|
||||
None => ExitCode::FAILURE,
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! `solidcraft-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,
|
||||
"boolean_fallbacks": run.boolean_fallbacks,
|
||||
"boolean_reports_truncated": run.boolean_reports_truncated,
|
||||
"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 fallbacks: BTreeMap<String, [u64; 2]> = BTreeMap::new();
|
||||
let mut truncated = [0usize; 2];
|
||||
let mut steps = 0;
|
||||
let (mut dp, mut bp) = (0, 0);
|
||||
for v in results {
|
||||
for (i, group) in ["design", "boolean"].into_iter().enumerate() {
|
||||
if v[group]["boolean_reports_truncated"].as_bool().unwrap_or(false) {
|
||||
truncated[i] += 1;
|
||||
}
|
||||
if let Some(counts) = v[group]["boolean_fallbacks"].as_object() {
|
||||
for (strategy, count) in counts {
|
||||
if let Some(total) = fallbacks.entry(strategy.clone()).or_default().get_mut(i) {
|
||||
*total = total.saturating_add(count.as_u64().unwrap_or(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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 += "\nSuccessful Boolean retries (events, including nested operations):\n";
|
||||
for (strategy, [designs, checks]) in &fallbacks {
|
||||
s += &format!("- {strategy}: designs {designs}, boolean checks {checks}\n");
|
||||
}
|
||||
s += &format!("- Capped reports: designs {}, boolean checks {}\n", truncated[0], truncated[1]);
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn report_counts_retry_events_separately_and_marks_incomplete_logs() {
|
||||
let results = [
|
||||
json!({"design":{"outcome":{"kind":"ok"},"boolean_fallbacks":{"shifted_operands":2}},"boolean":{"outcome":"ok","boolean_fallbacks":{"shifted_operands":3},"boolean_reports_truncated":true}}),
|
||||
json!({"design":{"outcome":{"kind":"ok"},"boolean_fallbacks":{"shifted_operands":1}},"boolean":{"outcome":"ok"}}),
|
||||
json!({"timeout":90}),
|
||||
];
|
||||
let text = summarise(&results, Duration::ZERO);
|
||||
assert!(text.contains("shifted_operands: designs 3, boolean checks 3"), "{text}");
|
||||
assert!(text.contains("Capped reports: designs 0, boolean checks 1"), "{text}");
|
||||
assert!(text.contains("timeout: 1 (33.3 %)"), "{text}");
|
||||
}
|
||||
}
|
||||
+109
-68
@@ -1,44 +1,49 @@
|
||||
//! SolveCraft headless command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
|
||||
//! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
|
||||
//! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! solvecraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solvecraft-cli commands the command registry as JSON
|
||||
//! solvecraft-cli recipe <recipe.json> translate an oracle recipe to a script
|
||||
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with measure.json
|
||||
//! solvecraft-cli step-corpus <case-dir>... import part.step and compare with measure.json
|
||||
//! solvecraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
|
||||
//! 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)
|
||||
//! solidcraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
|
||||
//! solidcraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli bench-open <design.solvecraft | part.step> [--parse-only] loading timings as JSON lines
|
||||
//! solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solidcraft-cli commands the command registry as JSON
|
||||
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
|
||||
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
|
||||
//! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
|
||||
//! solidcraft-cli licences SolveCraft's licence and the third-party licences
|
||||
//! solidcraft-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;
|
||||
mod spatial;
|
||||
|
||||
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 snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solvecraft-cli exec <command> [json-params]
|
||||
solvecraft-cli commands
|
||||
solvecraft-cli recipe <recipe.json>
|
||||
solvecraft-cli oracle <case-dir>... [--json]
|
||||
solvecraft-cli step-corpus <case-dir>... [--json]
|
||||
solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
|
||||
solvecraft-cli licences
|
||||
solvecraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
solidcraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
|
||||
solidcraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
|
||||
solidcraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
|
||||
solidcraft-cli bench-open <design.solvecraft | part.step> [--parse-only]
|
||||
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solidcraft-cli exec <command> [json-params]
|
||||
solidcraft-cli commands
|
||||
solidcraft-cli recipe <recipe.json>
|
||||
solidcraft-cli oracle <case-dir>... [--json]
|
||||
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
|
||||
solidcraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
|
||||
solidcraft-cli licences
|
||||
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -49,15 +54,25 @@ fn main() -> ExitCode {
|
||||
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(|v| println!("{}", pretty(&v))).map_err(|e| e.to_string())
|
||||
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") => {
|
||||
@@ -70,8 +85,9 @@ fn main() -> ExitCode {
|
||||
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"));
|
||||
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.to_string()),
|
||||
@@ -90,7 +106,9 @@ fn pretty(v: &Value) -> String {
|
||||
}
|
||||
|
||||
pub fn read_json(path: &str) -> Result<Value, String> {
|
||||
let s = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
// 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}"))
|
||||
}
|
||||
|
||||
@@ -145,28 +163,31 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
Some(b) => load(b)?,
|
||||
None => Session::default(),
|
||||
};
|
||||
if args.iter().any(|a| a == "--results") {
|
||||
// Print every command's result (scripts only).
|
||||
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())?;
|
||||
println!("{}", pretty(&Value::Array(r)));
|
||||
return Ok(());
|
||||
}
|
||||
match (base, path) {
|
||||
(Some(_), Some(p)) => {
|
||||
let v = read_json(p)?;
|
||||
s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
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, 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 !args.iter().any(|a| a == "--quiet") {
|
||||
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));
|
||||
}
|
||||
@@ -207,7 +228,7 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The app icon: a blue block with a bore, drawn by the CPU renderer on a transparent background
|
||||
/// 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")?;
|
||||
@@ -225,29 +246,7 @@ fn cmd_icon(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
|
||||
use solvecraft_engine::render::{Rgb, Scene, SceneLine, SceneMesh, png, render_transparent};
|
||||
let mut s = Session::default();
|
||||
for (cmd, p) in [
|
||||
("solid.box", json!({"length": 20, "width": 20, "height": 20, "center": [0, 0, 0]})),
|
||||
("solid.cylinder", json!({"diameter": 9, "height": 30, "base": [0, 0, -15], "operation": "cut"})),
|
||||
] {
|
||||
s.execute(cmd, &p).map_err(|e| format!("icon: {e}"))?;
|
||||
}
|
||||
let mut cam = solvecraft_engine::view::home_camera(&s);
|
||||
cam.distance *= 0.95;
|
||||
let mut scene = Scene { radius: 60.0, ..Default::default() };
|
||||
let px = size as f64 / 256.0;
|
||||
for body in &s.world_state().bodies {
|
||||
let mesh = body.mesh();
|
||||
scene.meshes.push(SceneMesh { mesh: std::sync::Arc::clone(&mesh), color: Rgb(90, 160, 240) });
|
||||
for (i, e) in mesh.edges.iter().enumerate() {
|
||||
if !mesh.seams.get(i).copied().unwrap_or(false) {
|
||||
scene.lines.push(SceneLine { points: e.clone(), color: Rgb(24, 52, 96), width: (3.0 * px).max(0.8), on_top: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
let c = render_transparent(&mut scene, &cam, size, size);
|
||||
png(c.w, c.h, c.rgba).ok_or_else(|| "icon: PNG encoding failed".to_string())
|
||||
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).
|
||||
@@ -287,7 +286,13 @@ fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
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}"))?),
|
||||
(Some(addr), None) => Box::new(solvecraft_mcp::Remote::connect(addr).map_err(|e| {
|
||||
let port = addr.rsplit(':').next().filter(|p| p.parse::<u16>().is_ok()).unwrap_or("PORT");
|
||||
format!(
|
||||
"cannot connect to {addr}: {e}\nno SolidCraft app is listening there; start it with \
|
||||
`solvecraft --control {port}` (or SOLVECRAFT_CONTROL_PORT={port}) and retry"
|
||||
)
|
||||
})?),
|
||||
(None, Some(path)) => Box::new(solvecraft_mcp::Headless::new(load(path)?)),
|
||||
(None, None) => Box::new(solvecraft_mcp::Headless::default()),
|
||||
};
|
||||
@@ -300,7 +305,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
/// 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());
|
||||
return Err("usage: solidcraft-cli save-stress <path> <rounds>".into());
|
||||
};
|
||||
let mut s = Session::default();
|
||||
let filler = (0..3000).map(|i| solvecraft_engine::doc::Param {
|
||||
@@ -322,7 +327,7 @@ fn cmd_save_stress(args: &[String]) -> Result<(), String> {
|
||||
/// 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());
|
||||
return Err("usage: solidcraft-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 };
|
||||
@@ -347,3 +352,39 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
|
||||
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: solidcraft-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(())
|
||||
}
|
||||
|
||||
+154
-159
@@ -1,129 +1,149 @@
|
||||
//! Replay oracle recipes and compare with Fusion's measurements; import oracle STEP files and
|
||||
//! compare them with the same measurements.
|
||||
//! Reference tests: replay `tests/oracle/<case>/recipe.json` through the command engine and
|
||||
//! compare the part with `expected.json` (closed-form values, or values from an independent
|
||||
//! kernel; see tests/oracle/README.md), and with `reference.stl` where one exists.
|
||||
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
|
||||
use crate::{read_json, recipe, seams};
|
||||
use crate::spatial::{self, Tri};
|
||||
use crate::{read_json, recipe};
|
||||
|
||||
/// Relative tolerance for volume and area (our curved-face tessellation is within ~2e-4).
|
||||
/// Default relative tolerance for volume and area (our curved-face tessellation is within ~2e-4).
|
||||
pub const REL_TOL: f64 = 1e-3;
|
||||
/// Default tolerance for centre of mass and bounding box, as a fraction of the bbox diagonal.
|
||||
const ABS_FRAC: f64 = 1e-3;
|
||||
/// Chord tolerance used to triangulate our part for the surface-distance check (mm).
|
||||
const MESH_TOL: f64 = 0.01;
|
||||
/// Relative tolerance on the principal second moments of the two triangulations.
|
||||
const INERTIA_REL: f64 = 2e-3;
|
||||
|
||||
/// Cases compared loosely (relative tolerance on volume and area, topology not compared), and why.
|
||||
const APPROXIMATE: [(&str, f64, &str); 1] = [(
|
||||
"49-helical-sweep",
|
||||
5e-3,
|
||||
"Fusion sweeps along a spline fitted through helix points; SolveCraft builds the true helix (a coil), so the size is compared within 0.5% and the topology not at all",
|
||||
)];
|
||||
/// What SolidCraft built: totals over every body.
|
||||
struct Got {
|
||||
body_count: usize,
|
||||
volume: f64,
|
||||
area: f64,
|
||||
com: [f64; 3],
|
||||
bbox: ([f64; 3], [f64; 3]),
|
||||
faces: u64,
|
||||
}
|
||||
|
||||
fn v3(v: &Value) -> Option<[f64; 3]> {
|
||||
let a = v.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
|
||||
}
|
||||
|
||||
fn totals(m: &Value) -> Got {
|
||||
let bodies = m["bodies"].as_array().cloned().unwrap_or_default();
|
||||
let (mut volume, mut area, mut faces) = (0.0, 0.0, 0);
|
||||
let mut moment = [0.0; 3];
|
||||
let mut lo = [f64::INFINITY; 3];
|
||||
let mut hi = [f64::NEG_INFINITY; 3];
|
||||
for b in &bodies {
|
||||
let v = b["volume_mm3"].as_f64().unwrap_or(f64::NAN);
|
||||
volume += v;
|
||||
area += b["area_mm2"].as_f64().unwrap_or(f64::NAN);
|
||||
faces += b["faces"].as_u64().unwrap_or(0);
|
||||
let c = v3(&b["center_of_mass"]).unwrap_or([f64::NAN; 3]);
|
||||
let (bl, bh) = (v3(&b["bbox"]["min"]).unwrap_or([f64::NAN; 3]), v3(&b["bbox"]["max"]).unwrap_or([f64::NAN; 3]));
|
||||
for k in 0..3 {
|
||||
moment[k] += v * c[k];
|
||||
lo[k] = lo[k].min(bl[k]);
|
||||
hi[k] = hi[k].max(bh[k]);
|
||||
}
|
||||
}
|
||||
let com = moment.map(|x| x / volume);
|
||||
Got { body_count: bodies.len(), volume, area, com, bbox: (lo, hi), faces }
|
||||
}
|
||||
|
||||
struct Checks {
|
||||
list: Vec<Value>,
|
||||
pass: bool,
|
||||
}
|
||||
|
||||
impl Checks {
|
||||
fn push(&mut self, what: &str, got: Value, want: Value, ok: bool) {
|
||||
self.pass &= ok;
|
||||
self.list.push(json!({"check": what, "got": got, "want": want, "ok": ok}));
|
||||
}
|
||||
fn rel(&mut self, what: &str, g: f64, w: Option<f64>, rel: f64) {
|
||||
let ok = w.is_some_and(|w| g.is_finite() && (g - w).abs() <= rel * w.abs().max(1.0));
|
||||
self.push(what, json!(g), json!(w), ok);
|
||||
}
|
||||
fn vec(&mut self, what: &str, g: [f64; 3], w: Option<[f64; 3]>, tol: f64) {
|
||||
let ok = w.is_some_and(|w| g.iter().zip(w).all(|(g, w)| g.is_finite() && (g - w).abs() <= tol));
|
||||
self.push(what, json!(g), json!(w), ok);
|
||||
}
|
||||
}
|
||||
|
||||
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 recipe = match read_json(&format!("{dir}/recipe.json")) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e}),
|
||||
};
|
||||
let want = read_json(&format!("{dir}/expected.json")).ok();
|
||||
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()});
|
||||
return json!({"case": name, "pass": false, "error": e.to_string(), "source": want.as_ref().map(|w| w["source"].clone())});
|
||||
}
|
||||
let got = match s.execute("inspect.measure", &json!({})) {
|
||||
Ok(m) => m,
|
||||
Ok(m) => totals(&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}));
|
||||
let got_json = json!({
|
||||
"body_count": got.body_count, "volume_mm3": got.volume, "area_mm2": got.area,
|
||||
"center_of_mass": got.com, "bbox": {"min": got.bbox.0, "max": got.bbox.1}, "faces": got.faces,
|
||||
});
|
||||
let Some(want) = want else {
|
||||
return json!({"case": name, "pass": false, "skipped": true, "error": "no expected.json yet", "got": got_json});
|
||||
};
|
||||
// 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 only: Option<Vec<String>> = want["compare"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());
|
||||
let wanted = |k: &str| want.get(k).is_some_and(|v| !v.is_null()) && only.as_ref().is_none_or(|o| o.iter().any(|x| x == k));
|
||||
let rel = want["tolerance"]["rel"].as_f64().unwrap_or(REL_TOL);
|
||||
let diag = |b: ([f64; 3], [f64; 3])| ((b.1[0] - b.0[0]).powi(2) + (b.1[1] - b.0[1]).powi(2) + (b.1[2] - b.0[2]).powi(2)).sqrt();
|
||||
let want_bbox = v3(&want["bbox"]["min"]).zip(v3(&want["bbox"]["max"]));
|
||||
let abs = want["tolerance"]["abs_mm"].as_f64().unwrap_or_else(|| ABS_FRAC * want_bbox.map(diag).unwrap_or_else(|| diag(got.bbox)).max(1.0));
|
||||
let mut c = Checks { list: Vec::new(), pass: true };
|
||||
if wanted("body_count") {
|
||||
let ok = want["body_count"].as_u64() == Some(got.body_count as u64);
|
||||
c.push("body_count", json!(got.body_count), want["body_count"].clone(), ok);
|
||||
}
|
||||
let mut seam_note = None;
|
||||
if approx.is_none() {
|
||||
let exact = ["faces", "edges", "vertices"].iter().all(|k| got["total"][k].as_f64().is_some() && got["total"][k] == want["total"][k]);
|
||||
let fusion_seams = want["bodies"]
|
||||
.as_array()
|
||||
.and_then(|bs| bs.iter().map(seams::normalised).try_fold((0, 0, 0), |a, n| n.map(|n| (a.0 + n.0, a.1 + n.1, a.2 + n.2))));
|
||||
match fusion_seams {
|
||||
// Size matches and only the topology differs: compare with Fusion's seam splits merged.
|
||||
Some((f, e, v)) if size_ok && !exact => {
|
||||
let raw: Vec<String> = ["faces", "edges", "vertices"]
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let ours: i64 = got["bodies"].as_array().into_iter().flatten().filter_map(|b| b["kernel"][k].as_i64()).sum();
|
||||
format!("{k} {ours}/{}", want["total"][k])
|
||||
})
|
||||
.collect();
|
||||
seam_note = Some(format!(
|
||||
"topology compared seam-normalised: Fusion's {}/{}/{} faces/edges/vertices become {f}/{e}/{v} with closed-surface patches counted once and seam edges and vertices dropped (raw SolveCraft/Fusion: {})",
|
||||
want["total"]["faces"],
|
||||
want["total"]["edges"],
|
||||
want["total"]["vertices"],
|
||||
raw.join(", ")
|
||||
));
|
||||
num("faces (seam-normalised)", got["total"]["faces"].as_f64(), Some(f as f64), 0.0);
|
||||
num("edges (seam-normalised)", got["total"]["edges"].as_f64(), Some(e as f64), 0.0);
|
||||
num("vertices (seam-normalised)", got["total"]["vertices"].as_f64(), Some(v as f64), 0.0);
|
||||
}
|
||||
_ => {
|
||||
num("faces", got["total"]["faces"].as_f64(), want["total"]["faces"].as_f64(), 0.0);
|
||||
num("edges", got["total"]["edges"].as_f64(), want["total"]["edges"].as_f64(), 0.0);
|
||||
num("vertices", got["total"]["vertices"].as_f64(), want["total"]["vertices"].as_f64(), 0.0);
|
||||
}
|
||||
}
|
||||
if wanted("volume_mm3") {
|
||||
c.rel("volume_mm3", got.volume, want["volume_mm3"].as_f64(), rel);
|
||||
}
|
||||
if wanted("area_mm2") {
|
||||
c.rel("area_mm2", got.area, want["area_mm2"].as_f64(), rel);
|
||||
}
|
||||
if wanted("center_of_mass") {
|
||||
c.vec("center_of_mass", got.com, v3(&want["center_of_mass"]), abs);
|
||||
}
|
||||
if wanted("bbox") {
|
||||
c.vec("bbox.min", got.bbox.0, want_bbox.map(|b| b.0), abs);
|
||||
c.vec("bbox.max", got.bbox.1, want_bbox.map(|b| b.1), abs);
|
||||
}
|
||||
if wanted("faces") {
|
||||
let ok = want["faces"].as_u64() == Some(got.faces);
|
||||
c.push("faces", json!(got.faces), want["faces"].clone(), ok);
|
||||
}
|
||||
let stl = format!("{dir}/reference.stl");
|
||||
if std::path::Path::new(&stl).is_file() && only.as_ref().is_none_or(|o| o.iter().any(|x| x == "surface")) {
|
||||
let tol = want["tolerance"]["surface_mm"].as_f64().unwrap_or(0.05);
|
||||
reference_checks(&s, &stl, tol, &mut c);
|
||||
}
|
||||
// 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 {
|
||||
let (ok, note) = step_export_check(&s, got.volume);
|
||||
c.push("step_export", json!(note), json!("valid, re-imports cleanly"), ok);
|
||||
let mut out = json!({"case": name, "pass": c.pass, "source": want["source"], "checks": c.list, "got": got_json});
|
||||
if let Some(n) = want["note"].as_str() {
|
||||
out["note"] = json!(n);
|
||||
}
|
||||
if area_off {
|
||||
out["note"] = json!(
|
||||
"area not compared: Fusion reports this tight sweep's horn torus face at three times its area (π²·R·r quarter-turn value; 740 mm² against 246.7 by Pappus); volume and topology are compared"
|
||||
);
|
||||
}
|
||||
let effective: Vec<String> = recipe["features"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|f| f.get("fusion_effective_extent").is_some())
|
||||
.map(|f| f["name"].as_str().unwrap_or("?").to_string())
|
||||
.collect();
|
||||
if !effective.is_empty() {
|
||||
out["note"] = json!(format!(
|
||||
"known Fusion difference: {} replayed with the extent Fusion actually used (its through-all is sized as 1.1 × the farthest vertex, so curved tops stop it short; SolveCraft's through-all goes truly through)",
|
||||
effective.join(", ")
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -163,68 +183,43 @@ fn step_export_check(s: &solvecraft_engine::Session, volume: f64) -> (bool, Stri
|
||||
}
|
||||
}
|
||||
|
||||
/// Cases whose Fusion area is not the geometric one: Fusion reports the horn torus face of a
|
||||
/// tight sweep (tube touching its axis) at three times π²·R·r, its quarter-turn area; the
|
||||
/// volumes agree. Their area is not compared.
|
||||
const FUSION_AREA_OFF: &[&str] = &["50-sweep-tight-bend"];
|
||||
|
||||
/// Import a case's `part.step` (as File → Open does) and compare with Fusion's measurements:
|
||||
/// body count, volume and area (relative 1e-3) and the faces read from the file.
|
||||
fn check_step_case(dir: &str) -> Value {
|
||||
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let sp = format!("{dir}/part.step");
|
||||
let want = match read_json(&format!("{dir}/measure.json")) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e}),
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = solvecraft_engine::Session::default();
|
||||
let opened = match s.execute("doc.open", &json!({"path": sp})) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
|
||||
};
|
||||
let got = match s.execute("inspect.measure", &json!({})) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
|
||||
};
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
// Faces read from the file (the kernel may split a face that wraps around a closed surface).
|
||||
let file_faces = std::fs::read(&sp)
|
||||
.ok()
|
||||
.and_then(|b| solvecraft_engine::kernel::step_import_shared(&String::from_utf8_lossy(&b)).ok())
|
||||
.map(|imp| imp.bodies.iter().map(|b| b.file_faces).sum::<usize>() as f64);
|
||||
let mut checks = Vec::new();
|
||||
let mut pass = true;
|
||||
let mut num = |what: &str, g: Option<f64>, w: Option<f64>, rel: f64| {
|
||||
let ok = match (g, w) {
|
||||
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
|
||||
_ => false,
|
||||
};
|
||||
pass &= ok;
|
||||
checks.push(json!({"check": what, "got": g, "want": w, "ok": ok}));
|
||||
};
|
||||
num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0);
|
||||
num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), REL_TOL);
|
||||
if !FUSION_AREA_OFF.contains(&name.as_str()) {
|
||||
num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), REL_TOL);
|
||||
/// Our part as a triangle soup (curved faces refined onto the surface).
|
||||
fn our_mesh(s: &solvecraft_engine::Session) -> Result<Vec<Tri>, String> {
|
||||
let mut ours: Vec<Tri> = Vec::new();
|
||||
for b in &s.world_state().bodies {
|
||||
let m = b.body.tessellate_with(MESH_TOL, true).map_err(|e| e.to_string())?;
|
||||
let p = |i: u32| m.positions.get(i as usize).map(|v: &Vec3| [v.x, v.y, v.z]);
|
||||
for t in &m.triangles {
|
||||
if let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) {
|
||||
ours.push([a, b, c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
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"]},
|
||||
})
|
||||
if ours.is_empty() { Err("empty mesh".into()) } else { Ok(ours) }
|
||||
}
|
||||
|
||||
pub fn step_corpus(args: &[String]) -> Result<(), String> {
|
||||
let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
|
||||
if dirs.is_empty() {
|
||||
return Err("step-corpus: give one or more case directories (each with part.step and measure.json)".into());
|
||||
}
|
||||
let results: Vec<Value> = dirs.iter().map(|d| check_step_case(d)).collect();
|
||||
report(args, &results);
|
||||
let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false)).count();
|
||||
if failed > 0 { Err(format!("{failed} STEP case(s) failed")) } else { Ok(()) }
|
||||
/// Compare our part with the reference triangulation: the symmetric surface distance, and the
|
||||
/// principal second moments of volume of both meshes (the shape's inertia, independent of how it
|
||||
/// sits in space).
|
||||
fn reference_checks(s: &solvecraft_engine::Session, stl: &str, tol: f64, c: &mut Checks) {
|
||||
let (ours, reference) = match (our_mesh(s), spatial::read_stl(stl)) {
|
||||
(Ok(o), Ok(r)) if !r.is_empty() => (o, r),
|
||||
(Err(e), _) | (_, Err(e)) => return c.push("surface_distance_mm", json!(e), json!(format!("<= {tol}")), false),
|
||||
_ => return c.push("surface_distance_mm", json!("empty reference.stl"), json!(format!("<= {tol}")), false),
|
||||
};
|
||||
let (lo, hi) = spatial::bounds(&reference);
|
||||
let size = (0..3).map(|k| hi[k] - lo[k]).fold(0.0, f64::max);
|
||||
let h = spatial::hausdorff(&ours, &reference, size.max(1.0));
|
||||
let worst = h.a_to_b.max(h.b_to_a);
|
||||
c.push(
|
||||
"surface_distance_mm",
|
||||
json!({"max": worst, "mean": h.mean, "ours_to_reference": h.a_to_b, "reference_to_ours": h.b_to_a, "worst_at": h.worst_at}),
|
||||
json!(format!("<= {tol}")),
|
||||
worst <= tol,
|
||||
);
|
||||
let ((_, _, pa), (_, _, pb)) = (spatial::mass_properties(&ours), spatial::mass_properties(&reference));
|
||||
let ok = pa.iter().zip(pb).all(|(a, b)| (a - b).abs() <= INERTIA_REL * b.abs().max(1.0));
|
||||
c.push("principal_moments_mm5", json!(pa), json!(pb), ok);
|
||||
}
|
||||
|
||||
fn report(args: &[String], results: &[Value]) {
|
||||
@@ -259,7 +254,7 @@ fn report(args: &[String], results: &[Value]) {
|
||||
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());
|
||||
return Err("oracle: give one or more case directories (each with recipe.json and expected.json)".into());
|
||||
}
|
||||
let results: Vec<Value> = dirs.iter().map(|d| check_case(d)).collect();
|
||||
report(args, &results);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Oracle recipes (`plan/fusion/oracle/*/recipe.json`) → SolveCraft command scripts.
|
||||
//! Reference-test recipes (`tests/oracle/*/recipe.json`) → SolidCraft 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.
|
||||
@@ -10,7 +10,7 @@ fn plane_param(pl: &Value) -> Value {
|
||||
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])),
|
||||
"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,
|
||||
};
|
||||
@@ -85,14 +85,6 @@ fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
|
||||
|
||||
/// 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);
|
||||
@@ -545,8 +537,8 @@ fn edge_points(f: &Value) -> Value {
|
||||
)
|
||||
}
|
||||
|
||||
/// Fusion names a pattern's source features with an occurrence suffix ("Hole1 (1)"); the
|
||||
/// features are the recipe's own names.
|
||||
/// A pattern's source features may carry 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)
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Geometry comparison helpers: an STL reader, bounding boxes, volume, centre of mass and principal
|
||||
//! second moments of a closed triangle mesh, and a sampled symmetric Hausdorff distance between two
|
||||
//! triangle meshes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A triangle soup in mm.
|
||||
pub type Tri = [[f64; 3]; 3];
|
||||
|
||||
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
||||
}
|
||||
fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
|
||||
[a[0] * s, a[1] * s, a[2] * s]
|
||||
}
|
||||
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
/// Read a binary (or ASCII) STL file.
|
||||
pub fn read_stl(path: &str) -> Result<Vec<Tri>, String> {
|
||||
let b = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if b.len() >= 84 {
|
||||
let n = u32::from_le_bytes([b[80], b[81], b[82], b[83]]) as usize;
|
||||
if b.len() == 84 + 50 * n {
|
||||
let f = |o: usize| f64::from(f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]));
|
||||
return Ok((0..n)
|
||||
.map(|i| {
|
||||
let o = 84 + 50 * i + 12;
|
||||
let v = |k: usize| [f(o + 12 * k), f(o + 12 * k + 4), f(o + 12 * k + 8)];
|
||||
[v(0), v(1), v(2)]
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8_lossy(&b);
|
||||
let mut verts = Vec::new();
|
||||
for line in text.lines() {
|
||||
let mut it = line.split_whitespace();
|
||||
if it.next() == Some("vertex") {
|
||||
let c: Vec<f64> = it.filter_map(|x| x.parse().ok()).collect();
|
||||
if c.len() == 3 {
|
||||
verts.push([c[0], c[1], c[2]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if verts.is_empty() || verts.len() % 3 != 0 {
|
||||
return Err(format!("{path}: not an STL file"));
|
||||
}
|
||||
Ok(verts.chunks(3).map(|c| [c[0], c[1], c[2]]).collect())
|
||||
}
|
||||
|
||||
/// Volume, centre of mass and the principal second moments of volume (mm^5, sorted ascending) of a
|
||||
/// closed, outward-oriented triangle soup, by signed tetrahedra from the origin.
|
||||
pub fn mass_properties(tris: &[Tri]) -> (f64, [f64; 3], [f64; 3]) {
|
||||
let (mut v6, mut m1) = (0.0, [0.0; 3]);
|
||||
let mut c = [[0.0; 3]; 3]; // ∫ x xᵀ dV
|
||||
for t in tris {
|
||||
let d = dot(t[0], cross(t[1], t[2]));
|
||||
v6 += d;
|
||||
let s = add(add(t[0], t[1]), t[2]);
|
||||
m1 = add(m1, scale(s, d / 24.0));
|
||||
for (i, row) in c.iter_mut().enumerate() {
|
||||
for (j, cell) in row.iter_mut().enumerate() {
|
||||
let vv: f64 = t.iter().map(|p| p[i] * p[j]).sum::<f64>() + s[i] * s[j];
|
||||
*cell += d / 120.0 * vv;
|
||||
}
|
||||
}
|
||||
}
|
||||
let vol = v6 / 6.0;
|
||||
if vol.abs() < 1e-12 {
|
||||
return (0.0, [0.0; 3], [0.0; 3]);
|
||||
}
|
||||
let com = scale(m1, 1.0 / vol);
|
||||
// Central covariance and inertia tensor I = tr(C)·1 − C.
|
||||
let mut cc = c;
|
||||
for (i, row) in cc.iter_mut().enumerate() {
|
||||
for (j, cell) in row.iter_mut().enumerate() {
|
||||
*cell -= vol * com[i] * com[j];
|
||||
}
|
||||
}
|
||||
let tr = cc[0][0] + cc[1][1] + cc[2][2];
|
||||
let mut inertia = [[0.0; 3]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
inertia[i][j] = if i == j { tr - cc[i][j] } else { -cc[i][j] };
|
||||
}
|
||||
}
|
||||
let mut ev = eigen_sym3(inertia);
|
||||
ev.sort_by(f64::total_cmp);
|
||||
(vol, com, ev)
|
||||
}
|
||||
|
||||
/// Eigenvalues of a symmetric 3×3 matrix (Jacobi rotations).
|
||||
fn eigen_sym3(mut a: [[f64; 3]; 3]) -> [f64; 3] {
|
||||
for _ in 0..50 {
|
||||
let (mut p, mut q, mut big) = (0, 1, 0.0);
|
||||
for (i, j) in [(0, 1), (0, 2), (1, 2)] {
|
||||
if a[i][j].abs() > big {
|
||||
(p, q, big) = (i, j, a[i][j].abs());
|
||||
}
|
||||
}
|
||||
if big <= 1e-15 * (a[0][0].abs() + a[1][1].abs() + a[2][2].abs()).max(1e-300) {
|
||||
break;
|
||||
}
|
||||
let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
|
||||
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
|
||||
let t = if theta == 0.0 { 1.0 } else { t };
|
||||
let (cs, sn) = (1.0 / (t * t + 1.0).sqrt(), t / (t * t + 1.0).sqrt());
|
||||
let mut b = a;
|
||||
for k in 0..3 {
|
||||
b[k][p] = cs * a[k][p] - sn * a[k][q];
|
||||
b[k][q] = sn * a[k][p] + cs * a[k][q];
|
||||
}
|
||||
let mut c2 = b;
|
||||
for k in 0..3 {
|
||||
c2[p][k] = cs * b[p][k] - sn * b[q][k];
|
||||
c2[q][k] = sn * b[p][k] + cs * b[q][k];
|
||||
}
|
||||
a = c2;
|
||||
}
|
||||
[a[0][0], a[1][1], a[2][2]]
|
||||
}
|
||||
|
||||
/// Axis-aligned bounds of a triangle soup.
|
||||
pub fn bounds(tris: &[Tri]) -> ([f64; 3], [f64; 3]) {
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for p in tris.iter().flatten() {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(p[k]);
|
||||
hi[k] = hi[k].max(p[k]);
|
||||
}
|
||||
}
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
/// Distance from a point to a triangle (Ericson, Real-Time Collision Detection 5.1.5).
|
||||
fn point_tri(p: [f64; 3], t: &Tri) -> f64 {
|
||||
let (a, b, c) = (t[0], t[1], t[2]);
|
||||
let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a));
|
||||
let (d1, d2) = (dot(ab, ap), dot(ac, ap));
|
||||
let q = if d1 <= 0.0 && d2 <= 0.0 {
|
||||
a
|
||||
} else {
|
||||
let bp = sub(p, b);
|
||||
let (d3, d4) = (dot(ab, bp), dot(ac, bp));
|
||||
if d3 >= 0.0 && d4 <= d3 {
|
||||
b
|
||||
} else {
|
||||
let vc = d1 * d4 - d3 * d2;
|
||||
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
|
||||
add(a, scale(ab, d1 / (d1 - d3)))
|
||||
} else {
|
||||
let cp = sub(p, c);
|
||||
let (d5, d6) = (dot(ab, cp), dot(ac, cp));
|
||||
if d6 >= 0.0 && d5 <= d6 {
|
||||
c
|
||||
} else {
|
||||
let vb = d5 * d2 - d1 * d6;
|
||||
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
|
||||
add(a, scale(ac, d2 / (d2 - d6)))
|
||||
} else {
|
||||
let va = d3 * d6 - d5 * d4;
|
||||
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
|
||||
add(b, scale(sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6))))
|
||||
} else {
|
||||
let den = va + vb + vc;
|
||||
if den.abs() < 1e-300 { a } else { add(a, add(scale(ab, vb / den), scale(ac, vc / den))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let d = sub(p, q);
|
||||
dot(d, d).sqrt()
|
||||
}
|
||||
|
||||
/// Uniform grid of triangles for distance queries up to `cap`.
|
||||
struct Grid<'a> {
|
||||
tris: &'a [Tri],
|
||||
cell: f64,
|
||||
cells: HashMap<(i64, i64, i64), Vec<u32>>,
|
||||
}
|
||||
|
||||
impl<'a> Grid<'a> {
|
||||
fn new(tris: &'a [Tri], cell: f64) -> Self {
|
||||
let mut cells: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
|
||||
let key = |x: f64| (x / cell).floor() as i64;
|
||||
for (i, t) in tris.iter().enumerate() {
|
||||
let (lo, hi) = bounds(std::slice::from_ref(t));
|
||||
for x in key(lo[0])..=key(hi[0]) {
|
||||
for y in key(lo[1])..=key(hi[1]) {
|
||||
for z in key(lo[2])..=key(hi[2]) {
|
||||
cells.entry((x, y, z)).or_default().push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Grid { tris, cell, cells }
|
||||
}
|
||||
|
||||
/// Distance from `p` to the nearest triangle, or `cap` if none is that close. Searches shells
|
||||
/// of cells outwards and stops once the nearest hit is closer than the unsearched shells.
|
||||
fn distance(&self, p: [f64; 3], cap: f64, seen: &mut [u32], stamp: u32) -> f64 {
|
||||
let rmax = (cap / self.cell).ceil() as i64;
|
||||
let k = |x: f64| (x / self.cell).floor() as i64;
|
||||
let (cx, cy, cz) = (k(p[0]), k(p[1]), k(p[2]));
|
||||
let mut best = cap;
|
||||
for r in 0..=rmax {
|
||||
for x in cx - r..=cx + r {
|
||||
for y in cy - r..=cy + r {
|
||||
for z in cz - r..=cz + r {
|
||||
// only the shell at Chebyshev distance r
|
||||
if (x - cx).abs() != r && (y - cy).abs() != r && (z - cz).abs() != r {
|
||||
continue;
|
||||
}
|
||||
let Some(list) = self.cells.get(&(x, y, z)) else { continue };
|
||||
for &i in list {
|
||||
let Some(slot) = seen.get_mut(i as usize) else { continue };
|
||||
if *slot == stamp {
|
||||
continue;
|
||||
}
|
||||
*slot = stamp;
|
||||
if let Some(t) = self.tris.get(i as usize) {
|
||||
best = best.min(point_tri(p, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Everything not yet searched is at least r·cell away from p.
|
||||
if best <= r as f64 * self.cell {
|
||||
break;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
/// At most this many sample points per side (larger meshes are sampled with an even stride).
|
||||
const MAX_SAMPLES: usize = 250_000;
|
||||
|
||||
/// Sample points of a triangle soup: its vertices and triangle centroids (every k-th triangle for
|
||||
/// very large meshes).
|
||||
fn samples(tris: &[Tri]) -> Vec<[f64; 3]> {
|
||||
let stride = (tris.len() * 4).div_ceil(MAX_SAMPLES).max(1);
|
||||
let mut out: Vec<[f64; 3]> = Vec::with_capacity(tris.len() * 4 / stride + 4);
|
||||
for t in tris.iter().step_by(stride) {
|
||||
out.extend_from_slice(t);
|
||||
out.push(scale(add(add(t[0], t[1]), t[2]), 1.0 / 3.0));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One-sided sampled distance from `from` to `to`: (max, mean, worst sample point). Distances are
|
||||
/// capped at `cap`.
|
||||
fn one_sided(from: &[Tri], to: &[Tri], cap: f64) -> (f64, f64, [f64; 3]) {
|
||||
let (lo, hi) = bounds(to);
|
||||
let diag = dot(sub(hi, lo), sub(hi, lo)).sqrt();
|
||||
// Cells about twice the typical triangle size (so a cell holds a handful of triangles), but no
|
||||
// finer than diag/400 (so big flat triangles don't fill too many cells).
|
||||
let area: f64 = to.iter().map(|t| dot(cross(sub(t[1], t[0]), sub(t[2], t[0])), cross(sub(t[1], t[0]), sub(t[2], t[0]))).sqrt() / 2.0).sum();
|
||||
let typical = (area / to.len().max(1) as f64).sqrt();
|
||||
let grid = Grid::new(to, (2.0 * typical).max(diag / 400.0).max(1e-3));
|
||||
let mut seen = vec![0u32; to.len()];
|
||||
let (mut max, mut sum, mut worst, mut n) = (0.0f64, 0.0, [0.0; 3], 0usize);
|
||||
for (i, p) in samples(from).into_iter().enumerate() {
|
||||
let d = grid.distance(p, cap, &mut seen, i as u32 + 1);
|
||||
sum += d;
|
||||
n += 1;
|
||||
if d > max {
|
||||
max = d;
|
||||
worst = p;
|
||||
}
|
||||
}
|
||||
(max, if n > 0 { sum / n as f64 } else { 0.0 }, worst)
|
||||
}
|
||||
|
||||
/// Sampled symmetric Hausdorff distance between two triangle soups (distances capped at `cap`).
|
||||
pub struct Hausdorff {
|
||||
pub a_to_b: f64,
|
||||
pub b_to_a: f64,
|
||||
pub mean: f64,
|
||||
/// Where the larger one-sided distance was found (mm, world).
|
||||
pub worst_at: [f64; 3],
|
||||
}
|
||||
|
||||
pub fn hausdorff(a_mesh: &[Tri], b_mesh: &[Tri], cap: f64) -> Hausdorff {
|
||||
let (a, am, ap) = one_sided(a_mesh, b_mesh, cap);
|
||||
let (b, bm, bp) = one_sided(b_mesh, a_mesh, cap);
|
||||
Hausdorff { a_to_b: a, b_to_a: b, mean: (am + bm) / 2.0, worst_at: if a >= b { ap } else { bp } }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cube(s: f64, o: [f64; 3]) -> Vec<Tri> {
|
||||
let v = |x: f64, y: f64, z: f64| [o[0] + x * s, o[1] + y * s, o[2] + z * s];
|
||||
let q = |a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]| vec![[a, b, c], [a, c, d]];
|
||||
let mut t = Vec::new();
|
||||
t.extend(q(v(0., 0., 0.), v(0., 1., 0.), v(1., 1., 0.), v(1., 0., 0.)));
|
||||
t.extend(q(v(0., 0., 1.), v(1., 0., 1.), v(1., 1., 1.), v(0., 1., 1.)));
|
||||
t.extend(q(v(0., 0., 0.), v(1., 0., 0.), v(1., 0., 1.), v(0., 0., 1.)));
|
||||
t.extend(q(v(0., 1., 0.), v(0., 1., 1.), v(1., 1., 1.), v(1., 1., 0.)));
|
||||
t.extend(q(v(0., 0., 0.), v(0., 0., 1.), v(0., 1., 1.), v(0., 1., 0.)));
|
||||
t.extend(q(v(1., 0., 0.), v(1., 1., 0.), v(1., 1., 1.), v(1., 0., 1.)));
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_moments_and_distance() {
|
||||
let c = cube(10.0, [5.0, -3.0, 2.0]);
|
||||
let (v, com, pm) = mass_properties(&c);
|
||||
assert!((v - 1000.0).abs() < 1e-9 && (com[0] - 10.0).abs() < 1e-9 && (com[2] - 7.0).abs() < 1e-9);
|
||||
// V·(a²+b²)/12 for a cube of side 10: 1000·200/12.
|
||||
for m in pm {
|
||||
assert!((m - 1000.0 * 200.0 / 12.0).abs() < 1e-6, "{pm:?}");
|
||||
}
|
||||
let moved = cube(10.0, [5.0, -3.0, 2.3]);
|
||||
let h = hausdorff(&c, &moved, 1.0);
|
||||
assert!((h.a_to_b - 0.3).abs() < 1e-9 && (h.b_to_a - 0.3).abs() < 1e-9);
|
||||
let same = hausdorff(&c, &c, 1.0);
|
||||
assert!(same.a_to_b < 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! 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_solidcraft-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_solidcraft-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));
|
||||
}
|
||||
@@ -15,6 +15,8 @@ hostile variants of it never panic.
|
||||
| `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 |
|
||||
| `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) |
|
||||
| `split-body-pieces` | v0.3.2 (07e263d) | a slot along X cuts a block into two bodies; a fillet, a chamfer and an appearance refer to the pieces by body name (`Body1`, `Body1 (1)`) |
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 4078.7680390666596,
|
||||
"bbox": {
|
||||
"max": [
|
||||
40.0,
|
||||
30.000000000000014,
|
||||
12.000000000000002
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
19.25890231218174,
|
||||
15.000000010054242,
|
||||
5.04117761026949
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 30,
|
||||
"face_types": {
|
||||
"cylinder": 1,
|
||||
"plane": 11
|
||||
},
|
||||
"faces": 12,
|
||||
"kernel": {
|
||||
"edges": 30,
|
||||
"faces": 12,
|
||||
"shells": 1,
|
||||
"vertices": 20
|
||||
},
|
||||
"mass_g": 14.435720327850033,
|
||||
"material": "Default",
|
||||
"name": "Body1",
|
||||
"vertices": 20,
|
||||
"volume_mm3": 11190.480874302351
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 2,
|
||||
"id": 1,
|
||||
"name": "Base",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 4,
|
||||
"id": 3,
|
||||
"name": "SlotSketch",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.09895799999999999,
|
||||
"name": "Base",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 20.768292000000002,
|
||||
"name": "Block",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.057,
|
||||
"name": "SlotSketch",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 136.28875,
|
||||
"name": "Slot",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 153.58625,
|
||||
"name": "Round",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "FilletFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 169.174375,
|
||||
"name": "Bevel",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ChamferFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 7,
|
||||
"ms": 47.772166,
|
||||
"name": "Raise",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "OffsetFacesFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 4078.7680390666596,
|
||||
"edges": 30,
|
||||
"faces": 12,
|
||||
"mass_g": 14.435720327850033,
|
||||
"vertices": 20,
|
||||
"volume_mm3": 11190.480874302351
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
{
|
||||
"format": "solvecraft/13",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Base",
|
||||
"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": "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": [
|
||||
{
|
||||
"id": "k1",
|
||||
"type": "horizontal",
|
||||
"l": 0
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"type": "horizontal",
|
||||
"l": 2
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"type": "vertical",
|
||||
"l": 1
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"type": "vertical",
|
||||
"l": 3
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"type": "coincident",
|
||||
"p": 0,
|
||||
"q": 1
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 5,
|
||||
"l": 4,
|
||||
"p": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Block",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d1"
|
||||
],
|
||||
"type": "extrude",
|
||||
"sketch": 1,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"extent": {
|
||||
"distance": "10",
|
||||
"direction": "positive"
|
||||
},
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "SlotSketch",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XY"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
15.0,
|
||||
-5.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
25.0,
|
||||
-5.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
25.0,
|
||||
35.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
15.0,
|
||||
35.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": [
|
||||
{
|
||||
"id": "k1",
|
||||
"type": "horizontal",
|
||||
"l": 0
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"type": "horizontal",
|
||||
"l": 2
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"type": "vertical",
|
||||
"l": 1
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"type": "vertical",
|
||||
"l": 3
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 4,
|
||||
"l": 4,
|
||||
"p": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Slot",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d2",
|
||||
"d3"
|
||||
],
|
||||
"type": "extrude",
|
||||
"sketch": 3,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"extent": {
|
||||
"distance": "10",
|
||||
"direction": "positive",
|
||||
"start_offset": "5"
|
||||
},
|
||||
"operation": "cut"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Round",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d4"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
-1.0,
|
||||
0.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
40.0,
|
||||
30.0,
|
||||
10.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edge_names": [
|
||||
"F2:side:l2|F2:top#2"
|
||||
],
|
||||
"type": "fillet",
|
||||
"edges": [
|
||||
[
|
||||
40.0,
|
||||
15.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"radius": "2"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Bevel",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d5"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
1.0,
|
||||
0.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
40.0,
|
||||
30.000000000000007,
|
||||
10.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edge_names": [
|
||||
"F2:side:l4|F2:top#1"
|
||||
],
|
||||
"type": "chamfer",
|
||||
"edges": [
|
||||
[
|
||||
0.0,
|
||||
15.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"distance": "1.5"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "Raise",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d6"
|
||||
],
|
||||
"face_names": [
|
||||
"F2:top#1"
|
||||
],
|
||||
"type": "offset_face",
|
||||
"faces": [
|
||||
[
|
||||
7.0,
|
||||
15.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"distance": "2"
|
||||
}
|
||||
],
|
||||
"next_id": 8
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": "XY",
|
||||
"name": "Base"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.rectangle.two_point",
|
||||
"params": {
|
||||
"p0": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"p1": [
|
||||
40,
|
||||
30
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"distance": 10,
|
||||
"name": "Block"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": "XY",
|
||||
"name": "SlotSketch"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.rectangle.two_point",
|
||||
"params": {
|
||||
"p0": [
|
||||
15,
|
||||
-5
|
||||
],
|
||||
"p1": [
|
||||
25,
|
||||
35
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"sketch": "SlotSketch",
|
||||
"distance": 10,
|
||||
"start_offset": 5,
|
||||
"operation": "cut",
|
||||
"name": "Slot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.fillet",
|
||||
"params": {
|
||||
"edges": [
|
||||
[
|
||||
40,
|
||||
15,
|
||||
10
|
||||
]
|
||||
],
|
||||
"radius": 2,
|
||||
"name": "Round"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.chamfer",
|
||||
"params": {
|
||||
"edges": [
|
||||
[
|
||||
0,
|
||||
15,
|
||||
10
|
||||
]
|
||||
],
|
||||
"distance": 1.5,
|
||||
"name": "Bevel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.offset_face",
|
||||
"params": {
|
||||
"faces": [
|
||||
[
|
||||
7,
|
||||
15,
|
||||
10
|
||||
]
|
||||
],
|
||||
"distance": 2,
|
||||
"name": "Raise"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": "XY",
|
||||
"name": "Base"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.rectangle.two_point",
|
||||
"params": {
|
||||
"p0": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"p1": [
|
||||
40,
|
||||
30
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"distance": 10,
|
||||
"name": "Block"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": "XY",
|
||||
"name": "SlotSketch"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.rectangle.two_point",
|
||||
"params": {
|
||||
"p0": [
|
||||
-5,
|
||||
12
|
||||
],
|
||||
"p1": [
|
||||
45,
|
||||
18
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"sketch": "SlotSketch",
|
||||
"distance": 10,
|
||||
"operation": "cut",
|
||||
"name": "Slot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.fillet",
|
||||
"params": {
|
||||
"edges": [
|
||||
[
|
||||
20,
|
||||
0,
|
||||
10
|
||||
]
|
||||
],
|
||||
"radius": 2,
|
||||
"body": "Body1 (1)",
|
||||
"name": "NearRound"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.chamfer",
|
||||
"params": {
|
||||
"edges": [
|
||||
[
|
||||
20,
|
||||
30,
|
||||
10
|
||||
]
|
||||
],
|
||||
"distance": 1.5,
|
||||
"body": "Body1",
|
||||
"name": "FarBevel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "appearance.assign",
|
||||
"params": {
|
||||
"bodies": [
|
||||
"Body1 (1)"
|
||||
],
|
||||
"color": "#cc3333"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 1962.6028137423857,
|
||||
"bbox": {
|
||||
"max": [
|
||||
40.0,
|
||||
30.0,
|
||||
10.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
18.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
20.0,
|
||||
23.94794952681388,
|
||||
4.957413249211356
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 15,
|
||||
"face_types": {
|
||||
"plane": 7
|
||||
},
|
||||
"faces": 7,
|
||||
"kernel": {
|
||||
"edges": 15,
|
||||
"faces": 7,
|
||||
"shells": 1,
|
||||
"vertices": 10
|
||||
},
|
||||
"mass_g": 6.13395,
|
||||
"material": "Default",
|
||||
"name": "Body1",
|
||||
"vertices": 10,
|
||||
"volume_mm3": 4755.0
|
||||
},
|
||||
{
|
||||
"area_mm2": 1963.9401350108699,
|
||||
"bbox": {
|
||||
"max": [
|
||||
40.00000000000002,
|
||||
12.000000000000002,
|
||||
10.0
|
||||
],
|
||||
"min": [
|
||||
-8.881784197001252e-15,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
20.00000000000079,
|
||||
6.040035701025577,
|
||||
4.967173993940324
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 15,
|
||||
"face_types": {
|
||||
"cylinder": 1,
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 7,
|
||||
"kernel": {
|
||||
"edges": 15,
|
||||
"faces": 7,
|
||||
"shells": 1,
|
||||
"vertices": 10
|
||||
},
|
||||
"mass_g": 6.147677129601969,
|
||||
"material": "Default",
|
||||
"name": "Body1 (1)",
|
||||
"vertices": 10,
|
||||
"volume_mm3": 4765.64118573796
|
||||
}
|
||||
],
|
||||
"body_count": 2,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 2,
|
||||
"id": 1,
|
||||
"name": "Base",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 4,
|
||||
"id": 3,
|
||||
"name": "SlotSketch",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.07066700000000001,
|
||||
"name": "Base",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 0.7462909999999999,
|
||||
"name": "Block",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.035125,
|
||||
"name": "SlotSketch",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 9.647208000000001,
|
||||
"name": "Slot",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 3.443708,
|
||||
"name": "NearRound",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "FilletFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 3.947834,
|
||||
"name": "FarBevel",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ChamferFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 3926.5429487532556,
|
||||
"edges": 30,
|
||||
"faces": 14,
|
||||
"mass_g": 12.281627129601969,
|
||||
"vertices": 20,
|
||||
"volume_mm3": 9520.64118573796
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
{
|
||||
"format": "solvecraft/13",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Base",
|
||||
"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": "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": [
|
||||
{
|
||||
"id": "k1",
|
||||
"type": "horizontal",
|
||||
"l": 0
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"type": "horizontal",
|
||||
"l": 2
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"type": "vertical",
|
||||
"l": 1
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"type": "vertical",
|
||||
"l": 3
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"type": "coincident",
|
||||
"p": 0,
|
||||
"q": 1
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 5,
|
||||
"l": 4,
|
||||
"p": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Block",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d1"
|
||||
],
|
||||
"type": "extrude",
|
||||
"sketch": 1,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"extent": {
|
||||
"distance": "10",
|
||||
"direction": "positive"
|
||||
},
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "SlotSketch",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XY"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
-5.0,
|
||||
12.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
45.0,
|
||||
12.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
45.0,
|
||||
18.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
-5.0,
|
||||
18.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": [
|
||||
{
|
||||
"id": "k1",
|
||||
"type": "horizontal",
|
||||
"l": 0
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"type": "horizontal",
|
||||
"l": 2
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"type": "vertical",
|
||||
"l": 1
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"type": "vertical",
|
||||
"l": 3
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 4,
|
||||
"l": 4,
|
||||
"p": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Slot",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d2"
|
||||
],
|
||||
"type": "extrude",
|
||||
"sketch": 3,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"extent": {
|
||||
"distance": "10",
|
||||
"direction": "positive"
|
||||
},
|
||||
"operation": "cut"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "NearRound",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d3"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
-1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
40.0,
|
||||
12.000000000000002,
|
||||
10.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edge_names": [
|
||||
"F2:side:l1|F2:top"
|
||||
],
|
||||
"type": "fillet",
|
||||
"edges": [
|
||||
[
|
||||
20.0,
|
||||
0.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"radius": "2",
|
||||
"body": "Body1 (1)"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "FarBevel",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d4"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
18.0,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
40.0,
|
||||
30.0,
|
||||
10.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edge_names": [
|
||||
"F2:side:l3|F2:top"
|
||||
],
|
||||
"type": "chamfer",
|
||||
"edges": [
|
||||
[
|
||||
20.0,
|
||||
30.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"distance": "1.5",
|
||||
"body": "Body1"
|
||||
}
|
||||
],
|
||||
"next_id": 7,
|
||||
"appearances": {
|
||||
"bodies": {
|
||||
"Body1 (1)": {
|
||||
"name": "Custom",
|
||||
"color": [
|
||||
204,
|
||||
51,
|
||||
51
|
||||
],
|
||||
"opacity": 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,44 @@ fn old_designs_open_and_evaluate_as_saved() {
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
/// Names saved before format 14 were worked out from geometry, pieces of a split face numbered by
|
||||
/// where their centres lay (`F2:top#2`). They keep naming the faces they named: marked as such,
|
||||
/// they are found as the build that saved them found them, without new notes.
|
||||
#[test]
|
||||
fn names_saved_before_exact_history_find_the_faces_they_named() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/naming-split-pieces.solvecraft");
|
||||
let s = open(&path);
|
||||
let feature = |name: &str| s.doc.features.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name}"));
|
||||
assert_eq!(feature("Round").edge_names, vec!["?F2:side:l2|F2:top#2".to_string()]);
|
||||
assert_eq!(feature("Bevel").edge_names, vec!["?F2:side:l4|F2:top#1".to_string()]);
|
||||
assert_eq!(feature("Raise").face_names, vec!["?F2:top#1".to_string()]);
|
||||
for r in &s.model.results {
|
||||
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
|
||||
}
|
||||
// The raised piece is the left one, as saved (the stored point is on it).
|
||||
let state = s.model.state();
|
||||
let solid = |p: [f64; 3]| state.bodies.iter().any(|b| b.body.contains(solvecraft_engine::geom::Vec3::new(p[0], p[1], p[2])));
|
||||
assert!(solid([7.0, 15.0, 11.0]) && !solid([32.0, 15.0, 11.0]));
|
||||
}
|
||||
|
||||
/// Body names saved before format 14 name the pieces of a split body in the kernel's shell order.
|
||||
/// The features after the split that refer to a piece by its body name still find that piece:
|
||||
/// the far one stays `Body1` (chamfered), the near one `Body1 (1)` (filleted, coloured).
|
||||
#[test]
|
||||
fn body_names_saved_before_exact_history_keep_their_pieces() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/split-body-pieces.solvecraft");
|
||||
let s = open(&path);
|
||||
assert!(s.doc.features.iter().all(|f| f.shell_piece_order), "every saved feature keeps the shell order");
|
||||
for r in &s.model.results {
|
||||
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
|
||||
}
|
||||
let state = s.model.state();
|
||||
let piece = |name: &str| state.bodies.iter().find(|b| b.name == name).unwrap_or_else(|| panic!("{name}"));
|
||||
let solid = |name: &str, p: [f64; 3]| piece(name).body.contains(solvecraft_engine::geom::Vec3::new(p[0], p[1], p[2]));
|
||||
assert!(solid("Body1", [20.0, 24.0, 5.0]) && solid("Body1 (1)", [20.0, 6.0, 5.0]));
|
||||
assert!(s.doc.appearances.bodies.contains_key("Body1 (1)"));
|
||||
}
|
||||
|
||||
/// A small deterministic generator (xorshift).
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
@@ -150,10 +188,35 @@ fn try_bytes(bytes: &[u8]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let mut failures = Vec::new();
|
||||
// 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();
|
||||
@@ -198,14 +261,29 @@ fn damaged_and_hostile_design_files_never_panic() {
|
||||
cases.push(("features repeated".into(), v.to_string().into_bytes()));
|
||||
cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes()));
|
||||
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
|
||||
for (what, b) in cases {
|
||||
if std::env::var_os("CORPUS_TRACE").is_some() {
|
||||
eprintln!("{name}: {what}");
|
||||
}
|
||||
if std::panic::catch_unwind(|| try_bytes(&b)).is_err() {
|
||||
failures.push(format!("{name}: {what}"));
|
||||
}
|
||||
}
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! 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_solidcraft-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_solidcraft-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_solidcraft-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,"));
|
||||
}
|
||||
@@ -12,7 +12,7 @@ fn round_of(path: &std::path::Path) -> Option<String> {
|
||||
|
||||
#[test]
|
||||
fn killed_mid_save_the_old_file_survives() {
|
||||
let exe = env!("CARGO_BIN_EXE_solvecraft-cli");
|
||||
let exe = env!("CARGO_BIN_EXE_solidcraft-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();
|
||||
@@ -22,10 +22,20 @@ fn killed_mid_save_the_old_file_survives() {
|
||||
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();
|
||||
// Let it get into its save loop (building the design takes a moment), then kill it.
|
||||
std::thread::sleep(Duration::from_millis(150 + (i * 37) % 200));
|
||||
// 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;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! The `solvecraft-cli` compatibility alias forwards to `solidcraft-cli`.
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn legacy_alias_forwards_with_a_deprecation_note() {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("--version").output().unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert_eq!(stdout.trim(), format!("solidcraft-cli {}", env!("CARGO_PKG_VERSION")));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("deprecated") && stderr.contains("solidcraft-cli"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_alias_passes_the_exit_status_through() {
|
||||
let alias = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("no-such-command").output().unwrap();
|
||||
let real = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("no-such-command").output().unwrap();
|
||||
assert_eq!(alias.status.code(), real.status.code());
|
||||
assert_eq!(alias.stdout, real.stdout);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! `mcp --connect` to a port where no app listens tells the user how to start one (#270).
|
||||
|
||||
use std::net::TcpListener;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn mcp_connect_without_a_listening_app_names_the_control_flag() {
|
||||
// A port that was free a moment ago: nothing listens on it.
|
||||
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "--connect", &format!("127.0.0.1:{port}")])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(!output.status.success(), "{stderr}");
|
||||
assert!(stderr.contains("--control"), "say how to start the app with a control port: {stderr}");
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! 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_solidcraft-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,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-web"
|
||||
description = "SolveCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
description = "SolidCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -29,5 +29,5 @@ 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",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "Response",
|
||||
] }
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -3,8 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>SolveCraft</title>
|
||||
<title>SolidCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="solvecraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="copy-file" href="fonts/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
|
||||
<link data-trunk rel="copy-file" href="fonts/OFL.txt" data-target-path="fonts" />
|
||||
<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; }
|
||||
@@ -14,6 +16,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="solvecraft_canvas"></canvas>
|
||||
<div id="solvecraft_loading">Loading SolveCraft…</div>
|
||||
<div id="solvecraft_loading">Loading SolidCraft…</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -349,7 +349,7 @@ pub fn rename(files: &Rc<Files>, from: &str, to: &str) -> Result<(), String> {
|
||||
if files.entries.borrow().iter().any(|e| e.name == to) {
|
||||
return Err(format!("there is already a file named {to}"));
|
||||
}
|
||||
let bytes = vfs::read(&format!("{OPFS}{from}")).map_err(|e| e.to_string())?;
|
||||
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);
|
||||
|
||||
@@ -46,6 +46,10 @@ mod web {
|
||||
on_unload.forget();
|
||||
}
|
||||
|
||||
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
|
||||
|
||||
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
|
||||
|
||||
struct Shell {
|
||||
app: SolveApp,
|
||||
files: Rc<Files>,
|
||||
@@ -58,6 +62,9 @@ mod web {
|
||||
rename_focus: bool,
|
||||
/// The start page's recent designs were filled from the stored ones.
|
||||
recents_synced: bool,
|
||||
/// On-demand CJK font: fetched only when a CJK language is selected.
|
||||
font: solvecraft_ui_egui::fonts::CjkFontLoader,
|
||||
font_result: FontLoadResult,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
@@ -234,7 +241,7 @@ mod web {
|
||||
self.app.insert_path(&path);
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
"download" => match vfs::read(&path) {
|
||||
"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);
|
||||
@@ -304,10 +311,42 @@ mod web {
|
||||
}
|
||||
files::autosave(&self.files, None);
|
||||
}
|
||||
|
||||
/// Fetch and install the CJK font when a CJK language is selected. If that fails, this
|
||||
/// session falls back to English (no tofu); the saved language preference is kept.
|
||||
fn tick_font(&mut self, ctx: &egui::Context) {
|
||||
let done = self.font_result.borrow_mut().take();
|
||||
if let Some(res) = done {
|
||||
let installed = match res {
|
||||
Ok(bytes) => solvecraft_ui_egui::fonts::install_cjk_font(ctx, bytes),
|
||||
Err(err) => {
|
||||
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
|
||||
false
|
||||
}
|
||||
};
|
||||
if installed {
|
||||
log::info!("loaded web CJK font from {JAPANESE_FONT_PATH}");
|
||||
} else {
|
||||
log::warn!("CJK font unavailable; showing English for this session");
|
||||
}
|
||||
self.font.finished(installed, &mut self.app.preferences.language);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if self.font.tick(&mut self.app.preferences.language) {
|
||||
let slot = self.font_result.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let res = fetch_font_bytes(JAPANESE_FONT_PATH).await;
|
||||
*slot.borrow_mut() = Some(res);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.tick_font(ctx);
|
||||
self.app.logic(ctx);
|
||||
self.files_tick(ctx);
|
||||
UNSAVED.with(|u| u.set(solvecraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
@@ -323,7 +362,11 @@ mod web {
|
||||
self.recovery_prompt(&ctx);
|
||||
}
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
// Persist the user's own language, not a session-only English fallback.
|
||||
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
|
||||
let live = std::mem::replace(&mut self.app.preferences.language, saved);
|
||||
storage.set_string(PREFS_KEY, self.app.prefs());
|
||||
self.app.preferences.language = live;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +377,19 @@ mod web {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Fetch font bytes asynchronously through the browser.
|
||||
async fn fetch_font_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let resp_val = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url)).await.map_err(|e| format!("{e:?}"))?;
|
||||
let resp: web_sys::Response = resp_val.dyn_into().map_err(|_| "response cast failed")?;
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
let ab_val = wasm_bindgen_futures::JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?).await.map_err(|e| format!("{e:?}"))?;
|
||||
let uint8_arr = js_sys::Uint8Array::new(&ab_val);
|
||||
Ok(uint8_arr.to_vec())
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
@@ -355,6 +411,7 @@ mod web {
|
||||
canvas,
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
solvecraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
let files = Rc::new(Files::default());
|
||||
files::start(&files);
|
||||
let panel = files.clone();
|
||||
@@ -376,7 +433,8 @@ mod web {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs, DEPTH_BITS, 1);
|
||||
// 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()) {
|
||||
@@ -397,6 +455,8 @@ mod web {
|
||||
renaming: None,
|
||||
rename_focus: false,
|
||||
recents_synced: false,
|
||||
font: Default::default(),
|
||||
font_result: Rc::new(std::cell::RefCell::new(None)),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
@@ -405,7 +465,7 @@ mod web {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
Err(e) => {
|
||||
el.set_inner_html(&format!("<p>SolveCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
|
||||
el.set_inner_html(&format!("<p>SolidCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "solvecraft"
|
||||
description = "SolveCraft desktop app"
|
||||
name = "solidcraft"
|
||||
description = "SolidCraft desktop app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
default-run = "solvecraft"
|
||||
default-run = "solidcraft"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -13,8 +13,20 @@ workspace = true
|
||||
[dependencies]
|
||||
solvecraft-engine = { workspace = true }
|
||||
solvecraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
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(any(target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
quick-xml = { 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"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
#![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, ["solidcraft"], embed_resource::NONE).manifest_required()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -3,12 +3,22 @@
|
||||
|
||||
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)) {
|
||||
@@ -21,19 +31,69 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
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));
|
||||
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;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
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;
|
||||
}
|
||||
@@ -53,10 +113,58 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+155
-26
@@ -12,37 +12,47 @@
|
||||
#![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(any(target_os = "windows", target_os = "linux"))]
|
||||
mod space_mouse;
|
||||
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_ui_egui::{Services, SolveApp};
|
||||
|
||||
struct App(SolveApp);
|
||||
struct App {
|
||||
solve: SolveApp,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
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.0.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.0);
|
||||
solvecraft_ui_egui::documents::request_quit(&mut self.solve);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
if self.0.quit_requested {
|
||||
self.solve.logic(ctx);
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
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.0.raw_input_hook(raw);
|
||||
self.solve.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
self.solve.ui(ui);
|
||||
}
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(PREFS_KEY, self.0.prefs());
|
||||
storage.set_string(PREFS_KEY, self.solve.prefs());
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
solvecraft_ui_egui::recovery_ui::close(&mut self.0);
|
||||
solvecraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,10 +67,11 @@ fn services() -> Services {
|
||||
"Designs, STEP, IGES and meshes",
|
||||
&["solvecraft", "step", "stp", "STEP", "STP", "igs", "iges", "IGS", "IGES", "3mf", "3MF", "stl", "STL"],
|
||||
)
|
||||
.add_filter("SolveCraft design", &["solvecraft"])
|
||||
.add_filter("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())
|
||||
@@ -69,6 +80,23 @@ fn services() -> Services {
|
||||
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();
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,8 +122,28 @@ fn msaa() -> u16 {
|
||||
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());
|
||||
@@ -107,35 +155,42 @@ fn main() -> eframe::Result {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--sample" => sample = true,
|
||||
"--version" | "-V" => {
|
||||
println!("solvecraft {}", env!("CARGO_PKG_VERSION"));
|
||||
println!("solidcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("usage: solvecraft [--control PORT] [--sample] [design.solvecraft | part.step]");
|
||||
println!("usage: solidcraft [--control PORT] [--sample] [design.solvecraft | part.step]");
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("SolveCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!CUSTOM_TITLEBAR)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id("ai.storyteller.solvecraft"),
|
||||
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")))]
|
||||
if prefer_x11(
|
||||
std::env::var_os("DISPLAY").is_some(),
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some(),
|
||||
std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref(),
|
||||
std::env::var("SOLVECRAFT_WAYLAND").ok().as_deref(),
|
||||
) {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
"SolveCraft",
|
||||
"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");
|
||||
@@ -143,7 +198,10 @@ fn main() -> eframe::Result {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs, DEPTH_BITS, u32::from(samples));
|
||||
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);
|
||||
// A saved anti-aliasing the device can't do: back to the default, with a notice.
|
||||
solvecraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
@@ -158,7 +216,7 @@ fn main() -> eframe::Result {
|
||||
app.session.undo.clear();
|
||||
}
|
||||
for f in &files {
|
||||
app.open_path(f);
|
||||
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).
|
||||
@@ -171,7 +229,78 @@ fn main() -> eframe::Result {
|
||||
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
|
||||
solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
||||
}
|
||||
Ok(Box::new(App(app)))
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
space_mouse,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
|
||||
/// on Wayland (only on X11), so dropping files from the file manager showed a "no" cursor: use
|
||||
/// XWayland when it's there. Except on niri, whose XWayland (xwayland-satellite) breaks window
|
||||
/// management (maximize/restore), so it stays on native Wayland and gives up file drops.
|
||||
/// `SOLVECRAFT_WAYLAND=1` keeps native Wayland, `SOLVECRAFT_WAYLAND=0` forces XWayland.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn prefer_x11(display: bool, wayland_display: bool, desktop: Option<&str>, wayland_override: Option<&str>) -> bool {
|
||||
match wayland_override.map(str::trim) {
|
||||
Some("0") => return display,
|
||||
Some(_) => return false,
|
||||
None => {}
|
||||
}
|
||||
let niri = desktop.is_some_and(|d| d.split(':').any(|name| name.trim().eq_ignore_ascii_case("niri")));
|
||||
display && !(niri && wayland_display)
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix, not(target_os = "macos")))]
|
||||
mod backend_tests {
|
||||
use super::prefer_x11;
|
||||
|
||||
#[test]
|
||||
fn xwayland_by_default_native_wayland_on_niri_and_the_override_wins() {
|
||||
// XWayland whenever there's an X display, for file drag-and-drop.
|
||||
assert!(prefer_x11(true, true, Some("GNOME"), None));
|
||||
assert!(prefer_x11(true, false, None, None));
|
||||
assert!(!prefer_x11(false, true, Some("KDE"), None));
|
||||
// niri (alone or in a desktop list) under Wayland stays native.
|
||||
assert!(!prefer_x11(true, true, Some("niri"), None));
|
||||
assert!(!prefer_x11(true, true, Some("niri:GNOME"), None));
|
||||
// niri without a Wayland socket (nested X session) still uses X11.
|
||||
assert!(prefer_x11(true, false, Some("niri"), None));
|
||||
// SOLVECRAFT_WAYLAND: anything but 0 keeps Wayland, 0 forces XWayland (when present).
|
||||
assert!(!prefer_x11(true, true, Some("GNOME"), Some("1")));
|
||||
assert!(!prefer_x11(true, true, Some("GNOME"), Some("")));
|
||||
assert!(prefer_x11(true, true, Some("niri"), Some("0")));
|
||||
assert!(!prefer_x11(false, true, Some("niri"), Some("0")));
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
//! 3Dconnexion HID navigation on Windows (through hidapi) and Linux (through hidraw). The device
|
||||
//! reader never touches the document or UI; it publishes the latest axes, and the egui thread
|
||||
//! applies them once per frame.
|
||||
|
||||
mod hid_descriptor;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod hidraw;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use egui::Context;
|
||||
use hid_descriptor::Layout;
|
||||
#[cfg(target_os = "windows")]
|
||||
use hidapi::{DeviceInfo, HidApi};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
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);
|
||||
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Axes {
|
||||
translation: [i16; 3],
|
||||
rotation: [i16; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct AxisRule {
|
||||
output: usize,
|
||||
enabled: bool,
|
||||
reversed: bool,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct DriverMapping {
|
||||
axes: [AxisRule; 6],
|
||||
overall_scale: f64,
|
||||
}
|
||||
|
||||
impl Default for DriverMapping {
|
||||
fn default() -> Self {
|
||||
Self { axes: std::array::from_fn(|output| AxisRule { output, enabled: true, reversed: false, scale: 1.0 }), overall_scale: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Motion {
|
||||
translation: [f64; 3],
|
||||
rotation: [f64; 3],
|
||||
}
|
||||
|
||||
impl DriverMapping {
|
||||
fn apply(self, axes: Axes) -> Motion {
|
||||
let raw = [
|
||||
normalized(axes.translation[0]),
|
||||
normalized(axes.translation[1]),
|
||||
normalized(axes.translation[2]),
|
||||
normalized(axes.rotation[0]),
|
||||
normalized(axes.rotation[1]),
|
||||
normalized(axes.rotation[2]),
|
||||
];
|
||||
let mut logical = [0.0; 6];
|
||||
for (input, rule) in self.axes.iter().enumerate() {
|
||||
if rule.enabled {
|
||||
let sign = if rule.reversed { -1.0 } else { 1.0 };
|
||||
logical[rule.output] = raw[input] * sign * rule.scale * self.overall_scale;
|
||||
}
|
||||
}
|
||||
Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct ProfileAxis {
|
||||
input: Option<usize>,
|
||||
output: Option<usize>,
|
||||
enabled: bool,
|
||||
reversed: bool,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum ProfilePart {
|
||||
Input,
|
||||
Output,
|
||||
}
|
||||
|
||||
fn axis_index(name: &str) -> Option<usize> {
|
||||
match name.trim() {
|
||||
"HIDMultiAxis_X" => Some(0),
|
||||
"HIDMultiAxis_Y" => Some(1),
|
||||
"HIDMultiAxis_Z" => Some(2),
|
||||
"HIDMultiAxis_Rx" => Some(3),
|
||||
"HIDMultiAxis_Ry" => Some(4),
|
||||
"HIDMultiAxis_Rz" => Some(5),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 3DxWare stores the active KMJ application's axis selection, swapping, inversion and speed in
|
||||
/// `%APPDATA%/3Dconnexion/3DxWare/Cfg/<app>-KMJ.xml`. Direct HID reads bypass the driver, so apply
|
||||
/// that profile explicitly when it exists. Unknown or incomplete fields retain the raw HID axis.
|
||||
fn parse_driver_mapping(xml: &[u8]) -> Option<DriverMapping> {
|
||||
let mut reader = Reader::from_reader(xml);
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut buf = Vec::new();
|
||||
let mut mapping = DriverMapping::default();
|
||||
let mut axis: Option<ProfileAxis> = None;
|
||||
let mut part = None;
|
||||
let mut field = Vec::new();
|
||||
let mut found = false;
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf).ok()? {
|
||||
Event::Start(start) => match start.local_name().as_ref() {
|
||||
b"Axis" => axis = Some(ProfileAxis { enabled: true, scale: 1.0, ..Default::default() }),
|
||||
b"Input" => part = Some(ProfilePart::Input),
|
||||
b"Output" => part = Some(ProfilePart::Output),
|
||||
name @ (b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale") => field = name.to_vec(),
|
||||
_ => {}
|
||||
},
|
||||
Event::Text(text) => {
|
||||
let value = String::from_utf8_lossy(text.as_ref());
|
||||
match field.as_slice() {
|
||||
b"ActionID" => {
|
||||
if let Some(current) = axis.as_mut() {
|
||||
match part {
|
||||
Some(ProfilePart::Input) => current.input = axis_index(&value),
|
||||
Some(ProfilePart::Output) => current.output = axis_index(&value),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
b"Enabled" => {
|
||||
if let Some(current) = axis.as_mut() {
|
||||
current.enabled = value.trim().eq_ignore_ascii_case("true");
|
||||
}
|
||||
}
|
||||
b"Reversed" => {
|
||||
if part == Some(ProfilePart::Output)
|
||||
&& let Some(current) = axis.as_mut()
|
||||
{
|
||||
current.reversed = value.trim().eq_ignore_ascii_case("true");
|
||||
}
|
||||
}
|
||||
b"Scale" => {
|
||||
if part == Some(ProfilePart::Output)
|
||||
&& let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite())
|
||||
&& let Some(current) = axis.as_mut()
|
||||
{
|
||||
current.scale = scale.clamp(0.0, 10.0);
|
||||
}
|
||||
}
|
||||
b"OverallScale" => {
|
||||
if let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite()) {
|
||||
mapping.overall_scale = scale.clamp(0.0, 10.0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::End(end) => match end.local_name().as_ref() {
|
||||
b"Axis" => {
|
||||
if let Some(current) = axis.take()
|
||||
&& let (Some(input), Some(output)) = (current.input, current.output)
|
||||
{
|
||||
mapping.axes[input] = AxisRule { output, enabled: current.enabled, reversed: current.reversed, scale: current.scale };
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
b"Input" | b"Output" => part = None,
|
||||
b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale" => field.clear(),
|
||||
_ => {}
|
||||
},
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
found.then_some(mapping)
|
||||
}
|
||||
|
||||
fn load_driver_mapping() -> Option<DriverMapping> {
|
||||
let profile = std::path::PathBuf::from(std::env::var_os("APPDATA")?).join("3Dconnexion").join("3DxWare").join("Cfg").join("solvecraft-KMJ.xml");
|
||||
if std::fs::metadata(&profile).ok()?.len() > MAX_PROFILE_BYTES {
|
||||
log::warn!("3D mouse: ignoring oversized 3DxWare profile {}", profile.display());
|
||||
return None;
|
||||
}
|
||||
let xml = std::fs::read(&profile).ok()?;
|
||||
parse_driver_mapping(&xml)
|
||||
}
|
||||
|
||||
#[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.
|
||||
fn is_3d_mouse_vendor(vendor_id: u16) -> bool {
|
||||
matches!(vendor_id, 0x046d | 0x256f)
|
||||
}
|
||||
|
||||
/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_3d_mouse(info: &DeviceInfo) -> bool {
|
||||
is_3d_mouse_vendor(info.vendor_id()) && 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.
|
||||
/// Some homemade devices send all six values in report 1 instead (`Layout::Combined`): only the
|
||||
/// device's report descriptor can tell the two apart, because a padded split report 1 is as long.
|
||||
fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Instant) -> bool {
|
||||
match report.first().copied() {
|
||||
Some(1) if layout == Layout::Combined => {
|
||||
let Some(body) = report.get(1..) else { return false };
|
||||
let (Some(translation), Some(rotation)) = (body.get(..6).and_then(three_axes), body.get(6..).and_then(three_axes)) else { return false };
|
||||
reading.axes = Axes { translation, rotation };
|
||||
reading.translation_at = now;
|
||||
reading.rotation_at = now;
|
||||
true
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes one input report from the device to the egui thread.
|
||||
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
|
||||
if let Ok(mut reading) = shared.lock()
|
||||
&& decode_report(report, layout, &mut reading, Instant::now())
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The device is gone: stop reporting its last position.
|
||||
fn reset(ctx: &Context, shared: &Mutex<Reading>) {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
*reading = Reading::new(Instant::now());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
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 layout = device_layout(&device);
|
||||
let mut report = [0_u8; 64];
|
||||
loop {
|
||||
match device.read_timeout(&mut report, 100) {
|
||||
Ok(0) => {}
|
||||
Ok(size) => publish(&ctx, &shared, layout, report.get(..size).unwrap_or_default()),
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
}
|
||||
|
||||
/// How the device packs its axes, from its report descriptor; the usual split layout if the
|
||||
/// descriptor cannot be read.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn device_layout(device: &hidapi::HidDevice) -> Layout {
|
||||
let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE];
|
||||
match device.get_report_descriptor(&mut descriptor) {
|
||||
Ok(size) => hid_descriptor::Descriptor::parse(descriptor.get(..size).unwrap_or_default()).layout(),
|
||||
Err(error) => {
|
||||
log::info!("3D mouse: no report descriptor, assuming separate translation and rotation reports: {error}");
|
||||
Layout::Split
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `/dev/hidrawN` with plain blocking reads, which end with an error when the device is
|
||||
/// unplugged. Opening needs permission: most distributions grant it to the logged-in user for
|
||||
/// devices that carry the `uaccess` udev tag, which a rule for the 3D mouse has to add.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
use std::io::Read;
|
||||
let mut denied = None;
|
||||
loop {
|
||||
if let Some(device) = hidraw::find() {
|
||||
match std::fs::File::open(&device.path) {
|
||||
Ok(mut file) => {
|
||||
denied = None;
|
||||
log::info!("3D mouse: reading {} ({:?} reports)", device.path.display(), device.layout);
|
||||
let mut report = [0_u8; 64];
|
||||
loop {
|
||||
match file.read(&mut report) {
|
||||
Ok(0) => break,
|
||||
Ok(size) => publish(&ctx, &shared, device.layout, report.get(..size).unwrap_or_default()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
// Say so once per device node, not every retry.
|
||||
Err(error) if denied.as_ref() != Some(&device.path) => {
|
||||
log::warn!("3D mouse: cannot open {}: {error}. A udev rule granting the logged-in user access is needed.", device.path.display());
|
||||
denied = Some(device.path);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SpaceMouse {
|
||||
shared: Arc<Mutex<Reading>>,
|
||||
last_frame: Instant,
|
||||
driver_mapping: DriverMapping,
|
||||
mapping_checked_at: 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, driver_mapping: load_driver_mapping().unwrap_or_default(), mapping_checked_at: 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 now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
|
||||
let updated = load_driver_mapping().unwrap_or_default();
|
||||
if updated != self.driver_mapping {
|
||||
log::info!("3D mouse: applying updated 3DxWare axis profile");
|
||||
self.driver_mapping = updated;
|
||||
}
|
||||
self.mapping_checked_at = 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,
|
||||
self.driver_mapping.apply(axes),
|
||||
seconds * speed.clamp(0.1, 5.0),
|
||||
height,
|
||||
app.session.active_sketch.is_some(),
|
||||
app.preferences.zoom_reverse,
|
||||
);
|
||||
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, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
|
||||
if !(seconds > 0.0) || !seconds.is_finite() {
|
||||
return;
|
||||
}
|
||||
let [x, y, z] = motion.translation;
|
||||
let [rx, ry, rz] = motion.rotation;
|
||||
// Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
|
||||
// profile above can swap the physical forward/back and up/down axes before they reach here.
|
||||
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
|
||||
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
|
||||
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None);
|
||||
if !sketch {
|
||||
// These are logical 3DxWare coordinates after the profile mapping above: Rx is Tilt,
|
||||
// Ry is Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected
|
||||
// navigation mode, so swapping them again here would make Roll behave like Spin.
|
||||
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
|
||||
cam.roll_by(rz * 2.0 * seconds);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn raw(axes: Axes) -> Motion {
|
||||
DriverMapping::default().apply(axes)
|
||||
}
|
||||
|
||||
#[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], Layout::Split, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes::default());
|
||||
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], Layout::Split, &mut reading, now));
|
||||
assert_eq!(reading.current(now).translation, [-100, 300, 0]);
|
||||
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], Layout::Split, &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], Layout::Split, &mut reading, later));
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], Layout::Split, &mut reading, later));
|
||||
assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_report_one_carries_translation_and_rotation_together() {
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
// Report id 1, then X Y Z Rx Ry Rz as signed little-endian 16-bit values.
|
||||
let report = [1, 0x9c, 0xff, 0x2c, 0x01, 0, 0, 1, 0, 0, 0, 0xff, 0xff];
|
||||
assert!(decode_report(&report, Layout::Combined, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [1, 0, -1] });
|
||||
// The same bytes on a split device are only the translation report; its padding is not rotation.
|
||||
let mut split = Reading::new(now);
|
||||
assert!(decode_report(&report, Layout::Split, &mut split, now));
|
||||
assert_eq!(split.current(now), Axes { translation: [-100, 300, 0], rotation: [0; 3] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_layout_ignores_short_and_other_reports() {
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
assert!(!decode_report(&[1, 1, 0, 2, 0, 3, 0], Layout::Combined, &mut reading, now));
|
||||
assert!(!decode_report(&[3, 1, 0, 0, 0, 0], Layout::Combined, &mut reading, now));
|
||||
assert!(!decode_report(&[], Layout::Combined, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes::default());
|
||||
// Extra padding after the six values is fine.
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 0, 0], Layout::Combined, &mut reading, now));
|
||||
assert_eq!(reading.current(now), 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, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, 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, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false);
|
||||
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
|
||||
assert!(cam.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn individual_axes_have_physical_spacemouse_meaning() {
|
||||
let before = Camera::default();
|
||||
|
||||
let mut slide = before;
|
||||
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(slide.target, before.target);
|
||||
assert_eq!(slide.distance, before.distance);
|
||||
|
||||
let mut push = before;
|
||||
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_eq!(push.target, before.target);
|
||||
assert!(push.distance < before.distance);
|
||||
|
||||
let mut lift = before;
|
||||
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(lift.target, before.target);
|
||||
assert_eq!(lift.distance, before.distance);
|
||||
|
||||
let mut tilt = before;
|
||||
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll));
|
||||
assert_ne!(tilt.pitch, before.pitch);
|
||||
|
||||
let mut roll = before;
|
||||
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll));
|
||||
assert_ne!(roll.yaw, before.yaw);
|
||||
|
||||
let mut twist = before;
|
||||
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(twist.roll, before.roll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_zoom_setting_applies_to_spacemouse() {
|
||||
let before = Camera::default();
|
||||
let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] });
|
||||
let mut normal = before;
|
||||
navigate(&mut normal, motion, 0.02, 600.0, false, false);
|
||||
let mut reversed = before;
|
||||
navigate(&mut reversed, motion, 0.02, 600.0, false, true);
|
||||
assert!(normal.distance < before.distance);
|
||||
assert!(reversed.distance > before.distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_3dxware_axis_swaps_and_reversals() {
|
||||
let profile = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_X</ActionID></Input><Output><ActionID>HIDMultiAxis_X</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Y</ActionID></Input><Output><ActionID>HIDMultiAxis_Z</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Z</ActionID></Input><Output><ActionID>HIDMultiAxis_Y</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rx</ActionID></Input><Output><ActionID>HIDMultiAxis_Rx</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
|
||||
let motion = mapping.apply(Axes { translation: [350, 0, 350], rotation: [350, 0, 350] });
|
||||
assert_eq!(motion.translation, [1.0, -1.0, 0.0]);
|
||||
assert_eq!(motion.rotation, [1.0, -1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
|
||||
let profile = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
|
||||
let before = Camera::default();
|
||||
|
||||
let mut physical_roll = before;
|
||||
navigate(&mut physical_roll, mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((physical_roll.yaw, physical_roll.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(physical_roll.roll, before.roll);
|
||||
|
||||
let mut physical_spin = before;
|
||||
navigate(&mut physical_spin, mapping.apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(physical_spin.yaw, before.yaw);
|
||||
assert_eq!((physical_spin.pitch, physical_spin.roll), (before.pitch, before.roll));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Just enough of a HID report descriptor reader to recognise a 3D mouse and to see how it packs
|
||||
//! its motion axes. Written from the public USB HID specification. Malformed input never panics;
|
||||
//! it simply yields less information.
|
||||
|
||||
/// Generic Desktop usage page, and the usages in it that matter here.
|
||||
const PAGE_GENERIC_DESKTOP: u32 = 0x01;
|
||||
const USAGE_MULTI_AXIS_CONTROLLER: u32 = 0x08;
|
||||
const FIRST_AXIS_USAGE: u32 = 0x30; // X, Y, Z, Rx, Ry, Rz are 0x30..=0x35
|
||||
const AXIS_COUNT: u32 = 6;
|
||||
|
||||
/// Motion reports are report id 1 (translation, or everything) and report id 2 (rotation).
|
||||
const MOTION_REPORT_ID: u8 = 1;
|
||||
|
||||
/// Descriptors are small (hidapi reserves 4 KiB). Anything past this is ignored.
|
||||
const MAX_DESCRIPTOR_BYTES: usize = 4096;
|
||||
/// Bound on the usages one main item can declare through a usage range.
|
||||
const MAX_USAGES_PER_ITEM: u32 = 256;
|
||||
|
||||
/// How a device spreads its six motion axes over input reports.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Layout {
|
||||
/// Report 1 carries translation and report 2 carries rotation, three 16-bit values each.
|
||||
/// This is how 3Dconnexion devices report, and it is the layout assumed when nothing is known.
|
||||
#[default]
|
||||
Split,
|
||||
/// Report 1 carries all six axes: translation, then rotation. Homemade devices do this.
|
||||
Combined,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Item {
|
||||
/// 0 main, 1 global, 2 local (3 is reserved).
|
||||
kind: u8,
|
||||
tag: u8,
|
||||
value: u32,
|
||||
}
|
||||
|
||||
/// Walks the short items of a descriptor. Long items are skipped; a truncated tail ends the walk.
|
||||
fn items(descriptor: &[u8]) -> impl Iterator<Item = Item> + '_ {
|
||||
let descriptor = descriptor.get(..MAX_DESCRIPTOR_BYTES).unwrap_or(descriptor);
|
||||
let mut pos = 0_usize;
|
||||
std::iter::from_fn(move || {
|
||||
loop {
|
||||
let prefix = *descriptor.get(pos)?;
|
||||
pos += 1;
|
||||
if prefix == 0xFE {
|
||||
// Long item: a data length byte, a tag byte, then the data.
|
||||
let length = usize::from(*descriptor.get(pos)?);
|
||||
pos = pos.checked_add(2 + length)?;
|
||||
continue;
|
||||
}
|
||||
let size = match prefix & 0b11 {
|
||||
0 => 0,
|
||||
1 => 1,
|
||||
2 => 2,
|
||||
_ => 4,
|
||||
};
|
||||
let data = descriptor.get(pos..pos + size)?;
|
||||
pos += size;
|
||||
let value = data.iter().enumerate().fold(0_u32, |value, (index, byte)| value | (u32::from(*byte) << (8 * index)));
|
||||
return Some(Item { kind: (prefix >> 2) & 0b11, tag: prefix >> 4, value });
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// What a descriptor says about a device, as far as 3D mouse support needs to know.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Descriptor {
|
||||
multi_axis_controller: bool,
|
||||
/// Distinct axes (X, Y, Z, Rx, Ry, Rz as bits 0..=5) that input report 1 carries.
|
||||
motion_report_axes: u8,
|
||||
}
|
||||
|
||||
impl Descriptor {
|
||||
pub fn parse(descriptor: &[u8]) -> Self {
|
||||
let mut summary = Self::default();
|
||||
let mut page = 0_u32;
|
||||
let mut report_id = 0_u8;
|
||||
let mut depth = 0_u32;
|
||||
// Local items belong to the next main item only.
|
||||
let mut usages: Vec<u32> = Vec::new();
|
||||
let mut usage_min = None;
|
||||
for item in items(descriptor) {
|
||||
match (item.kind, item.tag) {
|
||||
// Global: usage page, report id.
|
||||
(1, 0) => page = item.value,
|
||||
(1, 8) => report_id = u8::try_from(item.value).unwrap_or(0),
|
||||
// Local: usage, usage minimum, usage maximum.
|
||||
(2, 0) => usages.push(Self::full_usage(page, item)),
|
||||
(2, 1) => usage_min = Some(Self::full_usage(page, item)),
|
||||
(2, 2) => {
|
||||
if let Some(min) = usage_min.take() {
|
||||
let max = Self::full_usage(page, item);
|
||||
let count = max.saturating_sub(min).min(MAX_USAGES_PER_ITEM - 1);
|
||||
usages.extend((0..=count).map(|offset| min.saturating_add(offset)));
|
||||
}
|
||||
}
|
||||
// Main: input, collection, end collection.
|
||||
(0, 8) => {
|
||||
if report_id == MOTION_REPORT_ID || (report_id == 0 && depth > 0) {
|
||||
summary.note_axes(&usages);
|
||||
}
|
||||
}
|
||||
(0, 0xA) => {
|
||||
// An application collection (1) at the top level names the device.
|
||||
if depth == 0 && item.value == 1 && usages.first() == Some(&((PAGE_GENERIC_DESKTOP << 16) | USAGE_MULTI_AXIS_CONTROLLER)) {
|
||||
summary.multi_axis_controller = true;
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
(0, 0xC) => depth = depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
if item.kind == 0 {
|
||||
usages.clear();
|
||||
usage_min = None;
|
||||
}
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
/// Usage items of up to two bytes name a usage on the current page, four bytes carry the page.
|
||||
fn full_usage(page: u32, item: Item) -> u32 {
|
||||
if item.value > 0xFFFF { item.value } else { (page << 16) | item.value }
|
||||
}
|
||||
|
||||
fn note_axes(&mut self, usages: &[u32]) {
|
||||
for usage in usages {
|
||||
let axis = usage.wrapping_sub((PAGE_GENERIC_DESKTOP << 16) | FIRST_AXIS_USAGE);
|
||||
if axis < AXIS_COUNT {
|
||||
self.motion_report_axes |= 1 << axis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True for a top-level Generic Desktop "Multi-Axis Controller" collection. This tells a
|
||||
/// SpaceMouse's motion interface apart from its keyboard interface and from ordinary mice.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub fn is_multi_axis_controller(&self) -> bool {
|
||||
self.multi_axis_controller
|
||||
}
|
||||
|
||||
/// `Combined` when input report 1 declares all six axes, otherwise `Split`.
|
||||
pub fn layout(&self) -> Layout {
|
||||
if self.motion_report_axes.count_ones() == AXIS_COUNT { Layout::Combined } else { Layout::Split }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Report 1 = X Y Z, report 2 = Rx Ry Rz, each as three signed 16-bit values.
|
||||
const SPLIT: &[u8] = &[
|
||||
0x05, 0x01, // usage page: generic desktop
|
||||
0x09, 0x08, // usage: multi-axis controller
|
||||
0xA1, 0x01, // collection: application
|
||||
0x85, 0x01, // report id 1
|
||||
0x16, 0xA2, 0xFE, // logical minimum -350
|
||||
0x26, 0x5E, 0x01, // logical maximum 350
|
||||
0x75, 0x10, // report size 16
|
||||
0x95, 0x03, // report count 3
|
||||
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, // X Y Z
|
||||
0x81, 0x06, // input: data, variable, relative
|
||||
0x85, 0x02, // report id 2
|
||||
0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // Rx Ry Rz
|
||||
0x81, 0x06, // input
|
||||
0xC0, // end collection
|
||||
];
|
||||
|
||||
/// Report 1 = X Y Z Rx Ry Rz.
|
||||
const COMBINED: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, // multi-axis controller, report id 1
|
||||
0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, // 6 x 16 bit, -350..350
|
||||
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // X Y Z Rx Ry Rz
|
||||
0x81, 0x06, 0xC0,
|
||||
];
|
||||
|
||||
/// The same six axes declared as a usage range, with report id 1 after the collection opens.
|
||||
const COMBINED_RANGE: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x75, 0x10, 0x95, 0x06, //
|
||||
0x19, 0x30, 0x29, 0x35, // usage minimum X, usage maximum Rz
|
||||
0x81, 0x06, 0xC0,
|
||||
];
|
||||
|
||||
/// A boot keyboard-like interface: usage page 7 usages must not count as axes.
|
||||
const KEYBOARD: &[u8] =
|
||||
&[0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0xC0];
|
||||
|
||||
/// An ordinary mouse: Generic Desktop X, Y but top-level usage is Mouse (2).
|
||||
const MOUSE: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x09, 0x01, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02, 0x81,
|
||||
0x06, 0xC0, 0xC0,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn split_descriptor_is_a_multi_axis_controller_with_three_axes_in_report_one() {
|
||||
let descriptor = Descriptor::parse(SPLIT);
|
||||
assert!(descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.motion_report_axes, 0b000_111);
|
||||
assert_eq!(descriptor.layout(), Layout::Split);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_axes_in_report_one_are_a_combined_layout() {
|
||||
for bytes in [COMBINED, COMBINED_RANGE] {
|
||||
let descriptor = Descriptor::parse(bytes);
|
||||
assert!(descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.layout(), Layout::Combined);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_interfaces_are_not_3d_mice() {
|
||||
for bytes in [KEYBOARD, MOUSE, &[][..]] {
|
||||
let descriptor = Descriptor::parse(bytes);
|
||||
assert!(!descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.layout(), Layout::Split);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_byte_usages_carry_their_own_page() {
|
||||
// Usage (4 bytes) 0x0007_0030 is on page 7, not an axis.
|
||||
let descriptor = Descriptor::parse(&[0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x0B, 0x30, 0x00, 0x07, 0x00, 0x81, 0x06, 0xC0]);
|
||||
assert!(descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.motion_report_axes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_hostile_descriptors_never_panic() {
|
||||
for end in 0..=COMBINED.len() {
|
||||
let _ = Descriptor::parse(&COMBINED[..end]);
|
||||
}
|
||||
// Long item with a length running past the end, huge usage ranges, deep nesting.
|
||||
let _ = Descriptor::parse(&[0xFE, 0xFF, 0x00, 0x01]);
|
||||
let _ = Descriptor::parse(&[0x05, 0x01, 0x1B, 0, 0, 0, 0, 0x2B, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x06]);
|
||||
let _ = Descriptor::parse(&[0xC0; 64]);
|
||||
let _ = Descriptor::parse(&[0xA1; 64]);
|
||||
let mut state = 0x2545_F491_4F6C_DD1D_u64;
|
||||
for _ in 0..200 {
|
||||
let bytes: Vec<u8> = (0..64)
|
||||
.map(|_| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state.to_le_bytes()[0]
|
||||
})
|
||||
.collect();
|
||||
let _ = Descriptor::parse(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Finding a 3D mouse on Linux. The kernel exposes every HID interface as `/dev/hidrawN`, with
|
||||
//! its identity and report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading both is
|
||||
//! plain file I/O, so no udev or hidapi library is needed.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::hid_descriptor::{Descriptor, Layout};
|
||||
|
||||
const SYSFS: &str = "/sys/class/hidraw";
|
||||
const DEV: &str = "/dev";
|
||||
|
||||
pub struct Device {
|
||||
pub path: PathBuf,
|
||||
pub layout: Layout,
|
||||
}
|
||||
|
||||
/// The vendor id from `HID_ID=<bus>:<vendor>:<product>` (all hexadecimal) in a sysfs `uevent`.
|
||||
fn vendor_id(uevent: &str) -> Option<u16> {
|
||||
let id = uevent.lines().find_map(|line| line.strip_prefix("HID_ID="))?;
|
||||
let vendor = id.split(':').nth(1)?;
|
||||
u32::from_str_radix(vendor.trim(), 16).ok().and_then(|vendor| u16::try_from(vendor).ok())
|
||||
}
|
||||
|
||||
/// The first hidraw interface that is a 3D mouse's multi-axis controller.
|
||||
pub fn find() -> Option<Device> {
|
||||
let mut names: Vec<_> = std::fs::read_dir(SYSFS).ok()?.filter_map(Result::ok).map(|entry| entry.file_name()).collect();
|
||||
names.sort();
|
||||
names.into_iter().find_map(|name| probe(&Path::new(SYSFS).join(&name), Path::new(DEV).join(&name)))
|
||||
}
|
||||
|
||||
fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
|
||||
let uevent = std::fs::read_to_string(sysfs.join("device/uevent")).ok()?;
|
||||
if !super::is_3d_mouse_vendor(vendor_id(&uevent)?) {
|
||||
return None;
|
||||
}
|
||||
let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?);
|
||||
descriptor.is_multi_axis_controller().then(|| Device { path, layout: descriptor.layout() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_the_vendor_from_the_hid_id() {
|
||||
let uevent = "DRIVER=hid-generic\nHID_ID=0003:0000256F:0000C631\nHID_NAME=Some Device\n";
|
||||
assert_eq!(vendor_id(uevent), Some(0x256f));
|
||||
assert_eq!(vendor_id("HID_ID=0003:0000046D:0000C077"), Some(0x046d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ids_are_rejected() {
|
||||
for uevent in ["", "HID_NAME=x", "HID_ID=", "HID_ID=0003", "HID_ID=0003:zzzz:0001", "HID_ID=0003:FFFFFFFF:0001"] {
|
||||
assert_eq!(vendor_id(uevent), None, "{uevent:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-doc"
|
||||
description = "SolveCraft document model: parameters and expressions, sketches and features on a parametric timeline, incremental evaluation"
|
||||
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
|
||||
@@ -16,4 +16,5 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//! 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::same(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))
|
||||
}
|
||||
@@ -45,6 +45,15 @@ pub struct FaceLook {
|
||||
/// 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
|
||||
@@ -130,6 +139,24 @@ pub fn material_look(material: &str) -> Option<Look> {
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -167,12 +194,30 @@ impl Document {
|
||||
None => Vec::new(),
|
||||
},
|
||||
};
|
||||
for f in self.appearances.faces.iter().filter(|f| f.body == b.name) {
|
||||
let (p, look) = (f.point, f.look.clone());
|
||||
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();
|
||||
let hits = crate::naming::faces_with_stem(b, n);
|
||||
if !hits.is_empty() {
|
||||
for i in hits {
|
||||
out.retain(|(j, _)| *j != i);
|
||||
@@ -181,7 +226,9 @@ impl Document {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(i) = face_index_at(b, p) {
|
||||
if !f.strict_name
|
||||
&& let Some(i) = face_index_at(b, p)
|
||||
{
|
||||
out.retain(|(j, _)| *j != i);
|
||||
out.push((i, look));
|
||||
}
|
||||
|
||||
+90
-14
@@ -148,7 +148,11 @@ fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
|
||||
*at = apply_point(m, *at);
|
||||
map_geo_ref(m, face);
|
||||
}
|
||||
PlaneRef::Midplane { .. } | PlaneRef::TwoEdges { .. } | PlaneRef::ThreePoints { .. } | PlaneRef::AlongPath { .. } => {
|
||||
PlaneRef::Midplane { .. }
|
||||
| PlaneRef::TwoEdges { .. }
|
||||
| PlaneRef::ThreePoints { .. }
|
||||
| PlaneRef::AlongPath { .. }
|
||||
| PlaneRef::Perpendicular { .. } => {
|
||||
for r in p.refs_mut() {
|
||||
map_geo_ref(m, r);
|
||||
}
|
||||
@@ -160,6 +164,39 @@ 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) {
|
||||
@@ -203,12 +240,12 @@ impl FeatureKind {
|
||||
map_plane_ref(m, neutral);
|
||||
dir(pull);
|
||||
}
|
||||
FeatureKind::Box { corner, .. } => pt(corner),
|
||||
FeatureKind::Box { corner, .. } => map_point_expr(m, corner),
|
||||
FeatureKind::Cylinder { base, axis, .. } => {
|
||||
pt(base);
|
||||
map_point_expr(m, base);
|
||||
dir(axis);
|
||||
}
|
||||
FeatureKind::Sphere { center, .. } | FeatureKind::Torus { center, .. } => pt(center),
|
||||
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 {
|
||||
@@ -255,7 +292,7 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetUnfold { .. } => {}
|
||||
FeatureKind::Coil { base, axis, .. } => {
|
||||
pt(base);
|
||||
map_point_expr(m, base);
|
||||
dir(axis);
|
||||
}
|
||||
FeatureKind::ReplaceFace { faces, target, .. } => {
|
||||
@@ -291,7 +328,9 @@ impl FeatureKind {
|
||||
| FeatureKind::Stitch { .. }
|
||||
| FeatureKind::Thicken { .. }
|
||||
| FeatureKind::Import { .. }
|
||||
| FeatureKind::MeshImport { .. } => {}
|
||||
| FeatureKind::MeshImport { .. }
|
||||
| FeatureKind::MeshConvert { .. }
|
||||
| FeatureKind::BodyTransfer { .. } => {}
|
||||
FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
|
||||
map_plane_ref(m, plane);
|
||||
@@ -348,24 +387,61 @@ impl Document {
|
||||
/// 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() {
|
||||
let primary = path == self.first_path(comp);
|
||||
let label = || -> String {
|
||||
path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::<Vec<_>>().join("/")
|
||||
};
|
||||
for b in st.bodies.iter().filter(|b| self.body_component(&b.name, b.feature) == comp) {
|
||||
let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) };
|
||||
let w = match self.body_offsets.get(&b.name) {
|
||||
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, comp, path.clone(), w));
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! 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::same(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -34,6 +34,14 @@ pub struct Canvas {
|
||||
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 {
|
||||
@@ -57,7 +65,7 @@ impl Canvas {
|
||||
let f = if self.flip { -1.0 } else { 1.0 };
|
||||
[rot(-hw * f, -hh), rot(hw * f, -hh), rot(hw * f, hh), rot(-hw * f, hh)]
|
||||
}
|
||||
/// Corners in world coordinates.
|
||||
/// 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))
|
||||
}
|
||||
@@ -66,6 +74,19 @@ impl Canvas {
|
||||
}
|
||||
}
|
||||
|
||||
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") {
|
||||
|
||||
@@ -49,7 +49,10 @@ impl FeatureKind {
|
||||
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);
|
||||
@@ -74,3 +77,395 @@ impl FeatureKind {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
// An older design's name keeps its mark; what follows it is remapped as any name.
|
||||
if let Some(old) = crate::naming::legacy_name(name) {
|
||||
return format!("{}{}", crate::naming::LEGACY_MARK, remap_topology(old, ids, bodies));
|
||||
}
|
||||
// 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..];
|
||||
// A feature token starts a name, or one inside it: after `@`, `:` or `|`, or in a
|
||||
// piece's list of the faces around it (`[`, `,`).
|
||||
if rest.starts_with('F') && (at == 0 || matches!(name.as_bytes().get(at - 1), Some(b'@' | b':' | b'|' | b'[' | 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, .. }
|
||||
| K::MeshConvert { 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");
|
||||
}
|
||||
}
|
||||
|
||||
+278
-25
@@ -1,11 +1,24 @@
|
||||
//! Configurations: a table whose rows are variants of the design. Each column is a parameter
|
||||
//! (its expression per row) or a feature (suppressed or not per row); activating a row sets
|
||||
//! those values in the design, which then rebuilds as usual.
|
||||
//! 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 {
|
||||
@@ -13,12 +26,20 @@ pub enum Column {
|
||||
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: an expression, or "true"/"false" for suppression.
|
||||
/// One cell per column: expression, suppression, material name or appearance look.
|
||||
pub cells: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -28,67 +49,235 @@ pub struct ConfigTable {
|
||||
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;
|
||||
|
||||
impl ConfigTable {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.columns.is_empty() && self.rows.is_empty()
|
||||
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 {
|
||||
/// A column's current value in the design.
|
||||
pub fn config_current(&self, c: &Column) -> Option<String> {
|
||||
match c {
|
||||
Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, _)| e),
|
||||
Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()),
|
||||
/// 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 display key: `param:<name>` or `suppress:<feature name>`.
|
||||
/// 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(n) => {
|
||||
if next.config_current(c).as_deref() != Some(v.as_str()) {
|
||||
next.change_param(n, v, None, None).map_err(|e| DocError::Invalid(format!("configuration `{row}`, parameter `{n}`: {e}")))?;
|
||||
}
|
||||
}
|
||||
Column::Param(_) => {}
|
||||
Column::Suppress(id) => {
|
||||
let on = match v.trim() {
|
||||
"true" | "1" | "yes" => true,
|
||||
"false" | "0" | "no" => false,
|
||||
o => return Err(DocError::Invalid(format!("configuration `{row}`: suppression must be true or false, not `{o}`"))),
|
||||
};
|
||||
let 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(())
|
||||
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(_) => self.config_current(c).as_deref() == Some(v.trim()),
|
||||
Column::Param(_) => self.config_current(c).is_some_and(|x| x.replace(' ', "") == v.replace(' ', "")),
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -97,6 +286,53 @@ impl Document {
|
||||
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");
|
||||
@@ -114,4 +350,21 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
//! 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())
|
||||
}
|
||||
fn exact_region_signature(curves: &ExactSketchCurves, 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| curves.find(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 curves = ExactSketchCurves::new(sketch);
|
||||
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(&curves, 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) struct ExactSketchCurves<'a> {
|
||||
segments: Vec<(&'a str, Vec<Seg2>)>,
|
||||
}
|
||||
|
||||
impl<'a> ExactSketchCurves<'a> {
|
||||
/// Prepare only bounded analytic curves, once per certification pass. Point lookup
|
||||
/// and segment allocation must not be repeated for every generated face.
|
||||
pub(crate) fn new(sketch: &'a SolvedSketch) -> Self {
|
||||
let mut segments = Vec::new();
|
||||
if sketch.sketch.curves.len() > 2000 {
|
||||
return Self { segments };
|
||||
}
|
||||
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 originals = if let solvecraft_sketch::CurveKind::Circle { c, r } = curve.kind {
|
||||
sketch
|
||||
.sketch
|
||||
.point(c)
|
||||
.map(|center| vec![Seg2::Arc { center, radius: r, start: 0., sweep: std::f64::consts::TAU }])
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
sketch.sketch.segs(index)
|
||||
};
|
||||
segments.push((curve.id.as_str(), originals));
|
||||
}
|
||||
Self { segments }
|
||||
}
|
||||
|
||||
pub(crate) fn find(&self, generated: &Seg2) -> Option<String> {
|
||||
let mut found = None;
|
||||
for (id, originals) in &self.segments {
|
||||
if originals.iter().any(|original| exact_segment_contains(original, generated)) {
|
||||
if found.is_some() {
|
||||
return None;
|
||||
}
|
||||
found = Some(*id);
|
||||
}
|
||||
}
|
||||
found.map(str::to_owned)
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
fn solved(mut sketch: solvecraft_sketch::Sketch) -> SolvedSketch {
|
||||
let report = solvecraft_sketch::solve(&mut sketch);
|
||||
SolvedSketch { feature: 1, name: "sources".into(), plane: solvecraft_geom::Plane::XY, sketch, report, profiles: Vec::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_sources_keep_exact_identity_and_reject_ambiguity() {
|
||||
use solvecraft_geom::Vec2;
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("line")).unwrap();
|
||||
sketch.add_circle(Vec2::new(20., 0.), 2., None, Some("circle")).unwrap();
|
||||
let construction = sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("construction")).unwrap();
|
||||
sketch.curves[construction].construction = true;
|
||||
let sketch = solved(sketch);
|
||||
let curves = ExactSketchCurves::new(&sketch);
|
||||
let partial = Seg2::Line { a: Vec2::new(8., 0.), b: Vec2::new(2., 0.) };
|
||||
assert_eq!(curves.find(&partial).as_deref(), Some("line"));
|
||||
assert_eq!(curves.find(&Seg2::Arc { center: Vec2::new(20., 0.), radius: 2., start: 1., sweep: -2. }).as_deref(), Some("circle"));
|
||||
assert_eq!(curves.find(&Seg2::Line { a: Vec2::ZERO, b: Vec2::new(11., 0.) }), None);
|
||||
let mut ambiguous = sketch.clone();
|
||||
ambiguous.sketch.add_line(Vec2::new(1., 0.), Vec2::new(9., 0.), None, None, Some("overlap")).unwrap();
|
||||
assert_eq!(ExactSketchCurves::new(&ambiguous).find(&partial), None);
|
||||
// A prepared pass borrows one immutable evaluated sketch, so an upstream edit
|
||||
// necessarily prepares fresh sources rather than retaining stale coordinates.
|
||||
let mut moved = sketch.clone();
|
||||
moved.sketch = solvecraft_sketch::Sketch::new();
|
||||
moved.sketch.add_line(Vec2::new(0., 1.), Vec2::new(10., 1.), None, None, Some("line")).unwrap();
|
||||
assert_eq!(ExactSketchCurves::new(&moved).find(&partial), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual optimized certification microbenchmark; no timing assertion"]
|
||||
fn benchmark_prepared_extrude_sources() {
|
||||
use solvecraft_geom::Vec2;
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
for i in 0..1000 {
|
||||
let y = i as f64;
|
||||
sketch.add_line(Vec2::new(0., y), Vec2::new(10., y), None, None, None).unwrap();
|
||||
}
|
||||
let sketch = solved(sketch);
|
||||
let queries: Vec<_> = (0..1000).map(|i| Seg2::Line { a: Vec2::new(2., i as f64), b: Vec2::new(8., i as f64) }).collect();
|
||||
let start = std::time::Instant::now();
|
||||
let old: Vec<_> = queries
|
||||
.iter()
|
||||
.map(|generated| {
|
||||
let mut found = None;
|
||||
for (index, curve) in sketch.sketch.curves.iter().enumerate() {
|
||||
if sketch.sketch.segs(index).iter().any(|original| exact_segment_contains(original, generated)) {
|
||||
if found.is_some() {
|
||||
return None;
|
||||
}
|
||||
found = Some(curve.id.clone());
|
||||
}
|
||||
}
|
||||
found
|
||||
})
|
||||
.collect();
|
||||
let old_ms = start.elapsed().as_secs_f64() * 1000.;
|
||||
let start = std::time::Instant::now();
|
||||
let curves = ExactSketchCurves::new(&sketch);
|
||||
let new: Vec<_> = queries.iter().map(|query| curves.find(query)).collect();
|
||||
let new_ms = start.elapsed().as_secs_f64() * 1000.;
|
||||
assert_eq!(old, new);
|
||||
assert!(new.iter().all(Option::is_some));
|
||||
eprintln!("1000 analytic sources / 1000 face segments: repeated={old_ms:.3}ms prepared={new_ms:.3}ms (includes preparation)");
|
||||
}
|
||||
#[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.)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
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(&crate::format::format_string(crate::format::FORMAT)));
|
||||
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"], crate::format::format_string(crate::format::FORMAT));
|
||||
current["format"] = json!("solvecraft/12");
|
||||
crate::format::pieces_in_shell_order(&mut legacy["features"]);
|
||||
assert_eq!(legacy, current, "the migration preserves every prior field");
|
||||
legacy["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
|
||||
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 captured = crate::naming::face_name_at_body(&old_body, Vec3::new(20., 6., 1.)).unwrap();
|
||||
assert_eq!(crate::naming::parent_piece(&captured), Some(stem.as_str()), "actual strip capture records a piece name");
|
||||
// What the builds before format 14 saved for that piece: its number by centroid order.
|
||||
let former = format!("{stem}#1");
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,10 @@ 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 {
|
||||
@@ -198,6 +202,14 @@ fn sketch_world(ctx: &Ctx, sketch: u64, q: Vec2) -> Result<Vec3> {
|
||||
|
||||
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}")))?;
|
||||
if let Some(wire) = ss.sketch.wires.iter().find(|wire| wire.id == curve) {
|
||||
if wire.pts.len() < 2 {
|
||||
return Err(bad(format!("curve `{curve}` has no length")));
|
||||
}
|
||||
// Evaluation has already mapped wires into this component's frame. Their coordinates
|
||||
// are independent of the sketch plane; applying it again would move the path twice.
|
||||
return Ok(wire.pts.clone());
|
||||
}
|
||||
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 {
|
||||
@@ -281,7 +293,7 @@ pub(crate) fn line(ctx: &Ctx, g: &GeoRef) -> Result<(Vec3, Vec3)> {
|
||||
/// A reference as a plane.
|
||||
pub(crate) fn plane(ctx: &Ctx, g: &GeoRef) -> Result<Plane> {
|
||||
match g {
|
||||
GeoRef::Plane { plane } => ctx.doc.resolve_plane_in(ctx.vals, plane, 0, Some(ctx.st)),
|
||||
GeoRef::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) {
|
||||
@@ -513,12 +525,21 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
|
||||
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, 0, Some(ctx.st)),
|
||||
_ => ctx.doc.resolve_plane_in(ctx.vals, p, ctx.depth + 1, Some(ctx.st)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,6 +642,7 @@ impl PlaneRef {
|
||||
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()],
|
||||
|
||||
+426
-48
@@ -54,18 +54,32 @@ pub enum PlaneRef {
|
||||
Tangent { face: Box<GeoRef>, at: Vec3 },
|
||||
/// Square to an edge or sketch curve, a fraction `t` (expression, 0…1) of the way along it.
|
||||
AlongPath { path: Box<GeoRef>, t: String },
|
||||
/// A planar body face: found again on each evaluation (the face with this plane's normal
|
||||
/// nearest `at`), so sketches on it follow when earlier features change; `plane` is the
|
||||
/// plane as picked (its frame, and the fallback when the face is gone). `name` is the face's
|
||||
/// persistent name (found first; see `naming`).
|
||||
/// Through a straight edge (or line, axis) and square to a plane (or planar face).
|
||||
Perpendicular { plane: Box<GeoRef>, line: Box<GeoRef> },
|
||||
/// A planar body face. Certified attachments resolve only their exact source and report
|
||||
/// an error when it is lost; `plane` then supplies a repair guide. Legacy references without
|
||||
/// an anchor try the persistent `name`, then the nearest parallel face at `at`, and retain
|
||||
/// the picked `plane` as their historical fallback.
|
||||
Face {
|
||||
plane: Plane,
|
||||
at: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
/// Exact attachment identity for newly certified faces; absent on legacy designs.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
anchor: Option<Box<crate::FaceAnchor>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Complete source curves of an outer boundary and each of its holes. Unlike a
|
||||
/// span multiset, this identity is independent of adaptive spline subdivision.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WholeProfileBoundary {
|
||||
pub outer: Vec<String>,
|
||||
pub holes: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Which closed profiles of a sketch a feature uses.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -74,9 +88,16 @@ pub enum ProfileSel {
|
||||
All,
|
||||
/// Profiles by index in the solved sketch's profile list.
|
||||
Indices { indices: Vec<usize> },
|
||||
/// Profiles whose outer loop is made of exactly these curves (order-insensitive); stable
|
||||
/// across parameter changes.
|
||||
/// Profiles whose outer loop is made of exactly these curves, or pieces of them where other
|
||||
/// curves cut them (order and repeats don't matter; see `sketch::profile_by_curves`). Found
|
||||
/// again on every evaluation, so stable across parameter changes; no match or several is
|
||||
/// an error, never another profile.
|
||||
Curves { loops: Vec<Vec<String>> },
|
||||
/// Complete current boundaries of topology-aware analytic offset associations.
|
||||
/// The local association identity survives retirement and resurrection of arc roles.
|
||||
OffsetBoundaries { associations: Vec<String> },
|
||||
/// Exact whole source coverage, including holes; partial source curves do not match.
|
||||
WholeBoundaries { profiles: Vec<WholeProfileBoundary> },
|
||||
/// The profile containing each sketch point.
|
||||
Points { points: Vec<Vec2> },
|
||||
}
|
||||
@@ -114,6 +135,16 @@ pub enum Direction {
|
||||
Symmetric,
|
||||
}
|
||||
|
||||
/// Angular starts in older designs used the raw axis sign. New revolves measure starts
|
||||
/// along the same sketch-normal sweep direction as the kernel.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RevolveConvention {
|
||||
#[default]
|
||||
LegacyAxisStart,
|
||||
SketchNormal,
|
||||
}
|
||||
|
||||
/// Extent of an extrude.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Extent {
|
||||
@@ -123,7 +154,8 @@ pub struct Extent {
|
||||
/// Optional second side distance (two-sided extrude), along the negative normal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub distance2: Option<String>,
|
||||
/// Start offset from the sketch plane (expression).
|
||||
/// Start offset from the sketch plane (expression), along the sketch normal whatever the
|
||||
/// direction.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub start_offset: Option<String>,
|
||||
/// Through all bodies in the direction(s) instead of `distance`.
|
||||
@@ -158,10 +190,97 @@ pub enum AxisRef {
|
||||
Line { origin: Vec3, dir: Vec3 },
|
||||
}
|
||||
|
||||
/// Persistent cylindrical Emboss selection, authored in the feature component frame.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmbossFace {
|
||||
pub body: String,
|
||||
pub at: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub path: Vec<u64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub author_path: Vec<u64>,
|
||||
}
|
||||
|
||||
impl EmbossFace {
|
||||
/// The selected sketch placement, shared by evaluation and interactive guides.
|
||||
pub fn sketch_frame(&self, doc: &Document, author: u64, sketch_owner: u64, body: &crate::ModelBody) -> Result<crate::Mat> {
|
||||
let owner = doc.model_body_component(body);
|
||||
if sketch_owner == owner {
|
||||
return Self::component_frame(doc, owner, &self.path);
|
||||
}
|
||||
if sketch_owner == author {
|
||||
return Self::component_frame(doc, author, &self.author_path);
|
||||
}
|
||||
let path = doc.first_path(sketch_owner);
|
||||
let mut component = sketch_owner;
|
||||
for _ in 0..64 {
|
||||
if component == 0 {
|
||||
break;
|
||||
}
|
||||
let mut occurrences = doc.occurrences.iter().filter(|o| o.component == component);
|
||||
let occurrence = occurrences.next().ok_or_else(|| DocError::Invalid("sketch placement no longer exists".into()))?;
|
||||
if occurrences.next().is_some() {
|
||||
return Err(DocError::Invalid("sketch placement is ambiguous".into()));
|
||||
}
|
||||
component = occurrence.parent;
|
||||
}
|
||||
if component != 0 {
|
||||
return Err(DocError::Invalid("sketch placement exceeds depth limit".into()));
|
||||
}
|
||||
Self::component_frame(doc, sketch_owner, &path)
|
||||
}
|
||||
|
||||
/// Resolve this exact placement without expanding every assembly instance.
|
||||
pub fn body_frame(&self, doc: &Document, body: &crate::ModelBody) -> Result<crate::Mat> {
|
||||
let owner = doc.model_body_component(body);
|
||||
let mut frame = Self::component_frame(doc, owner, &self.path)?;
|
||||
if let Some(offset) = doc.model_body_offset(body) {
|
||||
frame = crate::mat_mul(&frame, offset);
|
||||
}
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
/// Resolve one saved component path without expanding other occurrences.
|
||||
pub fn component_frame(doc: &Document, owner: u64, path: &[u64]) -> Result<crate::Mat> {
|
||||
if path.len() > 64 || doc.occurrences.len() > 100_000 {
|
||||
return Err(DocError::Invalid("selected cylinder path exceeds work limits".into()));
|
||||
}
|
||||
let mut frame = crate::IDENTITY;
|
||||
let mut component = 0;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for id in path {
|
||||
if *id == 0 || !seen.insert(*id) {
|
||||
return Err(DocError::Invalid("invalid selected cylinder path".into()));
|
||||
}
|
||||
let occurrence = doc
|
||||
.occurrences
|
||||
.iter()
|
||||
.find(|o| o.id == *id && o.parent == component)
|
||||
.ok_or_else(|| DocError::Invalid("selected cylinder placement no longer exists".into()))?;
|
||||
frame = crate::mat_mul(&frame, &occurrence.transform);
|
||||
component = occurrence.component;
|
||||
}
|
||||
if path.is_empty() {
|
||||
frame = doc.component_transform(owner);
|
||||
} else if component != owner {
|
||||
return Err(DocError::Invalid("selected cylinder path belongs to another component".into()));
|
||||
}
|
||||
Ok(frame)
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature definitions.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum FeatureKind {
|
||||
/// A recorded timeline ownership boundary. Geometry is converted from its
|
||||
/// current local frame into the destination component frame exactly once.
|
||||
BodyTransfer {
|
||||
bodies: Vec<String>,
|
||||
from: u64,
|
||||
to: u64,
|
||||
transform: crate::Mat,
|
||||
},
|
||||
Sketch {
|
||||
plane: PlaneRef,
|
||||
sketch: Sketch,
|
||||
@@ -192,6 +311,9 @@ pub enum FeatureKind {
|
||||
/// Symmetric: `angle` split evenly either side of the profile.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
symmetric: bool,
|
||||
/// Missing fields retain the angular placement of existing designs.
|
||||
#[serde(default)]
|
||||
convention: RevolveConvention,
|
||||
/// To Object: turn until the face, vertex or point here.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
to: Option<Vec3>,
|
||||
@@ -223,7 +345,8 @@ pub enum FeatureKind {
|
||||
body: Option<String>,
|
||||
},
|
||||
Box {
|
||||
corner: Vec3,
|
||||
#[serde(with = "point_expr")]
|
||||
corner: PointExpr,
|
||||
length: String,
|
||||
width: String,
|
||||
height: String,
|
||||
@@ -231,7 +354,8 @@ pub enum FeatureKind {
|
||||
operation: Operation,
|
||||
},
|
||||
Cylinder {
|
||||
base: Vec3,
|
||||
#[serde(with = "point_expr")]
|
||||
base: PointExpr,
|
||||
#[serde(default = "z_axis")]
|
||||
axis: Vec3,
|
||||
radius: String,
|
||||
@@ -240,13 +364,15 @@ pub enum FeatureKind {
|
||||
operation: Operation,
|
||||
},
|
||||
Sphere {
|
||||
center: Vec3,
|
||||
#[serde(with = "point_expr")]
|
||||
center: PointExpr,
|
||||
radius: String,
|
||||
#[serde(default)]
|
||||
operation: Operation,
|
||||
},
|
||||
Torus {
|
||||
center: Vec3,
|
||||
#[serde(with = "point_expr")]
|
||||
center: PointExpr,
|
||||
major: String,
|
||||
minor: String,
|
||||
#[serde(default)]
|
||||
@@ -430,13 +556,15 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
},
|
||||
/// Raise (emboss) or sink (deboss) sketch profiles on the planar face they lie on.
|
||||
/// Raise or sink profiles on a planar face, or wrap them on a selected cylindrical wall.
|
||||
Emboss {
|
||||
sketch: u64,
|
||||
profiles: ProfileSel,
|
||||
depth: String,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
deboss: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
face: Option<EmbossFace>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
},
|
||||
@@ -479,7 +607,8 @@ pub enum FeatureKind {
|
||||
/// A helical coil: a circular or square section swept `turns` times about `axis`, rising
|
||||
/// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside).
|
||||
Coil {
|
||||
base: Vec3,
|
||||
#[serde(with = "point_expr")]
|
||||
base: PointExpr,
|
||||
#[serde(default = "z_axis")]
|
||||
axis: Vec3,
|
||||
diameter: String,
|
||||
@@ -754,8 +883,12 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
components: Vec<solvecraft_kernel::StepNode>,
|
||||
},
|
||||
/// A non-parametric base feature: mesh bodies imported from a 3MF or STL file (kept in the
|
||||
/// design). Mesh bodies render, measure, move and export; solid features cannot use them.
|
||||
/// Rebuild an upstream constant-section mesh with analytic planar/cylindrical faces.
|
||||
MeshConvert {
|
||||
body: String,
|
||||
tolerance: String,
|
||||
},
|
||||
/// Mesh bodies imported from a 3MF or STL file, embedded in the design.
|
||||
MeshImport {
|
||||
/// Source file name (display only).
|
||||
file: String,
|
||||
@@ -771,8 +904,16 @@ pub enum FilletStyle {
|
||||
Constant,
|
||||
/// `radius` is the chord: the width across the blend.
|
||||
Chord,
|
||||
/// `radius` at the end of each edge nearest `start`, `radius2` at the other, linear between.
|
||||
Variable { radius2: String, start: Vec3 },
|
||||
/// `radius` at the end of each edge nearest `start`, `radius2` at the other; `mid` radii at
|
||||
/// positions between (0 to 1 from the start end); straight between them, or `smooth`.
|
||||
Variable {
|
||||
radius2: String,
|
||||
start: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
mid: Vec<(f64, String)>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
smooth: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl FilletStyle {
|
||||
@@ -928,7 +1069,108 @@ fn z_axis() -> Vec3 {
|
||||
Vec3::Z
|
||||
}
|
||||
|
||||
/// A point whose coordinates are length expressions (`["basin_width / 2", "10 mm", "0"]`), so
|
||||
/// it follows parameter changes. Primitives are placed by one.
|
||||
pub type PointExpr = [String; 3];
|
||||
|
||||
/// A point as expressions (plain numbers, mm).
|
||||
pub fn point_expr(p: Vec3) -> PointExpr {
|
||||
[format!("{}", p.x), format!("{}", p.y), format!("{}", p.z)]
|
||||
}
|
||||
|
||||
/// Stored as `[x, y, z]`: plain numbers as numbers (as points were before they took
|
||||
/// expressions, so such designs read and write the same), anything else as a string.
|
||||
mod point_expr {
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Coord {
|
||||
Num(f64),
|
||||
Expr(String),
|
||||
}
|
||||
|
||||
pub fn serialize<S: Serializer>(p: &super::PointExpr, s: S) -> Result<S::Ok, S::Error> {
|
||||
let c = |e: &String| match e.parse::<f64>() {
|
||||
Ok(v) if v.is_finite() && format!("{v}") == *e => Coord::Num(v),
|
||||
_ => Coord::Expr(e.clone()),
|
||||
};
|
||||
[c(&p[0]), c(&p[1]), c(&p[2])].serialize(s)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<super::PointExpr, D::Error> {
|
||||
let c = |x: Coord| match x {
|
||||
Coord::Num(v) => format!("{v}"),
|
||||
Coord::Expr(e) => e,
|
||||
};
|
||||
let [x, y, z] = <[Coord; 3]>::deserialize(d)?;
|
||||
Ok([c(x), c(y), c(z)])
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
/// Sketches the feature is built from (profiles, paths, points): deleting one of them
|
||||
/// deletes the feature. Every kind is listed, so a new one has to say what it uses.
|
||||
pub fn input_sketches(&self) -> Vec<u64> {
|
||||
use FeatureKind as K;
|
||||
match self {
|
||||
K::Extrude { sketch, .. }
|
||||
| K::Revolve { sketch, .. }
|
||||
| K::Emboss { sketch, .. }
|
||||
| K::Rib { sketch, .. }
|
||||
| K::SheetBase { sketch, .. }
|
||||
| K::SheetContour { sketch, .. } => vec![*sketch],
|
||||
K::Sweep { sketch, path_sketch, .. } => vec![*sketch, *path_sketch],
|
||||
K::Pipe { path_sketch, .. } | K::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } => vec![*path_sketch],
|
||||
K::Loft { sections, .. } => sections.iter().map(|s| s.sketch).collect(),
|
||||
K::SheetFold { sketch, .. } | K::SplitFace { sketch, .. } | K::Patch { sketch, .. } => sketch.iter().copied().collect(),
|
||||
K::Hole { points, .. } => points.iter().map(|p| p.sketch).collect(),
|
||||
K::Sketch { .. }
|
||||
| K::Pattern { .. }
|
||||
| K::Fillet { .. }
|
||||
| K::Chamfer { .. }
|
||||
| K::Box { .. }
|
||||
| K::Cylinder { .. }
|
||||
| K::Sphere { .. }
|
||||
| K::Torus { .. }
|
||||
| K::Combine { .. }
|
||||
| K::Mirror { .. }
|
||||
| K::Thread { .. }
|
||||
| K::Shell { .. }
|
||||
| K::Draft { .. }
|
||||
| K::ConstructionPlane { .. }
|
||||
| K::Split { .. }
|
||||
| K::ConstructionAxis { .. }
|
||||
| K::ConstructionPoint { .. }
|
||||
| K::Scale { .. }
|
||||
| K::OffsetFace { .. }
|
||||
| K::DeleteFace { .. }
|
||||
| K::BoundingSolid { .. }
|
||||
| K::ReplaceFace { .. }
|
||||
| K::Align { .. }
|
||||
| K::Coil { .. }
|
||||
| K::SheetFlange { .. }
|
||||
| K::SheetHem { .. }
|
||||
| K::SheetUnfold { .. }
|
||||
| K::SheetConvert { .. }
|
||||
| K::Boss { .. }
|
||||
| K::Lip { .. }
|
||||
| K::SnapFit { .. }
|
||||
| K::Rest { .. }
|
||||
| K::BoundaryFill { .. }
|
||||
| K::Remove { .. }
|
||||
| K::Stitch { .. }
|
||||
| K::Thicken { .. }
|
||||
| K::SurfaceExtend { .. }
|
||||
| K::SurfaceTrim { .. }
|
||||
| K::Move { .. }
|
||||
| K::Import { .. }
|
||||
| K::MeshImport { .. }
|
||||
| K::MeshConvert { .. }
|
||||
| K::BodyTransfer { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A sheet metal feature (its result depends on the sheet metal rules).
|
||||
pub fn is_plastic(&self) -> bool {
|
||||
matches!(self, FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. })
|
||||
@@ -950,6 +1192,7 @@ impl FeatureKind {
|
||||
/// Fusion-like type name (timeline tooltip, `inspect`).
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
|
||||
FeatureKind::Sketch { .. } => "Sketch",
|
||||
FeatureKind::Extrude { .. } => "ExtrudeFeature",
|
||||
FeatureKind::Revolve { .. } => "RevolveFeature",
|
||||
@@ -1009,11 +1252,13 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceExtend { .. } => "ExtendFeature",
|
||||
FeatureKind::Import { .. } => "BaseFeature",
|
||||
FeatureKind::MeshImport { .. } => "MeshFeature",
|
||||
FeatureKind::MeshConvert { .. } => "MeshConvertFeature",
|
||||
}
|
||||
}
|
||||
/// Default name prefix (`Extrude` → `Extrude1`).
|
||||
pub fn base_name(&self) -> &'static str {
|
||||
match self {
|
||||
FeatureKind::BodyTransfer { .. } => "BodyTransfer",
|
||||
FeatureKind::Sketch { .. } => "Sketch",
|
||||
FeatureKind::Extrude { .. } => "Extrude",
|
||||
FeatureKind::Revolve { .. } => "Revolve",
|
||||
@@ -1073,11 +1318,42 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceExtend { .. } => "Extend",
|
||||
FeatureKind::Import { .. } => "Import",
|
||||
FeatureKind::MeshImport { .. } => "Mesh",
|
||||
FeatureKind::MeshConvert { .. } => "ConvertMesh",
|
||||
}
|
||||
}
|
||||
/// Every expression the feature uses.
|
||||
/// Points on faces the feature refers to (shell, draft, offset and replace faces, a hole's
|
||||
/// placement), for persistent face names.
|
||||
/// The feature's profile selections, each with its sketch.
|
||||
pub fn profile_sels_mut(&mut self) -> Vec<(u64, &mut ProfileSel)> {
|
||||
match self {
|
||||
FeatureKind::Extrude { sketch, profiles, .. }
|
||||
| FeatureKind::Revolve { sketch, profiles, .. }
|
||||
| FeatureKind::Sweep { sketch, profiles, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles, .. }
|
||||
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sk), profiles, .. } => vec![(*sk, profiles)],
|
||||
FeatureKind::Loft { sections, .. } => sections.iter_mut().filter(|s| s.point.is_none()).map(|s| (s.sketch, &mut s.profiles)).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Profile selections and their source sketches without cloning feature data.
|
||||
pub fn profile_sels(&self) -> Vec<(u64, &ProfileSel)> {
|
||||
match self {
|
||||
FeatureKind::Extrude { sketch, profiles, .. }
|
||||
| FeatureKind::Revolve { sketch, profiles, .. }
|
||||
| FeatureKind::Sweep { sketch, profiles, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles, .. }
|
||||
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sketch), profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Loft { sections, .. } => {
|
||||
sections.iter().filter(|section| section.point.is_none()).map(|section| (section.sketch, §ion.profiles)).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> {
|
||||
match self {
|
||||
FeatureKind::Shell { faces, .. }
|
||||
@@ -1091,6 +1367,8 @@ impl FeatureKind {
|
||||
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
|
||||
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
|
||||
FeatureKind::Thread { face, .. } => vec![face],
|
||||
FeatureKind::Emboss { face: Some(face), .. } => vec![&mut face.at],
|
||||
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1125,6 +1403,9 @@ impl FeatureKind {
|
||||
if let Some(d) = &extent.taper {
|
||||
v.push(d);
|
||||
}
|
||||
if let Some(d) = &extent.to_offset {
|
||||
v.push(d);
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { angle, angle2, .. } => {
|
||||
v.push(angle);
|
||||
@@ -1132,8 +1413,9 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::Fillet { radius, style, .. } => {
|
||||
v.push(radius);
|
||||
if let FilletStyle::Variable { radius2, .. } = style {
|
||||
if let FilletStyle::Variable { radius2, mid, .. } = style {
|
||||
v.push(radius2);
|
||||
v.extend(mid.iter().map(|(_, r)| r.as_str()));
|
||||
}
|
||||
}
|
||||
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
|
||||
@@ -1141,10 +1423,22 @@ impl FeatureKind {
|
||||
v.extend(distance2.iter().map(String::as_str));
|
||||
v.extend(angle.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::Box { length, width, height, .. } => v.extend([length.as_str(), width, height]),
|
||||
FeatureKind::Cylinder { radius, height, .. } => v.extend([radius.as_str(), height]),
|
||||
FeatureKind::Sphere { radius, .. } => v.push(radius),
|
||||
FeatureKind::Torus { major, minor, .. } => v.extend([major.as_str(), minor]),
|
||||
FeatureKind::Box { length, width, height, corner, .. } => {
|
||||
v.extend([length.as_str(), width, height]);
|
||||
v.extend(corner.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::Cylinder { radius, height, base, .. } => {
|
||||
v.extend([radius.as_str(), height]);
|
||||
v.extend(base.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::Sphere { radius, center, .. } => {
|
||||
v.push(radius);
|
||||
v.extend(center.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::Torus { major, minor, center, .. } => {
|
||||
v.extend([major.as_str(), minor]);
|
||||
v.extend(center.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::Combine { .. } => {}
|
||||
FeatureKind::Pattern { pattern, .. } => match pattern {
|
||||
PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => {
|
||||
@@ -1221,9 +1515,10 @@ impl FeatureKind {
|
||||
v.extend(gap.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::SheetBase { .. } | FeatureKind::SheetUnfold { .. } | FeatureKind::SheetConvert { .. } => {}
|
||||
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, .. } => {
|
||||
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, base, .. } => {
|
||||
v.extend([diameter.as_str(), pitch, turns, section_size]);
|
||||
v.extend(start_angle.iter().map(String::as_str));
|
||||
v.extend(base.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::Rib { thickness, depth, .. } => {
|
||||
v.push(thickness);
|
||||
@@ -1232,11 +1527,12 @@ impl FeatureKind {
|
||||
FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v),
|
||||
FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } | FeatureKind::DeleteFace { .. } => {}
|
||||
FeatureKind::Stitch { tolerance, .. } => v.push(tolerance),
|
||||
FeatureKind::MeshConvert { tolerance, .. } => v.push(tolerance),
|
||||
FeatureKind::Thicken { thickness, .. } => v.push(thickness),
|
||||
FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v),
|
||||
FeatureKind::SurfaceExtend { distance, .. } => v.push(distance),
|
||||
FeatureKind::Patch { .. } => {}
|
||||
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {}
|
||||
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::BodyTransfer { .. } => {}
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -1248,7 +1544,8 @@ pub struct Feature {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub suppressed: bool,
|
||||
/// Names for the bodies this feature creates (in order); missing ones get `BodyN`.
|
||||
/// Names for the bodies this feature creates (in order); missing ones are numbered after the
|
||||
/// first (`Ribs (4)`), or get `BodyN` when there is none (or see `shell_piece_order`).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub body_names: Vec<String>,
|
||||
/// Parameter names of the feature's inputs (see `FeatureKind::inputs`), e.g. `d3`.
|
||||
@@ -1271,6 +1568,13 @@ pub struct Feature {
|
||||
/// component's bodies.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub participants: Vec<String>,
|
||||
/// The pieces this feature splits a body into take its body names in the kernel's shell order,
|
||||
/// as builds before format 14 named them, instead of after their faces (`lineage::order_lumps`).
|
||||
/// Set on the features of designs saved before then, whose stored body names mean that order.
|
||||
/// Such features also name the bodies past their `body_names` `BodyN`, as those builds did,
|
||||
/// where newer ones number them after the first name (`Ribs (4)`, #373).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub shell_piece_order: bool,
|
||||
#[serde(flatten)]
|
||||
pub kind: FeatureKind,
|
||||
}
|
||||
@@ -1442,7 +1746,7 @@ impl Document {
|
||||
/// else its component's, else its material's look, else the colour it was imported with.
|
||||
/// (Faces may have their own: `face_looks`.)
|
||||
pub fn body_color(&self, b: &crate::ModelBody) -> Option<[f32; 3]> {
|
||||
self.body_look(&b.name, b.feature).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color))
|
||||
self.model_body_look(b).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color))
|
||||
}
|
||||
|
||||
/// The model with each body carrying the colour, opacity and face colours it is shown with
|
||||
@@ -1451,17 +1755,12 @@ impl Document {
|
||||
let mut st = state.clone();
|
||||
for b in &mut st.bodies {
|
||||
let c = self.body_color(b);
|
||||
let look = self.body_look(&b.name, b.feature);
|
||||
let faces: Vec<solvecraft_kernel::FacePaint> = self
|
||||
.face_colors(b)
|
||||
.into_iter()
|
||||
.map(|(face, l)| solvecraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32 })
|
||||
.collect();
|
||||
let opacity = look
|
||||
.map(|l| l.opacity as f32)
|
||||
.or_else(|| b.body.paint().map(|p| p.opacity))
|
||||
.or_else(|| b.imported.as_ref().and_then(|i| i.opacity))
|
||||
.unwrap_or(1.0);
|
||||
let opacity = self.model_body_opacity(b) as f32;
|
||||
let paint = solvecraft_kernel::Paint { opacity, faces };
|
||||
if c != b.body.color() || b.body.paint() != Some(&paint) {
|
||||
b.body = b.body.clone().with_color(c).with_paint(Some(paint));
|
||||
@@ -1477,9 +1776,36 @@ impl Document {
|
||||
|
||||
/// The component a body belongs to: where it was moved, else its feature's.
|
||||
pub fn body_component(&self, body: &str, feature: u64) -> u64 {
|
||||
self.features
|
||||
.iter()
|
||||
.take(self.marker.unwrap_or(usize::MAX))
|
||||
.rev()
|
||||
.find_map(|f| {
|
||||
if f.suppressed {
|
||||
return None;
|
||||
}
|
||||
match &f.kind {
|
||||
FeatureKind::BodyTransfer { bodies, to, .. } if bodies.iter().any(|b| b == body) => Some(*to),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| self.legacy_body_component(body, feature))
|
||||
}
|
||||
|
||||
pub fn legacy_body_component(&self, body: &str, feature: u64) -> u64 {
|
||||
self.body_components.get(body).copied().unwrap_or_else(|| self.feature(feature).map(|f| f.component).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Ownership in this evaluated timeline state, rather than a later transfer.
|
||||
pub fn model_body_component(&self, body: &crate::ModelBody) -> u64 {
|
||||
if body.transferred || body.source_name.is_some() { body.component } else { self.legacy_body_component(&body.name, body.feature) }
|
||||
}
|
||||
|
||||
/// Recorded transfers already converted geometry into the new local frame.
|
||||
pub fn model_body_offset(&self, body: &crate::ModelBody) -> Option<&crate::Mat> {
|
||||
if body.transferred { None } else { self.body_offsets.get(&body.name) }
|
||||
}
|
||||
|
||||
/// Add a component under `parent` and return its id.
|
||||
pub fn add_component(&mut self, name: Option<&str>, parent: u64) -> Result<u64> {
|
||||
if parent != 0 && !self.components.iter().any(|c| c.id == parent) {
|
||||
@@ -1560,6 +1886,12 @@ impl Document {
|
||||
if self.features.len() >= MAX_FEATURES {
|
||||
return Err(DocError::Invalid("too many features".into()));
|
||||
}
|
||||
let component = if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &kind {
|
||||
crate::validate_body_transfer(self, bodies, *from, *to, transform)?;
|
||||
*to
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let id = self.next_id.max(1);
|
||||
self.next_id = id + 1;
|
||||
let name = match name {
|
||||
@@ -1572,11 +1904,12 @@ impl Document {
|
||||
suppressed: false,
|
||||
body_names: Vec::new(),
|
||||
param_names: Vec::new(),
|
||||
component: 0,
|
||||
component,
|
||||
edge_refs: Vec::new(),
|
||||
edge_names: Vec::new(),
|
||||
face_names: Vec::new(),
|
||||
participants: Vec::new(),
|
||||
shell_piece_order: false,
|
||||
kind,
|
||||
};
|
||||
match self.marker {
|
||||
@@ -1594,21 +1927,27 @@ impl Document {
|
||||
pub fn delete_feature(&mut self, id: u64) -> Result<Vec<u64>> {
|
||||
let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?;
|
||||
let mut gone = vec![id];
|
||||
for f in &self.features {
|
||||
match &f.kind {
|
||||
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } if *sketch == id => gone.push(f.id),
|
||||
FeatureKind::Sweep { sketch, path_sketch, .. } if *sketch == id || *path_sketch == id => gone.push(f.id),
|
||||
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.sketch == id) => gone.push(f.id),
|
||||
FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id),
|
||||
FeatureKind::Emboss { sketch, .. } | FeatureKind::Rib { sketch, .. } if *sketch == id => gone.push(f.id),
|
||||
FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } if *sketch == id => gone.push(f.id),
|
||||
FeatureKind::SheetFold { sketch: Some(s), .. } | FeatureKind::SplitFace { sketch: Some(s), .. } if *s == id => gone.push(f.id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id));
|
||||
let gone_params: Vec<String> = self.features.iter().filter(|f| gone.contains(&f.id)).flat_map(|f| f.param_names.clone()).collect();
|
||||
self.configs
|
||||
.rules
|
||||
.retain(|rule| !rule.references_features(&gone) && !rule.parameter_references().iter().any(|name| gone_params.contains(name)));
|
||||
self.features.retain(|f| !gone.contains(&f.id));
|
||||
// Configuration columns of deleted features go with them.
|
||||
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect();
|
||||
let keep: Vec<bool> = self
|
||||
.configs
|
||||
.columns
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
crate::config::Column::Suppress(id) => !gone.contains(id),
|
||||
crate::config::Column::Param(name) => !gone_params.contains(name),
|
||||
crate::config::Column::FaceAppearance(target) => !gone.contains(&target.origin_feature),
|
||||
_ => true,
|
||||
})
|
||||
.collect();
|
||||
self.appearances
|
||||
.faces
|
||||
.retain(|face| !face.strict_name || !face.name.as_ref().is_some_and(|name| gone.iter().any(|id| name.starts_with(&format!("F{id}:")))));
|
||||
if keep.contains(&false) {
|
||||
let mut k = keep.iter();
|
||||
self.configs.columns.retain(|_| k.next().copied().unwrap_or(true));
|
||||
@@ -1635,6 +1974,19 @@ impl Document {
|
||||
|
||||
fn referenced_names(&self) -> Vec<String> {
|
||||
let mut used: Vec<String> = Vec::new();
|
||||
for rule in &self.configs.rules {
|
||||
used.extend(rule.parameter_references());
|
||||
}
|
||||
for (index, column) in self.configs.columns.iter().enumerate() {
|
||||
if let crate::config::Column::Param(name) = column {
|
||||
used.push(name.clone());
|
||||
for row in &self.configs.rows {
|
||||
if let Some(value) = row.cells.get(index) {
|
||||
used.extend(expr::references(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for f in &self.features {
|
||||
for e in f.kind.expressions() {
|
||||
used.extend(expr::references(e));
|
||||
@@ -1794,9 +2146,10 @@ impl Document {
|
||||
| PlaneRef::TwoEdges { .. }
|
||||
| PlaneRef::ThreePoints { .. }
|
||||
| PlaneRef::Tangent { .. }
|
||||
| PlaneRef::AlongPath { .. } => {
|
||||
| PlaneRef::AlongPath { .. }
|
||||
| PlaneRef::Perpendicular { .. } => {
|
||||
let st = st.ok_or_else(|| DocError::Invalid("this plane is built from the model: evaluate first".into()))?;
|
||||
crate::construct::resolve_geo_plane(&crate::construct::Ctx { doc: self, vals, st }, p)
|
||||
crate::construct::resolve_geo_plane(&crate::construct::Ctx { doc: self, vals, st, depth }, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1826,8 +2179,33 @@ impl Document {
|
||||
c.kind.set_value(v);
|
||||
}
|
||||
}
|
||||
for c in &sk.constraints {
|
||||
sk.validate(&c.kind)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply parameter values to a sketch's dimensions and solve it. `stored` are the values
|
||||
/// its geometry is solved for (the stored sketch's): what the solve cannot reach in one
|
||||
/// jump it reaches by stepping the changed values from there, and an edit that would turn
|
||||
/// the sketch over is refused. Offsets whose sources moved are refreshed and solved again:
|
||||
/// from the stored state again after a refused or failed first solve (the geometry is still
|
||||
/// the stored one), from the solved one otherwise.
|
||||
pub fn solve_sketch(
|
||||
&self,
|
||||
vals: &std::collections::BTreeMap<String, expr::Value>,
|
||||
sk: &mut Sketch,
|
||||
stored: &solvecraft_sketch::DrivingValues,
|
||||
) -> Result<solvecraft_sketch::SolveReport> {
|
||||
self.apply_dimension_values(vals, sk)?;
|
||||
let mut report = solvecraft_sketch::solve_after_value_edit(sk, stored);
|
||||
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
|
||||
// Source dimensions may have moved curves during the solve.
|
||||
sk.refresh_offsets()?;
|
||||
report = if report.ok() { solvecraft_sketch::solve(sk) } else { solvecraft_sketch::solve_after_value_edit(sk, stored) };
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
|
||||
/// Density of the default material (g/cm³), as Fusion's default.
|
||||
|
||||
+1407
-283
File diff suppressed because it is too large
Load Diff
+364
-62
@@ -2,18 +2,66 @@
|
||||
//! kernel's extrude, boolean, offset and transform operations).
|
||||
|
||||
use super::*;
|
||||
use crate::EmbossFace;
|
||||
use crate::apply_point;
|
||||
use crate::sheet;
|
||||
use solvecraft_geom::{Loop2, Seg2};
|
||||
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
match &f.kind {
|
||||
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets } => {
|
||||
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
|
||||
let regions = select_profiles(ss, profiles)?;
|
||||
let d = val(vals, depth, Kind::Length)?;
|
||||
if !(d > 0.0) {
|
||||
return Err(DocError::Invalid("the emboss depth must be positive".into()));
|
||||
}
|
||||
if let Some(face) = face {
|
||||
if !targets.is_empty() && (targets.len() != 1 || targets.first() != Some(&face.body)) {
|
||||
return Err(DocError::Invalid("cylinder emboss must keep its selected target body".into()));
|
||||
}
|
||||
let i =
|
||||
st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("emboss body `{}`", face.body)))?;
|
||||
let mb = st.bodies.get(i).cloned().ok_or_else(|| DocError::Invalid("emboss body".into()))?;
|
||||
let body_frame = face.body_frame(doc, &mb)?;
|
||||
let inverse = crate::mat_inverse(&body_frame).ok_or_else(|| DocError::Invalid("invalid cylinder body frame".into()))?;
|
||||
let sketch_owner = doc.feature(ss.feature).map(|f| f.component).unwrap_or(0);
|
||||
let author_frame = EmbossFace::component_frame(doc, f.component, &face.author_path)?;
|
||||
let sketch_frame = face.sketch_frame(doc, f.component, sketch_owner, &mb)?;
|
||||
let sketch_transform = crate::mat_mul(&inverse, &sketch_frame);
|
||||
let plane = Plane::new(
|
||||
crate::apply_point(&sketch_transform, ss.plane.origin),
|
||||
crate::apply_vector(&sketch_transform, ss.plane.x),
|
||||
crate::apply_vector(&sketch_transform, ss.plane.y),
|
||||
)
|
||||
.ok_or_else(|| DocError::Invalid("invalid cylinder sketch frame".into()))?;
|
||||
let mut at = crate::apply_point(&crate::mat_mul(&inverse, &author_frame), face.at);
|
||||
let name = f
|
||||
.face_names
|
||||
.first()
|
||||
.filter(|name| !name.is_empty())
|
||||
.ok_or_else(|| DocError::Invalid("selected cylinder face has no persistent identity".into()))?;
|
||||
{
|
||||
let mut scoped = ModelState::default();
|
||||
scoped.bodies.push(mb.clone());
|
||||
let (picked, split) = crate::naming::point_on_face(&scoped, name, at)
|
||||
.ok_or_else(|| DocError::Invalid("selected cylinder face no longer exists".into()))?;
|
||||
if split {
|
||||
return Err(DocError::Invalid("selected cylinder face was split".into()));
|
||||
}
|
||||
let (index, _, _) = crate::project::nearest_face(&mb.mesh(), picked)
|
||||
.ok_or_else(|| DocError::Invalid("selected cylinder face cannot be resolved".into()))?;
|
||||
if !crate::naming::face_has_name(&mb, index as usize, name) {
|
||||
return Err(DocError::Invalid("selected cylinder face is ambiguous".into()));
|
||||
}
|
||||
at = kernel::cylinder_emboss_anchor(&mb.body, index as usize, picked)?;
|
||||
}
|
||||
let result = kernel::emboss_cylinder_at(&mb.body, at, &plane, ®ions, if *deboss { -d } else { d })?;
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, result, mb.feature);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let (a, b) = if *deboss { (-d, 0.0) } else { (0.0, d) };
|
||||
let tools = kernel::extrude(&ss.plane, ®ions, a, b)?;
|
||||
if st.bodies.is_empty() {
|
||||
@@ -242,12 +290,22 @@ fn point_on_polyline(pts: &[Vec2], d: f64) -> Vec2 {
|
||||
}
|
||||
|
||||
/// Rebuild a sheet body's solid from its model.
|
||||
fn replace_sheet_body(mb: &mut ModelBody, body: Body) {
|
||||
let mut next = ModelBody::new(mb.name.clone(), body, mb.feature);
|
||||
next.component = mb.component;
|
||||
next.transferred = mb.transferred;
|
||||
next.source_name = mb.source_name.clone();
|
||||
next.source_transform = mb.source_transform;
|
||||
next.imported = mb.imported.clone();
|
||||
*mb = next;
|
||||
}
|
||||
|
||||
fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> {
|
||||
let sheet = st.sheets.get(idx).cloned().ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
let solid = sheet.solid()?;
|
||||
let i = st.bodies.iter().position(|b| b.name == sheet.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", sheet.body)))?;
|
||||
if let Some(mb) = st.bodies.get_mut(i) {
|
||||
*mb = ModelBody::new(mb.name.clone(), solid, mb.feature);
|
||||
replace_sheet_body(mb, solid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -466,7 +524,7 @@ pub(super) fn sheet_eval(
|
||||
sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect();
|
||||
let solid = sh.solid()?;
|
||||
if let Some(b) = st.bodies.iter_mut().find(|b| &b.name == body) {
|
||||
*b = ModelBody::new(b.name.clone(), solid, b.feature);
|
||||
replace_sheet_body(b, solid);
|
||||
}
|
||||
st.sheets.retain(|s| &s.body != body);
|
||||
st.sheets.push(sh);
|
||||
@@ -481,79 +539,301 @@ fn poly_area(p: &[Vec2]) -> f64 {
|
||||
(0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
/// An extrude cut through sheet metal: the profiles become cut-outs of the flat pattern
|
||||
/// (parallel to a panel, through its thickness). Returns the sheet bodies handled.
|
||||
pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], targets: &[String]) -> Result<Vec<String>> {
|
||||
let FeatureKind::Extrude { sketch, profiles, .. } = &f.kind else { return Ok(Vec::new()) };
|
||||
/// Current unmodified extrusion caps, including placement, certify the tool's depth.
|
||||
/// Tessellation can drift in thickness at distant coordinates and cannot certify a slab.
|
||||
fn sheet_tool_span(body: &Body, source: &Plane) -> Option<(f64, f64, f64, Option<(Plane, Region2)>)> {
|
||||
use kernel::FaceSource;
|
||||
#[derive(PartialEq)]
|
||||
enum Signature<'a> {
|
||||
Raw(&'a Region2),
|
||||
Certified(&'a [String], &'a [Vec<String>]),
|
||||
}
|
||||
let sources = body.face_sources()?;
|
||||
if sources.len() != body.face_count() || sources.len() > 16_384 {
|
||||
return None;
|
||||
}
|
||||
let n = source.normal();
|
||||
let mut family = None;
|
||||
let mut caps = [None, None];
|
||||
let mut projection_roundoff: f64 = 0.0;
|
||||
let mut footprint = None;
|
||||
for (index, entry) in sources.iter().enumerate() {
|
||||
let (group, cap) = match entry.as_ref()? {
|
||||
FaceSource::ExtrudeSide { .. } => (None, None),
|
||||
FaceSource::ExtrudeCap { high, region, plane } => (None, Some((*high, *plane, Signature::Raw(region)))),
|
||||
FaceSource::CertifiedExtrudeSide { producer, sketch, .. } => (Some((*producer, *sketch)), None),
|
||||
FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, plane } => {
|
||||
(Some((*producer, *sketch)), Some((*high, *plane, Signature::Certified(outer, holes))))
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
match family {
|
||||
None => family = Some(group),
|
||||
Some(previous) if previous != group => return None,
|
||||
_ => {}
|
||||
}
|
||||
if let Some((high, plane, signature)) = cap {
|
||||
if high && let Signature::Raw(region) = &signature {
|
||||
footprint = Some((plane, (*region).clone()));
|
||||
}
|
||||
let valid = |p: &Plane| {
|
||||
p.origin.is_finite()
|
||||
&& p.x.is_finite()
|
||||
&& p.y.is_finite()
|
||||
&& (p.x.len2() - 1.0).abs() <= 1e-10
|
||||
&& (p.y.len2() - 1.0).abs() <= 1e-10
|
||||
&& p.x.dot(p.y).abs() <= 1e-10
|
||||
&& p.normal().cross(n).len() <= 1e-10
|
||||
};
|
||||
let current = body.planar_face(index)?;
|
||||
if !valid(&plane) || !valid(¤t) {
|
||||
return None;
|
||||
}
|
||||
// Bound floating point projection separately from model geometry tolerance.
|
||||
let roundoff = 128.0 * f64::EPSILON * (plane.origin.len() + current.origin.len() + source.origin.len()).max(1.0);
|
||||
if roundoff > 1e-7 || (current.origin - plane.origin).dot(n).abs() > roundoff {
|
||||
return None;
|
||||
}
|
||||
projection_roundoff = projection_roundoff.max(roundoff);
|
||||
let distance = (plane.origin - source.origin).dot(n);
|
||||
if !distance.is_finite() || caps[usize::from(high)].is_some() {
|
||||
return None;
|
||||
}
|
||||
caps[usize::from(high)] = Some((distance, signature));
|
||||
}
|
||||
}
|
||||
let [Some((low, low_signature)), Some((high, high_signature))] = caps else { return None };
|
||||
(low_signature == high_signature && low != high).then_some((low.min(high), low.max(high), projection_roundoff, footprint))
|
||||
}
|
||||
|
||||
/// Exact prismatic machining, retained in sheet material/depth metadata.
|
||||
pub(super) fn sheet_cut(
|
||||
f: &Feature,
|
||||
st: &mut ModelState,
|
||||
tools: &[Body],
|
||||
targets: &[String],
|
||||
constructor: Option<&ExtrudeConstructor>,
|
||||
) -> Result<Vec<String>> {
|
||||
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
|
||||
if extent.taper.is_some() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let targets = if targets.is_empty() { &f.participants } else { targets };
|
||||
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
|
||||
let eligible = |original: &crate::sheet::SheetBody| {
|
||||
all || if targets.is_empty() {
|
||||
st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id))
|
||||
} else {
|
||||
targets.contains(&original.body)
|
||||
}
|
||||
};
|
||||
if !st.sheets.iter().any(&eligible) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let n = ss.plane.normal();
|
||||
// How far the tools reach along the sketch normal.
|
||||
let mut work = sheet::FlatWork::new();
|
||||
let faces = tools.iter().fold(0usize, |count, body| count.saturating_add(body.face_count()));
|
||||
if tools.len() > 1000 || faces > 16_384 {
|
||||
return Err(DocError::Invalid("sheet machining tools exceed the bounded geometry budget".into()));
|
||||
}
|
||||
work.charge(faces.saturating_mul(5))?;
|
||||
let mut span = (f64::MAX, f64::MIN);
|
||||
for t in tools {
|
||||
for p in t.tessellate((t.size() * 1e-3).max(1e-3))?.positions {
|
||||
let d = (p - ss.plane.origin).dot(n);
|
||||
span = (span.0.min(d), span.1.max(d));
|
||||
let mut cap_roundoff: f64 = 0.0;
|
||||
let mut current_footprints = Vec::new();
|
||||
for tool in tools {
|
||||
let Some((lo, hi, roundoff, footprint)) = sheet_tool_span(tool, &ss.plane) else {
|
||||
// Modified tools need a current geometric prism certificate. The generic
|
||||
// sheet preflight permits actual no-change, otherwise refuses stale metadata.
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if span.0 != f64::MAX && ((span.0 - lo).abs() > cap_roundoff + roundoff || (span.1 - hi).abs() > cap_roundoff + roundoff) {
|
||||
// Different profile tools cannot be represented as one filled depth interval.
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
span = (span.0.min(lo), span.1.max(hi));
|
||||
cap_roundoff = cap_roundoff.max(roundoff);
|
||||
if let Some(footprint) = footprint {
|
||||
current_footprints.push(footprint);
|
||||
}
|
||||
}
|
||||
let mut handled = Vec::new();
|
||||
for i in 0..st.sheets.len() {
|
||||
let Some(sh) = st.sheets.get(i).cloned() else { continue };
|
||||
if !targets.is_empty() && !targets.contains(&sh.body) {
|
||||
// Only untrimmed Distance tools may use their exact authored scalar interval.
|
||||
// The cap certificate still qualifies their current physical slab above.
|
||||
let selected_edges =
|
||||
regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).fold(0usize, usize::saturating_add);
|
||||
work.charge(faces.saturating_mul(selected_edges).saturating_mul(16))?;
|
||||
let nominal = constructor.filter(|c| c.plane == ss.plane).and_then(|c| {
|
||||
let placed_basis = Plane::new(c.plane.origin, c.plane.x, c.plane.y)?;
|
||||
(current_footprints.len() == tools.len()
|
||||
&& current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r)))
|
||||
.then_some(c.interval)
|
||||
});
|
||||
let mut plans = Vec::new();
|
||||
'sheets: for original in &st.sheets {
|
||||
if !eligible(original) {
|
||||
continue;
|
||||
}
|
||||
let candidates: Vec<(Option<usize>, Region2)> = if sh.flat {
|
||||
if sh.frame.normal().cross(n).len() > 1e-6 {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![(None, Region2 { outer: Loop2::polygon(&sh.outline()?.into_iter().next().unwrap_or_default()), holes: Vec::new() })]
|
||||
}
|
||||
} else {
|
||||
sh.parallel_panels(&ss.plane)
|
||||
let Some(mb) = st.body(&original.body) else {
|
||||
continue;
|
||||
};
|
||||
let mut added = Vec::new();
|
||||
for (pi, panel) in candidates {
|
||||
// The panel's faces along the sketch normal must both be inside the cut.
|
||||
let w = sh.world(pi);
|
||||
let z0 = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n);
|
||||
let z1 = (apply_point(&w, Vec3::Z * sh.t) - ss.plane.origin).dot(n);
|
||||
if z0.min(z1) < span.0 - 1e-6 || z0.max(z1) > span.1 + 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let outline = if sh.flat { sh.outline()?.into_iter().flatten().collect::<Vec<_>>() } else { panel.outer.polyline(1e-3) };
|
||||
for r in ®ions {
|
||||
let Some(flat) = sh.region_to_flat(pi, &ss.plane, r) else { continue };
|
||||
let fp = flat.outer.polyline(1e-3);
|
||||
let overlaps = fp.iter().any(|p| point_in_poly(&outline, *p)) || outline.iter().any(|p| point_in_poly(&fp, *p));
|
||||
if overlaps && !added.iter().any(|x: &Region2| x == &flat) {
|
||||
added.push(flat);
|
||||
let mut sh = original.clone();
|
||||
if *operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
continue;
|
||||
}
|
||||
if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, *operation)? {
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
continue;
|
||||
}
|
||||
for bends in sh.unmodeled_solids(&ss.plane)? {
|
||||
for tool in tools {
|
||||
if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() {
|
||||
return Err(DocError::Invalid(
|
||||
"this machining intersects a folded bend or a nonparallel panel; unfold the sheet before machining that material".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if added.is_empty() {
|
||||
continue;
|
||||
let candidates = if sh.flat {
|
||||
if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|r| (None, r)).collect() }
|
||||
} else {
|
||||
sh.material_panels(Some(&ss.plane), &mut work)?
|
||||
};
|
||||
let mut modeled = false;
|
||||
for (pi, panel) in candidates {
|
||||
let w = if sh.flat { sh.world(None) } else { sh.world(pi) };
|
||||
let zorigin = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n);
|
||||
let sign = crate::apply_vector(&w, Vec3::Z).dot(n);
|
||||
if sign.abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = ((span.0 - zorigin) / sign, (span.1 - zorigin) / sign);
|
||||
let roundoff = (cap_roundoff
|
||||
+ 128.0 * f64::EPSILON * (apply_point(&w, Vec3::ZERO).len() + ss.plane.origin.len() + span.0.abs() + span.1.abs() + sh.t).max(1.0))
|
||||
/ sign.abs();
|
||||
let nominal_depth = if apply_point(&w, Vec3::ZERO) == ss.plane.origin
|
||||
&& let Some((lo, hi)) = nominal
|
||||
&& (span.0 - lo).abs() <= roundoff
|
||||
&& (span.1 - hi).abs() <= roundoff
|
||||
{
|
||||
// Equality of the two frame origins proves zero origin displacement;
|
||||
// use authored distances, never an inferred endpoint coincidence.
|
||||
let normal = crate::apply_vector(&w, Vec3::X).cross(crate::apply_vector(&w, Vec3::Y));
|
||||
let exact_sign = if normal == n {
|
||||
1.0
|
||||
} else if normal == -n {
|
||||
-1.0
|
||||
} else {
|
||||
sign
|
||||
};
|
||||
Some((lo / exact_sign, hi / exact_sign))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if nominal_depth.is_none() && roundoff > 1e-7 {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
|
||||
plans.push((original.body.clone(), Some((original.clone(), mb.body.clone()))));
|
||||
continue 'sheets;
|
||||
}
|
||||
let (a, b) = nominal_depth.unwrap_or((a, b));
|
||||
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
|
||||
// Sub-resolution layers must not be silently erased by endpoint snapping.
|
||||
let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6);
|
||||
let mapped = if extent.to.is_some() && current_footprints.len() == tools.len() {
|
||||
current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
|
||||
} else {
|
||||
regions.iter().map(|r| sh.region_to_flat(pi, &ss.plane, r)).collect::<Result<Vec<_>>>()?
|
||||
};
|
||||
if *operation == Operation::Intersect {
|
||||
if z1 <= z0 {
|
||||
sh.holes.push(panel);
|
||||
modeled = true;
|
||||
continue;
|
||||
}
|
||||
let tool = sheet::material_union(mapped, &mut work)?;
|
||||
let retained = sheet::material_boolean(std::slice::from_ref(&panel), &tool, kernel::BoolOp::Intersect, &mut work)?;
|
||||
let removed = sheet::material_boolean(std::slice::from_ref(&panel), &retained, kernel::BoolOp::Cut, &mut work)?;
|
||||
if removed.is_empty() && !sh.material_outside_depth(&panel, z0, z1, &mut work)? {
|
||||
modeled = true;
|
||||
continue;
|
||||
}
|
||||
if !retained.is_empty() && !depth_resolved {
|
||||
return Err(DocError::Invalid(
|
||||
"sheet machining depth is below the supported 0.000001 mm extrusion resolution; increase the layer depth".into(),
|
||||
));
|
||||
}
|
||||
sh.holes.extend(removed);
|
||||
// Preserve original stock thickness and prior machining. Intersection
|
||||
// removes the complementary layers above/below every surviving region.
|
||||
for region in retained {
|
||||
if z0 > 0.0 {
|
||||
sh.record_pocket(region.clone(), 0.0, z0, &mut work)?;
|
||||
}
|
||||
if z1 < sh.t {
|
||||
sh.record_pocket(region, z1, sh.t, &mut work)?;
|
||||
}
|
||||
}
|
||||
modeled = true;
|
||||
} else if z1 > z0 {
|
||||
for flat in mapped {
|
||||
let clipped =
|
||||
sheet::material_boolean(std::slice::from_ref(&panel), std::slice::from_ref(&flat), kernel::BoolOp::Intersect, &mut work)?;
|
||||
if !clipped.is_empty() && !depth_resolved {
|
||||
return Err(DocError::Invalid(
|
||||
"sheet machining depth is below the supported 0.000001 mm extrusion resolution; increase the layer depth".into(),
|
||||
));
|
||||
}
|
||||
if !clipped.is_empty() && z0 <= 1e-7 && z1 >= sh.t - 1e-7 {
|
||||
// Keep a single original tool boundary across structural seams.
|
||||
// Re-clipping notch polygons creates coincident seam endpoints.
|
||||
if !sh.holes.contains(&flat) {
|
||||
sh.holes.push(flat);
|
||||
}
|
||||
modeled = true;
|
||||
} else {
|
||||
for region in clipped {
|
||||
sh.record_pocket(region, z0, z1, &mut work)?;
|
||||
modeled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = st.sheets.get_mut(i) {
|
||||
s.holes.extend(added);
|
||||
if !modeled {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
} else if sh.flat_material()?.is_empty() {
|
||||
// Empty exact material removes geometry and metadata together. Budget or
|
||||
// topology errors propagate; they are never mistaken for an empty blank.
|
||||
plans.push((sh.body.clone(), None));
|
||||
} else {
|
||||
let body = sh.solid()?;
|
||||
plans.push((sh.body.clone(), Some((sh, body))));
|
||||
}
|
||||
}
|
||||
let handled = plans.iter().map(|(name, _)| name.clone()).collect();
|
||||
for (name, result) in plans {
|
||||
match result {
|
||||
Some((sh, body)) => {
|
||||
if let Some(mb) = st.bodies.iter_mut().find(|b| b.name == name) {
|
||||
replace_sheet_body(mb, body);
|
||||
}
|
||||
if let Some(target) = st.sheets.iter_mut().find(|s| s.body == name) {
|
||||
*target = sh;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
st.bodies.retain(|b| b.name != name);
|
||||
st.sheets.retain(|s| s.body != name);
|
||||
st.threads.retain(|t| t.body != name);
|
||||
}
|
||||
}
|
||||
rebuild_sheet(st, i)?;
|
||||
handled.push(sh.body.clone());
|
||||
}
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
fn point_in_poly(poly: &[Vec2], p: Vec2) -> bool {
|
||||
let mut inside = false;
|
||||
let m = poly.len();
|
||||
for i in 0..m {
|
||||
let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + m - 1) % m)) else { continue };
|
||||
if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
|
||||
/// `level` from `at` along `n`: the edges used by one of them only, chained.
|
||||
fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
@@ -613,7 +893,12 @@ fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, le
|
||||
/// The body a placed plastic feature joins: by name, else the one under the point.
|
||||
fn body_under(st: &ModelState, body: &Option<String>, p: Vec3) -> Result<String> {
|
||||
let i = body_at(st, body, &[p])?;
|
||||
st.bodies.get(i).map(|b| b.name.clone()).ok_or_else(|| DocError::Invalid("there is no body".into()))
|
||||
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("there is no body".into()))?;
|
||||
let tolerance = (b.body.size() * 1e-5).max(1e-4);
|
||||
if crate::project::nearest_face(&b.mesh(), p).is_none_or(|(_, _, distance)| distance > tolerance) {
|
||||
return Err(DocError::Invalid("the plastic feature's placement is no longer on its body face".into()));
|
||||
}
|
||||
Ok(b.name.clone())
|
||||
}
|
||||
|
||||
/// Axes with z along `z` (and x along `x` when given).
|
||||
@@ -783,11 +1068,28 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
return Err(DocError::Invalid("pick the rim face of a shelled body (a face with an inner and an outer edge)".into()));
|
||||
};
|
||||
let bw = if *groove { w + g } else { w };
|
||||
// The band along the chosen edge, inside the rim face.
|
||||
// A lip: the band raised from the rim's own faces (its walls continue the walls
|
||||
// below, which a join can't do: they would coincide).
|
||||
if !*groove
|
||||
&& let Ok(b) = kernel::raise_band(&mb.body, *face, w, h, *outside)
|
||||
&& let Some(slot) = st.bodies.iter_mut().find(|x| x.name == target)
|
||||
{
|
||||
*slot = ModelBody::new(target.clone(), b, slot.feature);
|
||||
return Ok(());
|
||||
}
|
||||
// The band along the chosen edge, inside the rim face. A groove's cutter reaches past
|
||||
// the edge into the free side (the cavity, or outside), so no wall coincides.
|
||||
let free = if *groove { bw } else { 0.0 };
|
||||
let band = if *outside {
|
||||
Region2 { outer: Loop2::polygon(&outer).ccw(), holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()] }
|
||||
Region2 {
|
||||
outer: Loop2::polygon(&offset_left(&outer, -free)).ccw(),
|
||||
holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()],
|
||||
}
|
||||
} else {
|
||||
Region2 { outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(), holes: vec![Loop2::polygon(&inner).ccw().reversed()] }
|
||||
Region2 {
|
||||
outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(),
|
||||
holes: vec![Loop2::polygon(&offset_left(&inner, free)).ccw().reversed()],
|
||||
}
|
||||
};
|
||||
let delta = (h * 0.05).clamp(1e-3, 0.5);
|
||||
if *groove {
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
+1238
-13
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,453 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Face names from exact history, and how a stored name matches a face.
|
||||
//!
|
||||
//! A face's name says what made it (see `naming` for the vocabulary). Where the feature that made
|
||||
//! a body reports its history (a [`Lineage`]), each face is named after the faces it came from:
|
||||
//! kept, trimmed or moved faces keep their names, a face made from a profile segment or cap is
|
||||
//! named by that role, a fillet face by the edge it replaces, a shell's inner wall by the face it
|
||||
//! offsets. Nothing is sampled. Where a face's origin cannot be named exactly the face is named
|
||||
//! from geometry, as a body without history is, and marked unverified.
|
||||
//!
|
||||
//! Two faces with the same name are pieces of one face (a cut split it). Each piece is told apart
|
||||
//! by the faces around it that the others do not have: `F2:top#[F2:side:l4,F4:side:l4]` is the
|
||||
//! piece of `F2:top` bounded by the block's left wall and the slot's left wall. Pieces nothing
|
||||
//! around them tells apart are numbered (`#1`, `#2`): in the order of what they came from when
|
||||
//! that differs (exact), else in the kernel's order (unverified). A face made of several (a
|
||||
//! coplanar join) takes one name and answers to the others (aliases).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use solvecraft_kernel::history::{FaceOrigin, FaceProv, History};
|
||||
|
||||
use crate::ModelBody;
|
||||
use crate::lineage::{Lineage, Operand};
|
||||
use crate::naming::NamingOrigin;
|
||||
|
||||
/// The names of a body's faces, with what finding them again needs.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Names {
|
||||
pub faces: Arc<Vec<String>>,
|
||||
pub(crate) info: Vec<FaceInfo>,
|
||||
}
|
||||
|
||||
/// What a face answers to besides its name, and how sure its name is.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct FaceInfo {
|
||||
/// Names of the other faces merged into it (a reference to one of them finds this face).
|
||||
pub aliases: Vec<String>,
|
||||
/// The stems of the faces around it: what tells it apart from other pieces of its face.
|
||||
pub bounds: BTreeSet<String>,
|
||||
/// Whether the name follows exact history (`false`: worked out from geometry, or a piece
|
||||
/// numbered because the faces around it did not tell it apart).
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
impl Names {
|
||||
/// Names worked out from geometry: the same names, none of them verified, pieces told apart
|
||||
/// by the faces around them (`adjacent`: pairs of faces sharing an edge).
|
||||
pub(crate) fn unverified(names: Vec<String>, adjacent: &[(usize, usize)]) -> Names {
|
||||
let named = names
|
||||
.into_iter()
|
||||
.map(|name| Named { name: crate::naming::strip_piece(&name).to_string(), aliases: Vec::new(), verified: false, origins: None })
|
||||
.collect();
|
||||
qualify(named, adjacent)
|
||||
}
|
||||
|
||||
/// Names given as they are (a moved copy keeps its source's), taken as exact.
|
||||
pub(crate) fn given(names: Arc<Vec<String>>) -> Names {
|
||||
let info = vec![FaceInfo { verified: true, ..FaceInfo::default() }; names.len()];
|
||||
Names { faces: names, info }
|
||||
}
|
||||
|
||||
/// Whether face `index`'s name follows exact history.
|
||||
pub fn verified(&self, index: usize) -> bool {
|
||||
self.info.get(index).is_some_and(|i| i.verified)
|
||||
}
|
||||
}
|
||||
|
||||
/// A face's name before pieces are told apart.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Named {
|
||||
name: String,
|
||||
aliases: Vec<String>,
|
||||
verified: bool,
|
||||
/// What it came from (`None`: not known): faces with one name but different origins (an
|
||||
/// extrude's two caps both above its sketch plane) are numbered in this order.
|
||||
origins: Option<Vec<FaceOrigin>>,
|
||||
}
|
||||
|
||||
/// Names of a body (`b`, made by `o`'s feature) from its lineage; `None` when the lineage does
|
||||
/// not describe it (then it is named from geometry).
|
||||
pub(crate) fn derive(b: &ModelBody, o: &NamingOrigin, lineage: &Lineage) -> Option<Names> {
|
||||
let h = &lineage.history;
|
||||
if h.faces.len() != b.body.face_count() || h.edges.len() != b.body.edge_count() {
|
||||
return None;
|
||||
}
|
||||
let operands: Vec<OperandNames> = lineage.operands.iter().map(|op| OperandNames::new(op, o)).collect::<Option<_>>()?;
|
||||
let fid = o.feature.id;
|
||||
let mut named: Vec<Option<Named>> = h.faces.iter().map(|f| from_origins(f, &operands, fid)).collect();
|
||||
if named.iter().any(Option::is_none) {
|
||||
let geometric = crate::naming::geometric_names(b, o, named.len());
|
||||
for (slot, name) in named.iter_mut().zip(geometric) {
|
||||
if slot.is_none() {
|
||||
*slot = Some(Named { name: crate::naming::strip_piece(&name).to_string(), aliases: Vec::new(), verified: false, origins: None });
|
||||
}
|
||||
}
|
||||
}
|
||||
let named: Vec<Named> = named.into_iter().collect::<Option<_>>()?;
|
||||
Some(qualify(named, &adjacency(h)))
|
||||
}
|
||||
|
||||
/// Pairs of faces that share an edge, from a history.
|
||||
fn adjacency(h: &History) -> Vec<(usize, usize)> {
|
||||
face_pairs(h.edges.iter().map(|e| e.faces.iter().copied()))
|
||||
}
|
||||
|
||||
/// The two faces of each edge (edges given by their faces) that has exactly two.
|
||||
pub(crate) fn face_pairs<I: Iterator<Item = usize>>(edges: impl Iterator<Item = I>) -> Vec<(usize, usize)> {
|
||||
edges
|
||||
.filter_map(|mut faces| match (faces.next(), faces.next(), faces.next()) {
|
||||
(Some(a), Some(b), None) => Some((a, b)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An operand's faces' names.
|
||||
struct OperandNames<'a> {
|
||||
operand: &'a Operand,
|
||||
/// The model body (or a stand-in for a stage) the operand's names belong to.
|
||||
body: Option<ModelBody>,
|
||||
names: Arc<Names>,
|
||||
/// Its edges' names, worked out on first use (for the faces that blend them).
|
||||
edges: OnceLock<Vec<String>>,
|
||||
}
|
||||
|
||||
impl<'a> OperandNames<'a> {
|
||||
fn new(operand: &'a Operand, o: &NamingOrigin) -> Option<OperandNames<'a>> {
|
||||
match operand {
|
||||
Operand::Body(name) | Operand::Copy { body: name, .. } | Operand::Inner(name) => {
|
||||
let body = o.before.body(name)?;
|
||||
Some(OperandNames { operand, names: crate::naming::names(body), body: Some(body.clone()), edges: OnceLock::new() })
|
||||
}
|
||||
Operand::Made(_) | Operand::CopiedTool { .. } => {
|
||||
Some(OperandNames { operand, body: None, names: Arc::default(), edges: OnceLock::new() })
|
||||
}
|
||||
Operand::Stage(stage) => {
|
||||
let body = &stage.body;
|
||||
let names = stage
|
||||
.names
|
||||
.get_or_init(|| {
|
||||
Arc::new(derive(body, o, &stage.lineage).unwrap_or_else(|| {
|
||||
let names = crate::naming::geometric_names(body, o, body.body.face_count());
|
||||
Names::unverified(names, &crate::naming::mesh_adjacency(body))
|
||||
}))
|
||||
})
|
||||
.clone();
|
||||
Some(OperandNames { operand, body: Some(body.clone()), names, edges: OnceLock::new() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of face `face`.
|
||||
fn face(&self, face: usize, fid: u64) -> Option<Named> {
|
||||
let given = |names: &Names| {
|
||||
let info = names.info.get(face)?;
|
||||
Some(Named { name: names.faces.get(face)?.clone(), aliases: info.aliases.clone(), verified: info.verified, origins: None })
|
||||
};
|
||||
match self.operand {
|
||||
Operand::Body(_) | Operand::Stage(_) => given(&self.names),
|
||||
Operand::Copy { instance, .. } => given(&self.names).map(|n| Named {
|
||||
name: format!("{}@F{fid}.{instance}", n.name),
|
||||
aliases: n.aliases.iter().map(|a| format!("{a}@F{fid}.{instance}")).collect(),
|
||||
verified: n.verified,
|
||||
origins: None,
|
||||
}),
|
||||
Operand::Inner(_) => given(&self.names).map(|n| Named {
|
||||
name: format!("F{fid}:inner:{}", n.name),
|
||||
aliases: Vec::new(),
|
||||
verified: n.verified,
|
||||
origins: None,
|
||||
}),
|
||||
Operand::Made(roles) => {
|
||||
let (first, rest) = roles.get(face)?.split_first()?;
|
||||
Some(Named {
|
||||
name: format!("F{fid}:{first}"),
|
||||
aliases: rest.iter().map(|r| format!("F{fid}:{r}")).collect(),
|
||||
verified: true,
|
||||
origins: None,
|
||||
})
|
||||
}
|
||||
Operand::CopiedTool { roles, feature, instance } => {
|
||||
let named = |role: &String| format!("F{feature}:{role}@F{fid}.{instance}");
|
||||
let (first, rest) = roles.get(face)?.split_first()?;
|
||||
Some(Named { name: named(first), aliases: rest.iter().map(named).collect(), verified: true, origins: None })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of edge `edge`, and whether it is exact.
|
||||
fn edge(&self, edge: usize) -> Option<(String, bool)> {
|
||||
match self.operand {
|
||||
Operand::Body(_) | Operand::Stage(_) => {
|
||||
let body = self.body.as_ref()?;
|
||||
let names = self.edges.get_or_init(|| crate::naming::edge_names(body));
|
||||
let faces = body.mesh().edge_faces.get(edge).cloned().unwrap_or_default();
|
||||
let verified = faces.iter().all(|f| self.names.verified(*f as usize));
|
||||
Some((names.get(edge)?.clone(), verified))
|
||||
}
|
||||
Operand::Copy { .. } | Operand::Inner(_) | Operand::Made(_) | Operand::CopiedTool { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A face's name from what it came from; `None` when an origin cannot be named exactly.
|
||||
fn from_origins(face: &FaceProv, operands: &[OperandNames], fid: u64) -> Option<Named> {
|
||||
let mut origins: Vec<FaceOrigin> = face.origins.clone();
|
||||
origins.sort_unstable();
|
||||
let mut names: Vec<Named> = Vec::with_capacity(origins.len());
|
||||
for origin in origins.iter().copied() {
|
||||
let named = match origin {
|
||||
FaceOrigin::Face { operand, face } => operands.get(operand)?.face(face, fid)?,
|
||||
FaceOrigin::Edge { operand, edge } => {
|
||||
let (edge, verified) = operands.get(operand)?.edge(edge)?;
|
||||
Named { name: format!("F{fid}:blend:{edge}"), aliases: Vec::new(), verified, origins: None }
|
||||
}
|
||||
FaceOrigin::Offset { operand, face } => {
|
||||
let source = operands.get(operand)?.face(face, fid)?;
|
||||
Named { name: format!("F{fid}:inner:{}", source.name), aliases: Vec::new(), verified: source.verified, origins: None }
|
||||
}
|
||||
// A profile or cap of a body made from nothing: only a built body's own roles name
|
||||
// these, and those come in as `Made` operands.
|
||||
FaceOrigin::Profile { .. } | FaceOrigin::Cap { .. } => return None,
|
||||
};
|
||||
if !names.iter().any(|n| n.name == named.name) {
|
||||
names.push(named);
|
||||
}
|
||||
}
|
||||
merged(names).map(|n| Named { origins: Some(origins), ..n })
|
||||
}
|
||||
|
||||
/// One name for a face made of faces with these names: the face they are all pieces of, else the
|
||||
/// first (the target's before a tool's); the others become aliases.
|
||||
fn merged(names: Vec<Named>) -> Option<Named> {
|
||||
let verified = names.iter().all(|n| n.verified);
|
||||
let first = names.first()?.name.clone();
|
||||
let mut common = Some(first.as_str());
|
||||
while let Some(c) = common {
|
||||
if names.iter().all(|n| descends(&n.name, c)) {
|
||||
break;
|
||||
}
|
||||
common = crate::naming::parent_piece(c);
|
||||
}
|
||||
let name = common.map(str::to_string).unwrap_or(first);
|
||||
let mut aliases: Vec<String> = Vec::new();
|
||||
for n in names {
|
||||
for alias in std::iter::once(n.name).chain(n.aliases) {
|
||||
if alias != name && !aliases.contains(&alias) {
|
||||
aliases.push(alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Named { name, aliases, verified, origins: None })
|
||||
}
|
||||
|
||||
/// Whether `name` is `stem` or a piece of it (at any depth).
|
||||
pub(crate) fn descends(name: &str, stem: &str) -> bool {
|
||||
let mut current = Some(name);
|
||||
while let Some(n) = current {
|
||||
if n == stem {
|
||||
return true;
|
||||
}
|
||||
current = crate::piece_names::parent_piece(n);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Tell pieces apart: faces sharing a name are named by the faces around them that the others
|
||||
/// lack (`adjacent`: pairs of faces sharing an edge).
|
||||
fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
|
||||
let n = named.len();
|
||||
let mut bounds: Vec<BTreeSet<String>> = vec![BTreeSet::new(); n];
|
||||
for &(a, b) in adjacent {
|
||||
let (Some(na), Some(nb)) = (named.get(a), named.get(b)) else { continue };
|
||||
let (sa, sb) = (crate::naming::strip_piece(&na.name), crate::naming::strip_piece(&nb.name));
|
||||
if a == b || sa == sb {
|
||||
continue;
|
||||
}
|
||||
if let Some(set) = bounds.get_mut(a) {
|
||||
set.insert(sb.to_string());
|
||||
}
|
||||
if let Some(set) = bounds.get_mut(b) {
|
||||
set.insert(sa.to_string());
|
||||
}
|
||||
}
|
||||
let mut groups: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
|
||||
for (i, x) in named.iter().enumerate() {
|
||||
groups.entry(x.name.as_str()).or_default().push(i);
|
||||
}
|
||||
let mut names: Vec<String> = named.iter().map(|x| x.name.clone()).collect();
|
||||
let mut verified: Vec<bool> = named.iter().map(|x| x.verified).collect();
|
||||
for (base, members) in groups.iter().filter(|(_, m)| m.len() > 1) {
|
||||
let empty = BTreeSet::new();
|
||||
let common: BTreeSet<&String> = members
|
||||
.iter()
|
||||
.map(|i| bounds.get(*i).unwrap_or(&empty))
|
||||
.fold(None::<BTreeSet<&String>>, |acc, set| {
|
||||
Some(match acc {
|
||||
None => set.iter().collect(),
|
||||
Some(acc) => acc.into_iter().filter(|x| set.contains(*x)).collect(),
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let own = |i: usize| -> Vec<&String> { bounds.get(i).unwrap_or(&empty).iter().filter(|x| !common.contains(x)).collect() };
|
||||
let mut by_key: BTreeMap<String, Vec<usize>> = BTreeMap::new();
|
||||
for &i in members {
|
||||
let key: Vec<&str> = own(i).into_iter().map(String::as_str).collect();
|
||||
// Nothing around it of its own: the piece is only numbered (below).
|
||||
let name = if key.is_empty() { base.to_string() } else { format!("{base}#[{}]", key.join(",")) };
|
||||
by_key.entry(name).or_default().push(i);
|
||||
}
|
||||
for (key, tied) in by_key {
|
||||
if let [i] = tied.as_slice() {
|
||||
if let Some(slot) = names.get_mut(*i) {
|
||||
*slot = key;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The faces around them don't tell these apart: numbered by what they came from when
|
||||
// that differs (exact), else in the kernel's order (unverified).
|
||||
let mut tied = tied;
|
||||
let origins: Vec<Option<&Vec<FaceOrigin>>> = tied.iter().map(|i| named.get(*i).and_then(|x| x.origins.as_ref())).collect();
|
||||
let distinct = origins.iter().all(Option::is_some) && origins.iter().collect::<BTreeSet<_>>().len() == origins.len();
|
||||
if distinct {
|
||||
tied.sort_by_key(|i| named.get(*i).and_then(|x| x.origins.clone()));
|
||||
}
|
||||
for (k, i) in tied.iter().enumerate() {
|
||||
if let Some(slot) = names.get_mut(*i) {
|
||||
*slot = format!("{key}#{}", k + 1);
|
||||
}
|
||||
if let Some(v) = verified.get_mut(*i).filter(|_| !distinct) {
|
||||
*v = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A piece's new name can meet a face that already had it: numbered too, never shared.
|
||||
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for name in &names {
|
||||
*seen.entry(name.clone()).or_default() += 1;
|
||||
}
|
||||
let mut counter: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for (i, name) in names.iter_mut().enumerate() {
|
||||
if seen.get(name.as_str()).is_some_and(|c| *c > 1) {
|
||||
let k = counter.entry(name.clone()).or_default();
|
||||
*k += 1;
|
||||
*name = format!("{name}#{k}");
|
||||
if let Some(v) = verified.get_mut(i) {
|
||||
*v = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
let info = named.into_iter().zip(bounds).zip(verified).map(|((x, bounds), verified)| FaceInfo { aliases: x.aliases, bounds, verified }).collect();
|
||||
Names { faces: Arc::new(names), info }
|
||||
}
|
||||
|
||||
/// How a stored face name matches a current face.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) enum Match {
|
||||
/// Only its root stem is the same: some piece of the same face (the caller picks by point).
|
||||
Stem,
|
||||
/// A piece of the named face, or the named piece told apart by the faces around it.
|
||||
Piece,
|
||||
/// One of the faces merged into this one.
|
||||
Alias,
|
||||
/// The very name.
|
||||
Exact,
|
||||
}
|
||||
|
||||
/// How the stored name `name` matches face `index` (`None`: not at all).
|
||||
pub(crate) fn matches(names: &Names, index: usize, name: &str) -> Option<Match> {
|
||||
let current = names.faces.get(index)?;
|
||||
let info = names.info.get(index);
|
||||
if current == name {
|
||||
return Some(Match::Exact);
|
||||
}
|
||||
if info.is_some_and(|i| i.aliases.iter().any(|a| a == name)) {
|
||||
return Some(Match::Alias);
|
||||
}
|
||||
// The named face (or piece) was split further: this is one of its pieces.
|
||||
if descends(current, name) {
|
||||
return Some(Match::Piece);
|
||||
}
|
||||
// The named piece, told apart by the faces around it: they still bound this piece.
|
||||
if let Some((stem, around)) = crate::piece_names::piece_parts(name)
|
||||
&& crate::naming::parent_piece(current) == Some(stem)
|
||||
&& info.is_some_and(|i| around.iter().all(|f| i.bounds.contains(*f)))
|
||||
{
|
||||
return Some(Match::Piece);
|
||||
}
|
||||
let root = crate::naming::strip_piece(name);
|
||||
(crate::naming::strip_piece(current) == root).then_some(Match::Stem)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "history_names_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,68 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn piece_names_parse_into_stem_and_bounding_faces() {
|
||||
use crate::naming::{parent_piece, strip_edge_piece, strip_piece};
|
||||
use crate::piece_names::piece_parts;
|
||||
assert_eq!(strip_piece("F2:top#[F2:side:l4,F4:side:l4]"), "F2:top");
|
||||
assert_eq!(strip_piece("F2:top#[a]#[b]"), "F2:top");
|
||||
assert_eq!(parent_piece("F2:top#[a]#[b]"), Some("F2:top#[a]"));
|
||||
assert_eq!(strip_piece("F2:top#[a]#2"), "F2:top");
|
||||
assert_eq!(strip_piece("F2:top#2"), "F2:top", "an older design's numbered piece");
|
||||
assert_eq!(strip_piece("F2:top"), "F2:top");
|
||||
assert_eq!(parent_piece("F2:top"), None);
|
||||
assert_eq!(piece_parts("F2:top#[F2:side:l4,F5:blend:A#[x,y]|B]"), Some(("F2:top", vec!["F2:side:l4", "F5:blend:A#[x,y]|B"])));
|
||||
assert_eq!(piece_parts("F2:top#[]"), Some(("F2:top", vec![])));
|
||||
assert_eq!(strip_edge_piece("F2:side:l2|F2:top#[F4:side:l2]~2"), "F2:side:l2|F2:top");
|
||||
assert_eq!(strip_edge_piece("F2:side:l2#1|F2:top#2"), "F2:side:l2|F2:top");
|
||||
// Hostile names never panic and are their own stems.
|
||||
for n in ["", "#", "#[", "]", "a#[", "a]", "#[]", "a#[b", "x#[[]]]", "é#[ü]", "#1", "a#[b]#", "~", "a~"] {
|
||||
let _ = (strip_piece(n), parent_piece(n), piece_parts(n), strip_edge_piece(n));
|
||||
}
|
||||
assert_eq!(strip_piece("#[]"), "#[]");
|
||||
}
|
||||
|
||||
fn named(names: &[&str]) -> Vec<Named> {
|
||||
names.iter().map(|n| Named { name: n.to_string(), aliases: Vec::new(), verified: true, origins: None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_are_told_apart_by_the_faces_only_they_touch() {
|
||||
// A top face cut in two by a slot: each piece touches the front and back walls (common),
|
||||
// its own end wall and its own slot wall.
|
||||
let faces = named(&["top", "top", "front", "back", "left", "right", "slot:a", "slot:b"]);
|
||||
let adjacent = [(0, 2), (0, 3), (0, 4), (0, 6), (1, 2), (1, 3), (1, 5), (1, 7)];
|
||||
let names = qualify(faces, &adjacent);
|
||||
assert_eq!(names.faces[0], "top#[left,slot:a]");
|
||||
assert_eq!(names.faces[1], "top#[right,slot:b]");
|
||||
assert!(names.verified(0) && names.verified(1));
|
||||
// A stored name finds its piece, though the faces around it have changed since.
|
||||
assert_eq!(matches(&names, 0, "top#[left]"), Some(Match::Piece));
|
||||
assert_eq!(matches(&names, 1, "top#[left]"), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 0, "top"), Some(Match::Piece), "a piece of the named face");
|
||||
assert_eq!(matches(&names, 0, "top#[left,slot:a]"), Some(Match::Exact));
|
||||
assert_eq!(matches(&names, 2, "top"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_nothing_tells_apart_are_numbered_and_unverified() {
|
||||
let faces = named(&["top", "top", "ring"]);
|
||||
let names = qualify(faces, &[(0, 2), (1, 2)]);
|
||||
assert_eq!(names.faces[0], "top#1");
|
||||
assert_eq!(names.faces[1], "top#2");
|
||||
assert!(!names.verified(0) && !names.verified(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_merged_face_takes_the_shared_stem_or_the_first_name_and_answers_to_the_others() {
|
||||
let m = merged(named(&["top#[a]", "top#[b]"])).unwrap();
|
||||
assert_eq!(m.name, "top");
|
||||
assert_eq!(m.aliases, vec!["top#[a]".to_string(), "top#[b]".to_string()]);
|
||||
let m = merged(named(&["top#1", "top#2"])).unwrap();
|
||||
assert_eq!(m.name, "top", "numbered pieces joined again");
|
||||
let m = merged(named(&["F2:top", "F9:top"])).unwrap();
|
||||
assert_eq!(m.name, "F2:top");
|
||||
assert_eq!(m.aliases, vec!["F9:top".to_string()]);
|
||||
let names = qualify(vec![m], &[]);
|
||||
assert_eq!(matches(&names, 0, "F9:top"), Some(Match::Alias));
|
||||
}
|
||||
@@ -223,7 +223,7 @@ pub fn motion(kind: JointKind, q: &[f64]) -> Mat {
|
||||
|
||||
/// Bodies of a component in the model (its own frame).
|
||||
fn component_bodies<'a>(doc: &Document, st: &'a ModelState, component: u64) -> Vec<&'a crate::ModelBody> {
|
||||
st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component).collect()
|
||||
st.bodies.iter().filter(|b| doc.model_body_component(b) == component).collect()
|
||||
}
|
||||
|
||||
/// The component an occurrence places (0 for the root).
|
||||
|
||||
+23
-3
@@ -7,29 +7,44 @@
|
||||
|
||||
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;
|
||||
mod history_names;
|
||||
pub use format_offsets::whole_profile_selection_from_value;
|
||||
pub mod joints;
|
||||
mod lineage;
|
||||
pub mod motion;
|
||||
mod name_lookup;
|
||||
pub mod naming;
|
||||
mod params;
|
||||
mod piece_names;
|
||||
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, mat_inverse, mat_mul, rigid};
|
||||
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, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices,
|
||||
world_state,
|
||||
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;
|
||||
@@ -51,5 +66,10 @@ pub enum DocError {
|
||||
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
//! What each face of a body a feature made came from, in the model's terms.
|
||||
//!
|
||||
//! The kernel reports, for every operation, which faces and edges of its operands each face and
|
||||
//! edge of its result came from ([`History`]). A feature may run several operations (a tool
|
||||
//! built from a sketch, then cut from each target, then split into lumps) whose operands are
|
||||
//! the feature's tools, intermediate bodies or bodies of the model before it. A [`Lineage`]
|
||||
//! composes those histories into one over the operands that names can be read from: bodies of
|
||||
//! the model before the feature (whose faces already have names), moved copies of them, and
|
||||
//! bodies the feature built itself (whose faces are named by their role: the sketch curve they
|
||||
//! sweep, the cap they are). Naming (`history_names`) reads it; nothing here samples geometry.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use solvecraft_geom::Region2;
|
||||
use solvecraft_kernel::Body;
|
||||
use solvecraft_kernel::history::{Change, FaceOrigin, FaceProv, History};
|
||||
|
||||
use crate::SolvedSketch;
|
||||
|
||||
/// Something a body's faces came from, and how its faces are named.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum Operand {
|
||||
/// A body of the model before the feature (by name): its faces keep their names.
|
||||
Body(String),
|
||||
/// A moved copy of a body of the model before the feature, instance `instance` of a pattern
|
||||
/// or mirror: its faces are named after their sources (`<name>@F<feature>.<instance>`).
|
||||
Copy { body: String, instance: usize },
|
||||
/// The cavity side of a shell grown from a body's faces (by name): each face is the inner
|
||||
/// wall of the face it offsets (`F<feature>:inner:<name>`).
|
||||
Inner(String),
|
||||
/// A body the feature built: each face's role (`side:<curve>`, `top`, `+x`, …; the first is
|
||||
/// its name, the others aliases), empty where the role is not known exactly.
|
||||
Made(Vec<Vec<String>>),
|
||||
/// A moved copy of a body another feature (`feature`) builds, instance `instance` of a
|
||||
/// pattern or mirror of that feature: its faces named by their roles in that feature
|
||||
/// (`F<feature>:<role>@F<pattern>.<instance>`).
|
||||
CopiedTool { roles: Vec<Vec<String>>, feature: u64, instance: usize },
|
||||
/// A body the feature made on the way to its result (a cut body before it falls into lumps),
|
||||
/// named as a whole first so pieces of one face in different results are told apart.
|
||||
Stage(Box<Stage>),
|
||||
}
|
||||
|
||||
/// An intermediate body of a feature, with its lineage and, once worked out, its names.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Stage {
|
||||
/// The whole body (a stand-in model body: its mesh is worked out once for every lump).
|
||||
pub body: crate::ModelBody,
|
||||
pub lineage: Lineage,
|
||||
pub names: OnceLock<Arc<crate::history_names::Names>>,
|
||||
}
|
||||
|
||||
/// The history of a body over operands that can be named.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Lineage {
|
||||
pub(crate) history: History,
|
||||
pub(crate) operands: Vec<Arc<Operand>>,
|
||||
}
|
||||
|
||||
impl Lineage {
|
||||
/// `body` as it is: every face and edge its own, named as `operand` names them.
|
||||
pub(crate) fn of(body: &Body, operand: Operand) -> Lineage {
|
||||
Lineage { history: History::identity(body), operands: vec![Arc::new(operand)] }
|
||||
}
|
||||
|
||||
/// The lineage of `result`, made by one kernel operation from operands whose lineages are
|
||||
/// `inputs` (in the operation's operand order). `None` where the operation reports no
|
||||
/// history or its history cannot be composed with theirs: the result is then named from
|
||||
/// geometry, flagged as unverified.
|
||||
pub(crate) fn step(result: &Body, inputs: &[&Lineage]) -> Option<Lineage> {
|
||||
let mut history = result.history()?.clone();
|
||||
if history.operands != inputs.len() {
|
||||
return None;
|
||||
}
|
||||
// From the last operand back: each one's operands come in at its place.
|
||||
for (k, input) in inputs.iter().enumerate().rev() {
|
||||
if let Err(e) = history.substitute(k, &input.history) {
|
||||
log::debug!("no lineage: {e}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let operands: Vec<Arc<Operand>> = inputs.iter().flat_map(|l| l.operands.iter().cloned()).collect();
|
||||
(history.operands == operands.len()).then_some(Lineage { history, operands })
|
||||
}
|
||||
|
||||
/// This lineage for a moved copy of what feature `feature` built (instance `instance` of a
|
||||
/// pattern or mirror of it): the faces it built are named as that feature's, copied.
|
||||
pub(crate) fn copied(&self, feature: u64, instance: usize) -> Lineage {
|
||||
let operands = self
|
||||
.operands
|
||||
.iter()
|
||||
.map(|op| match op.as_ref() {
|
||||
Operand::Made(roles) => Arc::new(Operand::CopiedTool { roles: roles.clone(), feature, instance }),
|
||||
_ => op.clone(),
|
||||
})
|
||||
.collect();
|
||||
Lineage { history: self.history.clone(), operands }
|
||||
}
|
||||
|
||||
/// A tool of `faces` faces and no edges (a mesh's triangles) whose faces have no role.
|
||||
pub(crate) fn unnamed_faces(faces: usize) -> Lineage {
|
||||
let faces = (0..faces).map(|face| FaceProv { origins: vec![FaceOrigin::Face { operand: 0, face }], change: Change::Kept }).collect();
|
||||
Lineage { history: History { operands: 1, faces, edges: Vec::new() }, operands: vec![Arc::new(Operand::Made(Vec::new()))] }
|
||||
}
|
||||
|
||||
/// The lineages of `pieces`, the lumps of `whole` (whose lineage is `lineage`): their faces
|
||||
/// named after the whole's, worked out once for the whole, so pieces of one face that end up
|
||||
/// in different bodies are told apart. `None` for a piece whose history is not known.
|
||||
/// A body in one piece is its own lump: its lineage as it is.
|
||||
pub(crate) fn lumps(whole: &Body, lineage: Option<&Lineage>, pieces: &[Body]) -> Vec<Option<Lineage>> {
|
||||
let Some(lineage) = lineage else { return vec![None; pieces.len()] };
|
||||
if pieces.len() < 2 {
|
||||
return vec![Some(lineage.clone()); pieces.len()];
|
||||
}
|
||||
let body = crate::ModelBody::new(String::new(), whole.clone(), 0);
|
||||
let stage = Arc::new(Operand::Stage(Box::new(Stage { body, lineage: lineage.clone(), names: OnceLock::new() })));
|
||||
pieces
|
||||
.iter()
|
||||
.map(|piece| {
|
||||
let history = piece.history()?.clone();
|
||||
(history.operands == 1).then(|| Lineage { history, operands: vec![stage.clone()] })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A body made during a feature, with its lineage (`None`: not known; it is then named from
|
||||
/// geometry).
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Traced {
|
||||
pub body: Body,
|
||||
pub lineage: Option<Lineage>,
|
||||
}
|
||||
|
||||
impl Traced {
|
||||
/// A body the feature built whose faces' roles are not known: its faces are named from
|
||||
/// geometry, those of the bodies it is combined with by their history.
|
||||
pub fn unnamed(body: Body) -> Traced {
|
||||
let roles = vec![Vec::new(); body.face_count()];
|
||||
Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body }
|
||||
}
|
||||
|
||||
/// A body the feature built, its faces' roles given (one per face), else not known.
|
||||
pub fn made(body: Body, roles: Option<Vec<Vec<String>>>) -> Traced {
|
||||
match roles.filter(|r| r.len() == body.face_count()) {
|
||||
Some(roles) => Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body },
|
||||
None => Traced::unnamed(body),
|
||||
}
|
||||
}
|
||||
|
||||
/// `copy`, this tool moved: instance `instance` of a pattern or mirror of feature `feature`,
|
||||
/// which built it.
|
||||
pub fn copied(&self, copy: Body, feature: u64, instance: usize) -> Traced {
|
||||
let lineage = self.lineage.as_ref().and_then(|l| Lineage::step(©, &[&l.copied(feature, instance)]));
|
||||
Traced { body: copy, lineage }
|
||||
}
|
||||
|
||||
/// `result`, made by one kernel operation from `inputs` (in its operand order).
|
||||
pub fn step(result: Body, inputs: &[&Traced]) -> Traced {
|
||||
let lineages: Option<Vec<&Lineage>> = inputs.iter().map(|t| t.lineage.as_ref()).collect();
|
||||
let lineage = lineages.and_then(|l| Lineage::step(&result, &l));
|
||||
Traced { body: result, lineage }
|
||||
}
|
||||
}
|
||||
|
||||
/// How the bodies a feature builds from a profile name their faces: what each profile segment's
|
||||
/// face is called, and each cap.
|
||||
pub(crate) struct ProfileRoles<'a> {
|
||||
/// The sketch the profile is in (its curves name the side faces), if any.
|
||||
pub sketch: Option<&'a SolvedSketch>,
|
||||
/// The region the body was built from, in the sketch's coordinates; its segments (outer loop,
|
||||
/// then holes) number [`FaceOrigin::Profile`].
|
||||
pub region: Option<&'a Region2>,
|
||||
/// The start and end caps' roles (`None`: not known).
|
||||
pub caps: [Option<String>; 2],
|
||||
}
|
||||
|
||||
impl<'a> ProfileRoles<'a> {
|
||||
/// The roles of the faces of a body built from the profile, whose history (over no
|
||||
/// operands: it was built, not changed) is `history`. A face swept from profile segments is
|
||||
/// `side:<curve>` of the sketch curve each lies on exactly; a cap is its cap's role. Unknown
|
||||
/// (empty) where a segment lies on no single sketch curve, or the history is not a builder's.
|
||||
pub fn of(&self, history: &History) -> Option<Vec<Vec<String>>> {
|
||||
if history.operands != 0 {
|
||||
return None;
|
||||
}
|
||||
let segments: Vec<&solvecraft_geom::Seg2> =
|
||||
self.region.map(|r| std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).collect()).unwrap_or_default();
|
||||
let curves = self.sketch.map(crate::config::ExactSketchCurves::new);
|
||||
let curve = |segment: usize| -> Option<String> { curves.as_ref()?.find(segments.get(segment)?).map(|id| format!("side:{id}")) };
|
||||
let roles = history
|
||||
.faces
|
||||
.iter()
|
||||
.map(|face| {
|
||||
let mut roles: Vec<String> = Vec::new();
|
||||
for origin in &face.origins {
|
||||
let role = match *origin {
|
||||
FaceOrigin::Profile { segment } => curve(segment),
|
||||
FaceOrigin::Cap { end } => self.caps.get(usize::from(end)).cloned().flatten(),
|
||||
_ => None,
|
||||
};
|
||||
match role {
|
||||
Some(role) if !roles.contains(&role) => roles.push(role),
|
||||
Some(_) => {}
|
||||
// One unknown origin leaves the face unknown: never a partial guess.
|
||||
None => return Vec::new(),
|
||||
}
|
||||
}
|
||||
roles
|
||||
})
|
||||
.collect();
|
||||
Some(roles)
|
||||
}
|
||||
|
||||
/// A body swept from a profile (revolve, sweep, loft): its sides named by the sketch curves,
|
||||
/// its caps `start` and `end`.
|
||||
pub fn swept(sketch: Option<&'a SolvedSketch>, region: Option<&'a Region2>) -> ProfileRoles<'a> {
|
||||
ProfileRoles { sketch, region, caps: [Some("start".into()), Some("end".into())] }
|
||||
}
|
||||
|
||||
/// A tool built from the profile (and then moved, from `built`, when given), its faces named
|
||||
/// by their roles where its history gives them.
|
||||
pub fn tool(&self, body: Body, built: Option<&Body>) -> Traced {
|
||||
let history = match built {
|
||||
Some(built) => built_history(&body, built),
|
||||
None => body.history().cloned(),
|
||||
};
|
||||
Traced::made(body, history.and_then(|h| self.of(&h)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A box primitive, its faces named by the side they face (`+x`, `-z`, …), as its construction
|
||||
/// records them.
|
||||
pub(crate) fn box_tool(body: Body) -> Traced {
|
||||
let roles: Option<Vec<Vec<String>>> = body.face_sources().map(|sources| {
|
||||
sources
|
||||
.iter()
|
||||
.map(|source| match source {
|
||||
Some(solvecraft_kernel::FaceSource::Box { axis, high, .. }) => {
|
||||
["x", "y", "z"].get(usize::from(*axis)).map(|a| vec![format!("{}{a}", if *high { "+" } else { "-" })]).unwrap_or_default()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Traced::made(body, roles)
|
||||
}
|
||||
|
||||
/// A cylinder primitive: its wall `side`, its caps `bottom` (at the base) and `top`.
|
||||
pub(crate) fn cylinder_tool(body: Body) -> Traced {
|
||||
let roles = body.history().filter(|h| h.operands == 0).map(|h| {
|
||||
h.faces
|
||||
.iter()
|
||||
.map(|f| match f.origins.as_slice() {
|
||||
[FaceOrigin::Cap { end }] => vec![if *end { "top" } else { "bottom" }.to_string()],
|
||||
origins if !origins.is_empty() && origins.iter().all(|o| matches!(o, FaceOrigin::Profile { .. })) => vec!["side".to_string()],
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Traced::made(body, roles)
|
||||
}
|
||||
|
||||
/// Faces named by their place in a body built as a whole (an imported part's faces in the
|
||||
/// file's order): `face:<k>`.
|
||||
pub(crate) fn numbered_tool(body: Body) -> Traced {
|
||||
let roles = (0..body.face_count()).map(|k| vec![format!("face:{k}")]).collect();
|
||||
Traced::made(body, Some(roles))
|
||||
}
|
||||
|
||||
/// The role of an extrude's cap at `height` along the sketch normal: on the sketch plane, above
|
||||
/// it or below it.
|
||||
pub(crate) fn extrude_cap_role(height: f64) -> String {
|
||||
let tol = 1e-6 * (1.0 + height.abs());
|
||||
if height.abs() <= tol {
|
||||
"start".into()
|
||||
} else if height > 0.0 {
|
||||
"top".into()
|
||||
} else {
|
||||
"bottom".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// The history over no operands of a body built and then moved (or otherwise rebuilt in place)
|
||||
/// by `moved`'s operation from `built`.
|
||||
pub(crate) fn built_history(moved: &Body, built: &Body) -> Option<History> {
|
||||
let mut history = moved.history()?.clone();
|
||||
if history.operands == 1 {
|
||||
history.substitute(0, built.history()?).ok()?;
|
||||
Some(history)
|
||||
} else {
|
||||
(history.operands == 0).then_some(history)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a body of the model being evaluated came to be during the feature being evaluated.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) enum Made {
|
||||
/// Made or changed by the feature, how not known: named from geometry.
|
||||
#[default]
|
||||
Unknown,
|
||||
/// As it was before the feature.
|
||||
Before,
|
||||
/// Made or changed by the feature, with this lineage.
|
||||
Traced(Arc<Lineage>),
|
||||
}
|
||||
|
||||
impl crate::ModelBody {
|
||||
/// A body made or changed by the feature, with what it came from.
|
||||
pub(crate) fn traced_new(name: String, made: Traced, feature: u64) -> Self {
|
||||
crate::ModelBody::new(name, made.body, feature).with_lineage(made.lineage)
|
||||
}
|
||||
|
||||
/// This body with what it came from during the feature (`None`: not known).
|
||||
pub(crate) fn with_lineage(mut self, lineage: Option<Lineage>) -> Self {
|
||||
self.made = match lineage {
|
||||
Some(l) => Made::Traced(Arc::new(l)),
|
||||
None => Made::Unknown,
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
/// This body as an operand of the feature being evaluated: a body of the model before it, or
|
||||
/// what the feature made of one so far.
|
||||
pub(crate) fn traced(&self) -> Traced {
|
||||
let lineage = match &self.made {
|
||||
Made::Before => Some(Lineage::of(&self.body, Operand::Body(self.name.clone()))),
|
||||
Made::Traced(l) => Some(l.as_ref().clone()),
|
||||
Made::Unknown => None,
|
||||
};
|
||||
Traced { body: self.body.clone(), lineage }
|
||||
}
|
||||
|
||||
/// This body, as it was before the feature, as an operand named by `operand` (a moved copy of
|
||||
/// it, the cavity side of a shell grown from it); `None` once the feature has changed it.
|
||||
pub(crate) fn before_as(&self, operand: Operand) -> Traced {
|
||||
let lineage = matches!(self.made, Made::Before).then(|| Lineage::of(&self.body, operand));
|
||||
Traced { body: self.body.clone(), lineage }
|
||||
}
|
||||
|
||||
/// The lineage a feature left this body with, when it made or changed it with one.
|
||||
pub(crate) fn lineage(&self) -> Option<Arc<Lineage>> {
|
||||
match &self.made {
|
||||
Made::Traced(l) => Some(l.clone()),
|
||||
Made::Before | Made::Unknown => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Name the pieces a body fell into after their faces: of the bodies in `bodies` that are lumps
|
||||
/// of one body (made in the same step, from one stage), the one whose least face name sorts first
|
||||
/// takes the first of their body names, and so on. The kernel's shell order, which named them
|
||||
/// before, can swap when an upstream edit moves the cut; their faces' names follow history.
|
||||
/// `origin` is the feature that made them, for naming their faces.
|
||||
pub(crate) fn order_lumps(bodies: &mut [crate::ModelBody], origin: &Arc<crate::naming::NamingOrigin>) {
|
||||
let mut families: Vec<(*const Operand, Vec<usize>)> = Vec::new();
|
||||
for (i, b) in bodies.iter().enumerate() {
|
||||
let Made::Traced(l) = &b.made else { continue };
|
||||
let [op] = l.operands.as_slice() else { continue };
|
||||
if !matches!(op.as_ref(), Operand::Stage(_)) {
|
||||
continue;
|
||||
}
|
||||
let key = Arc::as_ptr(op);
|
||||
match families.iter_mut().find(|(k, _)| *k == key) {
|
||||
Some((_, members)) => members.push(i),
|
||||
None => families.push((key, vec![i])),
|
||||
}
|
||||
}
|
||||
for (_, members) in families.into_iter().filter(|(_, m)| m.len() > 1) {
|
||||
let feature = members.first().and_then(|i| bodies.get(*i)).map(|b| b.feature);
|
||||
if members.iter().any(|i| bodies.get(*i).map(|b| b.feature) != feature) {
|
||||
continue;
|
||||
}
|
||||
let least = |b: &crate::ModelBody| {
|
||||
let mut named = b.clone();
|
||||
named.names = crate::naming::NameCell::traced(Some(origin.clone()), b.lineage());
|
||||
crate::naming::face_names(&named).iter().min().cloned()
|
||||
};
|
||||
let keys: Option<Vec<(String, usize)>> = members.iter().map(|i| bodies.get(*i).and_then(least).map(|k| (k, *i))).collect();
|
||||
let Some(mut keys) = keys else { continue };
|
||||
keys.sort();
|
||||
let names: Vec<String> = members.iter().filter_map(|i| bodies.get(*i).map(|b| b.name.clone())).collect();
|
||||
for (name, (_, i)) in names.into_iter().zip(keys) {
|
||||
if let Some(b) = bodies.get_mut(i) {
|
||||
b.name = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Finding a face or edge again by the name a feature stored for it.
|
||||
//!
|
||||
//! The face with that name is the face; else the one face answering to it (a face merged into
|
||||
//! it, or the named piece told apart by the faces around it, which still bound it); else, when
|
||||
//! several faces are pieces of the named one, the piece nearest the reference's point, and the
|
||||
//! feature says the face was split. Names saved before format 14 ([`LEGACY_MARK`]) are found
|
||||
//! among the names geometry gives, as the builds that saved them found them. A face whose name
|
||||
//! was worked out from geometry, found where the reference's point no longer lies, is reported:
|
||||
//! only geometry decided.
|
||||
|
||||
use solvecraft_geom::{Mesh, Vec3};
|
||||
|
||||
use crate::history_names::{Match, Names};
|
||||
use crate::naming::{self, closest, edge_names, face_names, legacy_edge_names, legacy_face_names, strip_piece};
|
||||
use crate::piece_names::{strip_edge_number, strip_edge_piece};
|
||||
use crate::{ModelBody, ModelState};
|
||||
|
||||
/// The mark on a name saved before format 14 (`?F2:top#2`). Those names were worked out from
|
||||
/// geometry, and a few of them name a face that exact history names differently (a tube's outer
|
||||
/// wall was named after the nearest sketch curve to its centre, its inner circle): such a name is
|
||||
/// found again among the names that geometry gives today, as the build that saved it did, so an
|
||||
/// older design evaluates as it was saved. Picking the face again stores its exact name.
|
||||
pub const LEGACY_MARK: char = '?';
|
||||
|
||||
/// A name saved before format 14, without its mark (`None`: an exact name).
|
||||
pub fn legacy_name(name: &str) -> Option<&str> {
|
||||
name.strip_prefix(LEGACY_MARK)
|
||||
}
|
||||
|
||||
/// Where a stored edge name was found.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EdgeHit {
|
||||
/// A point on the edge: the reference's own point when it lies on it, else the edge's middle.
|
||||
pub point: Vec3,
|
||||
/// The named edge is not one edge any more: the piece nearest the point was taken.
|
||||
pub split: bool,
|
||||
/// Whether the names of the faces it separates follow exact history (and the edge is not
|
||||
/// one of several between them, numbered by where they lie).
|
||||
pub verified: bool,
|
||||
/// Whether the reference's point still lies on the edge.
|
||||
pub on_point: bool,
|
||||
/// The name was saved before format 14 and found as that build did ([`LEGACY_MARK`]).
|
||||
pub legacy: bool,
|
||||
}
|
||||
|
||||
/// The edge of a body a stored edge name finds: the edge with that name; else the one edge
|
||||
/// between the faces it names (followed through splits and merges of those faces); else the
|
||||
/// piece of it nearest `near` (`split`). `Err` explains when there is none.
|
||||
pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, String> {
|
||||
if let Some(old) = legacy_name(name) {
|
||||
return legacy_locate_edge(b, old, near).map_err(|_| format!("edge `{name}` no longer exists"));
|
||||
}
|
||||
let m = b.mesh();
|
||||
let names = edge_names(b);
|
||||
let faces = naming::names(b);
|
||||
let mid = |i: usize| edge_mid(&m, i);
|
||||
// Several edges between the same two faces are numbered by where they lie: not exact.
|
||||
let verified = |i: usize| {
|
||||
m.edge_faces.get(i).is_some_and(|fs| fs.iter().all(|f| faces.verified(*f as usize)))
|
||||
&& names.get(i).is_some_and(|n| strip_edge_number(n).len() == n.len())
|
||||
};
|
||||
let exact = |i: usize| -> Result<EdgeHit, String> {
|
||||
let on = edge_near(b, &m, i, near);
|
||||
mid(i)
|
||||
.map(|p| EdgeHit { point: if on { near } else { p }, split: false, verified: verified(i), on_point: on, legacy: false })
|
||||
.ok_or_else(|| "edge".into())
|
||||
};
|
||||
if let Some(i) = names.iter().position(|n| n == name) {
|
||||
return exact(i);
|
||||
}
|
||||
// By the faces it separates: the best way any edge matches.
|
||||
let wanted = strip_edge_number(name);
|
||||
let mut best: Option<(Match, Vec<usize>)> = None;
|
||||
for (i, fs) in m.edge_faces.iter().enumerate() {
|
||||
let [fa, fb] = fs.as_slice() else { continue };
|
||||
let Some(level) = edge_match(&faces, *fa as usize, *fb as usize, wanted) else { continue };
|
||||
keep_best(&mut best, level, i);
|
||||
}
|
||||
let (level, cands) = best.ok_or_else(|| format!("edge `{name}` no longer exists"))?;
|
||||
if let ([i], true) = (cands.as_slice(), level >= Match::Piece) {
|
||||
return exact(*i);
|
||||
}
|
||||
cands
|
||||
.iter()
|
||||
.filter_map(|i| mid(*i).map(|p| (p, *i)))
|
||||
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
|
||||
.map(|(p, i)| EdgeHit { point: p, split: true, verified: verified(i), on_point: false, legacy: false })
|
||||
.ok_or_else(|| format!("edge `{name}` no longer exists"))
|
||||
}
|
||||
|
||||
/// Whether the picked point `near` lies on edge `i` of body `b` (whose mesh is `m`): then a
|
||||
/// reference keeps it.
|
||||
fn edge_near(b: &ModelBody, m: &Mesh, i: usize, near: Vec3) -> bool {
|
||||
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)))
|
||||
}
|
||||
|
||||
/// The point of face `face` of a mesh nearest `p`, and how far it is.
|
||||
fn nearest_on_face(m: &Mesh, face: usize, p: Vec3) -> Option<(f64, Vec3)> {
|
||||
let mut best: Option<(f64, Vec3)> = None;
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
if *f as usize != face {
|
||||
continue;
|
||||
}
|
||||
let Some([a, b, c]) = m.tri(t) else { continue };
|
||||
let q = closest(p, a, b, c);
|
||||
let d = q.dist(p);
|
||||
if best.is_none_or(|x| d < x.0) {
|
||||
best = Some((d, q));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// The middle of edge `i` of a mesh (along its polyline).
|
||||
fn edge_mid(m: &Mesh, 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()
|
||||
}
|
||||
|
||||
/// [`locate_edge`] for a name saved before format 14 (without its mark), as those builds found
|
||||
/// it: the edge with that name among the names geometry gives, else the piece of it nearest
|
||||
/// `near`.
|
||||
fn legacy_locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, ()> {
|
||||
let m = b.mesh();
|
||||
let names = legacy_edge_names(b);
|
||||
if let Some(i) = names.iter().position(|n| n == name) {
|
||||
let on = edge_near(b, &m, i, near);
|
||||
return edge_mid(&m, i)
|
||||
.map(|p| EdgeHit { point: if on { near } else { p }, split: false, verified: false, on_point: on, legacy: true })
|
||||
.ok_or(());
|
||||
}
|
||||
let stem = strip_edge_piece(name);
|
||||
(0..names.len())
|
||||
.filter(|i| names.get(*i).is_some_and(|n| strip_edge_piece(n) == stem))
|
||||
.filter_map(|i| edge_mid(&m, i))
|
||||
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
|
||||
.map(|p| EdgeHit { point: p, split: true, verified: false, on_point: false, legacy: true })
|
||||
.ok_or(())
|
||||
}
|
||||
|
||||
/// How well the stored edge name `wanted` (without its own number) names the edge between faces
|
||||
/// `a` and `b`: the weaker of its two faces' matches, read every way the name splits into two
|
||||
/// face names (a face name may hold `|` itself: a fillet face is named after its edge).
|
||||
fn edge_match(faces: &Names, a: usize, b: usize, wanted: &str) -> Option<Match> {
|
||||
let mut best = None;
|
||||
for (i, _) in wanted.match_indices('|') {
|
||||
let (Some(x), Some(y)) = (wanted.get(..i), wanted.get(i + 1..)) else { continue };
|
||||
for (p, q) in [(a, b), (b, a)] {
|
||||
if let (Some(m1), Some(m2)) = (crate::history_names::matches(faces, p, x), crate::history_names::matches(faces, q, y)) {
|
||||
best = best.max(Some(m1.min(m2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Keep `item` among the best matches so far.
|
||||
fn keep_best<T>(best: &mut Option<(Match, Vec<T>)>, level: Match, item: T) {
|
||||
match best {
|
||||
Some((b, items)) if *b == level => items.push(item),
|
||||
Some((b, _)) if *b > level => {}
|
||||
_ => *best = Some((level, vec![item])),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a stored face name was found.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct FaceHit {
|
||||
/// The body (its index in the model's bodies).
|
||||
pub body: usize,
|
||||
/// The face (its index in the body).
|
||||
pub face: usize,
|
||||
/// The point on the face nearest the reference's point.
|
||||
pub point: Vec3,
|
||||
/// The named face is not one face any more (split, or found on several bodies): the piece
|
||||
/// nearest the point was taken.
|
||||
pub split: bool,
|
||||
/// The named face was merged into this one.
|
||||
pub merged: bool,
|
||||
/// Whether the face's name follows exact history.
|
||||
pub verified: bool,
|
||||
/// Whether the reference's point still lies on the face (so a name worked out from geometry
|
||||
/// and the pick agree).
|
||||
pub on_point: bool,
|
||||
/// The name was saved before format 14 and found as that build did ([`LEGACY_MARK`]).
|
||||
pub legacy: bool,
|
||||
}
|
||||
|
||||
impl FaceHit {
|
||||
/// What a feature says about how it found a face by its stored name `name` (`None`: exactly).
|
||||
pub fn note(&self, name: &str) -> Option<String> {
|
||||
if self.split {
|
||||
Some(format!("face `{name}` was split; using the piece nearest the picked point"))
|
||||
} else if self.merged {
|
||||
Some(format!("face `{name}` was merged into another face; using that face"))
|
||||
} else if !self.verified && !self.on_point && !self.legacy {
|
||||
Some(format!("face `{name}` was found by geometry, not exact history; check it"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EdgeHit {
|
||||
/// What a feature says about how it found an edge by its stored name `name` (`None`: exactly).
|
||||
pub fn note(&self, name: &str) -> Option<String> {
|
||||
if self.split {
|
||||
Some(format!("edge `{name}` was split; using the piece nearest the picked point"))
|
||||
} else if !self.verified && !self.on_point && !self.legacy {
|
||||
Some(format!("edge `{name}` was found by geometry, not exact history; check it"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The face a stored face name finds, on any body: the face with that name, or the one face
|
||||
/// answering to it (merged into it, or the named piece told apart by the faces around it); else
|
||||
/// the piece of it nearest `p` (`split`).
|
||||
pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option<FaceHit> {
|
||||
locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p)
|
||||
}
|
||||
|
||||
/// [`locate_face`] among `bodies` (with their indices).
|
||||
fn locate_in<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name: &str, p: Vec3) -> Option<FaceHit> {
|
||||
if let Some(old) = legacy_name(name) {
|
||||
return legacy_locate(bodies, old, p);
|
||||
}
|
||||
let mut best: Option<(Match, Vec<(usize, &ModelBody, usize)>)> = None;
|
||||
for (bi, b) in bodies {
|
||||
let names = naming::names(b);
|
||||
for face in 0..names.faces.len() {
|
||||
if let Some(level) = crate::history_names::matches(&names, face, name) {
|
||||
keep_best(&mut best, level, (bi, b, face));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (level, faces) = best?;
|
||||
let unique = faces.len() == 1 && level >= Match::Piece;
|
||||
let mut nearest: Option<(f64, usize, &ModelBody, usize, Vec3)> = None;
|
||||
for (bi, b, face) in faces {
|
||||
if let Some((d, q)) = nearest_on_face(&b.mesh(), face, p)
|
||||
&& nearest.as_ref().is_none_or(|n| d < n.0)
|
||||
{
|
||||
nearest = Some((d, bi, b, face, q));
|
||||
}
|
||||
}
|
||||
let (distance, body, b, face, point) = nearest?;
|
||||
let on_point = distance <= (b.body.size() * 1e-3).max(1e-3);
|
||||
Some(FaceHit {
|
||||
body,
|
||||
face,
|
||||
point,
|
||||
split: !unique,
|
||||
merged: level == Match::Alias,
|
||||
verified: naming::names(b).verified(face),
|
||||
on_point,
|
||||
legacy: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`locate_face`] for a name saved before format 14 (without its mark), as those builds found
|
||||
/// it: among the names geometry gives, the face with that name (or a piece of the same face, if
|
||||
/// nearer `p`), the nearest point on it.
|
||||
fn legacy_locate<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name: &str, p: Vec3) -> Option<FaceHit> {
|
||||
let stem = strip_piece(name);
|
||||
let mut best: Option<(f64, usize, usize, Vec3, bool, f64)> = None;
|
||||
for (bi, b) in bodies {
|
||||
let names = legacy_face_names(b);
|
||||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||||
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(|x| d < x.0) {
|
||||
best = Some((d, bi, *f as usize, q, !exact, tol));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(d, body, face, point, split, tol)| FaceHit {
|
||||
body,
|
||||
face,
|
||||
point,
|
||||
split,
|
||||
merged: false,
|
||||
verified: false,
|
||||
on_point: d <= tol,
|
||||
legacy: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether face `index` of a body is the face a stored name names: that name (or one merged into
|
||||
/// it); for a name saved before format 14, that name among the names those builds gave.
|
||||
pub fn face_has_name(b: &ModelBody, index: usize, name: &str) -> bool {
|
||||
match legacy_name(name) {
|
||||
Some(old) => legacy_face_names(b).get(index).is_some_and(|n| n == old),
|
||||
None => crate::history_names::matches(&naming::names(b), index, name).is_some_and(|m| m >= Match::Alias),
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces of a body that are the stored name's face or pieces of it (by stem), as the name's
|
||||
/// own era names them.
|
||||
pub fn faces_with_stem(b: &ModelBody, name: &str) -> Vec<usize> {
|
||||
let (names, wanted) = match legacy_name(name) {
|
||||
Some(old) => (legacy_face_names(b), old),
|
||||
None => (face_names(b), name),
|
||||
};
|
||||
let stem = strip_piece(wanted);
|
||||
let aliases =
|
||||
|i: usize| legacy_name(name).is_none() && naming::names(b).info.get(i).is_some_and(|x| x.aliases.iter().any(|a| strip_piece(a) == stem));
|
||||
(0..names.len()).filter(|i| names.get(*i).is_some_and(|n| strip_piece(n) == stem) || aliases(*i)).collect()
|
||||
}
|
||||
|
||||
/// [`locate_face`] on one body.
|
||||
pub(crate) fn locate_on(body: &ModelBody, name: &str, p: Vec3) -> Option<FaceHit> {
|
||||
locate_in(std::iter::once((0, body)), name, p)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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"));
|
||||
}
|
||||
+209
-109
@@ -1,16 +1,26 @@
|
||||
//! Persistent names of faces and edges (plan/adr/0002-persistent-naming.md).
|
||||
//! Persistent names of faces and edges.
|
||||
//!
|
||||
//! 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.
|
||||
//! A fillet stores the edges it rounds, a sketch the face it sits on: by name, so that an
|
||||
//! upstream edit that adds, removes, splits or reorders faces does not move them to another
|
||||
//! face. Every face of an evaluated body is named after how it was made: a face that came from 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`/`top`/`bottom`/`end`,
|
||||
//! `F<feature>:blend:<edge>`, `F<feature>:inner:<face>`, …). Pieces of a split face are told
|
||||
//! apart by the faces around them (`F2:top#[F2:side:l4,F4:side:l4]`). Edges are named by the
|
||||
//! faces they separate (`<face A>|<face B>`, `~k` for several edges between the same two).
|
||||
//!
|
||||
//! Where the feature that made a body reports exact history (`crate::lineage`), the names follow
|
||||
//! it (`crate::history_names`). Bodies without history (made by operations that do not report
|
||||
//! it yet, imported meshes, legacy chains) are named from geometry: sample points of each face
|
||||
//! are looked up on the input's faces. Those names are marked unverified, and references that
|
||||
//! resolve through them say so. Names are worked out lazily, on first request.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use solvecraft_geom::{Mesh, Seg2, Vec2, Vec3};
|
||||
|
||||
use crate::history_names::Names;
|
||||
use crate::lineage::Lineage;
|
||||
use crate::{Feature, FeatureKind, ModelBody, ModelState};
|
||||
|
||||
/// How a body came to be: the feature and the model just before it.
|
||||
@@ -27,15 +37,43 @@ pub struct NamingOrigin {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct NameCell {
|
||||
pub origin: Option<Arc<NamingOrigin>>,
|
||||
names: Arc<OnceLock<Arc<Vec<String>>>>,
|
||||
/// What the body's faces came from, when the feature that made it reports it.
|
||||
lineage: Option<Arc<Lineage>>,
|
||||
names: Arc<OnceLock<Arc<Names>>>,
|
||||
/// The names designs saved before format 14 gave its faces (see [`LEGACY_MARK`]).
|
||||
legacy: Arc<OnceLock<Arc<Vec<String>>>>,
|
||||
/// The body this one is a moved copy of (its legacy names are that body's).
|
||||
copy_of: Option<Arc<ModelBody>>,
|
||||
}
|
||||
|
||||
impl NameCell {
|
||||
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
|
||||
NameCell { origin, names: Arc::new(OnceLock::new()) }
|
||||
Self::traced(origin, None)
|
||||
}
|
||||
/// Names of a body made by `origin`'s feature, following `lineage` where it is known.
|
||||
pub(crate) fn traced(origin: Option<Arc<NamingOrigin>>, lineage: Option<Arc<Lineage>>) -> Self {
|
||||
NameCell { origin, lineage, ..NameCell::default() }
|
||||
}
|
||||
/// Names already known, taken as they are.
|
||||
pub fn known(names: Arc<Vec<String>>) -> Self {
|
||||
let legacy = OnceLock::new();
|
||||
let _ = legacy.set(names.clone());
|
||||
NameCell { legacy: Arc::new(legacy), ..Self::shared(Arc::new(Names::given(names)), None) }
|
||||
}
|
||||
/// The names of `body` (a moved copy of a body keeps its faces' names, and how sure they are).
|
||||
pub fn same(body: &ModelBody) -> Self {
|
||||
Self::shared(names(body), Some(Arc::new(body.clone())))
|
||||
}
|
||||
fn shared(names: Arc<Names>, copy_of: Option<Arc<ModelBody>>) -> Self {
|
||||
let cell = OnceLock::new();
|
||||
let _ = cell.set(names);
|
||||
NameCell { names: Arc::new(cell), copy_of, ..NameCell::default() }
|
||||
}
|
||||
}
|
||||
|
||||
pub use crate::name_lookup::{EdgeHit, FaceHit, LEGACY_MARK, face_has_name, faces_with_stem, legacy_name, locate_edge, locate_face};
|
||||
pub use crate::piece_names::parent_piece;
|
||||
|
||||
/// Deeper chains than this are named by position only (bounded work and stack).
|
||||
const MAX_DEPTH: usize = 400;
|
||||
|
||||
@@ -74,7 +112,7 @@ fn samples(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
pub(crate) 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 {
|
||||
@@ -152,12 +190,53 @@ fn face_count(b: &ModelBody) -> usize {
|
||||
|
||||
/// Names of a body's faces (by B-rep face index).
|
||||
pub fn face_names(b: &ModelBody) -> Arc<Vec<String>> {
|
||||
names(b).faces.clone()
|
||||
}
|
||||
|
||||
/// Whether face `index`'s name follows exact history (`false`: worked out from geometry, which an
|
||||
/// upstream edit can fool).
|
||||
pub fn face_name_exact(b: &ModelBody, index: usize) -> bool {
|
||||
names(b).verified(index)
|
||||
}
|
||||
|
||||
/// A body's names, with what finding them again needs: from the history of the feature that
|
||||
/// made it where it reports one, else from geometry (unverified).
|
||||
pub(crate) fn names(b: &ModelBody) -> Arc<Names> {
|
||||
b.names
|
||||
.names
|
||||
.get_or_init(|| {
|
||||
// Exact history needs no mesh; geometry does (its face count is the mesh's).
|
||||
let names = match b.names.origin.as_ref() {
|
||||
Some(o) if o.depth < MAX_DEPTH => b
|
||||
.names
|
||||
.lineage
|
||||
.as_ref()
|
||||
.and_then(|l| crate::history_names::derive(b, o, l))
|
||||
.unwrap_or_else(|| Names::unverified(geometric_names(b, o, face_count(b)), &mesh_adjacency(b))),
|
||||
_ => Names::unverified((0..face_count(b)).map(|k| format!("{}:face:{k}", b.name)).collect(), &[]),
|
||||
};
|
||||
Arc::new(names)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The names builds before format 14 gave a body's faces: from geometry, over the inputs' names
|
||||
/// as those builds gave them, pieces of a split face numbered along the longest axis of their
|
||||
/// spread (`#1`, `#2`, …).
|
||||
pub(crate) fn legacy_face_names(b: &ModelBody) -> Arc<Vec<String>> {
|
||||
if let Some(source) = &b.names.copy_of {
|
||||
return legacy_face_names(source);
|
||||
}
|
||||
b.names
|
||||
.legacy
|
||||
.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),
|
||||
Some(o) if o.depth < MAX_DEPTH => {
|
||||
let mut names = geometric_from(b, o, nf, legacy_face_names);
|
||||
number_pieces(&mut names, &samples(&b.mesh(), nf), '#');
|
||||
names
|
||||
}
|
||||
_ => (0..nf).map(|k| format!("{}:face:{k}", b.name)).collect(),
|
||||
};
|
||||
Arc::new(names)
|
||||
@@ -165,8 +244,32 @@ pub fn face_names(b: &ModelBody) -> Arc<Vec<String>> {
|
||||
.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> {
|
||||
/// The names builds before format 14 gave a body's edges.
|
||||
pub(crate) fn legacy_edge_names(b: &ModelBody) -> Vec<String> {
|
||||
edge_names_from(b, &legacy_face_names(b))
|
||||
}
|
||||
|
||||
/// Pairs of faces of a body that share an edge (from its mesh's edges).
|
||||
pub(crate) fn mesh_adjacency(b: &ModelBody) -> Vec<(usize, usize)> {
|
||||
crate::history_names::face_pairs(b.mesh().edge_faces.iter().map(|fs| fs.iter().map(|f| *f as usize)))
|
||||
}
|
||||
|
||||
/// Work out a body's face names from the feature that made it and the model before it, by
|
||||
/// geometry: where each face's sample points lie on the input's faces. Faces sharing a name are
|
||||
/// pieces of one, told apart afterwards.
|
||||
pub(crate) fn geometric_names(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
geometric_from(b, o, nf, face_names)
|
||||
}
|
||||
|
||||
/// [`geometric_names`], the input bodies' faces named by `names_of`.
|
||||
fn geometric_from(b: &ModelBody, o: &NamingOrigin, nf: usize, names_of: fn(&ModelBody) -> Arc<Vec<String>>) -> 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 names_of(input).as_ref().clone();
|
||||
}
|
||||
let m = b.mesh();
|
||||
let size = b.body.size().max(1e-6);
|
||||
let tol = (size * 1e-4).max(1e-6);
|
||||
@@ -182,15 +285,26 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
|| 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| !x.body.is_mesh() && overlaps(x))
|
||||
.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))
|
||||
(x, FaceIndex::new(&x.mesh(), n), names_of(x))
|
||||
})
|
||||
.collect();
|
||||
let fid = o.feature.id;
|
||||
@@ -205,7 +319,7 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
.iter()
|
||||
.filter(|x| !x.body.is_mesh())
|
||||
.take(64)
|
||||
.map(|x| (x, FaceIndex::new(&x.mesh(), face_count(x)), face_names(x)))
|
||||
.map(|x| (x, FaceIndex::new(&x.mesh(), face_count(x)), names_of(x)))
|
||||
.collect();
|
||||
let mut best: Option<(usize, usize, Vec<Option<String>>)> = None;
|
||||
for (k, mat) in o.mats.iter().enumerate() {
|
||||
@@ -221,7 +335,7 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
let empty = Vec::new();
|
||||
let mut names: Vec<String> = names
|
||||
let names: Vec<String> = names
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(fi, n)| {
|
||||
@@ -237,7 +351,6 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
number_pieces(&mut names, &pts, '#');
|
||||
names
|
||||
}
|
||||
|
||||
@@ -307,49 +420,39 @@ fn vote(ps: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<String>>)], tol:
|
||||
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.clone(), 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)).map(|(n, _)| strip_piece(n).to_string())
|
||||
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`).
|
||||
/// A face name without its piece qualifiers: the face it is a piece of (`F2:top` for
|
||||
/// `F2:top#[F4:side:l1]`, for pieces of pieces, and for the numbered pieces of older designs).
|
||||
pub fn strip_piece(n: &str) -> &str {
|
||||
strip_mark(n, '#')
|
||||
let mut stem = n;
|
||||
while let Some(parent) = parent_piece(stem) {
|
||||
stem = parent;
|
||||
}
|
||||
stem
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// An edge name without piece numbers: its own (`~k`) and its faces' (`#[…]`, and the numbered
|
||||
/// pieces of older designs), 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
|
||||
crate::piece_names::strip_edge_piece(n)
|
||||
}
|
||||
|
||||
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.
|
||||
/// Edges sharing a name: 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() {
|
||||
@@ -401,18 +504,7 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
|
||||
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"
|
||||
}
|
||||
);
|
||||
return format!("F{fid}:{}", crate::lineage::extrude_cap_role((c - ss.plane.origin).dot(n)));
|
||||
}
|
||||
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}"))
|
||||
@@ -548,8 +640,12 @@ fn seg_dist(s: &Seg2, q: Vec2) -> f64 {
|
||||
|
||||
/// 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> {
|
||||
edge_names_from(b, &face_names(b))
|
||||
}
|
||||
|
||||
/// Names of a body's edges from its faces' names `faces`.
|
||||
fn edge_names_from(b: &ModelBody, faces: &[String]) -> Vec<String> {
|
||||
let m = b.mesh();
|
||||
let faces = face_names(b);
|
||||
let mut names: Vec<String> = m
|
||||
.edge_faces
|
||||
.iter()
|
||||
@@ -567,35 +663,7 @@ pub fn edge_names(b: &ModelBody) -> Vec<String> {
|
||||
/// 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"))
|
||||
locate_edge(b, name, near).map(|hit| (hit.point, hit.split))
|
||||
}
|
||||
|
||||
/// Names of the edges nearest each point on a body (for storing references).
|
||||
@@ -631,27 +699,59 @@ pub fn face_names_at(st: &ModelState, pts: &[Vec3]) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The point on the face with this name nearest `p` (a split face: its nearest piece, with
|
||||
/// `true` for "it was split"); `None` when no face has the name.
|
||||
pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool)> {
|
||||
let stem = strip_piece(name);
|
||||
let mut best: Option<(f64, Vec3, bool)> = None;
|
||||
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
|
||||
let names = face_names(b);
|
||||
let m = b.mesh();
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let Some(n) = names.get(*f as usize) else { continue };
|
||||
let exact = n == name;
|
||||
if !exact && strip_piece(n) != stem {
|
||||
continue;
|
||||
}
|
||||
let Some([a, bb, c]) = m.tri(t) else { continue };
|
||||
let q = closest(p, a, bb, c);
|
||||
let d = q.dist(p) - if exact { 1e-9 } else { 0.0 };
|
||||
if best.is_none_or(|(bd, _, _)| d < bd) {
|
||||
best = Some((d, q, !exact));
|
||||
}
|
||||
}
|
||||
/// 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 "not exactly that face": split, or merged into another); `None` when no face has
|
||||
/// the name.
|
||||
pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool)> {
|
||||
locate_face(st, name, p).map(|hit| (hit.point, hit.split || hit.merged))
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
crate::name_lookup::locate_on(body, name, p).map(|hit| (hit.face, hit.point, hit.split || hit.merged))
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
best.map(|(_, q, split)| (q, split))
|
||||
}
|
||||
|
||||
+171
-4
@@ -65,6 +65,9 @@ impl FeatureKind {
|
||||
if let Some(d) = &mut extent.taper {
|
||||
v.push(("Taper angle", d, A));
|
||||
}
|
||||
if let Some(d) = &mut extent.to_offset {
|
||||
v.push(("To offset", d, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { angle, angle2, .. } => {
|
||||
v.push(("Angle", angle, A));
|
||||
@@ -75,9 +78,10 @@ impl FeatureKind {
|
||||
FeatureKind::Fillet { radius, style, .. } => match style {
|
||||
crate::FilletStyle::Constant => v.push(("Radius", radius, L)),
|
||||
crate::FilletStyle::Chord => v.push(("Chord length", radius, L)),
|
||||
crate::FilletStyle::Variable { radius2, .. } => {
|
||||
crate::FilletStyle::Variable { radius2, mid, .. } => {
|
||||
v.push(("Start radius", radius, L));
|
||||
v.push(("End radius", radius2, L));
|
||||
v.extend(mid.iter_mut().map(|(_, r)| ("Mid radius", r, L)));
|
||||
}
|
||||
},
|
||||
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
|
||||
@@ -255,6 +259,7 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v),
|
||||
FeatureKind::Stitch { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
|
||||
FeatureKind::MeshConvert { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
|
||||
FeatureKind::Thicken { thickness, .. } => v.push(("Thickness", thickness, L)),
|
||||
FeatureKind::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v),
|
||||
FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)),
|
||||
@@ -270,11 +275,38 @@ impl FeatureKind {
|
||||
| FeatureKind::Loft { .. }
|
||||
| FeatureKind::Sweep { .. }
|
||||
| FeatureKind::Import { .. }
|
||||
| FeatureKind::MeshImport { .. } => {}
|
||||
| FeatureKind::MeshImport { .. }
|
||||
| FeatureKind::BodyTransfer { .. } => {}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A primitive's placement point (length expressions). Its coordinates are not inputs:
|
||||
/// they get no parameter names and keep bare numbers in mm whatever the design's units, as
|
||||
/// every other position does; renames and users still find them.
|
||||
pub fn point_expr_mut(&mut self) -> Option<&mut crate::PointExpr> {
|
||||
match self {
|
||||
FeatureKind::Box { corner: p, .. }
|
||||
| FeatureKind::Cylinder { base: p, .. }
|
||||
| FeatureKind::Sphere { center: p, .. }
|
||||
| FeatureKind::Torus { center: p, .. }
|
||||
| FeatureKind::Coil { base: p, .. } => Some(p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only view of [`FeatureKind::point_expr_mut`].
|
||||
pub fn point_expr(&self) -> Option<&crate::PointExpr> {
|
||||
match self {
|
||||
FeatureKind::Box { corner: p, .. }
|
||||
| FeatureKind::Cylinder { base: p, .. }
|
||||
| FeatureKind::Sphere { center: p, .. }
|
||||
| FeatureKind::Torus { center: p, .. }
|
||||
| FeatureKind::Coil { base: p, .. } => Some(p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only view of [`FeatureKind::inputs_mut`]: (label, expression, kind).
|
||||
pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> {
|
||||
// Imports have no inputs (and their STEP text is large: don't clone it).
|
||||
@@ -371,6 +403,55 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair a loaded timeline in one pass. Reserve even later features' names, just as
|
||||
/// `name_feature_inputs` does, without restarting a document scan for every `dN` candidate.
|
||||
pub(crate) fn name_all_feature_inputs(&mut self) {
|
||||
let mut taken: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for name in self.params.iter().map(|p| &p.name).chain(self.features.iter().flat_map(|f| &f.param_names)) {
|
||||
*taken.entry(name.clone()).or_default() += 1;
|
||||
}
|
||||
let mut next = 1usize;
|
||||
for f in &mut self.features {
|
||||
let n = f.kind.inputs().len();
|
||||
for k in 0..n {
|
||||
if f.param_names.get(k).is_some_and(|name| !name.is_empty()) {
|
||||
continue;
|
||||
}
|
||||
let name = loop {
|
||||
let name = format!("d{next}");
|
||||
next += 1;
|
||||
if !taken.contains_key(&name) {
|
||||
break name;
|
||||
}
|
||||
};
|
||||
if f.param_names.len() <= k {
|
||||
f.param_names.resize(k + 1, String::new());
|
||||
}
|
||||
// Empty slots cannot collide with a generated name.
|
||||
if let Some(slot) = f.param_names.get_mut(k) {
|
||||
*slot = name.clone();
|
||||
}
|
||||
taken.insert(name, 1);
|
||||
}
|
||||
// Match the single-feature repair: surplus names become available only after
|
||||
// this feature has been repaired. Counts preserve names shared with parameters.
|
||||
for name in f.param_names.drain(n..) {
|
||||
if let Some(count) = taken.get_mut(&name) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
taken.remove(&name);
|
||||
if let Some(i) = name.strip_prefix('d').and_then(|s| s.parse::<usize>().ok())
|
||||
&& name == format!("d{i}")
|
||||
&& i > 0
|
||||
{
|
||||
next = next.min(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Change a parameter: a stored one, or a feature input by its name.
|
||||
/// A typed length that is a bare number means the design's units: it gets that unit written
|
||||
/// in (feature inputs are stored in mm otherwise). Anything else is returned unchanged.
|
||||
@@ -426,6 +507,53 @@ impl Document {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change existing expressions against their final dependency graph, independent of
|
||||
/// column order. Unlike `change_param`, this never creates a missing parameter.
|
||||
pub fn change_params_atomic(&mut self, changes: &[(String, String)]) -> Result<()> {
|
||||
if changes.len() > crate::config::MAX_COLUMNS {
|
||||
return Err(DocError::Invalid("too many parameter changes".into()));
|
||||
}
|
||||
let mut names = BTreeSet::new();
|
||||
let mut changed = BTreeSet::new();
|
||||
let (_, before_errors) = self.param_values();
|
||||
let mut next = self.clone();
|
||||
for (name, expression) in changes {
|
||||
if !names.insert(name.clone()) {
|
||||
return Err(DocError::Invalid(format!("duplicate parameter `{name}`")));
|
||||
}
|
||||
if expression.len() > 4096 || expression.trim().is_empty() {
|
||||
return Err(DocError::Expr("expression is empty or too long".into()));
|
||||
}
|
||||
if let Some(p) = next.params.iter_mut().find(|p| p.name == *name) {
|
||||
if p.expr != expression.trim() {
|
||||
changed.insert(name.clone());
|
||||
}
|
||||
p.expr = expression.trim().to_string();
|
||||
continue;
|
||||
}
|
||||
let hit = next.features.iter().enumerate().find_map(|(i, f)| f.param_names.iter().position(|n| n == name).map(|k| (i, k)));
|
||||
let Some((i, k)) = hit else { return Err(DocError::Unknown(format!("parameter `{name}`"))) };
|
||||
let kind = next.features.get(i).and_then(|f| f.kind.inputs().get(k).map(|x| x.2)).unwrap_or(Kind::Unitless);
|
||||
let normalized = next.with_design_unit(expression, kind);
|
||||
let Some(feature) = next.features.get_mut(i) else { return Err(DocError::Unknown(format!("parameter `{name}`"))) };
|
||||
let Some((_, target, _)) = feature.kind.inputs_mut().into_iter().nth(k) else {
|
||||
return Err(DocError::Unknown(format!("parameter `{name}`")));
|
||||
};
|
||||
if *target != normalized {
|
||||
changed.insert(name.clone());
|
||||
}
|
||||
*target = normalized;
|
||||
}
|
||||
let (_, errors) = next.param_values();
|
||||
for (name, error) in errors {
|
||||
if changed.contains(&name) || !before_errors.contains_key(&name) {
|
||||
return Err(DocError::Expr(format!("parameter `{name}`: {error}")));
|
||||
}
|
||||
}
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The parameters table (user, sketch dimensions, feature inputs).
|
||||
pub fn param_rows(&self) -> Vec<ParamRow> {
|
||||
let (vals, errs) = self.param_values();
|
||||
@@ -563,6 +691,8 @@ pub enum ParamUser {
|
||||
Feature { feature: u64, name: String, input: String },
|
||||
/// A sketch dimension (`constraint` is its id).
|
||||
Dimension { feature: u64, name: String, constraint: String },
|
||||
/// A configured column or an expression saved in a configuration row.
|
||||
Configuration { row: Option<String>, column: String },
|
||||
}
|
||||
|
||||
impl ParamUser {
|
||||
@@ -571,6 +701,10 @@ impl ParamUser {
|
||||
ParamUser::Parameter { name } => format!("parameter {name}"),
|
||||
ParamUser::Feature { name, input, .. } => format!("{name} ({input})"),
|
||||
ParamUser::Dimension { name, constraint, .. } => format!("{name} dimension {constraint}"),
|
||||
ParamUser::Configuration { row, column } => match row {
|
||||
Some(row) => format!("configuration `{row}` ({column})"),
|
||||
None => format!("configuration column {column}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -640,6 +774,24 @@ impl Document {
|
||||
/// expressions refer to it, and the sketch dimensions it drives.
|
||||
pub fn param_users(&self, name: &str) -> Vec<ParamUser> {
|
||||
let mut out = Vec::new();
|
||||
for rule in &self.configs.rules {
|
||||
if rule.parameter_references().iter().any(|reference| reference == name) {
|
||||
out.push(ParamUser::Configuration { row: None, column: format!("rule `{}`", rule.name) });
|
||||
}
|
||||
}
|
||||
for (index, column) in self.configs.columns.iter().enumerate() {
|
||||
if let crate::config::Column::Param(target) = column {
|
||||
let key = self.config_key(column);
|
||||
if target == name {
|
||||
out.push(ParamUser::Configuration { row: None, column: key.clone() });
|
||||
}
|
||||
for row in &self.configs.rows {
|
||||
if row.cells.get(index).is_some_and(|value| expr::references(value).iter().any(|reference| reference == name)) {
|
||||
out.push(ParamUser::Configuration { row: Some(row.name.clone()), column: key.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for p in &self.params {
|
||||
if p.name != name && expr::references(&p.expr).iter().any(|r| r == name) {
|
||||
out.push(ParamUser::Parameter { name: p.name.clone() });
|
||||
@@ -654,6 +806,9 @@ impl Document {
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.kind.point_expr().is_some_and(|p| p.iter().any(|e| expr::references(e).iter().any(|r| r == name))) {
|
||||
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: "Position".into() });
|
||||
}
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
for c in &sketch.constraints {
|
||||
if c.param.as_deref() == Some(name) {
|
||||
@@ -717,6 +872,13 @@ impl Document {
|
||||
*x = new.to_string();
|
||||
}
|
||||
}
|
||||
for e in f.kind.point_expr_mut().into_iter().flatten() {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
*e = r;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for (_, e, _) in f.kind.inputs_mut() {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
@@ -742,9 +904,14 @@ impl Document {
|
||||
}
|
||||
}
|
||||
// Configuration cells refer to parameters too.
|
||||
for rule in &mut self.configs.rules {
|
||||
rule.rename_parameter(old, new);
|
||||
}
|
||||
for r in &mut self.configs.rows {
|
||||
for cell in &mut r.cells {
|
||||
*cell = expr::rename_reference(cell, old, new);
|
||||
for (column, cell) in self.configs.columns.iter().zip(&mut r.cells) {
|
||||
if matches!(column, crate::config::Column::Param(_)) {
|
||||
*cell = expr::rename_reference(cell, old, new);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(c) = self.param_comments.remove(old) {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! The grammar of piece names: a face split into pieces is named `<face>#[<faces around it>]`
|
||||
//! (the faces only that piece touches), `#k` after that when nothing tells pieces apart, and
|
||||
//! designs saved before format 14 numbered pieces `<face>#k`. Parsing never panics on hostile
|
||||
//! names: an unbalanced or empty qualifier is simply not one.
|
||||
|
||||
/// The face a piece name is a piece of (one qualifier off), `None` when it is not a piece.
|
||||
pub fn parent_piece(n: &str) -> Option<&str> {
|
||||
piece_split(n).map(|(stem, _)| stem)
|
||||
}
|
||||
|
||||
/// A piece name's stem and the faces its qualifier lists (`F2:top`, [`F2:side:l4`, `F4:side:l4`]
|
||||
/// for `F2:top#[F2:side:l4,F4:side:l4]`; none for an older design's numbered piece).
|
||||
pub(crate) fn piece_parts(n: &str) -> Option<(&str, Vec<&str>)> {
|
||||
let (stem, around) = piece_split(n)?;
|
||||
Some((stem, around.map(split_top_level).unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// `n` as stem and qualifier contents: `stem#[…]`, `stem#[…]#k` (a numbered tie) or `stem#k` (an
|
||||
/// older design's numbered piece, no contents).
|
||||
fn piece_split(n: &str) -> Option<(&str, Option<&str>)> {
|
||||
let mut rest = n;
|
||||
let mut numbered = false;
|
||||
if let Some(i) = rest.rfind('#')
|
||||
&& let Some(digits) = rest.get(i + 1..)
|
||||
&& !digits.is_empty()
|
||||
&& digits.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
rest = rest.get(..i)?;
|
||||
numbered = true;
|
||||
}
|
||||
if let Some(inner_end) = rest.len().checked_sub(1).filter(|_| rest.ends_with(']')) {
|
||||
let open = matching_open(rest, inner_end)?;
|
||||
let stem = rest.get(..open.checked_sub(1)?)?;
|
||||
if rest.get(open - 1..open) == Some("#") && !stem.is_empty() {
|
||||
return Some((stem, rest.get(open + 1..inner_end)));
|
||||
}
|
||||
}
|
||||
(numbered && !rest.is_empty()).then_some((rest, None))
|
||||
}
|
||||
|
||||
/// The index of the `[` matching the `]` at byte `close`.
|
||||
fn matching_open(s: &str, close: usize) -> Option<usize> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut depth = 0usize;
|
||||
for i in (0..=close).rev() {
|
||||
match bytes.get(i)? {
|
||||
b']' => depth += 1,
|
||||
b'[' => {
|
||||
depth = depth.checked_sub(1)?;
|
||||
if depth == 0 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `s` split at the commas outside brackets (a qualifier's list of faces).
|
||||
fn split_top_level(s: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let (mut depth, mut from) = (0usize, 0usize);
|
||||
for (i, b) in s.bytes().enumerate() {
|
||||
match b {
|
||||
b'[' => depth += 1,
|
||||
b']' => depth = depth.saturating_sub(1),
|
||||
b',' if depth == 0 => {
|
||||
out.extend(s.get(from..i).filter(|x| !x.is_empty()));
|
||||
from = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.extend(s.get(from..).filter(|x| !x.is_empty()));
|
||||
out
|
||||
}
|
||||
|
||||
/// An edge name without piece qualifiers: its own number (`~k`) and its faces' (`#[…]`, and the
|
||||
/// numbered pieces of older designs), so pieces of a split edge, or of an edge between split
|
||||
/// faces, match.
|
||||
pub(crate) fn strip_edge_piece(n: &str) -> String {
|
||||
let n = strip_edge_number(n);
|
||||
let mut out = String::with_capacity(n.len());
|
||||
let bytes = n.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes.get(i) == Some(&b'#') {
|
||||
match bytes.get(i + 1) {
|
||||
Some(b'[') => {
|
||||
// Skip the bracketed qualifier (and a tie number after it).
|
||||
let mut depth = 0usize;
|
||||
let mut j = i + 1;
|
||||
while j < bytes.len() {
|
||||
match bytes.get(j) {
|
||||
Some(b'[') => depth += 1,
|
||||
Some(b']') => {
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
Some(d) if d.is_ascii_digit() => {
|
||||
i += 1;
|
||||
while bytes.get(i).is_some_and(u8::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Copy one whole character.
|
||||
let ch = n.get(i..).and_then(|rest| rest.chars().next());
|
||||
match ch {
|
||||
Some(c) => {
|
||||
out.push(c);
|
||||
i += c.len_utf8();
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An edge name without its own number (`~k`).
|
||||
pub(crate) fn strip_edge_number(n: &str) -> &str {
|
||||
match n.rfind('~') {
|
||||
Some(i) if n.get(i + 1..).is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit())) => n.get(..i).unwrap_or(n),
|
||||
_ => n,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
+265
-11
@@ -20,6 +20,9 @@ const AXIS_HALF: f64 = 100.0;
|
||||
/// Points in one fitted chain at most (longer chains are thinned).
|
||||
const MAX_CHAIN: usize = 4000;
|
||||
|
||||
#[path = "project_order.rs"]
|
||||
mod order;
|
||||
|
||||
/// A resolved link: its geometry in sketch coordinates and the (possibly moved) reference.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Resolved {
|
||||
@@ -29,6 +32,9 @@ pub struct Resolved {
|
||||
pub source: LinkSource,
|
||||
/// The reference was not exactly where it was and was re-found on the nearest geometry.
|
||||
pub moved: bool,
|
||||
/// Exact face selected during this resolution, before its closest point is
|
||||
/// projected onto an edge shared with another face. Computed metadata only.
|
||||
pub face_index: Option<usize>,
|
||||
}
|
||||
|
||||
/// Resolutions of body-based references, keyed by the reference, the sketch plane and the
|
||||
@@ -91,6 +97,7 @@ fn resolve_uncached(
|
||||
) -> Result<Resolved> {
|
||||
let lost = |what: &str| DocError::Invalid(format!("{what} no longer exists"));
|
||||
let mut moved = false;
|
||||
let mut face_index = None;
|
||||
let mut source = src.clone();
|
||||
let mut wires: Vec<Vec<Vec3>> = Vec::new();
|
||||
// Included curves that leave the sketch plane stay 3D.
|
||||
@@ -122,6 +129,7 @@ fn resolve_uncached(
|
||||
LinkSource::Face { body, at } => {
|
||||
let (bi, mesh) = body_mesh(st, body, *at)?;
|
||||
let (f, q, d) = nearest_face(&mesh, *at).ok_or_else(|| lost("the face"))?;
|
||||
face_index = Some(usize::try_from(f).map_err(|_| DocError::Invalid("face index exceeds platform limit".into()))?);
|
||||
let size = mesh.bounds().diagonal().max(1e-9);
|
||||
if d > size * 0.25 {
|
||||
return Err(lost("the face"));
|
||||
@@ -133,15 +141,13 @@ fn resolve_uncached(
|
||||
LinkKind::Intersect => section(plane, &mesh, Some(f)),
|
||||
_ => {
|
||||
let mut g = Vec::new();
|
||||
for e in mesh.face_edges(f) {
|
||||
if mesh.seams.get(e).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let target = st.bodies.get(bi).ok_or_else(|| lost("the projection body"))?;
|
||||
for e in projection_face_edges(target, &mesh, f, plane)? {
|
||||
if let Some(pts) = mesh.edges.get(e) {
|
||||
g.extend(fit_world(plane, pts));
|
||||
}
|
||||
}
|
||||
g
|
||||
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,16 +270,264 @@ fn resolve_uncached(
|
||||
if geom.is_empty() && wires.is_empty() {
|
||||
return Err(DocError::Invalid(format!("the {} projects to nothing on this sketch plane", src.describe())));
|
||||
}
|
||||
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved })
|
||||
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved, face_index })
|
||||
}
|
||||
|
||||
/// Re-resolve every link of a sketch (in place). Returns warnings for lost, moved or rebuilt
|
||||
/// links; lost links keep their last geometry.
|
||||
fn lost(reference: &str) -> DocError {
|
||||
DocError::Unknown(format!("{reference} no longer exists"))
|
||||
}
|
||||
|
||||
fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str, index: usize, at: Vec3) -> Result<Resolved> {
|
||||
let target = st.body(body).ok_or_else(|| lost("named projection body"))?;
|
||||
let mesh = target.mesh();
|
||||
if mesh.triangles.len() > 2_000_000 || mesh.edges.len() > 2_000_000 {
|
||||
return Err(DocError::Invalid("named face projection work limit exceeded".into()));
|
||||
}
|
||||
let face_index = Some(index);
|
||||
let index = u32::try_from(index).map_err(|_| DocError::Invalid("named projection face index exceeds limit".into()))?;
|
||||
let geom = if kind == LinkKind::Intersect {
|
||||
section(plane, &mesh, Some(index))
|
||||
} else {
|
||||
let mut geom = Vec::new();
|
||||
for edge in projection_face_edges(target, &mesh, index, plane)? {
|
||||
if let Some(points) = mesh.edges.get(edge) {
|
||||
geom.extend(fit_world(plane, points));
|
||||
}
|
||||
}
|
||||
let geom = merge_split_circles(geom);
|
||||
if certified_planar_construction(target, index as usize) { merge_family_lines(geom)? } else { geom }
|
||||
};
|
||||
if geom.is_empty() {
|
||||
return Err(DocError::Invalid("named face projects to nothing on this sketch plane".into()));
|
||||
}
|
||||
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index })
|
||||
}
|
||||
|
||||
fn projection_face_edges(body: &crate::ModelBody, mesh: &Mesh, index: u32, plane: &Plane) -> Result<Vec<usize>> {
|
||||
let shell_plane = body
|
||||
.body
|
||||
.face_sources()
|
||||
.and_then(|sources| sources.get(index as usize))
|
||||
.is_some_and(|source| matches!(source, Some(solvecraft_kernel::FaceSource::ShellPlanar { .. })));
|
||||
if shell_plane { order::raw_face_edges(mesh, index, plane) } else { order::face_edges(mesh, index, plane) }
|
||||
}
|
||||
|
||||
fn certified_planar_construction(body: &crate::ModelBody, index: usize) -> bool {
|
||||
body.body.face_sources().and_then(|sources| sources.get(index)).is_some_and(|source| {
|
||||
matches!(
|
||||
source,
|
||||
Some(
|
||||
solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(_), .. }
|
||||
| solvecraft_kernel::FaceSource::Box { producer: Some(_), .. }
|
||||
| solvecraft_kernel::FaceSource::CertifiedExtrudeSide { plane: Some(_), .. }
|
||||
| solvecraft_kernel::FaceSource::CertifiedExtrudeCap { .. }
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_anchored_face(doc: &Document, st: &ModelState, plane: &Plane, kind: LinkKind, anchor: &crate::FaceAnchor) -> Result<Resolved> {
|
||||
let body = st.body(&anchor.target.body).ok_or_else(|| lost("anchored projection body"))?;
|
||||
let indices = doc.configuration_face_indices(body, &anchor.target)?;
|
||||
resolve_face_indices(st, plane, kind, &body.name, &indices)
|
||||
}
|
||||
|
||||
/// Project explicitly selected current face indices, without claiming persistent provenance.
|
||||
/// Callers retaining a link must supply their own replay identity policy.
|
||||
pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body_name: &str, indices: &[usize]) -> Result<Resolved> {
|
||||
let body = st.body(body_name).ok_or_else(|| lost("projection body"))?;
|
||||
let mesh = body.mesh();
|
||||
if mesh.triangles.len() > 2_000_000 || indices.len() > 10_000 {
|
||||
return Err(DocError::Invalid("anchored projection work limit exceeded".into()));
|
||||
}
|
||||
let work = mesh.triangles.len().saturating_add(mesh.edges.len()).saturating_mul(indices.len());
|
||||
if work > 10_000_000 {
|
||||
return Err(DocError::Invalid("anchored projection aggregate work limit exceeded".into()));
|
||||
}
|
||||
let selected: std::collections::BTreeSet<_> = indices.iter().copied().collect();
|
||||
let mut geometry = Vec::new();
|
||||
if kind == LinkKind::Intersect {
|
||||
for index in indices {
|
||||
let index = u32::try_from(*index).map_err(|_| DocError::Invalid("anchored face index exceeds format bounds".into()))?;
|
||||
geometry.extend(section(plane, &mesh, Some(index)));
|
||||
}
|
||||
} else {
|
||||
let edges = if let [index] = indices {
|
||||
projection_face_edges(
|
||||
body,
|
||||
&mesh,
|
||||
u32::try_from(*index).map_err(|_| DocError::Invalid("face index exceeds format bounds".into()))?,
|
||||
plane,
|
||||
)?
|
||||
} else {
|
||||
order::family_edges(&mesh, &selected, plane)?
|
||||
};
|
||||
for edge in edges {
|
||||
if let Some(points) = mesh.edges.get(edge) {
|
||||
geometry.extend(fit_world(plane, points));
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind != LinkKind::Intersect && (indices.len() > 1 || indices.first().is_some_and(|index| certified_planar_construction(body, *index))) {
|
||||
geometry = merge_family_lines(geometry)?;
|
||||
}
|
||||
let point = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.find_map(|(triangle, face)| selected.contains(&(*face as usize)).then(|| mesh.tri(triangle)).flatten().map(|[a, b, c]| (a + b + c) / 3.0))
|
||||
.ok_or_else(|| lost("anchored projection face"))?;
|
||||
if geometry.is_empty() {
|
||||
return Err(lost("anchored projection boundaries"));
|
||||
}
|
||||
Ok(Resolved {
|
||||
geom: merge_split_circles(geometry),
|
||||
wires: Vec::new(),
|
||||
source: LinkSource::Face { body: body.name.clone(), at: point },
|
||||
moved: false,
|
||||
face_index: if indices.len() == 1 { indices.first().copied() } else { None },
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh_face_anchor_links(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
st: &ModelState,
|
||||
plane: &Plane,
|
||||
sk: &mut Sketch,
|
||||
anchor: &crate::FaceAnchor,
|
||||
) -> Vec<String> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, Some(anchor))
|
||||
}
|
||||
|
||||
/// Remove degree-two straight stations introduced by coplanar face subdivision.
|
||||
/// The walk uses endpoint incidence; geometric sorting does not imply adjacency.
|
||||
fn merge_family_lines(geom: Vec<LinkGeom>) -> Result<Vec<LinkGeom>> {
|
||||
if geom.len().saturating_mul(geom.len()).saturating_mul(3) > 10_000_000 {
|
||||
return Err(DocError::Invalid("face family straight-boundary work limit exceeded".into()));
|
||||
}
|
||||
let ends = |curve: &LinkGeom| match curve {
|
||||
LinkGeom::Line(a, b) | LinkGeom::Arc { a, b, .. } | LinkGeom::Conic { a, b, .. } => Some((*a, *b)),
|
||||
LinkGeom::Spline { pts, periodic: false, .. } => pts.first().zip(pts.last()).map(|(a, b)| (*a, *b)),
|
||||
_ => None,
|
||||
};
|
||||
let scale = geom.iter().filter_map(&ends).flat_map(|(a, b)| [a.len(), b.len()]).fold(1., f64::max);
|
||||
let tol = scale * f64::EPSILON * 512.;
|
||||
let mut used = vec![false; geom.len()];
|
||||
let mut result = Vec::new();
|
||||
for (index, curve) in geom.iter().enumerate() {
|
||||
if used.get(index).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
if let Some(slot) = used.get_mut(index) {
|
||||
*slot = true;
|
||||
}
|
||||
let LinkGeom::Line(a, b) = curve else {
|
||||
result.push(curve.clone());
|
||||
continue;
|
||||
};
|
||||
let mut endpoints = [*a, *b];
|
||||
for side in 0..2 {
|
||||
let mut current = index;
|
||||
for _ in 0..geom.len() {
|
||||
let Some(point) = endpoints.get(side).copied() else { break };
|
||||
let mut incidence = geom
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(other, curve)| ends(curve).filter(|(a, b)| a.dist(point) <= tol || b.dist(point) <= tol).map(|_| other));
|
||||
let (Some(one), Some(two), None) = (incidence.next(), incidence.next(), incidence.next()) else { break };
|
||||
let other = if one == current {
|
||||
two
|
||||
} else if two == current {
|
||||
one
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
if used.get(other).copied().unwrap_or(true) {
|
||||
break;
|
||||
}
|
||||
let Some(LinkGeom::Line(a, b)) = geom.get(other) else { break };
|
||||
let next = if a.dist(point) <= tol { *b } else { *a };
|
||||
let Some(opposite) = endpoints.get(1 - side).copied() else { break };
|
||||
let (Some(incoming), Some(outgoing)) = ((point - opposite).normalized(), (next - point).normalized()) else { break };
|
||||
if incoming.dot(outgoing) <= 0. || incoming.cross(outgoing).abs() > f64::EPSILON * 512. {
|
||||
break;
|
||||
}
|
||||
if let Some(slot) = used.get_mut(other) {
|
||||
*slot = true;
|
||||
}
|
||||
if let Some(slot) = endpoints.get_mut(side) {
|
||||
*slot = next;
|
||||
}
|
||||
current = other;
|
||||
}
|
||||
}
|
||||
if let (Some(a), Some(b)) = (endpoints.first(), endpoints.get(1)) {
|
||||
result.push(LinkGeom::Line(*a, *b));
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Vec<String> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, None)
|
||||
}
|
||||
|
||||
fn refresh_links_in(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
st: &ModelState,
|
||||
plane: &Plane,
|
||||
sk: &mut Sketch,
|
||||
anchor: Option<&crate::FaceAnchor>,
|
||||
) -> Vec<String> {
|
||||
let mut warn = Vec::new();
|
||||
let links = sk.links.clone();
|
||||
for l in &links {
|
||||
match resolve(doc, vals, st, plane, l.kind, &l.source) {
|
||||
let mut source = l.source.clone();
|
||||
let mut named_face = None;
|
||||
if !l.auto_face
|
||||
&& let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source)
|
||||
{
|
||||
if let Some((index, p, split)) = st.body(body).and_then(|target| crate::naming::face_on_body(target, name, *at)) {
|
||||
let stem = crate::naming::strip_piece(name);
|
||||
let removed_piece = split
|
||||
&& name != stem
|
||||
&& st.body(body).is_some_and(|target| {
|
||||
(certified_planar_construction(target, index) || target.body.face_was_coalesced(index))
|
||||
&& crate::naming::face_has_name(target, index, stem)
|
||||
});
|
||||
if removed_piece {
|
||||
sk.set_link_lost(&l.id, true);
|
||||
warn.push(format!("projected face piece `{name}` no longer exists; last geometry retained"));
|
||||
continue;
|
||||
}
|
||||
*at = p;
|
||||
named_face = Some((body.clone(), index, p));
|
||||
if split {
|
||||
warn.push(format!("projected face `{name}` was split; using the nearest piece"));
|
||||
}
|
||||
} else {
|
||||
sk.set_link_lost(&l.id, true);
|
||||
warn.push(format!("projected face `{name}` no longer exists"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let resolved = if l.auto_face {
|
||||
anchor
|
||||
.filter(|anchor| {
|
||||
l.face_name.as_ref() == Some(&anchor.target.name) && matches!(&l.source,LinkSource::Face {body,..} if *body==anchor.target.body)
|
||||
})
|
||||
.ok_or_else(|| lost("exact auto-projected attachment"))
|
||||
.and_then(|anchor| resolve_anchored_face(doc, st, plane, l.kind, anchor))
|
||||
} else if let Some((body, index, at)) = named_face {
|
||||
resolve_named_face(st, plane, l.kind, &body, index, at)
|
||||
} else {
|
||||
resolve(doc, vals, st, plane, l.kind, &source)
|
||||
};
|
||||
match resolved {
|
||||
Ok(r) => {
|
||||
sk.set_link_source(&l.id, r.source);
|
||||
let _ = sk.set_link_wires(&l.id, &r.wires);
|
||||
@@ -426,9 +680,9 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec
|
||||
(Some(c), Some(m)) => vec![LinkGeom::Ellipse { c: map(c), major: map(m), minor: *r }],
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some(CurveKind::Spline { pts, control, degree }) if parallel => {
|
||||
Some(CurveKind::Spline { pts, control, degree, periodic }) if parallel => {
|
||||
let p: Option<Vec<Vec2>> = pts.iter().map(|q| sk.point(*q).map(map)).collect();
|
||||
p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree }]).unwrap_or_default()
|
||||
p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree, periodic: *periodic }]).unwrap_or_default()
|
||||
}
|
||||
Some(CurveKind::Conic { a, b, apex, rho }) if parallel => match (sk.point(*a), sk.point(*apex), sk.point(*b)) {
|
||||
(Some(a), Some(x), Some(b)) => vec![LinkGeom::Conic { a: map(a), apex: map(x), b: map(b), rho: *rho }],
|
||||
@@ -472,7 +726,7 @@ fn tilted_circle(plane: &Plane, pts: &[Vec3]) -> Option<Vec<LinkGeom>> {
|
||||
let (c, n, r, closed) = circle3(pts)?;
|
||||
let sn = plane.normal();
|
||||
let cosang = n.dot(sn).abs();
|
||||
if cosang > 1.0 - 1e-12 || cosang < 1e-9 {
|
||||
if !(1e-9..=1.0 - 1e-12).contains(&cosang) {
|
||||
return None;
|
||||
}
|
||||
if closed {
|
||||
@@ -544,7 +798,7 @@ fn as_spline(pts: &[Vec2], tol: f64) -> Option<LinkGeom> {
|
||||
<= tol
|
||||
});
|
||||
if ok {
|
||||
return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3 });
|
||||
return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3, periodic: false });
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
@@ -456,7 +456,7 @@ fn link_polyline(g: &solvecraft_sketch::LinkGeom) -> Vec<Vec2> {
|
||||
LinkGeom::Ellipse { c, major, minor } => {
|
||||
(0..=96).map(|i| solvecraft_sketch::ellipse_point(c, major, minor, i as f64 / 96.0 * std::f64::consts::TAU)).collect()
|
||||
}
|
||||
LinkGeom::Spline { ref pts, control, degree } => solvecraft_sketch::spline_polyline(pts, control, degree),
|
||||
LinkGeom::Spline { ref pts, control, degree, periodic } => solvecraft_sketch::spline_polyline_mode(pts, control, degree, periodic),
|
||||
LinkGeom::Point(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Stable face projection order: outer boundary first, then holes by geometry.
|
||||
//! B-rep edge indices depend on face enumeration and must not choose sketch curve IDs.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use solvecraft_geom::{Mesh, Plane, Vec3};
|
||||
|
||||
use crate::{DocError, Result};
|
||||
|
||||
type Key = (i64, i64, i64);
|
||||
|
||||
/// Boundary of the picked face as drawn: a closed surface cut into pieces (a cylinder or a
|
||||
/// round's torus in halves) projects as the whole patch, since its seams are hidden.
|
||||
pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
|
||||
let patch = mesh.surface_patch(face as usize);
|
||||
if patch.len() > 1 {
|
||||
return family_edges(mesh, &patch.into_iter().collect(), plane);
|
||||
}
|
||||
let edges = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect();
|
||||
boundary_edges(mesh, edges, plane)
|
||||
}
|
||||
|
||||
/// Full boundary of an explicitly selected analytic planar piece. Display seams
|
||||
/// hide subdivisions for picking, but cannot remove a raw piece's closing edge.
|
||||
pub(super) fn raw_face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
|
||||
boundary_edges(mesh, mesh.face_edges(face), plane)
|
||||
}
|
||||
|
||||
/// Exterior of an exact current face family. Shared topological edges are internal;
|
||||
/// geometric proximity never merges distinct edges or chooses a different face.
|
||||
pub(super) fn family_edges(mesh: &Mesh, selected: &std::collections::BTreeSet<usize>, plane: &Plane) -> Result<Vec<usize>> {
|
||||
let mut edges = Vec::new();
|
||||
for (index, faces) in mesh.edge_faces.iter().enumerate() {
|
||||
let count = faces.iter().filter(|face| selected.contains(&(**face as usize))).count();
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
if faces.len() > 2 || (faces.len() == 2 && faces.first() == faces.get(1)) {
|
||||
return Err(DocError::Invalid("face family has ambiguous edge adjacency".into()));
|
||||
}
|
||||
if count == 1 && !mesh.seams.get(index).copied().unwrap_or(false) {
|
||||
edges.push(index);
|
||||
}
|
||||
}
|
||||
boundary_edges(mesh, edges, plane)
|
||||
}
|
||||
|
||||
fn boundary_edges(mesh: &Mesh, mut edges: Vec<usize>, plane: &Plane) -> Result<Vec<usize>> {
|
||||
let bounds = mesh.bounds();
|
||||
let origin = bounds.min;
|
||||
let local_origin = plane.to_local(origin);
|
||||
let tol = (bounds.diagonal() * 1e-8).max(1e-9);
|
||||
if !tol.is_finite() {
|
||||
return Err(DocError::Invalid("face projection has invalid bounds".into()));
|
||||
}
|
||||
let key = |p: Vec3| -> Key {
|
||||
let p = p - origin;
|
||||
((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64)
|
||||
};
|
||||
if edges.iter().any(|e| !mesh.edges.get(*e).is_some_and(|p| p.len() >= 2 && p.iter().all(|p| p.is_finite()))) {
|
||||
return Err(DocError::Invalid("face projection has an invalid boundary edge".into()));
|
||||
}
|
||||
if edges.len() > solvecraft_sketch::MAX_LINK_ENTITIES {
|
||||
return Err(DocError::Invalid("face projection exceeds the boundary edge limit".into()));
|
||||
}
|
||||
let geometry_key = |e: usize| {
|
||||
let pts = mesh.edges.get(e).map(Vec::as_slice).unwrap_or_default();
|
||||
let lo = pts.iter().copied().fold(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::min);
|
||||
let hi = pts.iter().copied().fold(Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), Vec3::max);
|
||||
(key(lo), key(hi))
|
||||
};
|
||||
edges.sort_by_key(|e| geometry_key(*e));
|
||||
let mut at: BTreeMap<Key, Vec<usize>> = BTreeMap::new();
|
||||
for e in &edges {
|
||||
if let Some(pts) = mesh.edges.get(*e) {
|
||||
for point in [pts.first(), pts.last()].into_iter().flatten() {
|
||||
at.entry(key(*point)).or_default().push(*e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut used = vec![false; mesh.edges.len()];
|
||||
let mut boundaries = Vec::new();
|
||||
for start_edge in &edges {
|
||||
if used.get(*start_edge).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Some(pts) = mesh.edges.get(*start_edge) else { continue };
|
||||
let (Some(a), Some(b)) = (pts.first(), pts.last()) else { continue };
|
||||
let start = if key(*a) <= key(*b) { *a } else { *b };
|
||||
let mut current = start;
|
||||
let mut edge = *start_edge;
|
||||
let mut boundary = Vec::new();
|
||||
let mut twice_area = 0.0;
|
||||
for _ in 0..edges.len() {
|
||||
let Some(pts) = mesh.edges.get(edge) else { break };
|
||||
let (Some(a), Some(b)) = (pts.first(), pts.last()) else { break };
|
||||
let forward = current.dist(*a) <= current.dist(*b);
|
||||
for pair in pts.windows(2) {
|
||||
let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue };
|
||||
let (a, b) = (plane.to_local(*a) - local_origin, plane.to_local(*b) - local_origin);
|
||||
twice_area += if forward { a.cross(b) } else { b.cross(a) };
|
||||
}
|
||||
current = if forward { *b } else { *a };
|
||||
if let Some(slot) = used.get_mut(edge) {
|
||||
*slot = true;
|
||||
}
|
||||
boundary.push(edge);
|
||||
if current.dist(start) <= tol {
|
||||
break;
|
||||
}
|
||||
// Neighbouring quantization cells cover points on either side of a rounding
|
||||
// boundary. Connectivity is checked in world space, not in the projection.
|
||||
let (x, y, z) = key(current);
|
||||
let mut candidates = Vec::new();
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
let cell = (x.saturating_add(dx), y.saturating_add(dy), z.saturating_add(dz));
|
||||
if let Some(list) = at.get(&cell) {
|
||||
candidates.extend(list.iter().copied().filter(|e| {
|
||||
!used.get(*e).copied().unwrap_or(true)
|
||||
&& mesh.edges.get(*e).is_some_and(|p| {
|
||||
p.first().is_some_and(|a| a.dist(current) <= tol) || p.last().is_some_and(|b| b.dist(current) <= tol)
|
||||
})
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort_by_key(|e| geometry_key(*e));
|
||||
candidates.dedup();
|
||||
if candidates.len() > 1 {
|
||||
return Err(DocError::Invalid("face projection has a branched boundary".into()));
|
||||
}
|
||||
let Some(next) = candidates.first() else { break };
|
||||
edge = *next;
|
||||
}
|
||||
boundary.sort_by_key(|e| geometry_key(*e));
|
||||
let closed = current.dist(start) <= tol;
|
||||
if !closed || !twice_area.is_finite() {
|
||||
return Err(DocError::Invalid("face projection has an open or invalid boundary".into()));
|
||||
}
|
||||
// Equal-sized holes use their geometric key, not round-off from summing the
|
||||
// same area at different positions or walking their edges in reverse.
|
||||
let area = (twice_area.abs() / (tol * bounds.diagonal().max(tol))).round() as i64;
|
||||
let first = boundary.first().copied().map(geometry_key);
|
||||
boundaries.push((area, first, boundary));
|
||||
}
|
||||
boundaries.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||
Ok(boundaries.into_iter().flat_map(|(_, _, edges)| edges).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn face_family_omits_only_exact_shared_topological_edges() {
|
||||
let mut mesh = Mesh::default();
|
||||
for (a, b, faces) in [
|
||||
((0., 0.), (2., 0.), vec![0]),
|
||||
((2., 0.), (2., 1.), vec![0]),
|
||||
((2., 1.), (0., 1.), vec![0, 1]),
|
||||
((0., 1.), (0., 0.), vec![0]),
|
||||
((2., 1.), (2., 2.), vec![1]),
|
||||
((2., 2.), (0., 2.), vec![1]),
|
||||
((0., 2.), (0., 1.), vec![1]),
|
||||
] {
|
||||
mesh.edges.push(vec![Vec3::new(a.0, a.1, 0.), Vec3::new(b.0, b.1, 0.)]);
|
||||
mesh.positions.extend([Vec3::new(a.0, a.1, 0.), Vec3::new(b.0, b.1, 0.)]);
|
||||
mesh.edge_faces.push(faces);
|
||||
}
|
||||
let selected = [0, 1].into_iter().collect();
|
||||
let edges = family_edges(&mesh, &selected, &Plane::XY).unwrap();
|
||||
assert_eq!(edges.len(), 6);
|
||||
assert!(!edges.contains(&2));
|
||||
let geometry: Vec<_> = edges.iter().flat_map(|edge| super::super::fit_world(&Plane::XY, &mesh.edges[*edge])).collect();
|
||||
assert_eq!(super::super::merge_family_lines(geometry).unwrap().len(), 4);
|
||||
assert_eq!(family_edges(&mesh, &[0].into_iter().collect(), &Plane::XY).unwrap().len(), 4);
|
||||
mesh.edge_faces[2] = vec![0, 1, 2];
|
||||
assert!(family_edges(&mesh, &selected, &Plane::XY).is_err());
|
||||
mesh.edge_faces[2] = vec![0, 0];
|
||||
assert!(family_edges(&mesh, &selected, &Plane::XY).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_crossing_boundaries_are_not_geometrically_cancelled() {
|
||||
let mut mesh = Mesh::default();
|
||||
for (face, x, y) in [(0, 0., 0.), (1, 1., 1.)] {
|
||||
let corners = [Vec3::new(x, y, 0.), Vec3::new(x + 2., y, 0.), Vec3::new(x + 2., y + 2., 0.), Vec3::new(x, y + 2., 0.)];
|
||||
mesh.positions.extend(corners);
|
||||
for i in 0..4 {
|
||||
mesh.edges.push(vec![corners[i], corners[(i + 1) % 4]]);
|
||||
mesh.edge_faces.push(vec![face]);
|
||||
}
|
||||
}
|
||||
let edges = family_edges(&mesh, &[0, 1].into_iter().collect(), &Plane::XY).unwrap();
|
||||
assert_eq!(edges.len(), 8);
|
||||
let geom: Vec<_> = edges.iter().flat_map(|edge| super::super::fit_world(&Plane::XY, &mesh.edges[*edge])).collect();
|
||||
let mut sk = solvecraft_sketch::Sketch::new();
|
||||
let link = sk
|
||||
.add_link(solvecraft_sketch::LinkKind::Project, solvecraft_sketch::LinkSource::Face { body: "ambiguous".into(), at: Vec3::ZERO }, &geom)
|
||||
.unwrap();
|
||||
assert!(sk.update_link(&link, &geom).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn family_line_normalization_preserves_corners_arcs_branches_and_retracing() {
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::LinkGeom;
|
||||
let line = |a, b| LinkGeom::Line(Vec2::new(a, 0.), Vec2::new(b, 0.));
|
||||
assert_eq!(super::super::merge_family_lines(vec![line(0., 1.), line(2., 1.)]).unwrap(), vec![line(0., 2.)]);
|
||||
for geometry in [
|
||||
vec![line(0., 1.), line(1., 0.5)],
|
||||
vec![line(0., 1.), LinkGeom::Line(Vec2::new(1., 0.), Vec2::new(2., 1e-8))],
|
||||
vec![line(0., 1.), line(1., 2.), LinkGeom::Line(Vec2::new(1., 0.), Vec2::new(1., 1.))],
|
||||
vec![line(0., 1.), line(1., 2.), LinkGeom::Conic { a: Vec2::new(1., 0.), apex: Vec2::new(2., 1.), b: Vec2::new(3., 1.), rho: 0.5 }],
|
||||
vec![
|
||||
line(0., 1.),
|
||||
line(1., 2.),
|
||||
LinkGeom::Spline { pts: vec![Vec2::new(1., 0.), Vec2::new(2., 1.), Vec2::new(3., 1.)], control: false, degree: 2, periodic: false },
|
||||
],
|
||||
vec![line(0., 1.), LinkGeom::Arc { c: Vec2::new(1., 1.), a: Vec2::new(1., 0.), b: Vec2::new(2., 1.) }],
|
||||
vec![line(0., 1.), line(1.00001, 2.)],
|
||||
] {
|
||||
assert_eq!(super::super::merge_family_lines(geometry.clone()).unwrap(), geometry);
|
||||
}
|
||||
}
|
||||
|
||||
fn rectangles() -> Mesh {
|
||||
let mut mesh = Mesh::default();
|
||||
for (x, y) in [(40.0, 30.0), (38.0, 28.0), (4.0, 2.0)] {
|
||||
let pts = [Vec3::new(-x, -y, 0.0), Vec3::new(x, -y, 0.0), Vec3::new(x, y, 0.0), Vec3::new(-x, y, 0.0)];
|
||||
mesh.positions.extend(pts);
|
||||
for i in 0..4 {
|
||||
mesh.edges.push(vec![pts[i], pts[(i + 1) % 4]]);
|
||||
mesh.edge_faces.push(vec![7]);
|
||||
}
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundaries_and_edge_identity_are_independent_of_mesh_enumeration() {
|
||||
let baseline = rectangles();
|
||||
let expected: Vec<_> =
|
||||
face_edges(&baseline, 7, &Plane::XY).unwrap().into_iter().map(|e| super::super::fit_world(&Plane::XY, &baseline.edges[e])).collect();
|
||||
for shift in 0..baseline.edges.len() {
|
||||
for reverse in [false, true] {
|
||||
let mut mesh = baseline.clone();
|
||||
mesh.edges = (0..mesh.edges.len()).map(|i| baseline.edges[(i * 5) % baseline.edges.len()].clone()).collect();
|
||||
mesh.edges.rotate_left(shift);
|
||||
if reverse {
|
||||
mesh.edges.reverse();
|
||||
for pts in &mut mesh.edges {
|
||||
pts.reverse();
|
||||
}
|
||||
}
|
||||
let actual: Vec<_> = face_edges(&mesh, 7, &Plane::XY)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let mut pts = mesh.edges[e].clone();
|
||||
// Lines' direction does not change their geometry.
|
||||
if pts.first().unwrap().x > pts.last().unwrap().x || pts.first().unwrap().y > pts.last().unwrap().y {
|
||||
pts.reverse();
|
||||
}
|
||||
super::super::fit_world(&Plane::XY, &pts)
|
||||
})
|
||||
.collect();
|
||||
let normalized: Vec<_> = expected
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let solvecraft_sketch::LinkGeom::Line(a, b) = g[0] else { panic!("line") };
|
||||
if a.x > b.x || a.y > b.y { vec![solvecraft_sketch::LinkGeom::Line(b, a)] } else { g.clone() }
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(actual, normalized);
|
||||
let ordered = face_edges(&mesh, 7, &Plane::XY).unwrap();
|
||||
for (loop_index, chunk) in ordered.chunks(4).enumerate() {
|
||||
let want = [40.0, 38.0, 4.0][loop_index];
|
||||
assert!(chunk.iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x.abs() <= want));
|
||||
assert!(chunk.iter().flat_map(|e| &mesh.edges[*e]).any(|p| p.x.abs() == want));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_boundary_arc_sweeps_survive_individual_edge_reversal() {
|
||||
use solvecraft_geom::{Seg2, Vec2};
|
||||
use solvecraft_sketch::LinkGeom;
|
||||
let mut mesh = Mesh::default();
|
||||
for (extent, radius) in [(10.0, 3.0), (8.0, 1.0)] {
|
||||
for k in 0..4 {
|
||||
let angle = k as f64 * std::f64::consts::FRAC_PI_2;
|
||||
let rotate = |p: Vec2| (0..k).fold(p, |p, _| p.perp());
|
||||
let centre = rotate(Vec2::new(extent - radius, extent - radius));
|
||||
let arc = Seg2::Arc { center: centre, radius, start: angle, sweep: std::f64::consts::FRAC_PI_2 };
|
||||
let line = Seg2::Line { a: arc.point_at(1.0), b: rotate(Vec2::new(-(extent - radius), extent)) };
|
||||
for seg in [arc, line] {
|
||||
let pts: Vec<_> = (0..=16).map(|i| Plane::XY.to_world(seg.point_at(i as f64 / 16.0))).collect();
|
||||
mesh.positions.extend(pts.iter().copied());
|
||||
mesh.edges.push(pts);
|
||||
mesh.edge_faces.push(vec![2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let normalized = |mesh: &Mesh| -> Vec<Vec<LinkGeom>> {
|
||||
face_edges(mesh, 2, &Plane::XY)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
super::super::fit_world(&Plane::XY, &mesh.edges[e])
|
||||
.into_iter()
|
||||
.map(|g| match g {
|
||||
LinkGeom::Line(a, b) if (a.x, a.y) > (b.x, b.y) => LinkGeom::Line(b, a),
|
||||
g => g,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let expected = normalized(&mesh);
|
||||
for shift in 0..mesh.edges.len() {
|
||||
let mut permuted = mesh.clone();
|
||||
permuted.edges = (0..mesh.edges.len()).map(|i| mesh.edges[(i * 5 + shift) % mesh.edges.len()].clone()).collect();
|
||||
for (i, points) in permuted.edges.iter_mut().enumerate() {
|
||||
if i % 2 == 0 {
|
||||
points.reverse();
|
||||
}
|
||||
}
|
||||
let actual = normalized(&permuted);
|
||||
for (a, b) in actual.iter().zip(&expected) {
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.iter().zip(b) {
|
||||
match (a, b) {
|
||||
(LinkGeom::Line(a, b), LinkGeom::Line(c, d)) => assert!(a.dist(*c) < 1e-9 && b.dist(*d) < 1e-9),
|
||||
(LinkGeom::Arc { c, a, b }, LinkGeom::Arc { c: cc, a: aa, b: bb }) => {
|
||||
assert!(c.dist(*cc) < 1e-9 && a.dist(*aa) < 1e-9 && b.dist(*bb) < 1e-9);
|
||||
}
|
||||
_ => panic!("boundary changed kind"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_and_branched_boundaries_are_reported_without_partial_geometry() {
|
||||
let mut open = rectangles();
|
||||
open.edges.pop();
|
||||
open.edge_faces.pop();
|
||||
assert!(face_edges(&open, 7, &Plane::XY).unwrap_err().to_string().contains("open"));
|
||||
let mut branched = rectangles();
|
||||
branched.edges.push(vec![Vec3::new(-40.0, -30.0, 0.0), Vec3::ZERO]);
|
||||
branched.edge_faces.push(vec![7]);
|
||||
assert!(face_edges(&branched, 7, &Plane::XY).is_err());
|
||||
let mut invalid = rectangles();
|
||||
invalid.edges[0].push(Vec3::new(f64::NAN, 0.0, 0.0));
|
||||
assert!(face_edges(&invalid, 7, &Plane::XY).unwrap_err().to_string().contains("invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_area_holes_use_geometric_order_after_translation() {
|
||||
let mut baseline = rectangles();
|
||||
baseline.edges.truncate(4);
|
||||
baseline.edge_faces.truncate(4);
|
||||
for x in [-12.0, 12.0] {
|
||||
let pts = [Vec3::new(x - 2.0, -2.0, 0.0), Vec3::new(x + 2.0, -2.0, 0.0), Vec3::new(x + 2.0, 2.0, 0.0), Vec3::new(x - 2.0, 2.0, 0.0)];
|
||||
for i in 0..4 {
|
||||
baseline.edges.push(vec![pts[i], pts[(i + 1) % 4]]);
|
||||
baseline.edge_faces.push(vec![7]);
|
||||
}
|
||||
}
|
||||
let shift = Vec3::new(12345.6789, -23456.7891, 987.0);
|
||||
for p in &mut baseline.positions {
|
||||
*p += shift;
|
||||
}
|
||||
for points in &mut baseline.edges {
|
||||
for p in points {
|
||||
*p += shift;
|
||||
}
|
||||
}
|
||||
for offset in 0..baseline.edges.len() {
|
||||
let mut mesh = baseline.clone();
|
||||
mesh.edges = (0..mesh.edges.len()).map(|i| baseline.edges[(i * 5 + offset) % baseline.edges.len()].clone()).collect();
|
||||
for (i, points) in mesh.edges.iter_mut().enumerate() {
|
||||
if i % 2 == 0 {
|
||||
points.reverse();
|
||||
}
|
||||
}
|
||||
let order = face_edges(&mesh, 7, &Plane::XY).unwrap();
|
||||
assert!(order[4..8].iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x < shift.x));
|
||||
assert!(order[8..12].iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x > shift.x));
|
||||
}
|
||||
}
|
||||
}
|
||||
+908
-135
File diff suppressed because it is too large
Load Diff
+128
-3
@@ -81,6 +81,26 @@ fn parametric_plate_reevaluates() {
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
|
||||
}
|
||||
|
||||
/// Setting a parameter back takes the earlier results from the cache: nothing is recomputed, but
|
||||
/// the evaluation reports the features it restored (their geometry did change back).
|
||||
#[test]
|
||||
fn set_back_reports_restored_features() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
|
||||
doc.set_param("width", "50 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
|
||||
doc.set_param("width", "40 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (0, 2));
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 24000.0) < 1e-9);
|
||||
// No change: nothing recomputed and nothing restored.
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_fillet_cut_timeline() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
@@ -137,6 +157,7 @@ fn errors_are_reported_not_fatal() {
|
||||
targets: vec![],
|
||||
angle2: None,
|
||||
symmetric: false,
|
||||
convention: Default::default(),
|
||||
to: None,
|
||||
},
|
||||
None,
|
||||
@@ -145,7 +166,13 @@ fn errors_are_reported_not_fatal() {
|
||||
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None, style: Default::default() }, None)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Box { corner: Vec3::ZERO, length: "nope".into(), width: "1".into(), height: "1".into(), operation: Operation::NewBody },
|
||||
FeatureKind::Box {
|
||||
corner: point_expr(Vec3::ZERO),
|
||||
length: "nope".into(),
|
||||
width: "1".into(),
|
||||
height: "1".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -182,13 +209,19 @@ fn params_and_serialization() {
|
||||
fn primitives_and_combine() {
|
||||
let mut doc = Document::new("p");
|
||||
doc.add_feature(
|
||||
FeatureKind::Box { corner: Vec3::ZERO, length: "10".into(), width: "10".into(), height: "10".into(), operation: Operation::NewBody },
|
||||
FeatureKind::Box {
|
||||
corner: point_expr(Vec3::ZERO),
|
||||
length: "10".into(),
|
||||
width: "10".into(),
|
||||
height: "10".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Cylinder {
|
||||
base: Vec3::new(5.0, 5.0, 0.0),
|
||||
base: point_expr(Vec3::new(5.0, 5.0, 0.0)),
|
||||
axis: Vec3::Z,
|
||||
radius: "2".into(),
|
||||
height: "20".into(),
|
||||
@@ -275,3 +308,95 @@ fn parameter_cycles_are_found() {
|
||||
assert!(errs["a"].contains("circular reference: a → b → c → a"), "{}", errs["a"]);
|
||||
assert!(vals.contains_key("d") && errs.contains_key("a") && errs.contains_key("e"));
|
||||
}
|
||||
|
||||
/// Features that use a sketch's points or regions go with it (not only profile and path users).
|
||||
#[test]
|
||||
fn input_sketches_cover_points_and_patches() {
|
||||
let hole: FeatureKind = serde_json::from_value(serde_json::json!({
|
||||
"type": "hole", "position": [0, 0, 0], "direction": [0, 0, -1], "diameter": "3",
|
||||
"points": {"sketch": 7, "ids": ["p1"]},
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(hole.input_sketches(), vec![7]);
|
||||
let patch: FeatureKind = serde_json::from_value(serde_json::json!({"type": "patch", "sketch": 9})).unwrap();
|
||||
assert_eq!(patch.input_sketches(), vec![9]);
|
||||
let pipe: FeatureKind = serde_json::from_value(serde_json::json!({"type": "pipe", "path_sketch": 72, "path": ["l1"], "diameter": "4"})).unwrap();
|
||||
assert_eq!(pipe.input_sketches(), vec![72]);
|
||||
}
|
||||
|
||||
/// Guard for `FeatureKind::input_sketches`: a variant with a field that refers to a sketch
|
||||
/// (a field named `*sketch*` holding an id, sketch points or loft sections) must not sit in the
|
||||
/// arm that returns no sketches. New variants are caught by the compiler (the match has no
|
||||
/// wildcard); this catches a sketch field added to an existing variant.
|
||||
#[test]
|
||||
fn input_sketches_lists_every_variant_with_a_sketch_field() {
|
||||
let src = include_str!("document.rs");
|
||||
let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap();
|
||||
let mut with_sketch = Vec::new();
|
||||
let mut variant = "";
|
||||
for line in body.lines() {
|
||||
let t = line.trim();
|
||||
if line.starts_with(" ") && !line.starts_with(" ") && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
|
||||
variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
let Some((name, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue };
|
||||
let refers = (name.contains("sketch") && (ty.contains("u64") || ty.contains("SketchPoints")))
|
||||
|| ty.contains("SketchPoints")
|
||||
|| ty.contains("LoftSection");
|
||||
if refers && !line.trim_start().starts_with("//") && !with_sketch.contains(&variant) {
|
||||
with_sketch.push(variant);
|
||||
}
|
||||
}
|
||||
assert!(with_sketch.len() >= 10, "the FeatureKind parse found too few sketch users: {with_sketch:?}");
|
||||
let f = src.split("pub fn input_sketches(&self)").nth(1).unwrap();
|
||||
let empty_arm = f.split("=> Vec::new()").next().and_then(|s| s.rsplit("=> ").next()).unwrap();
|
||||
for v in with_sketch {
|
||||
assert!(
|
||||
!empty_arm.contains(&format!("K::{v} {{")),
|
||||
"FeatureKind::{v} has a sketch field but input_sketches says it uses no sketch: deleting the sketch would leave it broken"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_clones_share_meshes_even_when_cloned_before_tessellation() {
|
||||
let body = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap();
|
||||
let a = crate::eval::ModelBody::new("box".into(), body, 1);
|
||||
let b = a.clone();
|
||||
assert!(std::sync::Arc::ptr_eq(&a.mesh(), &b.mesh()));
|
||||
assert!(!a.mesh().triangles.is_empty());
|
||||
}
|
||||
|
||||
/// A construction plane that is its own side resolves through the model and back into the
|
||||
/// document. The depth count has to survive that hop, or the recursion overflows the stack and
|
||||
/// aborts the process, which no panic guard can intercept.
|
||||
#[test]
|
||||
fn self_referencing_construction_planes_are_errors() {
|
||||
let selfref = |name: &str| Box::new(construct::GeoRef::Plane { plane: PlaneRef::Construction { name: name.into() } });
|
||||
let xz = || Box::new(construct::GeoRef::Plane { plane: PlaneRef::Origin { name: "XZ".into() } });
|
||||
|
||||
// Directly its own first side.
|
||||
let mut doc = Document::new("selfplane");
|
||||
doc.add_feature(FeatureKind::ConstructionPlane { plane: PlaneRef::Midplane { a: selfref("P1"), b: xz() } }, Some("P1")).unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().any(|r| r.error.is_some()), "a plane that is its own side must report an error: {:?}", m.results);
|
||||
|
||||
// Two planes that are each other's side.
|
||||
let mut doc = Document::new("twoplanes");
|
||||
doc.add_feature(FeatureKind::ConstructionPlane { plane: PlaneRef::Midplane { a: selfref("P2"), b: xz() } }, Some("P1")).unwrap();
|
||||
doc.add_feature(FeatureKind::ConstructionPlane { plane: PlaneRef::Midplane { a: selfref("P1"), b: xz() } }, Some("P2")).unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().any(|r| r.error.is_some()), "a two-plane cycle must report an error: {:?}", m.results);
|
||||
|
||||
// A perpendicular plane whose base is itself, and a sketch placed on it.
|
||||
let mut doc = Document::new("perpplane");
|
||||
let line = Box::new(construct::GeoRef::World { axis: "X".into() });
|
||||
doc.add_feature(FeatureKind::ConstructionPlane { plane: PlaneRef::Perpendicular { plane: selfref("P1"), line } }, Some("P1")).unwrap();
|
||||
doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Construction { name: "P1".into() }, sketch: Sketch::new() }, Some("Sketch1")).unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().any(|r| r.error.is_some()), "a cyclic perpendicular plane must report an error: {:?}", m.results);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-engine"
|
||||
description = "SolveCraft engine: the session, the command registry (everything is a command), undo/redo and selection"
|
||||
description = "SolidCraft engine: the session, the command registry (everything is a command), undo/redo and selection"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Booleans that failed, panicked or hung, replayed from the scripts in
|
||||
//! `examples/regressions/` (each script's description says what went wrong).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn replay(script: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(script).unwrap();
|
||||
s.run_script(&script).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn volume(s: &Session, body: &str) -> f64 {
|
||||
s.model.state().body(body).map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Slab less a 557.68 × 100 × 120 pocket and the loft below its floor (556.68 × 99 down 4 mm to
|
||||
/// 80 × 80; prismatoid: h/6 · (A_top + A_bottom + 4 A_mid)).
|
||||
fn slab_pocket_loft() -> (f64, f64) {
|
||||
let slab = 900.0 * 480.0 * 160.0;
|
||||
let loft = 4.0 / 6.0 * (556.68 * 99.0 + 80.0 * 80.0 + 4.0 * (556.68 + 80.0) / 2.0 * (99.0 + 80.0) / 2.0);
|
||||
(slab - 557.68 * 100.0 * 120.0, loft)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loft_cut_beside_a_round_hole() {
|
||||
let s = replay(include_str!("../../../examples/regressions/loft_cut_beside_round_hole.json"));
|
||||
let (pocketed, loft) = slab_pocket_loft();
|
||||
let hole = std::f64::consts::PI * 17.5 * 17.5 * 25.0;
|
||||
let want = pocketed - loft - hole;
|
||||
let got = volume(&s, "Body1");
|
||||
assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shallow_loft_cut_from_the_top_face() {
|
||||
let s = replay(include_str!("../../../examples/regressions/shallow_loft_cut_from_top_face.json"));
|
||||
let (_, loft) = slab_pocket_loft();
|
||||
let want = 900.0 * 480.0 * 160.0 - loft;
|
||||
let got = volume(&s, "Body1");
|
||||
assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oblique_cut_through_a_small_arc_matches_a_split() {
|
||||
let scripts = [
|
||||
include_str!("../../../examples/regressions/oblique_cut_of_cylinder.json"),
|
||||
include_str!("../../../examples/regressions/oblique_cut_of_cylinder_hang.json"),
|
||||
];
|
||||
// The same bar split by the prism's tilted top: the piece above it is the cut's result.
|
||||
let mut split = Session::default();
|
||||
let script: Value = serde_json::from_str(scripts[0]).unwrap();
|
||||
let steps = script.get("commands").and_then(Value::as_array).unwrap();
|
||||
split.run_script(&Value::Array(steps[..5].to_vec())).unwrap();
|
||||
let whole = volume(&split, "Tool");
|
||||
split.execute("solid.split_body", &json!({"body": "Tool", "plane": {"origin": [0, -472, 853.91], "normal": [0, 15.44, 40]}})).unwrap();
|
||||
let above = volume(&split, "Tool");
|
||||
assert!(above < whole - 10.0, "{above} of {whole}");
|
||||
for script in scripts {
|
||||
let s = replay(script);
|
||||
let got = volume(&s, "Tool");
|
||||
assert!((got - above).abs() < 1e-6 * above, "{got} vs {above}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Joining struts that end on a bore wall reached a truck-topology panic (#268). The guard caught it,
|
||||
/// but no kernel call should panic at all (wasm aborts on a panic).
|
||||
#[test]
|
||||
fn struts_ending_on_a_bore_wall_join_without_a_panic() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let me = std::thread::current().id();
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let c2 = count.clone();
|
||||
let prev = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |_| {
|
||||
if std::thread::current().id() == me {
|
||||
c2.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}));
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/join_struts_on_bore_wall.json")).unwrap();
|
||||
let result = s.run_script(&script);
|
||||
std::panic::set_hook(prev);
|
||||
assert_eq!(count.load(Ordering::SeqCst), 0, "kernel panics during the join");
|
||||
result.unwrap();
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
}
|
||||
@@ -9,14 +9,14 @@ use serde_json::{Value, json};
|
||||
use solvecraft_doc::FeatureKind;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::params::{bad, bool_, str_};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("body.rename", "Rename Body", rename_body).params("body: its name, name: the new name (features that use the body follow)"),
|
||||
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch)
|
||||
.icon("plane")
|
||||
.params("sketch: id or name; plane: XY|XZ|YZ | construction plane name | {face: [x,y,z]} | {origin, normal} (the sketch keeps its geometry)"),
|
||||
.params("sketch: id or name; plane: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"),
|
||||
CommandSpec::new("browser.group", "New Group", group)
|
||||
.icon("folder")
|
||||
.params("folder: bodies|sketches|construction; component?: id (default 0); name?; items?: [keys] (they leave other groups)"),
|
||||
@@ -32,8 +32,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"),
|
||||
CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"),
|
||||
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).noundo(),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).noundo().doc_only(),
|
||||
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
|
||||
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
|
||||
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
|
||||
or folder?: bodies|sketches|construction|origin (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). View state: not saved, not an undo step",
|
||||
),
|
||||
];
|
||||
|
||||
const FOLDERS: [&str; 3] = ["bodies", "sketches", "construction"];
|
||||
@@ -155,6 +159,146 @@ fn order(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
const ORIGIN: [&str; 7] = ["O", "X", "Y", "Z", "XY", "XZ", "YZ"];
|
||||
|
||||
/// A browser item whose visibility can change.
|
||||
enum Shown {
|
||||
Sketch(u64),
|
||||
Body(String),
|
||||
/// Origin items and construction geometry, by name.
|
||||
Origin(String),
|
||||
}
|
||||
|
||||
fn is_construction(k: &FeatureKind) -> bool {
|
||||
matches!(k, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. })
|
||||
}
|
||||
|
||||
fn shown_item(s: &Session, key: &str) -> Option<Shown> {
|
||||
if let Some(f) = s.doc.find_feature(key) {
|
||||
if matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
return Some(Shown::Sketch(f.id));
|
||||
}
|
||||
if is_construction(&f.kind) {
|
||||
return Some(Shown::Origin(f.name.clone()));
|
||||
}
|
||||
}
|
||||
if s.world_state().bodies.iter().any(|b| b.name == key) {
|
||||
return Some(Shown::Body(key.to_string()));
|
||||
}
|
||||
ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
|
||||
}
|
||||
|
||||
fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "browser.visibility";
|
||||
let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?;
|
||||
if let Some(value) = p.get("paths") {
|
||||
if p.get("items").is_some() || p.get("folder").is_some() {
|
||||
return Err(bad(cmd, "give paths, items or folder, not several"));
|
||||
}
|
||||
let paths =
|
||||
value.as_array().filter(|a| !a.is_empty() && a.len() <= 10_000).ok_or_else(|| bad(cmd, "paths must list 1..10000 occurrence paths"))?;
|
||||
let paths: Vec<Vec<u64>> = paths
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let path = value
|
||||
.as_array()
|
||||
.filter(|a| !a.is_empty() && a.len() <= 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(cmd, "each path must list 1..64 occurrence ids"))?;
|
||||
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(path)
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
if bool_(p, "isolate").unwrap_or(false) {
|
||||
if !visible {
|
||||
return Err(bad(cmd, "isolate requires visible:true"));
|
||||
}
|
||||
let mut hidden = Vec::new();
|
||||
let mut ancestor_bodies = Vec::new();
|
||||
let mut stack = vec![Vec::<u64>::new()];
|
||||
let mut count = 0;
|
||||
while let Some(parent) = stack.pop() {
|
||||
for child in s.occurrence_children(&parent)? {
|
||||
count += 1;
|
||||
if count > 100_000 {
|
||||
return Err(bad(cmd, "isolate exceeds 100000 placements"));
|
||||
}
|
||||
if paths.iter().any(|selected| child.path.starts_with(selected)) {
|
||||
continue;
|
||||
}
|
||||
if paths.iter().any(|selected| selected.starts_with(&child.path)) {
|
||||
ancestor_bodies.extend(child.bodies.into_iter().map(|b| b.world));
|
||||
stack.push(child.path);
|
||||
} else {
|
||||
hidden.push(child.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut root_bodies: Vec<String> =
|
||||
s.model.state().bodies.iter().filter(|b| s.doc.model_body_component(b) == 0).map(|b| b.name.clone()).collect();
|
||||
root_bodies.extend(ancestor_bodies);
|
||||
s.visibility.hidden_occurrences = hidden;
|
||||
s.visibility.hidden_bodies = root_bodies;
|
||||
s.revision += 1;
|
||||
return Ok(json!({"visible":true,"isolated":paths,"visibility":s.visibility}));
|
||||
}
|
||||
for path in &paths {
|
||||
s.visibility.hidden_occurrences.retain(|p| p != path);
|
||||
if !visible {
|
||||
s.visibility.hidden_occurrences.push(path.clone());
|
||||
}
|
||||
}
|
||||
s.revision += 1;
|
||||
return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility}));
|
||||
}
|
||||
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
|
||||
(Some(_), Some(_)) => return Err(bad(cmd, "give `items` or `folder`, not both")),
|
||||
(Some("bodies"), None) => s.world_state().bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(),
|
||||
(Some("sketches"), None) => s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)).collect(),
|
||||
(Some("construction"), None) => s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())).collect(),
|
||||
(Some("origin"), None) => ORIGIN.iter().map(|o| Shown::Origin(o.to_string())).collect(),
|
||||
(Some(f), None) => return Err(bad(cmd, format!("unknown folder `{f}` (bodies, sketches, construction, origin)"))),
|
||||
(None, Some(_)) => {
|
||||
let keys = keys(p, "items", cmd)?;
|
||||
if keys.is_empty() {
|
||||
return Err(bad(cmd, "`items` must list sketches, bodies or construction geometry"));
|
||||
}
|
||||
// Every item is found before anything changes.
|
||||
keys.iter()
|
||||
.map(|k| shown_item(s, k).ok_or_else(|| bad(cmd, format!("no sketch, body or construction item `{k}`"))))
|
||||
.collect::<Result<_>>()?
|
||||
}
|
||||
(None, None) => return Err(bad(cmd, "`items` or `folder` is required")),
|
||||
};
|
||||
let v = &mut s.visibility;
|
||||
if visible && str_(p, "folder") == Some("bodies") {
|
||||
v.hidden_occurrences.clear();
|
||||
}
|
||||
for it in &items {
|
||||
match it {
|
||||
Shown::Sketch(id) => {
|
||||
v.hidden_sketches.retain(|x| x != id);
|
||||
v.shown_sketches.retain(|x| x != id);
|
||||
if visible { v.shown_sketches.push(*id) } else { v.hidden_sketches.push(*id) }
|
||||
}
|
||||
Shown::Body(n) => {
|
||||
v.hidden_bodies.retain(|x| x != n);
|
||||
if !visible {
|
||||
v.hidden_bodies.push(n.clone());
|
||||
}
|
||||
}
|
||||
Shown::Origin(n) => {
|
||||
v.hidden_origin.retain(|x| x != n);
|
||||
if !visible {
|
||||
v.hidden_origin.push(n.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.revision += 1;
|
||||
Ok(json!({ "visible": visible, "changed": items.len(), "visibility": s.visibility }))
|
||||
}
|
||||
|
||||
fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "document.units";
|
||||
let u = str_(p, "units").ok_or_else(|| bad(cmd, "`units` is required"))?;
|
||||
@@ -166,7 +310,9 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "units": u, "kept": [] }));
|
||||
}
|
||||
// What the geometry reads: every feature's length inputs.
|
||||
let matched_configuration = s.doc.configuration_matches().then(|| s.doc.configs.active.clone()).flatten();
|
||||
let before = s.doc.length_inputs();
|
||||
let solved = s.solved_sketches();
|
||||
s.doc_mut().units = u.to_string();
|
||||
// Unit-less user parameters holding a bare number read it in the design's units where they
|
||||
// stand for lengths. If any input moved, those numbers keep the old unit, written out.
|
||||
@@ -197,6 +343,11 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
s.doc_mut().preserve_configuration_units(&old, &kept);
|
||||
if let Some(row) = matched_configuration {
|
||||
s.doc_mut().apply_configuration(&row)?;
|
||||
}
|
||||
s.settle_value_edit(&solved)?;
|
||||
Ok(json!({ "units": u, "kept": kept }))
|
||||
}
|
||||
|
||||
@@ -296,13 +447,14 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if st.body(name).is_some() {
|
||||
return Err(bad(cmd, format!("another body is called `{name}`")));
|
||||
}
|
||||
// The body is the k-th new body of its feature; the names before it are kept as they are.
|
||||
// The body is the k-th new body of its feature; its siblings keep the names they have now
|
||||
// (unpinned pattern copies would otherwise be renumbered into the freed name, #265).
|
||||
let fid = b.feature;
|
||||
let siblings: Vec<String> = st.bodies.iter().filter(|x| x.feature == fid).map(|x| x.name.clone()).collect();
|
||||
let k = siblings.iter().position(|n| *n == old).unwrap_or(0);
|
||||
let d = s.doc_mut();
|
||||
let f = d.feature_mut(fid).ok_or_else(|| bad(cmd, "the body's feature is gone"))?;
|
||||
while f.body_names.len() <= k {
|
||||
while f.body_names.len() < siblings.len().max(k + 1) {
|
||||
let i = f.body_names.len();
|
||||
f.body_names.push(siblings.get(i).cloned().unwrap_or_default());
|
||||
}
|
||||
@@ -312,6 +464,19 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Later features refer to bodies by name.
|
||||
let mut failed = None;
|
||||
for f in d.features.iter_mut().filter(|f| f.id != fid) {
|
||||
if let FeatureKind::Sketch { plane, sketch } = &mut f.kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
|
||||
&& anchor.target.body == old
|
||||
{
|
||||
let ids = std::collections::BTreeMap::from([(anchor.target.origin_feature, anchor.target.origin_feature)]);
|
||||
let bodies = std::collections::BTreeMap::from([(old.clone(), name.to_string())]);
|
||||
anchor.remap_target(&ids, &bodies)?;
|
||||
for link in &mut sketch.links {
|
||||
if link.auto_face && matches!(&link.source,solvecraft_sketch::LinkSource::Face {body,..} if *body==old) {
|
||||
link.face_name = Some(anchor.target.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let Ok(mut v) = serde_json::to_value(&f.kind) else { continue };
|
||||
if replace_str(&mut v, &old, name, 0) {
|
||||
match serde_json::from_value(v) {
|
||||
@@ -332,6 +497,17 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for f in d.appearances.faces.iter_mut().filter(|f| f.body == old) {
|
||||
f.body = name.to_string();
|
||||
}
|
||||
for column in &mut d.configs.columns {
|
||||
match column {
|
||||
solvecraft_doc::config::Column::Material(body) | solvecraft_doc::config::Column::Appearance(body) if *body == old => {
|
||||
*body = name.to_string();
|
||||
}
|
||||
solvecraft_doc::config::Column::FaceAppearance(target) if target.body == old => {
|
||||
target.body = name.to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(o) = d.body_offsets.remove(&old) {
|
||||
d.body_offsets.insert(name.to_string(), o);
|
||||
}
|
||||
@@ -339,6 +515,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
d.body_components.insert(name.to_string(), c);
|
||||
}
|
||||
rename_key(d, "bodies", &old, name);
|
||||
for b in s.visibility.hidden_bodies.iter_mut().filter(|b| **b == old) {
|
||||
*b = name.to_string();
|
||||
}
|
||||
Ok(json!({"body": old, "name": name}))
|
||||
}
|
||||
|
||||
@@ -356,13 +535,89 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("plane").is_none() {
|
||||
return Err(bad(cmd, "`plane` is required"));
|
||||
}
|
||||
let plane = super::sketch::plane_ref(s, p, cmd)?;
|
||||
let (vals, _) = s.doc.param_values();
|
||||
s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
|
||||
if let Some(FeatureKind::Sketch { plane: slot, .. }) = s.doc_mut().feature_mut(id).map(|f| &mut f.kind) {
|
||||
let feature = s.doc.feature(id).ok_or_else(|| bad(cmd, "sketch disappeared"))?;
|
||||
let component = feature.component;
|
||||
let index = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "sketch disappeared"))?;
|
||||
let author_path = super::sketch::author_path_for_rebind(s, feature, p, cmd)?;
|
||||
let marker = s.doc.marker;
|
||||
let mut baseline = s.scratch();
|
||||
baseline.doc_mut().marker = Some(index + 1);
|
||||
baseline.refresh();
|
||||
let visible_geometry = baseline.model.state().sketch(id).map(|resolved| resolved.sketch.clone());
|
||||
let mut trial = s.scratch();
|
||||
trial.active_component = component;
|
||||
trial.active_occurrence_path = (!author_path.is_empty()).then_some(author_path);
|
||||
// A new attachment may only depend on geometry preceding this sketch.
|
||||
trial.doc_mut().marker = Some(index);
|
||||
trial.refresh();
|
||||
let old_anchor = trial
|
||||
.doc
|
||||
.feature(id)
|
||||
.and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
})
|
||||
.cloned();
|
||||
let plane = super::sketch::plane_ref(&trial, p, cmd)?;
|
||||
if super::sketch::face::is_face(p) {
|
||||
super::sketch::face::capture_positions(&mut trial, &plane)?;
|
||||
}
|
||||
if let Some(anchor) = old_anchor
|
||||
&& super::sketch::face::anchor(&plane)
|
||||
.is_none_or(|next| next.target != anchor.target || next.component != anchor.component || next.path != anchor.path)
|
||||
{
|
||||
let sketch = trial.doc_mut().sketch_mut(id)?;
|
||||
if let Some(mut visible) = visible_geometry {
|
||||
let constraints: std::collections::BTreeMap<_, _> =
|
||||
sketch.constraints.iter().map(|constraint| (constraint.id.clone(), constraint.clone())).collect();
|
||||
for constraint in &mut visible.constraints {
|
||||
if let Some(original) = constraints.get(&constraint.id) {
|
||||
if std::mem::discriminant(&constraint.kind) != std::mem::discriminant(&original.kind) {
|
||||
return Err(bad(cmd, "a dimension role changed during border refresh; repair it explicitly before rebinding"));
|
||||
}
|
||||
// Keep evaluated entity indices after linked topology rebuilds.
|
||||
constraint.param = original.param.clone();
|
||||
constraint.driven = original.driven;
|
||||
constraint.text = original.text;
|
||||
if let Some(value) = original.kind.value() {
|
||||
constraint.kind.set_value(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
*sketch = visible;
|
||||
}
|
||||
let affected: Vec<_> = sketch
|
||||
.links
|
||||
.iter()
|
||||
.filter(|link| {
|
||||
link.auto_face
|
||||
&& link.face_name.as_ref() == Some(&anchor.target.name)
|
||||
&& matches!(&link.source,solvecraft_sketch::LinkSource::Face {body,..} if *body==anchor.target.body)
|
||||
})
|
||||
.map(|link| link.id.clone())
|
||||
.collect();
|
||||
for link in affected {
|
||||
sketch.break_link(&link)?;
|
||||
}
|
||||
}
|
||||
if let Some(FeatureKind::Sketch { plane: slot, .. }) = trial.doc_mut().feature_mut(id).map(|feature| &mut feature.kind) {
|
||||
*slot = plane;
|
||||
}
|
||||
Ok(json!({ "sketch": id }))
|
||||
trial.doc_mut().marker = Some(index + 1);
|
||||
trial.refresh();
|
||||
if let Some(error) = trial.model.result(id).and_then(|result| result.error.as_ref()) {
|
||||
return Err(bad(cmd, error.clone()));
|
||||
}
|
||||
trial.doc_mut().marker = marker;
|
||||
trial.refresh();
|
||||
s.doc = trial.doc;
|
||||
s.pending_moves = trial.pending_moves;
|
||||
|
||||
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() { "exact_face" } else { "fixed" }),
|
||||
_ => None,
|
||||
});
|
||||
Ok(json!({ "sketch": id,"attachment":attachment }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -411,7 +666,9 @@ mod tests {
|
||||
assert!((zmax(&mut s) - 5.0).abs() < 1e-6);
|
||||
run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"}));
|
||||
let z = zmax(&mut s);
|
||||
assert!(z > 9.0, "the profile stands up on XZ: {z}");
|
||||
assert!(z.abs() < 1e-6, "positive sketch Y on XZ extends toward -Z: {z}");
|
||||
let ymin = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"]["min"][2].as_f64().unwrap();
|
||||
assert!((ymin + 20.0).abs() < 1e-6);
|
||||
assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err());
|
||||
assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err());
|
||||
}
|
||||
@@ -441,6 +698,23 @@ mod tests {
|
||||
assert_eq!(s.doc.units, "mm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_width_keeps_its_inches_when_the_design_goes_metric() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "document.units", json!({"units": "in"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "w", "expression": "2", "unit": ""}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": "w", "height": 5}));
|
||||
let width = |s: &mut Session| {
|
||||
let b = &run(s, "inspect.measure", json!({}))["bodies"][0]["bbox"];
|
||||
b["max"][1].as_f64().unwrap() - b["min"][1].as_f64().unwrap()
|
||||
};
|
||||
assert!((width(&mut s) - 50.8).abs() < 1e-6, "2 in an inch design");
|
||||
let r = run(&mut s, "document.units", json!({"units": "mm"}));
|
||||
assert!((width(&mut s) - 50.8).abs() < 1e-6, "still 50.8 mm: {r}");
|
||||
assert_eq!(r["kept"], json!(["w"]));
|
||||
assert_eq!(s.doc.param("w").map(|p| p.expr.as_str()), Some("2 in"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_views_are_saved_renamed_and_deleted() {
|
||||
let mut s = Session::default();
|
||||
@@ -461,6 +735,60 @@ mod tests {
|
||||
assert_eq!(s.doc.named_views.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_sketches_hide_and_show() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let floor = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [50, 0]], "construction": true}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let drain = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.point", json!({"point": [25, 5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Floor"}));
|
||||
let body = s.model.state().bodies[0].name.clone();
|
||||
let drain_name = s.doc.feature(drain).unwrap().name.clone();
|
||||
let cam = solvecraft_render::Camera::default();
|
||||
let lines = |s: &Session| crate::view::scene(s, &cam).lines.len();
|
||||
let shown = |s: &mut Session| {
|
||||
let d = run(s, "document.inspect", json!({}));
|
||||
d["sketches"].as_array().unwrap().iter().filter(|x| x["visible"] == true).count()
|
||||
};
|
||||
let (n0, undo0, doc0) = (lines(&s), s.undo.len(), s.doc.clone());
|
||||
assert_eq!(shown(&mut s), 2);
|
||||
// By id and by name; the view loses their lines, the design and its history don't change.
|
||||
let r = run(&mut s, "browser.visibility", json!({"items": [floor, drain_name], "visible": false}));
|
||||
assert_eq!(r["changed"], 2);
|
||||
assert_eq!(shown(&mut s), 0);
|
||||
assert!(lines(&s) < n0);
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc0) && s.undo.len() == undo0, "view state, not a design change");
|
||||
run(&mut s, "browser.visibility", json!({"folder": "sketches", "visible": true}));
|
||||
assert_eq!(lines(&s), n0);
|
||||
// Bodies (following a rename) and construction planes.
|
||||
run(&mut s, "browser.visibility", json!({"items": [body.clone(), "Floor", "xy"], "visible": false}));
|
||||
assert!(crate::view::scene(&s, &cam).meshes.is_empty());
|
||||
assert_eq!(s.visibility.hidden_origin, vec!["Floor".to_string(), "XY".to_string()]);
|
||||
run(&mut s, "body.rename", json!({"body": body, "name": "Tray"}));
|
||||
assert_eq!(s.visibility.hidden_bodies, vec!["Tray".to_string()]);
|
||||
let d = run(&mut s, "document.inspect", json!({}));
|
||||
assert_eq!(d["bodies"][0]["visible"], false);
|
||||
// Hostile input is refused and changes nothing.
|
||||
let before = s.visibility.clone();
|
||||
for p in [
|
||||
json!({"items": [floor]}),
|
||||
json!({"items": [floor], "visible": "no"}),
|
||||
json!({"items": [floor, "Nope"], "visible": false}),
|
||||
json!({"items": [], "visible": false}),
|
||||
json!({"items": "Floor", "visible": false}),
|
||||
json!({"folder": "sketches", "items": [floor], "visible": false}),
|
||||
json!({"folder": "nope", "visible": false}),
|
||||
json!({"visible": false}),
|
||||
] {
|
||||
assert!(s.execute("browser.visibility", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.visibility, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_hold_items_and_follow_renames() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -62,11 +62,68 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let feats = s.clipboard.clone();
|
||||
let comp = s.active_component;
|
||||
let mut sketches: BTreeMap<u64, u64> = BTreeMap::new();
|
||||
let mut bodies: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut used: std::collections::BTreeSet<String> = s.model.state().bodies.iter().map(|body| body.name.clone()).collect();
|
||||
let mut notices = Vec::new();
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(comp));
|
||||
solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), comp, &author_path)?;
|
||||
let mut names: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut made = Vec::new();
|
||||
for f in feats {
|
||||
let mut kind = f.kind.clone();
|
||||
kind.remap_refs(&sketches, &names);
|
||||
if let FeatureKind::Sketch { plane, .. } = &mut kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
|
||||
{
|
||||
let internal = bodies.contains_key(&anchor.target.body);
|
||||
if !internal && translate.is_some_and(|translate| translate.len() > 1e-12) {
|
||||
return Err(bad(cmd, "translated paste retains an external exact face attachment; explicitly convert it to a fixed plane first"));
|
||||
}
|
||||
if !internal {
|
||||
notices.push(format!("pasted sketch retains external exact face attachment to {}", anchor.target.body));
|
||||
}
|
||||
anchor.remap_target(&sketches, &bodies)?;
|
||||
if internal {
|
||||
anchor.component = comp;
|
||||
anchor.path = author_path.clone();
|
||||
}
|
||||
anchor.author_path = author_path.clone();
|
||||
let target = anchor.target.clone();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &mut kind {
|
||||
for link in &mut sketch.links {
|
||||
if link.auto_face {
|
||||
if let solvecraft_sketch::LinkSource::Face { body, .. } = &mut link.source
|
||||
&& let Some(mapped) = bodies.get(body)
|
||||
{
|
||||
*body = mapped.clone();
|
||||
}
|
||||
link.face_name = Some(target.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let outputs = if f.body_names.is_empty() {
|
||||
s.model
|
||||
.result(f.id)
|
||||
.map(|result| result.state.bodies.iter().filter(|body| body.feature == f.id).map(|body| body.name.clone()).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
f.body_names.clone()
|
||||
};
|
||||
let copied_names: Vec<_> = outputs
|
||||
.iter()
|
||||
.map(|original| {
|
||||
let base = format!("{original} (copy)");
|
||||
let mut name = base.clone();
|
||||
let mut suffix = 2usize;
|
||||
while !used.insert(name.clone()) {
|
||||
name = format!("{base} {suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
bodies.insert(original.clone(), name.clone());
|
||||
name
|
||||
})
|
||||
.collect();
|
||||
let doc = s.doc_mut();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &mut kind {
|
||||
// Each dimension of the copy gets its own parameter, with the same expression.
|
||||
@@ -90,12 +147,10 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = doc.add_feature(kind, None)?;
|
||||
if let Some(nf) = doc.feature_mut(id) {
|
||||
nf.component = comp;
|
||||
nf.body_names = f.body_names.iter().map(|b| format!("{b} (copy)")).collect();
|
||||
nf.body_names = copied_names;
|
||||
}
|
||||
let new_name = doc.feature(id).map(|x| x.name.clone()).unwrap_or_default();
|
||||
if matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
sketches.insert(f.id, id);
|
||||
}
|
||||
sketches.insert(f.id, id);
|
||||
names.insert(f.name.clone(), new_name.clone());
|
||||
made.push(json!({"id": id, "name": new_name, "from": f.name}));
|
||||
}
|
||||
@@ -105,7 +160,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter_map(|m| m["id"].as_u64())
|
||||
.filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|e| json!({"feature": id, "error": e})))
|
||||
.collect();
|
||||
Ok(json!({"pasted": made, "errors": errors}))
|
||||
Ok(json!({"pasted": made, "errors": errors, "notices":notices}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+226
-127
@@ -8,31 +8,47 @@ use solvecraft_doc::{FeatureKind, Mat, Occurrence};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, num, str_, string_list, vec3};
|
||||
use crate::params::{bad, bool_, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[path = "component_properties.rs"]
|
||||
pub(super) mod properties;
|
||||
#[path = "component_transfer.rs"]
|
||||
mod transfer;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "occurrence_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod occurrence_tests;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.create", "New Component", new_component)
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("component")
|
||||
.params("name?, parent?: component id or name (default: the active one); activate?: bool (default true)"),
|
||||
.params("name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool (default true)"),
|
||||
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
|
||||
.icon("component")
|
||||
.params("bodies: [names] → one new component per body, named after it"),
|
||||
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design)"),
|
||||
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
|
||||
CommandSpec::new("component.list", "List Components", list).noundo(),
|
||||
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
|
||||
.icon("measure").noundo()
|
||||
.params("component?: id or name (default active); descendants?: bool (default true). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates"),
|
||||
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
|
||||
.icon("measure").noundo()
|
||||
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves"),
|
||||
CommandSpec::new("component.rename", "Rename Component", rename).params("component, name"),
|
||||
CommandSpec::new("component.delete", "Delete Component", delete).params("component: deletes it, its subcomponents, occurrences and features"),
|
||||
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"),
|
||||
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).params("sketches: [ids or names], component"),
|
||||
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence)
|
||||
.params("component (or occurrence): what to place again; parent?: component (default: the same parent); translate?: [x,y,z]"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: copied with its features into a new, independent component; translate?: [x,y,z]"),
|
||||
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]. Copies the selected effective transform, including pending moves"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
|
||||
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence)
|
||||
.params("occurrence; translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position)"),
|
||||
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position). Nested occurrence definitions remain shared"),
|
||||
CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"),
|
||||
CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("drops pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
|
||||
];
|
||||
|
||||
fn key(v: Option<&Value>) -> Option<String> {
|
||||
@@ -57,12 +73,61 @@ fn translation(t: Vec3) -> Mat {
|
||||
solvecraft_doc::rigid(t, Vec3::ZERO, Vec3::Z, 0.0)
|
||||
}
|
||||
|
||||
fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result<Vec3> {
|
||||
let v = match p.get(key) {
|
||||
None => default,
|
||||
Some(value) => vec3(value).ok_or_else(|| bad(cmd, format!("{key} must contain three finite numbers")))?,
|
||||
};
|
||||
if !v.is_finite() || (coordinate && [v.x, v.y, v.z].iter().any(|x| x.abs() > 1e9)) {
|
||||
return Err(bad(cmd, format!("{key} exceeds supported numeric range")));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub(super) fn valid_position(m: &Mat) -> bool {
|
||||
let column = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]);
|
||||
let (x, y, z) = (column(0), column(1), column(2));
|
||||
m.iter().flatten().all(|x| x.is_finite())
|
||||
&& m[3][..3].iter().all(|x| x.abs() <= 1e9)
|
||||
&& [x, y, z].iter().all(|axis| (axis.dot(*axis) - 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(|x| x.abs() < 1e-9)
|
||||
&& (m[3][3] - 1.0).abs() < 1e-9
|
||||
}
|
||||
|
||||
fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.create";
|
||||
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component };
|
||||
let parent_path = if let Some(value) = p.get("parent_path") {
|
||||
let path = value
|
||||
.as_array()
|
||||
.filter(|a| a.len() < 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(cmd, "parent_path must list up to 63 occurrence ids"))?;
|
||||
let component = if path.is_empty() { 0 } else { s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?.component };
|
||||
if component != parent {
|
||||
return Err(bad(cmd, "parent_path belongs to another component"));
|
||||
}
|
||||
path
|
||||
} else if parent == s.active_component {
|
||||
s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(parent))
|
||||
} else {
|
||||
s.doc.first_path(parent)
|
||||
};
|
||||
if parent_path.len() >= 64 {
|
||||
return Err(bad(cmd, "new component exceeds occurrence depth limit"));
|
||||
}
|
||||
let id = s.doc_mut().add_component(str_(p, "name"), parent)?;
|
||||
if p.get("activate").and_then(Value::as_bool).unwrap_or(true) {
|
||||
let mut path = parent_path;
|
||||
if let Some(o) = s.doc.occurrence_of(id) {
|
||||
path.push(o.id);
|
||||
}
|
||||
s.active_component = id;
|
||||
s.active_occurrence_path = Some(path);
|
||||
}
|
||||
let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
let occ = s.doc.occurrence_of(id).map(|o| o.id);
|
||||
@@ -70,30 +135,23 @@ fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.from_bodies";
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() || bodies.len() > 1000 {
|
||||
return Err(bad(cmd, "`bodies` must list 1…1000 body names"));
|
||||
}
|
||||
let st = s.model.state();
|
||||
for b in &bodies {
|
||||
if st.body(b).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{b}`")));
|
||||
}
|
||||
}
|
||||
let parent = s.active_component;
|
||||
let mut made = Vec::new();
|
||||
for b in bodies {
|
||||
let id = s.doc_mut().add_component(Some(&b), parent)?;
|
||||
s.doc_mut().body_components.insert(b.clone(), id);
|
||||
made.push(json!({"component": id, "body": b}));
|
||||
}
|
||||
Ok(json!({ "components": made }))
|
||||
transfer::from_bodies(s, p)
|
||||
}
|
||||
|
||||
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = component(s, p, "component", "component.activate")?;
|
||||
let path = if p.get("path").is_some() || p.get("occurrence").is_some() {
|
||||
let occurrence = occurrence(s, p, "component.activate")?;
|
||||
let path = occurrence_path(s, p, occurrence, "component.activate")?;
|
||||
if s.occurrence_placement(&path)?.component != id {
|
||||
return Err(bad("component.activate", "path belongs to another component"));
|
||||
}
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
s.active_component = id;
|
||||
s.active_occurrence_path = path;
|
||||
s.revision += 1;
|
||||
Ok(json!({ "active": id }))
|
||||
}
|
||||
@@ -102,7 +160,7 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let doc = &s.doc;
|
||||
let node = |id: u64, name: &str, parent: Option<u64>| {
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == id).map(|b| b.name.as_str()).collect();
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.model_body_component(b) == id).map(|b| b.name.as_str()).collect();
|
||||
let features: Vec<&str> = doc
|
||||
.features
|
||||
.iter()
|
||||
@@ -149,12 +207,22 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "the design itself can't be deleted"));
|
||||
}
|
||||
let gone: Vec<u64> = s.doc.components.iter().filter(|c| s.doc.component_within(c.id, id)).map(|c| c.id).collect();
|
||||
if s.doc.features.iter().any(|feature| {
|
||||
!gone.contains(&feature.component)
|
||||
&& matches!(&feature.kind, FeatureKind::BodyTransfer { from, to, .. } if gone.contains(from) || gone.contains(to))
|
||||
}) {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"component supplies a recorded body transfer outside this subtree; remove that transfer history before deleting its source",
|
||||
));
|
||||
}
|
||||
let feats: Vec<u64> = s.doc.features.iter().filter(|f| gone.contains(&f.component)).map(|f| f.id).collect();
|
||||
let d = s.doc_mut();
|
||||
for f in &feats {
|
||||
let _ = d.delete_feature(*f);
|
||||
}
|
||||
d.components.retain(|c| !gone.contains(&c.id));
|
||||
d.remove_configuration_components(&gone);
|
||||
d.occurrences.retain(|o| !gone.contains(&o.component) && !gone.contains(&o.parent));
|
||||
let moved_out: Vec<String> = d.body_components.iter().filter(|(_, c)| gone.contains(c)).map(|(b, _)| b.clone()).collect();
|
||||
d.body_components.retain(|_, c| !gone.contains(c));
|
||||
@@ -166,36 +234,11 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.move_bodies";
|
||||
let to = component(s, p, "component", cmd)?;
|
||||
let bodies = string_list(p, "bodies");
|
||||
let st = s.model.state();
|
||||
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
|
||||
return Err(bad(cmd, "`bodies` must list existing bodies"));
|
||||
}
|
||||
for b in &bodies {
|
||||
let feature = st.body(b).map(|x| x.feature).unwrap_or(0);
|
||||
let own = s.doc.feature(feature).map(|f| f.component).unwrap_or(0);
|
||||
// The body keeps its place in the world: where it is shown now (its component's frame and
|
||||
// any earlier offset), seen from the new component's frame.
|
||||
let from = s.doc.body_component(b, feature);
|
||||
let now = solvecraft_doc::mat_mul(&s.doc.component_transform(from), s.doc.body_offsets.get(b).unwrap_or(&solvecraft_doc::IDENTITY));
|
||||
let offset = solvecraft_doc::mat_inverse(&s.doc.component_transform(to))
|
||||
.map(|inv| solvecraft_doc::mat_mul(&inv, &now))
|
||||
.unwrap_or(solvecraft_doc::IDENTITY);
|
||||
let doc = s.doc_mut();
|
||||
if own == to {
|
||||
doc.body_components.remove(b);
|
||||
} else {
|
||||
doc.body_components.insert(b.clone(), to);
|
||||
}
|
||||
if solvecraft_doc::is_identity(&offset) {
|
||||
doc.body_offsets.remove(b);
|
||||
} else {
|
||||
doc.body_offsets.insert(b.clone(), offset);
|
||||
}
|
||||
}
|
||||
Ok(json!({"bodies": bodies, "component": to}))
|
||||
transfer::move_bodies(s, p)
|
||||
}
|
||||
|
||||
pub(crate) fn transfer_constructor_result(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
transfer::move_result(s, p)
|
||||
}
|
||||
|
||||
fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -222,16 +265,30 @@ fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn copy_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.copy";
|
||||
let comp = if p.get("occurrence").is_some() {
|
||||
let selected = if p.get("occurrence").is_some() {
|
||||
let o = occurrence(s, p, cmd)?;
|
||||
s.doc.occurrences.iter().find(|x| x.id == o).map(|x| x.component).unwrap_or(0)
|
||||
Some(o)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let comp = if let Some(id) = selected {
|
||||
s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.component).unwrap_or(0)
|
||||
} else {
|
||||
component(s, p, "component", cmd)?
|
||||
};
|
||||
let src = s.doc.occurrence_of(comp).cloned().ok_or_else(|| bad(cmd, "that component has no occurrence"))?;
|
||||
let src = if let Some(id) = selected { s.doc.occurrences.iter().find(|o| o.id == id) } else { s.doc.occurrence_of(comp) }
|
||||
.cloned()
|
||||
.ok_or_else(|| bad(cmd, "that component has no occurrence"))?;
|
||||
if p.get("path").is_some() {
|
||||
occurrence_path(s, p, src.id, cmd)?;
|
||||
}
|
||||
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { src.parent };
|
||||
let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let m = solvecraft_doc::mat_mul(&translation(t), &src.transform);
|
||||
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
|
||||
let base = s.pending_moves.get(&src.id).unwrap_or(&src.transform);
|
||||
let m = solvecraft_doc::mat_mul(&translation(t), base);
|
||||
if !valid_position(&m) {
|
||||
return Err(bad(cmd, "copy transform exceeds supported numeric range"));
|
||||
}
|
||||
let id = s.doc_mut().add_occurrence(comp, parent, m)?;
|
||||
Ok(json!({"occurrence": id, "component": comp}))
|
||||
}
|
||||
@@ -243,68 +300,55 @@ fn paste_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "pick a component, not the design"));
|
||||
}
|
||||
let src = s.doc.components.iter().find(|c| c.id == comp).cloned().ok_or_else(|| bad(cmd, "component"))?;
|
||||
let occ = s.doc.occurrence_of(comp).cloned();
|
||||
let new_id = s.doc_mut().add_component(Some(&format!("{} (copy)", src.name)), src.parent)?;
|
||||
// Copy the component's features with fresh ids and names; references between them follow.
|
||||
let feats: Vec<solvecraft_doc::Feature> = s.doc.features.iter().filter(|f| f.component == comp).cloned().collect();
|
||||
let mut idmap: std::collections::HashMap<u64, u64> = Default::default();
|
||||
let mut namemap: std::collections::HashMap<String, String> = Default::default();
|
||||
let mut new_ids = Vec::new();
|
||||
for f in &feats {
|
||||
let nid = s.doc_mut().add_feature(f.kind.clone(), Some(&format!("{} (copy)", f.name)))?;
|
||||
idmap.insert(f.id, nid);
|
||||
let nname = s.doc.feature(nid).map(|x| x.name.clone()).unwrap_or_default();
|
||||
namemap.insert(f.name.clone(), nname);
|
||||
new_ids.push(nid);
|
||||
}
|
||||
for nid in &new_ids {
|
||||
let Some(f) = s.doc.feature(*nid).cloned() else { continue };
|
||||
let mut v = serde_json::to_value(&f.kind).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
remap(&mut v, &idmap, &namemap, 0);
|
||||
let kind: FeatureKind = serde_json::from_value(v).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if let Some(slot) = s.doc_mut().feature_mut(*nid) {
|
||||
slot.kind = kind;
|
||||
slot.component = new_id;
|
||||
let occ = if p.get("occurrence").is_some() {
|
||||
let id = occurrence(s, p, cmd)?;
|
||||
if p.get("path").is_some() {
|
||||
occurrence_path(s, p, id, cmd)?;
|
||||
}
|
||||
let occurrence = s.doc.occurrences.iter().find(|o| o.id == id).cloned().ok_or_else(|| bad(cmd, "no occurrence"))?;
|
||||
if occurrence.component != comp {
|
||||
return Err(bad(cmd, "occurrence belongs to another component"));
|
||||
}
|
||||
Some(occurrence)
|
||||
} else {
|
||||
s.doc.occurrence_of(comp).cloned()
|
||||
};
|
||||
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
|
||||
let base = occ.as_ref().map(|o| s.pending_moves.get(&o.id).copied().unwrap_or(o.transform)).unwrap_or(solvecraft_doc::IDENTITY);
|
||||
let transform = solvecraft_doc::mat_mul(&translation(t), &base);
|
||||
if !valid_position(&transform) {
|
||||
return Err(bad(cmd, "copy transform exceeds supported numeric range"));
|
||||
}
|
||||
let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let base = occ.map(|o| o.transform).unwrap_or(solvecraft_doc::IDENTITY);
|
||||
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.component == new_id) {
|
||||
o.transform = solvecraft_doc::mat_mul(&translation(t), &base);
|
||||
}
|
||||
Ok(json!({"component": new_id, "features": new_ids}))
|
||||
let parent = occ.as_ref().map(|o| o.parent).unwrap_or(src.parent);
|
||||
super::component_clone::paste(s, comp, occ, transform, parent, p)
|
||||
}
|
||||
|
||||
/// Point copied features at their copied sketches and features.
|
||||
fn remap(v: &mut Value, ids: &std::collections::HashMap<u64, u64>, names: &std::collections::HashMap<String, String>, depth: usize) {
|
||||
if depth > 32 {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
for (k, x) in o.iter_mut() {
|
||||
match (k.as_str(), &*x) {
|
||||
("sketch" | "path_sketch", Value::Number(n)) => {
|
||||
if let Some(m) = n.as_u64().and_then(|i| ids.get(&i)) {
|
||||
*x = json!(m);
|
||||
}
|
||||
}
|
||||
("features", Value::Array(_)) => {
|
||||
if let Value::Array(a) = x {
|
||||
for e in a.iter_mut() {
|
||||
if let Some(m) = e.as_str().and_then(|n| names.get(n)) {
|
||||
*e = json!(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => remap(x, ids, names, depth + 1),
|
||||
}
|
||||
pub(super) fn occurrence_path(s: &Session, p: &Value, id: u64, cmd: &str) -> Result<Vec<u64>> {
|
||||
let path = if let Some(value) = p.get("path") {
|
||||
value
|
||||
.as_array()
|
||||
.filter(|a| !a.is_empty() && a.len() <= 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(cmd, "`path` must list 1..64 occurrence ids"))?
|
||||
} else {
|
||||
let o = s.doc.occurrences.iter().find(|o| o.id == id).ok_or_else(|| bad(cmd, "no occurrence"))?;
|
||||
let mut path = s.doc.first_path(o.parent);
|
||||
// A world frame requires an explicit containing placement when any ancestor is instanced.
|
||||
for ancestor in &path {
|
||||
if let Some(a) = s.doc.occurrences.iter().find(|a| a.id == *ancestor)
|
||||
&& s.doc.occurrences.iter().filter(|x| x.component == a.component).count() > 1
|
||||
{
|
||||
return Err(bad(cmd, "occurrence has multiple placements; supply its `path`"));
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(|x| remap(x, ids, names, depth + 1)),
|
||||
_ => {}
|
||||
path.push(id);
|
||||
path
|
||||
};
|
||||
if path.last() != Some(&id) {
|
||||
return Err(bad(cmd, "path and occurrence disagree"));
|
||||
}
|
||||
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -314,19 +358,56 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if o.grounded {
|
||||
return Err(bad(cmd, format!("{} is grounded", o.name)));
|
||||
}
|
||||
let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let axis = p.get("axis").and_then(vec3).unwrap_or(Vec3::Z);
|
||||
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
|
||||
let axis = move_vector(p, "axis", Vec3::Z, false, cmd)?;
|
||||
let angle = match p.get("angle") {
|
||||
Some(Value::String(e)) => s.doc.eval(e, solvecraft_doc::expr::Kind::Angle).map_err(|e| bad(cmd, e.to_string()))?,
|
||||
_ => num(p, "angle").unwrap_or(0.0),
|
||||
None => 0.0,
|
||||
Some(value) => value.as_f64().ok_or_else(|| bad(cmd, "angle must be a number or angle expression"))?,
|
||||
};
|
||||
if !(t.is_finite() && angle.is_finite()) {
|
||||
if !(t.is_finite() && axis.is_finite() && angle.is_finite()) {
|
||||
return Err(bad(cmd, "non-finite move"));
|
||||
}
|
||||
let origin = p.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
if angle != 0.0 && axis.normalized().is_none() {
|
||||
return Err(bad(cmd, "rotation axis must have a finite nonzero length"));
|
||||
}
|
||||
let origin = move_vector(p, "origin", Vec3::ZERO, true, cmd)?;
|
||||
let delta = solvecraft_doc::rigid(t, origin, axis, angle);
|
||||
let cur = s.pending_moves.get(&id).copied().unwrap_or(o.transform);
|
||||
let space = match p.get("space") {
|
||||
None => "local",
|
||||
Some(Value::String(space)) => space.as_str(),
|
||||
_ => return Err(bad(cmd, "space must be local or world")),
|
||||
};
|
||||
if !matches!(space, "local" | "world") {
|
||||
return Err(bad(cmd, "space must be local or world"));
|
||||
}
|
||||
let path = if p.get("path").is_some() || space == "world" { Some(occurrence_path(s, p, id, cmd)?) } else { None };
|
||||
let delta = if space == "world" {
|
||||
let placement = s.occurrence_placement(path.as_deref().unwrap_or(&[]))?;
|
||||
let inverse = solvecraft_doc::mat_inverse(&placement.parent_world).ok_or_else(|| bad(cmd, "parent frame is singular"))?;
|
||||
solvecraft_doc::mat_mul(&solvecraft_doc::mat_mul(&inverse, &delta), &placement.parent_world)
|
||||
} else {
|
||||
delta
|
||||
};
|
||||
let cur = if let Some(value) = p.get("base_transform") {
|
||||
let base: Mat = serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "base_transform must be a 4x4 affine matrix"))?;
|
||||
if !valid_position(&base)
|
||||
|| base[0][3].abs() > 1e-12
|
||||
|| base[1][3].abs() > 1e-12
|
||||
|| base[2][3].abs() > 1e-12
|
||||
|| (base[3][3] - 1.0).abs() > 1e-12
|
||||
|| solvecraft_doc::mat_inverse(&base).is_none()
|
||||
{
|
||||
return Err(bad(cmd, "base_transform must be finite, affine and invertible"));
|
||||
}
|
||||
base
|
||||
} else {
|
||||
s.pending_moves.get(&id).copied().unwrap_or(o.transform)
|
||||
};
|
||||
let next = solvecraft_doc::mat_mul(&delta, &cur);
|
||||
if !valid_position(&next) {
|
||||
return Err(bad(cmd, "move transform exceeds supported numeric range"));
|
||||
}
|
||||
if bool_(p, "capture").unwrap_or(true) {
|
||||
s.pending_moves.remove(&id);
|
||||
if let Some(x) = s.doc_mut().occurrences.iter_mut().find(|x| x.id == id) {
|
||||
@@ -342,6 +423,9 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn ground(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.ground";
|
||||
let id = occurrence(s, p, cmd)?;
|
||||
if p.get("path").is_some() {
|
||||
occurrence_path(s, p, id, cmd)?;
|
||||
}
|
||||
let want = bool_(p, "grounded");
|
||||
let o = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id).ok_or_else(|| bad(cmd, "occurrence"))?;
|
||||
o.grounded = want.unwrap_or(!o.grounded);
|
||||
@@ -361,9 +445,24 @@ fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "captured": n }))
|
||||
}
|
||||
|
||||
fn revert(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let n = s.pending_moves.len();
|
||||
s.pending_moves.clear();
|
||||
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let before = s.pending_moves.len();
|
||||
if let Some(value) = p.get("occurrences") {
|
||||
let ids = value
|
||||
.as_array()
|
||||
.filter(|a| !a.is_empty() && a.len() <= 10_000)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad("component.revert_position", "occurrences must list 1..10000 ids"))?;
|
||||
if ids.iter().any(|id| !s.doc.occurrences.iter().any(|o| o.id == *id)) {
|
||||
return Err(bad("component.revert_position", "no occurrence"));
|
||||
}
|
||||
for id in ids {
|
||||
s.pending_moves.remove(&id);
|
||||
}
|
||||
} else {
|
||||
s.pending_moves.clear();
|
||||
}
|
||||
let n = before - s.pending_moves.len();
|
||||
s.revision += 1;
|
||||
Ok(json!({ "reverted": n }))
|
||||
}
|
||||
@@ -374,7 +473,7 @@ pub fn to_active_frame(s: &Session, kind: &mut FeatureKind) {
|
||||
if s.active_component == 0 || crate::frames::mapped() {
|
||||
return;
|
||||
}
|
||||
let m = s.doc.component_transform(s.active_component);
|
||||
let m = s.active_component_frame();
|
||||
if solvecraft_doc::is_identity(&m) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
//! Independent copies of a component definition graph, assembled and evaluated atomically.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Sel, Session};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence};
|
||||
|
||||
const CMD: &str = "component.paste_new";
|
||||
const MAX_WORK: usize = 10_000;
|
||||
|
||||
fn mesh_distance(mesh: &solvecraft_geom::Mesh, point: solvecraft_geom::Vec3) -> f64 {
|
||||
mesh.triangles
|
||||
.iter()
|
||||
.filter_map(|triangle| mesh.tri(triangle))
|
||||
.map(|[a, b, c]| {
|
||||
let normal = (b - a).cross(c - a);
|
||||
let squared = normal.dot(normal);
|
||||
let projected = if squared > 0. { point - normal * ((point - a).dot(normal) / squared) } else { point };
|
||||
if squared > 0. && [(a, b), (b, c), (c, a)].iter().all(|(start, end)| (*end - *start).cross(projected - *start).dot(normal) >= 0.) {
|
||||
return point.dist(projected);
|
||||
}
|
||||
[(a, b), (b, c), (c, a)]
|
||||
.iter()
|
||||
.map(|(start, end)| {
|
||||
let edge = *end - *start;
|
||||
let t = if edge.dot(edge) > 0. { ((point - *start).dot(edge) / edge.dot(edge)).clamp(0., 1.) } else { 0. };
|
||||
point.dist(*start + edge * t)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
fn material_probe_matches(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, point: solvecraft_geom::Vec3, tol_a: f64, tol_b: f64) -> bool {
|
||||
if !point.is_finite() || !tol_a.is_finite() || !tol_b.is_finite() || tol_a <= 0. || tol_b <= 0. {
|
||||
return false;
|
||||
}
|
||||
if a.contains(point) == b.contains(point) {
|
||||
return true;
|
||||
}
|
||||
// A chord approximation does not define material ON a boundary. Certify
|
||||
// nearby stock and void instead, outside both meshes' approximation strips.
|
||||
if mesh_distance(a, point) > tol_a || mesh_distance(b, point) > tol_b {
|
||||
return false;
|
||||
}
|
||||
let offset = 4. * tol_a.max(tol_b);
|
||||
let mut material = false;
|
||||
let mut void = false;
|
||||
for axis in [solvecraft_geom::Vec3::X, solvecraft_geom::Vec3::Y, solvecraft_geom::Vec3::Z] {
|
||||
for sign in [-1., 1.] {
|
||||
let q = point + axis * (sign * offset);
|
||||
if !q.is_finite() {
|
||||
return false;
|
||||
}
|
||||
if mesh_distance(a, q) <= tol_a || mesh_distance(b, q) <= tol_b {
|
||||
continue;
|
||||
}
|
||||
let inside = a.contains(q);
|
||||
if inside != b.contains(q) {
|
||||
return false;
|
||||
}
|
||||
material |= inside;
|
||||
void |= !inside;
|
||||
}
|
||||
}
|
||||
material && void
|
||||
}
|
||||
|
||||
fn cloned_path(source: &Document, path: &[u64], root: u64, destination: &[u64], occurrences: &BTreeMap<u64, u64>) -> Result<Vec<u64>> {
|
||||
let start = path
|
||||
.iter()
|
||||
.position(|id| source.occurrences.iter().any(|o| o.id == *id && o.component == root))
|
||||
.ok_or_else(|| bad(CMD, "copied Emboss placement is outside the copied component tree"))?;
|
||||
let mut mapped = destination.to_vec();
|
||||
for id in path.iter().skip(start + 1) {
|
||||
mapped.push(*occurrences.get(id).ok_or_else(|| bad(CMD, "copied Emboss path has an external descendant"))?);
|
||||
}
|
||||
if mapped.len() > 64 {
|
||||
return Err(bad(CMD, "copied Emboss path exceeds placement depth limit"));
|
||||
}
|
||||
Ok(mapped)
|
||||
}
|
||||
|
||||
fn unique(base: &str, used: &mut BTreeSet<String>) -> String {
|
||||
let stem = format!("{base} (copy)");
|
||||
let mut name = stem.clone();
|
||||
let mut n = 2;
|
||||
while used.contains(&name) {
|
||||
name = format!("{stem} {n}");
|
||||
n += 1;
|
||||
}
|
||||
used.insert(name.clone());
|
||||
name
|
||||
}
|
||||
|
||||
fn graph(doc: &Document, root: u64) -> Result<Vec<u64>> {
|
||||
let definitions: BTreeSet<_> = doc.components.iter().map(|c| c.id).collect();
|
||||
let mut children: BTreeMap<u64, Vec<u64>> = BTreeMap::new();
|
||||
for occurrence in &doc.occurrences {
|
||||
children.entry(occurrence.parent).or_default().push(occurrence.component);
|
||||
}
|
||||
let mut output = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut stack = vec![(root, Vec::<u64>::new())];
|
||||
let mut work = 0;
|
||||
while let Some((id, mut ancestors)) = stack.pop() {
|
||||
work += 1;
|
||||
if work > MAX_WORK || ancestors.len() >= 64 {
|
||||
return Err(bad(CMD, "component graph exceeds copy work limit"));
|
||||
}
|
||||
if ancestors.contains(&id) {
|
||||
return Err(bad(CMD, "component graph contains a cycle"));
|
||||
}
|
||||
if !definitions.contains(&id) {
|
||||
return Err(bad(CMD, "component graph has a missing definition"));
|
||||
}
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
output.push(id);
|
||||
ancestors.push(id);
|
||||
if let Some(children) = children.get(&id) {
|
||||
if work.saturating_add(stack.len()).saturating_add(children.len()) > MAX_WORK {
|
||||
return Err(bad(CMD, "component graph exceeds copy work limit"));
|
||||
}
|
||||
for child in children.iter().rev() {
|
||||
stack.push((*child, ancestors.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A shared node may be reached by a longer path after its first DFS visit. Check the
|
||||
// longest path over the complete DAG, rather than trusting the first placement depth.
|
||||
let mut indegree: BTreeMap<u64, usize> = output.iter().map(|id| (*id, 0)).collect();
|
||||
for parent in &output {
|
||||
if let Some(edges) = children.get(parent) {
|
||||
for child in edges {
|
||||
if let Some(degree) = indegree.get_mut(child) {
|
||||
*degree += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ready = std::collections::VecDeque::from([root]);
|
||||
let mut depth = BTreeMap::from([(root, 1usize)]);
|
||||
let mut sorted = Vec::new();
|
||||
while let Some(parent) = ready.pop_front() {
|
||||
sorted.push(parent);
|
||||
let parent_depth = *depth.get(&parent).unwrap_or(&1);
|
||||
if let Some(edges) = children.get(&parent) {
|
||||
for child in edges {
|
||||
let next = parent_depth + 1;
|
||||
if next > 64 {
|
||||
return Err(bad(CMD, "component graph exceeds copy depth limit"));
|
||||
}
|
||||
depth.entry(*child).and_modify(|d| *d = (*d).max(next)).or_insert(next);
|
||||
if let Some(degree) = indegree.get_mut(child) {
|
||||
*degree = degree.saturating_sub(1);
|
||||
if *degree == 0 {
|
||||
ready.push_back(*child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sorted.len() != output.len() {
|
||||
return Err(bad(CMD, "component graph contains a cycle"));
|
||||
}
|
||||
Ok(sorted)
|
||||
}
|
||||
|
||||
pub(super) fn paste(
|
||||
s: &mut Session,
|
||||
component: u64,
|
||||
source_occurrence: Option<Occurrence>,
|
||||
transform: Mat,
|
||||
parent: u64,
|
||||
params: &Value,
|
||||
) -> Result<Value> {
|
||||
let source = s.doc.clone();
|
||||
let source_parameters: BTreeMap<_, _> = source.params.iter().map(|p| (p.name.as_str(), p)).collect();
|
||||
let generated_parameters: BTreeSet<_> = source
|
||||
.params
|
||||
.iter()
|
||||
.filter(|p| p.model)
|
||||
.map(|p| p.name.as_str())
|
||||
.chain(source.features.iter().flat_map(|f| f.param_names.iter().map(String::as_str)))
|
||||
.collect();
|
||||
let definitions = graph(&source, component)?;
|
||||
let definition_records: BTreeMap<_, _> = source.components.iter().map(|c| (c.id, c)).collect();
|
||||
let selected: BTreeSet<_> = definitions.iter().copied().collect();
|
||||
if source.marker.is_some_and(|m| m < source.features.len()) {
|
||||
return Err(bad(CMD, "move the timeline marker to the end before Paste New"));
|
||||
}
|
||||
let feats: Vec<Feature> = source.features.iter().filter(|f| selected.contains(&f.component)).cloned().collect();
|
||||
if feats.len() + source.features.len() > MAX_WORK {
|
||||
return Err(bad(CMD, "too many features to copy"));
|
||||
}
|
||||
for f in &feats {
|
||||
if s.model.result(f.id).is_some_and(|r| r.error.is_some()) {
|
||||
return Err(bad(CMD, format!("source feature {} has an evaluation error", f.name)));
|
||||
}
|
||||
}
|
||||
let state = s.model.state();
|
||||
if state.bodies.len() > MAX_WORK {
|
||||
return Err(bad(CMD, "body set exceeds copy work limit"));
|
||||
}
|
||||
if source.next_id.checked_add(100_000).is_none() {
|
||||
return Err(bad(CMD, "copy id allocation exceeds supported range"));
|
||||
}
|
||||
let mut scratch = s.scratch();
|
||||
let mut components = BTreeMap::new();
|
||||
let mut occurrences = BTreeMap::new();
|
||||
let mut features = BTreeMap::new();
|
||||
let mut names = BTreeMap::new();
|
||||
let mut bodies = BTreeMap::new();
|
||||
let mut parameters = BTreeMap::new();
|
||||
let mut used: BTreeSet<String> = source
|
||||
.features
|
||||
.iter()
|
||||
.map(|f| f.name.clone())
|
||||
.chain(source.components.iter().map(|c| c.name.clone()))
|
||||
.chain(state.bodies.iter().map(|b| b.name.clone()))
|
||||
.collect();
|
||||
let mut new_ids = Vec::new();
|
||||
let mut frozen = Vec::new();
|
||||
// Allocate definitions, discarding their automatic placeholder occurrence except root.
|
||||
for old in &definitions {
|
||||
let c = definition_records.get(old).ok_or_else(|| bad(CMD, "missing definition"))?;
|
||||
let mapped_parent = if *old == component {
|
||||
parent
|
||||
} else {
|
||||
components.get(&c.parent).copied().unwrap_or(*components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?)
|
||||
};
|
||||
let name = unique(&c.name, &mut used);
|
||||
let new = scratch.doc_mut().add_component(Some(&name), mapped_parent)?;
|
||||
components.insert(*old, new);
|
||||
if *old != component {
|
||||
scratch.doc_mut().occurrences.retain(|o| o.component != new);
|
||||
}
|
||||
}
|
||||
let root = *components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?;
|
||||
let root_occurrence = scratch.doc.occurrence_of(root).map(|o| o.id).ok_or_else(|| bad(CMD, "missing cloned placement"))?;
|
||||
let mut clone_path = if let Some(o) = &source_occurrence {
|
||||
let mut path = super::component::occurrence_path(s, params, o.id, CMD)?;
|
||||
path.pop();
|
||||
path
|
||||
} else {
|
||||
source.first_path(parent)
|
||||
};
|
||||
clone_path.push(root_occurrence);
|
||||
if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == root_occurrence) {
|
||||
o.transform = transform;
|
||||
o.grounded = source_occurrence.as_ref().is_some_and(|o| o.grounded);
|
||||
}
|
||||
if let Some(o) = &source_occurrence {
|
||||
occurrences.insert(o.id, root_occurrence);
|
||||
}
|
||||
for old in source.occurrences.iter().filter(|o| selected.contains(&o.parent)) {
|
||||
let child = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing child definition"))?;
|
||||
let owner = *components.get(&old.parent).ok_or_else(|| bad(CMD, "missing parent definition"))?;
|
||||
let pose = s.pending_moves.get(&old.id).copied().unwrap_or(old.transform);
|
||||
if !super::component::valid_position(&pose) {
|
||||
return Err(bad(CMD, "invalid internal pending occurrence transform"));
|
||||
}
|
||||
let id = scratch.doc_mut().add_occurrence(child, owner, pose)?;
|
||||
if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == id) {
|
||||
o.grounded = old.grounded;
|
||||
}
|
||||
occurrences.insert(old.id, id);
|
||||
}
|
||||
// Collect historical outputs too: a later Combine may have consumed a named tool body.
|
||||
let mut outputs: BTreeMap<u64, Vec<String>> = BTreeMap::new();
|
||||
for f in &feats {
|
||||
if let Some(result) = s.model.result(f.id) {
|
||||
let created: Vec<_> = result.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| b.name.clone()).collect();
|
||||
for name in &created {
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used));
|
||||
}
|
||||
outputs.insert(f.id, created);
|
||||
}
|
||||
for name in &f.body_names {
|
||||
if !name.is_empty() {
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used));
|
||||
}
|
||||
}
|
||||
if bodies.len() > MAX_WORK {
|
||||
return Err(bad(CMD, "historical body set exceeds copy work limit"));
|
||||
}
|
||||
}
|
||||
// Body assignment may put externally authored geometry into this graph. Freeze it explicitly.
|
||||
let owned_features: BTreeSet<_> = feats.iter().map(|f| f.id).collect();
|
||||
for body in state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b)) && !owned_features.contains(&b.feature)) {
|
||||
if body.body.is_mesh() {
|
||||
return Err(bad(CMD, "Paste New of a reassigned mesh body is not supported yet"));
|
||||
}
|
||||
let offset = source.model_body_offset(body).copied().unwrap_or(solvecraft_doc::IDENTITY);
|
||||
// An external transfer establishes the local baseline in this graph.
|
||||
// Freeze AFTER that boundary, then replay it as an identity ownership event.
|
||||
let entry = feats.iter().find(|f| {
|
||||
!f.suppressed
|
||||
&& matches!(&f.kind, FeatureKind::BodyTransfer { bodies, from, to, .. }
|
||||
if !selected.contains(from) && bodies.contains(&body.name)
|
||||
&& s.model.result(f.id).is_some_and(|result| !result.skipped && result.error.is_none()
|
||||
&& result.state.body(&body.name).is_some_and(|entered| source.model_body_component(entered) == *to)))
|
||||
});
|
||||
let before = feats.iter().find_map(|f| {
|
||||
let before = s.model.state_before(f.id);
|
||||
let prior = before.body(&body.name)?;
|
||||
let after = s.model.result(f.id)?.state.body(&body.name);
|
||||
if after.is_none_or(|after| !prior.body.same(&after.body)) { Some(before) } else { None }
|
||||
});
|
||||
let entered = entry.and_then(|f| s.model.result(f.id)).map(|r| r.state.clone());
|
||||
let base = entered.as_ref().and_then(|st| st.body(&body.name)).or_else(|| before.as_ref().and_then(|st| st.body(&body.name))).unwrap_or(body);
|
||||
let baseline_component = source.model_body_component(base);
|
||||
let geometry = solvecraft_kernel::transform_matrix(&base.body, offset)?;
|
||||
let step = solvecraft_kernel::step_export(&[&geometry], "SolidCraft independent reassigned body")?;
|
||||
let new_name = unique(&body.name, &mut used);
|
||||
bodies.insert(body.name.clone(), new_name.clone());
|
||||
let feature_name = unique(&format!("{} Base", body.name), &mut used);
|
||||
let id = scratch.doc_mut().add_feature(
|
||||
FeatureKind::Import { file: format!("{} independent base", body.name), step, components: Vec::new() },
|
||||
Some(&feature_name),
|
||||
)?;
|
||||
if let Some(f) = scratch.doc_mut().feature_mut(id) {
|
||||
f.component = *components.get(&baseline_component).ok_or_else(|| bad(CMD, "reassigned baseline is outside copied component graph"))?;
|
||||
f.body_names = vec![new_name];
|
||||
}
|
||||
new_ids.push(id);
|
||||
frozen.push(body.name.clone());
|
||||
}
|
||||
// Allocate all history IDs before rewriting any forward reference.
|
||||
for f in &feats {
|
||||
let name = unique(&f.name, &mut used);
|
||||
let id = scratch.doc_mut().add_feature(f.kind.clone(), Some(&name))?;
|
||||
if let Some(new) = scratch.doc.feature(id) {
|
||||
for (old, pn) in f.param_names.iter().zip(&new.param_names) {
|
||||
parameters.insert(old.clone(), pn.clone());
|
||||
}
|
||||
}
|
||||
features.insert(f.id, id);
|
||||
names.insert(f.name.clone(), name);
|
||||
new_ids.push(id);
|
||||
}
|
||||
// Constraint dimensions own independent generated parameters; public user parameters stay shared.
|
||||
for f in &feats {
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
for c in &sketch.constraints {
|
||||
if let Some(name) = &c.param
|
||||
&& !parameters.contains_key(name)
|
||||
&& let Some(p) = source_parameters.get(name.as_str())
|
||||
&& p.model
|
||||
{
|
||||
if scratch.doc.params.len() >= solvecraft_doc::MAX_PARAMS {
|
||||
return Err(bad(CMD, "too many copied parameters"));
|
||||
}
|
||||
let new = scratch.doc_mut().new_model_param(&p.expr, &p.unit);
|
||||
parameters.insert(name.clone(), new);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (old, new) in ¶meters {
|
||||
if let Some(comment) = source.param_comments.get(old) {
|
||||
scratch.doc_mut().param_comments.insert(new.clone(), comment.clone());
|
||||
}
|
||||
if let Some(p) = source_parameters.get(old.as_str())
|
||||
&& p.model
|
||||
{
|
||||
let expr = solvecraft_doc::clone_expression(&p.expr, ¶meters);
|
||||
if let Some(p) = scratch.doc_mut().params.iter_mut().find(|p| p.name == *new) {
|
||||
p.expr = expr;
|
||||
p.comment = source_parameters.get(old.as_str()).map(|p| p.comment.clone()).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
for old in &feats {
|
||||
let mut f = old.clone();
|
||||
f.id = *features.get(&old.id).ok_or_else(|| bad(CMD, "missing feature mapping"))?;
|
||||
f.name = names.get(&old.name).cloned().ok_or_else(|| bad(CMD, "missing feature name"))?;
|
||||
f.component = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing component mapping"))?;
|
||||
if f.body_names.is_empty() {
|
||||
f.body_names = outputs.get(&old.id).cloned().unwrap_or_default();
|
||||
}
|
||||
f.remap_clone(&features, &names, &bodies, ¶meters)?;
|
||||
if let FeatureKind::BodyTransfer { from, to, transform, .. } = &mut f.kind {
|
||||
*to = *components.get(to).ok_or_else(|| bad(CMD, "transfer destination is outside the copied graph"))?;
|
||||
if let Some(mapped) = components.get(from) {
|
||||
*from = *mapped;
|
||||
} else {
|
||||
*from = *to;
|
||||
*transform = solvecraft_doc::IDENTITY;
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
|
||||
&& !face.path.is_empty()
|
||||
&& let FeatureKind::Emboss { face: Some(old_face), .. } = &old.kind
|
||||
&& bodies.contains_key(&old_face.body)
|
||||
{
|
||||
face.path = cloned_path(&source, &old_face.path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
|
||||
&& !face.author_path.is_empty()
|
||||
{
|
||||
face.author_path = cloned_path(&source, &face.author_path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let (FeatureKind::Sketch { plane, .. }, FeatureKind::Sketch { plane: old_plane, .. }) = (&mut f.kind, &old.kind)
|
||||
&& let (Some(anchor), Some(original)) = (super::sketch::face::anchor_mut(plane), super::sketch::face::anchor(old_plane))
|
||||
{
|
||||
if bodies.contains_key(&original.target.body) {
|
||||
anchor.component =
|
||||
*components.get(&original.component).ok_or_else(|| bad(CMD, "anchored source component is outside copied graph"))?;
|
||||
anchor.path = cloned_path(&source, &original.path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
anchor.author_path = cloned_path(&source, &original.author_path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let Some(slot) = scratch.doc_mut().feature_mut(f.id) {
|
||||
*slot = f;
|
||||
}
|
||||
}
|
||||
for (old, new) in &bodies {
|
||||
if let Some(mat) = source.materials.get(old) {
|
||||
scratch.doc_mut().materials.insert(new.clone(), mat.clone());
|
||||
}
|
||||
if let Some(look) = source.appearances.bodies.get(old) {
|
||||
scratch.doc_mut().appearances.bodies.insert(new.clone(), look.clone());
|
||||
}
|
||||
if !frozen.contains(old) {
|
||||
if let Some(comp) = source.body_components.get(old).and_then(|c| components.get(c)) {
|
||||
scratch.doc_mut().body_components.insert(new.clone(), *comp);
|
||||
}
|
||||
if let Some(offset) = source.body_offsets.get(old) {
|
||||
scratch.doc_mut().body_offsets.insert(new.clone(), *offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (old, new) in &components {
|
||||
if let Some(part) = source.parts.get(old) {
|
||||
scratch.doc_mut().parts.insert(*new, part.clone());
|
||||
}
|
||||
if let Some(look) = source.appearances.components.get(old) {
|
||||
scratch.doc_mut().appearances.components.insert(*new, look.clone());
|
||||
}
|
||||
}
|
||||
// A creator's history can belong here while its final body was reassigned outside.
|
||||
// Retain the independent history, but reproduce the current ownership at its end.
|
||||
let moved_out: Vec<_> = state
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| owned_features.contains(&b.feature) && !selected.contains(&source.model_body_component(b)))
|
||||
.filter_map(|b| bodies.get(&b.name).cloned())
|
||||
.collect();
|
||||
if !moved_out.is_empty() {
|
||||
let name = unique("Assigned-out bodies", &mut used);
|
||||
let id = scratch.doc_mut().add_feature(FeatureKind::Remove { bodies: moved_out }, Some(&name))?;
|
||||
if let Some(f) = scratch.doc_mut().feature_mut(id) {
|
||||
f.component = root;
|
||||
}
|
||||
new_ids.push(id);
|
||||
}
|
||||
for face in &source.appearances.faces {
|
||||
if let Some(new) = bodies.get(&face.body) {
|
||||
let mut face = face.clone();
|
||||
if frozen.contains(&face.body) {
|
||||
if face.strict_name {
|
||||
return Err(bad(CMD, "cannot copy strict face paint through uncertified foreign re-export"));
|
||||
}
|
||||
if let Some(name) = &face.name {
|
||||
face.point = solvecraft_doc::naming::point_on_face(&state, name, face.point)
|
||||
.map(|(point, _)| point)
|
||||
.ok_or_else(|| bad(CMD, "source frozen face appearance lost its identity"))?;
|
||||
}
|
||||
let source_body = state.body(&face.body).ok_or_else(|| bad(CMD, "source frozen face appearance lost its body"))?;
|
||||
face.point = solvecraft_doc::apply_point(source.model_body_offset(source_body).unwrap_or(&solvecraft_doc::IDENTITY), face.point);
|
||||
face.name = None;
|
||||
} else if let Some(name) = &mut face.name {
|
||||
*name = solvecraft_doc::remap_topology(name, &features, &bodies);
|
||||
}
|
||||
face.body = new.clone();
|
||||
if let Some(certificate) = &mut face.certificate {
|
||||
certificate.remap_features(&features);
|
||||
}
|
||||
scratch.doc_mut().appearances.faces.push(face);
|
||||
}
|
||||
}
|
||||
// Typed configuration and assembly helpers preserve original targets.
|
||||
scratch.doc_mut().configs.clone_targets(&solvecraft_doc::config::ConfigCloneMaps {
|
||||
features: features.clone(),
|
||||
components: components.clone(),
|
||||
bodies: bodies.clone(),
|
||||
parameters: parameters.clone(),
|
||||
})?;
|
||||
let mut notices = super::component_clone_assembly::clone_assembly(scratch.doc_mut(), &source, &occurrences, &features, &bodies)?;
|
||||
let mut reference_work = 0usize;
|
||||
for feature in &feats {
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &feature.kind
|
||||
&& !face.path.is_empty()
|
||||
&& !bodies.contains_key(&face.body)
|
||||
{
|
||||
notices.push(format!("{} retains external selected occurrence path {:?}", feature.name, face.path));
|
||||
}
|
||||
if let FeatureKind::Sketch { sketch, .. } = &feature.kind {
|
||||
use solvecraft_sketch::LinkSource as L;
|
||||
let mut pending: Vec<_> = sketch.links.iter().map(|l| &l.source).collect();
|
||||
let mut work = 0;
|
||||
while let Some(link) = pending.pop() {
|
||||
work += 1;
|
||||
reference_work += 1;
|
||||
if work > MAX_WORK || reference_work > 100_000 {
|
||||
return Err(bad(CMD, "linked references exceed copy work limit"));
|
||||
}
|
||||
match link {
|
||||
L::Edge { body, .. }
|
||||
| L::Face { body, .. }
|
||||
| L::Body { body }
|
||||
| L::Vertex { body, .. }
|
||||
| L::Iso { body, .. }
|
||||
| L::Spun { body, .. } => {
|
||||
if !bodies.contains_key(body) {
|
||||
notices.push(format!("{} retains external linked body {body}", feature.name));
|
||||
}
|
||||
}
|
||||
L::SketchCurve { sketch, .. } | L::SketchPoint { sketch, .. } => {
|
||||
if !features.contains_key(sketch) {
|
||||
notices.push(format!("{} retains external linked sketch {sketch}", feature.name));
|
||||
}
|
||||
}
|
||||
L::OnSurface { sketch, body, .. } => {
|
||||
if !features.contains_key(sketch) || !bodies.contains_key(body) {
|
||||
notices.push(format!("{} retains external surface link", feature.name));
|
||||
}
|
||||
}
|
||||
L::Intersection { a, b } => {
|
||||
pending.push(a);
|
||||
pending.push(b);
|
||||
}
|
||||
L::Plane { name } if source.find_feature(name).is_some() && !names.contains_key(name) => {
|
||||
notices.push(format!("{} retains external linked plane {name}", feature.name));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Sketch { plane, .. } = &feature.kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor(plane)
|
||||
&& !bodies.contains_key(&anchor.target.body)
|
||||
{
|
||||
notices.push(format!(
|
||||
"{} retains external exact face attachment to {} (producer {})",
|
||||
feature.name, anchor.target.body, anchor.target.origin_feature
|
||||
));
|
||||
}
|
||||
for id in feature.kind.input_sketches() {
|
||||
if !features.contains_key(&id) {
|
||||
notices.push(format!("{} retains an external sketch reference to {id}", feature.name));
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Pattern { features: references, .. } | FeatureKind::Mirror { features: references, .. } = &feature.kind {
|
||||
for name in references {
|
||||
if !names.contains_key(name) {
|
||||
notices.push(format!("{} retains external feature reference {name}", feature.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
for expression in feature.kind.expressions() {
|
||||
for name in solvecraft_doc::expr::references(expression) {
|
||||
reference_work += 1;
|
||||
if reference_work > 100_000 {
|
||||
return Err(bad(CMD, "expression references exceed copy work limit"));
|
||||
}
|
||||
if !parameters.contains_key(&name) && generated_parameters.contains(name.as_str()) {
|
||||
notices.push(format!("{} retains external model parameter {name}", feature.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
notices.sort();
|
||||
notices.dedup();
|
||||
scratch.refresh();
|
||||
let original_errors: BTreeMap<_, _> = s.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect();
|
||||
let refreshed_errors: BTreeMap<_, _> = scratch.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect();
|
||||
if source.features.iter().any(|feature| original_errors.get(&feature.id) != refreshed_errors.get(&feature.id)) {
|
||||
return Err(bad(CMD, "copy would change an original feature's error state"));
|
||||
}
|
||||
// Base imports acquire new face identities. Resolve copied face overrides against the
|
||||
// verified new geometry once, rather than weakening strict persistent-name semantics.
|
||||
let resolved = scratch.model.state();
|
||||
let mut face_updates = Vec::new();
|
||||
for (index, face) in scratch.doc.appearances.faces.iter().enumerate() {
|
||||
if bodies.iter().any(|(old, new)| frozen.contains(old) && *new == face.body) {
|
||||
let body = resolved.body(&face.body).ok_or_else(|| bad(CMD, "missing frozen face body"))?;
|
||||
let face_index =
|
||||
solvecraft_doc::appearance::face_index_at(body, face.point).ok_or_else(|| bad(CMD, "cannot reproduce frozen face appearance"))?;
|
||||
let name = solvecraft_doc::naming::face_names(body).get(face_index).cloned().ok_or_else(|| bad(CMD, "missing frozen face identity"))?;
|
||||
face_updates.push((index, name));
|
||||
}
|
||||
}
|
||||
for (index, name) in face_updates {
|
||||
if let Some(face) = scratch.doc_mut().appearances.faces.get_mut(index) {
|
||||
face.name = Some(name);
|
||||
}
|
||||
}
|
||||
for id in &new_ids {
|
||||
if let Some(error) = scratch.model.result(*id).and_then(|r| r.error.as_ref()) {
|
||||
return Err(bad(CMD, format!("copied feature failed: {error}")));
|
||||
}
|
||||
}
|
||||
let copied = scratch.model.state();
|
||||
let copied_bodies: BTreeMap<_, _> = copied.bodies.iter().map(|body| (body.name.as_str(), body)).collect();
|
||||
// Adding placements can invalidate cached source evaluation. Compare clone effects
|
||||
// against that same evaluation's source prefix, not older shared B-rep pointers.
|
||||
if scratch.doc.features.get(..source.features.len()) != Some(source.features.as_slice())
|
||||
|| scratch.doc.params.get(..source.params.len()) != Some(source.params.as_slice())
|
||||
{
|
||||
return Err(bad(CMD, "copy would change original feature history or parameters"));
|
||||
}
|
||||
let original_state = new_ids.first().map(|id| scratch.model.state_before(*id)).unwrap_or_else(|| copied.clone());
|
||||
let all_columns = std::mem::take(&mut scratch.doc_mut().configs.columns);
|
||||
scratch.doc_mut().configs.columns = all_columns.iter().skip(source.configs.columns.len()).cloned().collect();
|
||||
let style_validation = scratch.doc.configuration_style_status(&copied);
|
||||
scratch.doc_mut().configs.columns = all_columns;
|
||||
style_validation?;
|
||||
let mut proof_work = 0usize;
|
||||
let expected_count = state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b))).count();
|
||||
let copied_components: BTreeSet<_> = components.values().copied().collect();
|
||||
let actual_count = copied.bodies.iter().filter(|b| copied_components.contains(&scratch.doc.model_body_component(b))).count();
|
||||
if actual_count != expected_count {
|
||||
return Err(bad(CMD, "copy produced an incomplete or unexpected set of bodies"));
|
||||
}
|
||||
for original in &original_state.bodies {
|
||||
let Some(after) = copied_bodies.get(original.name.as_str()) else {
|
||||
return Err(bad(CMD, "copy would remove a source body outside its independent history"));
|
||||
};
|
||||
if !original.body.same(&after.body) {
|
||||
return Err(bad(CMD, "copy would modify a source body; external destructive dependencies are not supported"));
|
||||
}
|
||||
}
|
||||
for body in state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b))) {
|
||||
let new = bodies.get(&body.name).ok_or_else(|| bad(CMD, format!("body {} has no clone identity", body.name)))?;
|
||||
let cloned = copied_bodies.get(new.as_str()).ok_or_else(|| bad(CMD, format!("copy did not reproduce body {}", body.name)))?;
|
||||
let a = solvecraft_kernel::transform_matrix(&body.body, *source.model_body_offset(body).unwrap_or(&solvecraft_doc::IDENTITY))?;
|
||||
let b = solvecraft_kernel::transform_matrix(&cloned.body, *scratch.doc.model_body_offset(cloned).unwrap_or(&solvecraft_doc::IDENTITY))?;
|
||||
let ma = solvecraft_kernel::measure(&a)?;
|
||||
let mb = solvecraft_kernel::measure(&b)?;
|
||||
let tolerance = 1e-5 * (1.0 + (ma.bbox.max - ma.bbox.min).len()) + 64.0 * f64::EPSILON * (ma.bbox.min.len() + ma.bbox.max.len());
|
||||
let mut material_matches = true;
|
||||
let tol_a = (a.size() * 1e-3).max(1e-3).max(a.size() * 1e-6);
|
||||
let tol_b = (b.size() * 1e-3).max(1e-3).max(b.size() * 1e-6);
|
||||
let mesh_a = a.display_mesh(tol_a)?;
|
||||
let mesh_b = b.display_mesh(tol_b)?;
|
||||
// Precharge all 27 candidates, six possible replacements, their triangle
|
||||
// distances and the three containment rays before any proof traversal.
|
||||
proof_work = proof_work.saturating_add(mesh_a.triangles.len().saturating_add(mesh_b.triangles.len()).saturating_mul(27 * 7 * 4));
|
||||
if proof_work > 50_000_000 {
|
||||
return Err(bad(CMD, "geometry verification exceeds copy work limit"));
|
||||
}
|
||||
for x in [0.2, 0.5, 0.8] {
|
||||
for y in [0.2, 0.5, 0.8] {
|
||||
for z in [0.2, 0.5, 0.8] {
|
||||
let d = ma.bbox.max - ma.bbox.min;
|
||||
let p = ma.bbox.min + solvecraft_geom::Vec3::new(d.x * x, d.y * y, d.z * z);
|
||||
let roundoff = 64. * f64::EPSILON * (1. + p.len());
|
||||
material_matches &= material_probe_matches(&mesh_a, &mesh_b, p, tol_a + roundoff, tol_b + roundoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Meshes of the same current circular section can have different
|
||||
// chord-volume biases after STEP. Only independently certified current
|
||||
// sections may replace that volume comparison; shape mismatches still
|
||||
// refuse even when their volumes happen to agree.
|
||||
let volume_matches = match solvecraft_kernel::certified_prism_equivalent(&a, &b)? {
|
||||
Some(same) => same,
|
||||
None => (ma.volume - mb.volume).abs() <= 1e-5 * (1.0 + ma.volume.abs()),
|
||||
};
|
||||
if !volume_matches
|
||||
|| (ma.area - mb.area).abs() > 1e-5 * (1.0 + ma.area.abs())
|
||||
|| ma.centroid.dist(mb.centroid) > tolerance
|
||||
|| ma.bbox.min.dist(mb.bbox.min) > tolerance
|
||||
|| ma.bbox.max.dist(mb.bbox.max) > tolerance
|
||||
|| !material_matches
|
||||
{
|
||||
return Err(bad(CMD, format!("copy geometry differs for body {}", body.name)));
|
||||
}
|
||||
}
|
||||
scratch.selection = vec![Sel::Occurrence { path: clone_path }];
|
||||
s.accept_computed(scratch);
|
||||
Ok(
|
||||
json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_clone_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,360 @@
|
||||
//! Typed remapping of assembly relationships for an independent component-tree copy.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use solvecraft_doc::Document;
|
||||
use solvecraft_doc::joints::{Assembly, JointOrigin, Snap};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
const MAX_RECORDS: usize = 10_000;
|
||||
const MAX_REFERENCES: usize = 100_000;
|
||||
const MAX_TEXT: usize = 1_048_576;
|
||||
const MAX_NAME: usize = 8192;
|
||||
|
||||
fn invalid(message: &str) -> EngineError {
|
||||
EngineError::Other(format!("Paste New assembly: {message}"))
|
||||
}
|
||||
|
||||
fn add_references(count: &mut usize, more: usize) -> Result<()> {
|
||||
*count = count.saturating_add(more);
|
||||
if *count > MAX_REFERENCES {
|
||||
return Err(invalid("assembly references exceed the processing budget"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finite_origin(origin: &JointOrigin) -> Result<()> {
|
||||
let finite = match &origin.snap {
|
||||
Snap::Frame { origin, z, x } => origin.is_finite() && z.is_finite() && x.is_none_or(|x| x.is_finite()),
|
||||
Snap::FaceCenter { pick, .. } | Snap::CircleCenter { pick, .. } => pick.is_finite(),
|
||||
Snap::Point { point, z } => point.is_finite() && z.is_none_or(|z| z.is_finite()),
|
||||
};
|
||||
if !finite {
|
||||
return Err(invalid("joint origin contains non-finite geometry"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn budget(assembly: &Assembly) -> Result<()> {
|
||||
let counts = [
|
||||
assembly.origins.len(),
|
||||
assembly.joints.len(),
|
||||
assembly.links.len(),
|
||||
assembly.contact_sets.len(),
|
||||
assembly.studies.len(),
|
||||
assembly.exploded.len(),
|
||||
];
|
||||
if counts.iter().any(|n| *n > MAX_RECORDS) {
|
||||
return Err(invalid("too many assembly records"));
|
||||
}
|
||||
let mut references = counts.iter().fold(0usize, |n, c| n.saturating_add(*c));
|
||||
let mut bytes = 0usize;
|
||||
let mut text = |s: &str| -> Result<()> {
|
||||
bytes = bytes.saturating_add(s.len());
|
||||
if s.len() > MAX_NAME || bytes > MAX_TEXT {
|
||||
return Err(invalid("assembly names exceed the processing budget"));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for origin in &assembly.origins {
|
||||
finite_origin(&origin.origin)?;
|
||||
text(&origin.name)?;
|
||||
if let Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } = &origin.origin.snap {
|
||||
text(face)?;
|
||||
}
|
||||
}
|
||||
for joint in &assembly.joints {
|
||||
text(&joint.name)?;
|
||||
add_references(&mut references, joint.values.len().saturating_add(joint.limits.len()))?;
|
||||
if !joint.values.iter().all(|v| v.is_finite())
|
||||
|| !joint.limits.iter().flatten().all(|(lo, hi)| lo.is_finite() && hi.is_finite() && lo <= hi)
|
||||
|| !joint.offset.is_finite()
|
||||
|| !joint.angle.is_finite()
|
||||
{
|
||||
return Err(invalid("joint contains invalid motion values"));
|
||||
}
|
||||
for origin in [&joint.a, &joint.b] {
|
||||
finite_origin(origin)?;
|
||||
if let Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } = &origin.snap {
|
||||
text(face)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for contact in &assembly.contact_sets {
|
||||
text(&contact.name)?;
|
||||
add_references(&mut references, contact.occurrences.len())?;
|
||||
}
|
||||
for study in &assembly.studies {
|
||||
text(&study.name)?;
|
||||
add_references(&mut references, study.keys.len())?;
|
||||
for key in &study.keys {
|
||||
add_references(&mut references, key.points.len())?;
|
||||
if !key.points.iter().all(|(step, value)| step.is_finite() && value.is_finite()) {
|
||||
return Err(invalid("motion study contains non-finite keys"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for view in &assembly.exploded {
|
||||
text(&view.name)?;
|
||||
add_references(&mut references, view.moves.len())?;
|
||||
if !view.moves.iter().all(|(_, p)| p.is_finite()) {
|
||||
return Err(invalid("exploded view contains non-finite moves"));
|
||||
}
|
||||
}
|
||||
if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite()) {
|
||||
return Err(invalid("motion link contains non-finite values"));
|
||||
}
|
||||
if references > MAX_REFERENCES {
|
||||
return Err(invalid("assembly references exceed the processing budget"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unique_name(base: &str, used: &mut BTreeSet<String>) -> Result<String> {
|
||||
if !used.contains(base) {
|
||||
used.insert(base.to_string());
|
||||
return Ok(base.to_string());
|
||||
}
|
||||
for index in 1..=MAX_RECORDS {
|
||||
let name = if index == 1 { format!("{base} (copy)") } else { format!("{base} (copy {index})") };
|
||||
if name.len() > MAX_NAME {
|
||||
return Err(invalid("copied assembly name is too long"));
|
||||
}
|
||||
if used.insert(name.clone()) {
|
||||
return Ok(name);
|
||||
}
|
||||
}
|
||||
Err(invalid("could not allocate a unique assembly name"))
|
||||
}
|
||||
|
||||
fn next_id(id: &mut u64) -> Result<u64> {
|
||||
*id = id.checked_add(1).ok_or_else(|| invalid("assembly identifier space is exhausted"))?;
|
||||
Ok(*id)
|
||||
}
|
||||
|
||||
fn unique_ids(ids: impl Iterator<Item = u64>) -> Result<u64> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut largest = 0;
|
||||
for id in ids {
|
||||
if id == 0 || !seen.insert(id) {
|
||||
return Err(invalid("assembly identifiers must be nonzero and unique"));
|
||||
}
|
||||
largest = largest.max(id);
|
||||
}
|
||||
Ok(largest)
|
||||
}
|
||||
|
||||
fn validate_map<K: Ord, V: Ord>(map: &BTreeMap<K, V>) -> Result<()> {
|
||||
if map.len() > MAX_RECORDS || map.values().collect::<BTreeSet<_>>().len() != map.len() {
|
||||
return Err(invalid("copy maps exceed their budget or contain duplicate destinations"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remap certified naming tokens, rather than replacing digit substrings (F1 must not
|
||||
/// alter F10). Body-based legacy names are remapped only through an explicit identity map.
|
||||
fn face_name(name: &str, features: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>) -> Option<String> {
|
||||
let body = name.match_indices(':').filter_map(|(end, _)| bodies.get_key_value(name.get(..end)?)).next_back();
|
||||
let mut out = String::new();
|
||||
let mut last = 0usize;
|
||||
let mut mapped = false;
|
||||
if let Some((old, new)) = body {
|
||||
out.push_str(new);
|
||||
last = old.len();
|
||||
mapped = true;
|
||||
}
|
||||
let bytes = name.as_bytes();
|
||||
let mut i = last;
|
||||
while i < bytes.len() {
|
||||
let boundary = i == 0
|
||||
|| bytes.get(i.wrapping_sub(1)).is_some_and(|c| matches!(*c, b'@' | b'|'))
|
||||
|| name.get(..i).is_some_and(|prefix| prefix.ends_with(":blend:") || prefix.ends_with(":inner:"));
|
||||
if bytes.get(i) == Some(&b'F') && boundary {
|
||||
let mut end = i + 1;
|
||||
while bytes.get(end).is_some_and(u8::is_ascii_digit) {
|
||||
end += 1;
|
||||
}
|
||||
if end > i + 1 && bytes.get(end).is_some_and(|c| matches!(*c, b':' | b'.')) {
|
||||
let id: u64 = name.get(i + 1..end)?.parse().ok()?;
|
||||
let replacement = features.get(&id)?;
|
||||
out.push_str(name.get(last..i)?);
|
||||
out.push_str(&format!("F{replacement}"));
|
||||
last = end;
|
||||
mapped |= i == 0;
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if !mapped {
|
||||
return None;
|
||||
}
|
||||
out.push_str(name.get(last..)?);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn origin(
|
||||
origin: &JointOrigin,
|
||||
occurrences: &BTreeMap<u64, u64>,
|
||||
features: &BTreeMap<u64, u64>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
) -> Option<JointOrigin> {
|
||||
let mut out = origin.clone();
|
||||
out.occurrence = *occurrences.get(&origin.occurrence)?;
|
||||
match &mut out.snap {
|
||||
Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } => *face = face_name(face, features, bodies)?,
|
||||
_ => {}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn report(messages: &mut Vec<String>, kind: &str, name: &str) {
|
||||
messages.push(format!("Skipped {kind} `{name}`: references outside the copied component tree or geometry without a copied identity."));
|
||||
}
|
||||
|
||||
/// Called with a scratch document after geometry cloning. Only wholly internal records
|
||||
/// are copied; cross-boundary relationships are reported and omitted without truncation.
|
||||
/// The assembly is swapped in only after every map, allocation and aggregate check passes.
|
||||
pub(super) fn clone_assembly(
|
||||
doc: &mut Document,
|
||||
source: &Document,
|
||||
occurrences: &BTreeMap<u64, u64>,
|
||||
features: &BTreeMap<u64, u64>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
) -> Result<Vec<String>> {
|
||||
budget(&source.assembly)?;
|
||||
budget(&doc.assembly)?;
|
||||
validate_map(occurrences)?;
|
||||
validate_map(features)?;
|
||||
validate_map(bodies)?;
|
||||
if doc.occurrences.len() > MAX_REFERENCES {
|
||||
return Err(invalid("too many destination occurrences"));
|
||||
}
|
||||
let mut map_bytes = 0usize;
|
||||
for (old, new) in bodies {
|
||||
map_bytes = map_bytes.saturating_add(old.len()).saturating_add(new.len());
|
||||
if old.is_empty() || new.is_empty() || old.len() > MAX_NAME || new.len() > MAX_NAME || map_bytes > MAX_TEXT {
|
||||
return Err(invalid("invalid or excessive body identity map"));
|
||||
}
|
||||
}
|
||||
let available: BTreeSet<u64> = doc.occurrences.iter().map(|o| o.id).chain(std::iter::once(0)).collect();
|
||||
if occurrences.values().any(|id| !available.contains(id)) {
|
||||
return Err(invalid("copy occurrence map points to a missing occurrence"));
|
||||
}
|
||||
unique_ids(source.assembly.joints.iter().map(|j| j.id))?;
|
||||
let mut joint_id = unique_ids(doc.assembly.joints.iter().map(|j| j.id))?;
|
||||
let aux = |a: &Assembly| {
|
||||
a.contact_sets.iter().map(|x| x.id).chain(a.studies.iter().map(|x| x.id)).chain(a.exploded.iter().map(|x| x.id)).collect::<Vec<_>>()
|
||||
};
|
||||
unique_ids(aux(&source.assembly).into_iter())?;
|
||||
let mut auxiliary_id = unique_ids(aux(&doc.assembly).into_iter())?;
|
||||
let mut a = doc.assembly.clone();
|
||||
let mut messages = Vec::new();
|
||||
let mut origin_names = a.origins.iter().map(|o| o.name.clone()).collect();
|
||||
for old in &source.assembly.origins {
|
||||
if !occurrences.contains_key(&old.origin.occurrence) {
|
||||
continue;
|
||||
}
|
||||
if let Some(new) = origin(&old.origin, occurrences, features, bodies) {
|
||||
let mut item = old.clone();
|
||||
item.name = unique_name(&old.name, &mut origin_names)?;
|
||||
item.origin = new;
|
||||
a.origins.push(item);
|
||||
} else {
|
||||
report(&mut messages, "joint origin", &old.name);
|
||||
}
|
||||
}
|
||||
let mut joint_names = a.joints.iter().map(|j| j.name.clone()).collect();
|
||||
let mut joints = BTreeMap::new();
|
||||
let mut touched_joints = BTreeSet::new();
|
||||
for old in &source.assembly.joints {
|
||||
if !occurrences.contains_key(&old.a.occurrence) && !occurrences.contains_key(&old.b.occurrence) {
|
||||
continue;
|
||||
}
|
||||
touched_joints.insert(old.id);
|
||||
match (origin(&old.a, occurrences, features, bodies), origin(&old.b, occurrences, features, bodies)) {
|
||||
(Some(aa), Some(bb)) => {
|
||||
let mut item = old.clone();
|
||||
item.id = next_id(&mut joint_id)?;
|
||||
item.name = unique_name(&old.name, &mut joint_names)?;
|
||||
item.a = aa;
|
||||
item.b = bb;
|
||||
joints.insert(old.id, item.id);
|
||||
a.joints.push(item);
|
||||
}
|
||||
_ => report(&mut messages, "joint", &old.name),
|
||||
}
|
||||
}
|
||||
for old in &source.assembly.links {
|
||||
if !touched_joints.contains(&old.a) && !touched_joints.contains(&old.b) {
|
||||
continue;
|
||||
}
|
||||
match (joints.get(&old.a), joints.get(&old.b)) {
|
||||
(Some(aa), Some(bb)) => {
|
||||
let mut item = old.clone();
|
||||
item.a = *aa;
|
||||
item.b = *bb;
|
||||
a.links.push(item);
|
||||
}
|
||||
_ => report(&mut messages, "motion link", &format!("{} → {}", old.a, old.b)),
|
||||
}
|
||||
}
|
||||
let mut contact_names = a.contact_sets.iter().map(|x| x.name.clone()).collect();
|
||||
for old in &source.assembly.contact_sets {
|
||||
if !old.occurrences.iter().any(|o| occurrences.contains_key(o)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(ids) = old.occurrences.iter().map(|o| occurrences.get(o).copied()).collect::<Option<Vec<_>>>() {
|
||||
let mut item = old.clone();
|
||||
item.id = next_id(&mut auxiliary_id)?;
|
||||
item.name = unique_name(&old.name, &mut contact_names)?;
|
||||
item.occurrences = ids;
|
||||
a.contact_sets.push(item);
|
||||
} else {
|
||||
report(&mut messages, "contact set", &old.name);
|
||||
}
|
||||
}
|
||||
let mut study_names = a.studies.iter().map(|x| x.name.clone()).collect();
|
||||
for old in &source.assembly.studies {
|
||||
if !old.keys.iter().any(|k| touched_joints.contains(&k.joint)) {
|
||||
continue;
|
||||
}
|
||||
if old.keys.iter().all(|k| joints.contains_key(&k.joint)) {
|
||||
let mut item = old.clone();
|
||||
item.id = next_id(&mut auxiliary_id)?;
|
||||
item.name = unique_name(&old.name, &mut study_names)?;
|
||||
for key in &mut item.keys {
|
||||
key.joint = *joints.get(&key.joint).ok_or_else(|| invalid("missing copied study joint"))?;
|
||||
}
|
||||
a.studies.push(item);
|
||||
} else {
|
||||
report(&mut messages, "motion study", &old.name);
|
||||
}
|
||||
}
|
||||
let mut view_names = a.exploded.iter().map(|x| x.name.clone()).collect();
|
||||
for old in &source.assembly.exploded {
|
||||
if !old.moves.iter().any(|(o, _)| occurrences.contains_key(o)) {
|
||||
continue;
|
||||
}
|
||||
if old.moves.iter().all(|(o, _)| occurrences.contains_key(o)) {
|
||||
let mut item = old.clone();
|
||||
item.id = next_id(&mut auxiliary_id)?;
|
||||
item.name = unique_name(&old.name, &mut view_names)?;
|
||||
for (o, _) in &mut item.moves {
|
||||
*o = *occurrences.get(o).ok_or_else(|| invalid("missing copied exploded occurrence"))?;
|
||||
}
|
||||
a.exploded.push(item);
|
||||
} else {
|
||||
report(&mut messages, "exploded view", &old.name);
|
||||
}
|
||||
}
|
||||
budget(&a)?;
|
||||
doc.assembly = a;
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_clone_assembly_tests.rs"]
|
||||
mod tests;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user