mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 19:15:57 +00:00
Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5ae72057d | |||
| 29258767ce | |||
| 392fae1af4 | |||
| 8be0f64f5a | |||
| 9f6fb735dd | |||
| 46e21127dd | |||
| 90869732a3 | |||
| 24cff445ad | |||
| bb60a95289 | |||
| 8d58e3b9f9 | |||
| f0d80a1d7e | |||
| 0f0bb09858 | |||
| 6d5df4b9b3 | |||
| 27f20f97cb | |||
| e67cc0c747 | |||
| bcbfa50eac | |||
| 24974e6f37 | |||
| bf962a9989 | |||
| 04cfb74c5e | |||
| 14635e5d3a | |||
| a2f31f683d | |||
| 9089edfb71 | |||
| a4ca1b68db | |||
| 94ab54d1f5 | |||
| 9e2a1eb993 | |||
| abff7a49bb | |||
| 651e8e0853 | |||
| 4a86ad5a7c | |||
| 5ad8b3966f | |||
| dc060f8b86 | |||
| 9c94e1b96d | |||
| 2aff1c5303 | |||
| 135a63f549 | |||
| 2a434d59c3 | |||
| 083ddf7722 | |||
| db02a18627 | |||
| c727e2d0c2 | |||
| f9c19ade16 | |||
| 711e5508b5 | |||
| 913583bfee | |||
| 1abd989a67 | |||
| b1740aeee5 | |||
| 736d9277b8 | |||
| 9283d2b5bb | |||
| c1cd4cf194 | |||
| 1a6ff0954e | |||
| 88e9661457 | |||
| 7a8754bef7 | |||
| 6441621b8b | |||
| d7797c3d38 | |||
| b11841b0bc | |||
| a8121215bc | |||
| a69c2928f0 | |||
| 7d00df0a3f | |||
| 69e54f85d2 | |||
| bf2a9f5541 | |||
| d6af554c6d | |||
| c2a9c71436 | |||
| f93577ad70 | |||
| 9f88537927 | |||
| 3159fc0dd0 | |||
| 340ac0313a | |||
| bb431f7ac2 | |||
| 2f7046efac | |||
| aaeb7ceea7 | |||
| 10c6b865c7 | |||
| a9879fa668 | |||
| 45bc22fc30 | |||
| 4aaa5457b3 | |||
| 1fd4a2f782 | |||
| 00baf1d883 | |||
| 91de39d3fe | |||
| 219816d282 | |||
| 2880525e6b | |||
| aa156f4a9f | |||
| ccc36dc1f9 | |||
| 0fe032438f | |||
| 72c5d974b8 | |||
| 201e9d5f9f | |||
| af0440aaab | |||
| 49956c2797 | |||
| b24e478bc1 | |||
| 9ab25cfd4a | |||
| 1ae6e9beb4 | |||
| fc1d70cc3b | |||
| dd67472252 | |||
| 9062c739d8 | |||
| fb4c1d74b9 | |||
| 0dee9deec1 | |||
| 722bd007c8 | |||
| 9e60080b10 | |||
| 455ca86a6c | |||
| 6fde5ff55b | |||
| 7f92093cc8 | |||
| ddd528abaf | |||
| 5c7149e57d |
@@ -0,0 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [bherbruck]
|
||||
ko_fi: bherbruck
|
||||
+71
-51
@@ -5,58 +5,76 @@ parameter to watch the timeline rebuild. [Documentation](https://bherbruck.githu
|
||||
[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)).
|
||||
|
||||
**Why 0.6:** this is a minor bump because two things change the files you work with: the
|
||||
rename to SolidCraft and the new design file format. Files from earlier builds open fine, but
|
||||
builds 0.5 and older can't open designs saved by 0.6.
|
||||
**Why 0.8:** this is a minor bump because the design file format moved to version 16, and
|
||||
because it adds many new features.
|
||||
|
||||
**What's new**
|
||||
- **New design file format.** Saves are a zip container by default; plain JSON is still one
|
||||
click away (details below).
|
||||
- **Sheet metal:** flange corners close at any angle, and cut-outs fold across bends at any
|
||||
angle, including curved bends.
|
||||
- **Crash reports:** after a crash, Help ▸ Crash Reports… shows what happened with a ready-to-file
|
||||
issue draft. Reports stay on your computer; nothing is sent. `solidcraft-cli crashes` lists and
|
||||
replays them.
|
||||
- **Snap package** for x86_64 and aarch64 on every release, alongside the AppImage, `.deb`, `.rpm`
|
||||
and Flatpak.
|
||||
- **Trackpad navigation** (two-finger pan, pinch zoom), a fillet option that shrinks a radius too
|
||||
big for its edges, cleaner dialogs and toolbar panels, and the unfinished Generative Design
|
||||
workspace is gone.
|
||||
- Many modelling fixes: extrude/revolve in moved components, To Object picks, very flat arcs,
|
||||
touching lines and arcs in booleans, and `solidcraft-cli run` now fails when a feature fails.
|
||||
- **Face actions work on far more faces.** Sketches, offset planes and press-pull now attach to
|
||||
Cut, Hole and chamfer faces, each pattern or mirror copy, side walls a Join merged and the
|
||||
pieces a later feature split a face into
|
||||
([#625](https://github.com/bherbruck/solidcraft/pull/625)), to moved or pasted components
|
||||
([#610](https://github.com/bherbruck/solidcraft/pull/610)) and beside long thin holes
|
||||
([#629](https://github.com/bherbruck/solidcraft/pull/629)). Across the test models, flat-face
|
||||
actions pass 70% of the time, up from 61% (sketch 52% → 71%, offset plane 52% → 71%,
|
||||
press-pull 41% → 60%).
|
||||
- **Fillet and chamfer** handle a single edge whose ends meet curved or slanted faces, and blend
|
||||
smooth chains of edges ([#646](https://github.com/bherbruck/solidcraft/pull/646)): 83% → 93%
|
||||
of edges.
|
||||
- **New:** user-defined physical materials stored in the design, linked components (insert
|
||||
another design), saved body visibility, named views through `view.apply`, sketch text fonts,
|
||||
corner reliefs, flanges on flanges, edge flanges that trim the base along their edge, grouped
|
||||
sketch tools, Home thumbnails, and an improved Select Other list.
|
||||
- **Fixes:** filleting beside a modeled thread no longer freezes the app, a loft standing on a
|
||||
face fuses with it, and many sheet metal, hole and sketch fixes.
|
||||
- **Booleans are checked.** A Boolean retry that would change the part away from the tool is now
|
||||
refused instead of returning wrong geometry, which fixes stray cuts
|
||||
([#616](https://github.com/bherbruck/solidcraft/issues/616)). MCP feature replies include
|
||||
warnings.
|
||||
- **Opening a design** keeps the window draggable, shows progress, and can be cancelled with
|
||||
Cancel or Esc.
|
||||
- **Under the hood:** compact MCP replies, a kernel-neutral native B-rep format, and an opt-in
|
||||
preview from the native cache while a design rebuilds.
|
||||
- **Quality:** every ordinary action is run on every face, edge, vertex and body of the test
|
||||
models; CI blocks regressions and the full matrix runs nightly.
|
||||
- **Security:** private vulnerability reporting is on; see
|
||||
[SECURITY.md](https://github.com/bherbruck/solidcraft/blob/main/SECURITY.md).
|
||||
|
||||
**New file format.** Save and Save As now write `.solidcraft` designs as a zip container
|
||||
that stores imported STEP, mesh and image data as binary, once each, at full precision, so
|
||||
designs with imports save and open faster. What that means for you:
|
||||
- Your existing `.solidcraft` and `.solvecraft` files (plain JSON) still open as before; the
|
||||
format is detected from the contents.
|
||||
- Want plain JSON (for diffs or scripts)? Use **File ▸ Save As JSON…**, or pick a
|
||||
`.json` name in Save As; later saves keep JSON. **File ▸ Export Design as JSON…** writes a
|
||||
separate JSON copy. From the CLI, `solidcraft-cli run … --save design.json`; from MCP or
|
||||
scripts, pass `"format": "json"` to `save` / `file.save`.
|
||||
- SolidCraft 0.5 and older can't open designs saved by 0.6, as a container or as JSON (the
|
||||
JSON format tag is now `solidcraft/14`). Keep a copy of a file you may still open in 0.5.
|
||||
**File format 16.** Designs are now format 16 (`solidcraft/16`). SolidCraft 0.7 and older can't
|
||||
open designs saved by 0.8, as a container or as JSON; keep a copy of a file you may still open
|
||||
in 0.7. Files from every earlier version still open in 0.8.
|
||||
|
||||
**Renamed to SolidCraft.** Everything now uses the new name: the `solidcraft` and
|
||||
`solidcraft-cli` programs, `.solidcraft` designs, `SOLIDCRAFT_*` environment variables,
|
||||
settings folders, package names and the `solidcraft` MCP server. Upgrading from SolveCraft:
|
||||
- `.solvecraft` designs still open (double-click, Open, drag-drop, the CLI and MCP); new saves
|
||||
are `.solidcraft`.
|
||||
- Settings, preferences and recovery files are copied to the new folders on first launch; the
|
||||
old folders are left in place. `SOLVECRAFT_*` environment variables are still read.
|
||||
- `solvecraft-cli` remains as a deprecated alias for one release; it forwards to
|
||||
`solidcraft-cli` and prints a note. Agents: `claude mcp add solidcraft -- solidcraft-cli mcp`;
|
||||
an existing `claude mcp add solvecraft -- solvecraft-cli mcp` setup keeps working.
|
||||
- Windows: the `.msi` upgrades the existing installation in place (now in
|
||||
`Program Files\SolidCraft`). Linux: the `solidcraft` `.deb`/`.rpm` replaces the `solvecraft`
|
||||
package. The Flatpak app ID stays `ai.storyteller.solvecraft`, so `flatpak update` just works,
|
||||
and existing AppImages find the update.
|
||||
- macOS: quit the app, move the old `SolveCraft.app` to the Trash (your designs and settings
|
||||
stay), then drag `SolidCraft.app` from the DMG onto Applications and pin it in the Dock.
|
||||
- Details: [compatibility notes](https://github.com/bherbruck/solidcraft/blob/main/docs/compatibility.md).
|
||||
**Linux app ID.** The Linux app ID is now `ai.storyteller.solidcraft` (it was
|
||||
`ai.storyteller.solvecraft`). Flatpak users from the SolidCraft feed: `flatpak update` reports
|
||||
the old app as end-of-life and asks to replace it; say yes, and your settings come along. The
|
||||
`.deb`, `.rpm` and AppImage remove the old menu entry on upgrade or first launch.
|
||||
|
||||
Documentation, the browser app and the Flatpak feed use the SolidCraft project Pages URLs;
|
||||
old-name Pages addresses are not permanent aliases.
|
||||
**Known limitations**
|
||||
- Some faces still can't take an attached sketch: after Revolve, Offset Face or Boss
|
||||
([#494](https://github.com/bherbruck/solidcraft/issues/494)), sheet metal thickness faces
|
||||
([#495](https://github.com/bherbruck/solidcraft/issues/495)), Loft, Sweep and Coil faces
|
||||
([#561](https://github.com/bherbruck/solidcraft/issues/561)), faces after a Draft or a tapered
|
||||
Extrude ([#563](https://github.com/bherbruck/solidcraft/issues/563)) and a Shell's rim
|
||||
([#564](https://github.com/bherbruck/solidcraft/issues/564)).
|
||||
- A Hole on a sheet metal face is refused
|
||||
([#560](https://github.com/bherbruck/solidcraft/issues/560)); some through-all holes along a
|
||||
wall or beside curved faces still fail
|
||||
([#559](https://github.com/bherbruck/solidcraft/issues/559)).
|
||||
- Some fillets are still refused, such as along an outline of lines and arcs
|
||||
([#431](https://github.com/bherbruck/solidcraft/issues/431)).
|
||||
- Cutting a coaxial cylinder from a thin disc can lose a little volume
|
||||
([#500](https://github.com/bherbruck/solidcraft/issues/500)); an edge flange with different
|
||||
per-edge angles leaves gaps at the corners
|
||||
([#501](https://github.com/bherbruck/solidcraft/issues/501)).
|
||||
- More in [ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
|
||||
|
||||
**Upgrading from 0.5 or earlier.** 0.6 renamed SolveCraft to SolidCraft and made the zip
|
||||
container the default save format. `.solvecraft` designs, old settings folders and
|
||||
`SOLVECRAFT_*` variables are still picked up, and `solvecraft-cli` still forwards to
|
||||
`solidcraft-cli`. Want plain JSON? Use **File ▸ Save As JSON…** or `--save design.json`. On
|
||||
macOS, replace `SolveCraft.app` with `SolidCraft.app`. Details:
|
||||
[compatibility notes](https://github.com/bherbruck/solidcraft/blob/main/docs/compatibility.md).
|
||||
|
||||
Documentation, the browser app and the Flatpak feed use the SolidCraft project Pages URLs.
|
||||
|
||||
**Which file?**
|
||||
- **Windows:** `SolidCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
|
||||
@@ -78,10 +96,13 @@ 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
|
||||
flatpak install --user solidcraft ai.storyteller.solidcraft
|
||||
flatpak run ai.storyteller.solidcraft
|
||||
```
|
||||
|
||||
Flatpak installs from 0.7 or earlier use the old ID `ai.storyteller.solvecraft`: `flatpak update --user`
|
||||
offers to replace it with `ai.storyteller.solidcraft`, and settings move along.
|
||||
|
||||
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).
|
||||
|
||||
@@ -89,6 +110,5 @@ The builds aren't signed yet. On macOS, right-click `SolidCraft.app` in Applicat
|
||||
**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).
|
||||
install `THIRD-PARTY-LICENSES.txt`.
|
||||
|
||||
|
||||
@@ -29,6 +29,19 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
dependency-advisories:
|
||||
name: Dependency advisories
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install advisory scanner
|
||||
run: cargo install cargo-deny --version 0.20.2 --locked
|
||||
- name: Check locked dependencies
|
||||
run: cargo deny --locked check --warn unmaintained advisories bans sources
|
||||
|
||||
changes:
|
||||
name: Select checks
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -172,6 +185,34 @@ jobs:
|
||||
path: target/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
action-matrix:
|
||||
# Every ordinary action on every face, edge, vertex and body of the design corpus, against
|
||||
# tests/action_matrix/baseline.json (docs/action-matrix.md). Every test run checks a sample;
|
||||
# the whole matrix runs nightly (or by hand), a quarter per job.
|
||||
name: Action matrix (${{ matrix.part }}/4)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
part: [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 a quarter of the matrix
|
||||
env:
|
||||
PART: ${{ matrix.part }}
|
||||
run: |
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --run-ignored only -E "binary(action_matrix) & test(=action_matrix_full_$PART)"
|
||||
|
||||
doc-tests:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
@@ -315,4 +356,4 @@ jobs:
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts"
|
||||
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
|
||||
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
|
||||
|
||||
@@ -53,9 +53,13 @@ jobs:
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
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'] == 'solidcraft'"
|
||||
desktop-file-validate packaging/linux/ai.storyteller.solidcraft.desktop
|
||||
# The window's X11 class is its app id; the old id stays only as the rename shims.
|
||||
grep -qx 'StartupWMClass=ai.storyteller.solidcraft' packaging/linux/ai.storyteller.solidcraft.desktop
|
||||
grep -q '<id>ai.storyteller.solvecraft</id>' packaging/linux/ai.storyteller.solidcraft.metainfo.xml.in
|
||||
grep -qF -e '--end-of-life-rebase=' packaging/flatpak-site.sh
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solidcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solidcraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solidcraft' and m['command'] == 'solidcraft'"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/snap/snapcraft.yaml')); assert m['name'] == 'solidcraft' and m['confinement'] == 'strict' and m['apps']['solidcraft']['command'] == 'usr/bin/solidcraft'"
|
||||
python3 -m py_compile packaging/linux/snap-smoke.py
|
||||
|
||||
+4
-3
@@ -37,8 +37,9 @@ Generated-in-code assets are original and have no file to list:
|
||||
| assets/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/enclosure-demo-0.2.1.gif | SolidCraft contributors | original electronics enclosure screen recording of the actual SolidCraft 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 | SolidCraft contributors | original electronics enclosure screen recording of the actual SolidCraft 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/demo-0.6.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.6.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/og.png | SolidCraft contributors | original 1280 × 640 social preview card (the SolidCraft mark, name and tagline), served by the docs site for link previews | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sample-plate.png | SolidCraft 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 | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/enclosure.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
@@ -93,7 +94,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
|
||||
| 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-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, subdivision and provisional fixed-grid previews (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; 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) |
|
||||
|
||||
@@ -100,6 +100,8 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
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).
|
||||
Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see
|
||||
[the local command and coverage limits](docs/book/src/dev/testing.md#dependency-advisories).
|
||||
3. **Every asset is original or openly licensed and has a row in
|
||||
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
|
||||
`cargo xtask assets` enforces this.
|
||||
@@ -166,6 +168,11 @@ Pull requests skip a few heavy tests (one to four minutes each), which run on ev
|
||||
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`
|
||||
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
|
||||
|
||||
**The action matrix** tries every ordinary action on every face, edge, vertex and body of a
|
||||
corpus of designs against a checked-in ratchet ([docs/action-matrix.md](docs/action-matrix.md)).
|
||||
A pull request that makes an action pass should record it with `cargo xtask action-matrix --bless`;
|
||||
one that makes a passing action fail fails the sample every CI run checks.
|
||||
|
||||
**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`,
|
||||
|
||||
Generated
+17
-25
@@ -2765,15 +2765,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
@@ -3290,14 +3281,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"embed-resource",
|
||||
"hidapi",
|
||||
"log",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -3307,7 +3298,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -3316,7 +3307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3331,7 +3322,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3347,7 +3338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3356,9 +3347,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -3371,10 +3362,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-geom",
|
||||
"thiserror 2.0.21",
|
||||
"truck-meshalgo",
|
||||
@@ -3386,7 +3378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -3394,7 +3386,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3404,7 +3396,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3415,7 +3407,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3430,7 +3422,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4181,7 +4173,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.41.0",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -4853,7 +4845,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.0"
|
||||
version = "0.8.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -40,7 +40,7 @@ truck-topology = "0.6"
|
||||
|
||||
# packages (3MF)
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.38"
|
||||
quick-xml = "0.41"
|
||||
|
||||
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
|
||||
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
|
||||
|
||||
@@ -6,9 +6,9 @@ Sketch, constrain, and build solids with a parametric feature timeline. Change a
|
||||
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
|
||||
model through the built-in MCP server.
|
||||
|
||||

|
||||

|
||||
|
||||
*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).*
|
||||
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots and a colour, then height 24 → 32 mm in Parameters and it all rebuilds. 6× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.6.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
|
||||
|
||||
## Download
|
||||
|
||||
@@ -81,11 +81,15 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
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.
|
||||
- **Constraints and dimensions** solved by our own solver. Points the constraints fix relative
|
||||
to known geometry are placed exactly by construction (loci intersections and rigid-cluster
|
||||
decomposition, after Owen 1991 and Fudos–Hoffmann 1997); Levenberg–Marquardt solves the rest,
|
||||
and the rank of its Jacobian gives the remaining degrees of freedom. Constraints: coincident,
|
||||
point on curve, horizontal/vertical, parallel, perpendicular, tangent, curvature (G2), equal,
|
||||
concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
|
||||
point-to-line, radius, diameter, angle, arc sweep, arc length and slot length dimensions driven
|
||||
by parameters. Over-constraining edits are rejected; fully constrained geometry turns black.
|
||||
Dragging solves the sketch every frame and rebuilds the model once, when the drag ends.
|
||||
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.
|
||||
@@ -108,7 +112,8 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
separately from through-hole outlines in the flat preview and DXF.
|
||||
- **Files**: design containers (`.solidcraft`) with lossless binary mesh payloads;
|
||||
plain JSON remains supported for opening, Save As and export. Desktop Open can show a saved
|
||||
experimental preview (explicit `preview: true` engine saves) while checking geometry before editing. Export to STEP (AP242 with body names, colours and
|
||||
provisional picture from warm geometry caches while rebuilding before editing; caches never
|
||||
replace the parametric design. 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
|
||||
|
||||
+47
-7
@@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. 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); flanges meeting at a corner closed at any angle with the rule's gap | in progress | 36 |
|
||||
| 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); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular) chosen by the rule | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
@@ -61,6 +61,22 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Dimensioning and error reporting
|
||||
|
||||
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
|
||||
`batch`: body changes and created sketch IDs stay, while repeated lists are omitted.
|
||||
Both tools retain full replies by default.
|
||||
|
||||
- Sketch Create groups drawing variants under Line, Rectangle, Circle, Arc, Slot,
|
||||
Polygon and Spline. Each open part remembers its last interactive choice per family
|
||||
across sketch sessions and document-tab switches; closing the part resets these UI choices.
|
||||
- Inscribed, circumscribed and edge polygon tools preview their actual outline before
|
||||
placement, including orientation and the selected side count.
|
||||
- Construction (X) preserves the active sketch drawing tool, picked anchors and inline
|
||||
dimension values, so line type can change while a shape is in progress.
|
||||
- Planar sketch construction previews and hover feedback keep the same dashed pattern as
|
||||
committed geometry; centerlines retain their dash-dot pattern.
|
||||
|
||||
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
|
||||
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
|
||||
- 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
|
||||
@@ -145,6 +161,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## View navigation
|
||||
|
||||
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
|
||||
its layout stable during a drag and applies the selected scale on release.
|
||||
|
||||
- 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
|
||||
@@ -154,6 +173,11 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Scheduled and manual CI scan locked dependency advisories, bans and sources with
|
||||
cargo-deny. Vulnerabilities fail the scan; unmaintained crates warn. Local vendored
|
||||
patches require separate upstream review because advisory databases cannot version-match
|
||||
their path dependencies. Pull requests retain the existing licence checks.
|
||||
|
||||
- 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,
|
||||
@@ -285,6 +309,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
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).
|
||||
- Toolbar drops on panel labels, gaps and margins append commands with insertion feedback;
|
||||
button drops keep insert-before ordering.
|
||||
- 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.
|
||||
@@ -317,6 +343,12 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
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.
|
||||
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
|
||||
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
|
||||
A flange on another flange's far edge (a return) follows that flange's closed end and keeps
|
||||
clear of the flange beside it, and the returns of one feature are mitred toward each other
|
||||
with the rule's gap where their flanges meet at a corner; returns of separate features, and
|
||||
flanges on returns, are not closed toward each other.
|
||||
- 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
|
||||
@@ -350,13 +382,21 @@ and reference-object placement remain separate work, as do loft rails and center
|
||||
- Design containers (#303, phase one) separate and deduplicate STEP, raw images and
|
||||
lossless binary mesh arrays while keeping plain JSON as a supported Save As/export
|
||||
format. Generated-mesh storage measurements reduce serialization and decoding work;
|
||||
evaluated geometry caching and instant cold display remain separate follow-up work.
|
||||
a seekable reader defers original blobs until after provisional display.
|
||||
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
|
||||
- Desktop Open can show an experimental, opt-in bounded saved display preview while rebuilding in the existing
|
||||
file worker. Ordinary saves omit display caches; explicit `preview: true` saves cache
|
||||
only already warm meshes. Document hashes and application versions reject
|
||||
stale previews. Preview geometry cannot be edited or measured, and cancelled or stale
|
||||
completions cannot replace a newer document. Evaluated B-rep restoration remains open.
|
||||
- Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology
|
||||
and geometry records, with a separate truck adapter and bounded binary reader. Exact
|
||||
face-source certificates and operation history survive codec round trips. A provisional
|
||||
picture can be generated before original blobs are read; editing and measurement wait
|
||||
for authoritative reconstruction. Cached metadata binds preview and reconstruction to
|
||||
the same document despite concurrent file writes. Default saves omit this cache and never force cold
|
||||
evaluation or meshing; explicit `preview: true` remains a separate warm-mesh option.
|
||||
Keys, resource budgets and document/revision guards reject incompatible, hostile or
|
||||
stale results. A second modelling kernel still requires its own tested adapter.
|
||||
- Warm saves with explicit `cache: true` can also carry a bounded PNG thumbnail from already cached meshes.
|
||||
Home reads it without rebuilding the design when its local picture is missing;
|
||||
mismatched or malformed thumbnails fall back to normal generation. The image is
|
||||
kernel-independent and saving never forces cold meshing to produce it.
|
||||
|
||||
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
|
||||
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# Security policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
SolidCraft is young and moves quickly, so only the latest release gets security fixes. Please
|
||||
update to the [latest release](https://github.com/bherbruck/solidcraft/releases/latest) before
|
||||
reporting, and check whether the problem is still on `main`.
|
||||
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| Latest release | ✅ |
|
||||
| Nightly builds | ✅ (fixes land here first) |
|
||||
| Older releases | ❌ |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please **don't open a public issue** for a security problem. Report it privately through GitHub:
|
||||
open the repository's [Security tab](https://github.com/bherbruck/solidcraft/security) and choose
|
||||
**Report a vulnerability**
|
||||
([direct link](https://github.com/bherbruck/solidcraft/security/advisories/new)).
|
||||
|
||||
Please include what you found, how to reproduce it (a design file, command or MCP request), the
|
||||
version and platform, and the impact you expect. I'll acknowledge the report as soon as I can,
|
||||
keep you updated in the advisory, and credit you when the fix is published unless you'd rather
|
||||
stay anonymous.
|
||||
|
||||
Areas where reports are especially useful:
|
||||
|
||||
- opening untrusted design, STEP, STL or 3MF files (crashes, excessive memory or CPU, path tricks)
|
||||
- the MCP server and the control API (file access outside the allowed folders, command injection)
|
||||
- the web app (anything reaching beyond the page's own storage)
|
||||
- release packages and the update paths (AppImage, Flatpak, MSI, deb, rpm, snap)
|
||||
@@ -1,6 +1,6 @@
|
||||
Third-party software in SolidCraft
|
||||
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 454 crates below.
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 453 crates below.
|
||||
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
|
||||
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
|
||||
|
||||
@@ -262,7 +262,6 @@ proc-macro2 1.0.107 (MIT OR Apache-2.0)
|
||||
profiling 1.0.18 (MIT OR Apache-2.0)
|
||||
pxfm 0.1.30 (BSD-3-Clause OR Apache-2.0)
|
||||
quick-error 2.0.1 (MIT/Apache-2.0)
|
||||
quick-xml 0.38.4 (MIT)
|
||||
quick-xml 0.41.0 (MIT)
|
||||
quote 1.0.47 (MIT OR Apache-2.0)
|
||||
r-efi 5.3.0 (MIT OR Apache-2.0 OR LGPL-2.1-or-later)
|
||||
@@ -8709,7 +8708,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
quick-xml 0.38.4, quick-xml 0.41.0
|
||||
quick-xml 0.41.0
|
||||
================================================================================
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,13 @@ hostile variants of it never panic.
|
||||
| `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`) |
|
||||
| `move-bodies` | 00baf1d | `solid.move` before it could copy: two bodies, one translated in place, one translated and turned 90 deg about Z (no `copy` key anywhere in the file) |
|
||||
| `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)`) |
|
||||
| `issue-489-stacked-join` | v0.6.0 (809ecf7), desktop | a 70 × 84 extrude, then a 70 × 62 strip joined on its top face, sketched partly over the face's projected edges (issue #489; expected values from main at 2880525) |
|
||||
| `issue-616-lid-ribs` | v0.7.0 (a9879fa), CLI | a rounded lid with a skirt ring, a window and a slot cut through, then four ribs joined on the plate over the skirt, flush with the window's sides; v0.7.0 displaced the ribs and left a slit through the skirt and plate (issue #616; script `examples/regressions/lid_ribs_flush_with_window.json`, expected values from the fix) |
|
||||
|
||||
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.
|
||||
The scripts that made them are in `scripts/` (`issue-489-stacked-join` was drawn in the desktop
|
||||
app and has none; `issue-616-lid-ribs` is made by its regression script). Every flat face of every final body must also take an attached sketch and an
|
||||
offset plane; the faces that can't yet are listed with their issues in `design_corpus.rs`.
|
||||
|
||||
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,157 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 25751.999999999945,
|
||||
"bbox": {
|
||||
"max": [
|
||||
35.0,
|
||||
42.0,
|
||||
48.00000000000001
|
||||
],
|
||||
"min": [
|
||||
-35.0,
|
||||
-42.000000000000014,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
5.524909928104648e-14,
|
||||
-1.0945382096961234e-12,
|
||||
22.366336633663238
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 24,
|
||||
"face_types": {
|
||||
"plane": 10
|
||||
},
|
||||
"faces": 10,
|
||||
"kernel": {
|
||||
"edges": 24,
|
||||
"faces": 10,
|
||||
"shells": 1,
|
||||
"vertices": 16
|
||||
},
|
||||
"mass_g": 328.33079999999734,
|
||||
"material": "Default",
|
||||
"name": "Body1",
|
||||
"vertices": 16,
|
||||
"volume_mm3": 254519.9999999979
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [
|
||||
{
|
||||
"comment": "",
|
||||
"error": null,
|
||||
"expression": "84",
|
||||
"model": true,
|
||||
"name": "d1",
|
||||
"unit": "mm",
|
||||
"value": 84.0
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"error": null,
|
||||
"expression": "70",
|
||||
"model": true,
|
||||
"name": "d2",
|
||||
"unit": "mm",
|
||||
"value": 70.0
|
||||
},
|
||||
{
|
||||
"comment": "",
|
||||
"error": null,
|
||||
"expression": "62",
|
||||
"model": true,
|
||||
"name": "d4",
|
||||
"unit": "mm",
|
||||
"value": 62.0
|
||||
}
|
||||
],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 6,
|
||||
"dof": 0,
|
||||
"id": 1,
|
||||
"name": "Sketch1",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"curves": 10,
|
||||
"dof": 1,
|
||||
"id": 3,
|
||||
"name": "Sketch2",
|
||||
"profiles": 3,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.131943,
|
||||
"name": "Sketch1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 1.0812709999999999,
|
||||
"name": "Extrude1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.450002,
|
||||
"name": "Sketch2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 5.191514000000001,
|
||||
"name": "Extrude2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 25751.999999999945,
|
||||
"edges": 24,
|
||||
"faces": 10,
|
||||
"mass_g": 328.33079999999734,
|
||||
"vertices": 16,
|
||||
"volume_mm3": 254519.9999999979
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Sketch1",
|
||||
"plane": {
|
||||
"name": "XY",
|
||||
"type": "origin"
|
||||
},
|
||||
"sketch": {
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"l": 0,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"l": 2,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"l": 1,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"l": 3,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"l": 4,
|
||||
"p": 5,
|
||||
"type": "midpoint"
|
||||
},
|
||||
{
|
||||
"id": "k6",
|
||||
"p": 0,
|
||||
"q": 5,
|
||||
"type": "coincident"
|
||||
},
|
||||
{
|
||||
"id": "k7",
|
||||
"l": 1,
|
||||
"param": "d1",
|
||||
"type": "length",
|
||||
"value": 84.0
|
||||
},
|
||||
{
|
||||
"id": "k8",
|
||||
"l": 0,
|
||||
"param": "d2",
|
||||
"type": "length",
|
||||
"value": 70.0
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 8,
|
||||
"l": 6,
|
||||
"p": 5
|
||||
},
|
||||
"curves": [
|
||||
{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false,
|
||||
"id": "l1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false,
|
||||
"id": "l2",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false,
|
||||
"id": "l3",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false,
|
||||
"id": "l4",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 1,
|
||||
"b": 3,
|
||||
"construction": true,
|
||||
"id": "l5",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 4,
|
||||
"construction": true,
|
||||
"id": "l6",
|
||||
"type": "line"
|
||||
}
|
||||
],
|
||||
"points": [
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
-35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
-35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p5",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"suppressed": false,
|
||||
"type": "sketch"
|
||||
},
|
||||
{
|
||||
"extent": {
|
||||
"direction": "positive",
|
||||
"distance": "30"
|
||||
},
|
||||
"id": 2,
|
||||
"name": "Extrude1",
|
||||
"operation": "new_body",
|
||||
"param_names": [
|
||||
"d3"
|
||||
],
|
||||
"profiles": {
|
||||
"loops": [
|
||||
[
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
],
|
||||
"type": "curves"
|
||||
},
|
||||
"sketch": 1,
|
||||
"suppressed": false,
|
||||
"type": "extrude"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Sketch2",
|
||||
"plane": {
|
||||
"anchor": {
|
||||
"author_path": [],
|
||||
"component": 0,
|
||||
"coordinates": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"path": [],
|
||||
"target": {
|
||||
"body": "Body1",
|
||||
"certificate": {
|
||||
"high": true,
|
||||
"holes": [],
|
||||
"kind": "extrude_cap",
|
||||
"outer": [
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
],
|
||||
"sketch": 1
|
||||
},
|
||||
"name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
|
||||
"origin_feature": 2,
|
||||
"point": [
|
||||
0.0,
|
||||
0.0,
|
||||
30.0
|
||||
]
|
||||
},
|
||||
"x_axis": [
|
||||
1.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"at": [
|
||||
2.012802880253332,
|
||||
7.4635742926698185,
|
||||
30.000000000000114
|
||||
],
|
||||
"name": "F2:top",
|
||||
"plane": {
|
||||
"origin": [
|
||||
0.0,
|
||||
0.0,
|
||||
30.0
|
||||
],
|
||||
"x_dir": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"y_dir": [
|
||||
0.0,
|
||||
1.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"type": "face"
|
||||
},
|
||||
"sketch": {
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"l": 4,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"l": 6,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"l": 5,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"l": 7,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"l": 8,
|
||||
"p": 9,
|
||||
"type": "midpoint"
|
||||
},
|
||||
{
|
||||
"id": "k6",
|
||||
"p": 0,
|
||||
"q": 9,
|
||||
"type": "coincident"
|
||||
},
|
||||
{
|
||||
"id": "k7",
|
||||
"p": 7,
|
||||
"param": "d4",
|
||||
"q": 6,
|
||||
"text": [
|
||||
-0.2,
|
||||
9.200000000000003
|
||||
],
|
||||
"type": "distance",
|
||||
"value": 62.0
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"j": 1,
|
||||
"k": 7,
|
||||
"l": 10,
|
||||
"p": 9
|
||||
},
|
||||
"curves": [
|
||||
{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false,
|
||||
"id": "l1",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 3,
|
||||
"b": 1,
|
||||
"construction": false,
|
||||
"id": "l2",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 4,
|
||||
"construction": false,
|
||||
"id": "l3",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 4,
|
||||
"b": 3,
|
||||
"construction": false,
|
||||
"id": "l4",
|
||||
"link": "j1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 5,
|
||||
"b": 6,
|
||||
"construction": false,
|
||||
"id": "l5",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 6,
|
||||
"b": 7,
|
||||
"construction": false,
|
||||
"id": "l6",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 7,
|
||||
"b": 8,
|
||||
"construction": false,
|
||||
"id": "l7",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 8,
|
||||
"b": 5,
|
||||
"construction": false,
|
||||
"id": "l8",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 5,
|
||||
"b": 7,
|
||||
"construction": true,
|
||||
"id": "l9",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 6,
|
||||
"b": 8,
|
||||
"construction": true,
|
||||
"id": "l10",
|
||||
"type": "line"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"auto_face": true,
|
||||
"face_name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
|
||||
"id": "j1",
|
||||
"kind": "project",
|
||||
"source": {
|
||||
"at": [
|
||||
11.666666666666666,
|
||||
-13.999999999999995,
|
||||
30.0
|
||||
],
|
||||
"body": "Body1",
|
||||
"type": "face"
|
||||
}
|
||||
}
|
||||
],
|
||||
"points": [
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p1",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
-35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p2",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
-35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p3",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
35.0,
|
||||
-42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "p4",
|
||||
"link": "j1",
|
||||
"pos": [
|
||||
35.0,
|
||||
42.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p5",
|
||||
"pos": [
|
||||
-35.0,
|
||||
-31.000000000002185
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p6",
|
||||
"pos": [
|
||||
35.0,
|
||||
-31.00000000000349
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p7",
|
||||
"pos": [
|
||||
35.0,
|
||||
30.99999999999603
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p8",
|
||||
"pos": [
|
||||
-35.0,
|
||||
30.999999999995318
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p9",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"suppressed": false,
|
||||
"type": "sketch"
|
||||
},
|
||||
{
|
||||
"extent": {
|
||||
"direction": "positive",
|
||||
"distance": "18"
|
||||
},
|
||||
"id": 4,
|
||||
"name": "Extrude2",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d5"
|
||||
],
|
||||
"profiles": {
|
||||
"loops": [
|
||||
[
|
||||
"l1",
|
||||
"l5",
|
||||
"l4",
|
||||
"l7"
|
||||
]
|
||||
],
|
||||
"type": "curves"
|
||||
},
|
||||
"sketch": 3,
|
||||
"suppressed": false,
|
||||
"type": "extrude"
|
||||
}
|
||||
],
|
||||
"format": "solidcraft/14",
|
||||
"name": "Untitled",
|
||||
"next_id": 6,
|
||||
"params": [
|
||||
{
|
||||
"expr": "84",
|
||||
"model": true,
|
||||
"name": "d1",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"expr": "70",
|
||||
"model": true,
|
||||
"name": "d2",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"expr": "62",
|
||||
"model": true,
|
||||
"name": "d4",
|
||||
"unit": "mm"
|
||||
}
|
||||
],
|
||||
"units": "mm"
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 8430.29544246263,
|
||||
"bbox": {
|
||||
"max": [
|
||||
176.8000000000002,
|
||||
89.30000000000001,
|
||||
14.100000000000001
|
||||
],
|
||||
"min": [
|
||||
99.99999999999991,
|
||||
-1.820765760385257e-14,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
138.3999999716193,
|
||||
51.04392591016203,
|
||||
2.0990634391586322
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 156,
|
||||
"face_types": {
|
||||
"cylinder": 4,
|
||||
"plane": 50
|
||||
},
|
||||
"faces": 54,
|
||||
"kernel": {
|
||||
"edges": 156,
|
||||
"faces": 54,
|
||||
"shells": 1,
|
||||
"vertices": 104
|
||||
},
|
||||
"mass_g": 9.101726874183207,
|
||||
"material": "Default",
|
||||
"name": "Lid",
|
||||
"vertices": 104,
|
||||
"volume_mm3": 7055.602228048997
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 8,
|
||||
"dof": 8,
|
||||
"id": 3,
|
||||
"name": "Skirt",
|
||||
"profiles": 2,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 1.178957,
|
||||
"name": "Box1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 83.24342399999999,
|
||||
"name": "Fillet1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "FilletFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.150889,
|
||||
"name": "Skirt",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 94.465481,
|
||||
"name": "Extrude1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 50.426736,
|
||||
"name": "Box2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 40.383005,
|
||||
"name": "Box3",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 7,
|
||||
"ms": 59.041461000000005,
|
||||
"name": "Box4",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [
|
||||
{
|
||||
"face_shift_mm": 0.0,
|
||||
"rotation_rad": 0.0,
|
||||
"strategy": "planar_fallback",
|
||||
"tolerance_mm": 0.0,
|
||||
"translation_mm": 0.0
|
||||
}
|
||||
],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 8,
|
||||
"ms": 562.5477970000001,
|
||||
"name": "Rib1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": "Boolean retry: exact planar fallback (no displacement)"
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 9,
|
||||
"ms": 89.56076,
|
||||
"name": "Rib2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 10,
|
||||
"ms": 65.330634,
|
||||
"name": "Rib3",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 11,
|
||||
"ms": 97.229403,
|
||||
"name": "Rib4",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "BoxFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 8430.29544246263,
|
||||
"edges": 156,
|
||||
"faces": 54,
|
||||
"mass_g": 9.101726874183207,
|
||||
"vertices": 104,
|
||||
"volume_mm3": 7055.602228048997
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"body_names": [
|
||||
"Lid"
|
||||
],
|
||||
"corner": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"height": "2.4",
|
||||
"id": 1,
|
||||
"length": "76.8",
|
||||
"name": "Box1",
|
||||
"operation": "new_body",
|
||||
"param_names": [
|
||||
"d1",
|
||||
"d2",
|
||||
"d3"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "89.3"
|
||||
},
|
||||
{
|
||||
"edge_names": [
|
||||
"F1:-x|F1:-y",
|
||||
"F1:+x|F1:-y",
|
||||
"F1:+y|F1:-x",
|
||||
"F1:+x|F1:+y"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
-1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
"max": [
|
||||
176.8,
|
||||
89.3,
|
||||
2.4
|
||||
],
|
||||
"min": [
|
||||
100.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
[
|
||||
100.0,
|
||||
0.0,
|
||||
1.2
|
||||
],
|
||||
[
|
||||
176.8,
|
||||
0.0,
|
||||
1.2
|
||||
],
|
||||
[
|
||||
100.0,
|
||||
89.3,
|
||||
1.2
|
||||
],
|
||||
[
|
||||
176.8,
|
||||
89.3,
|
||||
1.2
|
||||
]
|
||||
],
|
||||
"id": 2,
|
||||
"name": "Fillet1",
|
||||
"param_names": [
|
||||
"d4"
|
||||
],
|
||||
"radius": "3",
|
||||
"suppressed": false,
|
||||
"type": "fillet"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Skirt",
|
||||
"param_names": [
|
||||
"d5"
|
||||
],
|
||||
"plane": {
|
||||
"base": {
|
||||
"name": "XY",
|
||||
"type": "origin"
|
||||
},
|
||||
"distance": "2.4",
|
||||
"type": "offset"
|
||||
},
|
||||
"sketch": {
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"l": 0,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"l": 2,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"l": 1,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"l": 3,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"l": 4,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k6",
|
||||
"l": 6,
|
||||
"type": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": "k7",
|
||||
"l": 5,
|
||||
"type": "vertical"
|
||||
},
|
||||
{
|
||||
"id": "k8",
|
||||
"l": 7,
|
||||
"type": "vertical"
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 8,
|
||||
"l": 8,
|
||||
"p": 8
|
||||
},
|
||||
"curves": [
|
||||
{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false,
|
||||
"id": "l1",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false,
|
||||
"id": "l2",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false,
|
||||
"id": "l3",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false,
|
||||
"id": "l4",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 5,
|
||||
"b": 6,
|
||||
"construction": false,
|
||||
"id": "l5",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 6,
|
||||
"b": 7,
|
||||
"construction": false,
|
||||
"id": "l6",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 7,
|
||||
"b": 8,
|
||||
"construction": false,
|
||||
"id": "l7",
|
||||
"type": "line"
|
||||
},
|
||||
{
|
||||
"a": 8,
|
||||
"b": 5,
|
||||
"construction": false,
|
||||
"id": "l8",
|
||||
"type": "line"
|
||||
}
|
||||
],
|
||||
"points": [
|
||||
{
|
||||
"fixed": true,
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
102.65,
|
||||
2.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
174.15,
|
||||
2.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
174.15,
|
||||
86.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
102.65,
|
||||
86.65
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p5",
|
||||
"pos": [
|
||||
104.15,
|
||||
4.15
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p6",
|
||||
"pos": [
|
||||
172.65,
|
||||
4.15
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p7",
|
||||
"pos": [
|
||||
172.65,
|
||||
85.15
|
||||
]
|
||||
},
|
||||
{
|
||||
"fixed": false,
|
||||
"id": "p8",
|
||||
"pos": [
|
||||
104.15,
|
||||
85.15
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"suppressed": false,
|
||||
"type": "sketch"
|
||||
},
|
||||
{
|
||||
"extent": {
|
||||
"direction": "positive",
|
||||
"distance": "3"
|
||||
},
|
||||
"id": 4,
|
||||
"name": "Extrude1",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d6"
|
||||
],
|
||||
"profiles": {
|
||||
"loops": [
|
||||
[
|
||||
"l1",
|
||||
"l2",
|
||||
"l3",
|
||||
"l4"
|
||||
]
|
||||
],
|
||||
"type": "curves"
|
||||
},
|
||||
"sketch": 3,
|
||||
"suppressed": false,
|
||||
"targets": [
|
||||
"Lid"
|
||||
],
|
||||
"type": "extrude"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
104.4,
|
||||
5.4,
|
||||
-1.0
|
||||
],
|
||||
"height": "8",
|
||||
"id": 5,
|
||||
"length": "68",
|
||||
"name": "Box2",
|
||||
"operation": "cut",
|
||||
"param_names": [
|
||||
"d7",
|
||||
"d8",
|
||||
"d9"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "61"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
116.4,
|
||||
72.0,
|
||||
-1.0
|
||||
],
|
||||
"height": "8",
|
||||
"id": 6,
|
||||
"length": "44",
|
||||
"name": "Box3",
|
||||
"operation": "cut",
|
||||
"param_names": [
|
||||
"d10",
|
||||
"d11",
|
||||
"d12"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "10"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
127.4,
|
||||
84.0,
|
||||
2.4
|
||||
],
|
||||
"height": "4",
|
||||
"id": 7,
|
||||
"length": "22",
|
||||
"name": "Box4",
|
||||
"operation": "cut",
|
||||
"param_names": [
|
||||
"d13",
|
||||
"d14",
|
||||
"d15"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
102.8,
|
||||
14.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 8,
|
||||
"length": "1.6",
|
||||
"name": "Rib1",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d16",
|
||||
"d17",
|
||||
"d18"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
102.8,
|
||||
48.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 9,
|
||||
"length": "1.6",
|
||||
"name": "Rib2",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d19",
|
||||
"d20",
|
||||
"d21"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
172.4,
|
||||
14.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 10,
|
||||
"length": "1.6",
|
||||
"name": "Rib3",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d22",
|
||||
"d23",
|
||||
"d24"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
},
|
||||
{
|
||||
"corner": [
|
||||
172.4,
|
||||
48.0,
|
||||
2.4
|
||||
],
|
||||
"height": "11.7",
|
||||
"id": 11,
|
||||
"length": "1.6",
|
||||
"name": "Rib4",
|
||||
"operation": "join",
|
||||
"param_names": [
|
||||
"d25",
|
||||
"d26",
|
||||
"d27"
|
||||
],
|
||||
"suppressed": false,
|
||||
"type": "box",
|
||||
"width": "6"
|
||||
}
|
||||
],
|
||||
"format": "solidcraft/15",
|
||||
"name": "issue-616-lid-ribs",
|
||||
"next_id": 12,
|
||||
"params": [],
|
||||
"units": "mm"
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 2400.0,
|
||||
"bbox": {
|
||||
"max": [
|
||||
50.0,
|
||||
20.0,
|
||||
20.0
|
||||
],
|
||||
"min": [
|
||||
30.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
40.0,
|
||||
10.0,
|
||||
10.0
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 12,
|
||||
"face_types": {
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 6,
|
||||
"kernel": {
|
||||
"edges": 12,
|
||||
"faces": 6,
|
||||
"shells": 1,
|
||||
"vertices": 8
|
||||
},
|
||||
"mass_g": 10.32,
|
||||
"material": "Default",
|
||||
"name": "Block",
|
||||
"vertices": 8,
|
||||
"volume_mm3": 8000.0
|
||||
},
|
||||
{
|
||||
"area_mm2": 600.0,
|
||||
"bbox": {
|
||||
"max": [
|
||||
3.552713678800501e-15,
|
||||
90.0,
|
||||
10.0
|
||||
],
|
||||
"min": [
|
||||
-9.999999999999998,
|
||||
80.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
-4.999999999999999,
|
||||
85.0,
|
||||
5.000000000000001
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 12,
|
||||
"face_types": {
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 6,
|
||||
"kernel": {
|
||||
"edges": 12,
|
||||
"faces": 6,
|
||||
"shells": 1,
|
||||
"vertices": 8
|
||||
},
|
||||
"mass_g": 1.29,
|
||||
"material": "Default",
|
||||
"name": "Peg",
|
||||
"vertices": 8,
|
||||
"volume_mm3": 1000.0
|
||||
}
|
||||
],
|
||||
"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": "Peg",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.204585,
|
||||
"name": "Base",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 2.318662,
|
||||
"name": "Extrude1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.109196,
|
||||
"name": "Peg",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 0.338859,
|
||||
"name": "Extrude2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 0.312259,
|
||||
"name": "Move1",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "MoveFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"boolean_diagnostics": {
|
||||
"events": [],
|
||||
"truncated": false
|
||||
},
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 0.23144699999999999,
|
||||
"name": "Move2",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "MoveFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 3000.0,
|
||||
"edges": 24,
|
||||
"faces": 12,
|
||||
"mass_g": 11.61,
|
||||
"vertices": 16,
|
||||
"volume_mm3": 9000.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"features":[{"id":1,"name":"Base","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"},{"id":"k5","p":0,"q":1,"type":"coincident"}],"counters":{"k":5,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[0.0,0.0]},{"fixed":false,"id":"p2","pos":[20.0,0.0]},{"fixed":false,"id":"p3","pos":[20.0,20.0]},{"fixed":false,"id":"p4","pos":[0.0,20.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Block"],"extent":{"direction":"positive","distance":"20"},"id":2,"name":"Extrude1","operation":"new_body","param_names":["d1"],"profiles":{"type":"all"},"sketch":1,"suppressed":false,"type":"extrude"},{"id":3,"name":"Peg","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"}],"counters":{"k":4,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[40.0,0.0]},{"fixed":false,"id":"p2","pos":[50.0,0.0]},{"fixed":false,"id":"p3","pos":[50.0,10.0]},{"fixed":false,"id":"p4","pos":[40.0,10.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Peg"],"extent":{"direction":"positive","distance":"10"},"id":4,"name":"Extrude2","operation":"new_body","param_names":["d2"],"profiles":{"type":"all"},"sketch":3,"suppressed":false,"type":"extrude"},{"bodies":["Block"],"id":5,"name":"Move1","param_names":["d3","d4","d5"],"suppressed":false,"translate":["30","0","0"],"type":"move"},{"angle":"90 deg","bodies":["Peg"],"id":6,"name":"Move2","param_names":["d6","d7","d8","d9"],"rotate_axis":[0.0,0.0,1.0],"suppressed":false,"translate":["0","40","0"],"type":"move"}],"format":"solidcraft/15","name":"old-move-bodies","next_id":7,"params":[],"units":"mm"}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"commands": [
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [20, 20]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 20, "body_name": "Block"}},
|
||||
{"command": "sketch.create", "params": {"plane": "XY", "name": "Peg"}},
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [40, 0], "p1": [50, 10]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.extrude", "params": {"distance": 10, "operation": "new", "body_name": "Peg"}},
|
||||
{"command": "solid.move", "params": {"bodies": ["Block"], "translate": [30, 0, 0]}},
|
||||
{"command": "solid.move", "params": {"bodies": ["Peg"], "translate": [0, 40, 0], "axis": [0, 0, 1], "angle": "90 deg"}}
|
||||
]
|
||||
}
|
||||
@@ -131,6 +131,155 @@ fn body_names_saved_before_exact_history_keep_their_pieces() {
|
||||
assert!(s.doc.appearances.bodies.contains_key("Body1 (1)"));
|
||||
}
|
||||
|
||||
/// Flat faces that can't take an attached sketch yet, by cause: (design, body, the faces'
|
||||
/// planes as outward normal and distance from the origin, or every flat face of the body,
|
||||
/// issue). Face indices are not used: they can change between builds. Each issue has an
|
||||
/// ignored test below that checks its faces; a listed plane whose face attaches fails the main
|
||||
/// check until it is removed from here.
|
||||
type Planes = &'static [([f64; 3], f64)];
|
||||
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 5] = [
|
||||
("m0-revolve-primitives", "Body1", None, 494),
|
||||
("naming-split-pieces", "Body1", None, 494),
|
||||
("modern-plastic", "Body1", None, 494),
|
||||
("modern-sheet-metal", "Body1", None, 495),
|
||||
("issue-616-lid-ribs", "Lid", None, 628),
|
||||
];
|
||||
|
||||
/// The issue a face is listed under, and whether it is listed by its plane (not as every face).
|
||||
fn known_without_attachment(design: &str, body: &str, normal: [f64; 3], distance: f64) -> Option<(u32, bool)> {
|
||||
let same = |(n, d): &([f64; 3], f64)| (0..3).all(|i| (n[i] - normal[i]).abs() < 1e-6) && (d - distance).abs() < 1e-4;
|
||||
NO_ATTACHMENT_YET.iter().find_map(|(d, b, planes, issue)| {
|
||||
if *d != design || *b != body {
|
||||
return None;
|
||||
}
|
||||
match planes {
|
||||
None => Some((*issue, false)),
|
||||
Some(planes) => planes.iter().any(same).then_some((*issue, true)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn undo_to(s: &mut Session, features: usize) {
|
||||
for _ in 0..4 {
|
||||
if s.doc.features.len() <= features {
|
||||
return;
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc.features.len(), features, "undo did not remove the added feature");
|
||||
}
|
||||
|
||||
/// Try a sketch and an offset plane on every flat face of every final body of every corpus
|
||||
/// design, through the same face pick the desktop sends (body, face index, occurrence path, a
|
||||
/// point on it), without `fixed`. Undo leaves the design as it was for the next face. Returns
|
||||
/// (design, body, face, issue it is known under, what went wrong or None).
|
||||
fn flat_face_attachments() -> Vec<(String, String, usize, Option<(u32, bool)>, Option<String>)> {
|
||||
use solidcraft_engine::doc::{FaceAttachmentStatus, apply_point};
|
||||
let mut out = Vec::new();
|
||||
for p in corpus() {
|
||||
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let mut s = open(&p);
|
||||
let state = s.model.state();
|
||||
let placed = s.placed_document();
|
||||
let mut picks = Vec::new();
|
||||
for (world, definition, component, path, frame) in placed.body_frames_with_source(&state) {
|
||||
let Some(body) = state.bodies.iter().find(|b| b.definition_name() == definition && placed.model_body_component(b) == component) else {
|
||||
continue;
|
||||
};
|
||||
let mesh = body.body.tessellate(0.01).unwrap();
|
||||
for index in 0..body.body.face_count() {
|
||||
if body.body.planar_face(index).is_none() {
|
||||
continue;
|
||||
}
|
||||
// The middle of the face's largest triangle: well inside the face.
|
||||
let point = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, f)| **f as usize == index)
|
||||
.filter_map(|(t, _)| mesh.tri(t))
|
||||
.max_by(|[a, b, c], [d, e, f]| (*b - *a).cross(*c - *a).len().total_cmp(&(*e - *d).cross(*f - *d).len()))
|
||||
.map(|[a, b, c]| (a + b + c) * (1.0 / 3.0));
|
||||
let Some(point) = point else { continue };
|
||||
let point = apply_point(&frame, point);
|
||||
let normal =
|
||||
body.body.planar_face(index).map(|plane| solidcraft_engine::doc::apply_vector(&frame, plane.normal())).unwrap_or_default();
|
||||
let key = ([normal.x, normal.y, normal.z], normal.dot(point));
|
||||
picks.push((world.clone(), index, key, json!({"face": point, "body": world, "index": index, "path": path})));
|
||||
}
|
||||
}
|
||||
let before = s.doc.features.len();
|
||||
for (body, index, (normal, distance), plane) in picks {
|
||||
let mut problem = None;
|
||||
match s.execute("sketch.create", &json!({"plane": plane})) {
|
||||
Ok(created) => {
|
||||
let id = created["sketch"].as_u64().unwrap_or(0);
|
||||
match s.placed_document().sketch_face_attachment(id, &s.model) {
|
||||
FaceAttachmentStatus::Healthy { .. } | FaceAttachmentStatus::Sheet { .. } => {}
|
||||
other => problem = Some(format!("sketch attachment {other:?}")),
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
undo_to(&mut s, before);
|
||||
}
|
||||
Err(e) => problem = Some(format!("sketch.create: {e}")),
|
||||
}
|
||||
match s.execute("construct.plane.offset", &json!({"base": plane, "offset": 5})) {
|
||||
Ok(_) => undo_to(&mut s, before),
|
||||
Err(e) => problem = Some(problem.map_or(String::new(), |p| p + "; ") + &format!("construct.plane.offset: {e}")),
|
||||
}
|
||||
let known = known_without_attachment(&name, &body, normal, distance);
|
||||
out.push((name.clone(), body, index, known, problem));
|
||||
}
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{name}: left with errors after undo");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every flat face of every corpus design takes a durable attachment (issue #489: the top face
|
||||
/// of an extrude sketched over a face's projected boundary had "no exact planar source"),
|
||||
/// except the faces listed in [`NO_ATTACHMENT_YET`] under their open issues.
|
||||
#[test]
|
||||
fn every_flat_face_of_every_corpus_design_takes_an_attached_sketch() {
|
||||
let all = flat_face_attachments();
|
||||
assert!(all.len() > 50, "only {} faces checked", all.len());
|
||||
let problems: Vec<String> = all
|
||||
.iter()
|
||||
.filter_map(|(design, body, face, known, problem)| match (known, problem) {
|
||||
(None, Some(problem)) => Some(format!("{design}: {body} face {face}: {problem}")),
|
||||
(Some((issue, true)), None) => Some(format!("{design}: {body} face {face} attaches now: remove it from NO_ATTACHMENT_YET (#{issue})")),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(problems.is_empty(), "{} of {} faces:\n{}", problems.len(), all.len(), problems.join("\n"));
|
||||
}
|
||||
|
||||
fn known_faces_attach(issue: u32) {
|
||||
let problems: Vec<String> = flat_face_attachments()
|
||||
.into_iter()
|
||||
.filter(|(.., known, problem)| known.is_some_and(|(known, _)| known == issue) && problem.is_some())
|
||||
.map(|(design, body, face, _, problem)| format!("{design}: {body} face {face}: {}", problem.unwrap_or_default()))
|
||||
.collect();
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #494 is fixed"]
|
||||
fn faces_after_revolve_offset_face_and_boss_take_an_attached_sketch() {
|
||||
known_faces_attach(494);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #495 is fixed"]
|
||||
fn sheet_metal_thickness_faces_take_an_attached_sketch() {
|
||||
known_faces_attach(495);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #628 is fixed"]
|
||||
fn faces_of_a_lid_with_joined_ribs_take_an_attached_sketch() {
|
||||
known_faces_attach(628);
|
||||
}
|
||||
|
||||
/// A small deterministic generator (xorshift).
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
|
||||
@@ -4,6 +4,21 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>SolidCraft</title>
|
||||
<meta name="description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="SolidCraft">
|
||||
<meta property="og:title" content="SolidCraft: try it in your browser">
|
||||
<meta property="og:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
|
||||
<meta property="og:url" content="https://bherbruck.github.io/solidcraft/app/">
|
||||
<meta property="og:image" content="https://bherbruck.github.io/solidcraft/og.png">
|
||||
<meta property="og:image:width" content="1280">
|
||||
<meta property="og:image:height" content="640">
|
||||
<meta property="og:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="SolidCraft: try it in your browser">
|
||||
<meta name="twitter:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
|
||||
<meta name="twitter:image" content="https://bherbruck.github.io/solidcraft/og.png">
|
||||
<meta name="twitter:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
|
||||
<link data-trunk rel="rust" data-bin="solidcraft-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" />
|
||||
|
||||
@@ -110,7 +110,7 @@ mod web {
|
||||
let changed = s.is_dirty() && self.autosaved != Some(s.revision);
|
||||
let wait = AUTOSAVE_S - (now - self.autosaved_at);
|
||||
if changed && wait <= 0.0 {
|
||||
files::autosave(&self.files, Some((&s.doc, s.path.as_deref())));
|
||||
files::autosave(&self.files, Some((&s.design(), s.path.as_deref())));
|
||||
self.autosaved = Some(s.revision);
|
||||
self.autosaved_at = now;
|
||||
} else if changed {
|
||||
@@ -251,7 +251,10 @@ mod web {
|
||||
},
|
||||
"download_open" => {
|
||||
let d = &self.app.session.doc;
|
||||
let result = solidcraft_engine::io::write_design_container(d)
|
||||
let result = self
|
||||
.app
|
||||
.session
|
||||
.design_container(solidcraft_engine::io::DesignCompression::Fast, false, false)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|bytes| files::download(&format!("{}.{}", d.name, solidcraft_engine::io::DESIGN_EXT), &bytes));
|
||||
if let Err(e) = result {
|
||||
|
||||
@@ -141,17 +141,28 @@ fn graphics_path() -> Option<std::path::PathBuf> {
|
||||
/// Compatibility shim (docs/compatibility.md): on the first launch after the rename, copy the
|
||||
/// settings of earlier releases into the new folder. They kept `graphics.json` (and, on Linux
|
||||
/// and macOS, the recovery folder) under the old product name, and eframe's `app.ron` under the
|
||||
/// window's app id. The old folders are never changed.
|
||||
/// window's old app id. In the Flatpak sandbox, releases before 0.8 kept everything under the
|
||||
/// old app id's `~/.var/app` folder; that copy comes first, as it is the newest. The old folders
|
||||
/// are never changed.
|
||||
fn adopt_legacy_settings() {
|
||||
let (Some(current), Some(legacy)) = (settings_dir(), eframe::storage_dir("SolveCraft")) else { return };
|
||||
let legacy_eframe = eframe::storage_dir(APP_ID);
|
||||
if let Err(e) = adopt_settings(¤t, &legacy, legacy_eframe.as_deref()) {
|
||||
let legacy_eframe = eframe::storage_dir(solidcraft_engine::io::LEGACY_APP_ID);
|
||||
let old_flatpak = solidcraft_engine::io::legacy_flatpak_path(¤t);
|
||||
if let Err(e) = adopt_settings(¤t, old_flatpak.as_deref(), &legacy, legacy_eframe.as_deref()) {
|
||||
log::warn!("settings: could not copy the previous release's settings into {}: {e}", current.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn adopt_settings(current: &std::path::Path, legacy: &std::path::Path, legacy_eframe: Option<&std::path::Path>) -> std::io::Result<()> {
|
||||
fn adopt_settings(
|
||||
current: &std::path::Path,
|
||||
old_flatpak: Option<&std::path::Path>,
|
||||
legacy: &std::path::Path,
|
||||
legacy_eframe: Option<&std::path::Path>,
|
||||
) -> std::io::Result<()> {
|
||||
let fresh = !current.exists();
|
||||
if let Some(old) = old_flatpak {
|
||||
solidcraft_engine::io::adopt_legacy_dir(current, old)?;
|
||||
}
|
||||
solidcraft_engine::io::adopt_legacy_dir(current, legacy)?;
|
||||
if let Some(old) = legacy_eframe.map(|d| d.join("app.ron")).filter(|p| fresh && p.is_file()) {
|
||||
std::fs::create_dir_all(current)?;
|
||||
@@ -203,9 +214,8 @@ fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
|
||||
.with_app_id(APP_ID))
|
||||
}
|
||||
|
||||
/// The window's app id: it names the desktop entry, so it stays the packages' reverse-DNS id,
|
||||
/// which is kept from before the rename (compatibility shim, docs/compatibility.md).
|
||||
const APP_ID: &str = "ai.storyteller.solvecraft";
|
||||
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
|
||||
const APP_ID: &str = solidcraft_engine::io::APP_ID;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
adopt_legacy_settings();
|
||||
@@ -408,14 +418,14 @@ mod msaa_tests {
|
||||
std::fs::create_dir_all(&legacy_eframe).unwrap();
|
||||
std::fs::write(legacy.join("graphics.json"), r#"{"msaa":8}"#).unwrap();
|
||||
std::fs::write(legacy_eframe.join("app.ron"), "(prefs)").unwrap();
|
||||
super::adopt_settings(¤t, &legacy, Some(&legacy_eframe)).unwrap();
|
||||
super::adopt_settings(¤t, None, &legacy, Some(&legacy_eframe)).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(current.join("graphics.json")).unwrap(), r#"{"msaa":8}"#);
|
||||
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(prefs)");
|
||||
assert!(legacy.join("graphics.json").is_file() && legacy_eframe.join("app.ron").is_file());
|
||||
// Later launches leave the current settings alone.
|
||||
std::fs::write(current.join("app.ron"), "(mine)").unwrap();
|
||||
std::fs::remove_file(current.join("graphics.json")).unwrap();
|
||||
super::adopt_settings(¤t, &legacy, Some(&legacy_eframe)).unwrap();
|
||||
super::adopt_settings(¤t, None, &legacy, Some(&legacy_eframe)).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(mine)");
|
||||
assert!(!current.join("graphics.json").exists());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
|
||||
@@ -147,7 +147,7 @@ impl Document {
|
||||
.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)))
|
||||
.or_else(|| self.materials.get(name).and_then(|material| self.material_look(material)))
|
||||
}
|
||||
/// Effective body opacity, including paint carried from an imported body.
|
||||
pub fn model_body_opacity(&self, body: &crate::ModelBody) -> f64 {
|
||||
@@ -168,7 +168,7 @@ impl Document {
|
||||
let c = self.body_component(name, feature);
|
||||
(c != 0).then(|| self.appearances.components.get(&c).cloned()).flatten()
|
||||
})
|
||||
.or_else(|| self.materials.get(name).and_then(|m| material_look(m)))
|
||||
.or_else(|| self.materials.get(name).and_then(|m| self.material_look(m)))
|
||||
}
|
||||
|
||||
/// Face appearances on a body: (point on the face, look).
|
||||
|
||||
@@ -263,7 +263,8 @@ impl FeatureKind {
|
||||
dir(direction);
|
||||
}
|
||||
FeatureKind::Thread { face, .. } => pt(face),
|
||||
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SheetFlange { edges, .. } => edges.iter_mut().for_each(|e| pt(&mut e.point)),
|
||||
FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SheetConvert { face, .. } => pt(face),
|
||||
FeatureKind::SheetFold { a, b, fixed, .. } => {
|
||||
pt(a);
|
||||
|
||||
@@ -84,6 +84,10 @@ pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<St
|
||||
if let Some(old) = crate::naming::legacy_name(name) {
|
||||
return format!("{}{}", crate::naming::LEGACY_MARK, remap_topology(old, ids, bodies));
|
||||
}
|
||||
// A flat face of a sheet: `sheet:<body>:<panel>:<side>`, the panel a flange's feature token.
|
||||
if let Some((body, face)) = crate::sheet::split_sheet_face_name(name) {
|
||||
return crate::sheet::sheet_face_name(bodies.get(body).map_or(body, String::as_str), &remap_topology(face, 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:")
|
||||
@@ -464,6 +468,14 @@ mod clone_tests {
|
||||
assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label");
|
||||
}
|
||||
#[test]
|
||||
fn a_sheet_face_follows_its_body_and_its_flange() {
|
||||
let ids = BTreeMap::from([(1, 2), (10, 20)]);
|
||||
let bodies = BTreeMap::from([("Body1".into(), "Body1 (copy)".into())]);
|
||||
assert_eq!(remap_topology("sheet:Body1:F1.0:top", &ids, &bodies), "sheet:Body1 (copy):F2.0:top");
|
||||
assert_eq!(remap_topology("sheet:Body1:base:bottom", &ids, &bodies), "sheet:Body1 (copy):base:bottom");
|
||||
assert_eq!(remap_topology("sheet:Other:F10.1:top", &ids, &bodies), "sheet:Other:F20.1:top");
|
||||
}
|
||||
#[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");
|
||||
|
||||
@@ -91,17 +91,14 @@ pub fn appearance_cell(value: &str) -> Result<Option<crate::appearance::Look>> {
|
||||
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 {
|
||||
/// Parse a physical material cell (a built-in material or one defined in this design),
|
||||
/// returning None for the default material.
|
||||
pub fn material_cell(&self, value: &str) -> Result<Option<String>> {
|
||||
let name = self.material_name(value).ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
|
||||
Ok((name != "Default").then_some(name))
|
||||
}
|
||||
|
||||
/// 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]) {
|
||||
@@ -199,7 +196,7 @@ impl Document {
|
||||
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)? {
|
||||
Column::Material(body) => match self.material_cell(v)? {
|
||||
Some(material) => {
|
||||
next.materials.insert(body.clone(), material);
|
||||
}
|
||||
@@ -274,7 +271,7 @@ impl Document {
|
||||
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::Material(body) => self.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),
|
||||
|
||||
@@ -151,11 +151,41 @@ pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solidcraft_k
|
||||
#[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 },
|
||||
Box {
|
||||
axis: u8,
|
||||
high: bool,
|
||||
},
|
||||
/// A cylinder primitive's cap: `high` is its `top` (the far end), else its `bottom`.
|
||||
CylinderCap {
|
||||
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,
|
||||
},
|
||||
/// A flat face known by its exact history name (the target's `name`, without piece
|
||||
/// numbers): what the kernel's face history says it came from, followed through every
|
||||
/// feature since (a cut wall `F5:side:l1`, a chamfer `F7:blend:…`, a pattern copy
|
||||
/// `F4:top@F6.2`, a face a join merged from two features). Used where no construction
|
||||
/// certificate above names the face uniquely. Its plane is the face's exact current plane in
|
||||
/// the frame [`lineage_frame`] gives it. See docs/face-attachments.md.
|
||||
Lineage,
|
||||
}
|
||||
|
||||
impl FaceCertificate {
|
||||
@@ -177,6 +207,7 @@ impl FaceCertificate {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let valid = match self {
|
||||
Self::Box { axis, .. } => *axis <= 2,
|
||||
Self::CylinderCap { .. } => true,
|
||||
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 } => {
|
||||
@@ -186,8 +217,9 @@ impl FaceCertificate {
|
||||
&& 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()
|
||||
*source_producer != 0 && !matches!(source.as_ref(), Self::ShellPlanar { .. } | Self::Lineage) && source.validate().is_ok()
|
||||
}
|
||||
Self::Lineage => true,
|
||||
};
|
||||
if valid { Ok(()) } else { Err(DocError::Invalid("invalid face source certificate".into())) }
|
||||
}
|
||||
@@ -206,6 +238,11 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()>
|
||||
&& 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("cylinder_cap") => {
|
||||
fields.len() == 2
|
||||
&& fields.keys().all(|key| ["kind", "high"].contains(&key.as_str()))
|
||||
&& 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()))
|
||||
@@ -229,13 +266,16 @@ pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()>
|
||||
.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("lineage") => fields.len() == 1,
|
||||
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"))
|
||||
source
|
||||
.as_object()
|
||||
.is_some_and(|source| !matches!(source.get("kind").and_then(serde_json::Value::as_str), Some("shell_planar" | "lineage")))
|
||||
&& validate_certificate_json(source).is_ok()
|
||||
})
|
||||
}
|
||||
@@ -252,18 +292,26 @@ pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> R
|
||||
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::CylinderCap { high } => format!("cylinder:{}", cylinder_cap_role(*high)),
|
||||
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())),
|
||||
FaceCertificate::Lineage => return Err(DocError::Invalid("a shell's source needs a construction certificate".into())),
|
||||
};
|
||||
Ok(format!("shell:{}:{role}", if inner { "inner" } else { "outer" }))
|
||||
}
|
||||
|
||||
/// A cylinder cap's role, as the cylinder's face names call it.
|
||||
pub(crate) fn cylinder_cap_role(high: bool) -> &'static str {
|
||||
if high { "top" } else { "bottom" }
|
||||
}
|
||||
|
||||
pub(crate) fn flat_source_certificate(source: &solidcraft_kernel::FaceSource) -> Option<(u64, FaceCertificate)> {
|
||||
use solidcraft_kernel::FaceSource;
|
||||
match source {
|
||||
FaceSource::Box { producer: Some(producer), axis, high, .. } => Some((*producer, FaceCertificate::Box { axis: *axis, high: *high })),
|
||||
FaceSource::CylinderCap { producer, high, .. } => Some((*producer, FaceCertificate::CylinderCap { high: *high })),
|
||||
FaceSource::CertifiedExtrudeSide { producer, sketch, curve, .. } => {
|
||||
Some((*producer, FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() }))
|
||||
}
|
||||
@@ -283,6 +331,9 @@ pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solidc
|
||||
(FaceCertificate::Box { axis, high }, FaceSource::Box { producer: Some(current), axis: current_axis, high: current_high, .. }) => {
|
||||
producer == *current && axis == current_axis && high == current_high
|
||||
}
|
||||
(FaceCertificate::CylinderCap { high }, FaceSource::CylinderCap { producer: current, high: current_high, .. }) => {
|
||||
producer == *current && high == current_high
|
||||
}
|
||||
(
|
||||
FaceCertificate::ExtrudeSide { sketch, curve },
|
||||
FaceSource::CertifiedExtrudeSide { producer: current, sketch: current_sketch, curve: current_curve, .. },
|
||||
@@ -309,7 +360,9 @@ pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solidc
|
||||
/// 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>)>,
|
||||
/// Each analytic curve's id, whether it is projected (linked) reference geometry, and its
|
||||
/// segments.
|
||||
segments: Vec<(&'a str, bool, Vec<Seg2>)>,
|
||||
}
|
||||
|
||||
impl<'a> ExactSketchCurves<'a> {
|
||||
@@ -340,22 +393,33 @@ impl<'a> ExactSketchCurves<'a> {
|
||||
} else {
|
||||
sketch.sketch.segs(index)
|
||||
};
|
||||
segments.push((curve.id.as_str(), originals));
|
||||
segments.push((curve.id.as_str(), curve.link.is_some(), originals));
|
||||
}
|
||||
Self { segments }
|
||||
}
|
||||
|
||||
/// The one sketch curve a generated segment lies on. A curve drawn over projected
|
||||
/// reference geometry (a profile traced over a face's live boundary) is that segment's
|
||||
/// source: the projection only marks where it was drawn. Two drawn curves, or two
|
||||
/// projections, over the same segment stay ambiguous.
|
||||
pub(crate) fn find(&self, generated: &Seg2) -> Option<String> {
|
||||
let mut found = None;
|
||||
for (id, originals) in &self.segments {
|
||||
let mut drawn = None;
|
||||
let mut projected = None;
|
||||
for (id, linked, originals) in &self.segments {
|
||||
if originals.iter().any(|original| exact_segment_contains(original, generated)) {
|
||||
if found.is_some() {
|
||||
return None;
|
||||
let slot = if *linked { &mut projected } else { &mut drawn };
|
||||
if slot.is_some() {
|
||||
*slot = Some(None);
|
||||
} else {
|
||||
*slot = Some(Some(*id));
|
||||
}
|
||||
found = Some(*id);
|
||||
}
|
||||
}
|
||||
found.map(str::to_owned)
|
||||
match (drawn, projected) {
|
||||
(Some(id), _) => id.map(str::to_owned),
|
||||
(None, Some(id)) => id.map(str::to_owned),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,7 +475,8 @@ impl Document {
|
||||
| Some(solidcraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. })
|
||||
| Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. })
|
||||
| Some(solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), .. })
|
||||
| Some(solidcraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer,
|
||||
| Some(solidcraft_kernel::FaceSource::Box { producer: Some(producer), .. })
|
||||
| Some(solidcraft_kernel::FaceSource::CylinderCap { producer, .. }) => *producer,
|
||||
_ => name
|
||||
.strip_prefix('F')
|
||||
.and_then(|name| name.split_once(':'))
|
||||
@@ -481,6 +546,11 @@ impl Document {
|
||||
target.name = format!("F{producer}:{}{axis_name}", if *high { "+" } else { "-" });
|
||||
target.certificate = Some(FaceCertificate::Box { axis: *axis, high: *high });
|
||||
}
|
||||
Some(solidcraft_kernel::FaceSource::CylinderCap { producer, high, .. }) => {
|
||||
target.origin_feature = *producer;
|
||||
target.name = format!("F{producer}:{}", cylinder_cap_role(*high));
|
||||
target.certificate = Some(FaceCertificate::CylinderCap { high: *high });
|
||||
}
|
||||
Some(solidcraft_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));
|
||||
@@ -493,6 +563,153 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame a [`FaceCertificate::Lineage`] face's plane is given, from the plane alone (never
|
||||
/// from how a kernel parametrizes the surface, so it survives rebuilds and kernel changes): its
|
||||
/// origin where the body's definition origin projects onto the plane, its x axis the first of
|
||||
/// the definition's X, Y and Z axes that is not steep to the plane (`|cos| < 0.6`; one always
|
||||
/// is), projected onto it. A face that moves along its normal keeps the frame where it was;
|
||||
/// one that moves within its plane (a Move feature) keeps it too, as a sketch on a face does
|
||||
/// in the definition's coordinates.
|
||||
pub fn lineage_frame(body: &ModelBody, plane: &solidcraft_geom::Plane) -> Option<solidcraft_geom::Plane> {
|
||||
use solidcraft_geom::Vec3;
|
||||
let n = plane.normal();
|
||||
let m = &body.source_transform;
|
||||
let origin = crate::apply_point(m, Vec3::ZERO);
|
||||
let axis = [Vec3::X, Vec3::Y, Vec3::Z].into_iter().filter_map(|a| crate::apply_vector(m, a).normalized()).find(|a| a.dot(n).abs() < 0.6)?;
|
||||
let x = axis - n * axis.dot(n);
|
||||
let origin = origin - n * (origin - plane.origin).dot(n);
|
||||
solidcraft_geom::Plane::new(origin, x, n.cross(x))
|
||||
}
|
||||
|
||||
/// Whether two exact planes are one oriented plane (pieces of one face).
|
||||
fn same_oriented_plane(a: &solidcraft_geom::Plane, b: &solidcraft_geom::Plane, scale: f64) -> bool {
|
||||
a.normal().dot(b.normal()) >= 1. - 1e-8 && (b.origin - a.origin).dot(a.normal()).abs() <= 1e-7 * (1. + scale)
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The exact plane an anchor on face `index` of `body` is measured in: the construction
|
||||
/// certificate's plane, or for a [`FaceCertificate::Lineage`] face its current exact plane in
|
||||
/// the [`lineage_frame`].
|
||||
pub fn anchor_face_plane(&self, body: &ModelBody, index: usize, certificate: Option<&FaceCertificate>) -> Option<solidcraft_geom::Plane> {
|
||||
match certificate {
|
||||
Some(FaceCertificate::Lineage) => body.body.planar_face(index).and_then(|plane| lineage_frame(body, &plane)),
|
||||
_ => body.body.face_plane(index),
|
||||
}
|
||||
}
|
||||
|
||||
/// A target for flat face `index` of `body` by its exact history name
|
||||
/// ([`FaceCertificate::Lineage`]), when that name follows exact history, names a feature of
|
||||
/// the design and finds this face (with its other pieces, all on one oriented plane).
|
||||
pub(crate) fn lineage_face_target(
|
||||
&self,
|
||||
body: &ModelBody,
|
||||
index: usize,
|
||||
point: solidcraft_geom::Vec3,
|
||||
) -> Option<crate::config::ConfigFaceTarget> {
|
||||
let names = crate::naming::names(body);
|
||||
if !names.verified(index) {
|
||||
return None;
|
||||
}
|
||||
let name = crate::naming::strip_piece(names.faces.get(index)?).to_string();
|
||||
let origin = name.strip_prefix('F')?.split_once(':')?.0.parse::<u64>().ok()?;
|
||||
let inverse = crate::mat_inverse(&body.source_transform)?;
|
||||
let target = crate::config::ConfigFaceTarget {
|
||||
body: body.definition_name().into(),
|
||||
origin_feature: origin,
|
||||
name,
|
||||
point: crate::apply_point(&inverse, point),
|
||||
certificate: Some(FaceCertificate::Lineage),
|
||||
};
|
||||
self.configuration_face_indices(body, &target).ok()?.contains(&index).then_some(target)
|
||||
}
|
||||
|
||||
/// The faces of `body` an exact history name finds: those named `name` or a piece of it, else
|
||||
/// (a later join merged it into another face) the face that answers to it. Every one must
|
||||
/// follow exact history and lie on one oriented plane.
|
||||
pub(crate) fn lineage_face_indices(&self, body: &ModelBody, name: &str) -> Result<Vec<usize>> {
|
||||
let names = crate::naming::names(body);
|
||||
let mut indices: Vec<usize> =
|
||||
names.faces.iter().enumerate().filter(|(_, face)| crate::naming::strip_piece(face) == name).map(|(index, _)| index).collect();
|
||||
if indices.is_empty() {
|
||||
indices =
|
||||
names.info.iter().enumerate().filter(|(_, info)| info.aliases.iter().any(|alias| alias == name)).map(|(index, _)| index).collect();
|
||||
}
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown(format!("face `{name}` on `{}` no longer exists", body.name)));
|
||||
}
|
||||
self.coplanar_pieces(body, &indices, |index| names.verified(index))?;
|
||||
Ok(indices)
|
||||
}
|
||||
|
||||
/// Check that `indices` are pieces of one flat face: each `exact` and planar, all on one
|
||||
/// oriented plane, and not split on purpose (Split Face: its pieces are separate faces, so
|
||||
/// a pick on one never stands for the whole; docs/face-attachments.md).
|
||||
pub(crate) fn coplanar_pieces(&self, body: &ModelBody, indices: &[usize], exact: impl Fn(usize) -> bool) -> Result<()> {
|
||||
let scale = body.body.size();
|
||||
let mut first = None;
|
||||
for index in indices {
|
||||
if !exact(*index) {
|
||||
return Err(DocError::Invalid("this face's history is not exact".into()));
|
||||
}
|
||||
let plane = body.body.planar_face(*index).ok_or_else(|| DocError::Invalid("attached face is not flat".into()))?;
|
||||
match &first {
|
||||
None => first = Some(plane),
|
||||
Some(first) if same_oriented_plane(first, &plane, scale) => {}
|
||||
Some(_) => return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into())),
|
||||
}
|
||||
}
|
||||
if let (Some(plane), true) = (first, indices.len() > 1) {
|
||||
let tolerance = 1e-6 * (1. + scale);
|
||||
let on_plane = |line: &Vec<solidcraft_geom::Vec3>| {
|
||||
!line.is_empty() && line.iter().all(|p| (*p - plane.origin).dot(plane.normal()).abs() <= tolerance)
|
||||
};
|
||||
if body.body.split_lines().is_some_and(|lines| lines.iter().any(on_plane)) {
|
||||
return Err(DocError::Invalid(
|
||||
"this face was split on purpose (Split Face): pick an exact face piece's own plane with fixed:true".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Of several faces carrying one construction certificate of feature `origin`, the pieces of
|
||||
/// that feature's own face: copies a pattern or mirror made carry their seed's certificate
|
||||
/// but are named as copies (`…@F<pattern>.<instance>`, docs/face-attachments.md), and pieces
|
||||
/// a later feature split the face into share its exact name and plane.
|
||||
pub(crate) fn certified_pieces(&self, body: &ModelBody, indices: Vec<usize>, origin: u64) -> Result<Vec<usize>> {
|
||||
if indices.len() < 2 {
|
||||
return Ok(indices);
|
||||
}
|
||||
let names = crate::naming::names(body);
|
||||
let own = format!("F{origin}:");
|
||||
let stem = |index: usize| names.faces.get(index).map(|name| crate::naming::strip_piece(name));
|
||||
let seed: Vec<usize> =
|
||||
indices.into_iter().filter(|index| stem(*index).is_some_and(|name| name.starts_with(&own) && !name.contains('@'))).collect();
|
||||
let ambiguous = || DocError::Unknown("attached planar face certificate is missing or ambiguous".into());
|
||||
let first = seed.first().and_then(|index| stem(*index)).ok_or_else(ambiguous)?;
|
||||
if seed.iter().any(|index| stem(*index) != Some(first)) {
|
||||
return Err(ambiguous());
|
||||
}
|
||||
// The certificate is exact; the names only tell the seed from its copies here.
|
||||
self.coplanar_pieces(body, &seed, |_| true).map_err(|_| ambiguous())?;
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
/// Of several faces carrying one certificate of feature `origin`, those that are not a
|
||||
/// pattern's or mirror's copies (named `…@F<pattern>.<instance>`), when any is not.
|
||||
pub(crate) fn without_copies(&self, body: &ModelBody, indices: Vec<usize>) -> Vec<usize> {
|
||||
if indices.len() < 2 {
|
||||
return indices;
|
||||
}
|
||||
let names = crate::naming::names(body);
|
||||
let copy = |index: &usize| names.faces.get(*index).is_some_and(|name| crate::naming::strip_piece(name).contains('@'));
|
||||
if indices.iter().all(copy) {
|
||||
return indices;
|
||||
}
|
||||
indices.into_iter().filter(|index| !copy(index)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn payload_digest(payload: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(payload.as_bytes()))
|
||||
@@ -512,6 +729,31 @@ mod tests {
|
||||
SolvedSketch { feature: 1, name: "sources".into(), plane: solidcraft_geom::Plane::XY, sketch, report, profiles: Vec::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_cap_certificate_json_is_exact() {
|
||||
for high in [true, false] {
|
||||
let valid = json!({"kind":"cylinder_cap","high":high});
|
||||
assert!(validate_certificate_json(&valid).is_ok());
|
||||
let certificate: FaceCertificate = serde_json::from_value(valid.clone()).unwrap();
|
||||
assert_eq!(certificate, FaceCertificate::CylinderCap { high });
|
||||
assert_eq!(serde_json::to_value(&certificate).unwrap(), valid);
|
||||
}
|
||||
let shelled = json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"cylinder_cap","high":false},"inner":true});
|
||||
assert!(validate_certificate_json(&shelled).is_ok());
|
||||
let certificate: FaceCertificate = serde_json::from_value(shelled).unwrap();
|
||||
assert_eq!(shell_certificate_role(&FaceCertificate::CylinderCap { high: false }, true).unwrap(), "shell:inner:cylinder:bottom");
|
||||
assert!(certificate.validate().is_ok());
|
||||
for bad in [
|
||||
json!({"kind":"cylinder_cap"}),
|
||||
json!({"kind":"cylinder_cap","high":1}),
|
||||
json!({"kind":"cylinder_cap","high":null}),
|
||||
json!({"kind":"cylinder_cap","high":true,"axis":2}),
|
||||
json!({"kind":"cylinder_cap","high":true,"extra":[[[]]]}),
|
||||
] {
|
||||
assert!(validate_certificate_json(&bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_sources_keep_exact_identity_and_reject_ambiguity() {
|
||||
use solidcraft_geom::Vec2;
|
||||
@@ -537,6 +779,29 @@ mod tests {
|
||||
assert_eq!(ExactSketchCurves::new(&moved).find(&partial), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_drawn_over_a_projected_boundary_is_the_source() {
|
||||
use solidcraft_geom::Vec2;
|
||||
// A face sketch's live boundary, with a profile edge traced over it (issue #489).
|
||||
let mut sketch = solidcraft_sketch::Sketch::new();
|
||||
let projected = sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("edge")).unwrap();
|
||||
sketch.curves[projected].link = Some("boundary".into());
|
||||
let sketch = solved(sketch);
|
||||
let edge = Seg2::Line { a: Vec2::new(10., 0.), b: Vec2::ZERO };
|
||||
assert_eq!(ExactSketchCurves::new(&sketch).find(&edge).as_deref(), Some("edge"), "a lone projection is its own source");
|
||||
let mut traced = sketch.clone();
|
||||
traced.sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("drawn")).unwrap();
|
||||
assert_eq!(ExactSketchCurves::new(&traced).find(&edge).as_deref(), Some("drawn"));
|
||||
// Two drawn curves over it, or two projections, stay ambiguous.
|
||||
let mut twice = traced.clone();
|
||||
twice.sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("again")).unwrap();
|
||||
assert_eq!(ExactSketchCurves::new(&twice).find(&edge), None);
|
||||
let mut projections = sketch.clone();
|
||||
let other = projections.sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("other")).unwrap();
|
||||
projections.sketch.curves[other].link = Some("edge link".into());
|
||||
assert_eq!(ExactSketchCurves::new(&projections).find(&edge), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual optimized certification microbenchmark; no timing assertion"]
|
||||
fn benchmark_prepared_extrude_sources() {
|
||||
|
||||
@@ -253,6 +253,7 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
sheets: 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");
|
||||
@@ -376,6 +377,7 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
sheets: vec![],
|
||||
})));
|
||||
let t = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let at = Vec3::new(34. + 6. * t, 24. + 6. * t, 1.);
|
||||
@@ -515,6 +517,7 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
sheets: vec![],
|
||||
})));
|
||||
let at = Vec3::new(0., 15., 1.);
|
||||
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
|
||||
@@ -529,7 +532,15 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
|
||||
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");
|
||||
// The Join leaves Case's -x wall on its own support, so the wall keeps its exact box
|
||||
// identity through the Join and the Cut: one face again, no longer the saved piece.
|
||||
assert!(
|
||||
matches!(
|
||||
current.body.face_sources().and_then(|sources| sources.get(merged)).cloned().flatten(),
|
||||
Some(solidcraft_kernel::FaceSource::Box { axis: 0, high: false, .. })
|
||||
),
|
||||
"the merged wall carries its exact construction identity"
|
||||
);
|
||||
assert!(!current_names.contains(&former));
|
||||
let mut sketch = Sketch::new();
|
||||
sketch.add_link(LinkKind::Project, LinkSource::Face { body: "Case".into(), at }, &resolved.geom).unwrap();
|
||||
|
||||
@@ -108,9 +108,10 @@ impl Document {
|
||||
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, .. }) => {
|
||||
(FaceCertificate::Box { .. }, FeatureKind::Box { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => true,
|
||||
(FaceCertificate::CylinderCap { .. }, FeatureKind::Cylinder { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => true,
|
||||
(FaceCertificate::ExtrudeCap { sketch, .. }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => sketch == current && extent.taper.is_none(),
|
||||
(FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
|
||||
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, solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Circle { .. } | solidcraft_sketch::CurveKind::Arc { .. }))))
|
||||
}
|
||||
@@ -129,18 +130,22 @@ impl Document {
|
||||
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, .. }) => {
|
||||
(super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
|
||||
["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, .. }) => {
|
||||
(super::FaceCertificate::CylinderCap { high }, FeatureKind::Cylinder { operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
|
||||
role == super::cylinder_cap_role(*high)
|
||||
}
|
||||
(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
|
||||
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, .. }) => {
|
||||
(super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody | crate::Operation::Join, .. }) => {
|
||||
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, solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Circle { .. } | solidcraft_sketch::CurveKind::Arc { .. }))))
|
||||
}
|
||||
(super::FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => role.strip_prefix("face:").is_some_and(|index| index.parse::<usize>().is_ok()),
|
||||
(super::FaceCertificate::Lineage, _) => !role.is_empty(),
|
||||
(super::FaceCertificate::ShellPlanar { source_producer, source, inner }, FeatureKind::Shell { .. }) => {
|
||||
*source_producer != target.origin_feature
|
||||
&& self.flat_certificate_producer_matches(*source_producer, source)
|
||||
@@ -181,6 +186,9 @@ impl Document {
|
||||
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::Lineage) = &target.certificate {
|
||||
return self.lineage_face_indices(body, &target.name);
|
||||
}
|
||||
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(solidcraft_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)
|
||||
@@ -192,7 +200,10 @@ impl Document {
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) {
|
||||
if matches!(
|
||||
target.certificate,
|
||||
Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::CylinderCap { .. } | super::FaceCertificate::ExtrudeCap { .. })
|
||||
) {
|
||||
let indices: Vec<_> = body
|
||||
.body
|
||||
.face_sources()
|
||||
@@ -205,6 +216,10 @@ impl Document {
|
||||
Some(super::FaceCertificate::Box { axis, high }),
|
||||
Some(solidcraft_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::CylinderCap { high }),
|
||||
Some(solidcraft_kernel::FaceSource::CylinderCap { producer, high: current_high, .. }),
|
||||
) => *producer == target.origin_feature && high == current_high,
|
||||
(
|
||||
Some(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }),
|
||||
Some(solidcraft_kernel::FaceSource::CertifiedExtrudeCap {
|
||||
@@ -227,10 +242,10 @@ impl Document {
|
||||
matched.then_some(index)
|
||||
})
|
||||
.collect();
|
||||
if indices.len() != 1 {
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown("attached planar face certificate is missing or ambiguous".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
return self.certified_pieces(body, indices, target.origin_feature);
|
||||
}
|
||||
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)| {
|
||||
@@ -239,7 +254,7 @@ impl Document {
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown("constructed side certificate no longer exists".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
return Ok(self.without_copies(body, 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)| {
|
||||
@@ -248,7 +263,7 @@ impl Document {
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown("immutable import face certificate no longer exists".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
return Ok(self.without_copies(body, indices));
|
||||
}
|
||||
let names = crate::naming::face_names(body);
|
||||
let indices: Vec<usize> =
|
||||
|
||||
+200
-6
@@ -269,6 +269,73 @@ impl EmbossFace {
|
||||
}
|
||||
}
|
||||
|
||||
/// One edge of an edge flange feature: a point on the sheet's edge and, where given, this
|
||||
/// edge's own angle and height (expressions) in place of the feature's. In a design file an
|
||||
/// edge with neither is the point `[x, y, z]`, as every edge was before; one with its own
|
||||
/// values is `{"point": [x, y, z], "angle": "…", "height": "…"}`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FlangeEdge {
|
||||
pub point: Vec3,
|
||||
pub angle: Option<String>,
|
||||
pub height: Option<String>,
|
||||
}
|
||||
|
||||
impl FlangeEdge {
|
||||
/// An edge that takes the feature's angle and height.
|
||||
pub fn at(point: Vec3) -> Self {
|
||||
Self { point, angle: None, height: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for FlangeEdge {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeMap;
|
||||
if self.angle.is_none() && self.height.is_none() {
|
||||
return self.point.serialize(s);
|
||||
}
|
||||
let mut m = s.serialize_map(None)?;
|
||||
m.serialize_entry("point", &self.point)?;
|
||||
if let Some(a) = &self.angle {
|
||||
m.serialize_entry("angle", a)?;
|
||||
}
|
||||
if let Some(h) = &self.height {
|
||||
m.serialize_entry("height", h)?;
|
||||
}
|
||||
m.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FlangeEdge {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
use serde::de::value::{MapAccessDeserializer, SeqAccessDeserializer};
|
||||
/// The object form; a key this build doesn't know is refused, not dropped.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Own {
|
||||
point: Vec3,
|
||||
#[serde(default)]
|
||||
angle: Option<String>,
|
||||
#[serde(default)]
|
||||
height: Option<String>,
|
||||
}
|
||||
struct Edge;
|
||||
impl<'de> serde::de::Visitor<'de> for Edge {
|
||||
type Value = FlangeEdge;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a point [x, y, z] or an edge {point, angle?, height?}")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, seq: A) -> std::result::Result<FlangeEdge, A::Error> {
|
||||
Vec3::deserialize(SeqAccessDeserializer::new(seq)).map(FlangeEdge::at)
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> std::result::Result<FlangeEdge, A::Error> {
|
||||
let o = Own::deserialize(MapAccessDeserializer::new(map))?;
|
||||
Ok(FlangeEdge { point: o.point, angle: o.angle, height: o.height })
|
||||
}
|
||||
}
|
||||
d.deserialize_any(Edge)
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature definitions.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -659,9 +726,10 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
reverse: bool,
|
||||
},
|
||||
/// Edge flanges on sheet edges (points on the top or bottom edges).
|
||||
/// Edge flanges on sheet edges (points on the top or bottom edges). `height` and `angle`
|
||||
/// hold for every edge that doesn't carry its own (see [`FlangeEdge`]).
|
||||
SheetFlange {
|
||||
edges: Vec<Vec3>,
|
||||
edges: Vec<FlangeEdge>,
|
||||
height: String,
|
||||
angle: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -874,6 +942,10 @@ pub enum FeatureKind {
|
||||
rotate_axis: Option<Vec3>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
angle: Option<String>,
|
||||
/// When set, the original bodies are kept and the transform is applied to a new copy
|
||||
/// of each instead of moving them in place.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
copy: bool,
|
||||
},
|
||||
/// A non-parametric base feature: bodies imported from a STEP file. The STEP text is kept
|
||||
/// in the design (it is the B-rep), so the design reopens without the original file.
|
||||
@@ -1521,9 +1593,12 @@ impl FeatureKind {
|
||||
v.extend([length, draft, thickness].into_iter().flatten().map(String::as_str));
|
||||
}
|
||||
FeatureKind::SheetContour { distance, .. } => v.push(distance),
|
||||
FeatureKind::SheetFlange { height, angle, radius, .. } => {
|
||||
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
|
||||
v.extend([height.as_str(), angle]);
|
||||
v.extend(radius.iter().map(String::as_str));
|
||||
for e in edges {
|
||||
v.extend([&e.angle, &e.height].into_iter().flatten().map(String::as_str));
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetFold { angle, radius, .. } => {
|
||||
v.push(angle);
|
||||
@@ -1623,6 +1698,18 @@ pub struct StandardPart {
|
||||
pub length: Option<f64>,
|
||||
}
|
||||
|
||||
/// A component whose contents come from another design file (Insert Design): where that file
|
||||
/// is, and the Import feature in the component that holds its bodies (Update Links replaces
|
||||
/// them; Break Link keeps them and forgets the file).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DesignLink {
|
||||
/// The design file: relative to this design's folder once this design is saved, else as
|
||||
/// given (`/` separates folders).
|
||||
pub path: String,
|
||||
/// The Import feature holding the linked design's bodies.
|
||||
pub feature: u64,
|
||||
}
|
||||
|
||||
/// The document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Document {
|
||||
@@ -1656,6 +1743,9 @@ pub struct Document {
|
||||
/// Components that are standard parts (by component id): what to rebuild them from.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub parts: std::collections::BTreeMap<u64, StandardPart>,
|
||||
/// Components linked to another design file (by component id).
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub links: std::collections::BTreeMap<u64, DesignLink>,
|
||||
/// Configurations (rows of parameter values and suppressions).
|
||||
#[serde(default, skip_serializing_if = "crate::config::ConfigTable::is_empty")]
|
||||
pub configs: crate::config::ConfigTable,
|
||||
@@ -1671,6 +1761,9 @@ pub struct Document {
|
||||
/// Physical material per body (by body name); others use the default.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub materials: std::collections::BTreeMap<String, String>,
|
||||
/// Physical materials defined in this design (`material.create`), next to the built-in ones.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub user_materials: Vec<UserMaterial>,
|
||||
/// Appearance colour per body (by body name, sRGB); it overrides the material's look.
|
||||
#[serde(default, skip_serializing_if = "crate::appearance::Appearances::is_empty")]
|
||||
pub appearances: crate::appearance::Appearances,
|
||||
@@ -1690,6 +1783,38 @@ pub struct Document {
|
||||
/// Named views: saved cameras.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub named_views: Vec<NamedView>,
|
||||
/// What was hidden when the design was saved. A session takes it from here when the design
|
||||
/// opens and keeps the live state itself (`browser.visibility`: saved, not an undo step), so
|
||||
/// a document in a session leaves this empty; the session puts it back when it saves.
|
||||
#[serde(default, skip_serializing_if = "Visibility::is_empty")]
|
||||
pub visibility: Visibility,
|
||||
}
|
||||
|
||||
/// Items hidden or shown one by one. Saved with the design, but not an undo step.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Visibility {
|
||||
/// Bodies by name.
|
||||
pub hidden_bodies: Vec<String>,
|
||||
/// Exact occurrence placements hidden with all descendants.
|
||||
pub hidden_occurrences: Vec<Vec<u64>>,
|
||||
/// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction geometry, by name.
|
||||
pub hidden_origin: Vec<String>,
|
||||
/// Sketches by feature id.
|
||||
pub hidden_sketches: Vec<u64>,
|
||||
/// Finished sketches shown although a feature uses them.
|
||||
pub shown_sketches: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Visibility {
|
||||
/// Nothing hidden or shown one by one (the default view).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.hidden_bodies.is_empty()
|
||||
&& self.hidden_occurrences.is_empty()
|
||||
&& self.hidden_origin.is_empty()
|
||||
&& self.hidden_sketches.is_empty()
|
||||
&& self.shown_sketches.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved camera (orbit camera: target, yaw, pitch, distance; `fov` 0 for orthographic).
|
||||
@@ -1748,9 +1873,11 @@ impl Document {
|
||||
plastic: Default::default(),
|
||||
configs: Default::default(),
|
||||
parts: Default::default(),
|
||||
links: Default::default(),
|
||||
body_components: Default::default(),
|
||||
body_offsets: Default::default(),
|
||||
materials: Default::default(),
|
||||
user_materials: Vec::new(),
|
||||
appearances: Default::default(),
|
||||
favorites: Default::default(),
|
||||
canvases: Vec::new(),
|
||||
@@ -1758,6 +1885,7 @@ impl Document {
|
||||
browser_groups: Vec::new(),
|
||||
browser_order: Default::default(),
|
||||
named_views: Vec::new(),
|
||||
visibility: Visibility::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1790,7 +1918,46 @@ impl Document {
|
||||
|
||||
/// Density of a body's material (g/cm³).
|
||||
pub fn density(&self, body: &str) -> f64 {
|
||||
self.materials.get(body).and_then(|m| material_density(m)).unwrap_or(DEFAULT_DENSITY)
|
||||
self.materials.get(body).and_then(|m| self.material_density(m)).unwrap_or(DEFAULT_DENSITY)
|
||||
}
|
||||
|
||||
/// A material defined in this design, by name (case-insensitive).
|
||||
pub fn user_material(&self, name: &str) -> Option<&UserMaterial> {
|
||||
let name = name.trim();
|
||||
self.user_materials.iter().find(|m| m.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// A physical material's name as the library or this design spells it (case-insensitive
|
||||
/// lookup): built-in materials first, then the design's own.
|
||||
pub fn material_name(&self, name: &str) -> Option<String> {
|
||||
MATERIALS
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(name.trim()))
|
||||
.map(|(n, _)| n.to_string())
|
||||
.or_else(|| self.user_material(name).map(|m| m.name.clone()))
|
||||
}
|
||||
|
||||
/// Density of a physical material (g/cm³): built-in or defined in this design. A design
|
||||
/// material with an unusable density (a damaged file) has none.
|
||||
pub fn material_density(&self, name: &str) -> Option<f64> {
|
||||
material_density(name).or_else(|| self.user_material(name).map(|m| m.density).filter(|d| valid_density(*d)))
|
||||
}
|
||||
|
||||
/// The look a physical material gives a body without an appearance of its own.
|
||||
pub fn material_look(&self, name: &str) -> Option<crate::appearance::Look> {
|
||||
crate::appearance::material_look(name).or_else(|| {
|
||||
let mut look = self.user_material(name)?.appearance.clone()?;
|
||||
// A damaged file's opacity is shown opaque rather than trusted.
|
||||
if !(look.opacity.is_finite() && (0.0..=1.0).contains(&look.opacity)) {
|
||||
look.opacity = 1.0;
|
||||
}
|
||||
Some(look)
|
||||
})
|
||||
}
|
||||
|
||||
/// Every physical material's name: the built-in library, then the design's own.
|
||||
pub fn material_names(&self) -> Vec<String> {
|
||||
MATERIALS.iter().map(|(n, _)| n.to_string()).chain(self.user_materials.iter().map(|m| m.name.clone())).collect()
|
||||
}
|
||||
|
||||
/// The component a body belongs to: where it was moved, else its feature's.
|
||||
@@ -2210,18 +2377,25 @@ impl Document {
|
||||
/// 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.
|
||||
///
|
||||
/// `moved_from`: the stored sketch, when `sk` is that sketch with its links refreshed. A
|
||||
/// link's points are where their source is now; the sides and turns the solve keeps are
|
||||
/// those of the stored state ([`Sketch::positions_in`]), not the ones read beside a line
|
||||
/// that has moved past a point.
|
||||
pub fn solve_sketch(
|
||||
&self,
|
||||
vals: &std::collections::BTreeMap<String, expr::Value>,
|
||||
sk: &mut Sketch,
|
||||
stored: &solidcraft_sketch::DrivingValues,
|
||||
moved_from: Option<&Sketch>,
|
||||
) -> Result<solidcraft_sketch::SolveReport> {
|
||||
self.apply_dimension_values(vals, sk)?;
|
||||
let mut report = solidcraft_sketch::solve_after_value_edit(sk, stored);
|
||||
let was = |sk: &Sketch| moved_from.and_then(|from| sk.positions_in(from));
|
||||
let mut report = solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref());
|
||||
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() { solidcraft_sketch::solve(sk) } else { solidcraft_sketch::solve_after_value_edit(sk, stored) };
|
||||
report = if report.ok() { solidcraft_sketch::solve(sk) } else { solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref()) };
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
@@ -2268,6 +2442,26 @@ pub fn material_color(name: &str) -> Option<[u8; 3]> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A physical material defined in a design (`material.create`): its name, its density and,
|
||||
/// optionally, the look it gives bodies without an appearance of their own.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UserMaterial {
|
||||
pub name: String,
|
||||
/// Density (g/cm³).
|
||||
pub density: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub appearance: Option<crate::appearance::Look>,
|
||||
}
|
||||
|
||||
/// Largest density a material may have (g/cm³); the densest elements are about 22.6.
|
||||
pub const MAX_DENSITY: f64 = 1000.0;
|
||||
|
||||
/// A density a material can have: finite, above zero and at most [`MAX_DENSITY`] g/cm³.
|
||||
pub fn valid_density(d: f64) -> bool {
|
||||
d.is_finite() && d > 0.0 && d <= MAX_DENSITY
|
||||
}
|
||||
|
||||
/// Density of a built-in physical material (g/cm³).
|
||||
pub fn material_density(name: &str) -> Option<f64> {
|
||||
MATERIALS.iter().find(|(n, _)| n.eq_ignore_ascii_case(name.trim())).map(|(_, d)| *d)
|
||||
}
|
||||
|
||||
+262
-66
@@ -62,12 +62,14 @@ impl ModelBody {
|
||||
pub fn definition_name(&self) -> &str {
|
||||
self.source_name.as_deref().unwrap_or(&self.name)
|
||||
}
|
||||
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
|
||||
/// Display mesh, computed once: chord tolerance 1/1000 of the body size, and no facet of a
|
||||
/// curved face or edge turning more than [`kernel::DISPLAY_ANGLE`], so that a bend or a
|
||||
/// hole that is small beside the body is still drawn round.
|
||||
pub fn mesh(&self) -> Arc<Mesh> {
|
||||
self.mesh
|
||||
.get_or_init(|| {
|
||||
let tol = (self.body.size() * 1e-3).max(1e-3);
|
||||
Arc::new(self.body.display_mesh(tol).unwrap_or_default())
|
||||
Arc::new(self.body.display_mesh_angular(tol, kernel::DISPLAY_ANGLE).unwrap_or_default())
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
@@ -262,23 +264,38 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
|
||||
}
|
||||
|
||||
/// A constant fillet of `edges`; with [`FilletFit::Reduce`](crate::FilletFit::Reduce), a radius too
|
||||
/// large for the geometry around them is reduced to the largest that works for all of them, which
|
||||
/// is returned.
|
||||
/// large for the geometry around them is reduced where it doesn't fit, to the largest that works
|
||||
/// for each group of edges sharing a face; the radius per edge is returned.
|
||||
fn fillet_fitted(
|
||||
body: &kernel::Body,
|
||||
edges: &[Vec3],
|
||||
r: f64,
|
||||
fit: crate::FilletFit,
|
||||
) -> std::result::Result<(kernel::Body, Option<f64>), kernel::KernelError> {
|
||||
) -> std::result::Result<(kernel::Body, Option<Vec<f64>>), kernel::KernelError> {
|
||||
match kernel::fillet(body, edges, r) {
|
||||
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max(body, edges, r) {
|
||||
Some((used, b)) => Ok((b, Some(used))),
|
||||
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max_each(body, edges, r) {
|
||||
// Built at the radius asked for after all (the groups apart): nothing reduced.
|
||||
Some((used, b)) => Ok((b, used.iter().any(|u| *u < r).then_some(used))),
|
||||
None => Err(kernel::KernelError::TooLarge(why)),
|
||||
},
|
||||
other => other.map(|b| (b, None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The warning of a fillet whose radius `asked` was reduced to `used` (per edge).
|
||||
fn reduced_note(used: &[f64], asked: f64) -> String {
|
||||
let mut values: Vec<f64> = used.iter().copied().filter(|r| *r < asked).collect();
|
||||
values.sort_by(f64::total_cmp);
|
||||
values.dedup();
|
||||
let shown = values.iter().map(|v| show_mm(*v)).collect::<Vec<_>>().join(", ");
|
||||
let n = used.iter().filter(|r| **r < asked).count();
|
||||
if n == used.len() && values.len() == 1 {
|
||||
format!("the radius is reduced to {shown} mm, the largest that fits these edges")
|
||||
} else {
|
||||
format!("the radius is reduced to {shown} mm on {n} of {} edges, the largest that fits them", used.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// Add `note` to a feature's warning.
|
||||
fn append_warning(warning: &mut Option<String>, note: String) {
|
||||
*warning = Some(match warning.take() {
|
||||
@@ -294,9 +311,23 @@ fn show_mm(v: f64) -> String {
|
||||
}
|
||||
|
||||
impl ModelState {
|
||||
/// The radius fillet `feature` used when it reduced a radius too large for its edges.
|
||||
/// The smallest radius fillet `feature` used when it reduced a radius too large for its edges.
|
||||
pub fn fitted_radius(&self, feature: u64) -> Option<f64> {
|
||||
self.fitted.get(&feature).copied()
|
||||
self.fitted_range(feature).map(|(lo, _)| lo)
|
||||
}
|
||||
|
||||
/// The smallest and largest radius fillet `feature` used when it reduced a radius too large
|
||||
/// for some of its edges.
|
||||
pub fn fitted_range(&self, feature: u64) -> Option<(f64, f64)> {
|
||||
self.fitted.get(&feature)?.iter().fold(None, |acc, r| Some(acc.map_or((*r, *r), |(lo, hi): (f64, f64)| (lo.min(*r), hi.max(*r)))))
|
||||
}
|
||||
|
||||
/// Each edge fillet `feature` blended (a point on it) with the radius it used, when it
|
||||
/// reduced a radius too large for some of its edges.
|
||||
pub fn fitted_edges(&self, feature: u64) -> Option<Vec<(Vec3, f64)>> {
|
||||
let radii = self.fitted.get(&feature)?;
|
||||
let edges = self.blend_edges.get(&feature)?;
|
||||
Some(edges.iter().copied().zip(radii.iter().copied()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,9 +350,9 @@ pub struct ModelState {
|
||||
/// a pattern of the feature repeats these, not the points picked when it was made, which a
|
||||
/// parameter change may have moved off the edges.
|
||||
blend_edges: BTreeMap<u64, Vec<Vec3>>,
|
||||
/// The radius a fillet used when it reduced a radius too large for its edges (by feature id,
|
||||
/// see `FilletFit::Reduce`).
|
||||
fitted: BTreeMap<u64, f64>,
|
||||
/// The radius per edge a fillet used when it reduced a radius too large for some of its edges
|
||||
/// (by feature id, see `FilletFit::Reduce`).
|
||||
fitted: BTreeMap<u64, Vec<f64>>,
|
||||
}
|
||||
|
||||
impl ModelState {
|
||||
@@ -347,9 +378,11 @@ impl ModelState {
|
||||
[p.x, p.y, p.z].map(f64::to_bits).hash(&mut h);
|
||||
}
|
||||
}
|
||||
for (feature, r) in &self.fitted {
|
||||
for (feature, radii) in &self.fitted {
|
||||
feature.hash(&mut h);
|
||||
r.to_bits().hash(&mut h);
|
||||
for r in radii {
|
||||
r.to_bits().hash(&mut h);
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
|
||||
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
|
||||
@@ -362,7 +395,8 @@ impl ModelState {
|
||||
fn stamp(&mut self, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
|
||||
let feature = f.id;
|
||||
let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1;
|
||||
let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats });
|
||||
let sheets = if f.kind.is_sheet() { self.sheets.clone() } else { Vec::new() };
|
||||
let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats, sheets });
|
||||
// Pieces a cut left are named after their faces, not the kernel's shell order (unless
|
||||
// the feature was saved when they were named in that order).
|
||||
if !f.shell_piece_order {
|
||||
@@ -854,8 +888,11 @@ impl Model {
|
||||
let mut restored = 0;
|
||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||
let ctx = context_digest(doc, &vals);
|
||||
let watched = crate::progress::begin(doc.features.len());
|
||||
for (i, f) in doc.features.iter().enumerate() {
|
||||
let rolled_back = i >= marker;
|
||||
// Opening was cancelled: the rest is skipped and the result discarded.
|
||||
let cancelled = watched.step(i);
|
||||
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back);
|
||||
prev_fp = fp;
|
||||
if reuse
|
||||
@@ -871,7 +908,7 @@ impl Model {
|
||||
continue;
|
||||
}
|
||||
reuse = false;
|
||||
if f.suppressed || rolled_back {
|
||||
if f.suppressed || rolled_back || cancelled {
|
||||
out.push(FeatureResult {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
@@ -924,7 +961,14 @@ impl Model {
|
||||
let f = if !explicit_emboss && f.face_names.iter().any(|n| !n.is_empty()) {
|
||||
let mut g = f.clone();
|
||||
let mut notes = Vec::new();
|
||||
for (p, n) in g.kind.face_points_mut().into_iter().zip(&f.face_names) {
|
||||
// A hole's position is a place on its face, not a pick of the face: it follows
|
||||
// the face along the hole's axis and never slides sideways to the face's nearest
|
||||
// point when the face no longer reaches it.
|
||||
let hole_axis = match &f.kind {
|
||||
FeatureKind::Hole { direction, points: None, .. } => direction.normalized(),
|
||||
_ => None,
|
||||
};
|
||||
for (k, (p, n)) in g.kind.face_points_mut().into_iter().zip(&f.face_names).enumerate() {
|
||||
if n.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -933,7 +977,18 @@ impl Model {
|
||||
if let Some(note) = hit.note(n) {
|
||||
notes.push(note);
|
||||
}
|
||||
*p = hit.point;
|
||||
match (k, hole_axis) {
|
||||
(0, Some(axis)) => match hit.along(&state, *p, axis) {
|
||||
Some(q) => *p = q,
|
||||
// The place still lies on the face (the axis may run along it).
|
||||
None if hit.on_point => {}
|
||||
None => notes.push(format!(
|
||||
"the hole's position [{}, {}, {}] is no longer on face `{n}`: it stays where it was placed",
|
||||
p.x, p.y, p.z
|
||||
)),
|
||||
},
|
||||
_ => *p = hit.point,
|
||||
}
|
||||
}
|
||||
None => notes.push(format!("face `{n}` no longer exists; using the face at the picked point")),
|
||||
}
|
||||
@@ -1045,10 +1100,12 @@ pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region
|
||||
}
|
||||
let out: Vec<Region2> = match sel {
|
||||
// "All" means what was drawn: projected reference geometry (a face's auto-projected
|
||||
// edges) only counts when the drawn curves alone make no profile.
|
||||
// edges) only counts when the drawn curves alone make no profile. Text is drawn, and
|
||||
// its letters keep their counters as holes (the counters are not filled in).
|
||||
ProfileSel::All => {
|
||||
let drawn = solidcraft_sketch::find_drawn_profiles(&ss.sketch);
|
||||
if drawn.is_empty() { ps.iter().map(|p| p.region.clone()).collect() } else { drawn.into_iter().map(|p| p.region).collect() }
|
||||
let chosen = if drawn.is_empty() { ps.clone() } else { drawn };
|
||||
solidcraft_sketch::without_text_counters(&ss.sketch, chosen).into_iter().map(|p| p.region).collect()
|
||||
}
|
||||
ProfileSel::Indices { indices } => {
|
||||
let mut v = Vec::new();
|
||||
@@ -1121,6 +1178,25 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The k-th body of a body pattern: its stored name, else a copy of `source`. Once `body.rename`
|
||||
/// has stored this feature's names, a copy beyond them (the count was raised) is numbered after
|
||||
/// the highest stored `source (n)`, so it never takes a number freed by a rename (#372).
|
||||
/// Features without stored names keep the first free `source (n)`.
|
||||
fn pattern_copy_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> String {
|
||||
if f.body_names.get(k).is_some_and(|name| !name.trim().is_empty()) {
|
||||
return copy_body_name(state, f, k, source);
|
||||
}
|
||||
let base = if source.trim().is_empty() { "Body" } else { source.trim() };
|
||||
let prefix = format!("{base} (");
|
||||
let highest = f.body_names.iter().filter_map(|n| n.trim().strip_prefix(prefix.as_str())?.strip_suffix(')')?.parse::<u32>().ok()).max();
|
||||
match highest {
|
||||
Some(h) => {
|
||||
(u64::from(h) + 1..).map(|i| format!("{base} ({i})")).find(|n| state.body(n).is_none()).unwrap_or_else(|| unique_body_name(state, base))
|
||||
}
|
||||
None => unique_body_name(state, base),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_body_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> String {
|
||||
let base = f.body_names.get(k).filter(|name| !name.trim().is_empty()).map(String::as_str).unwrap_or(source);
|
||||
unique_body_name(state, base)
|
||||
@@ -1176,6 +1252,25 @@ fn intersection_tool_union(tools: Vec<Traced>) -> Result<Vec<Traced>> {
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
/// After a cut handled some sheet bodies as flat machining, apply it to the other targets.
|
||||
fn apply_beside_sheet_cut(st: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Operation, targets: &[String], handled: &[String]) -> Result<()> {
|
||||
let effective = if targets.is_empty() { &f.participants } else { targets };
|
||||
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
|
||||
let rest: Vec<String> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
!handled.contains(&b.name)
|
||||
&& (all || if effective.is_empty() { b.component == f.component || b.feature == f.id } else { effective.contains(&b.name) })
|
||||
})
|
||||
.map(|b| b.name.clone())
|
||||
.collect();
|
||||
if rest.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
apply_traced(st, f, tools, op, &rest)
|
||||
}
|
||||
|
||||
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
|
||||
apply_traced(state, f, tools.into_iter().map(Traced::unnamed).collect(), op, targets)
|
||||
}
|
||||
@@ -1305,7 +1400,9 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
});
|
||||
let live: std::collections::BTreeSet<&str> = state.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
state.threads.retain(|t| live.contains(t.body.as_str()));
|
||||
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)").
|
||||
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)"),
|
||||
// numbered on past names already taken (a body cut in pieces before).
|
||||
let mut taken: std::collections::BTreeSet<String> = state.bodies.iter().map(|b| b.name.clone()).collect();
|
||||
let mut split: Vec<ModelBody> = Vec::new();
|
||||
for mb in std::mem::take(&mut state.bodies) {
|
||||
if !affected.contains(&mb.name) || state.sheets.iter().any(|s| s.body == mb.name) {
|
||||
@@ -1321,7 +1418,12 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
|
||||
// (so pieces of one face in different bodies are told apart).
|
||||
let lineages = crate::lineage::Lineage::lumps(&mb.body, mb.lineage().as_deref(), &pieces);
|
||||
for (k, (p, lineage)) in pieces.into_iter().zip(lineages).enumerate() {
|
||||
let name = if k == 0 { mb.name.clone() } else { format!("{} ({k})", mb.name) };
|
||||
let name = if k == 0 {
|
||||
mb.name.clone()
|
||||
} else {
|
||||
(k..=k + taken.len()).map(|j| format!("{} ({j})", mb.name)).find(|n| !taken.contains(n)).unwrap_or_default()
|
||||
};
|
||||
taken.insert(name.clone());
|
||||
split.push(ModelBody::new(name, p, mb.feature).with_lineage(lineage));
|
||||
}
|
||||
}
|
||||
@@ -1446,7 +1548,10 @@ fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, target
|
||||
|
||||
/// Booleans struggle with coincident faces (a cut starting on the face it cuts, a boss
|
||||
/// starting on the face it joins). Extending the tool past such an end changes nothing when the
|
||||
/// space beyond is empty (cut) or already material (join), so do exactly that.
|
||||
/// space beyond is empty (cut) or already material (join), so do exactly that. Only an end
|
||||
/// that lies on a target face parallel to it moves, and by less than half the way to the next
|
||||
/// such face level: the samples can miss material beyond the end (a thin floor between a recess
|
||||
/// above and a ring pocket below), which a longer tool would cut through.
|
||||
fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi: f64, op: Operation, targets: &[String]) -> (f64, f64) {
|
||||
if !matches!(op, Operation::Cut | Operation::Join) || st.bodies.is_empty() {
|
||||
return (lo, hi);
|
||||
@@ -1461,18 +1566,40 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi:
|
||||
let samples = region_samples(r);
|
||||
let n = plane.normal();
|
||||
let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p));
|
||||
let ok_beyond = |h: f64, dir: f64| {
|
||||
// Heights of the targets' faces parallel to the profile plane.
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
let levels: Vec<f64> = meshes
|
||||
.iter()
|
||||
.flat_map(|m| {
|
||||
m.triangles.iter().filter_map(|t| {
|
||||
let [a, b, c] = m.tri(t)?;
|
||||
let normal = (b - a).cross(c - a).normalized()?;
|
||||
(normal.dot(n).abs() > 1.0 - 1e-9).then(|| (a - plane.origin).dot(n))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// How far the end at `h` may move in `dir`: zero unless a face lies on it, then short of
|
||||
// half the gap to the next face level beyond it.
|
||||
let room = |h: f64, dir: f64| {
|
||||
if !levels.iter().any(|z| (z - h).abs() < tol) {
|
||||
return 0.0;
|
||||
}
|
||||
let gap = levels.iter().map(|z| (z - h) * dir).filter(|g| *g > tol).fold(f64::INFINITY, f64::min);
|
||||
delta.min(gap * 0.5)
|
||||
};
|
||||
let ok_beyond = |h: f64, dir: f64, step: f64| {
|
||||
samples.iter().all(|s| {
|
||||
let base = plane.to_world(*s);
|
||||
[0.5, 1.0].iter().all(|k| {
|
||||
let p = base + n * (h + dir * delta * k);
|
||||
let p = base + n * (h + dir * step * k);
|
||||
let inside = inside_any(p);
|
||||
if op == Operation::Cut { !inside } else { inside }
|
||||
})
|
||||
})
|
||||
};
|
||||
let lo2 = if ok_beyond(lo, -1.0) { lo - delta } else { lo };
|
||||
let hi2 = if ok_beyond(hi, 1.0) { hi + delta } else { hi };
|
||||
let (down, up) = (room(lo, -1.0), room(hi, 1.0));
|
||||
let lo2 = if down > tol && ok_beyond(lo, -1.0, down) { lo - down } else { lo };
|
||||
let hi2 = if up > tol && ok_beyond(hi, 1.0, up) { hi + up } else { hi };
|
||||
(lo2, hi2)
|
||||
}
|
||||
|
||||
@@ -2108,7 +2235,9 @@ fn feature_tools_with_constructor(
|
||||
if untrimmed {
|
||||
*constructor = Some(ExtrudeConstructor { plane: ss.plane, interval: (lo + off, hi + off) });
|
||||
}
|
||||
if *operation == Operation::NewBody && extent.taper.is_none() {
|
||||
// A Join tool's faces outlive the boolean where they stay on the result, so they are
|
||||
// certified as a new body's are (see `Body::with_preserved_union_planar_sources`).
|
||||
if matches!(operation, Operation::NewBody | Operation::Join) && extent.taper.is_none() {
|
||||
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
|
||||
let cap_bindings = crate::config::extrude_cap_bindings(&ss, &bodies, 10_000_000);
|
||||
let source_curves = cap_bindings.as_ref().map(|_| crate::config::ExactSketchCurves::new(&ss));
|
||||
@@ -2127,8 +2256,20 @@ fn feature_tools_with_constructor(
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let caps =
|
||||
let mut caps =
|
||||
cap_bindings.as_ref().and_then(|bindings| bindings.get(body_index)).cloned().unwrap_or_else(|| vec![None; curves.len()]);
|
||||
// A cap moved off its designed height (into a Join target, or past a
|
||||
// curved To face) is not the feature's start or end face.
|
||||
let nominal = |plane: &solidcraft_geom::Plane| {
|
||||
let height = (plane.origin - ss.plane.origin).dot(n);
|
||||
let tolerance = 1e-9 * (1.0 + height.abs() + (lo + off).abs() + (hi + off).abs());
|
||||
(height - (lo + off)).abs() <= tolerance || (height - (hi + off)).abs() <= tolerance
|
||||
};
|
||||
for (cap, source) in caps.iter_mut().zip(body.face_sources().into_iter().flatten()) {
|
||||
if matches!(source, Some(kernel::FaceSource::ExtrudeCap { plane, .. }) if !nominal(plane)) {
|
||||
*cap = None;
|
||||
}
|
||||
}
|
||||
// Certificates set face sources only: the faces, and their lineage, stay.
|
||||
let body = body.with_extrude_certificates(f.id, *sketch, &curves)?.with_extrude_cap_certificates(f.id, *sketch, &caps)?;
|
||||
Ok(Traced { body, lineage })
|
||||
@@ -2848,8 +2989,10 @@ fn replay(
|
||||
let nb = match &src.kind {
|
||||
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &moved, r, distance2, angle, *flip)?,
|
||||
// A copy is congruent to the original: the radius it was reduced to first.
|
||||
FeatureKind::Fillet { fit, .. } => match st.fitted.get(&src.id).copied() {
|
||||
Some(used) => kernel::fillet(&mb.body, &moved, used).or_else(|_| fillet_fitted(&mb.body, &moved, r, *fit).map(|x| x.0))?,
|
||||
FeatureKind::Fillet { fit, .. } => match st.fitted.get(&src.id) {
|
||||
Some(used) => {
|
||||
kernel::fillet_each(&mb.body, &moved, used).or_else(|_| fillet_fitted(&mb.body, &moved, r, *fit).map(|x| x.0))?
|
||||
}
|
||||
None => fillet_fitted(&mb.body, &moved, r, *fit)?.0,
|
||||
},
|
||||
_ => kernel::fillet(&mb.body, &moved, r)?,
|
||||
@@ -3060,6 +3203,38 @@ fn smooth_chain(b: &ModelBody, picks: &[Vec3]) -> Vec<Vec3> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `plane` with the sheet face it names (see `sheet::plane_sheet_face`) replaced by that face's current
|
||||
/// plane in the frame of component `comp`, or an error when the face is gone: a lost
|
||||
/// attachment is reported, never moved to the nearest face.
|
||||
pub(crate) fn sheet_face_plane_in_frame(doc: &Document, st: &ModelState, comp: u64, plane: &PlaneRef) -> Result<PlaneRef> {
|
||||
Ok(match plane {
|
||||
PlaneRef::Face { name: Some(name), anchor: None, .. } if crate::sheet::plane_sheet_face(plane).is_some() => {
|
||||
let lost = || DocError::Invalid(format!("lost face attachment: the sheet face `{name}` no longer exists; reattach the sketch to a face"));
|
||||
let (body, face) = crate::sheet::sheet_face_plane(st, name).ok_or_else(lost)?;
|
||||
if doc.occurrences.is_empty() && doc.body_offsets.is_empty() {
|
||||
return Ok(PlaneRef::Custom { plane: face });
|
||||
}
|
||||
// The body's own coordinates into the component's frame, as `state_in_frame` places its bodies.
|
||||
let source = st.body(&body).ok_or_else(lost)?;
|
||||
let inverse = crate::mat_inverse(&doc.component_transform(comp)).ok_or_else(lost)?;
|
||||
let m = crate::mat_mul(&inverse, &doc.component_transform(doc.model_body_component(source)));
|
||||
let m = doc.model_body_offset(source).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m);
|
||||
let placed = Plane::new(crate::apply_point(&m, face.origin), crate::apply_vector(&m, face.x), crate::apply_vector(&m, face.y));
|
||||
PlaneRef::Custom { plane: placed.ok_or_else(lost)? }
|
||||
}
|
||||
PlaneRef::Offset { base, distance } => {
|
||||
PlaneRef::Offset { base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?), distance: distance.clone() }
|
||||
}
|
||||
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => PlaneRef::AtAngle {
|
||||
base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?),
|
||||
axis_origin: *axis_origin,
|
||||
axis_dir: *axis_dir,
|
||||
angle: angle.clone(),
|
||||
},
|
||||
other => other.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The plane of the body face a sketch sits on, found again: among planar faces with the
|
||||
/// picked plane's normal, the one nearest `at`. The picked frame moves along its normal onto it.
|
||||
fn face_plane(st: &ModelState, picked: &Plane, at: Vec3, name: Option<&str>) -> (Plane, Option<String>) {
|
||||
@@ -3191,6 +3366,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
};
|
||||
let (plane, face_warning) = match (anchor, plane) {
|
||||
(Some(_), plane) => (doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?, None),
|
||||
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
|
||||
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
|
||||
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
|
||||
}
|
||||
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref()),
|
||||
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
|
||||
};
|
||||
@@ -3204,9 +3383,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
if let Some(w) = face_warning {
|
||||
warns.insert(0, w);
|
||||
}
|
||||
// The stored sketch is solved for its stored values.
|
||||
// The stored sketch is solved for its stored values, with its links where they
|
||||
// were stored: a link that moved must not change the side a dimension from it is on.
|
||||
let stored = sk.driving_values();
|
||||
let report = doc.solve_sketch(vals, &mut sk, &stored)?;
|
||||
let report = doc.solve_sketch(vals, &mut sk, &stored, Some(sketch))?;
|
||||
if !report.keeps_orientation() {
|
||||
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
|
||||
}
|
||||
@@ -3232,26 +3412,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
|
||||
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref())?;
|
||||
if !handled.is_empty() {
|
||||
let effective = if targets.is_empty() { &f.participants } else { targets };
|
||||
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
|
||||
let rest: Vec<String> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
!handled.contains(&b.name)
|
||||
&& (all
|
||||
|| if effective.is_empty() {
|
||||
b.component == f.component || b.feature == f.id
|
||||
} else {
|
||||
effective.contains(&b.name)
|
||||
})
|
||||
})
|
||||
.map(|b| b.name.clone())
|
||||
.collect();
|
||||
if rest.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
return apply_traced(st, f, tools, *operation, &rest);
|
||||
return apply_beside_sheet_cut(st, f, tools, *operation, targets, &handled);
|
||||
}
|
||||
}
|
||||
apply_traced(st, f, tools, *operation, targets)
|
||||
@@ -3355,13 +3516,15 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let fst = crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?;
|
||||
doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?
|
||||
}
|
||||
None => doc.resolve_plane_in(vals, plane, 0, Some(st))?,
|
||||
// Picks on bodies of other (moved) components are where those bodies are shown.
|
||||
None => doc.resolve_plane_in(vals, plane, 0, Some(&construction_state(doc, st, f.component)))?,
|
||||
};
|
||||
st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Plane(pl) });
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::ConstructionAxis { def } => {
|
||||
let (origin, dir) = crate::construct::resolve_axis(&crate::construct::Ctx { doc, vals, st, depth: 0 }, def)?;
|
||||
let (origin, dir) =
|
||||
crate::construct::resolve_axis(&crate::construct::Ctx { doc, vals, st: &construction_state(doc, st, f.component), depth: 0 }, def)?;
|
||||
st.construct.push(crate::construct::Construct {
|
||||
feature: f.id,
|
||||
name: f.name.clone(),
|
||||
@@ -3370,7 +3533,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::ConstructionPoint { def } => {
|
||||
let p = crate::construct::resolve_point(&crate::construct::Ctx { doc, vals, st, depth: 0 }, def)?;
|
||||
let p =
|
||||
crate::construct::resolve_point(&crate::construct::Ctx { doc, vals, st: &construction_state(doc, st, f.component), depth: 0 }, def)?;
|
||||
st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Point(p) });
|
||||
Ok(())
|
||||
}
|
||||
@@ -3491,7 +3655,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let mb = st.body(n).cloned().ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
|
||||
for (k, m) in mats.iter().enumerate() {
|
||||
let copy = moved_copy(&mb, kernel::transform_matrix(&mb.body, *m)?, k + 1);
|
||||
let name = copy_body_name(st, f, output, &mb.name);
|
||||
let name = pattern_copy_name(st, f, output, &mb.name);
|
||||
st.bodies.push(ModelBody::traced_new(name, copy, f.id));
|
||||
output += 1;
|
||||
}
|
||||
@@ -3603,8 +3767,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
FeatureKind::Fillet { fit, .. } => {
|
||||
let (b, used) = fillet_fitted(&mb.body, &edges, r, *fit)?;
|
||||
if let Some(used) = used {
|
||||
append_warning(warning, reduced_note(&used, r));
|
||||
st.fitted.insert(f.id, used);
|
||||
append_warning(warning, format!("the radius is reduced to {} mm, the largest that fits these edges", show_mm(used)));
|
||||
}
|
||||
(b, input)
|
||||
}
|
||||
@@ -3624,12 +3788,25 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
|
||||
let corner = point(vals, corner)?;
|
||||
let b = kernel::box_solid(corner, corner + s)?;
|
||||
let b = if *operation == Operation::NewBody { b.with_box_certificate(f.id)? } else { b };
|
||||
apply_traced(st, f, vec![crate::lineage::box_tool(b)], *operation, &[])
|
||||
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b };
|
||||
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation)?;
|
||||
let tools = vec![crate::lineage::box_tool(b)];
|
||||
if !handled.is_empty() {
|
||||
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
|
||||
}
|
||||
apply_traced(st, f, tools, *operation, &[])
|
||||
}
|
||||
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
|
||||
let b = kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
|
||||
apply_traced(st, f, vec![crate::lineage::cylinder_tool(b)], *operation, &[])
|
||||
let (base, radius, height) = (point(vals, base)?, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?);
|
||||
let b = kernel::cylinder(base, *axis, radius, height)?;
|
||||
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_cylinder_certificate(f.id)? } else { b };
|
||||
let prism = more::PrimitivePrism::Cylinder { base, axis: *axis, radius, height };
|
||||
let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation)?;
|
||||
let tools = vec![crate::lineage::cylinder_tool(b)];
|
||||
if !handled.is_empty() {
|
||||
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
|
||||
}
|
||||
apply_traced(st, f, tools, *operation, &[])
|
||||
}
|
||||
FeatureKind::Sphere { center, radius, operation } => {
|
||||
let b = kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?;
|
||||
@@ -3728,7 +3905,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
st.bodies.push(ModelBody::traced_new(name, b, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Move { bodies, translate, rotate_axis, angle } => {
|
||||
FeatureKind::Move { bodies, translate, rotate_axis, angle, copy } => {
|
||||
let t =
|
||||
Vec3::new(val(vals, &translate[0], Kind::Length)?, val(vals, &translate[1], Kind::Length)?, val(vals, &translate[2], Kind::Length)?);
|
||||
let ang = match angle {
|
||||
@@ -3736,11 +3913,20 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
None => 0.0,
|
||||
};
|
||||
let axis = rotate_axis.unwrap_or(Vec3::Z);
|
||||
// With `copy` set, the source body is kept in place and the transform lands on a new
|
||||
// body pushed beside it instead of replacing it.
|
||||
let mut output = 0;
|
||||
for n in bodies {
|
||||
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
|
||||
if let Some(mb) = st.bodies.get_mut(i) {
|
||||
let nb = Traced::step(kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?, &[&mb.traced()]);
|
||||
*mb = ModelBody::traced_new(mb.name.clone(), nb, mb.feature);
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { continue };
|
||||
let nb = kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?;
|
||||
if *copy {
|
||||
let traced = moved_copy(&mb, nb, 1);
|
||||
let name = copy_body_name(st, f, output, &mb.name);
|
||||
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
|
||||
output += 1;
|
||||
} else if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::traced_new(mb.name.clone(), Traced::step(nb, &[&mb.traced()]), mb.feature);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -3817,6 +4003,16 @@ fn unique_body_name(st: &ModelState, base: &str) -> String {
|
||||
/// it are moved into its frame (through both placements), so a sketch on another component's
|
||||
/// face finds the face where it is shown and projects its edges there. The model itself when
|
||||
/// every placement agrees (the usual case).
|
||||
/// What construction geometry of component `comp` is built on: the model as that component sees
|
||||
/// it, other components' bodies where they are placed. In the root, every placement of a
|
||||
/// component (its pasted instances too) is there, as the viewport shows it.
|
||||
fn construction_state<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
|
||||
if comp == 0 && !doc.occurrences.is_empty() {
|
||||
return std::borrow::Cow::Owned(world_state(doc, st));
|
||||
}
|
||||
state_in_frame(doc, st, comp)
|
||||
}
|
||||
|
||||
pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
|
||||
state_in_frame_at(doc, st, comp, doc.component_transform(comp))
|
||||
}
|
||||
|
||||
+168
-34
@@ -325,6 +325,21 @@ fn sheet_for(st: &ModelState, body: &Option<String>, pick: Option<Vec3>) -> Resu
|
||||
.ok_or_else(|| DocError::Invalid("there is no sheet metal body".into()))
|
||||
}
|
||||
|
||||
/// Whether a point touches sheet `i`'s material: inside it, on one of its faces, or within its
|
||||
/// display tolerance of one (picks land on the display mesh). A sheet with no mesh to test
|
||||
/// against counts as touched.
|
||||
fn on_sheet_material(st: &ModelState, i: usize, p: Vec3) -> bool {
|
||||
let Some(mb) = st.sheets.get(i).and_then(|sh| st.body(&sh.body)) else { return true };
|
||||
let mesh = mb.mesh();
|
||||
if mesh.triangles.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// Twice the display mesh's chord tolerance (see `ModelBody::mesh`).
|
||||
let step = 2.0 * (mb.body.size() * 1e-3).max(1e-3);
|
||||
let offsets = [-step, 0.0, step];
|
||||
offsets.iter().any(|&dx| offsets.iter().any(|&dy| offsets.iter().any(|&dz| mesh.contains(p + Vec3::new(dx, dy, dz)))))
|
||||
}
|
||||
|
||||
/// A region mirrored in the sketch x axis (y → −y); curves other than lines and arcs as polylines.
|
||||
fn mirror_y(r: &Region2) -> Region2 {
|
||||
let f = |p: Vec2| Vec2::new(p.x, -p.y);
|
||||
@@ -415,8 +430,21 @@ pub(super) fn sheet_eval(
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body } => {
|
||||
let i = sheet_for(st, body, edges.first().copied())?;
|
||||
let i = sheet_for(st, body, edges.first().map(|e| e.point))?;
|
||||
let (h, a) = (val(vals, height, Kind::Length)?, val(vals, angle, Kind::Angle)?);
|
||||
// Each edge's own height and angle, else the feature's.
|
||||
let mut own = Vec::with_capacity(edges.len());
|
||||
for e in edges {
|
||||
let height = match &e.height {
|
||||
Some(x) => val(vals, x, Kind::Length)?,
|
||||
None => h,
|
||||
};
|
||||
let angle = match &e.angle {
|
||||
Some(x) => val(vals, x, Kind::Angle)?,
|
||||
None => a,
|
||||
};
|
||||
own.push((height, angle));
|
||||
}
|
||||
let rr = match radius {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => {
|
||||
@@ -428,9 +456,14 @@ pub(super) fn sheet_eval(
|
||||
if sh.flat {
|
||||
return Err(DocError::Invalid("refold the sheet before adding flanges".into()));
|
||||
}
|
||||
let picks = sheet_picks(sh, f, edges, warning);
|
||||
let points: Vec<Vec3> = edges.iter().map(|e| e.point).collect();
|
||||
let each: Vec<sheet::EdgeFlange> = sheet_picks(sh, f, &points, warning)
|
||||
.into_iter()
|
||||
.zip(own)
|
||||
.map(|(pick, (height, angle))| sheet::EdgeFlange { pick, height, angle })
|
||||
.collect();
|
||||
let n0 = sh.flanges.len();
|
||||
let (_, notes) = sh.add_flanges_noted(&picks, h, a, rr, position, *flip)?;
|
||||
let (_, notes) = sh.add_flanges_each(&each, rr, position, *flip)?;
|
||||
if !notes.is_empty() {
|
||||
let notes = notes.join("; ");
|
||||
*warning = Some(match warning.take() {
|
||||
@@ -482,12 +515,25 @@ pub(super) fn sheet_eval(
|
||||
doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius
|
||||
}
|
||||
};
|
||||
// A fixed point that touches no material at all (e.g. far above the sheet) still picks
|
||||
// the side it lies on, so a stored fold keeps recomputing; it says so (#371).
|
||||
if let Some(p) = fixed
|
||||
&& !on_sheet_material(st, i, *p)
|
||||
{
|
||||
*warning = Some(format!(
|
||||
"{} at ({:.3}, {:.3}, {:.3}); the side of the fold line it lies on stays",
|
||||
sheet::FOLD_FIXED_OFF_SHEET,
|
||||
p.x,
|
||||
p.y,
|
||||
p.z
|
||||
));
|
||||
}
|
||||
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
if sh.flat {
|
||||
return Err(DocError::Invalid("refold the sheet before folding it".into()));
|
||||
}
|
||||
let n0 = sh.flanges.len();
|
||||
sh.add_fold(a, b, ang, rr, position, *flip, *fixed)?;
|
||||
sh.add_fold(a, b, ang, rr, position, *flip, *fixed, warning)?;
|
||||
tag_flanges(sh, n0, f.id);
|
||||
rebuild_sheet(st, i)
|
||||
}
|
||||
@@ -622,24 +668,112 @@ pub(super) fn sheet_cut(
|
||||
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) {
|
||||
if extent.taper.is_some() || !any_eligible_sheet(f, st, targets) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let n = ss.plane.normal();
|
||||
sheet_prism_cut(f, st, &ss.plane, ®ions, *operation, tools, targets, constructor, extent.to.is_some())
|
||||
}
|
||||
|
||||
/// Whether a cut of `f` with these targets (else its participants, else its component's
|
||||
/// bodies) machines the sheet `original`.
|
||||
fn sheet_eligible(f: &Feature, st: &ModelState, targets: &[String], original: &crate::sheet::SheetBody) -> bool {
|
||||
let targets = if targets.is_empty() { &f.participants } else { targets };
|
||||
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a cut of `f` with these targets machines any sheet metal body.
|
||||
fn any_eligible_sheet(f: &Feature, st: &ModelState, targets: &[String]) -> bool {
|
||||
st.sheets.iter().any(|original| sheet_eligible(f, st, targets, original))
|
||||
}
|
||||
|
||||
/// A Box or Cylinder cut (or intersect) whose tool is a straight prism along the normal of
|
||||
/// the sheet it meets: machined like the equivalent extrude of its footprint, so the sheet
|
||||
/// keeps it as a flat cut-out or pocket. Returns the sheet bodies it handled; none when the
|
||||
/// primitive is not such a prism (the generic preflight then refuses real 3D machining).
|
||||
pub(super) fn sheet_primitive_cut(f: &Feature, st: &mut ModelState, tool: &Body, prism: PrimitivePrism, operation: Operation) -> Result<Vec<String>> {
|
||||
if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// The first sheet the primitive meets decides the machining direction.
|
||||
let mut normal = None;
|
||||
for sh in st.sheets.iter().filter(|sh| sheet_eligible(f, st, &[], sh)) {
|
||||
if let Some(mb) = st.body(&sh.body)
|
||||
&& kernel::boolean(&mb.body, tool, kernel::BoolOp::Intersect)?.is_some()
|
||||
{
|
||||
normal = Some(sh.frame.normal());
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(normal) = normal else { return Ok(Vec::new()) };
|
||||
let Some((plane, region, interval)) = prism.along(normal) else { return Ok(Vec::new()) };
|
||||
let tools = kernel::extrude(&plane, std::slice::from_ref(®ion), interval.0, interval.1)?;
|
||||
sheet_prism_cut(f, st, &plane, std::slice::from_ref(®ion), operation, &tools, &[], None, false)
|
||||
}
|
||||
|
||||
/// A primitive tool's exact geometry, for [`sheet_primitive_cut`].
|
||||
pub(super) enum PrimitivePrism {
|
||||
/// An axis-aligned box between two corners.
|
||||
Box { lo: Vec3, hi: Vec3 },
|
||||
/// A cylinder: base centre, unit axis, radius and signed height.
|
||||
Cylinder { base: Vec3, axis: Vec3, radius: f64, height: f64 },
|
||||
}
|
||||
|
||||
impl PrimitivePrism {
|
||||
/// The sketch-like plane, footprint region and depth interval of this tool as a prism along
|
||||
/// `normal` (either sense), or None when it is not one.
|
||||
fn along(&self, normal: Vec3) -> Option<(Plane, Region2, (f64, f64))> {
|
||||
let n = normal.normalized()?;
|
||||
match *self {
|
||||
PrimitivePrism::Box { lo, hi } => {
|
||||
let (lo, hi) = (lo.min(hi), lo.max(hi));
|
||||
let s = hi - lo;
|
||||
// Plane axes (x, y) and the depth axis, as a right-handed frame.
|
||||
let (x, y, depth) = if n.cross(Vec3::Z).len() <= 1e-12 {
|
||||
(Vec3::X, Vec3::Y, s.z)
|
||||
} else if n.cross(Vec3::X).len() <= 1e-12 {
|
||||
(Vec3::Y, Vec3::Z, s.x)
|
||||
} else if n.cross(Vec3::Y).len() <= 1e-12 {
|
||||
(Vec3::Z, Vec3::X, s.y)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let plane = Plane::new(lo, x, y)?;
|
||||
let (w, h) = (s.dot(x), s.dot(y));
|
||||
let outer = Loop2::polygon(&[Vec2::ZERO, Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)]);
|
||||
Some((plane, Region2 { outer, holes: vec![] }, (0.0, depth)))
|
||||
}
|
||||
PrimitivePrism::Cylinder { base, axis, radius, height } => {
|
||||
let axis = axis.normalized()?;
|
||||
if axis.cross(n).len() > 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let plane = Plane::from_normal(base, axis)?;
|
||||
Some((plane, Region2 { outer: Loop2::circle(Vec2::ZERO, radius), holes: vec![] }, (height.min(0.0), height.max(0.0))))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sheet_prism_cut(
|
||||
f: &Feature,
|
||||
st: &mut ModelState,
|
||||
plane: &Plane,
|
||||
regions: &[Region2],
|
||||
operation: Operation,
|
||||
tools: &[Body],
|
||||
targets: &[String],
|
||||
constructor: Option<&ExtrudeConstructor>,
|
||||
footprints_bound: bool,
|
||||
) -> Result<Vec<String>> {
|
||||
let n = plane.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 {
|
||||
@@ -650,7 +784,7 @@ pub(super) fn sheet_cut(
|
||||
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 {
|
||||
let Some((lo, hi, roundoff, footprint)) = sheet_tool_span(tool, 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());
|
||||
@@ -670,7 +804,7 @@ pub(super) fn sheet_cut(
|
||||
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 nominal = constructor.filter(|c| c.plane == *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)))
|
||||
@@ -678,23 +812,23 @@ pub(super) fn sheet_cut(
|
||||
});
|
||||
let mut plans = Vec::new();
|
||||
'sheets: for original in &st.sheets {
|
||||
if !eligible(original) {
|
||||
if !sheet_eligible(f, st, targets, original) {
|
||||
continue;
|
||||
}
|
||||
let Some(mb) = st.body(&original.body) else {
|
||||
continue;
|
||||
};
|
||||
let mut sh = original.clone();
|
||||
if *operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
|
||||
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)? {
|
||||
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 bends in sh.unmodeled_solids(plane)? {
|
||||
for tool in tools {
|
||||
if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() {
|
||||
return Err(DocError::Invalid(
|
||||
@@ -706,21 +840,21 @@ pub(super) fn sheet_cut(
|
||||
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)?
|
||||
sh.material_panels(Some(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 zorigin = (apply_point(&w, Vec3::ZERO) - 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))
|
||||
+ 128.0 * f64::EPSILON * (apply_point(&w, Vec3::ZERO).len() + 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 nominal_depth = if apply_point(&w, Vec3::ZERO) == plane.origin
|
||||
&& let Some((lo, hi)) = nominal
|
||||
&& (span.0 - lo).abs() <= roundoff
|
||||
&& (span.1 - hi).abs() <= roundoff
|
||||
@@ -740,7 +874,7 @@ pub(super) fn sheet_cut(
|
||||
None
|
||||
};
|
||||
if nominal_depth.is_none() && roundoff > 1e-7 {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
|
||||
plans.push((original.body.clone(), Some((original.clone(), mb.body.clone()))));
|
||||
continue 'sheets;
|
||||
}
|
||||
@@ -748,12 +882,12 @@ pub(super) fn sheet_cut(
|
||||
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() {
|
||||
let mapped = if footprints_bound && 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<_>>>()?
|
||||
regions.iter().map(|r| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
|
||||
};
|
||||
if *operation == Operation::Intersect {
|
||||
if operation == Operation::Intersect {
|
||||
if z1 <= z0 {
|
||||
sh.holes.push(panel);
|
||||
modeled = true;
|
||||
@@ -809,7 +943,7 @@ pub(super) fn sheet_cut(
|
||||
}
|
||||
}
|
||||
if !modeled {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
|
||||
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
|
||||
|
||||
@@ -117,11 +117,13 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// All status planes are world-space. A lost plane is a repair guide only.
|
||||
/// All status planes are world-space. A lost plane is a repair guide only. `Sheet` is a sketch on
|
||||
/// a flat face of a sheet metal body, attached by the name of its panel (`sheet::sheet_face_name`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FaceAttachmentStatus {
|
||||
Fixed { plane: Option<Plane> },
|
||||
Legacy { plane: Option<Plane> },
|
||||
Sheet { plane: Option<Plane> },
|
||||
Healthy { plane: Plane, target: ConfigFaceTarget, path: Vec<u64> },
|
||||
Lost { message: String, repair_plane: Option<Plane> },
|
||||
}
|
||||
@@ -142,15 +144,30 @@ impl Document {
|
||||
author: u64,
|
||||
author_path: &[u64],
|
||||
) -> Result<FaceAnchor> {
|
||||
let source = body
|
||||
// A construction certificate that names this face alone, else its exact history name.
|
||||
let certified = body
|
||||
.body
|
||||
.face_plane(index)
|
||||
.ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))
|
||||
.and_then(|source| {
|
||||
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()));
|
||||
}
|
||||
let indices = self.configuration_face_indices(body, &target)?;
|
||||
self.coplanar_pieces(body, &indices, |_| true)?;
|
||||
Ok(target)
|
||||
});
|
||||
let target = match certified {
|
||||
Ok(target) => target,
|
||||
Err(error) => match body.body.planar_face(index).and_then(|plane| self.lineage_face_target(body, index, plane.origin)) {
|
||||
Some(target) => target,
|
||||
None => return Err(error),
|
||||
},
|
||||
};
|
||||
let source = self
|
||||
.anchor_face_plane(body, index, target.certificate.as_ref())
|
||||
.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),
|
||||
@@ -176,9 +193,14 @@ impl Document {
|
||||
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()))?;
|
||||
let certificate = anchor.target.certificate.as_ref();
|
||||
let plane = self
|
||||
.anchor_face_plane(body, first, certificate)
|
||||
.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()))?;
|
||||
let other = self
|
||||
.anchor_face_plane(body, *index, certificate)
|
||||
.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()));
|
||||
}
|
||||
@@ -210,6 +232,13 @@ impl Document {
|
||||
self.resolve_plane(&vals, plane, 0).ok()
|
||||
});
|
||||
let current = world.and_then(|world| current.and_then(|plane| map_plane(&world, &plane).ok()));
|
||||
if crate::sheet::plane_sheet_face(plane).is_some() {
|
||||
let state = model.state_before(feature.id);
|
||||
return match crate::eval::sheet_face_plane_in_frame(self, &state, feature.component, plane) {
|
||||
Ok(_) => FaceAttachmentStatus::Sheet { plane: current },
|
||||
Err(error) => FaceAttachmentStatus::Lost { message: error.to_string(), repair_plane: current },
|
||||
};
|
||||
}
|
||||
return if has_face_reference(plane) {
|
||||
FaceAttachmentStatus::Legacy { plane: current }
|
||||
} else {
|
||||
|
||||
@@ -46,13 +46,40 @@
|
||||
//! legacy mark (`?F2:top#2`) and are found again as the build that saved them found them.
|
||||
//! Pieces a feature splits a body into are named after their faces; features saved before
|
||||
//! keep the kernel's shell order (`shell_piece_order`), which their stored body names mean.
|
||||
//! - 15: sketch face anchors and face bindings can name a cylinder primitive's cap
|
||||
//! (`cylinder_cap`), sketches can link lines to their own axes (`sketch_axis`), and sketch
|
||||
//! anchors can name a sheet metal panel side (`sheet:<body>:<panel>:<side>`). Version 14
|
||||
//! designs need no transformation; the version stamp lets older readers request an update
|
||||
//! instead of encountering an unknown certificate or link kind.
|
||||
//! - 16: sheet metal rules can choose a corner relief shape (`corner_relief_shape`: notch,
|
||||
//! round or rectangular). Version 15 designs need no transformation (an absent shape is the
|
||||
//! notch they always had); the version stamp lets older readers request an update instead of
|
||||
//! silently building notches where a design asks for another shape.
|
||||
//! Under the same version, sketch text can be set in a font other than the built-in one
|
||||
//! (`font` in the text link's source: an installed family or a font file); a text without
|
||||
//! `font` is in the built-in font, as it always was.
|
||||
//! A Move feature can also keep its source bodies and transform a copy (`copy: true`); a
|
||||
//! move without it saves as before.
|
||||
//! A component can link to another design file (`links`: component id → `{path, feature}`,
|
||||
//! the path relative to the design's folder); the linked bodies are kept in the component's
|
||||
//! Import feature, so an older build opens the design with them and only loses the link.
|
||||
//! A design can also define its own physical materials (`user_materials`: name, density in
|
||||
//! g/cm³, optional look), which bodies (`materials`) and configuration material cells name
|
||||
//! like built-in ones; a design without them saves as before.
|
||||
//! A design can also keep what was hidden when it was saved (`visibility`: hidden bodies,
|
||||
//! occurrence placements, sketches and origin items; `browser.visibility`). A design without
|
||||
//! it shows everything, as it always did; a reader that ignores it loses only the hiding.
|
||||
//! Sketch face anchors can name a flat face by its exact history name (a `lineage`
|
||||
//! certificate: the target's `name`, e.g. a cut wall `F5:side:l1` or a pattern copy
|
||||
//! `F4:top@F6.2`), where no construction certificate names it alone. Anchors with
|
||||
//! construction certificates resolve as before.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 14;
|
||||
pub const FORMAT: u32 = 16;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solidcraft/{n}")
|
||||
@@ -69,7 +96,7 @@ pub fn version_of(s: &str) -> Option<u32> {
|
||||
|
||||
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
||||
type JsonStep = fn(&mut Value) -> Result<()>;
|
||||
const JSON_STEPS: [JsonStep; 13] = [
|
||||
const JSON_STEPS: [JsonStep; 15] = [
|
||||
|_| Ok(()),
|
||||
v2_to_v3,
|
||||
v3_to_v4,
|
||||
@@ -83,6 +110,8 @@ const JSON_STEPS: [JsonStep; 13] = [
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
v13_to_v14,
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
];
|
||||
|
||||
/// Stored face and edge names were worked out from geometry before format 14; they get the
|
||||
|
||||
@@ -32,6 +32,7 @@ pub mod naming;
|
||||
mod params;
|
||||
mod piece_names;
|
||||
mod profile_refs;
|
||||
pub mod progress;
|
||||
pub use profile_refs::{capture_profile_selection, profile_selection_indices};
|
||||
pub mod plastic;
|
||||
pub mod project;
|
||||
|
||||
@@ -211,6 +211,35 @@ impl FaceHit {
|
||||
}
|
||||
}
|
||||
|
||||
impl FaceHit {
|
||||
/// Where the line through `p` along `dir` meets the face found (the hit nearest `p`, on
|
||||
/// either side of it), `None` when the line misses the face or runs along it. A place on
|
||||
/// a face follows the face this way, along its own axis, instead of sliding sideways to
|
||||
/// [`point`](Self::point), the face's nearest point.
|
||||
pub fn along(&self, st: &ModelState, p: Vec3, dir: Vec3) -> Option<Vec3> {
|
||||
let b = st.bodies.get(self.body)?;
|
||||
let d = dir.normalized()?;
|
||||
let m = b.mesh();
|
||||
let slack = (b.body.size() * 1e-9).max(1e-9);
|
||||
let mut best: Option<f64> = None;
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
if *f as usize != self.face {
|
||||
continue;
|
||||
}
|
||||
let Some([a, bb, c]) = m.tri(t) else { continue };
|
||||
// A hair's width around each triangle, so a place on the seam of two is on the face.
|
||||
let centre = (a + bb + c) * (1.0 / 3.0);
|
||||
let grow = |v: Vec3| v + (v - centre).normalized().unwrap_or(Vec3::ZERO) * slack;
|
||||
if let Some(hit) = naming::ray_hit(p, d, grow(a), grow(bb), grow(c))
|
||||
&& best.is_none_or(|old| hit.abs() < old.abs())
|
||||
{
|
||||
best = Some(hit);
|
||||
}
|
||||
}
|
||||
best.map(|t| p + d * t)
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -31,6 +31,8 @@ pub struct NamingOrigin {
|
||||
pub depth: usize,
|
||||
/// Instance transforms of a pattern or mirror (its copies are named after their sources).
|
||||
pub mats: Vec<crate::Mat>,
|
||||
/// The sheet metal bodies after a sheet feature (their folds place moved faces).
|
||||
pub sheets: Vec<crate::sheet::SheetBody>,
|
||||
}
|
||||
|
||||
/// The lazily computed names of a body, shared by its clones.
|
||||
@@ -334,6 +336,24 @@ fn geometric_from(b: &ModelBody, o: &NamingOrigin, nf: usize, names_of: fn(&Mode
|
||||
names = got.into_iter().map(|n| n.map(|n| format!("{n}@F{fid}.{}", k + 1))).collect();
|
||||
}
|
||||
}
|
||||
// A sheet feature that moved panels (a fold, an unfold): faces it carried along lie on the
|
||||
// faces they were, taken back through the panel they are on.
|
||||
if let (Some(after), Some(before), Some(input)) = (
|
||||
o.sheets.iter().find(|s| s.body == b.name),
|
||||
o.before.sheets.iter().find(|s| s.body == b.name),
|
||||
inputs.iter().find(|(x, _, _)| x.name == b.name),
|
||||
) {
|
||||
let input = std::slice::from_ref(input);
|
||||
for (name, ps) in names.iter_mut().zip(&pts) {
|
||||
if name.is_some() {
|
||||
continue;
|
||||
}
|
||||
let back: Option<Vec<Vec3>> = ps.iter().map(|p| after.point_before(before, *p)).collect();
|
||||
if let Some(back) = back.filter(|b| !b.is_empty()) {
|
||||
*name = vote(&back, input, tol, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
let empty = Vec::new();
|
||||
let names: Vec<String> = names
|
||||
.into_iter()
|
||||
@@ -386,7 +406,7 @@ fn behind(ps: &[Vec3], ns: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<St
|
||||
votes.first().filter(|(_, k)| *k * 2 >= ps.len().max(1)).map(|(n, _)| n.clone())
|
||||
}
|
||||
|
||||
fn ray_hit(o: Vec3, d: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Option<f64> {
|
||||
pub(crate) fn ray_hit(o: Vec3, d: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Option<f64> {
|
||||
let (e1, e2) = (b - a, c - a);
|
||||
let h = d.cross(e2);
|
||||
let det = e1.dot(h);
|
||||
@@ -454,14 +474,12 @@ pub fn strip_edge_piece(n: &str) -> String {
|
||||
|
||||
/// 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() {
|
||||
let n = names[i].clone();
|
||||
if seen.contains(&n) {
|
||||
continue;
|
||||
}
|
||||
seen.push(n.clone());
|
||||
let same: Vec<usize> = (0..names.len()).filter(|j| names[*j] == n).collect();
|
||||
// The pieces of each name, gathered in one pass (a faceted body has thousands of names).
|
||||
let mut groups: std::collections::BTreeMap<String, Vec<usize>> = std::collections::BTreeMap::new();
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
groups.entry(n.clone()).or_default().push(i);
|
||||
}
|
||||
for (n, same) in groups {
|
||||
if same.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -223,12 +223,21 @@ impl FeatureKind {
|
||||
v.push(("Catch length", catch_length, L));
|
||||
}
|
||||
FeatureKind::SheetContour { distance, .. } => v.push(("Distance", distance, L)),
|
||||
FeatureKind::SheetFlange { height, angle, radius, .. } => {
|
||||
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
|
||||
v.push(("Height", height, L));
|
||||
v.push(("Angle", angle, A));
|
||||
if let Some(r) = radius {
|
||||
v.push(("Bend radius", r, L));
|
||||
}
|
||||
// The edges' own values come last, so the inputs above keep their names.
|
||||
for e in edges {
|
||||
if let Some(a) = &mut e.angle {
|
||||
v.push(("Edge angle", a, A));
|
||||
}
|
||||
if let Some(h) = &mut e.height {
|
||||
v.push(("Edge height", h, L));
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetFold { angle, radius, .. } => {
|
||||
v.push(("Angle", angle, A));
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Progress and cooperative cancellation for a long evaluation (opening a large design) run
|
||||
//! off the window thread. The caller wraps the work in [`watch`]; the timeline's evaluation
|
||||
//! loop reports each feature it reaches and, once [`Progress::cancel`] is called, skips the
|
||||
//! rest. Nothing changes for evaluations that are not watched.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
/// Shared between the worker evaluating a design and the window showing its progress.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Progress {
|
||||
cancelled: AtomicBool,
|
||||
done: AtomicUsize,
|
||||
total: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
/// Ask the watched evaluation to stop at its next feature.
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::Relaxed);
|
||||
}
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Features evaluated so far and the timeline's length (0 before evaluation starts).
|
||||
pub fn features(&self) -> (usize, usize) {
|
||||
(self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed))
|
||||
}
|
||||
}
|
||||
|
||||
struct Current {
|
||||
progress: Arc<Progress>,
|
||||
/// Nested evaluations (derived designs) don't overwrite the outer timeline's count.
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CURRENT: RefCell<Option<Current>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Run `f` with `progress` watching every evaluation it makes on this thread.
|
||||
pub fn watch<R>(progress: Arc<Progress>, f: impl FnOnce() -> R) -> R {
|
||||
let previous = CURRENT.with(|c| c.replace(Some(Current { progress, depth: 0 })));
|
||||
struct Restore(Option<Option<Current>>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous) = self.0.take() {
|
||||
CURRENT.with(|c| *c.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _restore = Restore(Some(previous));
|
||||
f()
|
||||
}
|
||||
|
||||
/// Entered at the start of an evaluation; the guard leaves it.
|
||||
pub(crate) struct Evaluation {
|
||||
outer: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn begin(total: usize) -> Evaluation {
|
||||
let outer = CURRENT.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
let Some(cur) = c.as_mut() else { return false };
|
||||
cur.depth += 1;
|
||||
if cur.depth == 1 {
|
||||
cur.progress.total.store(total, Ordering::Relaxed);
|
||||
cur.progress.done.store(0, Ordering::Relaxed);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
Evaluation { outer }
|
||||
}
|
||||
|
||||
impl Evaluation {
|
||||
/// Feature `index` is next. True when the watcher asked to stop: skip it.
|
||||
pub(crate) fn step(&self, index: usize) -> bool {
|
||||
CURRENT.with(|c| {
|
||||
let c = c.borrow();
|
||||
let Some(cur) = c.as_ref() else { return false };
|
||||
if self.outer {
|
||||
cur.progress.done.store(index, Ordering::Relaxed);
|
||||
}
|
||||
cur.progress.is_cancelled()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Evaluation {
|
||||
fn drop(&mut self) {
|
||||
CURRENT.with(|c| {
|
||||
if let Some(cur) = c.borrow_mut().as_mut() {
|
||||
cur.depth = cur.depth.saturating_sub(1);
|
||||
if self.outer {
|
||||
let total = cur.progress.total.load(Ordering::Relaxed);
|
||||
cur.progress.done.store(total, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unwatched_evaluations_never_stop_and_watched_ones_report_and_stop() {
|
||||
let e = begin(3);
|
||||
assert!(!e.step(0));
|
||||
drop(e);
|
||||
let p = Arc::new(Progress::default());
|
||||
watch(p.clone(), || {
|
||||
let e = begin(5);
|
||||
assert!(!e.step(2));
|
||||
assert_eq!(p.features(), (2, 5));
|
||||
{
|
||||
let inner = begin(100);
|
||||
assert!(!inner.step(50));
|
||||
}
|
||||
assert_eq!(p.features(), (2, 5), "a nested evaluation keeps the outer count");
|
||||
p.cancel();
|
||||
assert!(e.step(3));
|
||||
});
|
||||
assert_eq!(p.features(), (5, 5));
|
||||
assert!(!begin(1).step(0), "the watch ends with its scope");
|
||||
}
|
||||
}
|
||||
@@ -259,7 +259,10 @@ fn resolve_uncached(
|
||||
_ => vec![LinkGeom::Point(o)],
|
||||
}
|
||||
}
|
||||
LinkSource::Text { text, at, height, angle } => solidcraft_sketch::text_geometry(text, *at, *height, *angle)?,
|
||||
LinkSource::SketchAxis { name } => {
|
||||
vec![solidcraft_sketch::sketch_axis_geom(name).ok_or_else(|| DocError::Unknown(format!("sketch axis `{name}`")))?]
|
||||
}
|
||||
LinkSource::Text { text, at, height, angle, font } => solidcraft_sketch::text_geometry_in(font.as_deref(), text, *at, *height, *angle)?,
|
||||
LinkSource::Plane { name } => {
|
||||
let pr =
|
||||
if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } };
|
||||
@@ -321,6 +324,7 @@ fn certified_planar_construction(body: &crate::ModelBody, index: usize) -> bool
|
||||
Some(
|
||||
solidcraft_kernel::FaceSource::ShellPlanar { producer: Some(_), .. }
|
||||
| solidcraft_kernel::FaceSource::Box { producer: Some(_), .. }
|
||||
| solidcraft_kernel::FaceSource::CylinderCap { .. }
|
||||
| solidcraft_kernel::FaceSource::CertifiedExtrudeSide { plane: Some(_), .. }
|
||||
| solidcraft_kernel::FaceSource::CertifiedExtrudeCap { .. }
|
||||
)
|
||||
|
||||
+2435
-61
File diff suppressed because it is too large
Load Diff
@@ -88,3 +88,68 @@ fn struts_ending_on_a_bore_wall_join_without_a_panic() {
|
||||
result.unwrap();
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
}
|
||||
|
||||
/// A blind Ø2 hole into the flat back of a long D-profile bar ends 2 mm short of the round front,
|
||||
/// yet the cut was refused (#544). It removes π · 1² · 10 mm³ (within 5 %: the measure tessellates
|
||||
/// the small bore) and adds the bore's wall and floor to the bar's six faces.
|
||||
#[test]
|
||||
fn blind_hole_into_a_long_d_profile_bar_stops_short_of_the_round_front() {
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/blind_hole_in_long_d_profile.json")).unwrap();
|
||||
let steps = script.get("commands").and_then(Value::as_array).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.run_script(&Value::Array(steps[..5].to_vec())).unwrap();
|
||||
let bar = volume(&s, "Slat");
|
||||
s.run_script(&Value::Array(steps[5..].to_vec())).unwrap();
|
||||
let hole = std::f64::consts::PI * 10.0;
|
||||
let removed = bar - volume(&s, "Slat");
|
||||
assert!((removed - hole).abs() < 0.05 * hole, "removed {removed} vs {hole}");
|
||||
let faces = s.model.state().body("Slat").map(|b| solidcraft_kernel::measure(&b.body).unwrap().merged.faces);
|
||||
assert_eq!(faces, Some(8));
|
||||
}
|
||||
|
||||
/// A ring pocket cut from below a recess leaves a 0.5 mm floor joining the outer wall to the inner
|
||||
/// block: one connected part, yet the cut left two bodies and lost volume (#545).
|
||||
#[test]
|
||||
fn ring_cut_below_a_recess_keeps_the_part_in_one_body() {
|
||||
let s = replay(include_str!("../../../examples/regressions/ring_cut_below_recess_thin_floor.json"));
|
||||
let bodies: Vec<(String, f64)> = s.model.state().bodies.iter().map(|b| (b.name.clone(), volume(&s, &b.name))).collect();
|
||||
let want = 100.0 * 100.0 * 30.0 - 88.0 * 88.0 * 12.0 - (95.0 * 95.0 - 68.0 * 68.0) * 17.5;
|
||||
assert_eq!(bodies.len(), 1, "{bodies:?}");
|
||||
let got = volume(&s, "Frame");
|
||||
assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}");
|
||||
}
|
||||
|
||||
/// Four ribs joined on a rounded lid, standing on the plate over the skirt and flush with the
|
||||
/// window's sides: each adds exactly its own material and nothing changes away from it. The
|
||||
/// joins displaced the tool and one left a slit through the far side of the skirt and plate
|
||||
/// (#616), which no volume shows: the area does. The reply of each join carries its warning, as
|
||||
/// the timeline does (an agent placing parts over MCP saw none).
|
||||
#[test]
|
||||
fn ribs_flush_with_a_window_change_nothing_away_from_them() {
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/lid_ribs_flush_with_window.json")).unwrap();
|
||||
let steps = script.get("commands").and_then(Value::as_array).unwrap();
|
||||
let (lid, ribs) = steps.split_at(steps.len() - 4);
|
||||
let mut s = Session::default();
|
||||
s.run_script(&Value::Array(lid.to_vec())).unwrap();
|
||||
let measure = |s: &Session| s.model.state().body("Lid").map(|b| solidcraft_kernel::measure(&b.body).unwrap()).unwrap();
|
||||
let (v0, a0) = (measure(&s).volume, measure(&s).area);
|
||||
assert!((v0 - 6703.5222).abs() < 1e-3, "{v0}");
|
||||
for (i, rib) in ribs.iter().enumerate() {
|
||||
let reply = s.execute(rib["command"].as_str().unwrap(), &rib["params"]).unwrap();
|
||||
let id = reply["feature"].as_u64().unwrap();
|
||||
let result = s.model.result(id).unwrap();
|
||||
assert_eq!(reply["warning"], json!(result.warning), "the reply says what the timeline says");
|
||||
assert!(!result.warning.as_deref().unwrap_or_default().contains("tool displacement"), "{:?}", result.warning);
|
||||
let m = measure(&s);
|
||||
let n = (i + 1) as f64;
|
||||
// 1.6 x 6 x 11.7 less the 1.35 x 6 x 3 inside the skirt.
|
||||
assert!((m.volume - (v0 + n * 88.02)).abs() < 1e-6, "rib {}: {}", i + 1, m.volume - v0);
|
||||
// Top 9.6, outer side 6 x 8.7 above the skirt, inner side 6 x 11.7 (flush with the
|
||||
// window's side), ends 2 x (1.6 x 11.7 - 1.35 x 3); the skirt top 8.1, its inner side
|
||||
// 18 and 1.5 of the plate go under the rib.
|
||||
let area = 9.6 + 52.2 + 70.2 + 29.34 - 8.1 - 18.0 - 1.5;
|
||||
assert!((m.area - (a0 + n * area)).abs() < 1e-6, "rib {}: area {} (no slit adds any)", i + 1, m.area - a0);
|
||||
}
|
||||
let bounds = s.model.state().body("Lid").unwrap().body.tessellate(0.01).unwrap().bounds();
|
||||
assert!((bounds.max.z - 14.1).abs() < 1e-9, "{}", bounds.max.z);
|
||||
}
|
||||
|
||||
@@ -33,10 +33,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
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().doc_only(),
|
||||
CommandSpec::new("view.apply", "Restore Named View", view_apply).icon("camera").noundo().params(
|
||||
"view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (radians, mm) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step",
|
||||
),
|
||||
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",
|
||||
or folder?: bodies|sketches|construction|origin (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design; not an undo step",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -375,6 +378,38 @@ fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "view": name }))
|
||||
}
|
||||
|
||||
fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "view.apply";
|
||||
let cam = match (str_(p, "view"), p.get("camera")) {
|
||||
(Some(_), Some(_)) => return Err(bad(cmd, "give `view` or `camera`, not both")),
|
||||
(None, None) => return Err(bad(cmd, "`view` (a name) or `camera` is required")),
|
||||
(None, Some(c)) => {
|
||||
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
|
||||
camera_of(&v)
|
||||
}
|
||||
// Standard view names come first, as in the browser's Named Views folder.
|
||||
(Some(name), None) => match crate::render::StandardView::parse(name) {
|
||||
Some(sv) => {
|
||||
let mut c = crate::view::home_camera(s);
|
||||
c.set_view(sv);
|
||||
c.fit(&crate::view::bounds(s));
|
||||
c
|
||||
}
|
||||
None => s.doc.named_views.iter().find(|v| v.name == name).map(camera_of).ok_or_else(|| bad(cmd, format!("no named view `{name}`")))?,
|
||||
},
|
||||
};
|
||||
if !cam.is_valid() || !cam.fov.is_finite() || cam.fov < 0.0 {
|
||||
return Err(bad(cmd, "the camera must be finite with a positive distance"));
|
||||
}
|
||||
s.camera = Some(cam);
|
||||
Ok(json!({ "camera": cam }))
|
||||
}
|
||||
|
||||
/// The orbit camera of a named view (Z up, no roll).
|
||||
fn camera_of(v: &solidcraft_doc::NamedView) -> crate::render::Camera {
|
||||
crate::render::Camera { target: v.target, yaw: v.yaw, pitch: v.pitch, distance: v.distance, fov: v.fov, ..Default::default() }
|
||||
}
|
||||
|
||||
fn view_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let k = str_(p, "view").ok_or_else(|| bad(cmd, "`view` must be a view name"))?;
|
||||
s.doc.named_views.iter().position(|v| v.name == k).ok_or_else(|| bad(cmd, format!("no named view `{k}`")))
|
||||
@@ -464,6 +499,9 @@ 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, .. } = &mut f.kind {
|
||||
solidcraft_doc::sheet::rename_plane_sheet_body(plane, &old, name);
|
||||
}
|
||||
if let FeatureKind::Sketch { plane, sketch } = &mut f.kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
|
||||
&& anchor.target.body == old
|
||||
@@ -614,7 +652,13 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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" }),
|
||||
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() {
|
||||
"exact_face"
|
||||
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
|
||||
"sheet_face"
|
||||
} else {
|
||||
"fixed"
|
||||
}),
|
||||
_ => None,
|
||||
});
|
||||
Ok(json!({ "sketch": id,"attachment":attachment }))
|
||||
@@ -715,6 +759,35 @@ mod tests {
|
||||
assert_eq!(s.doc.param("w").map(|p| p.expr.as_str()), Some("2 in"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_named_view_or_camera_moves_the_view() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 10}));
|
||||
run(&mut s, "view.save", json!({"name": "Photo", "camera": {"target": [1, 2, 3], "yaw": 0.5, "pitch": -0.6, "distance": 150}}));
|
||||
let undo = s.undo.len();
|
||||
assert!(s.camera.is_none());
|
||||
let r = run(&mut s, "view.apply", json!({"view": "Photo"}));
|
||||
let c = s.camera.expect("a camera for the view");
|
||||
assert_eq!((c.target, c.yaw, c.pitch, c.distance), (solidcraft_geom::Vec3::new(1.0, 2.0, 3.0), 0.5, -0.6, 150.0));
|
||||
assert_eq!(r["camera"]["yaw"], 0.5);
|
||||
// A standard view looks from its direction and frames the model.
|
||||
run(&mut s, "view.apply", json!({"view": "top"}));
|
||||
let c = s.camera.expect("a camera for the top view");
|
||||
assert!((c.pitch - std::f64::consts::FRAC_PI_2).abs() < 1e-3 && (c.target.x - 20.0).abs() < 1e-6, "{c:?}");
|
||||
run(&mut s, "view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 1, "pitch": 0.2, "distance": 80, "fov": 0.5}}));
|
||||
assert_eq!(s.camera.map(|c| (c.yaw, c.distance, c.fov)), Some((1.0, 80.0, 0.5)));
|
||||
assert_eq!(s.undo.len(), undo, "view state, not an undo step");
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"view": "Nope"}),
|
||||
json!({"view": "Photo", "camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 1}}),
|
||||
json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0}}),
|
||||
json!({"camera": "x"}),
|
||||
] {
|
||||
assert!(s.execute("view.apply", &p).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_views_are_saved_renamed_and_deleted() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Linked designs: a component whose bodies come from another design file (Insert Design).
|
||||
//!
|
||||
//! The linked design is opened and evaluated on its own, and its solid bodies, as placed in it,
|
||||
//! join a new component through an Import feature (exact B-rep, kept in this design so it opens
|
||||
//! without the part file). The link remembers the file, relative to this design's folder once it
|
||||
//! is saved; Update Links reads the file again and replaces the Import feature's bodies, and
|
||||
//! Break Link keeps the bodies and forgets the file.
|
||||
|
||||
use std::path::{Component as PathPart, Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{DesignLink, Document, FeatureKind};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.insert_design", "Insert Design", insert_design).icon("component").params(
|
||||
"path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design; \
|
||||
name? (default: the design's name); parent?: component id or name (default: the active one); translate?: [x,y,z]",
|
||||
),
|
||||
CommandSpec::new("component.update_links", "Update Links", update_links).params(
|
||||
"component?: id or name (default: every linked component) — reads the linked designs again and replaces their bodies where they changed",
|
||||
),
|
||||
CommandSpec::new("component.break_link", "Break Link", break_link)
|
||||
.params("component: id or name of a linked component — keeps its bodies as they are and forgets the design file"),
|
||||
CommandSpec::new("component.links", "List Links", list_links)
|
||||
.noundo()
|
||||
.params("→ linked components: component, name, path, file (resolved), found"),
|
||||
];
|
||||
|
||||
/// The folder relative link paths are kept against: the design's own, once it has a file.
|
||||
fn base_dir(path: Option<&str>) -> Option<PathBuf> {
|
||||
path.and_then(|p| absolute(Path::new(p)).parent().map(Path::to_path_buf))
|
||||
}
|
||||
|
||||
/// A path as given, made absolute when the platform can, with `.` and `..` worked out.
|
||||
fn absolute(p: &Path) -> PathBuf {
|
||||
let full = std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf());
|
||||
let mut out = PathBuf::new();
|
||||
for c in full.components() {
|
||||
match c {
|
||||
PathPart::CurDir => {}
|
||||
PathPart::ParentDir if matches!(out.components().next_back(), Some(PathPart::Normal(_))) => {
|
||||
out.pop();
|
||||
}
|
||||
c => out.push(c.as_os_str()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `target` relative to the folder `base` (both absolute), when they share a root.
|
||||
fn relative(base: &Path, target: &Path) -> Option<PathBuf> {
|
||||
let (b, t): (Vec<_>, Vec<_>) = (base.components().collect(), target.components().collect());
|
||||
let root = |c: &[PathPart]| c.iter().take_while(|x| matches!(x, PathPart::Prefix(_) | PathPart::RootDir)).count();
|
||||
if root(&b) == 0 || b.get(..root(&b)) != t.get(..root(&b)) {
|
||||
return None;
|
||||
}
|
||||
let common = b.iter().zip(&t).take_while(|(x, y)| x == y).count();
|
||||
let mut out = PathBuf::new();
|
||||
for _ in common..b.len() {
|
||||
out.push("..");
|
||||
}
|
||||
for c in t.iter().skip(common) {
|
||||
out.push(c.as_os_str());
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// How a link stores `file` for a design saved at `design` (`None`: not saved yet).
|
||||
fn stored(design: Option<&str>, file: &Path) -> String {
|
||||
let file = absolute(file);
|
||||
match base_dir(design).and_then(|d| relative(&d, &file)) {
|
||||
// Relative paths are written with `/`, so a design moves between systems with its parts.
|
||||
Some(rel) => rel.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::<Vec<_>>().join("/"),
|
||||
None => file.to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file a stored link path names, for a design saved at `design`.
|
||||
fn resolve(design: Option<&str>, link: &str) -> PathBuf {
|
||||
let p = Path::new(link);
|
||||
match base_dir(design) {
|
||||
Some(d) if p.is_relative() => d.join(p),
|
||||
_ => p.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The links of `doc` stored for a design saved at `to` instead of at `from`.
|
||||
pub(super) fn rebased(doc: &Document, from: Option<&str>, to: &str) -> std::collections::BTreeMap<u64, DesignLink> {
|
||||
doc.links.iter().map(|(c, l)| (*c, DesignLink { path: stored(Some(to), &resolve(from, &l.path)), feature: l.feature })).collect()
|
||||
}
|
||||
|
||||
/// The solid bodies of the design at `file`, evaluated, as an Import feature.
|
||||
fn linked_bodies(file: &Path, own: Option<&str>) -> Result<(String, solidcraft_io::StepFeature)> {
|
||||
let shown = file.display().to_string();
|
||||
if own.is_some_and(|o| absolute(Path::new(o)) == absolute(file)) {
|
||||
return Err(EngineError::Other(format!("{shown}: a design can't be linked into itself")));
|
||||
}
|
||||
let bytes = solidcraft_io::vfs::read(&shown, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
let doc = solidcraft_io::read_design(&bytes).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
let name = doc.name.clone();
|
||||
let part = Session::new(doc);
|
||||
if let Some(e) = part.model.results.iter().find_map(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(format!("{shown}: the design has a feature that fails ({e}); fix it before linking it")));
|
||||
}
|
||||
let world = part.world_state();
|
||||
let solids: Vec<String> = world.bodies.iter().filter(|b| !b.body.is_mesh()).map(|b| b.name.clone()).collect();
|
||||
if solids.is_empty() {
|
||||
return Err(EngineError::Other(format!("{shown}: the design has no solid bodies to link")));
|
||||
}
|
||||
let step = solidcraft_io::export(&part.doc.painted(&world), &solids, solidcraft_io::Format::Step, &name)
|
||||
.map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
let mut feature = solidcraft_io::step_import_feature(&step, &shown).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
if world.bodies.len() > solids.len() {
|
||||
feature.warnings.push(format!("{} mesh bodies of {shown} are not linked", world.bodies.len() - solids.len()));
|
||||
}
|
||||
Ok((name, feature))
|
||||
}
|
||||
|
||||
fn component_key(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<u64> {
|
||||
let k = match p.get(key) {
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
_ => return Err(bad(cmd, format!("`{key}` must be a component id or name"))),
|
||||
};
|
||||
s.doc.find_component(&k).ok_or_else(|| bad(cmd, format!("no component `{k}`")))
|
||||
}
|
||||
|
||||
fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.insert_design";
|
||||
let given = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))?;
|
||||
let translate = match p.get("translate") {
|
||||
None => solidcraft_geom::Vec3::ZERO,
|
||||
Some(v) => vec3(v).filter(|t| t.is_finite() && t.len() < 1e9).ok_or_else(|| bad(cmd, "`translate` must be [x, y, z] in mm"))?,
|
||||
};
|
||||
let file = absolute(Path::new(given));
|
||||
let (design_name, sf) = linked_bodies(&file, s.path.as_deref())?;
|
||||
let name = str_(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string).unwrap_or(design_name);
|
||||
let mut create = json!({"name": name, "activate": false});
|
||||
if let Some(parent) = p.get("parent") {
|
||||
create["parent"] = parent.clone();
|
||||
}
|
||||
let made = s.execute("component.create", &create)?;
|
||||
let comp = made["component"].as_u64().ok_or_else(|| bad(cmd, "the component was not created"))?;
|
||||
let occ = made["occurrence"].as_u64();
|
||||
let id = s.doc_mut().add_feature(sf.kind, Some(&name))?;
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
f.component = comp;
|
||||
f.body_names = sf.body_names;
|
||||
}
|
||||
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| Some(o.id) == occ) {
|
||||
o.transform[3] = [translate.x, translate.y, translate.z, 1.0];
|
||||
}
|
||||
let path = stored(s.path.as_deref(), &file);
|
||||
s.doc_mut().links.insert(comp, DesignLink { path: path.clone(), feature: id });
|
||||
s.refresh();
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
|
||||
Ok(
|
||||
json!({"component": comp, "occurrence": occ, "name": name, "feature": id, "path": path, "bodies": bodies, "created_bodies": bodies, "warnings": sf.warnings}),
|
||||
)
|
||||
}
|
||||
|
||||
fn update_links(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.update_links";
|
||||
let only = if p.get("component").is_some() { Some(component_key(s, p, "component", cmd)?) } else { None };
|
||||
if let Some(c) = only
|
||||
&& !s.doc.links.contains_key(&c)
|
||||
{
|
||||
return Err(bad(cmd, "that component is not linked to a design"));
|
||||
}
|
||||
let links: Vec<(u64, DesignLink)> = s.doc.links.iter().filter(|(c, _)| only.is_none_or(|o| o == **c)).map(|(c, l)| (*c, l.clone())).collect();
|
||||
let (mut updated, mut unchanged, mut warnings) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for (comp, link) in links {
|
||||
let cname = s.doc.components.iter().find(|c| c.id == comp).map(|c| c.name.clone());
|
||||
let Some(cname) = cname else {
|
||||
// The component was deleted: the link goes with it.
|
||||
s.doc_mut().links.remove(&comp);
|
||||
continue;
|
||||
};
|
||||
let Some(FeatureKind::Import { step: old, .. }) = s.doc.feature(link.feature).filter(|f| f.component == comp).map(|f| &f.kind) else {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("{cname}: its linked bodies (feature {}) are gone; break the link or insert the design again", link.feature),
|
||||
));
|
||||
};
|
||||
let old = old.clone();
|
||||
let file = resolve(s.path.as_deref(), &link.path);
|
||||
let (_, sf) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, format!("{cname}: {e}")))?;
|
||||
warnings.extend(sf.warnings.iter().cloned());
|
||||
if matches!(&sf.kind, FeatureKind::Import { step, .. } if *step == old) {
|
||||
unchanged.push(cname);
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = s.doc_mut().feature_mut(link.feature) {
|
||||
// Bodies keep their names where the design still has as many.
|
||||
if f.body_names.len() != sf.body_names.len() {
|
||||
f.body_names = sf.body_names;
|
||||
}
|
||||
f.kind = sf.kind;
|
||||
}
|
||||
updated.push(cname);
|
||||
}
|
||||
s.refresh();
|
||||
if let Some(e) = s.doc.links.values().find_map(|l| s.model.result(l.feature).and_then(|r| r.error.clone())) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
Ok(json!({"updated": updated, "unchanged": unchanged, "warnings": warnings}))
|
||||
}
|
||||
|
||||
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.break_link";
|
||||
let comp = component_key(s, p, "component", cmd)?;
|
||||
let link = s.doc_mut().links.remove(&comp).ok_or_else(|| bad(cmd, "that component is not linked to a design"))?;
|
||||
Ok(json!({"component": comp, "path": link.path}))
|
||||
}
|
||||
|
||||
fn list_links(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let links: Vec<Value> = s
|
||||
.doc
|
||||
.links
|
||||
.iter()
|
||||
.map(|(c, l)| {
|
||||
let file = resolve(s.path.as_deref(), &l.path);
|
||||
let name = s.doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone());
|
||||
json!({"component": c, "name": name, "path": l.path, "file": file.display().to_string(), "found": file.is_file()})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"links": links}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn link_paths_are_kept_relative_to_the_design_and_follow_save_as() {
|
||||
let root = absolute(Path::new("projects"));
|
||||
let part = root.join("parts").join("plate.solidcraft");
|
||||
let design = root.join("assembly").join("case.solidcraft");
|
||||
let design = design.to_string_lossy().into_owned();
|
||||
assert_eq!(stored(Some(&design), &part), "../parts/plate.solidcraft");
|
||||
assert_eq!(absolute(&resolve(Some(&design), "../parts/plate.solidcraft")), part);
|
||||
// A design path given relative to the working folder works the same.
|
||||
assert_eq!(stored(Some("case.solidcraft"), &absolute(Path::new("parts/plate.solidcraft"))), "parts/plate.solidcraft");
|
||||
// Before the design has a file the path is kept whole; saving it makes it relative.
|
||||
let unsaved = stored(None, &part);
|
||||
assert!(Path::new(&unsaved).is_absolute(), "{unsaved}");
|
||||
let mut doc = Document::default();
|
||||
doc.links.insert(7, DesignLink { path: unsaved, feature: 3 });
|
||||
let moved = rebased(&doc, None, &design);
|
||||
assert_eq!(moved.get(&7).map(|l| l.path.as_str()), Some("../parts/plate.solidcraft"));
|
||||
let beside = root.join("parts").join("asm.solidcraft").to_string_lossy().into_owned();
|
||||
assert_eq!(
|
||||
rebased(&Document { links: moved, ..Document::default() }, Some(&design), &beside).get(&7).map(|l| l.path.as_str()),
|
||||
Some("plate.solidcraft")
|
||||
);
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn save_box(path: &Path, height: f64) {
|
||||
let mut part = Session::default();
|
||||
run(&mut part, "solid.box", json!({"length": 10, "width": 20, "height": height, "body_name": "Plate"}));
|
||||
run(&mut part, "file.save", json!({"path": path}));
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_assembly_reopens_with_its_link_updates_it_and_breaks_it() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-link-reopen-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("parts")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("asm")).unwrap();
|
||||
let part = dir.join("parts").join("plate.solidcraft");
|
||||
let case = dir.join("asm").join("case.solidcraft");
|
||||
save_box(&part, 30.0);
|
||||
// Given an assembly holding the part, saved in a folder beside the part's.
|
||||
let mut a = Session::default();
|
||||
let inserted = run(&mut a, "component.insert_design", json!({"path": part}));
|
||||
assert_eq!(inserted["name"], "plate");
|
||||
run(&mut a, "file.save", json!({"path": case}));
|
||||
assert!((volume(&mut a) - 6000.0).abs() < 1e-6);
|
||||
// When it is opened again, the link is kept relative to it and finds the part.
|
||||
let mut b = Session::default();
|
||||
run(&mut b, "doc.open", json!({"path": case}));
|
||||
let links = run(&mut b, "component.links", json!({}));
|
||||
assert_eq!(links["links"][0]["path"], "../parts/plate.solidcraft", "{links}");
|
||||
assert_eq!(links["links"][0]["found"], true, "{links}");
|
||||
assert!((volume(&mut b) - 6000.0).abs() < 1e-6);
|
||||
// An unchanged part leaves the assembly as it is; an edited one comes in on update.
|
||||
assert_eq!(run(&mut b, "component.update_links", json!({}))["unchanged"], json!(["plate"]));
|
||||
save_box(&part, 45.0);
|
||||
assert_eq!(run(&mut b, "component.update_links", json!({}))["updated"], json!(["plate"]));
|
||||
assert!((volume(&mut b) - 9000.0).abs() < 1e-6, "{}", volume(&mut b));
|
||||
// A part file that has gone is reported, and the assembly keeps the bodies it had.
|
||||
std::fs::remove_file(&part).unwrap();
|
||||
let err = b.execute("component.update_links", &json!({})).unwrap_err().to_string();
|
||||
assert!(err.contains("plate"), "{err}");
|
||||
assert!((volume(&mut b) - 9000.0).abs() < 1e-6);
|
||||
// Break Link keeps the bodies and forgets the file.
|
||||
run(&mut b, "component.break_link", json!({"component": "plate"}));
|
||||
assert_eq!(run(&mut b, "component.links", json!({}))["links"], json!([]));
|
||||
assert!((volume(&mut b) - 9000.0).abs() < 1e-6);
|
||||
assert!(b.execute("component.update_links", &json!({"component": "plate"})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_a_missing_file_or_bad_parameters_changes_nothing() {
|
||||
let mut s = Session::default();
|
||||
let missing = std::env::temp_dir().join(format!("solidcraft-no-such-part-{}.solidcraft", std::process::id()));
|
||||
assert!(s.execute("component.insert_design", &json!({"path": missing})).is_err());
|
||||
assert!(s.execute("component.insert_design", &json!({})).is_err());
|
||||
assert!(s.execute("component.insert_design", &json!({"path": "x.solidcraft", "translate": [1, "a", 2]})).is_err());
|
||||
assert!(s.doc.components.is_empty() && s.doc.links.is_empty() && s.doc.features.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -127,3 +127,175 @@ fn excessively_nested_assembly_refuses_partial_totals() {
|
||||
let error = s.execute("component.properties", &json!({"component": 0})).unwrap_err();
|
||||
assert!(error.to_string().contains("traversal limits"), "{error}");
|
||||
}
|
||||
|
||||
/// #532: a product BOM needs materials outside the built-in library (walnut, polypropylene,
|
||||
/// rubber, leather). A user-defined material with its density is assigned like a built-in one
|
||||
/// and sets the body's mass: a 10 mm walnut cube at 0.65 g/cm³ weighs 0.65 g.
|
||||
#[test]
|
||||
fn user_defined_material_sets_the_body_mass() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Slat"}));
|
||||
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Slat"], "material": "Walnut"}));
|
||||
let props = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
assert_eq!(props["bodies"][0]["material"], "Walnut", "{props}");
|
||||
close(&props["bodies"][0]["density_g_cm3"], 0.65);
|
||||
close(&props["total"]["mass_g"], 0.65);
|
||||
}
|
||||
|
||||
/// A design file path in a fresh temporary folder (removed by the caller).
|
||||
fn temp_design(tag: &str) -> (std::path::PathBuf, String) {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-user-material-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("design.solidcraft").to_string_lossy().to_string();
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
fn mass(s: &mut Session) -> Value {
|
||||
run(s, "component.properties", json!({"component": "root"}))["total"]["mass_g"].clone()
|
||||
}
|
||||
|
||||
/// #532: a design's own material is listed after the library, gives its bodies its look, and
|
||||
/// can be edited (density and name: the bodies follow) and deleted; each step is undoable.
|
||||
#[test]
|
||||
fn user_defined_material_is_edited_renamed_and_deleted() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Pad"}));
|
||||
assert!(s.execute("material.edit", &json!({"material": "Rubber", "density_g_cm3": 1.2})).is_err(), "nothing to edit yet");
|
||||
let created = run(&mut s, "material.create", json!({"name": "Rubber", "density_g_cm3": 1.5, "color": "#1c1c1c"}));
|
||||
assert_eq!(created["appearance"]["color"], "#1c1c1c", "{created}");
|
||||
let list = run(&mut s, "material.list", json!({}));
|
||||
let materials = list["materials"].as_array().unwrap();
|
||||
assert_eq!(materials.len(), solidcraft_doc::MATERIALS.len() + 1);
|
||||
let rubber = materials.last().unwrap();
|
||||
assert_eq!((rubber["name"].as_str(), rubber["user"].as_bool()), (Some("Rubber"), Some(true)));
|
||||
|
||||
// Assigned (in any case), it sets the mass and the body's colour.
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Pad"], "material": "rubber"}));
|
||||
close(&mass(&mut s), 1.5);
|
||||
let pad = s.model.state().body("Pad").unwrap().clone();
|
||||
let grey = 28.0 / 255.0;
|
||||
assert_eq!(s.doc.body_color(&pad), Some([grey, grey, grey]));
|
||||
|
||||
// A new density changes the mass; a new name carries the body with it.
|
||||
run(&mut s, "material.edit", json!({"material": "Rubber", "density_g_cm3": 1.2, "name": "Foot Rubber"}));
|
||||
close(&mass(&mut s), 1.2);
|
||||
let props = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
assert_eq!(props["bodies"][0]["material"], "Foot Rubber");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
close(&mass(&mut s), 1.5);
|
||||
assert_eq!(s.doc.materials.get("Pad").map(String::as_str), Some("Rubber"));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
|
||||
// A material in use is not deleted unless its bodies get another one.
|
||||
let refused = s.execute("material.delete", &json!({"material": "Foot Rubber"})).unwrap_err().to_string();
|
||||
assert!(refused.contains("body `Pad`"), "{refused}");
|
||||
run(&mut s, "material.delete", json!({"material": "Foot Rubber", "replace": "Oak"}));
|
||||
close(&mass(&mut s), 0.75);
|
||||
assert!(s.doc.user_materials.is_empty());
|
||||
assert!(s.execute("material.assign", &json!({"bodies": ["Pad"], "material": "Foot Rubber"})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
close(&mass(&mut s), 1.2);
|
||||
}
|
||||
|
||||
/// #532: names and densities are checked before anything changes.
|
||||
#[test]
|
||||
fn user_defined_material_refuses_bad_names_and_densities() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
|
||||
let doc = s.doc.clone();
|
||||
for p in [
|
||||
json!({"name": "Steel", "density_g_cm3": 1.0}),
|
||||
json!({"name": "walnut", "density_g_cm3": 1.0}),
|
||||
json!({"name": " ", "density_g_cm3": 1.0}),
|
||||
json!({"name": "x".repeat(129), "density_g_cm3": 1.0}),
|
||||
json!({"name": "Leather", "density_g_cm3": 0}),
|
||||
json!({"name": "Leather", "density_g_cm3": -1}),
|
||||
json!({"name": "Leather", "density_g_cm3": 1e9}),
|
||||
json!({"name": "Leather", "density_g_cm3": "heavy"}),
|
||||
json!({"name": "Leather"}),
|
||||
json!({"name": "Leather", "density_g_cm3": 0.9, "color": "#zzzzzz"}),
|
||||
json!({"name": "Leather", "density_g_cm3": 0.9, "appearance": "No Such Look"}),
|
||||
json!({"name": "Leather", "density_g_cm3": 0.9, "opacity": 0.5}),
|
||||
] {
|
||||
assert!(s.execute("material.create", &p).is_err(), "{p}");
|
||||
}
|
||||
for p in [
|
||||
json!({"material": "Steel", "density_g_cm3": 1.0}),
|
||||
json!({"material": "Walnut", "name": "Oak"}),
|
||||
json!({"material": "Walnut", "density_g_cm3": 0}),
|
||||
] {
|
||||
assert!(s.execute("material.edit", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(*s.doc, *doc);
|
||||
}
|
||||
|
||||
/// #532: a design's materials are saved with it: after reopening, the bodies keep their
|
||||
/// material, its density and its look.
|
||||
#[test]
|
||||
fn user_defined_material_survives_save_and_reopen() {
|
||||
let (dir, path) = temp_design("saved");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Tab"}));
|
||||
run(&mut s, "material.create", json!({"name": "Leather", "density_g_cm3": 0.86, "appearance": "Rubber - Black"}));
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Tab"], "material": "Leather"}));
|
||||
run(&mut s, "file.save", json!({"path": path}));
|
||||
assert!(s.doc.to_json().contains("\"solidcraft/16\""));
|
||||
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
let props = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
assert_eq!(props["bodies"][0]["material"], "Leather", "{props}");
|
||||
close(&props["total"]["mass_g"], 0.86);
|
||||
assert_eq!(s.doc.material_look("Leather").map(|l| l.name), Some("Rubber - Black".to_string()));
|
||||
run(&mut s, "material.edit", json!({"material": "Leather", "density_g_cm3": 0.9}));
|
||||
close(&mass(&mut s), 0.9);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #532: designs from before user materials open unchanged: built-in assignments keep their
|
||||
/// densities and a design without its own materials saves no `user_materials`. A damaged
|
||||
/// file's unusable density falls back to the default instead of producing nonsense masses.
|
||||
#[test]
|
||||
fn designs_without_user_materials_open_unchanged() {
|
||||
let (dir, path) = temp_design("legacy");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Plate"}));
|
||||
run(&mut s, "material.assign", json!({"bodies": ["Plate"], "material": "Steel"}));
|
||||
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
assert!(legacy.get("user_materials").is_none());
|
||||
legacy["format"] = json!("solidcraft/16");
|
||||
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
close(&mass(&mut s), 7.85);
|
||||
assert!(s.doc.user_materials.is_empty());
|
||||
assert!(s.execute("material.delete", &json!({"material": "Steel"})).is_err(), "no design materials: nothing to delete");
|
||||
|
||||
legacy["user_materials"] = json!([{"name": "Broken", "density": -3.0, "appearance": {"name": "Broken", "color": [1, 2, 3], "opacity": 9.0}}]);
|
||||
legacy["materials"] = json!({"Plate": "Broken"});
|
||||
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
close(&mass(&mut s), solidcraft_doc::DEFAULT_DENSITY);
|
||||
assert_eq!(s.doc.material_look("Broken").map(|l| l.opacity), Some(1.0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #532: configuration material cells take a design's own material and follow its rename.
|
||||
#[test]
|
||||
fn configurations_switch_to_a_user_defined_material() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Slat"}));
|
||||
run(&mut s, "material.create", json!({"name": "Walnut", "density_g_cm3": 0.65}));
|
||||
run(&mut s, "config.configure", json!({"materials": ["Slat"]}));
|
||||
run(&mut s, "config.row", json!({"name": "Walnut", "values": {"material:Slat": "walnut"}}));
|
||||
run(&mut s, "config.activate", json!({"row": "Walnut"}));
|
||||
close(&mass(&mut s), 0.65);
|
||||
run(&mut s, "material.edit", json!({"material": "Walnut", "name": "Black Walnut"}));
|
||||
run(&mut s, "config.activate", json!({"row": "Default"}));
|
||||
run(&mut s, "config.activate", json!({"row": "Walnut"}));
|
||||
assert_eq!(s.doc.materials.get("Slat").map(String::as_str), Some("Black Walnut"));
|
||||
run(&mut s, "material.delete", json!({"material": "Black Walnut", "replace": "Default"}));
|
||||
assert!(!s.doc.materials.contains_key("Slat"));
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
|
||||
match s.doc.configs.columns.get(col) {
|
||||
Some(Column::Material(_)) => {
|
||||
text = solidcraft_doc::config::material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
|
||||
text = s.doc.material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
|
||||
}
|
||||
Some(Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_)) => {
|
||||
text = match solidcraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Undo/redo, the timeline, deletion and selection.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{Feature, FeatureKind};
|
||||
|
||||
@@ -22,7 +24,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("delete")
|
||||
.key("Delete")
|
||||
.params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"),
|
||||
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name"),
|
||||
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name (references to it follow)"),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
|
||||
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
|
||||
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
|
||||
@@ -163,15 +165,25 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let name =
|
||||
str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?;
|
||||
let old = s.doc.feature(id).map(|f| f.name.clone()).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
if s.doc.features.iter().any(|f| f.name == name && f.id != id) {
|
||||
return Err(bad(cmd, format!("another feature is called `{name}`")));
|
||||
}
|
||||
if s.doc.feature(id).is_some_and(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })) {
|
||||
super::features::check_plane_name(name, cmd)?;
|
||||
}
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
if name == old {
|
||||
return Ok(json!({"feature": id, "name": name}));
|
||||
}
|
||||
let names = BTreeMap::from([(old.clone(), name.to_string())]);
|
||||
let sketches = BTreeMap::new();
|
||||
let doc = s.doc_mut();
|
||||
if let Some(f) = doc.feature_mut(id) {
|
||||
f.name = name.to_string();
|
||||
}
|
||||
for f in doc.features.iter_mut() {
|
||||
f.kind.remap_refs(&sketches, &names);
|
||||
}
|
||||
Ok(json!({"feature": id, "name": name}))
|
||||
}
|
||||
|
||||
@@ -322,6 +334,14 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
nf.edge_refs = solidcraft_doc::edge_refs(&before, body, a);
|
||||
nf.edge_names = solidcraft_doc::edge_names_for(&before, body, a);
|
||||
}
|
||||
// New or reordered flange edges: their names now, so each edge's own angle and height stay
|
||||
// with the edge they were given for.
|
||||
if let (FeatureKind::SheetFlange { edges: a, .. }, FeatureKind::SheetFlange { edges: b, .. }) = (&nf.kind, &f.kind) {
|
||||
let points = |e: &[solidcraft_doc::FlangeEdge]| e.iter().map(|x| x.point).collect::<Vec<_>>();
|
||||
if points(a) != points(b) {
|
||||
nf.edge_names = solidcraft_doc::sheet::sheet_edge_names(&s.model.state_before(id), &points(a));
|
||||
}
|
||||
}
|
||||
// Profiles picked by index or point are kept by their curves, as when the feature was made.
|
||||
if nf.kind.clone().profile_sels_mut() != f.kind.clone().profile_sels_mut() {
|
||||
super::features::stable_profiles(s, &mut nf.kind)?;
|
||||
|
||||
@@ -136,30 +136,42 @@ fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 40 x 30 x 20 box whose top is split at x = 20 and its left half raised 5 by Offset Face:
|
||||
/// the raised top has no construction certificate and no exact history name yet (#494).
|
||||
fn raised_half(s: &mut Session, body: &str) {
|
||||
run(s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":body}));
|
||||
run(s, "solid.split_face", json!({"faces":[[10,15,20]],"plane":{"origin":[20,0,0],"normal":[1,0,0]}}));
|
||||
run(s, "solid.offset_face", json!({"faces":[[10,15,20]],"distance":5}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let occurrence = component["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Stock"}));
|
||||
run(&mut s, "solid.press_pull", json!({"face":[5,5,20],"distance":5}));
|
||||
// A face Offset Face raised has no construction certificate or exact history name yet:
|
||||
// the legacy path.
|
||||
raised_half(&mut s, "Stock");
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":false}));
|
||||
let state = s.model.state();
|
||||
let index = state
|
||||
// The raised half's top.
|
||||
let end = state
|
||||
.body("Stock")
|
||||
.unwrap()
|
||||
.body
|
||||
.faces(1e-4)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.into_iter()
|
||||
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 25.0).abs() < 1e-6)
|
||||
.unwrap()
|
||||
.index;
|
||||
.unwrap();
|
||||
let before = s.doc.to_json();
|
||||
let error =
|
||||
super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,25],"body":"Stock","index":index,"path":[occurrence]}), "internal")
|
||||
.unwrap_err();
|
||||
let error = super::super::sketch::face::capture_internal(
|
||||
&s,
|
||||
&json!({"face":[40,15,25],"body":"Stock","index":end.index,"path":[occurrence]}),
|
||||
"internal",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("capture the source occurrence position"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
@@ -229,3 +241,153 @@ fn blind_cut_inside_a_cylinder_keeps_only_planes_and_cylinders() {
|
||||
assert_eq!(body["face_types"], json!({"cylinder": 2, "plane": 12}), "{m}");
|
||||
assert_eq!(body["faces"], 14, "{m}");
|
||||
}
|
||||
|
||||
fn face_anchor(s: &Session, sketch: u64) -> solidcraft_doc::FaceAnchor {
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch).unwrap().kind else {
|
||||
panic!("sketch {sketch} has no face anchor")
|
||||
};
|
||||
(**anchor).clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_keeps_exact_face_attachments_from_both_operands() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-10,-10],"p1":[10,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let base = run(&mut s, "solid.extrude", json!({"distance":40,"name":"Base"}))["feature"].as_u64().unwrap();
|
||||
// A cross bar joined through the base (the shape of #420) and a boss joined on its top.
|
||||
run(&mut s, "sketch.create", json!({"plane":"YZ"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,20],"radius":5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":20,"symmetric":true,"operation":"join"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,40]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let boss = run(&mut s, "solid.extrude", json!({"distance":10,"operation":"join","name":"Boss"}))["feature"].as_u64().unwrap();
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
|
||||
// The base's top around the boss, and the boss's own top (the Union's second operand).
|
||||
let on_base = run(&mut s, "sketch.create", json!({"plane":{"face":[8,8,40]}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let on_boss = run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,50]}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(face_anchor(&s, on_base).target.origin_feature, base);
|
||||
assert_eq!(face_anchor(&s, on_boss).target.origin_feature, boss);
|
||||
|
||||
// The attachments follow their faces through an upstream edit and a reopen.
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Base","set":{"extent":{"distance":"50"}}}));
|
||||
s.refresh();
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none()), "{:?}", s.model.results);
|
||||
let state = s.model.state();
|
||||
assert!((state.sketch(on_base).unwrap().plane.origin.z - 50.0).abs() < 1e-6);
|
||||
assert!((state.sketch(on_boss).unwrap().plane.origin.z - 60.0).abs() < 1e-6);
|
||||
let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
reopened.refresh();
|
||||
assert!(reopened.model.results.iter().all(|result| result.error.is_none()), "{:?}", reopened.model.results);
|
||||
assert!((reopened.model.state().sketch(on_boss).unwrap().plane.origin.z - 60.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_gives_a_face_merged_from_both_operands_its_history_name() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10}));
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"corner":[20,0,0],"operation":"join"}));
|
||||
// Both boxes' tops share one support: no single construction certificate owns it, but its
|
||||
// exact history names it (#491).
|
||||
let top = run(&mut s, "sketch.create", json!({"plane":{"face":[10,10,10]}}))["sketch"].as_u64().unwrap();
|
||||
assert_eq!(face_anchor(&s, top).target.certificate, Some(solidcraft_doc::config::FaceCertificate::Lineage));
|
||||
// An unshared support keeps its box role.
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let side = run(&mut s, "sketch.create", json!({"plane":{"face":[0,10,5]}}))["sketch"].as_u64().unwrap();
|
||||
assert!(matches!(face_anchor(&s, side).target.certificate, Some(solidcraft_doc::config::FaceCertificate::Box { axis: 0, high: false })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_covering_a_whole_attached_face_loses_the_attachment_without_moving_it() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10,"name":"Block"}));
|
||||
let top = run(&mut s, "sketch.create", json!({"plane":{"face":[30,15,10]},"name":"Top"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[30,15],"radius":3}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Top","distance":5,"operation":"join","name":"Peg"}));
|
||||
// An earlier join whose roof replaces Block's whole top.
|
||||
run(&mut s, "timeline.roll_to", json!({"feature":"Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"B"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-5,-5],"p1":[45,35]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"B","distance":20,"operation":"join","name":"Boss"}));
|
||||
run(&mut s, "timeline.roll_to", json!({}));
|
||||
let failed = |s: &Session, id: u64| s.model.results.iter().find(|r| r.id == id).unwrap().error.is_some();
|
||||
assert!(failed(&s, top), "the attachment never follows the substitute roof");
|
||||
assert!(s.model.state().sketch(top).is_none());
|
||||
run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true}));
|
||||
assert!(!failed(&s, top));
|
||||
assert!((s.model.state().sketch(top).unwrap().plane.origin.z - 10.).abs() < 1e-9);
|
||||
}
|
||||
|
||||
fn certificate_of(s: &Session, sketch: u64) -> Option<solidcraft_doc::config::FaceCertificate> {
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch).unwrap().kind else {
|
||||
return None;
|
||||
};
|
||||
anchor.target.certificate.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_primitive_caps_take_attachments_that_follow_edits_and_reopen() {
|
||||
use solidcraft_doc::config::FaceCertificate;
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":40,"name":"Post"}));
|
||||
let top = run(&mut s, "sketch.create", json!({"plane":{"face":[2,1,40]}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let bottom = run(&mut s, "sketch.create", json!({"plane":{"face":[2,1,0]}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(certificate_of(&s, top), Some(FaceCertificate::CylinderCap { high: true }));
|
||||
assert_eq!(certificate_of(&s, bottom), Some(FaceCertificate::CylinderCap { high: false }));
|
||||
let heights = |s: &Session| {
|
||||
let state = s.model.state();
|
||||
(state.sketch(top).unwrap().plane.origin.z, state.sketch(bottom).unwrap().plane.origin.z)
|
||||
};
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Post","set":{"height":"55","base":[0,0,5]}}));
|
||||
s.refresh();
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none()), "{:?}", s.model.results);
|
||||
let (high, low) = heights(&s);
|
||||
assert!((high - 60.).abs() < 1e-9 && (low - 5.).abs() < 1e-9, "{high} {low}");
|
||||
let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
reopened.refresh();
|
||||
assert!(reopened.model.results.iter().all(|result| result.error.is_none()), "{:?}", reopened.model.results);
|
||||
assert_eq!(heights(&reopened), (high, low));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossed_cylinder_primitives_keep_their_caps_attachable() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":40}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius":5,"height":40,"base":[-20,0,20],"axis":"X","operation":"join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
for face in [[0., 0., 40.], [20., 0., 20.]] {
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane":{"face":face}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(certificate_of(&s, sketch).is_some(), "{face:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shelled_cylinder_floor_keeps_its_cap_identity() {
|
||||
use solidcraft_doc::config::FaceCertificate;
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":40}));
|
||||
run(&mut s, "solid.shell", json!({"faces":[[2,1,40]],"thickness":2}));
|
||||
let floor = run(&mut s, "sketch.create", json!({"plane":{"face":[2,1,2]}}))["sketch"].as_u64().unwrap();
|
||||
assert!(
|
||||
matches!(
|
||||
certificate_of(&s, floor),
|
||||
Some(FaceCertificate::ShellPlanar { source, inner: true, .. }) if *source == FaceCertificate::CylinderCap { high: false }
|
||||
),
|
||||
"{:?}",
|
||||
certificate_of(&s, floor)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("fillet")
|
||||
.key("F")
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce (exact, the default: a radius too large for the geometry around the edges is refused; reduce: it is reduced to the largest that fits all of them, returned as `radius_applied` and noted in the feature's warning)"),
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other); radius2?; start?: [x,y,z]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); fit?: exact|reduce (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning)"),
|
||||
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
|
||||
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
|
||||
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
||||
@@ -107,7 +107,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("presspull")
|
||||
.key("Q")
|
||||
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
|
||||
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"),
|
||||
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place)"),
|
||||
];
|
||||
|
||||
/// Where an extrude starts and which way is positive: a point inside the (first) profile or
|
||||
@@ -365,6 +365,8 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
|
||||
/// `add_feature`; `world` is false when `kind` is already in the active component's frame.
|
||||
fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> Result<Value> {
|
||||
let name = str_(p, "name");
|
||||
// The bodies before the feature, to say which ones it changed or removed.
|
||||
let before = s.model.state();
|
||||
// Commands speak world coordinates; the feature is authored in the active component.
|
||||
let mut kind = kind;
|
||||
if world {
|
||||
@@ -387,9 +389,11 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
(solidcraft_doc::edge_refs(&st, body, edges), solidcraft_doc::edge_names_for(&st, body, edges))
|
||||
}
|
||||
// Sheet metal edges are named by the sheet model.
|
||||
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => {
|
||||
(Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), edges))
|
||||
FeatureKind::SheetFlange { edges, .. } => {
|
||||
let points: Vec<_> = edges.iter().map(|e| e.point).collect();
|
||||
(Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), &points))
|
||||
}
|
||||
FeatureKind::SheetHem { edges, .. } => (Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), edges)),
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let face_names = {
|
||||
@@ -470,8 +474,20 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
}
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
// The bodies this feature made, apart from the ones it changed or that were there (#374).
|
||||
let created: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
|
||||
// Bodies that were there before and now have other geometry, and bodies that are gone.
|
||||
let old: std::collections::HashMap<&str, &solidcraft_doc::kernel::Body> = before.bodies.iter().map(|b| (b.name.as_str(), &b.body)).collect();
|
||||
let now: std::collections::HashSet<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
|
||||
let changed: Vec<&str> =
|
||||
st.bodies.iter().filter(|b| b.feature != id && old.get(b.name.as_str()).is_some_and(|o| !o.same(&b.body))).map(|b| b.name.as_str()).collect();
|
||||
let removed: Vec<&str> = before.bodies.iter().map(|b| b.name.as_str()).filter(|n| !now.contains(n)).collect();
|
||||
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"feature": id, "name": fname, "bodies": bodies, "ms": r.map(|r| r.ms)}))
|
||||
// A warning (a Boolean that needed a retry, a reference found again elsewhere) is part of
|
||||
// the reply: a script or an agent sees it without asking the timeline (#616).
|
||||
let warning = r.as_ref().and_then(|r| r.warning.clone());
|
||||
Ok(json!({"feature": id, "name": fname, "bodies": bodies, "created_bodies": created, "changed_bodies": changed,
|
||||
"removed_bodies": removed, "warning": warning, "ms": r.map(|r| r.ms)}))
|
||||
}
|
||||
|
||||
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -794,11 +810,15 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "`fit: reduce` takes a constant radius"));
|
||||
}
|
||||
let mut out = add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string), style, fit })?;
|
||||
// The radius the fillet used, when `fit: reduce` reduced it.
|
||||
// The radii the fillet used, when `fit: reduce` reduced it.
|
||||
if let Some(id) = out.get("feature").and_then(Value::as_u64)
|
||||
&& let Some(r) = s.model.result(id).and_then(|res| res.state.fitted_radius(id))
|
||||
&& let Some(state) = s.model.result(id).map(|res| &res.state)
|
||||
&& let Some(r) = state.fitted_radius(id)
|
||||
{
|
||||
out["radius_applied"] = serde_json::json!(r);
|
||||
let each: Vec<Value> =
|
||||
state.fitted_edges(id).unwrap_or_default().into_iter().map(|(p, r)| serde_json::json!({"edge": [p.x, p.y, p.z], "radius": r})).collect();
|
||||
out["radii_applied"] = Value::Array(each);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -873,7 +893,11 @@ fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(a) = &angle {
|
||||
check_expr(s, a, Kind::Angle, cmd, "angle")?;
|
||||
}
|
||||
add_feature(s, p, FeatureKind::Move { bodies, translate: t, rotate_axis: p.get("axis").and_then(vec3), angle })
|
||||
add_feature(
|
||||
s,
|
||||
p,
|
||||
FeatureKind::Move { bodies, translate: t, rotate_axis: p.get("axis").and_then(vec3), angle, copy: bool_(p, "copy").unwrap_or(false) },
|
||||
)
|
||||
}
|
||||
|
||||
fn source_features(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
@@ -1302,7 +1326,33 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] on the face to drill to"))?),
|
||||
None => None,
|
||||
};
|
||||
add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread, to })
|
||||
// A hole picked on a body of another component (a moved one, shown where it is placed) cuts
|
||||
// that body: the pick is already in that body's frame, and the component's own bodies alone
|
||||
// would leave nothing to cut.
|
||||
let mut p = p.clone();
|
||||
if points.is_none()
|
||||
&& p.get("participants").is_none()
|
||||
&& let Some(body) = body_of_other_component_at(s, position)
|
||||
{
|
||||
p["participants"] = json!([body]);
|
||||
}
|
||||
add_feature(s, &p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread, to })
|
||||
}
|
||||
|
||||
/// The body whose face is nearest `p` (within its pick tolerance), when it belongs to another
|
||||
/// component than the active one.
|
||||
fn body_of_other_component_at(s: &Session, p: Vec3) -> Option<String> {
|
||||
let st = s.model.state();
|
||||
let (_, body) = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| !b.body.is_mesh())
|
||||
.filter_map(|b| {
|
||||
let (d, _) = solidcraft_doc::appearance::nearest_face(b, p)?;
|
||||
(d <= solidcraft_doc::appearance::pick_tolerance(b)).then_some((d, b))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))?;
|
||||
(s.doc.model_body_component(body) != s.active_component).then(|| body.name.clone())
|
||||
}
|
||||
|
||||
/// The points of a sketch a hole drills at when only the sketch is given.
|
||||
@@ -1530,3 +1580,14 @@ mod offset_edit_guard_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "offset_plane_face_tests.rs"]
|
||||
mod offset_plane_face_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lineage_face_tests.rs"]
|
||||
mod lineage_face_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "stacked_face_tests.rs"]
|
||||
mod stacked_face_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "solid_move_copy_tests.rs"]
|
||||
mod solid_move_copy_tests;
|
||||
|
||||
@@ -602,3 +602,79 @@ fn cylinder_auto_inference_refuses_ambiguity_curves_and_seam_crossing_atomically
|
||||
assert_eq!(exact_cylinder_volume(&s), volume);
|
||||
}
|
||||
}
|
||||
|
||||
/// The design from #396: a loft from a Ø12 circle on a 40 × 30 × 20 box's top to a hexagon 15 up.
|
||||
fn loft_on_box(op: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [20, 15]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 20, "body_name": "Box"}));
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 20, "name": "A"}));
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 35, "name": "B"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "A", "name": "L0"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "diameter": 12}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "B", "name": "L1"}));
|
||||
run(&mut s, "sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 4, "sides": 6}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "L0"}, {"sketch": "L1"}], "operation": op}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loft_joined_onto_a_box_exports_one_connected_mesh() {
|
||||
// #396: one body, but its two shells only touched, and the STL came out in two pieces.
|
||||
let mut apart = loft_on_box("new_body");
|
||||
let m = measure(&mut apart);
|
||||
assert_eq!(m["body_count"], 2);
|
||||
let loft = m["bodies"][1]["volume_mm3"].as_f64().unwrap();
|
||||
let mut s = loft_on_box("join");
|
||||
let m = measure(&mut s);
|
||||
assert_eq!(m["body_count"], 1);
|
||||
assert_eq!(m["bodies"][0]["kernel"]["shells"], 1);
|
||||
let v = volume(&mut s);
|
||||
assert!(rel(v, 24000.0 + loft) < 1e-5, "{v} vs {}", 24000.0 + loft);
|
||||
// The exported mesh, its vertices merged, is one connected piece.
|
||||
let mesh = s.model.state().body("Box").unwrap().body.tessellate(0.01).unwrap();
|
||||
let (positions, triangles) = solidcraft_io::weld(&mesh.positions, &mesh.triangles, 1e-4);
|
||||
let mut piece: Vec<usize> = (0..positions.len()).collect();
|
||||
let root = |piece: &mut Vec<usize>, mut i: usize| {
|
||||
while piece[i] != i {
|
||||
i = piece[i];
|
||||
}
|
||||
i
|
||||
};
|
||||
for t in &triangles {
|
||||
let r0 = root(&mut piece, t[0] as usize);
|
||||
for k in [1, 2] {
|
||||
let rk = root(&mut piece, t[k] as usize);
|
||||
piece[rk] = r0;
|
||||
}
|
||||
}
|
||||
let pieces: std::collections::BTreeSet<usize> = triangles.iter().map(|t| root(&mut piece, t[0] as usize)).collect();
|
||||
assert_eq!(pieces.len(), 1);
|
||||
}
|
||||
|
||||
/// A cone that takes only a 1 mm chamfer off a cylinder's top edge leaves nearly all of the
|
||||
/// cylinder inside it; the intersection is still the chamfered cylinder (#443).
|
||||
#[test]
|
||||
fn loft_intersect_that_trims_one_edge_changes_the_body() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 35}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "body_name": "Body"}));
|
||||
for (name, offset, radius) in [("A", -1, 45), ("B", 11, 33)] {
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": offset, "name": name}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": radius}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
}
|
||||
run(&mut s, "solid.loft", json!({"sections": [{"sketch": "A"}, {"sketch": "B"}], "operation": "intersect"}));
|
||||
// r 35 up to z = 9, then a frustum to r 34 at z = 10.
|
||||
let want = std::f64::consts::PI * (35.0 * 35.0 * 9.0 + (35.0 * 35.0 + 35.0 * 34.0 + 34.0 * 34.0) / 3.0);
|
||||
let got = volume(&mut s);
|
||||
assert!((got - want).abs() < want * 2e-4, "{got} vs {want}");
|
||||
let m = measure(&mut s);
|
||||
assert_eq!(m["bodies"][0]["face_types"]["cone"], 1, "{m}");
|
||||
}
|
||||
|
||||
+326
-19
@@ -1,5 +1,8 @@
|
||||
//! Documents and files: new, open (designs and STEP), insert STEP, save, export.
|
||||
|
||||
#[path = "file_thumbnail.rs"]
|
||||
mod thumbnail;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::Document;
|
||||
use solidcraft_io::Format;
|
||||
@@ -27,11 +30,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("save")
|
||||
.key("Ctrl+S")
|
||||
.noundo()
|
||||
.params("path? (default: current file); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); preview?: bool (experimental saved picture, default: false)"),
|
||||
.params("path? (default: current file); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (warm native geometry, default: false); preview?: bool (saved display meshes, default: false)"),
|
||||
CommandSpec::new("file.save_as", "Save As", save_as)
|
||||
.icon("save")
|
||||
.noundo()
|
||||
.params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); preview?: bool (experimental saved picture, default: false)"),
|
||||
.params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (warm native geometry, default: false); preview?: bool (saved display meshes, default: false)"),
|
||||
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list)
|
||||
.noundo()
|
||||
.params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"),
|
||||
@@ -78,7 +81,7 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
|
||||
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "warnings": sf.warnings}))
|
||||
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": sf.warnings}))
|
||||
}
|
||||
|
||||
/// Add a MeshImport feature for a 3MF or STL file.
|
||||
@@ -98,7 +101,7 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
|
||||
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "warnings": mf.warnings}))
|
||||
Ok(json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": mf.warnings}))
|
||||
}
|
||||
|
||||
fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -131,35 +134,115 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
|
||||
}
|
||||
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let doc = solidcraft_io::read_design(&bytes)?;
|
||||
if let Some(notify) = s.open_preview.take()
|
||||
&& let Some(preview) = solidcraft_io::read_design_preview(&bytes)
|
||||
{
|
||||
notify(preview);
|
||||
let mut doc = solidcraft_io::read_design_file(path, s.open_preview.take()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if let Some(stem) = default_name_for(&doc.name, path) {
|
||||
doc.name = stem;
|
||||
}
|
||||
*s = Session::new(doc);
|
||||
s.path = Some(path.to_string());
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors}))
|
||||
Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "name": s.doc.name}))
|
||||
}
|
||||
|
||||
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool) -> Result<Value> {
|
||||
/// The name a design never given one by its user is shown under.
|
||||
const DEFAULT_NAME: &str = "Untitled";
|
||||
|
||||
/// A design still under the default name takes its file's name (`bracket.solidcraft` →
|
||||
/// "bracket"); a name the user chose is kept. `None` when nothing changes.
|
||||
fn default_name_for(name: &str, path: &str) -> Option<String> {
|
||||
let name = name.trim();
|
||||
if !name.is_empty() && name != DEFAULT_NAME {
|
||||
return None;
|
||||
}
|
||||
let stem = std::path::Path::new(path).file_stem()?.to_string_lossy().trim().to_string();
|
||||
(!stem.is_empty()).then_some(stem)
|
||||
}
|
||||
|
||||
/// Rename the design, and the undo and redo states that still carry the old name, so undo
|
||||
/// does not bring the default name back and leave the design looking changed.
|
||||
fn rename_design(s: &mut Session, from: &str, to: &str) {
|
||||
s.doc_mut().name = to.to_string();
|
||||
for snap in s.undo.iter_mut().chain(s.redo.iter_mut()) {
|
||||
if snap.doc.name == from {
|
||||
std::sync::Arc::make_mut(&mut snap.doc).name = to.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool) -> Result<Value> {
|
||||
// Saving an unnamed design names it after its file (not an undoable edit).
|
||||
let renamed = default_name_for(&s.doc.name, path).map(|stem| (s.doc.name.clone(), stem));
|
||||
if let Some((from, to)) = &renamed {
|
||||
rename_design(s, from, to);
|
||||
}
|
||||
let r = write_file(s, path, format, compression, preview, cache);
|
||||
if r.is_err()
|
||||
&& let Some((from, to)) = &renamed
|
||||
{
|
||||
rename_design(s, to, from);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, preview: bool, cache: bool) -> Result<Value> {
|
||||
let compression = match compression {
|
||||
"fast" => solidcraft_io::DesignCompression::Fast,
|
||||
"compact" => solidcraft_io::DesignCompression::Compact,
|
||||
_ => return Err(bad("file.save", "compression must be fast or compact")),
|
||||
};
|
||||
// Linked designs are kept relative to the folder the design is saved in.
|
||||
let links = super::component_link::rebased(&s.doc, s.path.as_deref(), path);
|
||||
let old_links = (links != s.doc.links).then(|| std::mem::replace(&mut s.doc_mut().links, links));
|
||||
let r = write_bytes(s, path, format, compression, preview, cache);
|
||||
if r.is_err()
|
||||
&& let Some(old) = old_links
|
||||
{
|
||||
s.doc_mut().links = old;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn write_bytes(
|
||||
s: &mut Session,
|
||||
path: &str,
|
||||
format: &str,
|
||||
compression: solidcraft_io::DesignCompression,
|
||||
preview: bool,
|
||||
cache: bool,
|
||||
) -> Result<Value> {
|
||||
let bytes = match format {
|
||||
"container" => solidcraft_io::write_design_container_with_preview(&s.doc, compression, preview.then(|| saved_preview(s)).flatten().as_ref())?,
|
||||
"json" => solidcraft_io::write_design(&s.doc),
|
||||
"container" => s.design_container(compression, preview, cache)?,
|
||||
"json" => solidcraft_io::write_design(&s.design()),
|
||||
_ => return Err(bad("file.save", "format must be container or json")),
|
||||
};
|
||||
// Atomic, keeping the version being replaced as `<file>.bak`.
|
||||
solidcraft_io::write_atomic(std::path::Path::new(path), &bytes, true).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
s.path = Some(path.to_string());
|
||||
s.mark_saved();
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "name": s.doc.name}))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Container bytes for desktop and browser saves. Only already evaluated bodies
|
||||
/// and warm meshes are eligible; saving never forces evaluation or tessellation.
|
||||
pub fn design_container(&self, compression: solidcraft_io::DesignCompression, preview: bool, cache: bool) -> solidcraft_io::Result<Vec<u8>> {
|
||||
let geometry = cache.then(|| saved_geometry(self)).flatten();
|
||||
let preview = preview.then(|| saved_preview(self)).flatten();
|
||||
let thumbnail = cache.then(|| thumbnail::saved(self)).flatten();
|
||||
solidcraft_io::write_design_container_with_thumbnail(&self.design(), compression, preview.as_ref(), geometry.as_deref(), thumbnail.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
fn saved_geometry(s: &Session) -> Option<Vec<solidcraft_io::DesignGeometry>> {
|
||||
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
|
||||
return None;
|
||||
}
|
||||
let world = s.cached_world_state()?;
|
||||
if world.bodies.is_empty() || world.bodies.len() > 4096 {
|
||||
return None;
|
||||
}
|
||||
let painted = s.doc.painted(&world);
|
||||
Some(painted.bodies.iter().map(|b| solidcraft_io::DesignGeometry { name: b.name.clone(), body: b.body.clone() }).collect())
|
||||
}
|
||||
|
||||
/// Cache only warm display geometry. A save never waits for an unevaluated mesh.
|
||||
@@ -203,6 +286,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
str_(p, "format").unwrap_or(default_design_format(&path)),
|
||||
str_(p, "compression").unwrap_or("fast"),
|
||||
bool_(p, "preview").unwrap_or(false),
|
||||
bool_(p, "cache").unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -218,6 +302,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
str_(p, "format").unwrap_or(default_design_format(&path)),
|
||||
str_(p, "compression").unwrap_or("fast"),
|
||||
bool_(p, "preview").unwrap_or(false),
|
||||
bool_(p, "cache").unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -228,7 +313,7 @@ fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Resu
|
||||
if !bodies.is_empty() {
|
||||
return Err(bad("file.export", "design JSON exports the complete document; omit bodies"));
|
||||
}
|
||||
solidcraft_io::write_design(&s.doc)
|
||||
solidcraft_io::write_design(&s.design())
|
||||
} else if format == Format::Step && bodies.is_empty() && !s.doc.occurrences.is_empty() {
|
||||
solidcraft_io::step_assembly(&s.doc, &s.doc.painted(&s.model.state()), &name)?
|
||||
} else {
|
||||
@@ -267,7 +352,10 @@ fn recover(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "doc.recover";
|
||||
let dir = recovery_dir(p, cmd)?;
|
||||
let id = str_(p, "id").ok_or_else(|| bad(cmd, "`id` is required (see doc.recovery_list)"))?;
|
||||
let (doc, entry) = crate::recovery::load(&dir, id)?;
|
||||
let (mut doc, entry) = crate::recovery::load(&dir, id)?;
|
||||
if let Some(stem) = entry.path.as_deref().and_then(|path| default_name_for(&doc.name, path)) {
|
||||
doc.name = stem;
|
||||
}
|
||||
*s = Session::new(doc);
|
||||
s.path = entry.path.clone();
|
||||
s.mark_unsaved();
|
||||
@@ -313,7 +401,7 @@ mod tests {
|
||||
for index in if round % 2 == 0 { [0, 1] } else { [1, 0] } {
|
||||
let path = target.join(if index == 0 { "issue303-warm.json" } else { "issue303-warm.solidcraft" });
|
||||
let began = std::time::Instant::now();
|
||||
session.execute("file.save", &json!({"path":path,"preview":true})).unwrap();
|
||||
session.execute("file.save", &json!({"path":path,"preview":true,"cache":false})).unwrap();
|
||||
samples[index].push(began.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
@@ -350,7 +438,9 @@ mod tests {
|
||||
}
|
||||
let save_path = std::env::temp_dir().join(format!("solidcraft-preview-save-{}.solidcraft", std::process::id()));
|
||||
s.execute("file.save", &json!({"path":save_path})).unwrap();
|
||||
assert!(solidcraft_io::read_design_preview(&std::fs::read(&save_path).unwrap()).is_none(), "ordinary saves omit redundant display data");
|
||||
let saved = std::fs::read(&save_path).unwrap();
|
||||
assert!(solidcraft_io::read_design_geometry(&saved).is_none(), "ordinary saves omit native geometry caches");
|
||||
assert!(!saved.windows(b"cache/preview.bin".len()).any(|s| s == b"cache/preview.bin"), "ordinary saves omit redundant display meshes");
|
||||
s.execute("file.save", &json!({"path":save_path,"preview":true})).unwrap();
|
||||
assert!(solidcraft_io::read_design_preview(&std::fs::read(&save_path).unwrap()).is_some());
|
||||
let _ = std::fs::remove_file(&save_path);
|
||||
@@ -393,6 +483,7 @@ mod tests {
|
||||
}
|
||||
*s.world_cache.lock().unwrap() = None;
|
||||
assert!(saved_preview(&s).is_none());
|
||||
assert!(saved_geometry(&s).is_none());
|
||||
assert!(s.world_cache.lock().unwrap().is_none(), "saving must not prepare cold placements");
|
||||
for body in &s.world_state().bodies {
|
||||
body.mesh();
|
||||
@@ -404,6 +495,7 @@ mod tests {
|
||||
}
|
||||
assert!(!s.pending_moves.is_empty());
|
||||
assert!(saved_preview(&s).is_none(), "uncaptured positions are not in the saved document");
|
||||
assert!(saved_geometry(&s).is_none());
|
||||
s.execute("component.capture_position", &json!({})).unwrap();
|
||||
for body in &s.world_state().bodies {
|
||||
body.mesh();
|
||||
@@ -418,6 +510,192 @@ mod tests {
|
||||
}
|
||||
assert!(editing.active_sketch.is_some());
|
||||
assert!(saved_preview(&editing).is_none());
|
||||
assert!(saved_geometry(&editing).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_save_as_require_explicit_native_cache_opt_in() {
|
||||
let mut session = Session::new(Document::new("Cache opt-in"));
|
||||
session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
let world = session.world_state();
|
||||
for body in &world.bodies {
|
||||
body.mesh();
|
||||
}
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-cache-opt-in-{}.solidcraft", std::process::id()));
|
||||
for command in ["file.save", "file.save_as"] {
|
||||
for cache in [None, Some(true), Some(false), None] {
|
||||
let mut params = json!({"path":path});
|
||||
if let Some(cache) = cache {
|
||||
params["cache"] = json!(cache);
|
||||
}
|
||||
session.execute(command, ¶ms).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert_eq!(solidcraft_io::read_design_geometry(&bytes).is_some(), cache == Some(true), "{command}: {cache:?}");
|
||||
assert_eq!(solidcraft_io::read_design_thumbnail(&bytes).is_some(), cache == Some(true), "{command}: {cache:?}");
|
||||
assert!(
|
||||
!bytes.windows(b"cache/preview.bin".len()).any(|entry| entry == b"cache/preview.bin"),
|
||||
"display meshes require preview: true"
|
||||
);
|
||||
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
|
||||
}
|
||||
}
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::fs::remove_file(format!("{}.bak", path.display())).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_cache_does_not_force_meshes_or_supply_editing_state() {
|
||||
let mut session = Session::new(Document::new("Native cache authority"));
|
||||
session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
let world = session.world_state();
|
||||
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()));
|
||||
let mut cached = saved_geometry(&session).unwrap();
|
||||
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
|
||||
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()), "saving native geometry must not triangulate");
|
||||
assert!(solidcraft_io::read_design_geometry(&bytes).is_some());
|
||||
assert!(
|
||||
solidcraft_io::read_design_geometry(&session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap()).is_none()
|
||||
);
|
||||
cached[0].body = solidcraft_kernel::box_solid(solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::new(100., 100., 100.)).unwrap();
|
||||
let bytes =
|
||||
solidcraft_io::write_design_container_with_cache(&session.doc, solidcraft_io::DesignCompression::Fast, None, Some(&cached)).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-native-forged-{}.solidcraft", std::process::id()));
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let seen = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let callback_seen = seen.clone();
|
||||
let mut reopened = Session {
|
||||
open_preview: Some(Box::new(move |p| {
|
||||
callback_seen.store(!p.meshes.is_empty(), std::sync::atomic::Ordering::Relaxed);
|
||||
})),
|
||||
..Session::default()
|
||||
};
|
||||
reopened.execute("doc.open", &json!({"path":path})).unwrap();
|
||||
assert!(seen.load(std::sync::atomic::Ordering::Relaxed));
|
||||
assert_eq!(reopened.doc, session.doc);
|
||||
let measured = reopened.execute("inspect.measure", &json!({})).unwrap();
|
||||
assert!((measured["bodies"][0]["volume_mm3"].as_f64().unwrap() - 6000.).abs() < 1e-6);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual native cache save benchmark; generate issue303-step.json first"]
|
||||
fn benchmark_native_cache_save() {
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target"));
|
||||
let input = std::env::var_os("SOLIDCRAFT_BENCH_FILE").map(std::path::PathBuf::from).unwrap_or_else(|| target.join("issue303-step.json"));
|
||||
let mut session = Session::new(solidcraft_io::read_design(&std::fs::read(input).unwrap()).unwrap());
|
||||
for body in &session.world_state().bodies {
|
||||
body.mesh();
|
||||
}
|
||||
let modes = [("json", false, "fast"), ("container", false, "fast"), ("container", true, "fast"), ("container", true, "compact")];
|
||||
let mut samples = [Vec::new(), Vec::new(), Vec::new(), Vec::new()];
|
||||
for round in 0..9 {
|
||||
for i in if round % 2 == 0 { [0, 1, 2, 3] } else { [3, 2, 1, 0] } {
|
||||
let (format, cache, compression) = modes[i];
|
||||
let path = target.join(format!("issue303-native-{i}.solidcraft"));
|
||||
let start = std::time::Instant::now();
|
||||
session.execute("file.save", &json!({"path":path,"format":format,"cache":cache,"compression":compression})).unwrap();
|
||||
samples[i].push(start.elapsed().as_secs_f64() * 1000.);
|
||||
}
|
||||
}
|
||||
for (i, times) in samples.iter_mut().enumerate() {
|
||||
times.sort_by(f64::total_cmp);
|
||||
let bytes = std::fs::read(target.join(format!("issue303-native-{i}.solidcraft"))).unwrap();
|
||||
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
|
||||
assert_eq!(solidcraft_io::read_design_geometry(&bytes).is_some(), modes[i].1);
|
||||
eprintln!("native_save mode={i} median_ms={:.3} bytes={}", times[4], bytes.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual fresh-process opening benchmark; one encoding per process"]
|
||||
fn benchmark_native_cache_open() {
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| std::path::PathBuf::from("target"));
|
||||
let mode = std::env::var("SOLIDCRAFT_BENCH_ENCODING").unwrap_or_else(|_| "2".into());
|
||||
let path = target.join(format!("issue303-native-{mode}.solidcraft"));
|
||||
let display = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let notify = display.clone();
|
||||
let start = std::time::Instant::now();
|
||||
let mut session = Session {
|
||||
open_preview: Some(Box::new(move |_| {
|
||||
*notify.lock().unwrap() = Some(start.elapsed().as_secs_f64() * 1000.);
|
||||
})),
|
||||
..Session::default()
|
||||
};
|
||||
let result = session.execute("doc.open", &json!({"path":path})).unwrap();
|
||||
assert_eq!(result["errors"], 0);
|
||||
let reconstructed = start.elapsed().as_secs_f64() * 1000.;
|
||||
let mut triangles = 0;
|
||||
let world = session.world_state();
|
||||
for body in &world.bodies {
|
||||
triangles += body.mesh().triangles.len();
|
||||
}
|
||||
let ready = start.elapsed().as_secs_f64() * 1000.;
|
||||
eprintln!(
|
||||
"native_open mode={mode} provisional_ms={:?} rebuilt_ms={reconstructed:.3} meshes_ready_ms={ready:.3} bodies={} triangles={triangles}",
|
||||
*display.lock().unwrap(),
|
||||
world.bodies.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn name_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-name-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_names_an_untitled_design_after_its_file() {
|
||||
let dir = name_dir("untitled");
|
||||
let path = dir.join("bracket.solidcraft");
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
assert_eq!(s.doc.name, "Untitled");
|
||||
let r = s.execute("file.save_as", &json!({"path":path})).unwrap();
|
||||
assert_eq!(r["name"], "bracket");
|
||||
assert_eq!(s.doc.name, "bracket");
|
||||
assert!(!s.is_dirty(), "taking the file's name is part of the save");
|
||||
assert_eq!(solidcraft_io::read_design(&std::fs::read(&path).unwrap()).unwrap().name, "bracket");
|
||||
// Undoing the box does not bring the old name back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.name, "bracket");
|
||||
// Saving under another file keeps the name it now has.
|
||||
s.execute("file.save_as", &json!({"path":dir.join("copy.solidcraft")})).unwrap();
|
||||
assert_eq!(s.doc.name, "bracket");
|
||||
// A failed save leaves the name alone.
|
||||
let mut failed = Session::default();
|
||||
assert!(failed.execute("file.save_as", &json!({"path":dir.join("missing-dir").join("plate.solidcraft")})).is_err());
|
||||
assert_eq!(failed.doc.name, "Untitled");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_keeps_a_name_the_user_chose() {
|
||||
let dir = name_dir("named");
|
||||
let mut s = Session::default();
|
||||
s.execute("file.new", &json!({"name":"Gearbox lid"})).unwrap();
|
||||
s.execute("file.save_as", &json!({"path":dir.join("lid-v2.solidcraft")})).unwrap();
|
||||
assert_eq!(s.doc.name, "Gearbox lid");
|
||||
let mut reopened = Session::default();
|
||||
reopened.execute("doc.open", &json!({"path":dir.join("lid-v2.solidcraft")})).unwrap();
|
||||
assert_eq!(reopened.doc.name, "Gearbox lid");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_an_untitled_file_shows_its_file_name() {
|
||||
let dir = name_dir("open");
|
||||
for (stored, file) in [("Untitled", "hinge.solidcraft"), ("", "spacer.json")] {
|
||||
let path = dir.join(file);
|
||||
let bytes = solidcraft_io::write_design(&Document::new(stored));
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("doc.open", &json!({"path":path})).unwrap();
|
||||
let stem = file.split('.').next().unwrap();
|
||||
assert_eq!(s.doc.name, stem);
|
||||
assert_eq!(r["name"], stem);
|
||||
assert!(!s.is_dirty(), "showing the file's name is not a change to save");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -454,4 +732,33 @@ mod tests {
|
||||
assert!(std::fs::read_to_string(&packaged).unwrap().contains("solidcraft/"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A watched open reports the timeline's progress; cancelled, it skips the rebuild.
|
||||
#[test]
|
||||
fn watched_open_reports_progress_and_stops_when_cancelled() {
|
||||
use solidcraft_doc::progress::{Progress, watch};
|
||||
use std::sync::Arc;
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-watched-open-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("three.solidcraft").to_string_lossy().to_string();
|
||||
let mut s = Session::default();
|
||||
for x in [0, 20, 40] {
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"corner":[x,0,0]})).unwrap();
|
||||
}
|
||||
s.execute("file.save", &json!({"path":path})).unwrap();
|
||||
|
||||
let progress = Arc::new(Progress::default());
|
||||
let mut opened = Session::default();
|
||||
watch(progress.clone(), || opened.execute("doc.open", &json!({"path":path}))).unwrap();
|
||||
assert_eq!(progress.features(), (3, 3));
|
||||
assert_eq!(opened.world_state().bodies.len(), 3);
|
||||
|
||||
let progress = Arc::new(Progress::default());
|
||||
progress.cancel();
|
||||
let mut stopped = Session::default();
|
||||
watch(progress, || stopped.execute("doc.open", &json!({"path":path}))).unwrap();
|
||||
assert!(stopped.model.results.iter().all(|r| r.skipped), "no feature was rebuilt after Cancel");
|
||||
assert!(stopped.world_state().bodies.is_empty());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! A portable picture from warm display meshes only; never evaluate on save.
|
||||
use crate::Session;
|
||||
use solidcraft_geom::Aabb3;
|
||||
use solidcraft_render::{Camera, Rgb, Scene, SceneMesh, StandardView};
|
||||
|
||||
pub(super) fn saved(s: &Session) -> Option<Vec<u8>> {
|
||||
if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) {
|
||||
return None;
|
||||
}
|
||||
let world = s.cached_world_state()?;
|
||||
if world.bodies.is_empty() || world.bodies.len() > 1024 {
|
||||
return None;
|
||||
}
|
||||
let mut scene = Scene::default();
|
||||
let mut bounds = Aabb3::EMPTY;
|
||||
let mut triangles = 0usize;
|
||||
let mut points = 0usize;
|
||||
for body in world.bodies.iter().filter(|b| s.body_visible(&b.name)) {
|
||||
let mesh = body.cached_mesh()?;
|
||||
triangles = triangles.saturating_add(mesh.triangles.len());
|
||||
points = points.saturating_add(mesh.positions.len());
|
||||
if triangles > 100_000 || points > 100_000 {
|
||||
return None;
|
||||
}
|
||||
bounds = bounds.union(&mesh.bounds());
|
||||
let channel = |x: f32| (x.clamp(0., 1.) * 255.).round() as u8;
|
||||
let color = s.doc.body_color(body).map_or(crate::view::colors::BODY, |[r, g, b]| Rgb(channel(r), channel(g), channel(b)));
|
||||
let face_colors =
|
||||
s.doc.face_colors(body).into_iter().map(|(face, look)| (face as u32, Rgb(look.color[0], look.color[1], look.color[2]))).collect();
|
||||
scene.meshes.push(SceneMesh { mesh, color, face_colors });
|
||||
}
|
||||
if bounds.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut camera = Camera::default();
|
||||
camera.set_view(StandardView::Iso);
|
||||
camera.fit(&bounds);
|
||||
camera.distance *= 0.7;
|
||||
scene.radius = (bounds.diagonal() * 0.5).max(camera.half_height()).max(10.);
|
||||
scene.background = Some((crate::view::colors::BG_TOP, crate::view::colors::BG_BOTTOM));
|
||||
let [width, height] = solidcraft_io::THUMBNAIL_SIZE;
|
||||
if !within_raster_budget(&scene, &camera, width, height) {
|
||||
return None;
|
||||
}
|
||||
let image = solidcraft_render::render(&scene, &camera, width, height);
|
||||
solidcraft_render::png(width, height, image.rgba)
|
||||
}
|
||||
|
||||
// Bound overlapping-triangle work too, not only mesh size. The renderer uses
|
||||
// 2x supersampling; conservatively count each projected triangle's pixel box.
|
||||
fn within_raster_budget(scene: &Scene, camera: &Camera, width: usize, height: usize) -> bool {
|
||||
let (w, h) = ((width * 2) as f64, (height * 2) as f64);
|
||||
let matrix = camera.view_proj(w / h, scene.radius.max(1.));
|
||||
let mut pixels = 0usize;
|
||||
let mut colors = 0usize;
|
||||
for entry in &scene.meshes {
|
||||
colors = colors.saturating_add(entry.mesh.triangles.len().saturating_mul(entry.face_colors.len()));
|
||||
if colors > 1_000_000 {
|
||||
return false;
|
||||
}
|
||||
let mut projected = Vec::with_capacity(entry.mesh.positions.len());
|
||||
for point in &entry.mesh.positions {
|
||||
let q = matrix.apply(*point);
|
||||
if q.iter().any(|x| !x.is_finite()) || q[3] <= 1e-9 {
|
||||
return false;
|
||||
}
|
||||
let pixel = [(q[0] / q[3] + 1.) * 0.5 * w, (1. - q[1] / q[3]) * 0.5 * h];
|
||||
if pixel.iter().any(|x| !x.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
projected.push(pixel);
|
||||
}
|
||||
for triangle in &entry.mesh.triangles {
|
||||
let [Some(a), Some(b), Some(c)] = triangle.map(|i| projected.get(i as usize).copied()) else {
|
||||
return false;
|
||||
};
|
||||
let dx = (a[0].max(b[0]).max(c[0]).ceil().clamp(0., w) - a[0].min(b[0]).min(c[0]).floor().clamp(0., w) + 2.) as usize;
|
||||
let dy = (a[1].max(b[1]).max(c[1]).ceil().clamp(0., h) - a[1].min(b[1]).min(c[1]).floor().clamp(0., h) + 2.) as usize;
|
||||
pixels = pixels.saturating_add(dx.saturating_mul(dy));
|
||||
if pixels > 4_000_000 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn portable_thumbnail_refuses_excessive_overdraw() {
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
let mesh = session.world_state().bodies[0].mesh();
|
||||
let bounds = mesh.bounds();
|
||||
let mut camera = Camera::default();
|
||||
camera.set_view(StandardView::Iso);
|
||||
camera.fit(&bounds);
|
||||
let mut scene = Scene { radius: bounds.diagonal(), ..Scene::default() };
|
||||
scene.meshes.push(SceneMesh { mesh: mesh.clone(), color: Rgb(100, 100, 100), face_colors: vec![] });
|
||||
assert!(within_raster_budget(&scene, &camera, 240, 150));
|
||||
let mut repeated = (*mesh).clone();
|
||||
repeated.triangles = vec![repeated.triangles[0]; 100_000];
|
||||
scene.meshes[0].mesh = std::sync::Arc::new(repeated);
|
||||
assert!(!within_raster_budget(&scene, &camera, 240, 150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_thumbnail_preserves_saved_visibility() {
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30,"body_name":"Visible"})).unwrap();
|
||||
session.execute("solid.box", &serde_json::json!({"length":100,"width":100,"height":100,"body_name":"Hidden"})).unwrap();
|
||||
session.execute("browser.visibility", &serde_json::json!({"items":["Hidden"],"visible":false})).unwrap();
|
||||
let world = session.world_state();
|
||||
for body in &world.bodies {
|
||||
body.mesh();
|
||||
}
|
||||
let expected = saved(&session).unwrap();
|
||||
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
|
||||
let doc = solidcraft_io::read_design(&bytes).unwrap();
|
||||
assert_eq!(doc.visibility.hidden_bodies, vec!["Hidden"]);
|
||||
assert_eq!(doc, *session.design());
|
||||
assert_eq!(solidcraft_io::read_design_thumbnail(&bytes), Some(expected));
|
||||
let reopened = Session::new(doc);
|
||||
assert_eq!(reopened.visibility, session.visibility);
|
||||
let disabled = session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap();
|
||||
assert_eq!(solidcraft_io::read_design(&disabled).unwrap().visibility, session.visibility);
|
||||
assert!(solidcraft_io::read_design_thumbnail(&disabled).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_thumbnail_never_forces_cold_evaluation_or_meshing() {
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap();
|
||||
let world = session.world_state();
|
||||
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()));
|
||||
assert!(saved(&session).is_none());
|
||||
assert!(world.bodies.iter().all(|b| b.cached_mesh().is_none()));
|
||||
for body in &world.bodies {
|
||||
body.mesh();
|
||||
}
|
||||
let png = saved(&session).unwrap();
|
||||
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
|
||||
assert_eq!(solidcraft_io::read_design_thumbnail(&bytes), Some(png));
|
||||
assert_eq!(solidcraft_io::read_design(&bytes).unwrap(), *session.doc);
|
||||
let disabled = session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap();
|
||||
assert!(solidcraft_io::read_design_thumbnail(&disabled).is_none());
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("material.assign", "Physical Material", physical_material)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("material")
|
||||
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …; `material.list`)"),
|
||||
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …, or one made with material.create; `material.list`)"),
|
||||
CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
|
||||
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
|
||||
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
|
||||
@@ -34,7 +34,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("appearance.list", "List Appearances", appearance_list)
|
||||
.noundo()
|
||||
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
|
||||
CommandSpec::new("material.list", "List Materials", material_list).noundo().doc_only(),
|
||||
CommandSpec::new("material.list", "List Materials", material_list)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("→ the built-in materials, then the design's own (user: true, with their look)"),
|
||||
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
|
||||
];
|
||||
|
||||
@@ -283,11 +286,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "material.assign";
|
||||
let bodies = string_list(p, "bodies");
|
||||
let material = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
|
||||
let canonical = solidcraft_doc::MATERIALS
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(material.trim()))
|
||||
.map(|(n, _)| n.to_string())
|
||||
.ok_or_else(|| bad(cmd, format!("unknown material `{material}` (see material.list)")))?;
|
||||
let canonical = s.doc.material_name(material).ok_or_else(|| bad(cmd, format!("unknown material `{material}` (see material.list)")))?;
|
||||
let st = s.model.state();
|
||||
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
|
||||
return Err(bad(cmd, "`bodies` must list existing bodies"));
|
||||
@@ -303,7 +302,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// A colour parameter: `"#rrggbb"` or `[r, g, b]` (0–255).
|
||||
fn color_param(v: &Value) -> Option<[u8; 3]> {
|
||||
pub(super) fn color_param(v: &Value) -> Option<[u8; 3]> {
|
||||
if let Some(t) = v.as_str() {
|
||||
let h = t.trim().trim_start_matches('#');
|
||||
if h.len() != 6 || !h.is_ascii() {
|
||||
@@ -455,7 +454,7 @@ fn dist_to(b: &solidcraft_doc::ModelBody, p: solidcraft_geom::Vec3) -> f64 {
|
||||
c.dist(p)
|
||||
}
|
||||
|
||||
fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
|
||||
pub(super) fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
|
||||
json!({"name": l.name, "color": l.hex(), "opacity": l.opacity})
|
||||
}
|
||||
|
||||
@@ -524,6 +523,10 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn material_list(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"materials": solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d})).collect::<Vec<_>>()}))
|
||||
fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d}));
|
||||
let user = s.doc.user_materials.iter().map(
|
||||
|m| json!({"name": m.name, "density_g_cm3": m.density, "appearance": s.doc.material_look(&m.name).as_ref().map(look_json), "user": true}),
|
||||
);
|
||||
Ok(json!({"materials": built_in.chain(user).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::joints::{self, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Snap};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, num, str_, string_list, vec3};
|
||||
@@ -37,7 +37,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("joint.list", "List Joints", list).noundo().params("→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts"),
|
||||
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).params("re-place the occurrences from the joints → conflicts, errors, dof"),
|
||||
CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
|
||||
"bodies?: [names] (default: all, in world placement) → pairs that overlap with the overlap volume, area, bounding box and centre",
|
||||
"bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, so any number of bodies is fine; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out)",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -523,35 +523,79 @@ fn solve_cmd(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.interference";
|
||||
let want = string_list(p, "bodies");
|
||||
let st = s.world_state();
|
||||
let bodies: Vec<&solidcraft_doc::ModelBody> =
|
||||
st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).filter(|b| !b.body.is_mesh()).collect();
|
||||
if bodies.len() > 200 {
|
||||
return Err(bad(cmd, "too many bodies (200 at most)"));
|
||||
}
|
||||
let mut hits = Vec::new();
|
||||
for (i, a) in bodies.iter().enumerate() {
|
||||
for b in bodies.iter().skip(i + 1) {
|
||||
let (ba, bb) = (a.mesh().bounds(), b.mesh().bounds());
|
||||
let apart = ba.max.x < bb.min.x
|
||||
|| bb.max.x < ba.min.x
|
||||
|| ba.max.y < bb.min.y
|
||||
|| bb.max.y < ba.min.y
|
||||
|| ba.max.z < bb.min.z
|
||||
|| bb.max.z < ba.min.z;
|
||||
if apart {
|
||||
// Prune by bounding boxes first (sort and sweep), so the cost follows the pairs that can
|
||||
// touch rather than the square of the body count.
|
||||
let boxes: Vec<Aabb3> = bodies.iter().map(|b| b.mesh().bounds()).collect();
|
||||
let (pairs, skipped) = overlapping_pairs(&boxes, MAX_INTERFERENCE_PAIRS);
|
||||
let (mut hits, mut unchecked) = (Vec::new(), Vec::new());
|
||||
for (i, j) in pairs {
|
||||
let (Some(a), Some(b)) = (bodies.get(i), bodies.get(j)) else { continue };
|
||||
// A pair whose shared part can't be worked out is reported, not passed as clear.
|
||||
let (shared, report) =
|
||||
solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect));
|
||||
let mut skip = |reason: String| unchecked.push(json!({"a": a.name, "b": b.name, "reason": reason}));
|
||||
let shared = match shared {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
skip(e.to_string());
|
||||
continue;
|
||||
}
|
||||
let Ok(Some(o)) = solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect) else { continue };
|
||||
let Ok(m) = solidcraft_kernel::measure(&o) else { continue };
|
||||
if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) {
|
||||
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}}));
|
||||
};
|
||||
if let Some(reason) = report.unverified() {
|
||||
skip(reason);
|
||||
}
|
||||
let Some(o) = shared else { continue };
|
||||
let m = match solidcraft_kernel::measure(&o) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
skip(e.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) {
|
||||
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}}));
|
||||
}
|
||||
}
|
||||
Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len(), "unchecked": unchecked,
|
||||
"truncated": skipped > 0, "skipped_pairs": skipped}))
|
||||
}
|
||||
|
||||
/// Most pairs with overlapping bounding boxes `inspect.interference` intersects in one call.
|
||||
const MAX_INTERFERENCE_PAIRS: usize = 20_000;
|
||||
|
||||
/// Index pairs `(i, j)`, `i < j`, whose boxes overlap or touch (sort and sweep along X), at most
|
||||
/// `limit` of them, plus how many more there were.
|
||||
fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, usize)>, usize) {
|
||||
let mut order: Vec<usize> = (0..boxes.len()).collect();
|
||||
order.sort_by(|&a, &b| match (boxes.get(a), boxes.get(b)) {
|
||||
(Some(a), Some(b)) => a.min.x.total_cmp(&b.min.x),
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
});
|
||||
let (mut pairs, mut skipped) = (Vec::new(), 0);
|
||||
for (k, &i) in order.iter().enumerate() {
|
||||
let Some(a) = boxes.get(i) else { continue };
|
||||
for &j in order.iter().skip(k + 1) {
|
||||
let Some(b) = boxes.get(j) else { continue };
|
||||
// Sorted by min.x: once one starts past `a`, all later ones do.
|
||||
if b.min.x > a.max.x {
|
||||
break;
|
||||
}
|
||||
if b.max.x < a.min.x || a.max.y < b.min.y || b.max.y < a.min.y || a.max.z < b.min.z || b.max.z < a.min.z {
|
||||
continue;
|
||||
}
|
||||
if pairs.len() < limit {
|
||||
pairs.push((i.min(j), i.max(j)));
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len()}))
|
||||
pairs.sort_unstable();
|
||||
(pairs, skipped)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -151,6 +151,57 @@ fn rigid_cycles_solve_and_conflicts_report() {
|
||||
assert!(s.execute("joint.create", &json!({"type": "hinge"})).is_err());
|
||||
}
|
||||
|
||||
/// A 30 x 30 x 3 plate with a through slot whose x = 20 wall carries one rib (0.3 tall, 0.8
|
||||
/// wide), and a peg in the slot with 0.15 clearance all round.
|
||||
fn ribbed_slot_and_peg(s: &mut Session, peg_x1: f64) {
|
||||
run(s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 30]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"distance": 3, "body_name": "Plate"}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "offset": -1}));
|
||||
let slot = json!([[10, 5], [20, 5], [20, 14.6], [19.7, 14.6], [19.7, 15.4], [20, 15.4], [20, 25], [10, 25]]);
|
||||
run(s, "sketch.line", json!({"points": slot, "closed": true}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"distance": 5, "operation": "cut", "targets": ["Plate"]}));
|
||||
run(s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [10.15, 5.15], "p1": [peg_x1, 24.85]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"distance": 3, "operation": "new", "body_name": "Peg"}));
|
||||
}
|
||||
|
||||
/// The peg overlaps only the rib: 0.15 x 0.8 x 3 = 0.36 mm³ (#442).
|
||||
#[test]
|
||||
fn interference_finds_a_peg_overlapping_a_small_rib() {
|
||||
let mut s = Session::default();
|
||||
ribbed_slot_and_peg(&mut s, 19.85);
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["count"], 1, "{i}");
|
||||
assert!((i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0) - 0.36).abs() < 1e-6, "{i}");
|
||||
let (lo, hi) = (&i["interferences"][0]["bbox"]["min"], &i["interferences"][0]["bbox"]["max"]);
|
||||
assert!((lo[0].as_f64().unwrap_or(0.0) - 19.7).abs() < 1e-6 && (hi[0].as_f64().unwrap_or(0.0) - 19.85).abs() < 1e-6, "{i}");
|
||||
}
|
||||
|
||||
/// An arc revolved up to a post ends on the post's wall, where the two bodies can't be
|
||||
/// intersected: the pair is listed as unchecked instead of passing as clear.
|
||||
#[test]
|
||||
fn interference_lists_a_pair_it_could_not_check() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"base": [0, 14, -5], "radius": 4, "height": 15, "body_name": "Post"}));
|
||||
run(&mut s, "sketch.create", json!({"name": "Profile", "plane": "XZ"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [15, 5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": {"origin": [0, 0, 0], "dir": [0, 0, 1]}, "to": [4, 14, 2], "body_name": "Arc"}));
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["count"], 0, "{i}");
|
||||
let unchecked = i["unchecked"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(unchecked.len(), 1, "{i}");
|
||||
assert!(unchecked[0]["reason"].as_str().is_some_and(|r| r.starts_with("not verified") && !r.contains("displacement")), "{i}");
|
||||
// Bodies that are clear of each other, or overlap plainly, leave the list empty.
|
||||
let mut s = two_cubes();
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["unchecked"].as_array().map(Vec::len), Some(0), "{i}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_links_and_interference() {
|
||||
let mut s = two_cubes();
|
||||
@@ -195,3 +246,52 @@ fn faces_mate_by_default() {
|
||||
run(&mut s, "joint.edit", json!({"joint": "Stack", "flip": true}));
|
||||
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 0.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0)));
|
||||
}
|
||||
|
||||
/// #527: an assembly with its fasteners passes 200 bodies quickly; the clash check must still
|
||||
/// cover the whole design. 201 separate 1 mm cubes 2 mm apart, plus one cube pushed half-way
|
||||
/// into the first: exactly that pair overlaps, by 0.5 mm³.
|
||||
#[test]
|
||||
fn interference_checks_designs_with_more_than_200_bodies() {
|
||||
let mut s = Session::default();
|
||||
for i in 0..201 {
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [2 * i, 0, 0], "operation": "new"}));
|
||||
}
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [0.5, 0, 0], "operation": "new", "body_name": "Clash"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 202);
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["checked"], 202, "{i}");
|
||||
assert_eq!(i["count"], 1, "{i}");
|
||||
let hit = &i["interferences"][0];
|
||||
let mut pair = [hit["a"].as_str().unwrap_or(""), hit["b"].as_str().unwrap_or("")];
|
||||
pair.sort_unstable();
|
||||
assert_eq!(pair, ["Body1", "Clash"], "{i}");
|
||||
assert!((hit["volume_mm3"].as_f64().unwrap_or(0.0) - 0.5).abs() < 1e-6, "{i}");
|
||||
}
|
||||
|
||||
/// The bounding-box sweep behind `inspect.interference` finds exactly the pairs a check of every
|
||||
/// pair finds (touching boxes count), and reports how many it left out past its limit.
|
||||
#[test]
|
||||
fn interference_box_sweep_matches_every_pair_check() {
|
||||
use solidcraft_geom::Aabb3;
|
||||
let cube = |x: f64, y: f64, z: f64, e: f64| Aabb3 { min: Vec3::new(x, y, z), max: Vec3::new(x + e, y + e, z + e) };
|
||||
// A deterministic scatter: overlapping, touching, and apart in only one axis.
|
||||
let boxes: Vec<Aabb3> = (0..60)
|
||||
.map(f64::from)
|
||||
.map(|i| cube((i * 7.3) % 20.0, (i * 3.1) % 9.0, (i * 5.7) % 4.0, 1.0 + (i % 3.0)))
|
||||
.chain([cube(30.0, 0.0, 0.0, 1.0), cube(31.0, 0.0, 0.0, 1.0)])
|
||||
.collect();
|
||||
let mut every = Vec::new();
|
||||
for (i, a) in boxes.iter().enumerate() {
|
||||
for (j, b) in boxes.iter().enumerate().skip(i + 1) {
|
||||
let apart = a.max.x < b.min.x || b.max.x < a.min.x || a.max.y < b.min.y || b.max.y < a.min.y || a.max.z < b.min.z || b.max.z < a.min.z;
|
||||
if !apart {
|
||||
every.push((i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(every.contains(&(60, 61)), "touching boxes are a candidate pair");
|
||||
let (pairs, skipped) = super::overlapping_pairs(&boxes, usize::MAX);
|
||||
assert_eq!((pairs, skipped), (every.clone(), 0));
|
||||
let (pairs, skipped) = super::overlapping_pairs(&boxes, 10);
|
||||
assert_eq!((pairs.len(), skipped), (10, every.len() - 10));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Sketches on flat faces that no construction certificate names: a Cut's walls and floor, a
|
||||
//! chamfer, a pattern's copies, a side a Join merged from two features, the pieces of a split
|
||||
//! face (#491, #492, #493, #557). They attach by the face's exact history name
|
||||
//! (`FaceCertificate::Lineage`), follow the face through parameter edits and survive a save.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::FaceAttachmentStatus;
|
||||
use solidcraft_doc::config::FaceCertificate;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command} {params}: {e}"))
|
||||
}
|
||||
|
||||
fn sketch_on(s: &mut Session, at: [f64; 3], name: &str) {
|
||||
run(s, "sketch.create", json!({"plane": {"face": at}, "name": name}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
fn status(s: &Session, sketch: &str) -> FaceAttachmentStatus {
|
||||
let id = s.doc.find_feature(sketch).map(|f| f.id).unwrap();
|
||||
s.placed_document().sketch_face_attachment(id, &s.model)
|
||||
}
|
||||
|
||||
fn healthy(s: &Session, sketch: &str) -> (solidcraft_geom::Plane, Option<FaceCertificate>) {
|
||||
match status(s, sketch) {
|
||||
FaceAttachmentStatus::Healthy { plane, target, .. } => (plane, target.certificate),
|
||||
other => panic!("{sketch}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn no_errors(s: &Session) {
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
|
||||
}
|
||||
|
||||
fn reopened(s: &Session) -> Session {
|
||||
let mut t = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
t.refresh();
|
||||
no_errors(&t);
|
||||
t
|
||||
}
|
||||
|
||||
/// A 40 x 30 x 10 block with a slot cut 5 deep across it from x = `slot_x` (a parameter), 10 wide.
|
||||
fn slotted_block() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Block"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "slot_x", "expression": "15 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "SlotSketch"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [15, -5], "p1": [25, 35]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p1"], "type": "horizontal", "value": "slot_x"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "10 mm"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "SlotSketch", "distance": 12, "start_offset": 5, "operation": "cut", "name": "Slot"}));
|
||||
no_errors(&s);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_wall_and_floor_take_a_sketch_that_follows_the_cut() {
|
||||
let mut s = slotted_block();
|
||||
sketch_on(&mut s, [15., 15., 7.5], "Wall");
|
||||
sketch_on(&mut s, [20., 15., 5.], "Floor");
|
||||
let (wall, certificate) = healthy(&s, "Wall");
|
||||
assert_eq!(certificate, Some(FaceCertificate::Lineage));
|
||||
// The wall faces into the slot (+x), its frame's origin over the design origin.
|
||||
assert!((wall.normal().x - 1.).abs() < 1e-9 && wall.origin.x.abs() > 14.9 && (wall.origin.x - 15.).abs() < 1e-9, "{wall:?}");
|
||||
assert!((wall.origin.y.abs() + wall.origin.z.abs()) < 1e-9, "{wall:?}");
|
||||
let (floor, _) = healthy(&s, "Floor");
|
||||
assert!((floor.normal().z - 1.).abs() < 1e-9 && (floor.origin.z - 5.).abs() < 1e-9, "{floor:?}");
|
||||
|
||||
// The slot moves: the sketch on its wall moves with it.
|
||||
run(&mut s, "parameters.change", json!({"name": "slot_x", "expression": "20 mm"}));
|
||||
no_errors(&s);
|
||||
let (wall, _) = healthy(&s, "Wall");
|
||||
assert!((wall.origin.x - 20.).abs() < 1e-9, "{wall:?}");
|
||||
let t = reopened(&s);
|
||||
assert!((healthy(&t, "Wall").0.origin.x - 20.).abs() < 1e-9);
|
||||
assert!((healthy(&t, "Floor").0.origin.z - 5.).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pieces_of_a_split_top_take_a_sketch() {
|
||||
let mut s = slotted_block();
|
||||
// The slot splits the block's top in two; each piece takes a sketch (on that face's plane).
|
||||
for (name, x) in [("Left", 5.), ("Right", 35.)] {
|
||||
sketch_on(&mut s, [x, 15., 10.], name);
|
||||
assert!((healthy(&s, name).0.origin.z - 10.).abs() < 1e-9, "{name}");
|
||||
}
|
||||
run(&mut s, "parameters.change", json!({"name": "slot_x", "expression": "20 mm"}));
|
||||
no_errors(&s);
|
||||
assert!((healthy(&s, "Left").0.origin.z - 10.).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chamfer_face_takes_a_sketch_that_follows_the_chamfer() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "bevel", "expression": "3 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "name": "Base"}));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[40, 15, 10]], "distance": "bevel", "name": "Bevel"}));
|
||||
no_errors(&s);
|
||||
sketch_on(&mut s, [38.5, 15., 8.5], "OnBevel");
|
||||
let (plane, certificate) = healthy(&s, "OnBevel");
|
||||
assert_eq!(certificate, Some(FaceCertificate::Lineage));
|
||||
let n = std::f64::consts::FRAC_1_SQRT_2;
|
||||
assert!((plane.normal().x - n).abs() < 1e-9 && (plane.normal().z - n).abs() < 1e-9, "{plane:?}");
|
||||
// A bigger chamfer moves the face inward: the sketch goes with it.
|
||||
let height = |p: &solidcraft_geom::Plane| (p.origin - solidcraft_geom::Vec3::new(40., 0., 10.)).dot(p.normal());
|
||||
assert!((height(&plane) + 3. * n).abs() < 1e-9, "{plane:?}");
|
||||
run(&mut s, "parameters.change", json!({"name": "bevel", "expression": "5 mm"}));
|
||||
no_errors(&s);
|
||||
assert!((height(&healthy(&s, "OnBevel").0) + 5. * n).abs() < 1e-9);
|
||||
healthy(&reopened(&s), "OnBevel");
|
||||
}
|
||||
|
||||
/// A plate with a boss patterned 3 times along x, `pitch` apart.
|
||||
fn patterned_bosses(sketch_on_seed_first: bool) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "pitch", "expression": "20 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [80, 20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [40, 10, 5]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 6, "operation": "join", "name": "Boss"}));
|
||||
if sketch_on_seed_first {
|
||||
sketch_on(&mut s, [10., 10., 11.], "Seed");
|
||||
}
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Boss"], "dir1": [1, 0, 0], "count1": 3, "spacing1": "pitch", "name": "Row"}));
|
||||
no_errors(&s);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_patterned_copy_takes_its_own_sketch() {
|
||||
let mut s = patterned_bosses(true);
|
||||
// A sketch made on the seed before the pattern keeps its construction certificate: the
|
||||
// copies carry it too, but are named as copies.
|
||||
assert!(matches!(healthy(&s, "Seed").1, Some(FaceCertificate::ExtrudeCap { .. })));
|
||||
for (name, x) in [("First", 10.), ("Middle", 30.), ("Last", 50.)] {
|
||||
sketch_on(&mut s, [x, 10., 11.], name);
|
||||
assert!((healthy(&s, name).0.origin.z - 11.).abs() < 1e-9, "{name}");
|
||||
}
|
||||
run(&mut s, "parameters.change", json!({"name": "pitch", "expression": "25 mm"}));
|
||||
no_errors(&s);
|
||||
for name in ["Seed", "First", "Middle", "Last"] {
|
||||
healthy(&s, name);
|
||||
}
|
||||
let t = reopened(&s);
|
||||
for name in ["Seed", "Middle", "Last"] {
|
||||
healthy(&t, name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_piece_of_a_face_split_on_purpose_still_needs_a_fixed_plane() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Modified"}));
|
||||
run(&mut s, "solid.split_face", json!({"body": "Modified", "faces": [[5, 5, 10]], "plane": {"origin": [5, 0, 0], "normal": [1, 0, 0]}}));
|
||||
let error = s.execute("sketch.create", &json!({"plane": {"face": [2.5, 5, 10]}})).unwrap_err().to_string();
|
||||
assert!(error.contains("fixed:true"), "{error}");
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Physical materials defined in a design (`material.create`, `material.edit`,
|
||||
//! `material.delete`): a name, a density and, optionally, a look. They are assigned like the
|
||||
//! built-in library's (`material.assign`), set masses (`component.properties`) and fill
|
||||
//! configuration material cells. The design keeps them, so they are saved and undone like
|
||||
//! features.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::appearance::{Look, library};
|
||||
use solidcraft_doc::config::Column;
|
||||
use solidcraft_doc::{MATERIALS, UserMaterial, valid_density};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::inspect::{color_param, look_json};
|
||||
use crate::params::{bad, str_};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("material.create", "New Physical Material", create).icon("material").params(
|
||||
"name (not a built-in material's); density_g_cm3: (0, 1000]; appearance?: library name (appearance.library) | color?: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1): the look bodies of this material get without an appearance of their own. Assign it with material.assign",
|
||||
),
|
||||
CommandSpec::new("material.edit", "Edit Physical Material", edit).icon("material").enabled(has_user_materials).params(
|
||||
"material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity? (as material.create; color: null clears the look)",
|
||||
),
|
||||
CommandSpec::new("material.delete", "Delete Physical Material", delete).enabled(has_user_materials).params(
|
||||
"material: a material defined in this design; replace?: material for the bodies and configuration cells that use it (without it, a material in use is refused)",
|
||||
),
|
||||
];
|
||||
|
||||
/// Most materials a design defines.
|
||||
const MAX_USER_MATERIALS: usize = 1000;
|
||||
|
||||
fn has_user_materials(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.doc.user_materials.is_empty() { Err("the design defines no physical materials (material.create)".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// A new material name: 1–128 characters, not a built-in material's or another design
|
||||
/// material's (`except`: the one being renamed).
|
||||
fn new_name(s: &Session, cmd: &str, name: &str, except: Option<&str>) -> Result<String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.chars().count() > 128 {
|
||||
return Err(bad(cmd, "`name` must have 1–128 characters"));
|
||||
}
|
||||
if MATERIALS.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)) {
|
||||
return Err(bad(cmd, format!("`{name}` is a built-in material (material.list)")));
|
||||
}
|
||||
let taken = |m: &&UserMaterial| m.name.eq_ignore_ascii_case(name) && except.is_none_or(|e| !m.name.eq_ignore_ascii_case(e));
|
||||
if s.doc.user_materials.iter().any(|m| taken(&m)) {
|
||||
return Err(bad(cmd, format!("the design already has a material `{name}`")));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
fn density(cmd: &str, p: &Value) -> Result<f64> {
|
||||
p.get("density_g_cm3")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|d| valid_density(*d))
|
||||
.ok_or_else(|| bad(cmd, "`density_g_cm3` must be a number above 0 and at most 1000"))
|
||||
}
|
||||
|
||||
/// The look `appearance` / `color` / `opacity` give: `None` when none is given, `Some(None)`
|
||||
/// to clear it (`color: null`).
|
||||
fn look(cmd: &str, p: &Value, material: &str) -> Result<Option<Option<Look>>> {
|
||||
let opacity = match p.get("opacity") {
|
||||
Some(o) => Some(o.as_f64().filter(|x| x.is_finite() && (0.0..=1.0).contains(x)).ok_or_else(|| bad(cmd, "`opacity` must be 0…1"))?),
|
||||
None => None,
|
||||
};
|
||||
let mut l = match (str_(p, "appearance"), p.get("color")) {
|
||||
(Some(_), Some(c)) if !c.is_null() => return Err(bad(cmd, "give `appearance` or `color`, not both")),
|
||||
(Some(n), _) => library(n).ok_or_else(|| bad(cmd, format!("no appearance `{n}` in the library (appearance.library)")))?,
|
||||
(None, Some(Value::Null)) => return Ok(Some(None)),
|
||||
(None, Some(c)) => {
|
||||
let mut l = Look::custom(color_param(c).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?, 1.0);
|
||||
l.name = material.to_string();
|
||||
l
|
||||
}
|
||||
(None, None) if opacity.is_some() => return Err(bad(cmd, "`opacity` needs `appearance` or `color`")),
|
||||
(None, None) => return Ok(None),
|
||||
};
|
||||
if let Some(o) = opacity {
|
||||
l.opacity = o;
|
||||
}
|
||||
Ok(Some(Some(l)))
|
||||
}
|
||||
|
||||
fn material_json(m: &UserMaterial) -> Value {
|
||||
json!({"name": m.name, "density_g_cm3": m.density, "appearance": m.appearance.as_ref().map(look_json), "user": true})
|
||||
}
|
||||
|
||||
/// The index of the design material `material` names.
|
||||
fn existing(s: &Session, cmd: &str, p: &Value) -> Result<usize> {
|
||||
let name = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
|
||||
s.doc
|
||||
.user_materials
|
||||
.iter()
|
||||
.position(|m| m.name.eq_ignore_ascii_case(name.trim()))
|
||||
.ok_or_else(|| bad(cmd, format!("the design defines no material `{name}` (built-in materials cannot be changed)")))
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "material.create";
|
||||
let name = new_name(s, cmd, str_(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?, None)?;
|
||||
if s.doc.user_materials.len() >= MAX_USER_MATERIALS {
|
||||
return Err(bad(cmd, format!("a design defines at most {MAX_USER_MATERIALS} materials")));
|
||||
}
|
||||
let material = UserMaterial { density: density(cmd, p)?, appearance: look(cmd, p, &name)?.flatten(), name };
|
||||
let out = material_json(&material);
|
||||
s.doc_mut().user_materials.push(material);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Which configuration columns hold materials.
|
||||
fn material_columns(s: &Session) -> Vec<bool> {
|
||||
s.doc.configs.columns.iter().map(|c| matches!(c, Column::Material(_))).collect()
|
||||
}
|
||||
|
||||
/// Where material `name` is used: bodies, and configuration rows with a cell naming it.
|
||||
fn uses(s: &Session, name: &str) -> (Vec<String>, Vec<String>) {
|
||||
let bodies = s.doc.materials.iter().filter(|(_, m)| m.eq_ignore_ascii_case(name)).map(|(b, _)| b.clone()).collect();
|
||||
let columns = material_columns(s);
|
||||
let rows = s
|
||||
.doc
|
||||
.configs
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|r| r.cells.iter().zip(&columns).any(|(cell, material)| *material && cell.trim().eq_ignore_ascii_case(name)))
|
||||
.map(|r| r.name.clone())
|
||||
.collect();
|
||||
(bodies, rows)
|
||||
}
|
||||
|
||||
/// Point every use of material `from` (body assignments, configuration cells) at `to`
|
||||
/// ("Default" removes the body assignments).
|
||||
fn move_uses(s: &mut Session, from: &str, to: &str) {
|
||||
let columns = material_columns(s);
|
||||
let d = s.doc_mut();
|
||||
let bodies: Vec<String> = d.materials.iter().filter(|(_, m)| m.eq_ignore_ascii_case(from)).map(|(b, _)| b.clone()).collect();
|
||||
for b in bodies {
|
||||
if to == "Default" {
|
||||
d.materials.remove(&b);
|
||||
} else {
|
||||
d.materials.insert(b, to.to_string());
|
||||
}
|
||||
}
|
||||
for row in &mut d.configs.rows {
|
||||
for (cell, material) in row.cells.iter_mut().zip(&columns) {
|
||||
if *material && cell.trim().eq_ignore_ascii_case(from) {
|
||||
*cell = to.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "material.edit";
|
||||
let i = existing(s, cmd, p)?;
|
||||
let old = s.doc.user_materials.get(i).cloned().ok_or_else(|| bad(cmd, "no such material"))?;
|
||||
let name = match str_(p, "name") {
|
||||
Some(n) => new_name(s, cmd, n, Some(&old.name))?,
|
||||
None => old.name.clone(),
|
||||
};
|
||||
let density = if p.get("density_g_cm3").is_some() { density(cmd, p)? } else { old.density };
|
||||
let appearance = match look(cmd, p, &name)? {
|
||||
Some(l) => l,
|
||||
// A colour named after the material keeps following its name.
|
||||
None => old.appearance.clone().map(|mut l| {
|
||||
if l.name == old.name {
|
||||
l.name = name.clone();
|
||||
}
|
||||
l
|
||||
}),
|
||||
};
|
||||
let material = UserMaterial { name: name.clone(), density, appearance };
|
||||
let mut out = material_json(&material);
|
||||
if let Some(m) = s.doc_mut().user_materials.get_mut(i) {
|
||||
*m = material;
|
||||
}
|
||||
if name != old.name {
|
||||
move_uses(s, &old.name, &name);
|
||||
}
|
||||
out["bodies"] = json!(uses(s, &name).0);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "material.delete";
|
||||
let i = existing(s, cmd, p)?;
|
||||
let name = s.doc.user_materials.get(i).map(|m| m.name.clone()).ok_or_else(|| bad(cmd, "no such material"))?;
|
||||
let (bodies, rows) = uses(s, &name);
|
||||
let replace = match str_(p, "replace") {
|
||||
Some(r) => {
|
||||
let r = s.doc.material_name(r).ok_or_else(|| bad(cmd, format!("unknown material `{r}` (see material.list)")))?;
|
||||
if r.eq_ignore_ascii_case(&name) {
|
||||
return Err(bad(cmd, "`replace` must be another material"));
|
||||
}
|
||||
Some(r)
|
||||
}
|
||||
None if bodies.is_empty() && rows.is_empty() => None,
|
||||
None => {
|
||||
let mut used: Vec<String> =
|
||||
bodies.iter().map(|b| format!("body `{b}`")).chain(rows.iter().map(|r| format!("configuration `{r}`"))).collect();
|
||||
if used.len() > 10 {
|
||||
let more = used.len() - 10;
|
||||
used.truncate(10);
|
||||
used.push(format!("{more} more"));
|
||||
}
|
||||
return Err(bad(cmd, format!("`{name}` is used by {}: give `replace` (a material for them)", used.join(", "))));
|
||||
}
|
||||
};
|
||||
if let Some(r) = &replace {
|
||||
move_uses(s, &name, r);
|
||||
}
|
||||
s.doc_mut().user_materials.remove(i);
|
||||
Ok(json!({"deleted": name, "replaced_by": replace, "bodies": bodies}))
|
||||
}
|
||||
@@ -22,10 +22,12 @@ mod face;
|
||||
mod features;
|
||||
mod features_more;
|
||||
pub use features::auto_operation;
|
||||
mod component_link;
|
||||
mod config;
|
||||
mod file;
|
||||
mod inspect;
|
||||
pub(crate) mod joints;
|
||||
mod materials;
|
||||
mod measure_sel;
|
||||
mod mesh;
|
||||
pub(crate) mod motion;
|
||||
@@ -203,6 +205,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(sheet::COMMANDS.iter());
|
||||
v.extend(plastic::COMMANDS.iter());
|
||||
v.extend(component::COMMANDS.iter());
|
||||
v.extend(component_link::COMMANDS.iter());
|
||||
v.extend(joints::COMMANDS.iter());
|
||||
v.extend(motion::COMMANDS.iter());
|
||||
v.extend(config::COMMANDS.iter());
|
||||
@@ -214,6 +217,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(parameters::COMMANDS.iter());
|
||||
v.extend(file::COMMANDS.iter());
|
||||
v.extend(inspect::COMMANDS.iter());
|
||||
v.extend(materials::COMMANDS.iter());
|
||||
v.extend(section::COMMANDS.iter());
|
||||
v.extend(diagnostics::COMMANDS.iter());
|
||||
v
|
||||
|
||||
@@ -424,3 +424,118 @@ fn instance_styles_follow_source_identity_after_collision_rename_and_pending_mov
|
||||
assert_eq!(imported.bodies[0].body.color(), Some([1.0, 0.0, 0.0]));
|
||||
assert!(imported.bodies[0].body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0]));
|
||||
}
|
||||
|
||||
/// A design file path in a fresh temporary folder (removed by the caller).
|
||||
fn temp_design(tag: &str, ext: &str) -> (std::path::PathBuf, String) {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-visibility-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(format!("design.{ext}")).to_string_lossy().to_string();
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
/// #531: hiding a placement and a body is saved with the design and comes back when it opens.
|
||||
/// It is not an undo step (undoing a later feature leaves it hidden), but the design then has
|
||||
/// unsaved changes, and showing it again is saved the same way.
|
||||
#[test]
|
||||
fn hidden_placements_and_bodies_are_saved_with_the_design_but_not_undone() {
|
||||
let (dir, path) = temp_design("saved", "solidcraft");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":4,"width":4,"height":4,"body_name":"Base"}));
|
||||
let (component, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
run(&mut s, "file.save", json!({"path": path}));
|
||||
assert!(!s.is_dirty());
|
||||
|
||||
let undo = s.undo.len();
|
||||
run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]}));
|
||||
run(&mut s, "browser.visibility", json!({"visible":false,"items":["Base"]}));
|
||||
assert_eq!(s.undo.len(), undo, "hiding is not an undo step");
|
||||
assert!(s.is_dirty(), "hiding leaves the design with unsaved changes");
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1,"corner":[50,0,0],"operation":"new","body_name":"Later"}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.body_visible("Base") && !s.occurrence_visible(&[second]), "undo keeps the hiding");
|
||||
|
||||
run(&mut s, "file.save", json!({}));
|
||||
assert!(!s.is_dirty());
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
assert!(!s.body_visible("Base"), "the hidden body stays hidden");
|
||||
assert!(!s.occurrence_visible(&[second]), "the hidden placement stays hidden");
|
||||
assert!(s.occurrence_visible(&[first]));
|
||||
assert!(s.doc.components.iter().any(|c| c.id == component));
|
||||
assert!(!s.is_dirty(), "an opened design has no unsaved changes");
|
||||
assert!(s.undo.is_empty());
|
||||
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"folder":"bodies"}));
|
||||
assert!(s.is_dirty());
|
||||
run(&mut s, "file.save", json!({}));
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
assert!(s.body_visible("Base") && s.occurrence_visible(&[second]), "showing again is saved too");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #531: designs saved before visibility was kept (no `visibility` in the file) open with
|
||||
/// everything shown, and a design with nothing hidden saves exactly as before.
|
||||
#[test]
|
||||
fn designs_without_saved_visibility_open_unchanged() {
|
||||
let (dir, path) = temp_design("legacy", "json");
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":4,"width":4,"height":4,"body_name":"Base"}));
|
||||
let mut legacy: Value = serde_json::from_slice(&solidcraft_io::write_design(&s.doc)).unwrap();
|
||||
assert!(legacy.get("visibility").is_none(), "nothing hidden: no visibility in the file");
|
||||
legacy["format"] = json!("solidcraft/15");
|
||||
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
|
||||
|
||||
run(&mut s, "file.new", json!({}));
|
||||
run(&mut s, "doc.open", json!({"path": path}));
|
||||
assert!(s.body_visible("Base"));
|
||||
assert_eq!(s.visibility, crate::Visibility::default());
|
||||
assert!(!s.is_dirty());
|
||||
run(&mut s, "file.save", json!({}));
|
||||
let saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert!(saved.get("visibility").is_none());
|
||||
|
||||
run(&mut s, "browser.visibility", json!({"visible":false,"items":["Base"]}));
|
||||
run(&mut s, "file.save", json!({}));
|
||||
let saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(saved["visibility"]["hidden_bodies"], json!(["Base"]));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Command names for linking another design are a proposal (#533); adjust them to the real API.
|
||||
const INSERT_DESIGN: &str = "component.insert_design";
|
||||
const UPDATE_LINKS: &str = "component.update_links";
|
||||
|
||||
fn saved_box_design(path: &std::path::Path, height: f64) {
|
||||
let mut part = Session::default();
|
||||
run(&mut part, "solid.box", json!({"length": 10, "width": 20, "height": height, "body_name": "Plate"}));
|
||||
run(&mut part, "file.save", json!({"path": path}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_design_brings_its_bodies_in_and_follows_edits_to_the_part_file() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-linked-design-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let part = dir.join("plate.solidcraft");
|
||||
// Given a part saved in its own design: a 10 x 20 x 30 plate.
|
||||
saved_box_design(&part, 30.0);
|
||||
// When an assembly inserts it as a linked component, the plate's body is in the assembly.
|
||||
let mut assembly = Session::default();
|
||||
let inserted = assembly.execute(INSERT_DESIGN, &json!({"path": part, "translate": [50, 0, 0]}));
|
||||
let inserted = inserted.unwrap_or_else(|e| panic!("{INSERT_DESIGN}: {e}"));
|
||||
let occurrence = inserted["occurrence"].as_u64().unwrap_or_else(|| panic!("no occurrence in {inserted}"));
|
||||
let measured = run(&mut assembly, "occurrence.properties", json!({"occurrence": occurrence, "path": [occurrence]}));
|
||||
near(&measured["total"]["volume_mm3"], 6000.0);
|
||||
near(&measured["total"]["center_of_mass"][0], 55.0);
|
||||
// When the part is edited (30 -> 45 tall) and saved again, updating the links follows it.
|
||||
saved_box_design(&part, 45.0);
|
||||
run(&mut assembly, UPDATE_LINKS, json!({}));
|
||||
let measured = run(&mut assembly, "occurrence.properties", json!({"occurrence": occurrence, "path": [occurrence]}));
|
||||
near(&measured["total"]["volume_mm3"], 9000.0);
|
||||
near(&measured["total"]["center_of_mass"][0], 55.0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -208,6 +208,25 @@ fn patterns_of_a_blind_hole_drill_every_copy() {
|
||||
assert!((c - 2.0 * one).abs() < 1e-3 * one, "{c} vs 2 × {one}");
|
||||
}
|
||||
|
||||
/// Feature patterns and mirrors reference features by name; a rename follows (#401).
|
||||
#[test]
|
||||
fn renaming_a_source_feature_follows_pattern_and_mirror_references() {
|
||||
let mut s = plate_with_hole();
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "YZ", "offset": 0, "name": "Mirror plane"}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Hole1"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 20, "name": "Copies"}));
|
||||
run(&mut s, "solid.mirror", json!({"features": ["Hole1"], "plane": "Mirror plane", "name": "Mirrored"}));
|
||||
let renamed = run(&mut s, "timeline.rename", json!({"feature": "Hole1", "name": "Single hole"}));
|
||||
assert_eq!(renamed["name"], "Single hole", "{renamed}");
|
||||
run(&mut s, "timeline.rename", json!({"feature": "Mirror plane", "name": "Renamed plane"}));
|
||||
for feature in ["Copies", "Mirrored"] {
|
||||
let inspected = run(&mut s, "timeline.inspect", json!({"feature": feature}));
|
||||
assert!(inspected["error"].is_null(), "{feature}: {inspected}");
|
||||
assert_eq!(inspected["definition"]["features"], json!(["Single hole"]), "{inspected}");
|
||||
}
|
||||
let inspected = run(&mut s, "timeline.inspect", json!({"feature": "Mirrored"}));
|
||||
assert_eq!(inspected["definition"]["plane"]["name"], "Renamed plane", "{inspected}");
|
||||
}
|
||||
|
||||
/// A plate with a `count1` × `count2` feature pattern of one through-hole cut: its volume and how
|
||||
/// long the pattern took. It runs on its own thread so a hang fails the test instead of CI.
|
||||
fn hole_pattern(count1: usize, count2: usize) -> (f64, std::time::Duration) {
|
||||
|
||||
+128
-21
@@ -2,9 +2,9 @@
|
||||
//! the flat pattern (outline, bend lines, bend table) and its DXF export.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::FeatureKind;
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_doc::sheet::{SheetBody, SheetRule};
|
||||
use solidcraft_doc::sheet::{self, SheetBody, SheetRule};
|
||||
use solidcraft_doc::{FeatureKind, FlangeEdge};
|
||||
use solidcraft_geom::{Vec2, Vec3};
|
||||
|
||||
use super::CommandSpec;
|
||||
@@ -15,8 +15,10 @@ use crate::{EngineError, Result, Session};
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sheet.flange", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params(
|
||||
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \
|
||||
edge: edges: [[x,y,z] on top/bottom edges] + height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
|
||||
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs); name?, body_name? (the new body)",
|
||||
edge: edges: [[x,y,z] on top/bottom edges, or {point: [x,y,z], angle?, height?} for an edge with its own angle and height] + \
|
||||
height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
|
||||
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs); name?, body_name? (the new body). \
|
||||
Thickness, K-factor and bend radius come from the rule (sheet.manage_rules); passing them here is refused",
|
||||
),
|
||||
CommandSpec::new("sheet.hem", "Hem", hem)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
@@ -35,20 +37,24 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
|
||||
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
|
||||
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's; \
|
||||
gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); active?: bool; delete?: bool",
|
||||
gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); \
|
||||
corner_relief_shape?: notch|round|rectangular (what is cut where two bends meet at a corner of the base, `corner_relief` across; default notch); \
|
||||
active?: bool; delete?: bool",
|
||||
),
|
||||
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
|
||||
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("flat")
|
||||
.noundo()
|
||||
.params("body? → flat size, outline loops and cut-outs (flat coordinates), bend lines and the bend table"),
|
||||
.params(
|
||||
"body? → flat size, outline loops and cut-outs (flat coordinates, as point lists), cutout_shapes (the same cut-outs exactly, in the same order: {circle: {center, radius}} for a round hole, else {segments: [{line: [a, b]} | {arc: {center, radius, start_deg, sweep_deg}} | {polyline: [...]}]}), pockets (with their own cutout_shapes), bend lines and the bend table",
|
||||
),
|
||||
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf)
|
||||
.noundo()
|
||||
.params("body?, path? → DXF (mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"),
|
||||
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table)
|
||||
.noundo()
|
||||
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates"),
|
||||
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; a bend that cut-outs split has a row for each piece"),
|
||||
];
|
||||
|
||||
fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
@@ -61,6 +67,39 @@ fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `edges` of an edge flange: points `[x, y, z]` on sheet edges, or `{point, angle?, height?}`
|
||||
/// for an edge with its own angle and height (expressions, checked like the feature's).
|
||||
fn flange_edges(s: &Session, p: &Value, cmd: &str) -> Result<Vec<FlangeEdge>> {
|
||||
const KEYS: [&str; 3] = ["point", "angle", "height"];
|
||||
let list = "`edges` must list points [x, y, z] on sheet edges, or {point, angle?, height?}";
|
||||
let a = p.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, list))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
return Err(bad(cmd, "`edges` must list 1…1000 points"));
|
||||
}
|
||||
let mut edges = Vec::with_capacity(a.len());
|
||||
for (i, v) in a.iter().enumerate() {
|
||||
// A bare point, as before: [x, y, z] or {x, y, z}.
|
||||
let Some(o) = v.as_object().filter(|o| KEYS.iter().any(|k| o.contains_key(*k))) else {
|
||||
edges.push(FlangeEdge::at(vec3(v).ok_or_else(|| bad(cmd, format!("edges[{i}]: {list}")))?));
|
||||
continue;
|
||||
};
|
||||
if let Some(k) = o.keys().find(|k| !KEYS.contains(&k.as_str())) {
|
||||
return Err(bad(cmd, format!("edges[{i}]: unknown key `{k}` (an edge takes point, angle and height)")));
|
||||
}
|
||||
let point = o.get("point").and_then(vec3).ok_or_else(|| bad(cmd, format!("edges[{i}]: `point` must be [x, y, z]")))?;
|
||||
let own = |k: &str, kind: Kind| -> Result<Option<String>> {
|
||||
if o.get(k).is_none_or(Value::is_null) {
|
||||
return Ok(None);
|
||||
}
|
||||
let e = expr(v, k).ok_or_else(|| bad(cmd, format!("edges[{i}]: `{k}` must be a number or an expression")))?;
|
||||
check_expr(s, &e, kind, cmd, &format!("edges[{i}].{k}"))?;
|
||||
Ok(Some(e))
|
||||
};
|
||||
edges.push(FlangeEdge { point, angle: own("angle", Kind::Angle)?, height: own("height", Kind::Length)? });
|
||||
}
|
||||
Ok(edges)
|
||||
}
|
||||
|
||||
fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
|
||||
match str_(p, "rule") {
|
||||
Some(r) if s.doc.sheet.rules.iter().any(|x| x.name == r) || r == SheetRule::steel().name => Ok(Some(r.to_string())),
|
||||
@@ -69,8 +108,23 @@ fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sheet values a flange takes from its rule. Passing one to `sheet.flange` is refused rather
|
||||
/// than ignored, so a script never gets a part of another thickness than it asked for.
|
||||
const RULE_ONLY: [&str; 8] =
|
||||
["thickness", "k_factor", "bend_radius", "relief_width", "relief_depth", "corner_relief", "corner_relief_shape", "hem_gap"];
|
||||
|
||||
fn flange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sheet.flange";
|
||||
let nested = p.get("base").filter(|b| b.is_object());
|
||||
if let Some(key) = RULE_ONLY.iter().find(|k| p.get(**k).is_some() || nested.is_some_and(|b| b.get(**k).is_some())) {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"`{key}` is not a sheet.flange parameter: the sheet metal rule sets it. Make a rule with \
|
||||
sheet.manage_rules {{name, {key}, …}} and pass its name as `rule`"
|
||||
),
|
||||
));
|
||||
}
|
||||
let ty = match str_(p, "type") {
|
||||
Some(t) => t.to_ascii_lowercase(),
|
||||
None if p.get("edges").is_some() => "edge".into(),
|
||||
@@ -106,7 +160,7 @@ fn flange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
)
|
||||
}
|
||||
"edge" => {
|
||||
let edges = points(p, "edges", cmd)?;
|
||||
let edges = flange_edges(s, p, cmd)?;
|
||||
let height = req_expr(cmd, p, "height")?;
|
||||
check_expr(s, &height, Kind::Length, cmd, "height")?;
|
||||
let angle = expr(p, "angle").unwrap_or_else(|| "90 deg".into());
|
||||
@@ -182,7 +236,25 @@ fn fold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => None,
|
||||
};
|
||||
let body = str_(p, "body").map(str::to_string);
|
||||
add_feature(s, p, FeatureKind::SheetFold { sketch, curve, a, b, angle, radius, position, flip: bool_(p, "flip").unwrap_or(false), fixed, body })
|
||||
let r = add_feature(
|
||||
s,
|
||||
p,
|
||||
FeatureKind::SheetFold { sketch, curve, a, b, angle, radius, position, flip: bool_(p, "flip").unwrap_or(false), fixed, body },
|
||||
)?;
|
||||
// A recompute keeps a stored fold whose fixed point no longer touches the sheet (with a
|
||||
// warning); a new fold refuses one, since it picks no side (#263).
|
||||
if let Some(f) = fixed {
|
||||
let id = r.get("feature").and_then(Value::as_u64);
|
||||
let off =
|
||||
id.and_then(|id| s.model.result(id)).and_then(|res| res.warning.as_deref()).is_some_and(|w| w.contains(sheet::FOLD_FIXED_OFF_SHEET));
|
||||
if off {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("the fixed point ({:.3}, {:.3}, {:.3}) is not on the sheet: pick a point on the face of the side that stays", f.x, f.y, f.z),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn unfold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -235,6 +307,10 @@ fn rules(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*slot = e;
|
||||
}
|
||||
}
|
||||
if let Some(shape) = p.get("corner_relief_shape").filter(|v| !v.is_null()) {
|
||||
let shape = shape.as_str().and_then(solidcraft_doc::sheet::ReliefShape::parse);
|
||||
rule.corner_relief_shape = shape.ok_or_else(|| bad(cmd, "`corner_relief_shape` must be notch, round or rectangular"))?.name().into();
|
||||
}
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let values = s.doc.rule_values(&vals, &rule).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let doc = s.doc_mut();
|
||||
@@ -274,6 +350,39 @@ fn sheet_of(s: &Session, p: &Value, cmd: &str) -> Result<SheetBody> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The centre and radius of a loop that is one whole circle (often two semicircles).
|
||||
fn loop_circle(lp: &solidcraft_geom::Loop2) -> Option<(Vec2, f64)> {
|
||||
use solidcraft_geom::Seg2;
|
||||
let Some(Seg2::Arc { center, radius, sweep, .. }) = lp.segs.first() else { return None };
|
||||
let total: f64 = lp.segs.iter().filter_map(|s| if let Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum();
|
||||
let circular = lp.segs.iter().all(
|
||||
|s| matches!(s, Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r - *radius).abs() < 1e-9 && a.signum() == sweep.signum()),
|
||||
);
|
||||
(circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9).then_some((*center, *radius))
|
||||
}
|
||||
|
||||
/// A loop's exact shape for the flat pattern JSON: a circle, or its lines and arcs (other
|
||||
/// curves as point lists at the flat pattern's 0.001 mm tolerance).
|
||||
fn loop_shape(lp: &solidcraft_geom::Loop2) -> Value {
|
||||
use solidcraft_geom::Seg2;
|
||||
let pt = |v: Vec2| json!([v.x, v.y]);
|
||||
if let Some((center, radius)) = loop_circle(lp) {
|
||||
return json!({"circle": {"center": pt(center), "radius": radius}});
|
||||
}
|
||||
let segments: Vec<Value> = lp
|
||||
.segs
|
||||
.iter()
|
||||
.map(|seg| match *seg {
|
||||
Seg2::Line { a, b } => json!({"line": [pt(a), pt(b)]}),
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
json!({"arc": {"center": pt(center), "radius": radius, "start_deg": start.to_degrees(), "sweep_deg": sweep.to_degrees()}})
|
||||
}
|
||||
ref other => json!({"polyline": other.polyline(1e-3).into_iter().map(pt).collect::<Vec<_>>()}),
|
||||
})
|
||||
.collect();
|
||||
json!({"segments": segments})
|
||||
}
|
||||
|
||||
fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
let regions = sh.flat_regions()?;
|
||||
let loops: Vec<Vec<Vec2>> = regions.iter().map(|r| r.outer.polyline(1e-3)).collect();
|
||||
@@ -286,6 +395,7 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
let pt = |v: &Vec2| json!([v.x, v.y]);
|
||||
let outline: Vec<Value> = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect();
|
||||
let holes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(|h| Value::Array(h.polyline(1e-3).iter().map(pt).collect())).collect();
|
||||
let hole_shapes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(loop_shape).collect();
|
||||
let bends: Vec<Value> = sh
|
||||
.bends()
|
||||
.iter()
|
||||
@@ -307,11 +417,12 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
};
|
||||
json!({"outline": p.region.outer.polyline(1e-3).iter().map(pt).collect::<Vec<_>>(),
|
||||
"cutouts": p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect::<Vec<_>>()).collect::<Vec<_>>(),
|
||||
"cutout_shapes": p.region.holes.iter().map(loop_shape).collect::<Vec<_>>(),
|
||||
"side": side, "depth_mm": p.z1-p.z0, "z0_mm": p.z0, "z1_mm": p.z1})
|
||||
})
|
||||
.collect();
|
||||
Ok(
|
||||
json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "pockets": pockets, "bends": bends, "unfolded": sh.flat}),
|
||||
json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "cutout_shapes": hole_shapes, "pockets": pockets, "bends": bends, "unfolded": sh.flat}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -385,17 +496,13 @@ pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
for (h, layer) in loops {
|
||||
// A circle is often represented by two semicircles. Keep the whole analytic
|
||||
// boundary as one CIRCLE rather than approximating outer boundaries with chords.
|
||||
if let Some(solidcraft_geom::Seg2::Arc { center, radius, sweep, .. }) = h.segs.first() {
|
||||
let total: f64 = h.segs.iter().filter_map(|s| if let solidcraft_geom::Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum();
|
||||
let circular = h.segs.iter().all(|s| matches!(s, solidcraft_geom::Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r-*radius).abs() < 1e-9 && a.signum() == sweep.signum()));
|
||||
if circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9 {
|
||||
w(0, "CIRCLE");
|
||||
w(8, &layer);
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(*radius));
|
||||
continue;
|
||||
}
|
||||
if let Some((center, radius)) = loop_circle(h) {
|
||||
w(0, "CIRCLE");
|
||||
w(8, &layer);
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(radius));
|
||||
continue;
|
||||
}
|
||||
for seg in &h.segs {
|
||||
match *seg {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sketch")
|
||||
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source, fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"),
|
||||
.params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source (a flat face of a sheet metal body attaches to its panel instead, in the flat pattern's coordinates), fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"),
|
||||
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"),
|
||||
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
|
||||
CommandSpec::new("sketch.line", "Line", draw_line)
|
||||
@@ -117,12 +117,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_coincident")
|
||||
.enabled(in_sketch)
|
||||
.params("a, b: points, a point and a curve, or two distinct lines (joins their closest endpoints)"),
|
||||
.params("a, b: points, a point and a curve or sketch axis (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created), or two distinct lines (joins their closest endpoints)"),
|
||||
CommandSpec::new("sketch.constraint.tangent", "Tangent", c_tangent)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_tangent")
|
||||
.enabled(in_sketch)
|
||||
.params("a, b: curves"),
|
||||
.params("a, b: curves or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
|
||||
CommandSpec::new("sketch.constraint.equal", "Equal", c_equal)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_equal")
|
||||
@@ -132,12 +132,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_parallel")
|
||||
.enabled(in_sketch)
|
||||
.params("a, b: lines"),
|
||||
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
|
||||
CommandSpec::new("sketch.constraint.perpendicular", "Perpendicular", c_perpendicular)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_perpendicular")
|
||||
.enabled(in_sketch)
|
||||
.params("a, b: lines"),
|
||||
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
|
||||
CommandSpec::new("sketch.constraint.fix", "Fix/UnFix", c_fix)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_fix")
|
||||
@@ -157,12 +157,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_collinear")
|
||||
.enabled(in_sketch)
|
||||
.params("a, b: lines"),
|
||||
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
|
||||
CommandSpec::new("sketch.constraint.symmetry", "Symmetry", c_symmetry)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_symmetry")
|
||||
.enabled(in_sketch)
|
||||
.params("a, b: points, line: symmetry line"),
|
||||
.params("a, b: two points or two curves (lines mirror their ends, circles/arcs their centres); line: symmetry line id, or x / y for the sketch's own axes (a curve with that id wins; an axis becomes a linked construction line on it, reported in created)"),
|
||||
CommandSpec::new("sketch.construction", "Normal/Construction", construction)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("construction")
|
||||
@@ -294,7 +294,7 @@ pub(super) fn edit_solved<T>(
|
||||
let unsolved = sk.clone();
|
||||
#[cfg(test)]
|
||||
solve_counts::record(Analysis::Full);
|
||||
let rep = doc.solve_sketch(&vals, &mut sk, &stored)?;
|
||||
let rep = doc.solve_sketch(&vals, &mut sk, &stored, None)?;
|
||||
Some(settle(strict, rep, &mut sk, unsolved)?)
|
||||
}
|
||||
};
|
||||
@@ -336,7 +336,7 @@ pub(super) fn counted_solve(
|
||||
pub(super) fn solve_current(sk: &mut Sketch, doc: &solidcraft_doc::Document) -> Result<SolveReport> {
|
||||
let stored = sk.driving_values();
|
||||
let (values, _) = doc.param_values();
|
||||
Ok(doc.solve_sketch(&values, sk, &stored)?)
|
||||
Ok(doc.solve_sketch(&values, sk, &stored, None)?)
|
||||
}
|
||||
|
||||
/// Solves run on this thread by [`counted_solve`] and the edit's own solve: (all of them, those
|
||||
@@ -454,6 +454,22 @@ pub(super) fn curve_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) -
|
||||
sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown sketch curve `{id}`")))
|
||||
}
|
||||
|
||||
/// A line a constraint refers to: a curve id, or `x` / `y` for the sketch's own axes (a curve
|
||||
/// with that id wins). An axis becomes the sketch's linked construction line on it
|
||||
/// ([`Sketch::sketch_axis_line`]), added on first use and reported in `created`.
|
||||
pub(super) fn line_or_axis_ref(sk: &mut Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result<usize> {
|
||||
let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, format!("`{what}` must be a curve id, or x / y for a sketch axis")))?;
|
||||
curve_or_axis(sk, id)?.ok_or_else(|| bad(cmd, format!("unknown sketch curve `{id}` (x and y name the sketch's own axes)")))
|
||||
}
|
||||
|
||||
/// Curve `id`, or the line of sketch axis `id` (see [`line_or_axis_ref`]); `None` when neither.
|
||||
fn curve_or_axis(sk: &mut Sketch, id: &str) -> Result<Option<usize>> {
|
||||
match sk.curve_index(id) {
|
||||
Some(c) => Ok(Some(c)),
|
||||
None => Ok(sk.sketch_axis_line(id)?),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn point_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result<usize> {
|
||||
let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, format!("`{what}` must be a point reference")))?;
|
||||
sk.resolve_point(id).ok_or_else(|| bad(cmd, format!("unknown sketch point `{id}`")))
|
||||
@@ -646,6 +662,8 @@ fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneRef, interna
|
||||
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => Some(if face::anchor(plane).is_some() {
|
||||
"exact_face"
|
||||
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
|
||||
"sheet_face"
|
||||
} else if internal_body.is_some() {
|
||||
"internal_legacy_face"
|
||||
} else {
|
||||
@@ -1154,7 +1172,7 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Constraints
|
||||
|
||||
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&Sketch) -> Result<ConstraintKind>) -> Result<Value> {
|
||||
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch) -> Result<ConstraintKind>) -> Result<Value> {
|
||||
let mut before = None;
|
||||
let (id, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let k = f(sk)?;
|
||||
@@ -1179,7 +1197,7 @@ pub(super) enum Ent {
|
||||
}
|
||||
|
||||
fn ent(sk: &Sketch, r: &str) -> Option<Ent> {
|
||||
sk.resolve_point(r).map(Ent::Point).or_else(|| sk.curve_index(r).map(Ent::Curve))
|
||||
sk.resolve_point(r).map(Ent::Point).or_else(|| sk.curve_index(r).or_else(|| sk.sketch_axis_curve(r)).map(Ent::Curve))
|
||||
}
|
||||
|
||||
/// The entities of a two-entity constraint in the order they were picked (`a` then `b`, or
|
||||
@@ -1372,15 +1390,18 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let aref = p.get("a").and_then(Value::as_str).unwrap_or_default();
|
||||
let bref = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?;
|
||||
// A curve picked first and a point second: the point goes onto the curve.
|
||||
if let (None, Some(c), Some(q)) = (sk.resolve_point(aref), sk.curve_index(aref), sk.resolve_point(bref)) {
|
||||
// A curve (or sketch axis) picked first and a point second: the point goes onto it.
|
||||
if sk.resolve_point(aref).is_none()
|
||||
&& let Some(q) = sk.resolve_point(bref)
|
||||
&& let Some(c) = curve_or_axis(sk, aref)?
|
||||
{
|
||||
return Ok(ConstraintKind::PointOnCurve { p: q, c });
|
||||
}
|
||||
let a = point_ref(sk, p.get("a"), cmd, "a")?;
|
||||
if let Some(b) = sk.resolve_point(bref) {
|
||||
return Ok(ConstraintKind::Coincident { p: a, q: b });
|
||||
}
|
||||
let c = sk.curve_index(bref).ok_or_else(|| bad(cmd, format!("unknown entity `{bref}`")))?;
|
||||
let c = curve_or_axis(sk, bref)?.ok_or_else(|| bad(cmd, format!("unknown entity `{bref}`")))?;
|
||||
Ok(ConstraintKind::PointOnCurve { p: a, c })
|
||||
})
|
||||
}
|
||||
@@ -1420,20 +1441,27 @@ fn two_curves(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> {
|
||||
Ok((curve_ref(sk, p.get("a"), cmd, "a")?, curve_ref(sk, p.get("b"), cmd, "b")?))
|
||||
}
|
||||
|
||||
/// Two curves where either may be a sketch axis (`x` / `y`, see [`line_or_axis_ref`]).
|
||||
fn two_curves_or_axes(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> {
|
||||
Ok((line_or_axis_ref(sk, p.get("a"), cmd, "a")?, line_or_axis_ref(sk, p.get("b"), cmd, "b")?))
|
||||
}
|
||||
|
||||
fn c_tangent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
constrain(s, p, "sketch.constraint.tangent", |sk| two_curves(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b }))
|
||||
constrain(s, p, "sketch.constraint.tangent", |sk| {
|
||||
two_curves_or_axes(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b })
|
||||
})
|
||||
}
|
||||
fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
constrain(s, p, "sketch.constraint.equal", |sk| two_curves(sk, p, "sketch.constraint.equal").map(|(a, b)| ConstraintKind::Equal { a, b }))
|
||||
}
|
||||
fn c_parallel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
constrain(s, p, "sketch.constraint.parallel", |sk| {
|
||||
two_curves(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b })
|
||||
two_curves_or_axes(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b })
|
||||
})
|
||||
}
|
||||
fn c_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
constrain(s, p, "sketch.constraint.perpendicular", |sk| {
|
||||
two_curves(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })
|
||||
two_curves_or_axes(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })
|
||||
})
|
||||
}
|
||||
fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1443,7 +1471,7 @@ fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
fn c_collinear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
constrain(s, p, "sketch.constraint.collinear", |sk| {
|
||||
two_curves(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b })
|
||||
two_curves_or_axes(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b })
|
||||
})
|
||||
}
|
||||
fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1455,7 +1483,7 @@ fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.constraint.symmetry";
|
||||
let (ids, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let l = curve_ref(sk, p.get("line"), cmd, "line")?;
|
||||
let l = line_or_axis_ref(sk, p.get("line"), cmd, "line")?;
|
||||
let ra = p.get("a").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`a` must be a point or curve"))?;
|
||||
let rb = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?;
|
||||
let rep = solve(&mut sk.clone());
|
||||
@@ -2119,6 +2147,9 @@ mod rect_tests;
|
||||
#[path = "sketch_ux_tests.rs"]
|
||||
mod ux_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_axis_ref_tests.rs"]
|
||||
mod axis_ref_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_drag_live_tests.rs"]
|
||||
mod drag_live_tests;
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Constraints that take a line accept `x` / `y` for the sketch's own axes (issue #394): the
|
||||
//! axis becomes one linked construction line on it, shared by every constraint on that axis,
|
||||
//! reported in `created`, and left out of profiles. A real curve with that id wins.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::{Vec2, Vec3};
|
||||
use solidcraft_sketch::{CurveKind, LinkSource, Sketch};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn sketch(s: &Session) -> Sketch {
|
||||
s.doc.sketch(s.active_sketch.unwrap()).unwrap().clone()
|
||||
}
|
||||
|
||||
fn on(plane: Value) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({ "plane": plane }));
|
||||
s
|
||||
}
|
||||
|
||||
fn pt(sk: &Sketch, r: &str) -> Vec2 {
|
||||
sk.point(sk.resolve_point(r).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn strs(v: &Value) -> Vec<String> {
|
||||
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The sketch's axis lines: (axis name, curve id).
|
||||
fn axis_lines(sk: &Sketch) -> Vec<(String, String)> {
|
||||
sk.links
|
||||
.iter()
|
||||
.filter_map(|l| match &l.source {
|
||||
LinkSource::SketchAxis { name } => Some((name.clone(), l.id.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.flat_map(|(name, link)| sk.link_curves(&link).into_iter().map(move |c| (name.clone(), sk.curves[c].id.clone())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_axis_line(sk: &Sketch, id: &str, dir: Vec2) {
|
||||
let c = sk.curve_index(id).unwrap();
|
||||
let curve = &sk.curves[c];
|
||||
assert!(curve.construction, "axis line {id} is construction");
|
||||
assert!(sk.is_linked_curve(c), "axis line {id} is linked");
|
||||
let CurveKind::Line { a, b } = curve.kind else { panic!("axis line {id} is a line") };
|
||||
for p in [sk.point(a).unwrap(), sk.point(b).unwrap()] {
|
||||
assert!(p.cross(dir).abs() < 1e-9, "{id} end {p:?} lies on the axis {dir:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_394_symmetry_about_the_sketch_y_axis() {
|
||||
let mut s = on(json!("XY"));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]}));
|
||||
let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"}));
|
||||
assert!(r["sketch"]["solved"].as_bool().unwrap(), "{r}");
|
||||
let sk = sketch(&s);
|
||||
let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end"));
|
||||
assert!((start.x + end.x).abs() < 1e-7 && (start.y - end.y).abs() < 1e-7, "mirrored about y: {start:?} {end:?}");
|
||||
assert!((start.x - -4.0).abs() < 1e-7, "the first entity stays: {start:?}");
|
||||
let axes = axis_lines(&sk);
|
||||
assert_eq!(axes.len(), 1, "{axes:?}");
|
||||
let (name, line) = &axes[0];
|
||||
assert_eq!(name, "y");
|
||||
assert_axis_line(&sk, line, Vec2::Y);
|
||||
assert_eq!(strs(&r["created"]["curves"]), vec![line.clone()], "the axis line is reported in created: {r}");
|
||||
assert_eq!(strs(&r["created"]["constraints"]).len(), 1, "{r}");
|
||||
|
||||
// A second constraint on the same axis reuses its line.
|
||||
run(&mut s, "sketch.line", json!({"points": [[-3, 5], [8, 5]], "ids": ["b"]}));
|
||||
let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "b.start", "b": "b.end", "line": "y"}));
|
||||
assert!(strs(&r["created"]["curves"]).is_empty(), "{r}");
|
||||
assert_eq!(axis_lines(&sketch(&s)).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_curve_named_like_an_axis_wins() {
|
||||
let mut s = on(json!("XY"));
|
||||
run(&mut s, "sketch.line", json!({"points": [[5, -20], [5, 20]], "ids": ["y"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entity": "y"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [12, 0]], "ids": ["a"]}));
|
||||
run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"}));
|
||||
let sk = sketch(&s);
|
||||
assert!(axis_lines(&sk).is_empty(), "no axis line when a curve is called y");
|
||||
let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end"));
|
||||
assert!((start.x + end.x - 10.0).abs() < 1e-7, "mirrored about x = 5: {start:?} {end:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_centred_on_the_origin_keeps_one_profile() {
|
||||
let mut s = on(json!("XY"));
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [2, 1], "p1": [12, 7]}));
|
||||
let sk = sketch(&s);
|
||||
let lines = strs(&r["curves"]);
|
||||
let dir = |id: &String| {
|
||||
let CurveKind::Line { a, b } = sk.curves[sk.curve_index(id).unwrap()].kind else { panic!() };
|
||||
sk.point(b).unwrap() - sk.point(a).unwrap()
|
||||
};
|
||||
let horizontal = lines.iter().find(|l| dir(l).y.abs() < 1e-9).unwrap().clone();
|
||||
let vertical = lines.iter().find(|l| dir(l).x.abs() < 1e-9).unwrap().clone();
|
||||
run(&mut s, "sketch.constraint.symmetry", json!({"a": format!("{horizontal}.start"), "b": format!("{horizontal}.end"), "line": "y"}));
|
||||
run(&mut s, "sketch.constraint.symmetry", json!({"a": format!("{vertical}.start"), "b": format!("{vertical}.end"), "line": "x"}));
|
||||
let sk = sketch(&s);
|
||||
let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY);
|
||||
let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for l in &lines {
|
||||
for end in ["start", "end"] {
|
||||
let p = pt(&sk, &format!("{l}.{end}"));
|
||||
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
|
||||
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
|
||||
}
|
||||
}
|
||||
assert!((lo.x + hi.x).abs() < 1e-7 && (lo.y + hi.y).abs() < 1e-7, "centred on the origin: {lo:?} {hi:?}");
|
||||
assert_eq!(axis_lines(&sk).len(), 2);
|
||||
// The axis lines cross the rectangle but are construction: still one profile.
|
||||
assert_eq!(run(&mut s, "sketch.solve", json!({}))["profiles"], json!(1));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_axes_on_a_plane_off_the_world_axes() {
|
||||
// A plane whose own axes are none of the world axes: x along world Y, y along world Z,
|
||||
// origin away from the world origin.
|
||||
let origin = Vec3::new(5.0, 7.0, 9.0);
|
||||
let mut s = on(json!({"origin": [5, 7, 9], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[3, -6], [3, 2]], "ids": ["a"]}));
|
||||
let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "x"}));
|
||||
assert!(r["sketch"]["solved"].as_bool().unwrap(), "{r}");
|
||||
let sk = sketch(&s);
|
||||
let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end"));
|
||||
assert!((start.y + end.y).abs() < 1e-7 && (start.x - end.x).abs() < 1e-7, "mirrored about the sketch x axis: {start:?} {end:?}");
|
||||
let axes = axis_lines(&sk);
|
||||
assert_eq!(axes.len(), 1);
|
||||
assert_axis_line(&sk, &axes[0].1, Vec2::X);
|
||||
|
||||
// After a recompute the axis line is still the sketch's x axis, in world space too.
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let st = s.model.state();
|
||||
let id = s.doc.find_feature("Sketch1").map(|f| f.id).unwrap();
|
||||
let ss = st.sketch(id).unwrap();
|
||||
let c = ss.sketch.curve_index(&axes[0].1).unwrap();
|
||||
let CurveKind::Line { a, b } = ss.sketch.curves[c].kind else { panic!() };
|
||||
for p in [a, b] {
|
||||
let w = ss.plane.to_world(ss.sketch.point(p).unwrap()) - origin;
|
||||
assert!(w.x.abs() < 1e-9 && w.z.abs() < 1e-9, "on the plane's x axis (world Y through its origin): {w:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_line_constraints_take_sketch_axes() {
|
||||
let mut s = on(json!("XZ"));
|
||||
run(&mut s, "sketch.line", json!({"points": [[1, 1], [6, 3]], "ids": ["p"]}));
|
||||
run(&mut s, "sketch.constraint.parallel", json!({"a": "p", "b": "x"}));
|
||||
let sk = sketch(&s);
|
||||
assert!((pt(&sk, "p.start").y - pt(&sk, "p.end").y).abs() < 1e-7, "parallel to x");
|
||||
|
||||
run(&mut s, "sketch.line", json!({"points": [[-2, 1], [-1, 6]], "ids": ["q"]}));
|
||||
run(&mut s, "sketch.constraint.perpendicular", json!({"a": "x", "b": "q"}));
|
||||
let sk = sketch(&s);
|
||||
assert!((pt(&sk, "q.start").x - pt(&sk, "q.end").x).abs() < 1e-7, "perpendicular to x");
|
||||
|
||||
run(&mut s, "sketch.line", json!({"points": [[2, -5], [4, -8]], "ids": ["r"]}));
|
||||
run(&mut s, "sketch.constraint.collinear", json!({"a": "r", "b": "y"}));
|
||||
let sk = sketch(&s);
|
||||
assert!(pt(&sk, "r.start").x.abs() < 1e-7 && pt(&sk, "r.end").x.abs() < 1e-7, "on the y axis");
|
||||
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [10, 4], "radius": 2, "id": "c"}));
|
||||
run(&mut s, "sketch.constraint.tangent", json!({"a": "c", "b": "x"}));
|
||||
let sk = sketch(&s);
|
||||
let c = sk.curve_index("c").unwrap();
|
||||
let CurveKind::Circle { c: centre, r } = sk.curves[c].kind else { panic!() };
|
||||
assert!((sk.point(centre).unwrap().y.abs() - r).abs() < 1e-7, "tangent to x");
|
||||
|
||||
// A point onto an axis, either pick order.
|
||||
run(&mut s, "sketch.point", json!({"point": [7, 9], "id": "u"}));
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": "u", "b": "x"}));
|
||||
assert!(pt(&sketch(&s), "u").y.abs() < 1e-7);
|
||||
run(&mut s, "sketch.point", json!({"point": [7, 9], "id": "v"}));
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": "y", "b": "v"}));
|
||||
assert!(pt(&sketch(&s), "v").x.abs() < 1e-7);
|
||||
|
||||
// Still one line per axis.
|
||||
let mut names: Vec<String> = axis_lines(&sketch(&s)).into_iter().map(|(n, _)| n).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["x", "y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_axis_survives_save_and_load() {
|
||||
let mut s = on(json!("YZ"));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]}));
|
||||
run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let id = doc.find_feature("Sketch1").map(|f| f.id).unwrap();
|
||||
let sk = doc.sketch(id).unwrap();
|
||||
assert_eq!(axis_lines(sk).len(), 1);
|
||||
assert_eq!(sk.links.iter().map(|l| &l.source).collect::<Vec<_>>(), [&LinkSource::SketchAxis { name: "y".into() }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_line_references_are_errors_and_change_nothing() {
|
||||
let mut s = on(json!("XY"));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 3], [9, 4]], "ids": ["b"]}));
|
||||
let before = s.doc.to_json();
|
||||
let hostile =
|
||||
[json!("Y"), json!("X"), json!("z"), json!(""), json!("x.start"), json!(" y"), json!(5), json!(null), json!(["y"]), json!({"axis": "y"})];
|
||||
for line in &hostile {
|
||||
assert!(s.execute("sketch.constraint.symmetry", &json!({"a": "a.start", "b": "a.end", "line": line})).is_err(), "symmetry line {line}");
|
||||
for cmd in ["sketch.constraint.parallel", "sketch.constraint.perpendicular", "sketch.constraint.collinear", "sketch.constraint.tangent"] {
|
||||
assert!(s.execute(cmd, &json!({"a": "b", "b": line})).is_err(), "{cmd} b {line}");
|
||||
}
|
||||
assert!(s.execute("sketch.constraint.coincident", &json!({"a": "b.start", "b": line})).is_err(), "coincident b {line}");
|
||||
}
|
||||
// Constraints that do not take an axis still refuse x / y, and leave no axis line behind.
|
||||
for cmd in ["sketch.constraint.equal", "sketch.constraint.concentric"] {
|
||||
assert!(s.execute(cmd, &json!({"a": "b", "b": "x"})).is_err(), "{cmd}");
|
||||
}
|
||||
assert!(s.execute("sketch.constraint.midpoint", &json!({"point": "b.start", "line": "y"})).is_err());
|
||||
assert!(s.execute("sketch.constraint.horizontal_vertical", &json!({"line": "x"})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before, "failed commands change nothing");
|
||||
// A constraint the sketch refuses rolls the axis line back with it.
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "b", "mode": "horizontal"}));
|
||||
let before_redundant = s.doc.to_json();
|
||||
assert!(s.execute("sketch.constraint.parallel", &json!({"a": "b", "b": "x"})).is_err(), "already horizontal");
|
||||
assert_eq!(s.doc.to_json(), before_redundant);
|
||||
assert!(axis_lines(&sketch(&s)).is_empty());
|
||||
}
|
||||
@@ -75,19 +75,24 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them", created_doc!())),
|
||||
CommandSpec::new("sketch.centerline", "Centerline", centerline)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("centerline")
|
||||
.enabled(in_sketch)
|
||||
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
|
||||
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!(
|
||||
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg",
|
||||
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg, font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text)
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg", created_doc!())),
|
||||
.params(concat!(
|
||||
"link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg, font?: family or file path (empty: the built-in font)",
|
||||
created_doc!()
|
||||
)),
|
||||
];
|
||||
|
||||
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> Result<LinkSource> {
|
||||
let (t0, at0, h0, a0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0));
|
||||
fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option<String>)>) -> Result<LinkSource> {
|
||||
let (t0, at0, h0, a0, f0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0, None));
|
||||
let text = str_(p, "text").map(str::to_string).unwrap_or(t0);
|
||||
if text.trim().is_empty() {
|
||||
return Err(bad(cmd, "`text` must not be empty"));
|
||||
@@ -101,7 +106,15 @@ fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> R
|
||||
return Err(bad(cmd, "`height` must be positive"));
|
||||
}
|
||||
let angle = num(p, "angle").map(f64::to_radians).unwrap_or(a0);
|
||||
Ok(LinkSource::Text { text, at, height, angle })
|
||||
let font = match p.get("font") {
|
||||
None | Some(Value::Null) => f0,
|
||||
Some(Value::String(f)) if f.trim().is_empty() => None,
|
||||
Some(Value::String(f)) => Some(f.trim().to_string()),
|
||||
Some(_) => return Err(bad(cmd, "`font` must be a font family name or a font file path")),
|
||||
};
|
||||
// An unknown font or an unreadable file is an error now, not a lost link later.
|
||||
solidcraft_sketch::font_data(font.as_deref()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(LinkSource::Text { text, at, height, angle, font })
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -131,12 +144,12 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(cmd, "give `link` or `entity`")),
|
||||
};
|
||||
let old = match sk.link(&l).map(|x| (x.kind, x.source.clone())) {
|
||||
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle })) => (text, at, height, angle),
|
||||
Some((LinkKind::Text, LinkSource::Text { text, at, height, angle, font })) => (text, at, height, angle, font),
|
||||
_ => return Err(bad(cmd, format!("`{l}` is not text"))),
|
||||
};
|
||||
let src = text_source(p, cmd, Some(old))?;
|
||||
let LinkSource::Text { text, at, height, angle } = &src else { return Err(bad(cmd, "text")) };
|
||||
let geom = solidcraft_sketch::text_geometry(text, *at, *height, *angle)?;
|
||||
let LinkSource::Text { text, at, height, angle, font } = &src else { return Err(bad(cmd, "text")) };
|
||||
let geom = solidcraft_sketch::text_geometry_in(font.as_deref(), text, *at, *height, *angle)?;
|
||||
sk.set_link_source(&l, src.clone());
|
||||
sk.update_link(&l, &geom)?;
|
||||
let curves = ids_of(&sk, &sk.link_curves(&l));
|
||||
|
||||
@@ -79,6 +79,7 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
|
||||
by_component.entry(doc.model_body_component(body)).or_default().push(body);
|
||||
}
|
||||
let mut chosen = None;
|
||||
let mut sheet_face: Option<(String, Plane)> = None;
|
||||
let mut work = state.bodies.len().saturating_add(doc.occurrences.len());
|
||||
for (component, path, placement) in placements {
|
||||
let Some(bodies) = by_component.get(&component) else {
|
||||
@@ -104,6 +105,22 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
|
||||
let inverse = mat_inverse(&frame).ok_or_else(|| bad(cmd, "invalid selected face frame"))?;
|
||||
let local_point = apply_point(&inverse, point);
|
||||
let tolerance = (body.body.size() * 1e-4).max(1e-5);
|
||||
// A flat face of a sheet metal body attaches to its panel: the exact plane comes from
|
||||
// the panel's flat region and fold, and the sketch keeps the face's name to follow it.
|
||||
if !fixed
|
||||
&& !internal
|
||||
&& let Some(sheet) = state.sheets.iter().find(|sheet| sheet.body == body.name)
|
||||
&& let Some((face, plane)) = sheet.panel_face_at(local_point, tolerance * 10.0)
|
||||
{
|
||||
if chosen.is_some() || sheet_face.is_some() {
|
||||
return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index"));
|
||||
}
|
||||
let relative = mat_mul(&author_inverse, &frame);
|
||||
let plane = Plane::new(apply_point(&relative, plane.origin), apply_vector(&relative, plane.x), apply_vector(&relative, plane.y))
|
||||
.ok_or_else(|| bad(cmd, "invalid sheet face plane"))?;
|
||||
sheet_face = Some((solidcraft_doc::sheet::sheet_face_name(&body.name, &face), plane));
|
||||
continue;
|
||||
}
|
||||
let faces = body.body.faces(tolerance)?;
|
||||
let mesh = body.body.tessellate(tolerance)?;
|
||||
work = work.saturating_add(mesh.triangles.len().saturating_mul(if requested_index.is_some() { 1 } else { faces.len() }));
|
||||
@@ -138,6 +155,12 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((name, plane)) = sheet_face {
|
||||
if chosen.is_some() {
|
||||
return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index"));
|
||||
}
|
||||
return Ok(PlaneRef::Face { plane, at: apply_point(&author_inverse, point), name: Some(name), anchor: None });
|
||||
}
|
||||
let (body, index, path, plane) =
|
||||
chosen.ok_or_else(|| bad(cmd, "the selected planar face no longer exists; choose another face or an explicit fixed plane"))?;
|
||||
if fixed {
|
||||
@@ -160,6 +183,19 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
|
||||
}
|
||||
None
|
||||
}
|
||||
Err(solidcraft_doc::DocError::Invalid(message))
|
||||
if matches!(
|
||||
message.as_str(),
|
||||
"this face has no exact planar source; choose a fixed plane instead"
|
||||
| "this face has no supported construction certificate; choose a fixed plane instead"
|
||||
) =>
|
||||
{
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"this face can't keep an attachment: it was merged from more than one feature's faces or reshaped by an edit. \
|
||||
Pick another face, or use a fixed plane here (fixed:true), which won't follow later changes to the face",
|
||||
));
|
||||
}
|
||||
Err(error) => return Err(bad(cmd, format!("{error}; choose a supported exact face or explicitly use fixed:true"))),
|
||||
};
|
||||
Ok(PlaneRef::Face { plane, at, name, anchor })
|
||||
|
||||
@@ -75,8 +75,10 @@ fn explicit_rebind_preserves_geometry_dimensions_context_and_one_undo() {
|
||||
#[test]
|
||||
fn unsupported_face_has_explicit_fixed_alternative_and_no_live_projection() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[4,4,10],"operation":"join"}));
|
||||
// A top half Offset Face raised: no construction certificate and no exact history name yet.
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Base"}));
|
||||
run(&mut s, "solid.split_face", json!({"faces":[[10,15,20]],"plane":{"origin":[20,0,0],"normal":[1,0,0]}}));
|
||||
run(&mut s, "solid.offset_face", json!({"faces":[[10,15,20]],"distance":5}));
|
||||
let mut pick = top_pick(&s, "Base");
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("sketch.create", &json!({"plane":pick})).unwrap_err();
|
||||
|
||||
@@ -135,7 +135,8 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
if let Geom2::Text { text, at, height, angle } = t {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
if super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, LinkSource::Text { text, at, height, angle }).is_ok() {
|
||||
if super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, LinkSource::Text { text, at, height, angle, font: None }).is_ok()
|
||||
{
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
n_text += 1;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Sketches on construction planes built from the model (perpendicular to a plane through a
|
||||
//! sketch line, through three points, a midplane…): Project, Intersect, Include 3D Geometry and
|
||||
//! Text resolve the sketch's plane against the model before the sketch, as evaluation does, and
|
||||
//! the links follow when a parameter moves the plane.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sketch `A` on YZ with the line `s` from the origin to (run 100, rise `rise`), and the plane
|
||||
/// `P` through that line, square to YZ. The plane's origin is the line's start and its x axis
|
||||
/// runs along the line.
|
||||
fn sloped_plane(s: &mut Session) {
|
||||
run(s, "parameters.add", json!({"name": "rise", "expression": "60 mm"}));
|
||||
run(s, "sketch.create", json!({"plane": "YZ", "name": "A"}));
|
||||
run(s, "sketch.line", json!({"points": [[0, 0], [100, 60]], "ids": ["s"]}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["s.start", "s.end"], "type": "horizontal", "value": "100 mm"}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["s.start", "s.end"], "type": "vertical", "value": "rise"}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "construct.plane.perpendicular", json!({"plane": "YZ", "line": {"sketch": "A", "curve": "s"}, "name": "P"}));
|
||||
}
|
||||
|
||||
/// World position of point `point` of sketch `sketch`.
|
||||
fn world_point(s: &Session, sketch: &str, point: &str) -> Vec3 {
|
||||
let id = s.doc.find_feature(sketch).unwrap().id;
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(id).unwrap();
|
||||
let i = ss.sketch.resolve_point(point).unwrap_or_else(|| panic!("no point {point} in {sketch}"));
|
||||
ss.plane.to_world(ss.sketch.point(i).unwrap())
|
||||
}
|
||||
|
||||
fn no_trouble(s: &Session) {
|
||||
for r in &s.model.results {
|
||||
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_onto_a_plane_built_from_a_sketch_line_follows_the_parameter() {
|
||||
let mut s = Session::default();
|
||||
sloped_plane(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": "P", "name": "B"}));
|
||||
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "A", "point": "s.end"}, {"sketch": "A", "curve": "s"}]}));
|
||||
let point = r["links"][0]["points"][0].as_str().unwrap().to_string();
|
||||
let curve = r["links"][1]["curves"][0].as_str().unwrap().to_string();
|
||||
// Geometry drawn against the projection keeps working: a circle on the projected point.
|
||||
run(&mut s, "sketch.circle.center", json!({"center": point, "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
no_trouble(&s);
|
||||
let len = (100.0f64 * 100.0 + 60.0 * 60.0).sqrt();
|
||||
let end = world_point(&s, "A", "s.end");
|
||||
assert!(world_point(&s, "B", &point).dist(end) < 1e-9, "{:?} {end:?}", world_point(&s, "B", &point));
|
||||
let ends = [world_point(&s, "B", &format!("{curve}.start")), world_point(&s, "B", &format!("{curve}.end"))];
|
||||
assert!(ends.iter().any(|q| q.dist(Vec3::ZERO) < 1e-9) && ends.iter().any(|q| q.dist(end) < 1e-9), "{ends:?}");
|
||||
{
|
||||
let st = s.model.state();
|
||||
let b = st.sketch(s.doc.find_feature("B").unwrap().id).unwrap();
|
||||
let q = b.sketch.point(b.sketch.resolve_point(&point).unwrap()).unwrap();
|
||||
assert!((q.x - len).abs() < 1e-9 && q.y.abs() < 1e-9, "the line's end lies on the plane's x axis: {q:?}");
|
||||
}
|
||||
// The parameter tilts the plane; the projection and the circle on it follow, in the file too.
|
||||
run(&mut s, "parameters.change", json!({"name": "rise", "expression": "25 mm"}));
|
||||
let check = |s: &Session| {
|
||||
no_trouble(s);
|
||||
let end = world_point(s, "A", "s.end");
|
||||
assert!((end.z - 25.0).abs() < 1e-9 && (end.y - 100.0).abs() < 1e-9, "{end:?}");
|
||||
assert!(world_point(s, "B", &point).dist(end) < 1e-9);
|
||||
assert!(world_point(s, "B", "c1.center").dist(end) < 1e-9);
|
||||
};
|
||||
check(&s);
|
||||
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
|
||||
// Editing the sketch again after the change refreshes its links on the moved plane.
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "B"}));
|
||||
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
check(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_a_plane_built_from_a_sketch_line() {
|
||||
let mut s = Session::default();
|
||||
sloped_plane(&mut s);
|
||||
run(&mut s, "sketch.create", json!({"plane": "P", "name": "B"}));
|
||||
let r = run(&mut s, "sketch.text", json!({"text": "AB", "at": [10, 10], "height": 5}));
|
||||
assert!(!r["curves"].as_array().unwrap().is_empty(), "{r}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
no_trouble(&s);
|
||||
let st = s.model.state();
|
||||
let b = st.sketch(s.doc.find_feature("B").unwrap().id).unwrap();
|
||||
assert!(!b.profiles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_and_include_on_planes_built_from_the_model() {
|
||||
let mut s = Session::default();
|
||||
sloped_plane(&mut s);
|
||||
run(&mut s, "solid.box", json!({"corner": [-10, 20, -5], "length": 20, "width": 30, "height": 80, "body_name": "Post"}));
|
||||
// The plane through three sketch points and the midplane of two faces are built from the model too.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "T"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 30]], "ids": ["u", "v"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"construct.plane.three_points",
|
||||
json!({"a": {"sketch": "T", "point": "u.start"}, "b": {"sketch": "T", "point": "u.end"}, "c": {"sketch": "T", "point": "v.end"}, "name": "Three"}),
|
||||
);
|
||||
run(&mut s, "construct.plane.midplane", json!({"a": {"face": [0, 20, 30]}, "b": {"face": [0, 50, 30]}, "name": "Mid"}));
|
||||
|
||||
run(&mut s, "sketch.create", json!({"plane": "P", "name": "B"}));
|
||||
// The slope cuts the post: a closed section of four lines.
|
||||
let r = run(&mut s, "sketch.intersect", json!({"refs": [{"body": "Post"}]}));
|
||||
assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}");
|
||||
// An edge of the post, brought onto the slope.
|
||||
let r = run(&mut s, "sketch.include_3d", json!({"refs": [{"edge": [-10, 20, 30], "body": "Post"}]}));
|
||||
assert!(r["links"][0]["link"].is_string(), "{r}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
|
||||
run(&mut s, "sketch.create", json!({"plane": "Three", "name": "C"}));
|
||||
let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "T", "point": "v.end"}]}));
|
||||
let p = r["links"][0]["points"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(world_point(&s, "C", &p).dist(Vec3::new(40.0, 30.0, 0.0)) < 1e-9);
|
||||
|
||||
run(&mut s, "sketch.create", json!({"plane": "Mid", "name": "D"}));
|
||||
let r = run(&mut s, "sketch.intersect", json!({"refs": [{"body": "Post"}]}));
|
||||
assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
no_trouble(&s);
|
||||
// The midplane sits half way between the post's two faces (y = 35).
|
||||
let st = s.model.state();
|
||||
let d = st.sketch(s.doc.find_feature("D").unwrap().id).unwrap();
|
||||
assert!(d.sketch.points.iter().any(|q| q.link.is_some()));
|
||||
for q in d.sketch.points.iter().filter(|q| q.link.is_some()) {
|
||||
assert!((d.plane.to_world(q.pos).y - 35.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -429,6 +429,113 @@ fn text_makes_profiles_that_extrude_and_edit() {
|
||||
assert!(s.execute("sketch.text", &json!({"text": "", "at": [0, 0]})).is_err());
|
||||
}
|
||||
|
||||
/// Volumes (mm³) of the bodies in the design, smallest first.
|
||||
fn body_volumes(s: &mut Session) -> Vec<f64> {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
let mut v: Vec<f64> = m["bodies"].as_array().unwrap().iter().map(|b| b["volume_mm3"].as_f64().unwrap()).collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_extrudes_with_its_counters_and_beside_other_curves() {
|
||||
// Letter areas in mm² at a capital height of 10 mm, summed from the Liberation Sans contours (shoelace
|
||||
// formula, quadratic pieces flattened finely), each outer contour minus its counters:
|
||||
// A 39.2159 - 9.0647 B 68.9283 - 12.9336 - 16.6277 8 56.8394 - 10.6451 - 13.7347 I 13.5557
|
||||
// Extruded 2 mm, so the volumes are twice that. Faceted walls: within 0.02 mm³.
|
||||
let near = |got: &[f64], want: &[f64]| {
|
||||
assert_eq!(got.len(), want.len(), "{got:?} against {want:?}");
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.02), "{got:?} against {want:?}");
|
||||
};
|
||||
// Letters with counters, alone: a letter is one body with its counters as holes. (A counter used to be
|
||||
// extruded as a body of its own, or the extrude failed with "profile has no area".)
|
||||
for (text, want) in
|
||||
[("AB", vec![2.0 * (39.2159 - 9.0647), 2.0 * (68.9283 - 12.9336 - 16.6277)]), ("8", vec![2.0 * (56.8394 - 10.6451 - 13.7347)])]
|
||||
{
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.text", json!({"text": text, "at": [0, 0], "height": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 2}));
|
||||
near(&body_volumes(&mut s), &want);
|
||||
}
|
||||
// Text beside a drawn rectangle is extruded with it (it used to be left out): the 30 x 20 x 2 plate and an "I".
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}));
|
||||
run(&mut s, "sketch.text", json!({"text": "I", "at": [40, 0], "height": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 2}));
|
||||
near(&body_volumes(&mut s), &[2.0 * 13.5557, 1200.0]);
|
||||
// A counter can still be picked on its own, by a point inside it: the inner contour of an O, 47.4584 mm².
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.text", json!({"text": "O", "at": [0, 0], "height": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 2, "profiles": [{"point": [5.65, 5.0]}]}));
|
||||
near(&body_volumes(&mut s), &[2.0 * 47.4584]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_embossed_or_debossed_leaves_its_counters_alone() {
|
||||
// A 40 x 30 x 5 plate (6000 mm³) with 10 mm tall text raised from, or cut 1 mm into, its top. Only the letters
|
||||
// change the volume, by their area in mm² (see above) x 1 mm; the counters of the O and of A and B stay as
|
||||
// they are. (Emboss and deboss used to fail with "boolean Cut: no result" or "boolean Union: no result",
|
||||
// because each counter was a tool body of its own, filling the hole of its letter.)
|
||||
let (o, ab) = (83.3133 - 47.4584, (39.2159 - 9.0647) + (68.9283 - 12.9336 - 16.6277));
|
||||
for (text, mode, change) in [("O", "deboss", -o), ("O", "emboss", o), ("AB", "deboss", -ab)] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "body_name": "Plate"}));
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Lid"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "Lid", "name": "Text"}));
|
||||
run(&mut s, "sketch.text", json!({"text": text, "at": [10, 10], "height": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch": "Text", "depth": 1, "mode": mode}));
|
||||
let v = body_volumes(&mut s);
|
||||
assert!(v.len() == 1 && (v[0] - (6000.0 + change)).abs() < 0.02, "{text} {mode}: {v:?} against {}", 6000.0 + change);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_takes_a_font_and_edit_text_keeps_or_changes_it() {
|
||||
// The repository's second font is BIZ UDPGothic. Its "I" is a polygon of two 606 x 145 bars and a 188 x 1369
|
||||
// stem in font units, with a capital height of 1567 units, so at 10 mm its area is
|
||||
// (2 x 606 x 145 + 188 x 1369) x (10 / 1567)^2 = 17.6385 mm². The built-in Liberation Sans "I" is 13.5557 mm².
|
||||
let font = concat!(env!("CARGO_MANIFEST_DIR"), "/../../apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf");
|
||||
let near = |s: &Session, want: f64| {
|
||||
let a = profiles(s);
|
||||
assert!(a.len() == 1 && (a[0] - want).abs() < 1e-3, "{a:?} against {want}");
|
||||
};
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10}));
|
||||
near(&s, 13.5557);
|
||||
let link = r["link"].as_str().unwrap().to_string();
|
||||
// A font by file path changes the outlines; the same edit with the built-in family's name changes them back.
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
|
||||
near(&s, 17.6385);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": "Liberation Sans"}));
|
||||
near(&s, 13.5557);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
|
||||
// An edit that does not name a font keeps it (the height scales the area by 4), and an empty name is the built-in font.
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "height": 20}));
|
||||
near(&s, 4.0 * 17.6385);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": ""}));
|
||||
near(&s, 4.0 * 13.5557);
|
||||
run(&mut s, "sketch.edit_text", json!({"link": link, "font": font}));
|
||||
// The font is kept in the design: after saving and reopening, the text is still in it.
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let source = reopened.doc.sketch(s.active_sketch.unwrap()).unwrap().link(&link).unwrap().source.clone();
|
||||
assert!(matches!(&source, solidcraft_sketch::LinkSource::Text { font: Some(f), .. } if f == font), "{source:?}");
|
||||
// A new text takes the font too. Unknown fonts and values that are not names are errors.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.text", json!({"text": "I", "at": [0, 0], "height": 10, "font": font}));
|
||||
near(&s, 17.6385);
|
||||
let e = s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": "No Such Family 12345"})).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown font `No Such Family 12345`"), "{e}");
|
||||
assert!(s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": 12})).is_err());
|
||||
assert!(s.execute("sketch.text", &json!({"text": "I", "at": [0, 0], "font": "C:/no/such/dir/font.ttf"})).is_err());
|
||||
near(&s, 17.6385);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_curve_joins_tangentially() {
|
||||
let mut s = new_sketch();
|
||||
|
||||
@@ -118,15 +118,36 @@ fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
|
||||
Err(bad(cmd, format!("cannot project {v}")))
|
||||
}
|
||||
|
||||
/// The plane of a sketch feature.
|
||||
fn sketch_plane(doc: &Document, id: u64) -> Result<Plane> {
|
||||
/// The plane of a sketch feature, resolved against `st`: the model before the sketch as its
|
||||
/// component sees it, which is what evaluation resolves it against. A construction plane built
|
||||
/// from the model (through a sketch line, three points, between two faces…) exists only there.
|
||||
fn sketch_plane(doc: &Document, id: u64, st: &solidcraft_doc::ModelState) -> Result<Plane> {
|
||||
let (vals, _) = doc.param_values();
|
||||
match doc.feature(id).map(|f| &f.kind) {
|
||||
Some(FeatureKind::Sketch { plane, .. }) => Ok(doc.resolve_plane(&vals, plane, 0)?),
|
||||
Some(FeatureKind::Sketch { plane, .. }) => Ok(doc.resolve_plane_in(&vals, plane, 0, Some(st))?),
|
||||
_ => Err(EngineError::Other(format!("feature {id} is not a sketch"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The model before sketch `id` in the frame its links and its plane are resolved in: the
|
||||
/// attached face's frame for a sketch on a face, else its component's.
|
||||
fn state_for_sketch<'a>(
|
||||
s: &Session,
|
||||
doc: &Document,
|
||||
id: u64,
|
||||
st: &'a solidcraft_doc::ModelState,
|
||||
) -> std::result::Result<std::borrow::Cow<'a, solidcraft_doc::ModelState>, solidcraft_doc::DocError> {
|
||||
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
});
|
||||
match attachment {
|
||||
Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(doc, st, anchor, comp),
|
||||
None => Ok(solidcraft_doc::state_in_frame_at(doc, st, comp, owner_frame(s, doc, comp))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep candidate feature edits while overlaying uncommitted occurrence placements.
|
||||
fn placed_candidate<'a>(s: &Session, doc: &'a Document) -> std::borrow::Cow<'a, Document> {
|
||||
if s.pending_moves.is_empty() {
|
||||
@@ -145,51 +166,54 @@ fn owner_frame(s: &Session, doc: &Document, comp: u64) -> solidcraft_doc::Mat {
|
||||
if comp == s.active_component { s.active_component_frame() } else { doc.component_transform(comp) }
|
||||
}
|
||||
|
||||
/// Re-resolve the links of a sketch being edited against the model before it, so edits see
|
||||
/// current reference geometry. Returns warnings.
|
||||
/// Re-resolve the links of a sketch being edited (`sk`, a copy of the stored sketch `id`)
|
||||
/// against the model before it, so edits see current reference geometry. Returns warnings.
|
||||
///
|
||||
/// When a link moved, the rest of the sketch follows it as the evaluation solves it, keeping
|
||||
/// the stored sides and turns, so the edit goes on from the sketch the model shows. Left where
|
||||
/// it was stored beside a line that has moved past it, a dimensioned point would give the
|
||||
/// edit's solve the other side to keep. A sketch that does not solve that way keeps its stored
|
||||
/// geometry, with the links moved.
|
||||
pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> Vec<String> {
|
||||
if sk.links.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let doc = placed_candidate(s, doc);
|
||||
let Ok(plane) = sketch_plane(&doc, id) else { return Vec::new() };
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
});
|
||||
let st = match attachment {
|
||||
Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp),
|
||||
None => Ok(solidcraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))),
|
||||
};
|
||||
let Ok(st) = st else {
|
||||
let Ok(st) = state_for_sketch(s, &doc, id, &st) else {
|
||||
return vec!["lost face attachment; reattach explicitly".into()];
|
||||
};
|
||||
match attachment {
|
||||
let Ok(plane) = sketch_plane(&doc, id, &st) else { return Vec::new() };
|
||||
let warnings = match attachment {
|
||||
Some(anchor) => solidcraft_doc::project::refresh_face_anchor_links(&doc, &vals, &st, &plane, sk, anchor),
|
||||
None => solidcraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk),
|
||||
};
|
||||
if let Ok(stored) = doc.sketch(id)
|
||||
&& sk.positions_in(stored).is_some()
|
||||
{
|
||||
let mut solved = sk.clone();
|
||||
let follows = solved.refresh_offsets().is_ok()
|
||||
&& doc.solve_sketch(&vals, &mut solved, &stored.driving_values(), Some(stored)).is_ok_and(|report| report.keeps_orientation());
|
||||
if follows {
|
||||
*sk = solved;
|
||||
}
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
/// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id.
|
||||
pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, kind: LinkKind, src: LinkSource) -> Result<String> {
|
||||
let doc = placed_candidate(s, doc);
|
||||
let plane = sketch_plane(&doc, id)?;
|
||||
let (vals, _) = doc.param_values();
|
||||
let st = s.model.state_before(id);
|
||||
// The model as the sketch's component sees it.
|
||||
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||||
let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
});
|
||||
let st = match attachment {
|
||||
Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp),
|
||||
None => Ok(solidcraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))),
|
||||
};
|
||||
let st = st?;
|
||||
let st = state_for_sketch(s, &doc, id, &st)?;
|
||||
let plane = sketch_plane(&doc, id, &st)?;
|
||||
let r = resolve(&doc, &vals, &st, &plane, kind, &src)?;
|
||||
let name = if let LinkSource::Face { body, at } = &r.source {
|
||||
st.body(body).and_then(|body| {
|
||||
@@ -454,7 +478,8 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
|
||||
sk.points.get(pi).map(|q| q.id.clone()).unwrap_or_default()
|
||||
} else if let Some(at) = edge {
|
||||
let l = add_link(s, d, id, sk, LinkKind::Project, LinkSource::Edge { body, at }).map_err(|e| bad(cmd, format!("edge: {e}")))?;
|
||||
let plane = sketch_plane(d, id)?;
|
||||
let before = s.model.state_before(id);
|
||||
let plane = sketch_plane(d, id, &*state_for_sketch(s, d, id, &before)?)?;
|
||||
let q = plane.to_local(at);
|
||||
let ci = sk.link_curves(&l).first().copied().ok_or_else(|| bad(cmd, "the edge projects to a point"))?;
|
||||
let pi = sk.add_point(q, None)?;
|
||||
@@ -490,10 +515,10 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
|
||||
/// edits, but cannot provide the construction certificate required by public attachments.
|
||||
pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result<String> {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let plane = sketch_plane(&doc, id)?;
|
||||
let raw = s.model.state_before(id);
|
||||
let component = doc.feature(id).map(|feature| feature.component).unwrap_or(0);
|
||||
let state = solidcraft_doc::state_in_frame_at(&doc, &raw, component, owner_frame(s, &doc, component));
|
||||
let plane = sketch_plane(&doc, id, &state)?;
|
||||
let resolved = solidcraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?;
|
||||
let face_name = state.body(body).and_then(|body| solidcraft_doc::naming::face_names(body).get(index).cloned());
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
@@ -538,3 +563,11 @@ pub(super) fn auto_project_face(s: &mut Session, id: u64, _at: Vec3) -> Result<O
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_project_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_project_side_tests.rs"]
|
||||
mod side_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_model_plane_tests.rs"]
|
||||
mod model_plane_tests;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! A dimension from linked geometry keeps its side when the link's source moves: a point held
|
||||
//! 5 from a projected line stays on the side it was drawn on wherever a parameter takes the
|
||||
//! line, also past the place the point is stored at.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sketch `A`: the horizontal line `rail`, 100 long, at the height the parameter `height`
|
||||
/// (50) gives it.
|
||||
fn rail_sketch(s: &mut Session) {
|
||||
run(s, "parameters.add", json!({"name": "height", "expression": "50 mm"}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "A"}));
|
||||
run(s, "sketch.line", json!({"points": [[0, 50], [100, 50]], "ids": ["rail"]}));
|
||||
run(s, "sketch.constraint.horizontal_vertical", json!({"line": "rail", "mode": "horizontal"}));
|
||||
run(s, "sketch.constraint.horizontal_vertical", json!({"points": ["origin", "rail.start"], "mode": "vertical"}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["origin", "rail.start"], "value": "height"}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["rail"], "value": "100 mm"}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
/// Sketch `S` (left open): `rail` brought in by `command`, and the point `p` at (50, `y`),
|
||||
/// 50 from the origin horizontally. Returns the linked line.
|
||||
fn point_by_the_rail(s: &mut Session, command: &str, y: f64) -> String {
|
||||
rail_sketch(s);
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
let r = run(s, command, json!({"refs": [{"sketch": "A", "curve": "rail"}]}));
|
||||
let rail = r["links"][0]["curves"][0].as_str().unwrap().to_string();
|
||||
run(s, "sketch.point", json!({"point": [50, y], "id": "p"}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["origin", "p"], "type": "horizontal", "value": "50 mm"}));
|
||||
rail
|
||||
}
|
||||
|
||||
/// `p` held 5 from `line`: the dimension's parameter.
|
||||
fn five_from(s: &mut Session, line: &str) -> String {
|
||||
let r = run(s, "sketch.dimension", json!({"entities": ["p", line], "type": "distance", "value": "5 mm"}));
|
||||
r["param"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn set_height(s: &mut Session, height: f64) {
|
||||
run(s, "parameters.change", json!({"name": "height", "expression": format!("{height} mm")}));
|
||||
}
|
||||
|
||||
/// Where the model has `p` of sketch `S`.
|
||||
fn p(s: &Session) -> Vec2 {
|
||||
let st = s.model.state();
|
||||
let sk = &st.sketch(s.doc.find_feature("S").unwrap().id).unwrap().sketch;
|
||||
sk.point(sk.resolve_point("p").unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// `p` is at (50, `y`), and the sketch has nothing to report.
|
||||
#[track_caller]
|
||||
fn assert_p(s: &Session, y: f64, when: &str) {
|
||||
assert!(p(s).dist(Vec2::new(50.0, y)) < 1e-9, "{when}: {:?}", p(s));
|
||||
let feature = s.model.result(s.doc.find_feature("S").unwrap().id).unwrap();
|
||||
assert_eq!((&feature.error, &feature.warning), (&None, &None), "{when}");
|
||||
}
|
||||
|
||||
/// A line of sketch `A` whose height a parameter drives, projected into sketch `S`, whose point
|
||||
/// is dimensioned 5 from the projection.
|
||||
#[test]
|
||||
fn a_distance_from_a_projected_line_keeps_its_side_when_the_source_moves_past() {
|
||||
let mut s = Session::default();
|
||||
let rail = point_by_the_rail(&mut s, "sketch.project", 45.0);
|
||||
five_from(&mut s, &rail);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_p(&s, 45.0, "drawn");
|
||||
// The source drops below the point's stored place: the point stays below the projection.
|
||||
for height in [20.0, 50.0, 6.0, 400.0] {
|
||||
set_height(&mut s, height);
|
||||
assert_p(&s, height - 5.0, &format!("height {height}"));
|
||||
}
|
||||
// Editing the sketch afterwards refreshes the projection first; the side is the one from before.
|
||||
set_height(&mut s, 20.0);
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
|
||||
run(&mut s, "sketch.point", json!({"point": [80, 80]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_p(&s, 15.0, "after an edit");
|
||||
}
|
||||
|
||||
/// The same from the other side: the point above the line, the source rising past it and
|
||||
/// coming back; and with the line brought in by Include 3D Geometry.
|
||||
#[test]
|
||||
fn a_point_above_the_line_stays_above_when_the_source_rises_past_and_returns() {
|
||||
for command in ["sketch.project", "sketch.include_3d"] {
|
||||
let mut s = Session::default();
|
||||
let rail = point_by_the_rail(&mut s, command, 55.0);
|
||||
five_from(&mut s, &rail);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
for height in [80.0, 400.0, 50.0, 6.0, 50.0] {
|
||||
set_height(&mut s, height);
|
||||
assert_p(&s, height + 5.0, &format!("{command}, height {height}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Model geometry in a sketch on XZ (whose y runs down the model's Z): the top of a box whose
|
||||
/// height a parameter drives, as a projected edge, as an edge of a projected face and as a
|
||||
/// section of the body. The point is 5 below the top, inside the box.
|
||||
#[test]
|
||||
fn a_distance_from_model_geometry_keeps_its_side_when_the_model_moves_past() {
|
||||
// A projected edge is found again by the point picked on it, so it follows small steps
|
||||
// only; a face (by its name) and a body follow any.
|
||||
let small: &[f64] = &[42.0, 50.0, 58.0, 50.0, 44.0];
|
||||
let large: &[f64] = &[20.0, 50.0, 6.0, 400.0];
|
||||
let cases = [
|
||||
("sketch.project", json!({"edge": [50, 0, 50]}), 0.0, small),
|
||||
("sketch.project", json!({"face": [50, 0, 25]}), 0.0, large),
|
||||
("sketch.intersect", json!({"body": "Body1"}), -20.0, large),
|
||||
];
|
||||
for (command, link, front, heights) in cases {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "height", "expression": "50 mm"}));
|
||||
run(&mut s, "solid.box", json!({"corner": [0, front, 0], "length": 100, "width": 40, "height": "height"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "S"}));
|
||||
run(&mut s, command, json!({"refs": [link]}));
|
||||
let curves = run(&mut s, "sketch.inspect", json!({}))["curves"].clone();
|
||||
let high = |c: &Value, end: &str| (c[end][1].as_f64().unwrap() + 50.0).abs() < 1e-6;
|
||||
let top = curves.as_array().unwrap().iter().find(|c| high(c, "start_at") && high(c, "end_at")).unwrap()["id"].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.point", json!({"point": [50, -45], "id": "p"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p"], "type": "horizontal", "value": "50 mm"}));
|
||||
five_from(&mut s, &top);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// (An edge found again by its picked point says so in a warning.)
|
||||
for height in heights {
|
||||
set_height(&mut s, *height);
|
||||
assert!(p(&s).dist(Vec2::new(50.0, 5.0 - height)) < 1e-9, "{command} {link}, height {height}: {:?}", p(&s));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Another projection added after the source moved stores the sketch without solving it: the
|
||||
/// sketch it stores is the one the model shows.
|
||||
#[test]
|
||||
fn a_projection_added_after_the_source_moved_leaves_the_side() {
|
||||
let mut s = Session::default();
|
||||
let rail = point_by_the_rail(&mut s, "sketch.project", 45.0);
|
||||
five_from(&mut s, &rail);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
set_height(&mut s, 20.0);
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
|
||||
run(&mut s, "sketch.project", json!({"refs": ["origin"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_p(&s, 15.0, "after the projection");
|
||||
set_height(&mut s, 50.0);
|
||||
assert_p(&s, 45.0, "back at 50");
|
||||
}
|
||||
|
||||
/// The side is still the user's to choose, by where the point is when the dimension is added:
|
||||
/// also when the source has moved past the point since it was drawn. That side is kept from
|
||||
/// then on, and through an edit of the dimension's value.
|
||||
#[test]
|
||||
fn a_dimension_added_after_the_source_moved_takes_the_side_its_point_is_on() {
|
||||
let mut s = Session::default();
|
||||
let rail = point_by_the_rail(&mut s, "sketch.project", 45.0);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Drawn below the line; the line drops below it.
|
||||
set_height(&mut s, 20.0);
|
||||
assert_p(&s, 45.0, "no distance yet");
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "S"}));
|
||||
let distance = five_from(&mut s, &rail);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_p(&s, 25.0, "dimensioned above the line");
|
||||
for height in [50.0, 6.0, 400.0, 20.0] {
|
||||
set_height(&mut s, height);
|
||||
assert_p(&s, height + 5.0, &format!("height {height}"));
|
||||
}
|
||||
run(&mut s, "parameters.change", json!({"name": distance, "expression": "8 mm"}));
|
||||
assert_p(&s, 28.0, "8 from the line");
|
||||
set_height(&mut s, 6.0);
|
||||
assert_p(&s, 14.0, "8 from the line, height 6");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! `solid.move` with `copy: true`: the original body stays where it was, a new body is
|
||||
//! pushed at the transform, and an older design file (no `copy` key at all) still loads.
|
||||
use super::*;
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap()
|
||||
}
|
||||
|
||||
fn cube20(s: &mut Session, name: &str) {
|
||||
run(s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "body_name": name}));
|
||||
}
|
||||
|
||||
fn volume(body: &solidcraft_doc::ModelBody) -> f64 {
|
||||
solidcraft_kernel::measure(&body.body).unwrap().volume
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_keeps_the_original_and_adds_a_transformed_body() {
|
||||
let mut s = Session::default();
|
||||
cube20(&mut s, "Cube");
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Cube"], "translate": [30, 0, 0], "copy": true}));
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.bodies.len(), 2, "a copy move must add one body, not replace the original");
|
||||
|
||||
let original = state.body("Cube").unwrap();
|
||||
let original_bounds = original.mesh().bounds();
|
||||
assert!(original_bounds.min.dist(Vec3::ZERO) < 1e-6, "the original body must stay at its own position: {original_bounds:?}");
|
||||
assert!(original_bounds.max.dist(Vec3::new(20.0, 20.0, 20.0)) < 1e-6, "{original_bounds:?}");
|
||||
|
||||
let copy = state.bodies.iter().find(|b| b.name != "Cube").unwrap();
|
||||
let copy_bounds = copy.mesh().bounds();
|
||||
assert!(copy_bounds.min.dist(Vec3::new(30.0, 0.0, 0.0)) < 1e-6, "the copy must land at the translated position: {copy_bounds:?}");
|
||||
assert!(copy_bounds.max.dist(Vec3::new(50.0, 20.0, 20.0)) < 1e-6, "{copy_bounds:?}");
|
||||
|
||||
let original_volume = volume(original);
|
||||
let copy_volume = volume(copy);
|
||||
assert!((original_volume - 8000.0).abs() < 1e-3, "original volume: {original_volume}");
|
||||
assert!((copy_volume - 8000.0).abs() < 1e-3, "copy volume: {copy_volume}");
|
||||
assert!((original_volume - copy_volume).abs() < 1e-6, "the copy must have the same mass properties as the source");
|
||||
|
||||
let total: f64 = state.bodies.iter().map(volume).sum();
|
||||
assert!((total - 16000.0).abs() < 1e-3, "total volume across both bodies: {total}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_copy_the_move_still_replaces_the_body_in_place() {
|
||||
let mut s = Session::default();
|
||||
cube20(&mut s, "Cube");
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Cube"], "translate": [30, 0, 0]}));
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.bodies.len(), 1, "a plain move must not create a second body");
|
||||
let moved = state.body("Cube").unwrap();
|
||||
let bounds = moved.mesh().bounds();
|
||||
assert!(bounds.min.dist(Vec3::new(30.0, 0.0, 0.0)) < 1e-6, "{bounds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_older_design_with_no_copy_key_at_all_still_loads_and_moves_in_place() {
|
||||
let mut s = Session::default();
|
||||
cube20(&mut s, "Cube");
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Cube"], "translate": [5, 0, 0]}));
|
||||
let saved = s.doc.to_json();
|
||||
assert!(!saved.contains("\"copy\""), "a move without the flag must serialize with no `copy` key, as an older file would read: {saved}");
|
||||
|
||||
// Simulate a design saved before this feature existed: strip the key even if present, then
|
||||
// reopen through the same loader a real design file goes through.
|
||||
let mut old: Value = serde_json::from_str(&saved).unwrap();
|
||||
let features = old.get_mut("features").and_then(Value::as_array_mut).expect("features array");
|
||||
let mut touched = false;
|
||||
for f in features.iter_mut() {
|
||||
if f.get("type").and_then(Value::as_str) == Some("move")
|
||||
&& let Some(obj) = f.as_object_mut()
|
||||
{
|
||||
obj.remove("copy");
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
assert!(touched, "the move feature must be present in the saved design");
|
||||
|
||||
let migrated = solidcraft_doc::Document::from_json(&old.to_string()).unwrap();
|
||||
let reopened = Session::new(migrated);
|
||||
let state = reopened.model.state();
|
||||
assert_eq!(state.bodies.len(), 1, "a pre-copy design must still move the one body in place, never duplicate it");
|
||||
let moved = state.body("Cube").unwrap();
|
||||
assert!(moved.mesh().bounds().min.dist(Vec3::new(5.0, 0.0, 0.0)) < 1e-6);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Issue #489: a sketch or an offset plane on the top face of an extrude that was itself
|
||||
//! sketched on a face. Sketching a profile over the whole face draws curves on top of the face's
|
||||
//! projected boundary; the extrude's faces must still carry an exact, certified planar source.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::FaceAttachmentStatus;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command} {params}: {e}"))
|
||||
}
|
||||
|
||||
fn profile(s: &mut Session, circle: bool, full: bool) {
|
||||
if circle {
|
||||
run(s, "sketch.circle.center", json!({"center": [20, 20], "diameter": if full { 40 } else { 16 }}));
|
||||
} else if full {
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
|
||||
} else {
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [30, 30]}));
|
||||
}
|
||||
}
|
||||
|
||||
/// A 40 mm base (square or round) 10 mm tall, then a second extrude 10 mm up from a sketch on
|
||||
/// its top face (or on a plane at that height), joined or as a new body.
|
||||
fn two_extrudes(circle: bool, full: bool, on_face: bool, operation: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
profile(&mut s, circle, true);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "First"}));
|
||||
let plane = if on_face { json!({"face": [20, 20, 10]}) } else { json!("XY") };
|
||||
let mut params = json!({"plane": plane, "name": "Upper"});
|
||||
if !on_face {
|
||||
params["offset"] = json!(10);
|
||||
}
|
||||
run(&mut s, "sketch.create", params);
|
||||
profile(&mut s, circle, full);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": operation, "name": "Second"}));
|
||||
s
|
||||
}
|
||||
|
||||
fn healthy(s: &Session, sketch: &str) -> solidcraft_geom::Plane {
|
||||
let id = s.doc.find_feature(sketch).map(|f| f.id).unwrap();
|
||||
match s.placed_document().sketch_face_attachment(id, &s.model) {
|
||||
FaceAttachmentStatus::Healthy { plane, .. } => plane,
|
||||
other => panic!("{sketch}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn no_errors(s: &Session) {
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_and_offset_plane_on_the_top_face_of_a_face_sketched_extrude() {
|
||||
for operation in ["join", "new"] {
|
||||
for circle in [false, true] {
|
||||
for full in [true, false] {
|
||||
for on_face in [true, false] {
|
||||
let case = format!("{operation} circle={circle} full={full} on_face={on_face}");
|
||||
let mut s = two_extrudes(circle, full, on_face, operation);
|
||||
let top = [20., 20., 20.];
|
||||
s.execute("sketch.create", &json!({"plane": {"face": top}, "name": "Top"})).unwrap_or_else(|e| panic!("{case}: {e}"));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let plane = healthy(&s, "Top");
|
||||
assert!((plane.origin.z - 20.).abs() < 1e-9 && (plane.normal().z - 1.).abs() < 1e-9, "{case}: {plane:?}");
|
||||
s.execute("construct.plane.offset", &json!({"base": {"face": top}, "offset": 5, "name": "Above"}))
|
||||
.unwrap_or_else(|e| panic!("{case}: {e}"));
|
||||
// The second extrude's flat side attaches too, also where a full-face Join
|
||||
// merged it with the first extrude's side (#491): that face is known by its
|
||||
// exact history name.
|
||||
if !circle {
|
||||
let side = if full { [40., 20., 15.] } else { [30., 20., 15.] };
|
||||
s.execute("sketch.create", &json!({"plane": {"face": side}, "name": "Side"})).unwrap_or_else(|e| panic!("{case} side: {e}"));
|
||||
assert!((healthy(&s, "Side").normal().x - 1.).abs() < 1e-9, "{case}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
}
|
||||
s.refresh();
|
||||
no_errors(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Three extrudes, each sketched over the whole top face of the one before; editing the first
|
||||
/// one's height moves every later sketch with its face.
|
||||
#[test]
|
||||
fn three_stacked_extrudes_follow_an_edit_of_the_first() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "E1"}));
|
||||
for (k, top) in [(2, 10.), (3, 20.)] {
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 20, top]}, "name": format!("S{k}")}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": "join", "name": format!("E{k}")}));
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 20, 30]}, "name": "S4"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [20, 20], "diameter": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
s.refresh();
|
||||
no_errors(&s);
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
for (name, z) in [("S2", 10.), ("S3", 20.), ("S4", 30.)] {
|
||||
assert!((healthy(&s, name).origin.z - z).abs() < 1e-9, "{name}");
|
||||
}
|
||||
|
||||
run(&mut s, "timeline.edit", json!({"feature": "E1", "set": {"extent": {"distance": "25"}}}));
|
||||
s.refresh();
|
||||
no_errors(&s);
|
||||
for (name, z) in [("S2", 25.), ("S3", 35.), ("S4", 45.)] {
|
||||
assert!((healthy(&s, name).origin.z - z).abs() < 1e-9, "{name} after edit");
|
||||
}
|
||||
let volume: f64 = s.model.state().bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum();
|
||||
assert!((volume - 40. * 40. * 45.).abs() < 1e-6, "{volume}");
|
||||
|
||||
// Saved and reopened, every attachment still resolves.
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
reopened.refresh();
|
||||
no_errors(&reopened);
|
||||
assert!((healthy(&reopened, "S4").origin.z - 45.).abs() < 1e-9);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("surface.patch", "Patch", patch)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("patch")
|
||||
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges) and body?; name?, body_name?"),
|
||||
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; name?, body_name?"),
|
||||
CommandSpec::new("surface.stitch", "Stitch", stitch)
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("stitch")
|
||||
@@ -129,7 +129,8 @@ mod tests {
|
||||
solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume
|
||||
}
|
||||
|
||||
/// A rectangle patched, trimmed by a plane and thickened; a box's faces stitched back.
|
||||
/// A rectangle patched, trimmed by a plane and thickened; a box's open top patched; a rim
|
||||
/// that is not planar patched.
|
||||
#[test]
|
||||
fn patch_trim_thicken_and_stitch() {
|
||||
let mut s = Session::default();
|
||||
@@ -148,6 +149,24 @@ mod tests {
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cup"}));
|
||||
run(&mut s, "surface.patch", json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup", "body_name": "Lid"}));
|
||||
assert!(s.world_state().body("Lid").unwrap().body.is_surface());
|
||||
// A rim that is not planar: a box with its top cut away by a sideways cylinder.
|
||||
run(&mut s, "solid.box", json!({"length": 28, "width": 28, "height": 20, "corner": [-14, -14, 0], "body_name": "Saddle"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.cylinder",
|
||||
json!({"radius": 40, "height": 120, "base": [0, -60, 50], "axis": "Y", "operation": "cut", "targets": ["Saddle"]}),
|
||||
);
|
||||
let z = |x: f64| 50.0 - (1600.0 - x * x).sqrt();
|
||||
let rim = json!([[14, 0, z(14.0)], [0, 14, z(0.0)], [-14, 0, z(-14.0)], [0, -14, z(0.0)]]);
|
||||
run(&mut s, "surface.patch", json!({"edges": rim, "body": "Saddle", "body_name": "Cover"}));
|
||||
let st = s.world_state();
|
||||
let cover = &st.body("Cover").unwrap().body;
|
||||
assert!(cover.is_surface() && cover.face_count() == 1);
|
||||
// The cover is a smooth surface on the rim, close to the cylinder that cut it (its area
|
||||
// within 1% of the cylinder's inside the rim).
|
||||
let area = solidcraft_kernel::measure(cover).unwrap().area;
|
||||
let want = 28.0 * 40.0 * 2.0 * (14.0f64 / 40.0).asin();
|
||||
assert!((area - want).abs() < want * 1e-2, "{area} vs {want}");
|
||||
assert!(s.execute("surface.thicken", &json!({"bodies": ["Nope"], "thickness": 1})).is_err());
|
||||
assert!(s.execute("surface.trim", &json!({"body": "Sheet"})).is_err());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! What the application draws of a large sheet with small bends and holes, and that what
|
||||
//! reads the same mesh (bounds, picks, inside tests) answers as before.
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, TAU};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::{Mesh, Vec3};
|
||||
use solidcraft_kernel::{DISPLAY_ANGLE, FaceSurface, face_surface};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
const HOLES: [[f64; 2]; 4] = [[30.0, 20.0], [1500.0, 20.0], [2970.0, 980.0], [30.0, 980.0]];
|
||||
|
||||
/// A 3000 x 1000 x 2 sheet with bend radius 1, four holes of diameter 4 and a 40 mm flange on
|
||||
/// each long side.
|
||||
fn large_sheet() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [3000, 1000]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Holes"}));
|
||||
for c in HOLES {
|
||||
run(&mut s, "sketch.circle.center", json!({"center": c, "diameter": 4}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Holes", "distance": 10, "through_all": true, "operation": "cut"}));
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[1500, 0, 2], [1500, 1000, 2]], "height": 40}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The widest angle between neighbouring lines of vertices around a cylinder face, and how
|
||||
/// many such steps the face has.
|
||||
fn steps_around(m: &Mesh, face: usize, origin: Vec3, axis: Vec3) -> (usize, f64) {
|
||||
let u = axis.any_perp();
|
||||
let v = axis.cross(u);
|
||||
let mut a: Vec<f64> = m
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, f)| **f as usize == face)
|
||||
.flat_map(|(t, _)| t.iter())
|
||||
.map(|k| m.positions[*k as usize] - origin)
|
||||
.map(|d| d.dot(v).atan2(d.dot(u)))
|
||||
.collect();
|
||||
a.sort_by(f64::total_cmp);
|
||||
a.dedup_by(|x, y| (*x - *y).abs() < 1e-7);
|
||||
// Leave out the widest gap: the part of the turn the face does not cover.
|
||||
let n = a.len();
|
||||
let mut gaps: Vec<f64> = (0..n).map(|i| (a[(i + 1) % n] - a[i]).rem_euclid(TAU)).collect();
|
||||
gaps.sort_by(f64::total_cmp);
|
||||
gaps.pop();
|
||||
(gaps.len(), gaps.last().copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_sheet_shows_its_small_bends_and_holes_round() {
|
||||
let s = large_sheet();
|
||||
let st = s.world_state();
|
||||
let b = &st.bodies[0];
|
||||
let m = b.mesh();
|
||||
assert!(b.body.size() * 1e-3 > 3.0, "the chord tolerance is more than any radius here");
|
||||
let (mut bends, mut walls) = (0, 0);
|
||||
for face in 0..b.body.face_count() {
|
||||
let Some(FaceSurface::Cylinder { origin, axis, radius }) = face_surface(&b.body, face) else { continue };
|
||||
let (steps, widest) = steps_around(&m, face, origin, axis);
|
||||
assert!(widest <= DISPLAY_ANGLE * 1.001, "radius {radius}: a facet spans {} degrees", widest.to_degrees());
|
||||
if (radius - 2.0).abs() < 1e-6 {
|
||||
// Half of a hole's wall.
|
||||
walls += 1;
|
||||
assert!(steps >= (TAU / 2.0 / DISPLAY_ANGLE).ceil() as usize, "{steps} facets around half a hole");
|
||||
} else {
|
||||
// A bend's inner (1 mm) or outer (3 mm) side: a quarter turn.
|
||||
bends += 1;
|
||||
assert!((radius - 1.0).abs() < 1e-6 || (radius - 3.0).abs() < 1e-6, "radius {radius}");
|
||||
assert!(steps >= (FRAC_PI_2 / DISPLAY_ANGLE).ceil() as usize, "radius {radius}: {steps} facets across the bend");
|
||||
}
|
||||
}
|
||||
assert_eq!((bends, walls), (4, 8));
|
||||
// The edges the viewport draws: every hole's rim is round, on both sides of the sheet.
|
||||
for [x, y] in HOLES {
|
||||
for z in [0.0, 2.0] {
|
||||
let on = |p: &Vec3| (p.z - z).abs() < 1e-6 && ((p.x - x).hypot(p.y - y) - 2.0).abs() < 1e-6;
|
||||
let rim: Vec<&Vec<Vec3>> = m.edges.iter().filter(|e| e.iter().all(on)).collect();
|
||||
let segments: usize = rim.iter().map(|e| e.len() - 1).sum();
|
||||
assert!(segments >= (TAU / DISPLAY_ANGLE).ceil() as usize, "{segments} segments around the rim at {x}, {y}, {z}");
|
||||
let widest = rim.iter().flat_map(|e| e.windows(2)).map(|w| 2.0 * (w[0].dist(w[1]) / 4.0).asin()).fold(0.0, f64::max);
|
||||
assert!(widest <= DISPLAY_ANGLE * 1.001, "a rim segment spans {} degrees", widest.to_degrees());
|
||||
}
|
||||
}
|
||||
// And the scene the renderers take draws those same polylines.
|
||||
let scene = crate::view::scene(&s, &crate::view::home_camera(&s));
|
||||
let drawn: usize =
|
||||
scene.lines.iter().filter(|l| l.points.iter().all(|p| ((p.x - 30.0).hypot(p.y - 20.0) - 2.0).abs() < 1e-6)).map(|l| l.points.len() - 1).sum();
|
||||
assert!(drawn >= 2 * (TAU / DISPLAY_ANGLE).ceil() as usize, "{drawn} rim segments in the scene");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_reads_the_display_mesh_of_a_large_sheet_answers_as_before() {
|
||||
let s = large_sheet();
|
||||
let st = s.world_state();
|
||||
let b = &st.bodies[0];
|
||||
let m = b.mesh();
|
||||
let coarse = b.body.display_mesh((b.body.size() * 1e-3).max(1e-3)).unwrap();
|
||||
// Bounds: the sheet's box is set by flat faces, so the rounder bends move nothing.
|
||||
let (now, before) = (m.bounds(), coarse.bounds());
|
||||
assert!(now.min.dist(before.min) < 1e-9 && now.max.dist(before.max) < 1e-9, "{now:?} {before:?}");
|
||||
let exact = solidcraft_kernel::measure(&b.body).unwrap().bbox;
|
||||
assert!(now.min.dist(exact.min) < 1e-6 && now.max.dist(exact.max) < 1e-6, "{now:?} {exact:?}");
|
||||
// The cost: the sheet takes 2212 triangles against the chord mesh's 92 when measured, a factor of
|
||||
// 24; what grows is the number of round faces and the facets across each, not the size of the body.
|
||||
assert!(
|
||||
m.triangles.len() <= 30 * coarse.triangles.len() && m.triangles.len() < 4000,
|
||||
"{} triangles, {} at the chord tolerance",
|
||||
m.triangles.len(),
|
||||
coarse.triangles.len()
|
||||
);
|
||||
// The same faces and edges, in the same order, between the same faces, with the same ends.
|
||||
assert_eq!(m.edge_faces, coarse.edge_faces);
|
||||
assert_eq!(m.tri_face.iter().max(), coarse.tri_face.iter().max());
|
||||
assert_eq!(m.edges.len(), coarse.edges.len());
|
||||
for (fine, rough) in m.edges.iter().zip(&coarse.edges) {
|
||||
assert!(fine[0].dist(rough[0]) < 1e-9 && fine[fine.len() - 1].dist(rough[rough.len() - 1]) < 1e-9);
|
||||
}
|
||||
// Picks: a point on a round face (on the true cylinder, which a flat strip across a bend
|
||||
// misses by up to 0.9 mm) is found on that face, as is one in the middle of a flat face.
|
||||
let mut round = 0;
|
||||
for face in 0..b.body.face_count() {
|
||||
let Some(FaceSurface::Cylinder { origin, axis, radius }) = face_surface(&b.body, face) else { continue };
|
||||
let corners: Vec<Vec3> =
|
||||
m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == face).flat_map(|(t, _)| t.map(|k| m.positions[k as usize])).collect();
|
||||
let middle = corners.iter().fold(Vec3::ZERO, |a, p| a + *p) / corners.len() as f64;
|
||||
// The middle of the face's corners, pushed out onto the cylinder.
|
||||
let along = origin + axis * (middle - origin).dot(axis);
|
||||
let p = along + (middle - along).normalized().unwrap() * radius;
|
||||
assert_eq!(solidcraft_doc::appearance::face_index_at(b, p), Some(face), "radius {radius} at {p:?}");
|
||||
round += 1;
|
||||
}
|
||||
assert_eq!(round, 12);
|
||||
let top = solidcraft_doc::appearance::face_index_at(b, Vec3::new(1500.0, 500.0, 2.0)).unwrap();
|
||||
assert!(matches!(face_surface(&b.body, top), Some(FaceSurface::Plane { normal, .. }) if normal.dist(Vec3::Z) < 1e-9));
|
||||
// Inside and outside: in the sheet, in a flange's wall, in a hole and above the sheet.
|
||||
assert!(m.contains(Vec3::new(1500.0, 500.0, 1.0)) && m.contains(Vec3::new(1500.0, now.min.y + 1.0, now.max.z - 10.0)));
|
||||
assert!(!m.contains(Vec3::new(30.0, 20.0, 1.0)) && !m.contains(Vec3::new(1500.0, 500.0, 3.0)));
|
||||
}
|
||||
@@ -252,3 +252,37 @@ fn root_sketch_on_a_moved_components_face() {
|
||||
let pts = sketch_world_points(&mut s, "Top");
|
||||
assert!(pts.iter().any(|p| near(*p, Vec3::new(-30.0, 40.0, 25.0))), "{pts:?}");
|
||||
}
|
||||
|
||||
/// (#558) With the root active, construction geometry, a hole and Press Pull picked on a moved
|
||||
/// component land on it where it is shown, and so do picks on a pasted instance.
|
||||
#[test]
|
||||
fn root_picks_on_a_moved_component_build_where_it_is_shown() {
|
||||
let mut s = moved_part();
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
// The block spans world x 50…80, y 0…20, z 0…10.
|
||||
run(&mut s, "construct.axis.edge", json!({"edge": {"edge": [65, 0, 0]}, "name": "FrontEdge"}));
|
||||
run(
|
||||
&mut s,
|
||||
"construct.plane.three_points",
|
||||
json!({"a": {"vertex": [50, 0, 10]}, "b": {"vertex": [80, 0, 10]}, "c": {"vertex": [50, 20, 10]}, "name": "Lid"}),
|
||||
);
|
||||
let lid = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Lid").unwrap().2;
|
||||
assert!((lid.origin.z - 10.0).abs() < 1e-6 && lid.normal().z.abs() > 1.0 - 1e-9, "{lid:?}");
|
||||
let st = s.model.state();
|
||||
let axis = st.construct.iter().find(|c| c.name == "FrontEdge").map(|c| c.geom.clone()).unwrap();
|
||||
let solidcraft_doc::construct::ConstructGeom::Axis { origin, dir } = axis else { panic!("{axis:?}") };
|
||||
assert!(origin.y.abs() < 1e-6 && origin.z.abs() < 1e-6 && dir.x.abs() > 1.0 - 1e-9, "{origin:?} {dir:?}");
|
||||
let v0 = volume(&mut s);
|
||||
run(&mut s, "solid.hole", json!({"position": [60, 10, 10], "diameter": 4, "depth": 5}));
|
||||
let v1 = volume(&mut s);
|
||||
assert!(v1 < v0 - 1.0, "the hole cut the moved block: {v0} {v1}");
|
||||
run(&mut s, "solid.press_pull", json!({"face": [80, 10, 5], "distance": 2}));
|
||||
assert!(volume(&mut s) > v1 + 1.0, "press pull grew the moved block");
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
|
||||
|
||||
// A pasted instance 40 mm further along y.
|
||||
run(&mut s, "occurrence.copy", json!({"component": "Part", "translate": [0, 40, 0]}));
|
||||
run(&mut s, "construct.plane.along_path", json!({"path": {"edge": [65, 40, 0]}, "t": 0.5, "name": "Across"}));
|
||||
let across = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Across").unwrap().2;
|
||||
assert!((across.origin.y - 40.0).abs() < 1e-6, "{across:?}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! A hole placed on a face follows that face along the hole's axis, never sideways: when the face
|
||||
//! moves the hole moves with it, and when the face no longer reaches the place the hole stays
|
||||
//! where it was put and says so instead of sliding to the face's nearest point.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn change(s: &mut Session, name: &str, expression: &str) {
|
||||
run(s, "parameters.change", json!({"name": name, "expression": expression}));
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
/// Whether the model holds material at (x, y, z).
|
||||
fn solid_at(s: &Session, x: f64, y: f64, z: f64) -> bool {
|
||||
s.model.state().bodies.iter().any(|b| b.body.contains(Vec3::new(x, y, z)))
|
||||
}
|
||||
|
||||
/// What the hole feature says (its error and its warning); empty when it is silent.
|
||||
fn said(s: &Session, feature: &str) -> String {
|
||||
let id = s.doc.find_feature(feature).unwrap().id;
|
||||
let r = s.model.result(id).unwrap();
|
||||
format!("{}{}", r.error.clone().unwrap_or_default(), r.warning.clone().unwrap_or_default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_the_face_no_longer_reaches_stays_where_it_was_placed_and_says_so() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "length", "expression": "100 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": "length", "width": 50, "height": 20, "name": "Plate"}));
|
||||
run(&mut s, "solid.hole", json!({"position": [80, 25, 20], "diameter": "6 mm", "name": "Hole"}));
|
||||
// The bore is at x = 80, 3 mm either way.
|
||||
let drilled_at_80 = |s: &Session| !solid_at(s, 80.0, 25.0, 10.0) && solid_at(s, 76.0, 25.0, 10.0) && solid_at(s, 84.0, 25.0, 10.0);
|
||||
assert!(drilled_at_80(&s));
|
||||
// Longer: the place is still on the top face, and the hole stays at x = 80.
|
||||
change(&mut s, "length", "150 mm");
|
||||
assert_eq!(said(&s, "Hole"), "");
|
||||
assert!(drilled_at_80(&s));
|
||||
// Shorter than the place: nothing is drilled at the new end, and the hole says why. A hole
|
||||
// moved to the face's nearest point would cut half a hole there (59 717 mm³ left).
|
||||
change(&mut s, "length", "60 mm");
|
||||
let left = volume(&mut s);
|
||||
assert!((left - 60_000.0).abs() < 1.0, "half a hole was cut at the plate's end: {left} mm³ of 60000");
|
||||
let msg = said(&s, "Hole");
|
||||
assert!(msg.contains("no longer on face") && msg.contains("[80, 25, 20]"), "{msg}");
|
||||
// Long again: the hole is back where it was put, with no message.
|
||||
change(&mut s, "length", "100 mm");
|
||||
assert_eq!(said(&s, "Hole"), "");
|
||||
assert!(drilled_at_80(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slanted_hole_stays_on_its_axis_when_its_face_moves() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "height", "expression": "20 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 60, "width": 30, "height": "height", "name": "Block"}));
|
||||
// Entering the top face at x = 20, running on in x as it goes down: 1 across for every 2 down.
|
||||
run(&mut s, "solid.hole", json!({"position": [20, 15, 20], "direction": [1, 0, -2], "diameter": "2 mm", "name": "Hole"}));
|
||||
// The axis is the line through the place along the direction, whatever the height of the block:
|
||||
// x = 20 + (20 - z) / 2. At mid-height the bore is empty on it and solid 3 mm to either side
|
||||
// (the bore is about 2.2 mm wide there).
|
||||
let on_axis = |s: &Session, h: f64| {
|
||||
let (x, z) = (20.0 + (20.0 - h / 2.0) / 2.0, h / 2.0);
|
||||
assert!(!solid_at(s, x, 15.0, z), "block height {h}: the bore should pass through x = {x} at z = {z}");
|
||||
assert!(solid_at(s, x - 3.0, 15.0, z) && solid_at(s, x + 3.0, 15.0, z), "block height {h}: material 3 mm beside the axis");
|
||||
// (The kernel may note a boolean retry for a slanted bore; the hole's place is on its face.)
|
||||
assert!(!said(s, "Hole").contains("no longer on face"), "block height {h}: {}", said(s, "Hole"));
|
||||
};
|
||||
on_axis(&s, 20.0);
|
||||
// Taller: the top face moves up. Moved to the face's nearest point the hole would slide
|
||||
// sideways onto another axis (5 mm further along x at mid-height); along its axis it enters
|
||||
// higher up the same line.
|
||||
change(&mut s, "height", "30 mm");
|
||||
on_axis(&s, 30.0);
|
||||
// Lower: the same line enters further along.
|
||||
change(&mut s, "height", "12 mm");
|
||||
on_axis(&s, 12.0);
|
||||
}
|
||||
+34
-20
@@ -27,6 +27,7 @@ use solidcraft_geom::Vec3;
|
||||
|
||||
pub use cmd::{CommandInfo, CommandSpec, auto_operation, command_specs, find_command};
|
||||
pub use solidcraft_doc as doc;
|
||||
pub use solidcraft_doc::Visibility;
|
||||
pub use solidcraft_geom as geom;
|
||||
pub use solidcraft_io as io;
|
||||
pub use solidcraft_kernel as kernel;
|
||||
@@ -217,8 +218,15 @@ pub struct Session {
|
||||
pub section: Option<(Vec3, Vec3)>,
|
||||
/// Surface analysis shading the model (zebra, draft, curvature map).
|
||||
pub analysis: Option<SurfaceAnalysis>,
|
||||
/// What is hidden or shown in the view (`browser.visibility`).
|
||||
/// What is hidden or shown in the view (`browser.visibility`): saved with the design, not an
|
||||
/// undo step. A design brings its saved visibility when it opens (`Session::new`).
|
||||
pub visibility: Visibility,
|
||||
/// The camera `view.apply` asked the view to use (view state, not saved). The desktop app
|
||||
/// moves its viewport there and takes it; headless renders keep using it until a standard
|
||||
/// view is chosen.
|
||||
pub camera: Option<render::Camera>,
|
||||
/// The visibility the design file holds (what `is_dirty` compares with).
|
||||
saved_visibility: Visibility,
|
||||
/// States to go back to (`edit.checkpoint`, `edit.restore_checkpoint`): how batches of
|
||||
/// commands are undone however long the undo history is.
|
||||
pub(crate) checkpoints: Vec<Checkpoint>,
|
||||
@@ -252,22 +260,6 @@ pub(crate) struct Checkpoint {
|
||||
pub redo: Vec<Snapshot>,
|
||||
}
|
||||
|
||||
/// Items hidden or shown one by one (view state, not part of the design and not an undo step).
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Visibility {
|
||||
/// Bodies by name.
|
||||
pub hidden_bodies: Vec<String>,
|
||||
/// Exact occurrence placements hidden with all descendants.
|
||||
pub hidden_occurrences: Vec<Vec<u64>>,
|
||||
/// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction geometry, by name.
|
||||
pub hidden_origin: Vec<String>,
|
||||
/// Sketches by feature id.
|
||||
pub hidden_sketches: Vec<u64>,
|
||||
/// Finished sketches shown although a feature uses them.
|
||||
pub shown_sketches: Vec<u64>,
|
||||
}
|
||||
|
||||
/// A way of shading the model's faces to judge their shape (view state, not part of the design).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -297,7 +289,9 @@ impl Default for Session {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(doc: Document) -> Self {
|
||||
pub fn new(mut doc: Document) -> Self {
|
||||
// The session keeps what is hidden (not an undo step); the design file carries it.
|
||||
let visibility = std::mem::take(&mut doc.visibility);
|
||||
let doc = Arc::new(doc);
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
@@ -324,18 +318,32 @@ impl Session {
|
||||
analysis: None,
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
visibility: Visibility::default(),
|
||||
saved_visibility: visibility.clone(),
|
||||
visibility,
|
||||
camera: None,
|
||||
#[cfg(test)]
|
||||
evaluations: 0,
|
||||
crash_journal: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The design has changes not in its file: in the document, or in what is hidden.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
!Arc::ptr_eq(&self.doc, &self.saved) && *self.doc != *self.saved
|
||||
(!Arc::ptr_eq(&self.doc, &self.saved) && *self.doc != *self.saved) || self.visibility != self.saved_visibility
|
||||
}
|
||||
pub fn mark_saved(&mut self) {
|
||||
self.saved = self.doc.clone();
|
||||
self.saved_visibility = self.visibility.clone();
|
||||
}
|
||||
|
||||
/// The design as a file holds it: the document with what is hidden in it.
|
||||
pub fn design(&self) -> std::borrow::Cow<'_, Document> {
|
||||
if self.visibility.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(&*self.doc);
|
||||
}
|
||||
let mut doc = (*self.doc).clone();
|
||||
doc.visibility = self.visibility.clone();
|
||||
std::borrow::Cow::Owned(doc)
|
||||
}
|
||||
/// The design has changes not in its file (a recovered design).
|
||||
pub fn mark_unsaved(&mut self) {
|
||||
@@ -667,6 +675,8 @@ impl Session {
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
visibility: self.visibility.clone(),
|
||||
camera: self.camera,
|
||||
saved_visibility: self.saved_visibility.clone(),
|
||||
#[cfg(test)]
|
||||
evaluations: 0,
|
||||
crash_journal: false,
|
||||
@@ -793,8 +803,12 @@ mod construct_tests;
|
||||
#[cfg(test)]
|
||||
mod delete_face_tests;
|
||||
#[cfg(test)]
|
||||
mod display_mesh_tests;
|
||||
#[cfg(test)]
|
||||
mod extent_tests;
|
||||
#[cfg(test)]
|
||||
mod hole_face_tests;
|
||||
#[cfg(test)]
|
||||
mod naming_history_tests;
|
||||
#[cfg(test)]
|
||||
mod pattern_suppress_tests;
|
||||
|
||||
@@ -35,10 +35,13 @@ pub fn default_dir() -> Option<PathBuf> {
|
||||
env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share")))?
|
||||
};
|
||||
let (dir, legacy) = dirs_under(&root, cfg!(windows) || cfg!(target_os = "macos"));
|
||||
// Compatibility shim (docs/compatibility.md): autosaves made before the rename are copied
|
||||
// to the new folder on first launch; the old folder is left as it was.
|
||||
if let Err(e) = solidcraft_io::adopt_legacy_dir(&dir, &legacy) {
|
||||
log::warn!("recovery: could not copy {} to {}: {e}", legacy.display(), dir.display());
|
||||
// Compatibility shim (docs/compatibility.md): autosaves made before the rename (or, in the
|
||||
// Flatpak sandbox, under the old app id) are copied to the new folder on first launch; the
|
||||
// old folder is left as it was.
|
||||
for old in solidcraft_io::legacy_flatpak_path(&dir).into_iter().chain([legacy]) {
|
||||
if let Err(e) = solidcraft_io::adopt_legacy_dir(&dir, &old) {
|
||||
log::warn!("recovery: could not copy {} to {}: {e}", old.display(), dir.display());
|
||||
}
|
||||
}
|
||||
Some(dir)
|
||||
}
|
||||
@@ -143,7 +146,8 @@ pub struct Autosaver {
|
||||
/// Autosave this often while there are unsaved changes.
|
||||
pub interval: Duration,
|
||||
lock: Option<File>,
|
||||
last: Option<Arc<Document>>,
|
||||
/// The design and what was hidden at the last autosave.
|
||||
last: Option<(Arc<Document>, crate::Visibility)>,
|
||||
last_at: Instant,
|
||||
has_entry: bool,
|
||||
}
|
||||
@@ -189,17 +193,17 @@ impl Autosaver {
|
||||
crate::crash::rebased(s, s.path.as_deref().map(Path::new), false);
|
||||
return Ok(false);
|
||||
}
|
||||
if self.last.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.doc) || **l == *s.doc) {
|
||||
if self.last.as_ref().is_some_and(|(l, v)| (Arc::ptr_eq(l, &s.doc) || **l == *s.doc) && *v == s.visibility) {
|
||||
return Ok(false);
|
||||
}
|
||||
let io = |e: std::io::Error| EngineError::Other(format!("autosave: {e}"));
|
||||
let bytes = solidcraft_io::write_design_container(&s.doc)?;
|
||||
let bytes = solidcraft_io::write_design_container(&s.design())?;
|
||||
solidcraft_io::write_atomic(&file(&self.dir, &self.id, solidcraft_io::DESIGN_EXT), &bytes, false).map_err(io)?;
|
||||
let entry =
|
||||
Entry { id: self.id.clone(), path: s.path.clone(), name: s.doc.name.clone(), saved_at: unix_now(), features: s.doc.features.len() };
|
||||
let meta = serde_json::to_vec_pretty(&entry).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
solidcraft_io::write_atomic(&file(&self.dir, &self.id, "json"), &meta, false).map_err(io)?;
|
||||
self.last = Some(s.doc.clone());
|
||||
self.last = Some((s.doc.clone(), s.visibility.clone()));
|
||||
self.has_entry = true;
|
||||
// Crash replays start from this autosave from here.
|
||||
crate::crash::rebased(s, Some(&file(&self.dir, &self.id, solidcraft_io::DESIGN_EXT)), true);
|
||||
|
||||
+218
-17
@@ -96,6 +96,20 @@ fn join_extrude_from_a_chamfered_top_face() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_cut_in_pieces_twice_gets_pieces_of_their_own_names() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 5, "height": 5, "body_name": "Bar"}));
|
||||
for x in [10, 25] {
|
||||
run(&mut s, "solid.box", json!({"corner": [x, -1, -1], "length": 2, "width": 7, "height": 7, "body_name": "Gap"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Bar", "tools": ["Gap"], "operation": "cut"}));
|
||||
}
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
let unique: std::collections::BTreeSet<&String> = names.iter().collect();
|
||||
assert_eq!(unique.len(), 3, "{names:?}");
|
||||
assert!(names.contains(&"Bar (2)".to_string()), "{names:?}");
|
||||
}
|
||||
|
||||
/// A join that bridges two bodies makes them one body, not an overlap of the first and the second.
|
||||
#[test]
|
||||
fn join_bridging_two_bodies_merges_them() {
|
||||
@@ -331,6 +345,83 @@ fn revolve_and_primitives() {
|
||||
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), PI * 125.0 * 5.0 / 2.0) < 5e-4);
|
||||
}
|
||||
|
||||
/// A spherical shell (a half ring between two arcs, both radial lines on the axis) revolved on a
|
||||
/// sketch plane that does not pass through the origin: the body is the shell, as it is on XY.
|
||||
/// Before, built in place, it measured a third too large (truck tessellates the degenerate faces
|
||||
/// on the axis reliably only with the axis through the origin).
|
||||
#[test]
|
||||
fn revolve_a_spherical_shell_off_the_origin() {
|
||||
for (plane, z) in [(json!("XY"), 0.0), (json!({"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}), 10.0)] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": plane, "name": "Ring"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-40, 0], [40, 0]]}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [40, 0], "end": [-40, 0]}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [18.5, 0], "end": [-18.5, 0]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// The half ring: the larger of the two regions above the axis.
|
||||
let si = run(&mut s, "sketch.inspect", json!({"sketch": "Ring"}));
|
||||
let ring = si["profiles"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.max_by(|a, b| a["area_mm2"].as_f64().unwrap().total_cmp(&b["area_mm2"].as_f64().unwrap()))
|
||||
.unwrap()["index"]
|
||||
.clone();
|
||||
run(&mut s, "solid.revolve", json!({"sketch": "Ring", "profiles": [ring], "axis": "l1", "body_name": "Shell"}));
|
||||
let m = run(&mut s, "inspect.measure", json!({"bodies": ["Shell"]}));
|
||||
let want = 4.0 / 3.0 * PI * (40.0f64.powi(3) - 18.5f64.powi(3));
|
||||
let v = m["total"]["volume_mm3"].as_f64().unwrap();
|
||||
assert!(rel(v, want) < 5e-4, "plane at z {z}: {v} vs {want}");
|
||||
let b = &m["bodies"][0]["bbox"];
|
||||
assert!((b["min"][2].as_f64().unwrap() - (z - 40.0)).abs() < 1e-6 && (b["max"][2].as_f64().unwrap() - (z + 40.0)).abs() < 1e-6, "{b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrude_of_adjacent_profiles_has_whole_edges() {
|
||||
// A holder from two regions of one sketch (a disc and the ring around it, both cut off by a
|
||||
// chord): the chord used to stay split where the inner circle met it, leaving vertices right
|
||||
// on the surface of a handle of that radius, and cutting the handle out of the holder failed.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Holder"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 40}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 35}));
|
||||
let x = (40.0f64 * 40.0 - 33.0 * 33.0).sqrt();
|
||||
run(&mut s, "sketch.line", json!({"points": [[-x, -33], [x, -33]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let si = run(&mut s, "sketch.inspect", json!({"sketch": "Holder"}));
|
||||
let below: Vec<Value> = si["profiles"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|p| (500.0..4900.0).contains(&p["area_mm2"].as_f64().unwrap()))
|
||||
.map(|p| p["index"].clone())
|
||||
.collect();
|
||||
assert_eq!(below.len(), 2, "{si}");
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Holder", "profiles": below, "distance": 23, "body_name": "Holder"}));
|
||||
let topo = run(&mut s, "model.faces", json!({"body": "Holder"}));
|
||||
assert_eq!(topo["faces"].as_array().unwrap().len(), 5, "a cut-off cylinder: {topo}");
|
||||
let lo = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["bodies"][0]["bbox"]["min"][1].as_f64().unwrap();
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Holder"], "translate": [0, -5.0 - lo, 0]}));
|
||||
// The handle: r35 on the Y axis with a bevelled end.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Handle"}));
|
||||
for pts in [
|
||||
[[0.0, 0.0], [0.0, 230.0]],
|
||||
[[0.0, 0.0], [25.0, 0.0]],
|
||||
[[25.0, 0.0], [35.0, 11.5]],
|
||||
[[35.0, 11.5], [35.0, 230.0]],
|
||||
[[35.0, 230.0], [0.0, 230.0]],
|
||||
] {
|
||||
run(&mut s, "sketch.line", json!({ "points": pts }));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.revolve", json!({"sketch": "Handle", "profiles": [0], "axis": "Y", "body_name": "Handle"}));
|
||||
let before = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
run(&mut s, "solid.combine", json!({"target": "Holder", "tools": ["Handle"], "operation": "cut", "keep_tools": true}));
|
||||
let after = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
assert!(after > 1000.0 && after < before * 0.5, "{before} -> {after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_tools_and_constraints() {
|
||||
let mut s = Session::default();
|
||||
@@ -739,12 +830,16 @@ fn step_open_then_model_on_it() {
|
||||
run(&mut s, "solid.hole", json!({"position": at(28.0, 15.0, 20.0), "diameter": 6}));
|
||||
want -= PI * 9.0 * 20.0;
|
||||
assert!(rel(volume(&mut s), want) < 5e-4, "{} vs {want}", volume(&mut s));
|
||||
// Modified imported faces no longer have an immutable construction certificate.
|
||||
// The public attachment refuses them; the user can explicitly choose a fixed plane.
|
||||
let before_fixed = s.doc.to_json();
|
||||
assert!(s.execute("sketch.create", &json!({"plane":{"face":at(10.0,15.0,20.0)}})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before_fixed);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0), "fixed": true}}));
|
||||
// Modified imported faces no longer have an immutable construction certificate; the top
|
||||
// attaches by its exact history name instead.
|
||||
let top = run(&mut s, "sketch.create", json!({"plane": {"face": at(10.0, 15.0, 20.0)}}))["sketch"].as_u64().unwrap();
|
||||
assert!(matches!(
|
||||
s.placed_document().sketch_face_attachment(top, &s.model),
|
||||
doc::FaceAttachmentStatus::Healthy {
|
||||
target: doc::config::ConfigFaceTarget { certificate: Some(doc::config::FaceCertificate::Lineage), .. },
|
||||
..
|
||||
}
|
||||
));
|
||||
let c = [x0 + 10.0, y0 + 15.0];
|
||||
// Sketch coordinates on the face are relative to the model origin projected onto it.
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [c[0] - 4.0, c[1] - 4.0], "p1": [c[0] + 4.0, c[1] + 4.0]}));
|
||||
@@ -1882,7 +1977,8 @@ mod naming {
|
||||
let mut legacy = Session::new(solidcraft_doc::Document::from_json(&old.to_json()).unwrap());
|
||||
assert_eq!(attached(&legacy), Some(10.));
|
||||
assert!((top(&mut legacy) - 15.).abs() < 1e-6);
|
||||
// The upstream join consumes the certified body; new attachments cannot follow its substitute roof.
|
||||
// An upstream join that trims the certified face: the attachment stays on what is left
|
||||
// of it and never follows the substitute roof (see face_tests for a face covered whole).
|
||||
for target in [&mut s, &mut legacy] {
|
||||
run(target, "timeline.roll_to", json!({"feature": "Block"}));
|
||||
run(target, "sketch.create", json!({"plane": "XY", "name": "B"}));
|
||||
@@ -1903,19 +1999,14 @@ mod naming {
|
||||
};
|
||||
assert!(anchor.is_none(), "opening old face references must not infer a strict attachment");
|
||||
let failed = |s: &Session, name: &str| s.model.results.iter().find(|r| r.id == s.doc.find_feature(name).unwrap().id).unwrap().error.is_some();
|
||||
assert!(failed(&s, "Top"));
|
||||
assert!(failed(&s, "Peg"), "lost sketch cannot supply downstream geometry");
|
||||
assert_eq!(attached(&s), None);
|
||||
assert!(!failed(&s, "Top"));
|
||||
assert!(!failed(&s, "Peg"));
|
||||
assert_eq!(attached(&s), Some(10.), "the strip of Block's top left beside the boss is still that face");
|
||||
assert!((top(&mut s) - 20.0).abs() < 1e-6, "{}", top(&mut s));
|
||||
run(&mut s, "timeline.suppress", json!({"feature":"Boss","suppressed":true}));
|
||||
assert!(!failed(&s, "Top"));
|
||||
assert!(!failed(&s, "Peg"));
|
||||
assert_eq!(attached(&s), Some(10.));
|
||||
assert!((top(&mut s) - 15.).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(failed(&s, "Top"));
|
||||
assert!(failed(&s, "Peg"));
|
||||
assert_eq!(attached(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2719,7 +2810,6 @@ fn a_pattern_repeats_a_fillet_on_the_edges_it_found() {
|
||||
|
||||
/// A copy added by raising the count after a rename takes the next number, not the freed one (#372).
|
||||
#[test]
|
||||
#[ignore = "fails until #372 is fixed"]
|
||||
fn raising_a_pattern_count_after_a_rename_numbers_the_new_copy_after_the_others() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
|
||||
@@ -2752,7 +2842,6 @@ fn a_profile_added_later_is_named_after_body_name() {
|
||||
|
||||
/// The reply names the bodies the feature created, apart from the ones already there (#374).
|
||||
#[test]
|
||||
#[ignore = "fails until #374 is fixed"]
|
||||
fn extrude_reply_lists_the_bodies_it_created() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [0, 50, 0], "operation": "new_body", "body_name": "Base"}));
|
||||
@@ -2820,6 +2909,33 @@ fn fillet_fit_reduce_uses_the_largest_radius_that_fits() {
|
||||
assert!(s.execute("solid.fillet", &json!({"edges": mids, "radius": 1, "fit": "reduce", "type": "chord"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_fit_reduce_keeps_the_radius_where_it_fits() {
|
||||
// A 2 mm fin on a block: its top edge can't take 4, a corner of the block far from it can.
|
||||
// Only the fin's edge is reduced; the reply gives each edge's radius.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 30, "height": 10, "corner": [19, 0, 20], "operation": "join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
let edges = json!([[21, 15, 30], [40, 0, 10]]);
|
||||
let out = run(&mut s, "solid.fillet", json!({"edges": edges, "radius": 4, "fit": "reduce"}));
|
||||
let fin = out["radius_applied"].as_f64().unwrap();
|
||||
assert!(fin < 4.0, "{out}");
|
||||
let each: Vec<(Vec<f64>, f64)> = out["radii_applied"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|x| (x["edge"].as_array().unwrap().iter().filter_map(Value::as_f64).collect(), x["radius"].as_f64().unwrap()))
|
||||
.collect();
|
||||
assert_eq!(each, [(vec![21.0, 15.0, 30.0], fin), (vec![40.0, 0.0, 10.0], 4.0)], "{out}");
|
||||
let removed = (1.0 - PI / 4.0) * (fin * fin * 30.0 + 16.0 * 20.0);
|
||||
let want = 40.0 * 30.0 * 20.0 + 2.0 * 30.0 * 10.0 - removed;
|
||||
assert!(rel(volume(&mut s), want) < 2e-4, "{} vs {want}", volume(&mut s));
|
||||
let id = out["feature"].as_u64().unwrap();
|
||||
let warning = s.model.result(id).and_then(|r| r.warning.clone()).unwrap_or_default();
|
||||
assert!(warning.contains("on 1 of 2 edges"), "{warning}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pattern_of_a_reduced_fillet_rounds_each_copy_alike() {
|
||||
// #293: a 6 mm wide post on a plate, its four vertical edges at 3.5 reduced to 3, patterned
|
||||
@@ -2833,3 +2949,88 @@ fn a_pattern_of_a_reduced_fillet_rounds_each_copy_alike() {
|
||||
let expect = 60.0 * 20.0 * 2.0 + 3.0 * (840.0 - (4.0 - PI) * 9.0 * 10.0);
|
||||
assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
/// A taper on a profile of lines and an arc (#546). The D profile (back 9.2, sides 7.4, a
|
||||
/// half-round front R 4.6 tangent to them) grows by d = z · tan 5° with mitred corners:
|
||||
/// A(d) = A₀ + P·d + (2 + π/2)·d², integrated over the 10 mm height.
|
||||
#[test]
|
||||
fn tapered_extrude_of_a_profile_mixing_lines_and_an_arc() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "D"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 4.6], [-7.4, 4.6], [-7.4, -4.6], [0, -4.6]], "closed": false}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [0, -4.6], "end": [0, 4.6]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "D", "distance": 10, "taper": "5 deg"}));
|
||||
let (t, h) = (5f64.to_radians().tan(), 10.0);
|
||||
let (a0, p, k) = (7.4 * 9.2 + PI * 4.6 * 4.6 / 2.0, 2.0 * 7.4 + 9.2 + PI * 4.6, 2.0 + PI / 2.0);
|
||||
let expect = a0 * h + p * t * h * h / 2.0 + k * t * t * h.powi(3) / 3.0;
|
||||
assert!(rel(volume(&mut s), expect) < 1e-3, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
/// A feature that joins into a body creates none, and its reply says so; a primitive's reply
|
||||
/// names the body it made (#374).
|
||||
#[test]
|
||||
fn feature_replies_list_only_the_bodies_they_created() {
|
||||
let mut s = Session::default();
|
||||
let r = run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Base"}));
|
||||
assert_eq!(r["created_bodies"], json!(["Base"]), "{r}");
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Boss"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [5, 5], "radius": 2}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let r = run(&mut s, "solid.extrude", json!({"sketch": "Boss", "distance": "15", "operation": "join"}));
|
||||
assert_eq!(r["created_bodies"], json!([]), "{r}");
|
||||
assert_eq!(r["bodies"], json!(["Base"]), "{r}");
|
||||
}
|
||||
|
||||
/// A feature reply also says which existing bodies it changed and which are gone, so a client
|
||||
/// that skips the full body list (MCP `batch` with `verbose: false`, #530) still sees every effect.
|
||||
#[test]
|
||||
fn feature_replies_list_the_bodies_they_changed_and_removed() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new", "body_name": "B"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0], "operation": "new", "body_name": "C"}));
|
||||
// A pocket in A changes A only.
|
||||
let r = run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 10, "corner": [3, 3, 5], "operation": "cut", "targets": ["A"]}));
|
||||
assert_eq!((&r["created_bodies"], &r["changed_bodies"], &r["removed_bodies"]), (&json!([]), &json!(["A"]), &json!([])), "{r}");
|
||||
// Joining C into B changes B and removes C.
|
||||
let r = run(&mut s, "solid.combine", json!({"target": "B", "tools": ["C"], "operation": "join"}));
|
||||
assert_eq!((&r["changed_bodies"], &r["removed_bodies"]), (&json!(["B"]), &json!(["C"])), "{r}");
|
||||
// A new body changes nothing else.
|
||||
let r = run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "corner": [0, 50, 0], "operation": "new", "body_name": "D"}));
|
||||
assert_eq!((&r["created_bodies"], &r["changed_bodies"], &r["removed_bodies"]), (&json!(["D"]), &json!([]), &json!([])), "{r}");
|
||||
}
|
||||
|
||||
/// Drafting a cylinder's side face 5° from the base plane makes a cone frustum (#547): radius 10
|
||||
/// at the base, 10 − 20 · tan 5° at the top.
|
||||
#[test]
|
||||
fn draft_on_a_cylinder_side_makes_a_frustum() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 20}));
|
||||
run(&mut s, "solid.draft", json!({"faces": [[10, 0, 10]], "angle": "5 deg", "neutral": "XY"}));
|
||||
let (big, h) = (10.0, 20.0);
|
||||
let small = big - h * 5f64.to_radians().tan();
|
||||
let expect = PI * h / 3.0 * (big * big + big * small + small * small);
|
||||
assert!(rel(volume(&mut s), expect) < 1e-3, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
/// The copies added after a rename keep their numbers through undo/redo, save/reopen and a
|
||||
/// further count change; lowering the count drops the last copies (#372).
|
||||
#[test]
|
||||
fn pattern_copies_added_after_a_rename_keep_their_numbers() {
|
||||
let mut s = Session::default();
|
||||
let names = |s: &Session| -> Vec<String> { s.model.state().bodies.iter().map(|b| b.name.clone()).collect() };
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20, "name": "Pat"}));
|
||||
run(&mut s, "body.rename", json!({"body": "X (2)", "name": "Second"}));
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "5"}}}));
|
||||
assert_eq!(names(&s), ["X", "Second", "X (3)", "X (4)", "X (5)"]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(names(&s), ["X", "Second", "X (3)"]);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(names(&s), ["X", "Second", "X (3)", "X (4)", "X (5)"]);
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(names(&reopened), ["X", "Second", "X (3)", "X (4)", "X (5)"]);
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "2"}}}));
|
||||
assert_eq!(names(&s), ["X", "Second"]);
|
||||
}
|
||||
|
||||
@@ -67,3 +67,38 @@ fn modelled_internal_threads_m3_to_m20() {
|
||||
assert!((got - want).abs() < want * 0.03, "M{d}: removed {got}, ISO profile {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue 602: rounding the edges of a hex boss on a threaded shaft froze the app. The thread's
|
||||
/// thousands of facets were healed pair by pair, once per feature after it, and the fillet's
|
||||
/// preview then compared each of them with the whole old body.
|
||||
#[test]
|
||||
fn fillet_beside_a_modelled_thread_finishes() {
|
||||
let mut s = Session::default();
|
||||
for (id, p) in [
|
||||
("sketch.create", json!({"plane": "XY"})),
|
||||
("sketch.circle.center", json!({"center": [0, 0], "diameter": 5})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.extrude", json!({"distance": 10, "body_name": "Screw"})),
|
||||
("sketch.create", json!({"plane": {"face": [0, 0, 10]}})),
|
||||
("sketch.polygon.circumscribed", json!({"center": [0, 0], "radius": 2, "sides": 6})),
|
||||
("sketch.finish", json!({})),
|
||||
("solid.extrude", json!({"distance": 3, "profiles": [{"point": [0, 0]}], "operation": "join", "targets": ["Screw"]})),
|
||||
("solid.thread", json!({"face": [2.5, 0, 5], "designation": "M5", "length": 4, "modeled": true})),
|
||||
] {
|
||||
run(&mut s, id, p);
|
||||
}
|
||||
let faces_before = s.model.state().body("Screw").map(|b| b.body.face_count()).unwrap_or(0);
|
||||
assert!(faces_before > 1000, "the thread is faceted: {faces_before} faces");
|
||||
let volume_before = volume(&mut s, "Screw");
|
||||
let top: Vec<Value> = (0..6)
|
||||
.map(|i| {
|
||||
let a = (30.0 + 60.0 * i as f64).to_radians();
|
||||
json!([2.0 * a.cos(), 2.0 * a.sin(), 13])
|
||||
})
|
||||
.collect();
|
||||
let started = std::time::Instant::now();
|
||||
run(&mut s, "solid.fillet", json!({"edges": top, "radius": 0.3}));
|
||||
assert!(started.elapsed().as_secs() < 30, "the fillet took {:?}", started.elapsed());
|
||||
let volume_after = volume(&mut s, "Screw");
|
||||
assert!(volume_after < volume_before && volume_after > volume_before * 0.99, "{volume_before} -> {volume_after}");
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ fn sampler_keeps_style_precedence_hidden_references_text_and_disjoint_parts() {
|
||||
let text = sk
|
||||
.add_link(
|
||||
LinkKind::Text,
|
||||
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0. },
|
||||
LinkSource::Text { text: "A".into(), at: Vec2::ZERO, height: 2., angle: 0., font: None },
|
||||
&[LinkGeom::Line(Vec2::new(0., 7.), Vec2::new(2., 7.))],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
//! SolidCraft-owned portable B-rep data, independent of a modelling backend.
|
||||
//! Millimetres, radians, right-handed Z-up. See docs/native-brep.md for semantics.
|
||||
//! Validation checks structure and resource budgets, not geometric equivalence or trust.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod envelope;
|
||||
|
||||
pub const VERSION: u32 = 2;
|
||||
pub const MAX_ITEMS: usize = 100_000;
|
||||
const MAX_DEPTH: usize = 16;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Brep {
|
||||
pub version: u32,
|
||||
pub shells: Vec<Shell>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Shell {
|
||||
pub vertices: Vec<[f64; 3]>,
|
||||
pub edges: Vec<Edge>,
|
||||
pub faces: Vec<Face>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Edge {
|
||||
pub vertices: [u32; 2],
|
||||
pub curve: Curve,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Face {
|
||||
pub loops: Vec<Vec<EdgeUse>>,
|
||||
pub surface: Surface,
|
||||
pub forward: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeUse {
|
||||
pub edge: u32,
|
||||
pub forward: bool,
|
||||
}
|
||||
|
||||
/// Right-handed orthonormal frame; z = x_axis cross y_axis.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Frame {
|
||||
pub origin: [f64; 3],
|
||||
pub x_axis: [f64; 3],
|
||||
pub y_axis: [f64; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Curve {
|
||||
Line { start: [f64; 3], end: [f64; 3] },
|
||||
CircleArc { frame: Frame, radius: f64, angle: [f64; 2] },
|
||||
EllipseArc { frame: Frame, radii: [f64; 2], angle: [f64; 2] },
|
||||
Polyline { points: Vec<[f64; 3]> },
|
||||
Spline { knots: Vec<f64>, points: Vec<[f64; 3]> },
|
||||
RationalSpline { knots: Vec<f64>, homogeneous: Vec<[f64; 4]> },
|
||||
Intersection { knots: Vec<f64>, points: Vec<[f64; 3]>, fitting_tolerance: f64, faces: Option<[u32; 2]> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Surface {
|
||||
Plane { origin: [f64; 3], u_axis: [f64; 3], v_axis: [f64; 3] },
|
||||
Cylinder { frame: Frame, radius: f64, angle: [f64; 2], height: [f64; 2] },
|
||||
Cone { frame: Frame, radius_at_origin: f64, half_angle: f64, angle: [f64; 2], height: [f64; 2] },
|
||||
Sphere { frame: Frame, radius: f64, longitude: [f64; 2], latitude: [f64; 2] },
|
||||
Torus { frame: Frame, major_radius: f64, minor_radius: f64, longitude: [f64; 2], meridian: [f64; 2] },
|
||||
Spline { u_knots: Vec<f64>, v_knots: Vec<f64>, points: Vec<Vec<[f64; 3]>> },
|
||||
RationalSpline { u_knots: Vec<f64>, v_knots: Vec<f64>, homogeneous: Vec<Vec<[f64; 4]>> },
|
||||
Revolution { profile: Box<Curve>, origin: [f64; 3], axis: [f64; 3], angle: [f64; 2] },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, thiserror::Error)]
|
||||
#[error("portable B-rep: {0}")]
|
||||
pub struct Invalid(pub &'static str);
|
||||
|
||||
type Result<T> = std::result::Result<T, Invalid>;
|
||||
|
||||
fn require(condition: bool, message: &'static str) -> Result<()> {
|
||||
if condition { Ok(()) } else { Err(Invalid(message)) }
|
||||
}
|
||||
|
||||
struct Budget(usize);
|
||||
impl Budget {
|
||||
fn add(&mut self, n: usize) -> Result<()> {
|
||||
self.0 = self.0.checked_add(n).ok_or(Invalid("item budget"))?;
|
||||
require(self.0 <= MAX_ITEMS, "item budget")
|
||||
}
|
||||
}
|
||||
fn numbers(values: impl IntoIterator<Item = f64>) -> Result<()> {
|
||||
require(values.into_iter().all(|v| v.is_finite() && v.abs() <= 1e12), "non-finite or excessive coordinate")
|
||||
}
|
||||
fn points<const N: usize>(values: &[[f64; N]], budget: &mut Budget) -> Result<()> {
|
||||
budget.add(values.len())?;
|
||||
numbers(values.iter().flatten().copied())
|
||||
}
|
||||
fn knots(values: &[f64], count: usize, budget: &mut Budget) -> Result<()> {
|
||||
budget.add(values.len())?;
|
||||
numbers(values.iter().copied())?;
|
||||
let degree = values.len().checked_sub(count.saturating_add(1)).ok_or(Invalid("spline degree"))?;
|
||||
require((2..=4096).contains(&count) && (1..=16).contains(°ree) && degree < count, "spline degree or size")?;
|
||||
require(values.windows(2).all(|w| w.first() <= w.get(1)), "unordered knots")?;
|
||||
require(values.get(degree) < values.get(count), "empty spline domain")?;
|
||||
// No knot may have multiplicity greater than degree + 1.
|
||||
let mut run = 1;
|
||||
for pair in values.windows(2) {
|
||||
run = if pair.first() == pair.get(1) { run + 1 } else { 1 };
|
||||
require(run <= degree + 1, "knot multiplicity")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn weights(values: &[[f64; 4]]) -> Result<()> {
|
||||
require(values.iter().all(|p| p.get(3).is_some_and(|w| *w >= 1e-12)), "non-positive rational weight")
|
||||
}
|
||||
fn grid<const N: usize>(u: &[f64], v: &[f64], rows: &[Vec<[f64; N]>], budget: &mut Budget) -> Result<()> {
|
||||
let columns = rows.first().map_or(0, Vec::len);
|
||||
knots(u, rows.len(), budget)?;
|
||||
knots(v, columns, budget)?;
|
||||
for row in rows {
|
||||
require(row.len() == columns, "ragged surface")?;
|
||||
points(row, budget)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn vector(p: [f64; 3]) -> crate::Vec3 {
|
||||
crate::Vec3::new(p[0], p[1], p[2])
|
||||
}
|
||||
fn frame(f: &Frame, budget: &mut Budget) -> Result<()> {
|
||||
points(&[f.origin, f.x_axis, f.y_axis], budget)?;
|
||||
let (x, y) = (vector(f.x_axis), vector(f.y_axis));
|
||||
require((x.len() - 1.).abs() <= 1e-8 && (y.len() - 1.).abs() <= 1e-8 && x.dot(y).abs() <= 1e-8, "nonorthonormal frame")
|
||||
}
|
||||
fn range(r: &[f64; 2]) -> Result<()> {
|
||||
numbers(*r)?;
|
||||
require(r[0] < r[1], "empty parameter range")
|
||||
}
|
||||
fn angle(r: &[f64; 2]) -> Result<()> {
|
||||
range(r)?;
|
||||
require(r[1] - r[0] <= std::f64::consts::TAU + 1e-12, "angular range exceeds one turn")
|
||||
}
|
||||
fn radius(r: f64) -> Result<()> {
|
||||
require(r.is_finite() && (1e-12..=1e12).contains(&r), "radius")
|
||||
}
|
||||
fn curve(c: &Curve, depth: usize, budget: &mut Budget) -> Result<()> {
|
||||
require(depth <= MAX_DEPTH, "geometry depth")?;
|
||||
budget.add(1)?;
|
||||
match c {
|
||||
Curve::Line { start, end } => {
|
||||
points(&[*start, *end], budget)?;
|
||||
require(start != end, "collapsed line")
|
||||
}
|
||||
Curve::CircleArc { frame: f, radius: r, angle: a } => {
|
||||
frame(f, budget)?;
|
||||
radius(*r)?;
|
||||
angle(a)
|
||||
}
|
||||
Curve::EllipseArc { frame: f, radii, angle: a } => {
|
||||
frame(f, budget)?;
|
||||
for r in radii {
|
||||
radius(*r)?;
|
||||
}
|
||||
angle(a)
|
||||
}
|
||||
Curve::Polyline { points: p } => {
|
||||
require(p.len() >= 2, "empty polyline")?;
|
||||
points(p, budget)?;
|
||||
require(p.windows(2).all(|p| p.first() != p.get(1)), "collapsed polyline segment")
|
||||
}
|
||||
Curve::Spline { knots: k, points: p } => {
|
||||
knots(k, p.len(), budget)?;
|
||||
points(p, budget)
|
||||
}
|
||||
Curve::RationalSpline { knots: k, homogeneous: p } => {
|
||||
knots(k, p.len(), budget)?;
|
||||
points(p, budget)?;
|
||||
weights(p)
|
||||
}
|
||||
Curve::Intersection { knots: k, points: p, fitting_tolerance, .. } => {
|
||||
require((1e-12..=1.0).contains(fitting_tolerance), "intersection fitting tolerance")?;
|
||||
knots(k, p.len(), budget)?;
|
||||
points(p, budget)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn surface(s: &Surface, depth: usize, budget: &mut Budget) -> Result<()> {
|
||||
require(depth <= MAX_DEPTH, "geometry depth")?;
|
||||
budget.add(1)?;
|
||||
match s {
|
||||
Surface::Plane { origin, u_axis, v_axis } => {
|
||||
points(&[*origin, *u_axis, *v_axis], budget)?;
|
||||
require(vector(*u_axis).cross(vector(*v_axis)).len() > 1e-12, "degenerate plane")
|
||||
}
|
||||
Surface::Cylinder { frame: f, radius: r, angle: a, height } => {
|
||||
frame(f, budget)?;
|
||||
radius(*r)?;
|
||||
angle(a)?;
|
||||
range(height)
|
||||
}
|
||||
Surface::Cone { frame: f, radius_at_origin, half_angle, angle: a, height } => {
|
||||
frame(f, budget)?;
|
||||
numbers([*radius_at_origin, *half_angle])?;
|
||||
require(*radius_at_origin >= 0. && *half_angle > 0. && *half_angle < std::f64::consts::FRAC_PI_2, "cone dimensions")?;
|
||||
angle(a)?;
|
||||
range(height)?;
|
||||
require(height.iter().all(|h| radius_at_origin + h * half_angle.tan() >= 0.), "negative cone radius")
|
||||
}
|
||||
Surface::Sphere { frame: f, radius: r, longitude, latitude } => {
|
||||
frame(f, budget)?;
|
||||
radius(*r)?;
|
||||
angle(longitude)?;
|
||||
range(latitude)?;
|
||||
require(latitude[0] >= -std::f64::consts::FRAC_PI_2 && latitude[1] <= std::f64::consts::FRAC_PI_2, "sphere latitude")
|
||||
}
|
||||
Surface::Torus { frame: f, major_radius, minor_radius, longitude, meridian } => {
|
||||
frame(f, budget)?;
|
||||
radius(*major_radius)?;
|
||||
radius(*minor_radius)?;
|
||||
require(major_radius > minor_radius, "ring torus radii")?;
|
||||
angle(longitude)?;
|
||||
angle(meridian)
|
||||
}
|
||||
Surface::Spline { u_knots, v_knots, points: p } => grid(u_knots, v_knots, p, budget),
|
||||
Surface::RationalSpline { u_knots, v_knots, homogeneous: p } => {
|
||||
grid(u_knots, v_knots, p, budget)?;
|
||||
for row in p {
|
||||
weights(row)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Surface::Revolution { profile, origin, axis, angle: a } => {
|
||||
points(&[*origin, *axis], budget)?;
|
||||
require((vector(*axis).len() - 1.0).abs() <= 1e-8, "revolution axis is not unit length")?;
|
||||
angle(a)?;
|
||||
curve(profile, depth + 1, budget)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Brep {
|
||||
/// Validate portable structure without calling a kernel or evaluating geometry.
|
||||
/// Does not certify watertightness, surface coincidence, or authoritative state.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
require(self.version == VERSION, "unsupported version")?;
|
||||
require(!self.shells.is_empty() && self.shells.len() <= 1024, "shell count")?;
|
||||
let mut budget = Budget(0);
|
||||
for shell in &self.shells {
|
||||
points(&shell.vertices, &mut budget)?;
|
||||
budget.add(shell.edges.len())?;
|
||||
budget.add(shell.faces.len())?;
|
||||
require(!shell.faces.is_empty(), "empty shell")?;
|
||||
for edge in &shell.edges {
|
||||
require(edge.vertices.iter().all(|&v| (v as usize) < shell.vertices.len()), "edge vertices")?;
|
||||
curve(&edge.curve, 0, &mut budget)?;
|
||||
if let Curve::Intersection { faces: Some(hints), .. } = &edge.curve {
|
||||
require(hints.iter().all(|&i| (i as usize) < shell.faces.len()), "intersection face hint")?;
|
||||
}
|
||||
}
|
||||
for face in &shell.faces {
|
||||
require(!face.loops.is_empty(), "face loops")?;
|
||||
surface(&face.surface, 0, &mut budget)?;
|
||||
for wire in &face.loops {
|
||||
require(!wire.is_empty(), "empty wire")?;
|
||||
budget.add(wire.len())?;
|
||||
let mut first = None;
|
||||
let mut last = None;
|
||||
for use_ in wire {
|
||||
let edge = shell.edges.get(use_.edge as usize).ok_or(Invalid("edge reference"))?;
|
||||
let [a, b] = edge.vertices;
|
||||
let (start, end) = if use_.forward { (a, b) } else { (b, a) };
|
||||
require(last.is_none_or(|v| v == start), "disconnected wire")?;
|
||||
first.get_or_insert(start);
|
||||
last = Some(end);
|
||||
}
|
||||
require(first == last, "open wire")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn triangle() -> Brep {
|
||||
let vertices = vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]];
|
||||
let edges = (0..3)
|
||||
.map(|i| Edge { vertices: [i as u32, ((i + 1) % 3) as u32], curve: Curve::Line { start: vertices[i], end: vertices[(i + 1) % 3] } })
|
||||
.collect();
|
||||
Brep {
|
||||
version: VERSION,
|
||||
shells: vec![Shell {
|
||||
vertices,
|
||||
edges,
|
||||
faces: vec![Face {
|
||||
loops: vec![(0..3).map(|edge| EdgeUse { edge, forward: true }).collect()],
|
||||
surface: Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] },
|
||||
forward: true,
|
||||
}],
|
||||
}],
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn portable_open_face_round_trip_without_a_kernel() {
|
||||
let body = triangle();
|
||||
body.validate().unwrap();
|
||||
let bytes = serde_json::to_vec(&body).unwrap();
|
||||
let restored: Brep = serde_json::from_slice(&bytes).unwrap();
|
||||
restored.validate().unwrap();
|
||||
assert_eq!(body, restored);
|
||||
assert!(!String::from_utf8(bytes).unwrap().contains("truck"));
|
||||
}
|
||||
#[test]
|
||||
fn topology_references_and_wire_orientation_are_checked() {
|
||||
let mut b = triangle();
|
||||
b.shells[0].faces[0].loops[0][0].edge = u32::MAX;
|
||||
assert!(b.validate().is_err());
|
||||
let mut b = triangle();
|
||||
b.shells[0].faces[0].loops[0][1].forward = false;
|
||||
assert!(b.validate().is_err());
|
||||
let mut b = triangle();
|
||||
b.shells[0].edges[0].vertices[0] = u32::MAX;
|
||||
assert!(b.validate().is_err());
|
||||
let mut b = triangle();
|
||||
b.version += 1;
|
||||
assert!(b.validate().is_err());
|
||||
let mut b = triangle();
|
||||
b.shells[0].vertices[0][0] = f64::NAN;
|
||||
assert!(b.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn closed_edge_can_share_its_endpoint_vertex() {
|
||||
let mut body = triangle();
|
||||
body.shells[0].vertices.truncate(1);
|
||||
body.shells[0].edges =
|
||||
vec![Edge { vertices: [0, 0], curve: Curve::Polyline { points: vec![[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 0.]] } }];
|
||||
body.shells[0].faces[0].loops = vec![vec![EdgeUse { edge: 0, forward: true }]];
|
||||
body.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_domains_weights_and_nesting_are_bounded() {
|
||||
let mut budget = Budget(0);
|
||||
assert!(knots(&[0., 0., 1., 1.], 2, &mut budget).is_ok());
|
||||
for bad in [vec![0., 0., 0., 0.], vec![0., 1., 0., 1.], vec![0., 0., f64::INFINITY, 1.]] {
|
||||
assert!(knots(&bad, 2, &mut Budget(0)).is_err());
|
||||
}
|
||||
let c = Curve::RationalSpline { knots: vec![0., 0., 1., 1.], homogeneous: vec![[0., 0., 0., 0.], [1., 0., 0., 1.]] };
|
||||
assert!(curve(&c, 0, &mut Budget(0)).is_err());
|
||||
let p = Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [2., 0., 0.] };
|
||||
assert!(surface(&p, 0, &mut Budget(0)).is_err());
|
||||
assert!(curve(&Curve::Line { start: [0.; 3], end: [1.; 3] }, MAX_DEPTH + 1, &mut Budget(0)).is_err());
|
||||
assert!(Budget(MAX_ITEMS).add(1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytic_records_have_independent_frames_and_domains() {
|
||||
use std::f64::consts::{FRAC_PI_2, TAU};
|
||||
let f = Frame { origin: [1., 2., 3.], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] };
|
||||
let surfaces = [
|
||||
Surface::Cylinder { frame: f.clone(), radius: 2., angle: [0., TAU], height: [-1., 3.] },
|
||||
Surface::Cone { frame: f.clone(), radius_at_origin: 2., half_angle: 0.2, angle: [0., TAU], height: [0., 3.] },
|
||||
Surface::Sphere { frame: f.clone(), radius: 2., longitude: [0., TAU], latitude: [-FRAC_PI_2, FRAC_PI_2] },
|
||||
Surface::Torus { frame: f.clone(), major_radius: 4., minor_radius: 1., longitude: [0., TAU], meridian: [0., TAU] },
|
||||
Surface::Revolution {
|
||||
profile: Box::new(Curve::Line { start: [2., 0., 0.], end: [2., 0., 3.] }),
|
||||
origin: [0.; 3],
|
||||
axis: [0., 0., 1.],
|
||||
angle: [0., TAU],
|
||||
},
|
||||
];
|
||||
for s in surfaces {
|
||||
surface(&s, 0, &mut Budget(0)).unwrap();
|
||||
let encoded = serde_json::to_string(&s).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Surface>(&encoded).unwrap(), s);
|
||||
assert!(!encoded.contains("columns") && !encoded.contains("forward"));
|
||||
}
|
||||
for c in [
|
||||
Curve::CircleArc { frame: f.clone(), radius: 2., angle: [0., FRAC_PI_2] },
|
||||
Curve::EllipseArc { frame: f.clone(), radii: [2., 3.], angle: [0., TAU] },
|
||||
Curve::Intersection { knots: vec![0., 0., 1., 1.], points: vec![[0.; 3], [1.; 3]], fitting_tolerance: 1e-6, faces: None },
|
||||
] {
|
||||
curve(&c, 0, &mut Budget(0)).unwrap();
|
||||
let encoded = serde_json::to_string(&c).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Curve>(&encoded).unwrap(), c);
|
||||
assert!(!encoded.contains("leader") && !encoded.contains("surfaces"));
|
||||
}
|
||||
let mut bad = f;
|
||||
bad.y_axis = bad.x_axis;
|
||||
assert!(frame(&bad, &mut Budget(0)).is_err());
|
||||
assert!(angle(&[0., TAU + 0.1]).is_err());
|
||||
assert!(range(&[2., 1.]).is_err());
|
||||
assert!(radius(f64::NAN).is_err());
|
||||
let mut b = triangle();
|
||||
b.shells[0].edges[0].curve =
|
||||
Curve::Intersection { knots: vec![0., 0., 1., 1.], points: vec![[0.; 3], [1., 0., 0.]], fitting_tolerance: 1e-6, faces: Some([0, 1]) };
|
||||
assert!(b.validate().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Stable version-one body metadata records. Runtime model types never define this wire contract.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Metadata {
|
||||
pub version: u32,
|
||||
pub color: Option<[f32; 3]>,
|
||||
pub paint: Option<Paint>,
|
||||
pub boundaries: Option<Vec<Vec<[f64; 3]>>>,
|
||||
pub face_sources: Option<Vec<Option<FaceSource>>>,
|
||||
pub history: Option<History>,
|
||||
pub preserve_boundaries: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Plane {
|
||||
pub origin: [f64; 3],
|
||||
pub x_axis: [f64; 3],
|
||||
pub y_axis: [f64; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Segment {
|
||||
Line { start: [f64; 2], end: [f64; 2] },
|
||||
Arc { center: [f64; 2], radius: f64, start: f64, sweep: f64 },
|
||||
Cubic { points: [[f64; 2]; 4] },
|
||||
Conic { points: [[f64; 2]; 3], middle_weight: f64 },
|
||||
Bezier { points: Vec<[f64; 2]> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Region {
|
||||
pub outer: Vec<Segment>,
|
||||
pub holes: Vec<Vec<Segment>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum FaceSource {
|
||||
ExtrusionSide { segment: Segment, plane: Option<Plane>, coalesced: bool },
|
||||
CertifiedExtrusionSide { producer: u64, sketch: u64, curve: String, plane: Option<Plane> },
|
||||
ExtrusionCap { high: bool, region: Region, plane: Plane },
|
||||
CertifiedExtrusionCap { producer: u64, sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>>, plane: Plane },
|
||||
CylinderCap { producer: u64, high: bool, plane: Plane },
|
||||
BoxFace { producer: Option<u64>, axis: u8, high: bool, plane: Plane },
|
||||
PlanarShell { producer: Option<u64>, source: Box<FaceSource>, inner: bool, plane: Plane },
|
||||
Imported { item: u64, face: u64, payload_sha256: Option<String>, producer: Option<u64> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Paint {
|
||||
pub opacity: f32,
|
||||
pub faces: Vec<FacePaint>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FacePaint {
|
||||
pub face: u32,
|
||||
pub color: [f32; 3],
|
||||
pub opacity: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct History {
|
||||
pub operands: u32,
|
||||
pub faces: Vec<FaceHistory>,
|
||||
pub edges: Vec<EdgeHistory>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FaceHistory {
|
||||
pub origins: Vec<FaceOrigin>,
|
||||
pub change: Change,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeHistory {
|
||||
pub faces: Vec<u32>,
|
||||
pub origins: Vec<EdgeOrigin>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum FaceOrigin {
|
||||
Face { operand: u32, face: u32 },
|
||||
Profile { segment: u32 },
|
||||
Cap { end: bool },
|
||||
Edge { operand: u32, edge: u32 },
|
||||
Offset { operand: u32, face: u32 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum EdgeOrigin {
|
||||
Edge { operand: u32, edge: u32 },
|
||||
Meeting { sides: [Vec<FaceOrigin>; 2] },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Change {
|
||||
Kept,
|
||||
Modified,
|
||||
Split,
|
||||
Merged,
|
||||
Generated,
|
||||
}
|
||||
+111
-67
@@ -154,6 +154,18 @@ pub fn check_knots(knots: &[f64]) -> SplineResult<()> {
|
||||
/// A knot vector shorter than two knots gives zeros.
|
||||
pub fn local_basis(knots: &[f64], u: f64, side: Side, n: usize) -> Vec<f64> {
|
||||
let mut out = vec![0.0; n + 1];
|
||||
let ders = local_basis_array(knots, u, side, n);
|
||||
for (o, d) in out.iter_mut().zip(ders) {
|
||||
*o = d;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`local_basis`] into a fixed array (no allocation): element `k` is the `k`-th derivative,
|
||||
/// for `k ≤ min(n, MAX_SPLINE_DEGREE)`, the rest zero.
|
||||
pub(crate) fn local_basis_array(knots: &[f64], u: f64, side: Side, n: usize) -> [f64; MAX_SPLINE_DEGREE + 1] {
|
||||
const W: usize = MAX_SPLINE_DEGREE + 1;
|
||||
let mut out = [0.0; W];
|
||||
let Some(p) = knots.len().checked_sub(2) else { return out };
|
||||
if p > MAX_SPLINE_DEGREE || !u.is_finite() {
|
||||
return out;
|
||||
@@ -163,7 +175,6 @@ pub fn local_basis(knots: &[f64], u: f64, side: Side, n: usize) -> Vec<f64> {
|
||||
// table[k][j]: k-th derivative of N_{j,q} for the current degree q (j = 0..=p−q), updated
|
||||
// in place: k descending (level k reads level k−1 of the previous degree), j ascending
|
||||
// (N_{j,q} reads N_{j+1,q−1}, not yet overwritten). Derivatives above p are zero.
|
||||
const W: usize = MAX_SPLINE_DEGREE + 1;
|
||||
let levels = n.min(p);
|
||||
let mut table = [[0.0f64; W]; W];
|
||||
for (j, slot) in table[0].iter_mut().enumerate().take(p + 1) {
|
||||
@@ -286,15 +297,16 @@ pub fn find_span(knots: &[f64], p: usize, n: usize, u: f64) -> Option<usize> {
|
||||
if u < lo || u > hi {
|
||||
return None;
|
||||
}
|
||||
// The last non-empty span in [p, n−1] that starts at or before u.
|
||||
let mut k = p;
|
||||
for i in p..n {
|
||||
if let (Some(&a), Some(&b)) = (knots.get(i), knots.get(i + 1))
|
||||
&& a <= u
|
||||
&& a < b
|
||||
{
|
||||
k = i;
|
||||
}
|
||||
// The last non-empty span in [p, n−1] that starts at or before u: by bisection (the knots
|
||||
// are sorted), and at the domain end the last non-empty span.
|
||||
let mut k = if u >= hi {
|
||||
n - 1
|
||||
} else {
|
||||
let below = knots.get(p..n)?.partition_point(|&x| x <= u);
|
||||
p + below.checked_sub(1)?
|
||||
};
|
||||
while k > p && !(knots.get(k)? < knots.get(k + 1)?) {
|
||||
k -= 1;
|
||||
}
|
||||
Some(k)
|
||||
}
|
||||
@@ -331,16 +343,62 @@ pub fn insert_knot(p: usize, knots: &[f64], ctrl: &[HPoint], u: f64) -> SplineRe
|
||||
Ok((nk, q))
|
||||
}
|
||||
|
||||
/// The binomial coefficient `(n k)` (zero for `k > n`).
|
||||
pub(crate) fn binomial(n: usize, k: usize) -> f64 {
|
||||
if k > n {
|
||||
return 0.0;
|
||||
}
|
||||
(0..k).fold(1.0, |c, m| c * (n - m) as f64 / (m + 1) as f64)
|
||||
}
|
||||
|
||||
/// Bernstein polynomial `Bᵢ,ₚ(x)`.
|
||||
pub fn bernstein(p: usize, i: usize, x: f64) -> f64 {
|
||||
if i > p {
|
||||
return 0.0;
|
||||
}
|
||||
let mut c = 1.0;
|
||||
for m in 0..i {
|
||||
c = c * (p - m) as f64 / (m + 1) as f64;
|
||||
binomial(p, i) * x.powi(i as i32) * (1.0 - x).powi((p - i) as i32)
|
||||
}
|
||||
|
||||
/// One row of a B-spline (degree `p`, knots, homogeneous control points) clamped on its domain
|
||||
/// `[knots[p], knots[n]]`: each domain end inserted until it repeats `p` times (§5.2), then the
|
||||
/// basis functions that vanish on the domain dropped (a basis function's last span does not
|
||||
/// depend on its first knot, so that knot moves to the domain end). The curve on the domain is
|
||||
/// unchanged.
|
||||
pub(crate) fn clamp_row(p: usize, knots: &[f64], row: &[HPoint]) -> SplineResult<(Vec<f64>, Vec<HPoint>)> {
|
||||
let n = row.len();
|
||||
let (Some(&a), Some(&b)) = (knots.get(p), knots.get(n)) else { return invalid("too few knots") };
|
||||
let (mut knots, mut row) = (knots.to_vec(), row.to_vec());
|
||||
for (at_end, x) in [(false, a), (true, b)] {
|
||||
let mult = |k: &[f64]| k.iter().filter(|&&y| y == x).count();
|
||||
let mut guard = 0;
|
||||
while mult(&knots) < p {
|
||||
guard += 1;
|
||||
if guard > MAX_SPLINE_DEGREE + 1 {
|
||||
return invalid("clamping did not converge");
|
||||
}
|
||||
(knots, row) = insert_knot(p, &knots, &row, x)?;
|
||||
}
|
||||
let n = row.len();
|
||||
let (first, last) = if at_end {
|
||||
// Functions i with i ≥ (first index of x) start at or past the end.
|
||||
(0, knots.iter().position(|&y| y == x).unwrap_or(n).min(n))
|
||||
} else {
|
||||
// Functions i with i + p + 1 ≤ (last index of x) end at or before the start.
|
||||
(knots.iter().rposition(|&y| y == x).unwrap_or(p).saturating_sub(p), n)
|
||||
};
|
||||
if last <= first + p {
|
||||
return invalid("too few control points remain after clamping");
|
||||
}
|
||||
let mut nk: Vec<f64> = knots.get(first..last + p + 1).map(<[f64]>::to_vec).unwrap_or_default();
|
||||
if at_end {
|
||||
nk.last_mut().into_iter().for_each(|y| *y = x);
|
||||
} else {
|
||||
nk.first_mut().into_iter().for_each(|y| *y = x);
|
||||
}
|
||||
row = row.get(first..last).map(<[HPoint]>::to_vec).unwrap_or_default();
|
||||
knots = nk;
|
||||
}
|
||||
c * x.powi(i as i32) * (1.0 - x).powi((p - i) as i32)
|
||||
Ok((knots, row))
|
||||
}
|
||||
|
||||
/// A Bézier curve's point at `x ∈ [0, 1]` (de Casteljau, NURBS Book §1.4).
|
||||
@@ -543,66 +601,37 @@ impl NurbsSurface {
|
||||
}
|
||||
|
||||
/// The same surface on the same domain with clamped knot vectors (`p+1` equal knots at
|
||||
/// each end, so the boundary curves are given by the boundary rows of control points):
|
||||
/// insert each domain end until it has multiplicity `p`, then drop the basis functions
|
||||
/// that vanish on the domain (NURBS Book §5.2 knot insertion; a basis function's last span
|
||||
/// does not depend on its first knot, so that knot can be moved to the domain end).
|
||||
/// each end, so the boundary curves are given by the boundary rows of control points): each
|
||||
/// row of control points clamped in turn ([`clamp_row`]).
|
||||
pub fn clamped(&self) -> SplineResult<NurbsSurface> {
|
||||
let mut s = self.clone();
|
||||
for dir in [Dir::S, Dir::T] {
|
||||
let d = dir.pick(0, 1);
|
||||
for d in 0..2 {
|
||||
let p = s.degree[d];
|
||||
for end in 0..2 {
|
||||
let a = s.domain()[d][end];
|
||||
while s.knots[d].iter().filter(|&&x| x == a).count() < p {
|
||||
s = s.insert_knot(dir, a)?;
|
||||
}
|
||||
s = s.trim_end(d, end, a)?;
|
||||
let [nu, nv] = s.count;
|
||||
let (lines, len) = if d == 0 { (nv, nu) } else { (nu, nv) };
|
||||
let mut knots = Vec::new();
|
||||
let mut rows = Vec::with_capacity(lines);
|
||||
for line in 0..lines {
|
||||
let row: Vec<HPoint> = (0..len).map(|k| if d == 0 { s.point(k, line) } else { s.point(line, k) }).collect();
|
||||
let (k, r) = clamp_row(p, &s.knots[d], &row)?;
|
||||
knots = k;
|
||||
rows.push(r);
|
||||
}
|
||||
let new_len = rows.first().map_or(0, Vec::len);
|
||||
let mut ctrl = Vec::with_capacity(lines * new_len);
|
||||
if d == 0 {
|
||||
rows.iter().for_each(|r| ctrl.extend_from_slice(r));
|
||||
} else {
|
||||
for j in 0..new_len {
|
||||
ctrl.extend(rows.iter().map(|r| r.get(j).copied().unwrap_or(HPoint::ZERO)));
|
||||
}
|
||||
}
|
||||
let mut all = s.knots.clone();
|
||||
all[d] = knots;
|
||||
s = NurbsSurface::from_homogeneous(s.degree, all, ctrl)?;
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Drop the basis functions that vanish on the domain past the knot `a` (multiplicity ≥ p)
|
||||
/// at the start (`end == 0`) or the end of direction `d`.
|
||||
fn trim_end(&self, d: usize, end: usize, a: f64) -> SplineResult<NurbsSurface> {
|
||||
let p = self.degree[d];
|
||||
let kn = &self.knots[d];
|
||||
let n = self.count[d];
|
||||
let (first, last) = match end {
|
||||
0 => {
|
||||
let last_a = kn.iter().rposition(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
|
||||
// Functions i with i + p + 1 ≤ last_a vanish on [a, …).
|
||||
(last_a.saturating_sub(p), n)
|
||||
}
|
||||
_ => {
|
||||
let first_a = kn.iter().position(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
|
||||
// Functions i with i ≥ first_a vanish on (…, a].
|
||||
(0, first_a.min(n))
|
||||
}
|
||||
};
|
||||
if last <= first + p {
|
||||
return invalid("clamp: too few control points remain");
|
||||
}
|
||||
let mut knots = self.knots.clone();
|
||||
let mut nk: Vec<f64> = kn.get(first..last + p + 1).map(<[f64]>::to_vec).unwrap_or_default();
|
||||
match end {
|
||||
0 => nk.first_mut().into_iter().for_each(|x| *x = a),
|
||||
_ => nk.last_mut().into_iter().for_each(|x| *x = a),
|
||||
}
|
||||
knots[d] = nk;
|
||||
let [nu, nv] = self.count;
|
||||
let mut ctrl = Vec::new();
|
||||
for j in 0..nv {
|
||||
for i in 0..nu {
|
||||
let k = if d == 0 { i } else { j };
|
||||
if k >= first && k < last {
|
||||
ctrl.push(self.point(i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
NurbsSurface::from_homogeneous(self.degree, knots, ctrl)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -666,6 +695,21 @@ pub(crate) mod tests {
|
||||
assert!(worst < 1e-5, "derivative error {worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn span_search_finds_the_last_non_empty_span_at_or_before_u() {
|
||||
let p = 3;
|
||||
let knots = [0.0, 0.0, 0.0, 0.0, 0.3, 0.3, 0.5, 0.9, 0.9, 0.9, 1.0, 1.0, 1.0, 1.0];
|
||||
let n = knots.len() - p - 1;
|
||||
for k in 0..=1000 {
|
||||
let u = k as f64 / 1000.0;
|
||||
// The plain search: the last span [knots[i], knots[i+1]) with knots[i] ≤ u.
|
||||
let want = (p..n).filter(|&i| knots[i] <= u && knots[i] < knots[i + 1]).max().unwrap();
|
||||
assert_eq!(find_span(&knots, p, n, u), Some(want), "u = {u}");
|
||||
}
|
||||
assert_eq!(find_span(&knots, p, n, 1.5), None);
|
||||
assert_eq!(find_span(&knots, p, n, f64::NAN), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_function_knot_insertion_is_exact() {
|
||||
let mut rng = Lcg(7);
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
//! Filling a closed loop of curves with one surface: a non-rational bicubic B-spline height
|
||||
//! field over the loop's best-fit plane, fitted to the loop by least squares with a smoothness
|
||||
//! term. A loop that cannot be seen as the boundary of a height field over that plane (it
|
||||
//! crosses itself, folds back or runs along the plane's normal when seen along that normal) is
|
||||
//! refused, never filled with wrong geometry.
|
||||
//!
|
||||
//! References: Piegl & Tiller, *The NURBS Book* (2nd ed.) — §9.4.1 and §9.4.3 (least squares
|
||||
//! approximation: minimise the sum of squared residuals, eq. 9.62, through the normal
|
||||
//! equations, eq. 9.65), §3.4 (tensor product B-spline surfaces), §3.3 (derivative of a
|
||||
//! B-spline curve, eq. 3.4: control values at the knot averages reproduce a linear function),
|
||||
//! A2.1 (span search).
|
||||
|
||||
use crate::Vec3;
|
||||
use crate::bspline::{HPoint, NurbsSurface, Side, SplineError, SplineResult, find_span, invalid, local_basis_array};
|
||||
|
||||
/// Most loop points a fill takes.
|
||||
pub const MAX_LOOP_POINTS: usize = 8192;
|
||||
/// Most spans of the height field in each direction.
|
||||
pub const MAX_FILL_SPANS: usize = 32;
|
||||
/// Fewest spans of the height field in each direction (the first fit tried).
|
||||
const MIN_FILL_SPANS: usize = 4;
|
||||
/// The smoothness energy's terms: `(order in u, order in v, weight)` of `∫∫ (∂ h)²`.
|
||||
const ENERGY: [(usize, usize, f64); 3] = [(2, 0, 1.0), (1, 1, 2.0), (0, 2, 1.0)];
|
||||
/// Four-point Gauss–Legendre nodes and weights on `[−1, 1]` (exact to degree 7).
|
||||
const GAUSS4: [(f64, f64); 4] = [
|
||||
(-0.861_136_311_594_052_6, 0.347_854_845_137_453_8),
|
||||
(-0.339_981_043_584_856_3, 0.652_145_154_862_546_2),
|
||||
(0.339_981_043_584_856_3, 0.652_145_154_862_546_2),
|
||||
(0.861_136_311_594_052_6, 0.347_854_845_137_453_8),
|
||||
];
|
||||
/// The smoothness term's weight per fitted point (see [`fill_loop`]).
|
||||
const SMOOTHNESS: f64 = 1e-7;
|
||||
/// A loop step shorter than this fraction of its length in space, seen along the normal, runs
|
||||
/// along the normal (steeper than about 87° from the plane).
|
||||
const MIN_STEP_SLOPE: f64 = 0.05;
|
||||
/// Consecutive steps closer to reversed than this cosine fold the loop back on itself.
|
||||
const FOLD_COS: f64 = -0.999;
|
||||
|
||||
/// A loop's fill: the surface (`S(u, v) = o + u·x + v·y + h(u, v)·n` over the plane with normal
|
||||
/// `n`, so `∂S/∂u × ∂S/∂v` points along `n`), the plane's frame, whether the loop runs counter-
|
||||
/// clockwise about it, the largest distance from a sampled loop point to the surface along `n`,
|
||||
/// and how many points of the loop were evaluated.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LoopFill {
|
||||
pub surface: NurbsSurface,
|
||||
/// The plane's origin and its axes `x`, `y` (the surface's parameter directions).
|
||||
pub origin: Vec3,
|
||||
pub axes: [Vec3; 2],
|
||||
pub normal: Vec3,
|
||||
pub counter_clockwise: bool,
|
||||
pub deviation: f64,
|
||||
pub evaluations: usize,
|
||||
}
|
||||
|
||||
/// Fill the closed loop of `sides` curves (side `k` given by `point(k, t)`, `t` from 0 at its
|
||||
/// start to 1 at its end, which is where side `k+1` starts) with one surface passing within
|
||||
/// `tol` of the loop.
|
||||
///
|
||||
/// - Sampling: each side at `t = i/s` (fitted) and halfway between (checked), `s` from 16
|
||||
/// doubling while the fit needs denser points, at most [`MAX_LOOP_POINTS`] points in all. The
|
||||
/// sample parameters are the same run either way, so the fill does not depend on where the
|
||||
/// loop starts or which way it runs.
|
||||
/// - The plane: through the fitted points' centroid, normal by Newell's method, turned so that
|
||||
/// its largest component is positive; its first axis points to the fitted point farthest from
|
||||
/// the centroid in the plane (the highest of points as far). So the plane and its axes, and with them the grid and the fill,
|
||||
/// move with the loop (a reflected axis gives the same grid).
|
||||
/// - Refused ("not supported yet"): a loop that, seen along the normal, has a step much shorter
|
||||
/// than in space (it runs along the normal), turns straight back, or crosses or touches
|
||||
/// itself; such a loop does not bound a height field over the plane.
|
||||
/// - The height field: a bicubic B-spline with uniform knots over the projected loop's box
|
||||
/// (widened by 1%); its control points sit at the knot averages in the plane, so its surface
|
||||
/// parameters are plane coordinates, and their heights are the unknowns. The heights minimise
|
||||
/// `Σ (h(uₖ, vₖ) − hₖ)² + λ·A · ∫∫ h_uu² + 2·h_uv² + h_vv²` over the fitted points — least
|
||||
/// squares (eq. 9.62, normal equations eq. 9.65) plus the thin-plate bending energy over the
|
||||
/// box, `A` the area of one grid cell (so each cell's energy counts like a squared second
|
||||
/// difference of heights, and the balance is the same at any size). The energy decides the
|
||||
/// inside, where there are no points; it is zero only for planes, so a plane through the
|
||||
/// loop is reproduced exactly, and any other rim gets the least-bending surface on it, which
|
||||
/// is free to turn at the rim (it is not the surface that made the rim). It does not depend
|
||||
/// on the grid's directions. `λ = 10⁻⁷ ·` (fitted points) `/` (control points): just enough
|
||||
/// to fix the inside, small enough not to pull the surface off the loop.
|
||||
/// - Grids of 4, 8, 16, 32 spans across the box's longer side are tried, with as many across
|
||||
/// the shorter side as keep the cells about square; a grid whose cells are under twice the
|
||||
/// fitted points' largest step in a direction (cells between points would be left to the
|
||||
/// energy alone) waits for denser sampling. The first grid within `tol` of every sampled
|
||||
/// point (measured along the normal, which bounds the distance) is the fill; otherwise the
|
||||
/// sampling doubles, and when that runs out, "not supported yet" with the closest deviation
|
||||
/// reached.
|
||||
pub fn fill_loop(sides: usize, point: &mut dyn FnMut(usize, f64) -> Option<Vec3>, tol: f64) -> SplineResult<LoopFill> {
|
||||
if sides == 0 || sides.saturating_mul(16) > MAX_LOOP_POINTS / 2 {
|
||||
return Err(SplineError::TooLarge(format!("a loop of {sides} sides (at most {})", MAX_LOOP_POINTS / 32)));
|
||||
}
|
||||
if !(tol.is_finite() && tol > 0.0) {
|
||||
return invalid("the fill tolerance must be positive");
|
||||
}
|
||||
let mut evaluations = 0usize;
|
||||
let mut eval = |k: usize, t: f64| -> SplineResult<Vec3> {
|
||||
evaluations += 1;
|
||||
point(k, t).filter(|p| p.is_finite()).ok_or_else(|| SplineError::Invalid(format!("side {k} has no finite point at {t}")))
|
||||
};
|
||||
let mut per = 16usize;
|
||||
// Fitted points: side by side, per side at i/per (i < per).
|
||||
let mut fit: Vec<Vec3> = Vec::with_capacity(sides * per);
|
||||
for k in 0..sides {
|
||||
for i in 0..per {
|
||||
fit.push(eval(k, i as f64 / per as f64)?);
|
||||
}
|
||||
}
|
||||
let mut deviation = f64::INFINITY;
|
||||
loop {
|
||||
let mut check = Vec::with_capacity(fit.len());
|
||||
for k in 0..sides {
|
||||
for i in 0..per {
|
||||
check.push(eval(k, (i as f64 + 0.5) / per as f64)?);
|
||||
}
|
||||
}
|
||||
// The loop in order: fitted and checked points alternating.
|
||||
let all: Vec<Vec3> = fit.iter().zip(&check).flat_map(|(a, b)| [*a, *b]).collect();
|
||||
match fit_once(&fit, &all, tol)? {
|
||||
Fit::Done(mut fill) => {
|
||||
fill.evaluations = evaluations;
|
||||
return Ok(*fill);
|
||||
}
|
||||
Fit::Denser(dev) => deviation = deviation.min(dev),
|
||||
Fit::Exhausted(dev) => {
|
||||
deviation = deviation.min(dev);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if all.len() * 2 > MAX_LOOP_POINTS {
|
||||
break;
|
||||
}
|
||||
fit = all;
|
||||
per *= 2;
|
||||
}
|
||||
Err(SplineError::Unsupported(format!("filling a loop this wavy with one surface (it stays {deviation:e} off the loop, tolerance {tol:e})")))
|
||||
}
|
||||
|
||||
/// What one round of [`fill_loop`] gave.
|
||||
enum Fit {
|
||||
Done(Box<LoopFill>),
|
||||
/// Within tolerance only with cells finer than the points allow: sample denser.
|
||||
Denser(f64),
|
||||
/// The finest grid tried is not within tolerance.
|
||||
Exhausted(f64),
|
||||
}
|
||||
|
||||
/// One round of [`fill_loop`]: fit the points `fit`, check every point of `all` (the loop in
|
||||
/// order, `fit` among them).
|
||||
fn fit_once(fit: &[Vec3], all: &[Vec3], tol: f64) -> SplineResult<Fit> {
|
||||
let m = fit.len();
|
||||
let o = fit.iter().fold(Vec3::ZERO, |a, p| a + *p) / m as f64;
|
||||
let at = |k: usize| fit.get(k % m).copied().unwrap_or(Vec3::ZERO);
|
||||
let area = (0..m).fold(Vec3::ZERO, |a, k| a + (at(k) - o).cross(at(k + 1) - o));
|
||||
let Some(normal) = area.normalized() else { return invalid("the loop encloses no area") };
|
||||
let largest = [normal.x, normal.y, normal.z].into_iter().fold(0.0f64, |a, c| if c.abs() > a.abs() { c } else { a });
|
||||
let counter_clockwise = largest > 0.0;
|
||||
let n = if counter_clockwise { normal } else { normal * -1.0 };
|
||||
// The first axis towards the fitted point farthest from the centroid in the plane: the axes,
|
||||
// the grid and so the fill move with the loop.
|
||||
let flat = |p: &Vec3| {
|
||||
let d = *p - o;
|
||||
d - n * d.dot(n)
|
||||
};
|
||||
// Points as far as each other (within rounding) are told apart by their height, and points
|
||||
// alike in both by their coordinates: the same point is chosen however the loop is given,
|
||||
// and as the loop moves, but for ties its symmetry makes.
|
||||
let key = |p: &Vec3| (flat(p).len(), (*p - o).dot(n));
|
||||
let reach = fit.iter().map(|p| key(p).0).fold(0.0, f64::max);
|
||||
let near = 1e-9 * reach;
|
||||
let far = fit.iter().fold(None::<&Vec3>, |best, p| match best {
|
||||
None => Some(p),
|
||||
Some(b) => {
|
||||
let ((db, hb), (dp, hp)) = (key(b), key(p));
|
||||
let better = if (dp - db).abs() > near {
|
||||
dp > db
|
||||
} else if (hp - hb).abs() > near {
|
||||
hp > hb
|
||||
} else {
|
||||
(p.x, p.y, p.z) > (b.x, b.y, b.z)
|
||||
};
|
||||
Some(if better { p } else { b })
|
||||
}
|
||||
});
|
||||
let far = far.map(flat).unwrap_or(Vec3::ZERO);
|
||||
let x = far.normalized().ok_or_else(|| SplineError::Invalid("the loop has no extent in its plane".into()))?;
|
||||
let y = n.cross(x);
|
||||
let project = |pts: &[Vec3]| -> (Vec<[f64; 2]>, Vec<f64>) {
|
||||
(pts.iter().map(|p| [(*p - o).dot(x), (*p - o).dot(y)]).collect(), pts.iter().map(|p| (*p - o).dot(n)).collect())
|
||||
};
|
||||
let (uv, h) = project(all);
|
||||
check_projectable(all, &uv)?;
|
||||
let (fuv, fh) = project(fit);
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
|
||||
for q in &uv {
|
||||
for d in 0..2 {
|
||||
lo[d] = lo[d].min(q[d]);
|
||||
hi[d] = hi[d].max(q[d]);
|
||||
}
|
||||
}
|
||||
for d in 0..2 {
|
||||
let pad = 0.01 * (hi[d] - lo[d]);
|
||||
lo[d] -= pad;
|
||||
hi[d] += pad;
|
||||
}
|
||||
if !(lo[0] < hi[0] && lo[1] < hi[1] && (hi[0] - lo[0]).is_finite() && (hi[1] - lo[1]).is_finite()) {
|
||||
return invalid("the loop's extent is not finite");
|
||||
}
|
||||
// Spans per direction: `level` across the longer side of the box, as many across the
|
||||
// shorter as keep the cells about square (at least one). A grid finer than the fitted
|
||||
// points can hold in a direction (cells under twice their largest step along it) is tried
|
||||
// only after denser sampling; the coarsest is always tried.
|
||||
let extent = [hi[0] - lo[0], hi[1] - lo[1]];
|
||||
let mut step = [0.0f64; 2];
|
||||
for k in 0..m {
|
||||
let (p, q) = (fuv.get(k).copied().unwrap_or([0.0; 2]), fuv.get((k + 1) % m).copied().unwrap_or([0.0; 2]));
|
||||
for d in 0..2 {
|
||||
step[d] = step[d].max((q[d] - p[d]).abs());
|
||||
}
|
||||
}
|
||||
let longer = extent[0].max(extent[1]);
|
||||
let mut deviation = f64::INFINITY;
|
||||
let mut level = MIN_FILL_SPANS;
|
||||
while level <= MAX_FILL_SPANS {
|
||||
let spans = [0, 1].map(|d| ((extent[d] / longer * level as f64).ceil() as usize).clamp(1, level));
|
||||
let holds = (0..2).all(|d| extent[d] / spans[d] as f64 >= 2.0 * step[d]);
|
||||
if !holds && level > MIN_FILL_SPANS {
|
||||
return Ok(Fit::Denser(deviation));
|
||||
}
|
||||
let grid = Grid::new(spans, lo, hi);
|
||||
let heights = grid.fit(&fuv, &fh)?;
|
||||
let dev = uv.iter().zip(&h).map(|(q, hk)| (grid.height(&heights, *q) - hk).abs()).fold(0.0, f64::max);
|
||||
if !dev.is_finite() {
|
||||
return invalid("the fit is not finite");
|
||||
}
|
||||
deviation = deviation.min(dev);
|
||||
if dev <= tol && holds {
|
||||
let surface = grid.surface(&heights, o, x, y, n)?;
|
||||
let fill = LoopFill { surface, origin: o, axes: [x, y], normal: n, counter_clockwise, deviation: dev, evaluations: 0 };
|
||||
return Ok(Fit::Done(Box::new(fill)));
|
||||
}
|
||||
if !holds {
|
||||
return Ok(Fit::Denser(deviation));
|
||||
}
|
||||
level *= 2;
|
||||
}
|
||||
Ok(Fit::Exhausted(deviation))
|
||||
}
|
||||
|
||||
/// Refuse a loop that does not bound a height field over its plane (see [`fill_loop`]).
|
||||
fn check_projectable(points: &[Vec3], uv: &[[f64; 2]]) -> SplineResult<()> {
|
||||
let m = uv.len();
|
||||
let q = |k: usize| uv.get(k % m).copied().unwrap_or([0.0; 2]);
|
||||
let p = |k: usize| points.get(k % m).copied().unwrap_or(Vec3::ZERO);
|
||||
let step = |k: usize| {
|
||||
let (a, b) = (q(k), q(k + 1));
|
||||
[b[0] - a[0], b[1] - a[1]]
|
||||
};
|
||||
for k in 0..m {
|
||||
let d = step(k);
|
||||
let flat = d[0].hypot(d[1]);
|
||||
let space = p(k).dist(p(k + 1));
|
||||
if space > 0.0 && flat < MIN_STEP_SLOPE * space {
|
||||
return Err(SplineError::Unsupported("filling a loop that runs along its plane's normal".into()));
|
||||
}
|
||||
let e = step(k + 1);
|
||||
let fe = e[0].hypot(e[1]);
|
||||
if flat > 0.0 && fe > 0.0 && (d[0] * e[0] + d[1] * e[1]) < FOLD_COS * flat * fe {
|
||||
return Err(SplineError::Unsupported("filling a loop that folds back on itself".into()));
|
||||
}
|
||||
}
|
||||
// Non-adjacent segments must not meet: a sweep over the segments' u extents.
|
||||
let mut segs: Vec<(f64, f64, usize)> = (0..m)
|
||||
.map(|k| {
|
||||
let (a, b) = (q(k), q(k + 1));
|
||||
(a[0].min(b[0]), a[0].max(b[0]), k)
|
||||
})
|
||||
.collect();
|
||||
segs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
for (i, &(_, hi, k)) in segs.iter().enumerate() {
|
||||
for &(lo2, _, l) in segs.get(i + 1..).into_iter().flatten() {
|
||||
if lo2 > hi {
|
||||
break;
|
||||
}
|
||||
let adjacent = (k + 1) % m == l || (l + 1) % m == k;
|
||||
if !adjacent && segments_meet(q(k), q(k + 1), q(l), q(l + 1)) {
|
||||
return Err(SplineError::Unsupported("filling a loop that crosses itself seen along its plane's normal".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the closed segments `ab` and `cd` share a point.
|
||||
fn segments_meet(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
|
||||
let orient = |p: [f64; 2], q: [f64; 2], r: [f64; 2]| (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
|
||||
let within =
|
||||
|p: [f64; 2], q: [f64; 2], r: [f64; 2]| r[0] >= p[0].min(q[0]) && r[0] <= p[0].max(q[0]) && r[1] >= p[1].min(q[1]) && r[1] <= p[1].max(q[1]);
|
||||
let (d1, d2, d3, d4) = (orient(c, d, a), orient(c, d, b), orient(a, b, c), orient(a, b, d));
|
||||
if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) {
|
||||
return true;
|
||||
}
|
||||
(d1 == 0.0 && within(c, d, a)) || (d2 == 0.0 && within(c, d, b)) || (d3 == 0.0 && within(a, b, c)) || (d4 == 0.0 && within(a, b, d))
|
||||
}
|
||||
|
||||
/// A bicubic height field's grid: uniform clamped knots over `[lo, hi]` in each direction,
|
||||
/// `count` control points along each.
|
||||
struct Grid {
|
||||
knots: [Vec<f64>; 2],
|
||||
count: [usize; 2],
|
||||
}
|
||||
|
||||
impl LoopFill {
|
||||
/// The surface's point over (or under) `p`, along the normal.
|
||||
pub fn point_over(&self, p: Vec3) -> SplineResult<Vec3> {
|
||||
let d = p - self.origin;
|
||||
let [x, y] = self.axes;
|
||||
self.surface.eval(d.dot(x), d.dot(y))
|
||||
}
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
fn new(spans: [usize; 2], lo: [f64; 2], hi: [f64; 2]) -> Grid {
|
||||
let knots = [0, 1].map(|d| {
|
||||
let (a, b, s) = (lo[d], hi[d], spans[d]);
|
||||
let mut k = vec![a; 4];
|
||||
k.extend((1..s).map(|i| a + (b - a) * i as f64 / s as f64));
|
||||
k.extend([b; 4]);
|
||||
k
|
||||
});
|
||||
Grid { knots, count: spans.map(|s| s + 3) }
|
||||
}
|
||||
|
||||
/// The length of one span in direction `d`.
|
||||
fn cell(&self, d: usize) -> f64 {
|
||||
let (Some(k), Some(&c)) = (self.knots.get(d), self.count.get(d)) else { return 0.0 };
|
||||
(k.last().copied().unwrap_or(0.0) - k.first().copied().unwrap_or(0.0)) / (c - 3) as f64
|
||||
}
|
||||
|
||||
/// The knot average `(kᵢ₊₁ + kᵢ₊₂ + kᵢ₊₃)/3` of control point `i` in direction `d`.
|
||||
fn average(&self, d: usize, i: usize) -> f64 {
|
||||
self.knots.get(d).map_or(0.0, |k| (1..=3).map(|t| k.get(i + t).copied().unwrap_or(0.0)).sum::<f64>() / 3.0)
|
||||
}
|
||||
|
||||
/// The four non-zero basis functions' `r`-th derivatives and the first index, at `x` in
|
||||
/// direction `d`.
|
||||
fn basis_derivative(&self, d: usize, x: f64, r: usize) -> Option<(usize, [f64; 4])> {
|
||||
let kn = self.knots.get(d)?;
|
||||
let x = x.clamp(*kn.first()?, *kn.last()?);
|
||||
let span = find_span(kn, 3, *self.count.get(d)?, x)?;
|
||||
let side = if x >= *kn.last()? { Side::Left } else { Side::Right };
|
||||
let first = span.checked_sub(3)?;
|
||||
let mut out = [0.0; 4];
|
||||
for (a, o) in out.iter_mut().enumerate() {
|
||||
*o = kn.get(first + a..first + a + 5).map_or(0.0, |l| local_basis_array(l, x, side, r).get(r).copied().unwrap_or(0.0));
|
||||
}
|
||||
Some((first, out))
|
||||
}
|
||||
|
||||
/// The height at `q` of the net `heights` (i fastest).
|
||||
fn height(&self, heights: &[f64], q: [f64; 2]) -> f64 {
|
||||
let (Some((i0, bu)), Some((j0, bv))) = (self.basis_derivative(0, q[0], 0), self.basis_derivative(1, q[1], 0)) else { return f64::NAN };
|
||||
let mut s = 0.0;
|
||||
for (b, wv) in bv.iter().enumerate() {
|
||||
for (a, wu) in bu.iter().enumerate() {
|
||||
s += wu * wv * heights.get((j0 + b) * self.count[0] + i0 + a).copied().unwrap_or(f64::NAN);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The control heights fitting the points (see [`fill_loop`]): the normal equations, banded
|
||||
/// (unknowns i fastest, so coupled unknowns are at most `3·count[0] + 3` apart), solved by
|
||||
/// Cholesky factorisation.
|
||||
fn fit(&self, uv: &[[f64; 2]], h: &[f64]) -> SplineResult<Vec<f64>> {
|
||||
let [cu, cv] = self.count;
|
||||
let n = cu * cv;
|
||||
let mut band = Band::new(n, 3 * cu + 3);
|
||||
let mut rhs = vec![0.0; n];
|
||||
let mut fitted = 0usize;
|
||||
for (q, hk) in uv.iter().zip(h) {
|
||||
let (Some((i0, bu)), Some((j0, bv))) = (self.basis_derivative(0, q[0], 0), self.basis_derivative(1, q[1], 0)) else {
|
||||
return invalid("a loop point is off the grid");
|
||||
};
|
||||
let mut terms = [(0usize, 0.0f64); 16];
|
||||
for (t, slot) in terms.iter_mut().enumerate() {
|
||||
let (a, b) = (t % 4, t / 4);
|
||||
*slot = ((j0 + b) * cu + i0 + a, bu.get(a).copied().unwrap_or(0.0) * bv.get(b).copied().unwrap_or(0.0));
|
||||
}
|
||||
for &(r, wr) in &terms {
|
||||
if let Some(slot) = rhs.get_mut(r) {
|
||||
*slot += wr * hk;
|
||||
}
|
||||
for &(s, ws) in &terms {
|
||||
band.add(r, s, wr * ws);
|
||||
}
|
||||
}
|
||||
fitted += 1;
|
||||
}
|
||||
// The thin-plate energy ∫∫ h_uu² + 2·h_uv² + h_vv² over the grid's box, times the area of
|
||||
// one cell: per cell that is the squared second difference of the heights, so it weighs
|
||||
// against the data term (squared heights) alike at any size. Each term is a product of
|
||||
// one-direction Gram matrices of the basis functions' derivatives.
|
||||
let lambda = SMOOTHNESS * fitted as f64 / n as f64 * self.cell(0) * self.cell(1);
|
||||
let gram = |d: usize, r: usize| -> Vec<f64> {
|
||||
let c = self.count.get(d).copied().unwrap_or(0);
|
||||
let mut g = vec![0.0; c * c];
|
||||
let Some(k) = self.knots.get(d) else { return g };
|
||||
for w in k.windows(2) {
|
||||
let &[a, b] = w else { continue };
|
||||
if !(b > a) {
|
||||
continue;
|
||||
}
|
||||
for (gx, gw) in GAUSS4 {
|
||||
let x = 0.5 * (a + b) + 0.5 * (b - a) * gx;
|
||||
let Some((first, vals)) = self.basis_derivative(d, x, r) else { continue };
|
||||
for (i, vi) in vals.iter().enumerate() {
|
||||
for (j, vj) in vals.iter().enumerate() {
|
||||
if let Some(slot) = g.get_mut((first + i) * c + first + j) {
|
||||
*slot += 0.5 * (b - a) * gw * vi * vj;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
g
|
||||
};
|
||||
let grams: [[Vec<f64>; 3]; 2] = [0, 1].map(|d| [0, 1, 2].map(|r| gram(d, r)));
|
||||
let g = |d: usize, r: usize, i: usize, k: usize| {
|
||||
let c = self.count.get(d).copied().unwrap_or(0);
|
||||
grams.get(d).and_then(|m| m.get(r)).and_then(|m| m.get(i * c + k)).copied().unwrap_or(0.0)
|
||||
};
|
||||
for j in 0..cv {
|
||||
for i in 0..cu {
|
||||
let r = j * cu + i;
|
||||
for l in j.saturating_sub(3)..(j + 4).min(cv) {
|
||||
for k in i.saturating_sub(3)..(i + 4).min(cu) {
|
||||
let e: f64 = ENERGY.iter().map(|&(ru, rv, w)| w * g(0, ru, i, k) * g(1, rv, j, l)).sum();
|
||||
band.add(r, l * cu + k, lambda * e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
band.solve(rhs)
|
||||
}
|
||||
|
||||
/// The surface of the net `heights` over the plane `o`, `x`, `y`, `n`: control point `(i, j)`
|
||||
/// at the knot averages `(ξᵢ, ηⱼ)` in the plane, `heights[j·count[0] + i]` along `n`.
|
||||
fn surface(&self, heights: &[f64], o: Vec3, x: Vec3, y: Vec3, n: Vec3) -> SplineResult<NurbsSurface> {
|
||||
let [cu, cv] = self.count;
|
||||
let mut ctrl = Vec::with_capacity(cu * cv);
|
||||
for j in 0..cv {
|
||||
for i in 0..cu {
|
||||
let hij = heights.get(j * cu + i).copied().unwrap_or(f64::NAN);
|
||||
ctrl.push(HPoint::weighted(o + x * self.average(0, i) + y * self.average(1, j) + n * hij, 1.0));
|
||||
}
|
||||
}
|
||||
NurbsSurface::from_homogeneous([3, 3], self.knots.clone(), ctrl)
|
||||
}
|
||||
}
|
||||
|
||||
/// A symmetric banded matrix: the upper band, row `r` holding columns `r ..= r + bw`.
|
||||
struct Band {
|
||||
n: usize,
|
||||
bw: usize,
|
||||
a: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Band {
|
||||
fn new(n: usize, bw: usize) -> Band {
|
||||
Band { n, bw, a: vec![0.0; n * (bw + 1)] }
|
||||
}
|
||||
fn index(&self, r: usize, s: usize) -> Option<usize> {
|
||||
let (r, s) = if r <= s { (r, s) } else { (s, r) };
|
||||
(s - r <= self.bw && s < self.n).then_some(r * (self.bw + 1) + (s - r))
|
||||
}
|
||||
fn get(&self, r: usize, s: usize) -> f64 {
|
||||
self.index(r, s).and_then(|i| self.a.get(i)).copied().unwrap_or(0.0)
|
||||
}
|
||||
fn set(&mut self, r: usize, s: usize, v: f64) {
|
||||
if let Some(x) = self.index(r, s).and_then(|i| self.a.get_mut(i)) {
|
||||
*x = v;
|
||||
}
|
||||
}
|
||||
/// Add `v` at `(r, s)` with `r ≤ s` (the lower half is the same by symmetry: callers adding
|
||||
/// a full outer product reach each stored entry once through `r ≤ s`).
|
||||
fn add(&mut self, r: usize, s: usize, v: f64) {
|
||||
if r <= s
|
||||
&& let Some(x) = self.index(r, s).and_then(|i| self.a.get_mut(i))
|
||||
{
|
||||
*x += v;
|
||||
}
|
||||
}
|
||||
/// Solve `A·x = b` by banded Cholesky factorisation `A = Uᵀ·U`; an error when `A` is not
|
||||
/// positive definite.
|
||||
fn solve(mut self, mut b: Vec<f64>) -> SplineResult<Vec<f64>> {
|
||||
let (n, bw) = (self.n, self.bw);
|
||||
for r in 0..n {
|
||||
let mut d = self.get(r, r);
|
||||
for k in r.saturating_sub(bw)..r {
|
||||
let u = self.get(k, r);
|
||||
d -= u * u;
|
||||
}
|
||||
if !(d > 0.0) {
|
||||
return invalid("the fit's equations are singular");
|
||||
}
|
||||
let d = d.sqrt();
|
||||
self.set(r, r, d);
|
||||
for s in r + 1..(r + bw + 1).min(n) {
|
||||
let mut v = self.get(r, s);
|
||||
for k in s.saturating_sub(bw)..r {
|
||||
v -= self.get(k, r) * self.get(k, s);
|
||||
}
|
||||
self.set(r, s, v / d);
|
||||
}
|
||||
}
|
||||
// Uᵀ·y = b, then U·x = y.
|
||||
for r in 0..n {
|
||||
let mut v = b.get(r).copied().unwrap_or(0.0);
|
||||
for k in r.saturating_sub(bw)..r {
|
||||
v -= self.get(k, r) * b.get(k).copied().unwrap_or(0.0);
|
||||
}
|
||||
if let Some(x) = b.get_mut(r) {
|
||||
*x = v / self.get(r, r);
|
||||
}
|
||||
}
|
||||
for r in (0..n).rev() {
|
||||
let mut v = b.get(r).copied().unwrap_or(0.0);
|
||||
for s in r + 1..(r + bw + 1).min(n) {
|
||||
v -= self.get(r, s) * b.get(s).copied().unwrap_or(0.0);
|
||||
}
|
||||
if let Some(x) = b.get_mut(r) {
|
||||
*x = v / self.get(r, r);
|
||||
}
|
||||
}
|
||||
if b.iter().any(|x| !x.is_finite()) {
|
||||
return invalid("the fit is not finite");
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The closed polygon through `corners` in space, side `k` from corner `k` to `k+1`.
|
||||
fn polygon3(corners: &[Vec3]) -> impl FnMut(usize, f64) -> Option<Vec3> + '_ {
|
||||
move |k, t| {
|
||||
let n = corners.len();
|
||||
Some(corners.get(k)?.lerp(*corners.get((k + 1) % n)?, t))
|
||||
}
|
||||
}
|
||||
|
||||
/// The polygon `corners` in the plane, lifted by `z(x, y)` all along.
|
||||
fn lifted<'a>(corners: &'a [[f64; 2]], z: &'a dyn Fn(f64, f64) -> f64) -> impl FnMut(usize, f64) -> Option<Vec3> + 'a {
|
||||
move |k, t| {
|
||||
let n = corners.len();
|
||||
let (a, b) = (corners.get(k)?, corners.get((k + 1) % n)?);
|
||||
let (x, y) = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t);
|
||||
Some(Vec3::new(x, y, z(x, y)))
|
||||
}
|
||||
}
|
||||
|
||||
fn ring(n: usize, r: f64) -> Vec<[f64; 2]> {
|
||||
(0..n).map(|k| k as f64 * std::f64::consts::TAU / n as f64).map(|a| [r * a.cos(), r * a.sin()]).collect()
|
||||
}
|
||||
|
||||
/// The farthest the surface is from `z` over the polygon's inside (sampled on a grid).
|
||||
fn off_inside(f: &LoopFill, corners: &[[f64; 2]], z: &dyn Fn(f64, f64) -> f64) -> f64 {
|
||||
let inside = |x: f64, y: f64| {
|
||||
let n = corners.len();
|
||||
let mut w = false;
|
||||
for k in 0..n {
|
||||
let (a, b) = (corners[k], corners[(k + 1) % n]);
|
||||
if (a[1] > y) != (b[1] > y) && x < a[0] + (y - a[1]) / (b[1] - a[1]) * (b[0] - a[0]) {
|
||||
w = !w;
|
||||
}
|
||||
}
|
||||
w
|
||||
};
|
||||
let s = &f.surface;
|
||||
let [du, dv] = s.domain();
|
||||
let mut worst = 0.0f64;
|
||||
for i in 0..=40 {
|
||||
for j in 0..=40 {
|
||||
let (u, v) = ((du[0] + (du[1] - du[0]) * i as f64 / 40.0).min(du[1]), (dv[0] + (dv[1] - dv[0]) * j as f64 / 40.0).min(dv[1]));
|
||||
let p = s.eval(u, v).unwrap();
|
||||
if inside(p.x, p.y) {
|
||||
worst = worst.max((p.z - z(p.x, p.y)).abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planes_are_reproduced_and_rims_of_quadrics_are_filled_closely() {
|
||||
// A tilted plane over a hexagon: the smoothness energy vanishes on it, so the fill is it.
|
||||
let hex = ring(6, 10.0);
|
||||
let plane = |x: f64, y: f64| 0.3 * x - 0.2 * y + 1.0;
|
||||
let f = fill_loop(6, &mut lifted(&hex, &plane), 1e-6).unwrap();
|
||||
let inside = off_inside(&f, &hex, &plane);
|
||||
eprintln!("plane: deviation {:e}, inside off by {inside:e}, {} evaluations", f.deviation, f.evaluations);
|
||||
assert!(f.deviation <= 1e-6 && inside < 1e-9);
|
||||
// A saddle and a dome: the least-bending fill through the rim is not the quadric (only
|
||||
// planes are reproduced), but stays near it (the plate is free to turn at the rim).
|
||||
let saddle = |x: f64, y: f64| 0.05 * x * y;
|
||||
let dome = |x: f64, y: f64| 3.0 - 0.02 * (x * x + y * y);
|
||||
for (name, z, bound) in [("saddle", &saddle as &dyn Fn(f64, f64) -> f64, 0.1), ("dome", &dome, 1.0)] {
|
||||
let f = fill_loop(6, &mut lifted(&hex, z), 2e-5).unwrap();
|
||||
let inside = off_inside(&f, &hex, z);
|
||||
eprintln!("{name}: deviation {:e}, inside off by {inside:e}", f.deviation);
|
||||
assert!(f.deviation <= 2e-5 && inside < bound, "{name}: {inside}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concave_loops_fill_and_the_result_does_not_depend_on_where_the_loop_starts() {
|
||||
// A dart, an arrow-head pentagon and a plus, on a cylinder.
|
||||
let z = |x: f64, _: f64| 50.0 - (1600.0 - x * x).sqrt();
|
||||
let dart = vec![[0.0, 0.0], [20.0, -10.0], [8.0, 0.0], [20.0, 10.0]];
|
||||
let arrow = vec![[0.0, 0.0], [10.0, -8.0], [10.0, -3.0], [20.0, 0.0], [10.0, 3.0]];
|
||||
let plus = vec![
|
||||
[-3.0, -9.0],
|
||||
[3.0, -9.0],
|
||||
[3.0, -3.0],
|
||||
[9.0, -3.0],
|
||||
[9.0, 3.0],
|
||||
[3.0, 3.0],
|
||||
[3.0, 9.0],
|
||||
[-3.0, 9.0],
|
||||
[-3.0, 3.0],
|
||||
[-9.0, 3.0],
|
||||
[-9.0, -3.0],
|
||||
[-3.0, -3.0],
|
||||
];
|
||||
let flat_corner = vec![[-10.0, -10.0], [0.0, -10.0], [10.0, -10.0], [10.0, 10.0], [-10.0, 10.0]];
|
||||
for (name, c) in [("dart", dart), ("arrow", arrow), ("plus", plus), ("flat corner", flat_corner)] {
|
||||
let n = c.len();
|
||||
let f = fill_loop(n, &mut lifted(&c, &z), 1e-4).unwrap();
|
||||
let inside = off_inside(&f, &c, &z);
|
||||
eprintln!("{name}: deviation {:e}, inside off the cylinder by {inside:e}, {} evaluations", f.deviation, f.evaluations);
|
||||
// The least-bending fill, not the cylinder: within a few tenths of a millimetre of it.
|
||||
assert!(f.deviation <= 1e-4 && inside < 0.2, "{name}: {inside}");
|
||||
// The same loop from another corner and the other way round.
|
||||
let mut back = c.clone();
|
||||
back.rotate_left(2);
|
||||
back.reverse();
|
||||
let g = fill_loop(n, &mut lifted(&back, &z), 1e-4).unwrap();
|
||||
assert_ne!(f.counter_clockwise, g.counter_clockwise);
|
||||
for (x, y) in [(4.0, 0.0), (6.0, 1.0), (5.0, -1.0)] {
|
||||
let p = Vec3::new(x, y, z(x, y));
|
||||
let gap = f.point_over(p).unwrap().dist(g.point_over(p).unwrap());
|
||||
assert!(gap < 1e-7, "{name}: {gap}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fill_is_the_same_at_any_size_and_place() {
|
||||
// The arrow on the cylinder, scaled by 100 and by 1/100, turned and moved: the same fill,
|
||||
// scaled and moved alike.
|
||||
let z = |x: f64, _: f64| 50.0 - (1600.0 - x * x).sqrt();
|
||||
let arrow = [[0.0, 0.0], [10.0, -8.0], [10.0, -3.0], [20.0, 0.0], [10.0, 3.0]];
|
||||
let f = fill_loop(5, &mut lifted(&arrow, &z), 1e-4).unwrap();
|
||||
let axis = Vec3::new(1.0, -2.0, 0.5).normalized().unwrap();
|
||||
let shift = Vec3::new(30.0, -12.0, 7.0);
|
||||
for scale in [100.0, 0.01] {
|
||||
let place = |p: Vec3| {
|
||||
// Scaled, turned by 0.9 about `axis` (Rodrigues), moved.
|
||||
let p = p * scale;
|
||||
let (c, s) = (0.9f64.cos(), 0.9f64.sin());
|
||||
p * c + axis.cross(p) * s + axis * (axis.dot(p) * (1.0 - c)) + shift * scale
|
||||
};
|
||||
let mut moved = |k: usize, t: f64| lifted(&arrow, &z)(k, t).map(place);
|
||||
let g = fill_loop(5, &mut moved, 1e-4 * scale).unwrap();
|
||||
let mut worst = 0.0f64;
|
||||
for (x, y) in [(4.0, 0.0), (6.0, 1.0), (5.0, -1.0), (9.0, -2.0), (12.0, 0.5)] {
|
||||
let p = Vec3::new(x, y, z(x, y));
|
||||
worst = worst.max(g.point_over(place(p)).unwrap().dist(place(f.point_over(p).unwrap())) / scale);
|
||||
}
|
||||
eprintln!("arrow scaled by {scale}, turned and moved: {} evaluations, surfaces apart by {worst:e} (relative)", g.evaluations);
|
||||
assert!(worst < 1e-7, "scale {scale}: {worst}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_narrow_slot_fills() {
|
||||
// A 200 × 1 mm slot on a cylinder of radius 300 running along it.
|
||||
let z = |x: f64, _: f64| 300.0 - (90_000.0 - (x - 100.0) * (x - 100.0)).sqrt();
|
||||
let slot = [[0.0, 0.0], [200.0, 0.0], [200.0, 1.0], [0.0, 1.0]];
|
||||
let f = fill_loop(4, &mut lifted(&slot, &z), 2e-4).unwrap();
|
||||
let inside = off_inside(&f, &slot, &z);
|
||||
eprintln!("slot: deviation {:e}, inside off the cylinder by {inside:e}, {} evaluations", f.deviation, f.evaluations);
|
||||
assert!(f.deviation <= 2e-4 && inside < 1e-2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loops_that_do_not_bound_a_height_field_are_refused() {
|
||||
let unsupported = |r: SplineResult<LoopFill>| matches!(r, Err(SplineError::Unsupported(_)));
|
||||
let flat = |_: f64, _: f64| 0.0;
|
||||
// A figure eight (lobes of different sizes).
|
||||
let eight = [[0.0, 0.0], [10.0, 10.0], [10.0, 0.0], [0.0, 6.0]];
|
||||
assert!(unsupported(fill_loop(4, &mut lifted(&eight, &flat), 1e-4)));
|
||||
// A loop with a vertical spike in it (up along the normal and down again).
|
||||
let spike = [
|
||||
Vec3::ZERO,
|
||||
Vec3::new(10.0, 0.0, 0.0),
|
||||
Vec3::new(10.0, 10.0, 0.0),
|
||||
Vec3::new(5.0, 10.0, 0.0),
|
||||
Vec3::new(5.0, 10.0, 3.0),
|
||||
Vec3::new(5.0, 10.0, 0.0),
|
||||
Vec3::new(0.0, 10.0, 0.0),
|
||||
];
|
||||
assert!(unsupported(fill_loop(7, &mut polygon3(&spike), 1e-4)));
|
||||
// A loop that runs back over itself.
|
||||
let back = [[0.0, 0.0], [10.0, 0.0], [4.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
|
||||
assert!(unsupported(fill_loop(5, &mut lifted(&back, &flat), 1e-4)));
|
||||
// A loop too wavy for one surface.
|
||||
let wavy = |x: f64, y: f64| (x * 9.0).sin() * (y * 7.0).cos();
|
||||
assert!(unsupported(fill_loop(64, &mut lifted(&ring(64, 10.0), &wavy), 1e-6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_loops_are_refused() {
|
||||
let flat = |_: f64, _: f64| 0.0;
|
||||
let five = ring(5, 10.0);
|
||||
assert!(fill_loop(0, &mut lifted(&five, &flat), 1e-4).is_err());
|
||||
assert!(fill_loop(5, &mut |_, _| Some(Vec3::X), 1e-4).is_err(), "no area");
|
||||
assert!(fill_loop(5, &mut |_, _| None, 1e-4).is_err());
|
||||
assert!(fill_loop(5, &mut |k, t| Some(Vec3::new(k as f64, t, f64::NAN)), 1e-4).is_err());
|
||||
assert!(fill_loop(5, &mut lifted(&five, &flat), 0.0).is_err() && fill_loop(5, &mut lifted(&five, &flat), f64::NAN).is_err());
|
||||
assert!(fill_loop(10_000, &mut lifted(&five, &flat), 1e-4).is_err());
|
||||
let huge = |x: f64, y: f64| (x * y) * 1e300;
|
||||
let _ = fill_loop(5, &mut lifted(&five, &huge), 1e-4);
|
||||
let tiny: Vec<[f64; 2]> = five.iter().map(|p| [p[0] * 1e-300, p[1] * 1e-300]).collect();
|
||||
let _ = fill_loop(5, &mut lifted(&tiny, &flat), 1e-4);
|
||||
let big: Vec<[f64; 2]> = five.iter().map(|p| [p[0] * 1e300, p[1] * 1e300]).collect();
|
||||
let _ = fill_loop(5, &mut lifted(&big, &flat), 1e-4);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
//! Geometry foundation for SolidCraft: 2D/3D vectors, sketch planes, axis-aligned boxes,
|
||||
//! triangle meshes with their measures (volume, area, centroid), and freeform surface maths
|
||||
//! (B-spline basis and NURBS surfaces in [`bspline`], T-splines in [`tspline`]). Units are
|
||||
//! millimetres.
|
||||
//! (B-spline basis and NURBS surfaces in [`bspline`], one-surface fills of closed loops in
|
||||
//! [`fill`], T-splines in [`tspline`]). Units are millimetres.
|
||||
//!
|
||||
//! This crate has no knowledge of sketches, documents or the B-rep kernel; every other crate
|
||||
//! This crate has no knowledge of sketches, documents or a modelling backend; every other crate
|
||||
//! builds on it.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod brep;
|
||||
pub mod bspline;
|
||||
pub mod env;
|
||||
pub mod fill;
|
||||
mod mesh;
|
||||
mod plane;
|
||||
mod profile;
|
||||
|
||||
@@ -76,6 +76,40 @@ pub struct Mesh {
|
||||
pub edge_faces: Vec<Vec<u32>>,
|
||||
}
|
||||
|
||||
fn boxes_meet(a: &Aabb3, b: &Aabb3) -> bool {
|
||||
a.min.x <= b.max.x && b.min.x <= a.max.x && a.min.y <= b.max.y && b.min.y <= a.max.y && a.min.z <= b.max.z && b.min.z <= a.max.z
|
||||
}
|
||||
|
||||
/// Two triangles that pass through each other (see [`Mesh::crosses`]).
|
||||
fn triangles_cross(t: &[Vec3; 3], u: &[Vec3; 3], tol: f64) -> bool {
|
||||
// Signed distances of one triangle's corners from the other's plane, when they lie more
|
||||
// than `tol` to both sides of it.
|
||||
let sides = |tri: &[Vec3; 3], plane: &[Vec3; 3]| -> Option<(Vec3, [f64; 3])> {
|
||||
let n = (plane[1] - plane[0]).cross(plane[2] - plane[0]).normalized()?;
|
||||
let d = [n.dot(tri[0] - plane[0]), n.dot(tri[1] - plane[0]), n.dot(tri[2] - plane[0])];
|
||||
let (lo, hi) = d.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
|
||||
(lo < -tol && hi > tol).then_some((n, d))
|
||||
};
|
||||
let (Some((nu, dt)), Some((nt, du))) = (sides(t, u), sides(u, t)) else { return false };
|
||||
let Some(line) = nt.cross(nu).normalized() else { return false };
|
||||
// Where a triangle's edges pass through the other's plane, measured along the line the
|
||||
// two planes share.
|
||||
let reach = |tri: &[Vec3; 3], d: &[f64; 3]| -> (f64, f64) {
|
||||
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for (i, j) in [(0, 1), (1, 2), (2, 0)] {
|
||||
let (p, q, dp, dq) = (tri[i], tri[j], d[i], d[j]);
|
||||
if (dp < 0.0) != (dq < 0.0) && dp != dq {
|
||||
let at = line.dot(p + (q - p) * (dp / (dp - dq)));
|
||||
lo = lo.min(at);
|
||||
hi = hi.max(at);
|
||||
}
|
||||
}
|
||||
(lo, hi)
|
||||
};
|
||||
let ((t0, t1), (u0, u1)) = (reach(t, &dt), reach(u, &du));
|
||||
t1.min(u1) - t0.max(u0) > tol
|
||||
}
|
||||
|
||||
/// Mass properties of a closed mesh.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MeshMeasure {
|
||||
@@ -281,6 +315,86 @@ impl Mesh {
|
||||
dirs.iter().filter(|d| self.ray_crossings(p, **d) % 2 == 1).count() >= 2
|
||||
}
|
||||
|
||||
/// Whether the surfaces of two meshes pass through each other by more than `tol`: some
|
||||
/// triangle of one has corners more than `tol` to both sides of a triangle of the other,
|
||||
/// and the reverse, and they share a stretch of the line where their planes meet longer
|
||||
/// than `tol`. Faces that only touch, lie on each other or stop on the other surface don't
|
||||
/// count, nor do two facetings of one curved surface that are within `tol` of it. Bodies
|
||||
/// can still overlap without crossing: one inside the other, or sharing a slab between
|
||||
/// faces that are parallel or in one plane.
|
||||
pub fn crosses(&self, o: &Mesh, tol: f64) -> bool {
|
||||
if !tol.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let tol = tol.max(0.0);
|
||||
let (ba, bb) = (self.bounds(), o.bounds());
|
||||
let region = Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) };
|
||||
if region.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let near = |m: &Mesh| -> Vec<([Vec3; 3], Aabb3)> {
|
||||
m.triangles
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let t = m.tri(t)?;
|
||||
let mut bx = Aabb3::EMPTY;
|
||||
t.iter().for_each(|p| bx.add(*p));
|
||||
(!bx.is_empty() && boxes_meet(&bx, ®ion)).then_some((t, bx))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (ta, tb) = (near(self), near(o));
|
||||
if ta.is_empty() || tb.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// A grid over the shared box holds the other mesh's triangles.
|
||||
let n = ((tb.len() as f64).cbrt().ceil() as usize).clamp(1, 24);
|
||||
let size = region.size();
|
||||
let cell = |v: f64, lo: f64, s: f64| -> usize { if s > 0.0 { (((v - lo) / s * n as f64).floor().max(0.0) as usize).min(n - 1) } else { 0 } };
|
||||
let span = |bx: &Aabb3| -> [(usize, usize); 3] {
|
||||
[
|
||||
(cell(bx.min.x, region.min.x, size.x), cell(bx.max.x, region.min.x, size.x)),
|
||||
(cell(bx.min.y, region.min.y, size.y), cell(bx.max.y, region.min.y, size.y)),
|
||||
(cell(bx.min.z, region.min.z, size.z), cell(bx.max.z, region.min.z, size.z)),
|
||||
]
|
||||
};
|
||||
let mut cells: Vec<Vec<u32>> = vec![Vec::new(); n * n * n];
|
||||
for (i, (_, bx)) in tb.iter().enumerate() {
|
||||
let [x, y, z] = span(bx);
|
||||
for ix in x.0..=x.1 {
|
||||
for iy in y.0..=y.1 {
|
||||
for iz in z.0..=z.1 {
|
||||
if let Some(c) = cells.get_mut((ix * n + iy) * n + iz) {
|
||||
c.push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Per triangle of `o`: the last triangle of `self` tested against it, plus one.
|
||||
let mut seen = vec![0usize; tb.len()];
|
||||
for (i, (t, bx)) in ta.iter().enumerate() {
|
||||
let [x, y, z] = span(bx);
|
||||
for ix in x.0..=x.1 {
|
||||
for iy in y.0..=y.1 {
|
||||
for iz in z.0..=z.1 {
|
||||
for j in cells.get((ix * n + iy) * n + iz).into_iter().flatten() {
|
||||
let (Some(mark), Some((u, ub))) = (seen.get_mut(*j as usize), tb.get(*j as usize)) else { continue };
|
||||
if *mark == i + 1 {
|
||||
continue;
|
||||
}
|
||||
*mark = i + 1;
|
||||
if boxes_meet(bx, ub) && triangles_cross(t, u, tol) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Closest intersection of a ray with the mesh: (distance along `dir`, triangle index).
|
||||
pub fn raycast(&self, o: Vec3, dir: Vec3) -> Option<(f64, usize)> {
|
||||
let mut best: Option<(f64, usize)> = None;
|
||||
|
||||
@@ -233,3 +233,55 @@ fn split_at_and_closest_param() {
|
||||
assert!(l.end().dist(q) < 1e-12 && r.start().dist(q) < 1e-12);
|
||||
assert!((quarter.closest_param(Vec2::new(20.0, 20.0)) - 0.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// An axis-aligned box as a closed mesh, with `n` x `n` quads on each side.
|
||||
fn box_mesh(min: Vec3, max: Vec3, n: usize) -> Mesh {
|
||||
let mut m = Mesh::default();
|
||||
let s = max - min;
|
||||
let at = |c: [f64; 3]| min + Vec3::new(s.x * c[0], s.y * c[1], s.z * c[2]);
|
||||
for axis in 0..3 {
|
||||
for side in [0.0, 1.0] {
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let corner = |a: usize, b: usize| {
|
||||
let mut c = [0.0; 3];
|
||||
c[axis] = side;
|
||||
c[(axis + 1) % 3] = a as f64 / n as f64;
|
||||
c[(axis + 2) % 3] = b as f64 / n as f64;
|
||||
at(c)
|
||||
};
|
||||
let k = m.positions.len() as u32;
|
||||
m.positions.extend([corner(i, j), corner(i + 1, j), corner(i + 1, j + 1), corner(i, j + 1)]);
|
||||
m.triangles.extend(if side > 0.0 { [[k, k + 1, k + 2], [k, k + 2, k + 3]] } else { [[k, k + 2, k + 1], [k, k + 3, k + 2]] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meshes_cross_only_where_their_surfaces_pass_through_each_other() {
|
||||
let cube = |x: f64, y: f64, n: usize| box_mesh(Vec3::new(x, y, 0.0), Vec3::new(x + 10.0, y + 10.0, 10.0), n);
|
||||
let a = cube(0.0, 0.0, 1);
|
||||
// A corner 0.15 deep in the other cube (0.02 % of its volume), and a deep overlap.
|
||||
for (x, y, n) in [(9.85, 3.0, 1), (9.85, 3.0, 7), (9.85, 9.85, 2), (5.0, 5.0, 3)] {
|
||||
assert!(a.crosses(&cube(x, y, n), 0.01), "{x} {y} {n}");
|
||||
assert!(cube(x, y, n).crosses(&a, 0.01), "{x} {y} {n}");
|
||||
}
|
||||
// Apart, touching on a face or an edge, and the same body.
|
||||
for (x, y, n) in [(10.5, 0.0, 1), (10.0, 0.0, 1), (10.0, 3.0, 5), (10.0, 10.0, 2), (0.0, 0.0, 1), (0.0, 0.0, 4)] {
|
||||
assert!(!a.crosses(&cube(x, y, n), 0.01), "{x} {y} {n}");
|
||||
}
|
||||
// One inside the other, standing on a face, and sharing a face plane from inside.
|
||||
let inner = box_mesh(Vec3::new(2.0, 2.0, 2.0), Vec3::new(8.0, 8.0, 8.0), 2);
|
||||
let on_top = box_mesh(Vec3::new(2.0, 2.0, 10.0), Vec3::new(8.0, 8.0, 14.0), 2);
|
||||
let flush = box_mesh(Vec3::new(2.0, 2.0, 4.0), Vec3::new(8.0, 8.0, 10.0), 2);
|
||||
assert!(!a.crosses(&inner, 0.01) && !inner.crosses(&a, 0.01));
|
||||
assert!(!a.crosses(&on_top, 0.01) && !a.crosses(&flush, 0.01));
|
||||
// A slab overlap between faces that are parallel or in one plane has no crossing.
|
||||
assert!(!a.crosses(&cube(9.85, 0.0, 1), 0.01));
|
||||
// Passing through by less than the tolerance doesn't count.
|
||||
assert!(!a.crosses(&cube(9.995, 3.0, 1), 0.01));
|
||||
assert!(!a.crosses(&cube(9.85, 3.0, 1), f64::NAN));
|
||||
}
|
||||
|
||||
+418
-16
@@ -1,7 +1,13 @@
|
||||
//! Versioned design packages. Geometry is rebuilt from authoritative inputs; no
|
||||
//! evaluated cache is trusted here. All archive access stays in memory, never on disk.
|
||||
//! evaluated cache is trusted here. Entries are never extracted to filesystem paths.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
mod native_cache;
|
||||
mod thumbnail;
|
||||
pub use native_cache::{DesignGeometry, read_design_geometry};
|
||||
pub use thumbnail::{THUMBNAIL_SIZE, read_design_thumbnail, read_design_thumbnail_file};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -96,6 +102,29 @@ pub fn write_design_container_with_compression(doc: &Document, compression: Desi
|
||||
|
||||
/// Optional display cache failure never prevents saving the authoritative design.
|
||||
pub fn write_design_container_with_preview(doc: &Document, compression: DesignCompression, preview: Option<&DesignPreview>) -> Result<Vec<u8>> {
|
||||
write_design_container_with_cache(doc, compression, preview, None)
|
||||
}
|
||||
|
||||
/// Save an optional warm native cache without making document saving depend on it.
|
||||
/// Display meshes remain separately opt-in; no evaluation or meshing happens here.
|
||||
pub fn write_design_container_with_cache(
|
||||
doc: &Document,
|
||||
compression: DesignCompression,
|
||||
preview: Option<&DesignPreview>,
|
||||
geometry: Option<&[DesignGeometry]>,
|
||||
) -> Result<Vec<u8>> {
|
||||
write_design_container_with_thumbnail(doc, compression, preview, geometry, None)
|
||||
}
|
||||
|
||||
/// Include a bounded portable PNG picture when one is already available.
|
||||
/// Unsupported headers or oversized pictures are omitted without changing the design.
|
||||
pub fn write_design_container_with_thumbnail(
|
||||
doc: &Document,
|
||||
compression: DesignCompression,
|
||||
preview: Option<&DesignPreview>,
|
||||
geometry: Option<&[DesignGeometry]>,
|
||||
thumbnail: Option<&[u8]>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut writer = PackageWriter { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0 };
|
||||
let mut features = Vec::with_capacity(doc.features.len());
|
||||
for feature in &doc.features {
|
||||
@@ -143,6 +172,19 @@ pub fn write_design_container_with_preview(doc: &Document, compression: DesignCo
|
||||
let metadata = serde_json::to_vec(&metadata).map_err(invalid)?;
|
||||
writer.part("document.json", &metadata, true)?;
|
||||
writer.part("container.json", &serde_json::to_vec(&json!({"format": VERSION})).map_err(invalid)?, false)?;
|
||||
if let Some((manifest, data)) = geometry.and_then(|g| native_cache::encode_geometry(g, &metadata).ok())
|
||||
&& writer.names.len().saturating_add(2) <= MAX_ENTRIES
|
||||
&& writer
|
||||
.expanded
|
||||
.saturating_add(manifest.len())
|
||||
.saturating_add(data.len())
|
||||
.saturating_add(writer.names.len().saturating_mul(256))
|
||||
.saturating_add(65_536)
|
||||
<= MAX_DESIGN_BYTES
|
||||
{
|
||||
writer.part("cache/key.json", &manifest, false)?;
|
||||
writer.part("cache/brep.bin", &data, compression == DesignCompression::Compact)?;
|
||||
}
|
||||
if let Some((manifest, data)) = preview.and_then(|p| encode_preview(p, &metadata).ok())
|
||||
&& writer.names.len().saturating_add(2) <= MAX_ENTRIES
|
||||
&& writer
|
||||
@@ -156,6 +198,9 @@ pub fn write_design_container_with_preview(doc: &Document, compression: DesignCo
|
||||
writer.part("cache/preview.json", &manifest, false)?;
|
||||
writer.part("cache/preview.bin", &data, false)?;
|
||||
}
|
||||
if let Some(png) = thumbnail {
|
||||
thumbnail::write(&mut writer, png, &metadata)?;
|
||||
}
|
||||
let bytes = writer.zip.finish().map_err(invalid)?.into_inner();
|
||||
if bytes.len() > MAX_DESIGN_BYTES {
|
||||
return Err(invalid("compressed file too large"));
|
||||
@@ -219,18 +264,26 @@ fn decode_mesh(bytes: &[u8], mesh: &mut MeshData) -> Result<()> {
|
||||
validate_mesh(mesh)
|
||||
}
|
||||
|
||||
struct PackageReader<'a> {
|
||||
zip: zip::ZipArchive<Cursor<&'a [u8]>>,
|
||||
struct PackageReader<R: Read + Seek> {
|
||||
zip: zip::ZipArchive<R>,
|
||||
blobs: BTreeMap<String, Vec<u8>>,
|
||||
hydrated: usize,
|
||||
document: Option<Arc<[u8]>>,
|
||||
}
|
||||
|
||||
impl<'a> PackageReader<'a> {
|
||||
fn new(bytes: &'a [u8]) -> Result<Self> {
|
||||
impl<R: Read + Seek> PackageReader<R> {
|
||||
fn new(mut source: R) -> Result<Self> {
|
||||
let length = source.seek(SeekFrom::End(0)).map_err(invalid)?;
|
||||
if length > MAX_DESIGN_BYTES as u64 {
|
||||
return Err(invalid("compressed file too large"));
|
||||
}
|
||||
let tail_length = length.min(65_557) as usize;
|
||||
source.seek(SeekFrom::Start(length - tail_length as u64)).map_err(invalid)?;
|
||||
let mut tail = vec![0; tail_length];
|
||||
source.read_exact(&mut tail).map_err(invalid)?;
|
||||
// zip's name index can coalesce duplicate central-directory entries. Our
|
||||
// bounded format does not need ZIP64/multiple disks: verify the declared
|
||||
// entry count as well so duplicate names cannot silently select a payload.
|
||||
let tail = bytes.get(bytes.len().saturating_sub(65_557)..).ok_or_else(|| invalid("missing ZIP directory"))?;
|
||||
let eocd = tail.windows(4).rposition(|v| v == b"PK\x05\x06").ok_or_else(|| invalid("missing ZIP directory"))?;
|
||||
let end = tail.get(eocd..).filter(|v| v.len() >= 22).ok_or_else(|| invalid("truncated ZIP directory"))?;
|
||||
let u16_at = |offset| -> Result<u16> {
|
||||
@@ -241,7 +294,7 @@ impl<'a> PackageReader<'a> {
|
||||
if declared > MAX_ENTRIES || u16_at(4)? != 0 || u16_at(6)? != 0 || u16_at(8)? as usize != declared || end.len() != 22 + u16_at(20)? as usize {
|
||||
return Err(invalid("too many entries or unsupported ZIP directory"));
|
||||
}
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(invalid)?;
|
||||
let mut zip = zip::ZipArchive::new(source).map_err(invalid)?;
|
||||
if u16_at(4)? != 0
|
||||
|| u16_at(6)? != 0
|
||||
|| u16_at(8)? as usize != zip.len()
|
||||
@@ -267,7 +320,15 @@ impl<'a> PackageReader<'a> {
|
||||
return Err(invalid("expanded package too large"));
|
||||
}
|
||||
}
|
||||
Ok(Self { zip, blobs: BTreeMap::new(), hydrated: 0 })
|
||||
Ok(Self { zip, blobs: BTreeMap::new(), hydrated: 0, document: None })
|
||||
}
|
||||
// Bind provisional caches and authoritative reconstruction to identical metadata,
|
||||
// even if another process overwrites the open file in place. Blobs remain hash-checked.
|
||||
fn document(&mut self, limit: usize) -> Result<Arc<[u8]>> {
|
||||
if self.document.is_none() {
|
||||
self.document = Some(self.limited_part("document.json", limit)?.into());
|
||||
}
|
||||
self.document.as_ref().filter(|d| d.len() <= limit).cloned().ok_or_else(|| invalid("metadata budget"))
|
||||
}
|
||||
fn part(&mut self, name: &str) -> Result<Vec<u8>> {
|
||||
self.limited_part(name, MAX_DESIGN_BYTES)
|
||||
@@ -342,11 +403,10 @@ pub fn read_design_preview(bytes: &[u8]) -> Option<DesignPreview> {
|
||||
if bytes.len() > MAX_DESIGN_BYTES || !is_design_container(bytes) {
|
||||
return None;
|
||||
}
|
||||
read_preview(bytes).ok()
|
||||
DesignContainerReader::new(Cursor::new(bytes)).ok()?.preview()
|
||||
}
|
||||
|
||||
fn read_preview(bytes: &[u8]) -> Result<DesignPreview> {
|
||||
let mut reader = PackageReader::new(bytes)?;
|
||||
fn read_preview<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<DesignPreview> {
|
||||
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
|
||||
if !matches!(container.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
|
||||
return Err(invalid("unsupported container version"));
|
||||
@@ -357,7 +417,7 @@ fn read_preview(bytes: &[u8]) -> Result<DesignPreview> {
|
||||
{
|
||||
return Err(invalid("incompatible preview"));
|
||||
}
|
||||
if manifest.get("document").and_then(Value::as_str) != Some(digest(&reader.part("document.json")?).as_str()) {
|
||||
if manifest.get("document").and_then(Value::as_str) != Some(digest(&reader.document(16 << 20)?).as_str()) {
|
||||
return Err(invalid("stale preview"));
|
||||
}
|
||||
let data = reader.limited_part("cache/preview.bin", MAX_PREVIEW_BYTES)?;
|
||||
@@ -398,13 +458,103 @@ fn read_preview(bytes: &[u8]) -> Result<DesignPreview> {
|
||||
Ok(preview)
|
||||
}
|
||||
|
||||
/// A bounded container reader over a seekable source.
|
||||
///
|
||||
/// Opening inspects the directory, not the source blobs. `preview` reads only the
|
||||
/// optional display entries and document metadata; `read_document` then resolves
|
||||
/// and validates authoritative blobs against the same retained metadata snapshot.
|
||||
/// Concurrent source changes either retain that document or fail validation.
|
||||
/// This type does not turn untrusted cached data into editable geometry.
|
||||
pub struct DesignContainerReader<R: Read + Seek> {
|
||||
reader: PackageReader<R>,
|
||||
}
|
||||
|
||||
impl<R: Read + Seek> DesignContainerReader<R> {
|
||||
/// Open a container (not plain JSON), enforcing the same budgets as byte IO.
|
||||
pub fn new(source: R) -> Result<Self> {
|
||||
Ok(Self { reader: PackageReader::new(source)? })
|
||||
}
|
||||
|
||||
/// Optional display-only data. Corruption or incompatibility is a cache miss.
|
||||
pub fn preview(&mut self) -> Option<DesignPreview> {
|
||||
// An explicitly saved mesh avoids tessellation. Native cache is the default
|
||||
// fallback, generating a bounded provisional mesh without source imports.
|
||||
read_preview(&mut self.reader).ok().or_else(|| native_cache::geometry_preview(self.geometry()?).ok())
|
||||
}
|
||||
|
||||
/// Portable PNG picture only; no source blobs or geometry are evaluated.
|
||||
pub fn thumbnail(&mut self) -> Option<Vec<u8>> {
|
||||
thumbnail::read(&mut self.reader).ok()
|
||||
}
|
||||
|
||||
/// Native geometry is provisional, never a substitute for rebuilding the document.
|
||||
pub fn geometry(&mut self) -> Option<Vec<DesignGeometry>> {
|
||||
let document = self.reader.document(16 << 20).ok()?;
|
||||
native_cache::read_geometry_with(&mut self.reader, &document).ok()
|
||||
}
|
||||
|
||||
/// Consume the reader, resolving every required blob before returning a document.
|
||||
pub fn read_document(mut self) -> Result<Document> {
|
||||
read_document(&mut self.reader)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn read(bytes: &[u8]) -> Result<Document> {
|
||||
let mut reader = PackageReader::new(bytes)?;
|
||||
DesignContainerReader::new(Cursor::new(bytes))?.read_document()
|
||||
}
|
||||
|
||||
/// Open from disk (or browser VFS), publishing only provisional display before
|
||||
/// authoritative blob hydration. Desktop keeps one seekable file handle instead
|
||||
/// of buffering the complete archive. JSON continues through the normal migrations.
|
||||
pub fn read_design_file(path: &str, notify: Option<impl FnOnce(DesignPreview)>) -> Result<Document> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let mut source = {
|
||||
let metadata = std::fs::metadata(path).map_err(invalid)?;
|
||||
if !metadata.is_file() {
|
||||
return Err(invalid("not a regular file"));
|
||||
}
|
||||
if metadata.len() > MAX_DESIGN_BYTES as u64 {
|
||||
return Err(invalid("file too large"));
|
||||
}
|
||||
let file = std::fs::File::open(path).map_err(invalid)?;
|
||||
if !file.metadata().map_err(invalid)?.is_file() {
|
||||
return Err(invalid("not a regular file"));
|
||||
}
|
||||
file
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let mut source = Cursor::new(crate::vfs::read(path, MAX_DESIGN_BYTES).map_err(invalid)?);
|
||||
let size = source.seek(SeekFrom::End(0)).map_err(invalid)?;
|
||||
if size > MAX_DESIGN_BYTES as u64 {
|
||||
return Err(invalid("file too large"));
|
||||
}
|
||||
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
|
||||
let mut prefix = [0u8; 4];
|
||||
if size >= 4 {
|
||||
source.read_exact(&mut prefix).map_err(invalid)?;
|
||||
}
|
||||
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
|
||||
if is_design_container(&prefix) {
|
||||
let mut reader = DesignContainerReader::new(source)?;
|
||||
if let Some(notify) = notify
|
||||
&& let Some(preview) = reader.preview()
|
||||
{
|
||||
notify(preview);
|
||||
}
|
||||
reader.read_document()
|
||||
} else {
|
||||
let mut bytes = Vec::new();
|
||||
source.take(MAX_DESIGN_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
|
||||
crate::read_design(&bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Document> {
|
||||
let version: Value = serde_json::from_slice(&reader.part("container.json")?).map_err(invalid)?;
|
||||
if !matches!(version.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
|
||||
return Err(invalid("unsupported container version"));
|
||||
}
|
||||
let bytes = reader.part("document.json")?;
|
||||
let bytes = reader.document(MAX_DESIGN_BYTES)?;
|
||||
let metadata: Value = serde_json::from_slice(&bytes).map_err(invalid)?;
|
||||
let text = std::str::from_utf8(&bytes).map_err(invalid)?;
|
||||
// Migrations and all document validation run on the small parametric metadata.
|
||||
@@ -450,6 +600,203 @@ mod tests {
|
||||
use super::*;
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
struct CountingReader {
|
||||
source: Cursor<Vec<u8>>,
|
||||
count: std::rc::Rc<std::cell::Cell<usize>>,
|
||||
}
|
||||
|
||||
impl Read for CountingReader {
|
||||
fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
|
||||
let count = self.source.read(bytes)?;
|
||||
self.count.set(self.count.get() + count);
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for CountingReader {
|
||||
fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
|
||||
self.source.seek(position)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seekable_reader_rejects_oversized_source_before_reading() {
|
||||
struct Oversized;
|
||||
impl Read for Oversized {
|
||||
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
|
||||
panic!("oversized input must be rejected before any allocation or read");
|
||||
}
|
||||
}
|
||||
impl Seek for Oversized {
|
||||
fn seek(&mut self, _: SeekFrom) -> std::io::Result<u64> {
|
||||
Ok(MAX_DESIGN_BYTES as u64 + 1)
|
||||
}
|
||||
}
|
||||
assert!(DesignContainerReader::new(Oversized).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seekable_preview_defers_large_source_blob_until_document_read() {
|
||||
let mut doc = Document::new("Deferred source");
|
||||
let step = "ISO-10303-21;".repeat(200_000);
|
||||
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone(), components: Vec::new() }, None).unwrap();
|
||||
let preview = DesignPreview { meshes: vec![mesh()] };
|
||||
let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
|
||||
let count = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let source = CountingReader { source: Cursor::new(bytes), count: count.clone() };
|
||||
let mut reader = DesignContainerReader::new(source).unwrap();
|
||||
assert_eq!(reader.preview().unwrap().meshes[0].positions, preview.meshes[0].positions);
|
||||
// ZIP directory scans may touch a bounded suffix of a stored blob. They
|
||||
// must not load its multi-megabyte payload to obtain the saved picture.
|
||||
assert!(count.get() < 256 * 1024, "read {} bytes before display", count.get());
|
||||
let restored = reader.read_document().unwrap();
|
||||
assert_eq!(restored.to_json(), doc.to_json());
|
||||
assert!(count.get() >= step.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seekable_reader_validates_required_blobs_after_preview() {
|
||||
let doc = document();
|
||||
let preview = DesignPreview { meshes: vec![mesh()] };
|
||||
let mut bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
|
||||
let marker = b"ISO-10303-21;";
|
||||
let offset = bytes.windows(marker.len()).position(|part| part == marker).unwrap();
|
||||
bytes[offset] ^= 1;
|
||||
let mut reader = DesignContainerReader::new(Cursor::new(bytes)).unwrap();
|
||||
assert!(reader.preview().is_some());
|
||||
assert!(reader.read_document().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seekable_reader_without_preview_preserves_authoritative_document() {
|
||||
let doc = document();
|
||||
let bytes = write_design_container(&doc).unwrap();
|
||||
let mut source = Cursor::new(bytes);
|
||||
source.set_position(17); // Constructor starts from the whole source, not its cursor.
|
||||
let mut reader = DesignContainerReader::new(source).unwrap();
|
||||
assert!(reader.preview().is_none());
|
||||
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
|
||||
assert!(DesignContainerReader::new(Cursor::new(vec![0u8; 1])).is_err());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn file_reader_refuses_nonfiles_before_notifying_preview() {
|
||||
let directory = std::env::temp_dir();
|
||||
let result = read_design_file(directory.to_str().unwrap(), Some(|_| panic!("non-file supplied a preview")));
|
||||
assert!(result.is_err_and(|e| e.to_string().contains("not a regular file")));
|
||||
}
|
||||
|
||||
fn native_geometry() -> Vec<DesignGeometry> {
|
||||
vec![DesignGeometry {
|
||||
name: "Cube".into(),
|
||||
body: solidcraft_kernel::box_solid(solidcraft_geom::Vec3::ZERO, solidcraft_geom::Vec3::new(10., 20., 30.)).unwrap(),
|
||||
}]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_cache_round_trip_and_preview_defer_original_sources() {
|
||||
let mut doc = Document::new("Native cache");
|
||||
let step = "ISO-10303-21;".repeat(200_000);
|
||||
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone(), components: Vec::new() }, None).unwrap();
|
||||
let bodies = native_geometry();
|
||||
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&bodies)).unwrap();
|
||||
let entries = parts(&bytes);
|
||||
assert!(entries.contains_key("cache/brep.bin"));
|
||||
assert!(!entries.contains_key("cache/preview.bin"));
|
||||
assert_eq!(read_design_geometry(&bytes).unwrap()[0].body.face_count(), bodies[0].body.face_count());
|
||||
assert_eq!(bytes, write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&bodies)).unwrap());
|
||||
let count = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let mut reader = DesignContainerReader::new(CountingReader { source: Cursor::new(bytes), count: count.clone() }).unwrap();
|
||||
assert!(!reader.preview().unwrap().meshes.is_empty());
|
||||
assert!(count.get() < 256 * 1024, "read {} bytes before native display", count.get());
|
||||
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
|
||||
assert!(count.get() >= step.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_cache_mismatch_is_only_a_cache_miss() {
|
||||
let doc = document();
|
||||
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&native_geometry())).unwrap();
|
||||
let original = parts(&bytes);
|
||||
for key in ["format", "producer", "app", "document", "payload"] {
|
||||
let mut entries = original.clone();
|
||||
let mut manifest: Value = serde_json::from_slice(&entries["cache/key.json"]).unwrap();
|
||||
manifest[key] = json!("unsupported-or-stale");
|
||||
entries.insert("cache/key.json".into(), serde_json::to_vec(&manifest).unwrap());
|
||||
let bytes = package(&entries);
|
||||
assert!(read_design_geometry(&bytes).is_none());
|
||||
assert!(read_design_preview(&bytes).is_none());
|
||||
assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json());
|
||||
}
|
||||
let mut entries = original;
|
||||
entries.get_mut("cache/brep.bin").unwrap()[0] ^= 1;
|
||||
let bytes = package(&entries);
|
||||
assert!(read_design_geometry(&bytes).is_none());
|
||||
assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_snapshot_survives_in_place_changes_after_preview() {
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
struct Shared {
|
||||
bytes: Rc<RefCell<Vec<u8>>>,
|
||||
position: u64,
|
||||
}
|
||||
impl Read for Shared {
|
||||
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
|
||||
let bytes = self.bytes.borrow();
|
||||
let mut cursor = Cursor::new(bytes.as_slice());
|
||||
cursor.set_position(self.position);
|
||||
let n = cursor.read(out)?;
|
||||
self.position = cursor.position();
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
impl Seek for Shared {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
let bytes = self.bytes.borrow();
|
||||
let mut cursor = Cursor::new(bytes.as_slice());
|
||||
cursor.set_position(self.position);
|
||||
self.position = cursor.seek(pos)?;
|
||||
Ok(self.position)
|
||||
}
|
||||
}
|
||||
let doc = document();
|
||||
let preview = DesignPreview { meshes: vec![mesh()] };
|
||||
let bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
|
||||
let offset = zip::ZipArchive::new(Cursor::new(&bytes)).unwrap().by_name("document.json").unwrap().data_start() as usize;
|
||||
let bytes = Rc::new(RefCell::new(bytes));
|
||||
let mut reader = DesignContainerReader::new(Shared { bytes: bytes.clone(), position: 0 }).unwrap();
|
||||
assert!(reader.preview().is_some());
|
||||
bytes.borrow_mut()[offset] ^= 0xff;
|
||||
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
|
||||
assert!(crate::read_design(&bytes.borrow()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_cache_prefixes_and_mutations_never_panic() {
|
||||
let bytes = write_design_container_with_cache(&document(), DesignCompression::Fast, None, Some(&native_geometry())).unwrap();
|
||||
for n in (0..bytes.len()).step_by((bytes.len() / 128).max(1)) {
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| {
|
||||
let _ = read_design_preview(&bytes[..n]);
|
||||
let _ = read_design_geometry(&bytes[..n]);
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
let mut changed = bytes.clone();
|
||||
changed[n] ^= 0xff;
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| {
|
||||
let _ = read_design_preview(&changed);
|
||||
let _ = read_design_geometry(&changed);
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn mesh() -> MeshData {
|
||||
MeshData {
|
||||
name: "Precise mesh".into(),
|
||||
@@ -512,6 +859,53 @@ mod tests {
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_thumbnail_is_keyed_bounded_and_does_not_read_sources() {
|
||||
// IO validates an opaque PNG header/hash; UI tests exercise real PNG decoding.
|
||||
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
|
||||
png.extend(240u32.to_be_bytes());
|
||||
png.extend(150u32.to_be_bytes());
|
||||
let mut doc = Document::from_json(&document().to_json()).unwrap();
|
||||
if let FeatureKind::Import { step, .. } = &mut doc.features[0].kind {
|
||||
*step = "source data".repeat(250_000);
|
||||
}
|
||||
let bytes = write_design_container_with_thumbnail(&doc, DesignCompression::Fast, None, None, Some(&png)).unwrap();
|
||||
let count = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let mut reader = DesignContainerReader::new(CountingReader { source: Cursor::new(bytes.clone()), count: count.clone() }).unwrap();
|
||||
assert_eq!(reader.thumbnail(), Some(png.clone()));
|
||||
assert!(count.get() < 256 * 1024);
|
||||
assert_eq!(reader.read_document().unwrap(), doc);
|
||||
let original = parts(&bytes);
|
||||
for field in ["format", "app", "document", "payload"] {
|
||||
let mut entries = original.clone();
|
||||
let mut key: Value = serde_json::from_slice(&entries["cache/thumbnail.json"]).unwrap();
|
||||
key[field] = json!("mismatch");
|
||||
entries.insert("cache/thumbnail.json".into(), serde_json::to_vec(&key).unwrap());
|
||||
let changed = package(&entries);
|
||||
assert!(read_design_thumbnail(&changed).is_none());
|
||||
assert_eq!(crate::read_design(&changed).unwrap(), doc);
|
||||
}
|
||||
let mut entries = original.clone();
|
||||
let source = entries.keys().find(|k| k.ends_with(".step")).unwrap().clone();
|
||||
entries.insert(source, b"corrupt source".to_vec());
|
||||
let changed = package(&entries);
|
||||
assert_eq!(read_design_thumbnail(&changed), Some(png));
|
||||
assert!(crate::read_design(&changed).is_err());
|
||||
for n in [0, 1, 8, bytes.len() / 2, bytes.len() - 1] {
|
||||
assert!(read_design_thumbnail(&bytes[..n]).is_none());
|
||||
}
|
||||
for i in (0..bytes.len()).step_by((bytes.len() / 128).max(1)) {
|
||||
let mut changed = bytes.clone();
|
||||
changed[i] ^= 0xff;
|
||||
assert!(std::panic::catch_unwind(|| read_design_thumbnail(&changed)).is_ok());
|
||||
}
|
||||
for invalid in [vec![], vec![0; 300_000]] {
|
||||
let bytes = write_design_container_with_thumbnail(&doc, DesignCompression::Fast, None, None, Some(&invalid)).unwrap();
|
||||
assert!(read_design_thumbnail(&bytes).is_none());
|
||||
assert_eq!(crate::read_design(&bytes).unwrap(), doc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_preview_is_bounded_keyed_and_never_authoritative() {
|
||||
let doc = Document::from_json(&document().to_json()).unwrap();
|
||||
@@ -676,7 +1070,7 @@ mod tests {
|
||||
let mut entries = parts(&bytes);
|
||||
let mut metadata: Value = serde_json::from_slice(&entries["document.json"]).unwrap();
|
||||
let reference = metadata["features"][0].clone();
|
||||
let mut reader = PackageReader::new(&bytes).unwrap();
|
||||
let mut reader = PackageReader::new(Cursor::new(&bytes)).unwrap();
|
||||
reader.hydrated = MAX_EXPANDED;
|
||||
assert!(reader.blob(&reference, "step_blob", "step").unwrap_err().to_string().contains("resolved payloads too large"));
|
||||
for name in ["../escape.step", "blobs/not-a-hash.step", "blobs/00.png"] {
|
||||
@@ -696,6 +1090,10 @@ mod tests {
|
||||
std::panic::catch_unwind(|| {
|
||||
let _ = crate::read_design(&bytes[..end]);
|
||||
let _ = read_design_preview(&bytes[..end]);
|
||||
if let Ok(mut reader) = DesignContainerReader::new(Cursor::new(&bytes[..end])) {
|
||||
let _ = reader.preview();
|
||||
let _ = reader.read_document();
|
||||
}
|
||||
})
|
||||
.is_ok(),
|
||||
"truncated at {end}"
|
||||
@@ -713,6 +1111,10 @@ mod tests {
|
||||
std::panic::catch_unwind(|| {
|
||||
let _ = crate::read_design(&changed);
|
||||
let _ = read_design_preview(&changed);
|
||||
if let Ok(mut reader) = DesignContainerReader::new(Cursor::new(&changed)) {
|
||||
let _ = reader.preview();
|
||||
let _ = reader.read_document();
|
||||
}
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Optional native cache. Its SolidCraft schema is independent of the producing kernel.
|
||||
use super::*;
|
||||
const PRODUCER: &str = "solidcraft-truck-adapter/2";
|
||||
/// Provisional evaluated geometry. Never an editable document state.
|
||||
#[derive(Clone)]
|
||||
pub struct DesignGeometry {
|
||||
pub name: String,
|
||||
pub body: solidcraft_kernel::Body,
|
||||
}
|
||||
pub(super) fn encode_geometry(bodies: &[DesignGeometry], document: &[u8]) -> Result<(Vec<u8>, Vec<u8>)> {
|
||||
if bodies.is_empty() || bodies.len() > 4096 {
|
||||
return Err(invalid("native body count"));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
let mut entries = Vec::new();
|
||||
let mut faces = 0usize;
|
||||
for entry in bodies {
|
||||
faces = faces.saturating_add(entry.body.face_count());
|
||||
if faces > 100_000 || entry.name.len() > 1024 {
|
||||
return Err(invalid("native geometry budget"));
|
||||
}
|
||||
let bytes = solidcraft_kernel::encode_native_body(&entry.body).map_err(invalid)?;
|
||||
let offset = data.len();
|
||||
if offset.saturating_add(bytes.len()) > 64 << 20 {
|
||||
return Err(invalid("native cache size"));
|
||||
}
|
||||
entries.push(json!({"name":entry.name,"offset":offset,"length":bytes.len()}));
|
||||
data.extend(bytes);
|
||||
}
|
||||
let manifest = serde_json::to_vec(&json!({
|
||||
"format":"solidcraft-brep-cache/1", "producer":PRODUCER,
|
||||
"app":env!("CARGO_PKG_VERSION"), "document":digest(document), "payload":digest(&data), "bodies":entries
|
||||
}))
|
||||
.map_err(invalid)?;
|
||||
if manifest.len() > 1 << 20 {
|
||||
return Err(invalid("native manifest size"));
|
||||
}
|
||||
Ok((manifest, data))
|
||||
}
|
||||
|
||||
/// Read only metadata and the native cache. Original blobs are not read/decompressed
|
||||
/// until the authoritative document reader needs them for background reconstruction.
|
||||
/// Any optional geometry failure is a miss, never a document error.
|
||||
pub fn read_design_geometry(bytes: &[u8]) -> Option<Vec<DesignGeometry>> {
|
||||
if bytes.len() > MAX_DESIGN_BYTES || !is_design_container(bytes) {
|
||||
return None;
|
||||
}
|
||||
DesignContainerReader::new(Cursor::new(bytes)).ok()?.geometry()
|
||||
}
|
||||
pub(super) fn read_geometry_with<R: Read + Seek>(reader: &mut PackageReader<R>, document: &[u8]) -> Result<Vec<DesignGeometry>> {
|
||||
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
|
||||
if container.get("format").and_then(Value::as_str) != Some(VERSION) {
|
||||
return Err(invalid("container version"));
|
||||
}
|
||||
let manifest: Value = serde_json::from_slice(&reader.limited_part("cache/key.json", 1 << 20)?).map_err(invalid)?;
|
||||
if manifest.get("format").and_then(Value::as_str) != Some("solidcraft-brep-cache/1")
|
||||
|| manifest.get("producer").and_then(Value::as_str) != Some(PRODUCER)
|
||||
|| manifest.get("app").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
|
||||
{
|
||||
return Err(invalid("native cache version"));
|
||||
}
|
||||
|
||||
if manifest.get("document").and_then(Value::as_str) != Some(digest(document).as_str()) {
|
||||
return Err(invalid("native document key"));
|
||||
}
|
||||
// Validate the small parametric metadata before showing a provisional model.
|
||||
Document::from_json(std::str::from_utf8(document).map_err(invalid)?)?;
|
||||
let data = reader.limited_part("cache/brep.bin", 64 << 20)?;
|
||||
if manifest.get("payload").and_then(Value::as_str) != Some(digest(&data).as_str()) {
|
||||
return Err(invalid("native payload key"));
|
||||
}
|
||||
let entries =
|
||||
manifest.get("bodies").and_then(Value::as_array).filter(|b| !b.is_empty() && b.len() <= 4096).ok_or_else(|| invalid("native bodies"))?;
|
||||
let mut bodies = Vec::new();
|
||||
let mut end = 0usize;
|
||||
let mut faces = 0usize;
|
||||
for entry in entries {
|
||||
let n = |key| entry.get(key).and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or_else(|| invalid("native slice"));
|
||||
let offset = n("offset")?;
|
||||
let length = n("length")?;
|
||||
if offset != end {
|
||||
return Err(invalid("overlapping native bodies"));
|
||||
}
|
||||
end = end.checked_add(length).ok_or_else(|| invalid("native length overflow"))?;
|
||||
let name = entry.get("name").and_then(Value::as_str).filter(|n| n.len() <= 1024).ok_or_else(|| invalid("native body name"))?.to_owned();
|
||||
let body = solidcraft_kernel::decode_native_body(data.get(offset..end).ok_or_else(|| invalid("truncated native body"))?).map_err(invalid)?;
|
||||
faces = faces.saturating_add(body.face_count());
|
||||
if faces > 100_000 {
|
||||
return Err(invalid("native face budget"));
|
||||
}
|
||||
bodies.push(DesignGeometry { name, body });
|
||||
}
|
||||
if end != data.len() {
|
||||
return Err(invalid("trailing native data"));
|
||||
}
|
||||
Ok(bodies)
|
||||
}
|
||||
pub(super) fn geometry_preview(bodies: Vec<DesignGeometry>) -> Result<DesignPreview> {
|
||||
let mut preview = DesignPreview::default();
|
||||
let mut triangles = 0usize;
|
||||
let mut display_bytes = 0usize;
|
||||
let faces: usize = bodies.iter().map(|b| b.body.face_count()).sum();
|
||||
let detail = if faces > 20_000 {
|
||||
1
|
||||
} else if faces > 4_000 {
|
||||
4
|
||||
} else {
|
||||
8
|
||||
};
|
||||
let mut work = 1_000_000usize;
|
||||
for entry in bodies {
|
||||
let mesh = solidcraft_kernel::native_preview_mesh(&entry.body, detail, &mut work).map_err(invalid)?;
|
||||
triangles = triangles.saturating_add(mesh.triangles.len());
|
||||
display_bytes = display_bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(12));
|
||||
if triangles > 1_000_000 || display_bytes > 128 << 20 {
|
||||
return Err(invalid("regenerated display budget"));
|
||||
}
|
||||
let displayed = MeshData {
|
||||
name: entry.name,
|
||||
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
|
||||
triangles: mesh.triangles.iter().flatten().copied().collect(),
|
||||
color: entry.body.color(),
|
||||
};
|
||||
validate_mesh(&displayed)?;
|
||||
preview.meshes.push(displayed);
|
||||
}
|
||||
Ok(preview)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Portable, disposable PNG pictures. Reading never resolves source blobs.
|
||||
use super::*;
|
||||
pub const THUMBNAIL_SIZE: [usize; 2] = [240, 150];
|
||||
const MAX_BYTES: usize = 256 << 10;
|
||||
const FORMAT: &str = "solidcraft-thumbnail/1";
|
||||
|
||||
fn valid_header(bytes: &[u8]) -> bool {
|
||||
bytes.len() <= MAX_BYTES
|
||||
&& bytes.get(..8) == Some(b"\x89PNG\r\n\x1a\n")
|
||||
&& bytes.get(8..16) == Some(b"\0\0\0\rIHDR")
|
||||
&& bytes.get(16..20) == Some((THUMBNAIL_SIZE[0] as u32).to_be_bytes().as_slice())
|
||||
&& bytes.get(20..24) == Some((THUMBNAIL_SIZE[1] as u32).to_be_bytes().as_slice())
|
||||
}
|
||||
pub(super) fn write(writer: &mut PackageWriter, png: &[u8], document: &[u8]) -> Result<()> {
|
||||
if !valid_header(png) {
|
||||
return Ok(());
|
||||
}
|
||||
let key = serde_json::to_vec(&json!({"format": FORMAT, "app": env!("CARGO_PKG_VERSION"), "document": digest(document), "payload": digest(png)}))
|
||||
.map_err(invalid)?;
|
||||
if writer.names.len().saturating_add(2) > MAX_ENTRIES
|
||||
|| writer
|
||||
.expanded
|
||||
.saturating_add(key.len())
|
||||
.saturating_add(png.len())
|
||||
.saturating_add(writer.names.len().saturating_mul(256))
|
||||
.saturating_add(65_536)
|
||||
> MAX_DESIGN_BYTES
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
writer.part("cache/thumbnail.json", &key, false)?;
|
||||
writer.part("cache/thumbnail.png", png, false)
|
||||
}
|
||||
pub(super) fn read<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Vec<u8>> {
|
||||
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
|
||||
if container.get("format").and_then(Value::as_str) != Some(VERSION) {
|
||||
return Err(invalid("thumbnail container version"));
|
||||
}
|
||||
let key: Value = serde_json::from_slice(&reader.limited_part("cache/thumbnail.json", 4096)?).map_err(invalid)?;
|
||||
let document = reader.document(16 << 20)?;
|
||||
if key.get("format").and_then(Value::as_str) != Some(FORMAT)
|
||||
|| key.get("app").and_then(Value::as_str) != Some(env!("CARGO_PKG_VERSION"))
|
||||
|| key.get("document").and_then(Value::as_str) != Some(digest(&document).as_str())
|
||||
{
|
||||
return Err(invalid("thumbnail key"));
|
||||
}
|
||||
let png = reader.limited_part("cache/thumbnail.png", MAX_BYTES)?;
|
||||
if !valid_header(&png) || key.get("payload").and_then(Value::as_str) != Some(digest(&png).as_str()) {
|
||||
return Err(invalid("thumbnail payload"));
|
||||
}
|
||||
// Consumers must also use a bounded PNG decoder; the header is not a decode.
|
||||
Ok(png)
|
||||
}
|
||||
/// Optional PNG bytes, bounded and keyed to this exact document. Consumers must
|
||||
/// decode with dimension/allocation limits; invalid pictures are cache misses.
|
||||
pub fn read_design_thumbnail(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
DesignContainerReader::new(Cursor::new(bytes)).ok()?.thumbnail()
|
||||
}
|
||||
/// Read a portable picture without importing or reading the source blobs.
|
||||
pub fn read_design_thumbnail_file(path: &str) -> Option<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let source = {
|
||||
let metadata = std::fs::metadata(path).ok()?;
|
||||
if !metadata.is_file() || metadata.len() > MAX_DESIGN_BYTES as u64 {
|
||||
return None;
|
||||
}
|
||||
let source = std::fs::File::open(path).ok()?;
|
||||
let metadata = source.metadata().ok()?;
|
||||
if !metadata.is_file() || metadata.len() > MAX_DESIGN_BYTES as u64 {
|
||||
return None;
|
||||
}
|
||||
source
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let source = Cursor::new(crate::vfs::read(path, MAX_DESIGN_BYTES).ok()?);
|
||||
DesignContainerReader::new(source).ok()?.thumbnail()
|
||||
}
|
||||
@@ -3,7 +3,12 @@
|
||||
//! the legacy folder is copied over. The legacy folder is never changed or deleted, so an older
|
||||
//! release installed side by side keeps working.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The Linux app id (Flatpak, desktop entry, AppStream, Wayland `app_id`).
|
||||
pub const APP_ID: &str = "ai.storyteller.solidcraft";
|
||||
/// The app id of releases before 0.8 (compatibility shim, docs/compatibility.md).
|
||||
pub const LEGACY_APP_ID: &str = "ai.storyteller.solvecraft";
|
||||
|
||||
/// Most files and nesting copied from a legacy folder (it holds preferences, autosaves and
|
||||
/// small caches; anything bigger is not ours).
|
||||
@@ -22,6 +27,25 @@ pub fn adopt_legacy_dir(current: &Path, legacy: &Path) -> std::io::Result<bool>
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// In the Flatpak sandbox, the same `path` under the pre-rename app's data folder: Flatpak keeps
|
||||
/// each app's XDG folders under `~/.var/app/<app id>`, so after the app id changed, settings and
|
||||
/// autosaves of earlier Flatpak releases sit under the old id. `None` outside the sandbox or for
|
||||
/// paths outside our own `~/.var/app` folder. Flatpak moves that folder itself when an install
|
||||
/// follows the end-of-life rebase; this covers installs made next to the old app, which reach it
|
||||
/// read-only through the manifest's `--filesystem` grant.
|
||||
pub fn legacy_flatpak_path(path: &Path) -> Option<PathBuf> {
|
||||
flatpak_twin(path, &std::env::var_os("FLATPAK_ID")?, Path::new(&std::env::var_os("HOME")?))
|
||||
}
|
||||
|
||||
fn flatpak_twin(path: &Path, flatpak_id: &std::ffi::OsStr, home: &Path) -> Option<PathBuf> {
|
||||
if flatpak_id != APP_ID {
|
||||
return None;
|
||||
}
|
||||
let apps = home.join(".var").join("app");
|
||||
let rel = path.strip_prefix(apps.join(APP_ID)).ok()?;
|
||||
Some(apps.join(LEGACY_APP_ID).join(rel))
|
||||
}
|
||||
|
||||
fn copy_tree(from: &Path, to: &Path, depth: usize, budget: &mut usize) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
if depth >= MAX_DEPTH {
|
||||
@@ -75,6 +99,17 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatpak_twin_maps_our_sandbox_data_to_the_old_app_id() {
|
||||
let home = Path::new("/home/u");
|
||||
let ours = home.join(".var/app/ai.storyteller.solidcraft/data/solidcraft");
|
||||
let id = std::ffi::OsStr::new(APP_ID);
|
||||
assert_eq!(flatpak_twin(&ours, id, home), Some(home.join(".var/app/ai.storyteller.solvecraft/data/solidcraft")));
|
||||
// Outside our sandbox folder, or in another app's sandbox, there is nothing to map.
|
||||
assert_eq!(flatpak_twin(&home.join(".local/share/solidcraft"), id, home), None);
|
||||
assert_eq!(flatpak_twin(&ours, std::ffi::OsStr::new("org.example.Other"), home), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_adopt() {
|
||||
let d = scratch("none");
|
||||
|
||||
@@ -10,8 +10,10 @@ mod design;
|
||||
mod design_metadata;
|
||||
mod dxf;
|
||||
pub use design::{
|
||||
DesignCompression, DesignPreview, is_design_container, read_design_preview, write_design_container, write_design_container_with_compression,
|
||||
write_design_container_with_preview,
|
||||
DesignCompression, DesignContainerReader, DesignGeometry, DesignPreview, THUMBNAIL_SIZE, is_design_container, read_design_file,
|
||||
read_design_geometry, read_design_preview, read_design_thumbnail, read_design_thumbnail_file, write_design_container,
|
||||
write_design_container_with_cache, write_design_container_with_compression, write_design_container_with_preview,
|
||||
write_design_container_with_thumbnail,
|
||||
};
|
||||
mod legacy_dir;
|
||||
pub mod safe_write;
|
||||
@@ -21,7 +23,7 @@ mod threemf;
|
||||
pub mod vfs;
|
||||
|
||||
pub use dxf::{read_dxf, write_dxf};
|
||||
pub use legacy_dir::adopt_legacy_dir;
|
||||
pub use legacy_dir::{APP_ID, LEGACY_APP_ID, adopt_legacy_dir, legacy_flatpak_path};
|
||||
pub use safe_write::{backup_path, write_atomic};
|
||||
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
|
||||
pub use svg::read_svg;
|
||||
|
||||
@@ -114,7 +114,10 @@ fn attrs(e: &BytesStart) -> Vec<(String, String)> {
|
||||
.flatten()
|
||||
.map(|a| {
|
||||
let k = String::from_utf8_lossy(a.key.local_name().as_ref()).to_string();
|
||||
let v = a.unescape_value().map(|v| v.to_string()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
|
||||
let v = a
|
||||
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
|
||||
(k, v)
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -319,7 +319,10 @@ fn attrs(e: &BytesStart) -> HashMap<String, String> {
|
||||
let mut m = HashMap::new();
|
||||
for a in e.attributes().flatten() {
|
||||
let k = String::from_utf8_lossy(a.key.local_name().as_ref()).to_string();
|
||||
let v = a.unescape_value().map(|v| v.to_string()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
|
||||
let v = a
|
||||
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string());
|
||||
m.insert(k, v);
|
||||
}
|
||||
m
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user